Source: core/radar-renderer.js

//@ts-check

import { hexColormapToRGBA, normalizeColorMap } from '../utils/colormap-utils.js';
import { isNumberMatrix, isStringArray } from '../utils/validation-utils.js';
import { WebGLRadarRenderer } from './webgl-radar-renderer.js';
import fragmentShaderSource from '../shaders/radar-fragment-shader.js';
import mercatorVertexShaderSource from '../shaders/mercator-vertex-shader.js';
import globeVertexShaderSource from '../shaders/globe-vertex-shader.js';

/**
 * @class
 */
export class RadarRenderer {
    /**
     * @param {mapboxgl.Map} map a mapbox map object
     * @param {string} layerId id of the layer to render the radar data
     * @param {string} insertBeforeId layer to insert the new layer before
     */
    constructor(map, layerId, insertBeforeId) {
        this.map = map;
        this.layerId = layerId;
        this.insertBeforeId = insertBeforeId;
        this.opacity = 1;
        this.filter = { min: -Infinity, max: Infinity };
        this.minValue = -10;
        this.maxValue = 85;

        /**
         * @private
         * @type {number[][]}*/
        this.colormap = [];

        /**
         * @private
         * @type {mapboxgl.CustomLayerInterface}*/
        this.layer = null;


        /**
         * @private
         * @type {Float32Array}*/
        this.vertices = null;

        /**
         * @private
         * @type {number[]}*/
        this.mercatorVertices = [];

        /**
         * @private
         * @type {number[]}*/
        this.ecefVertices = [];

        /**
         * @private
         * @type {RadarData}*/
        this.radarData = null;

        

        this.createLayer();
    }

    /**
     * @private
     * Create the custom layer for the radar data
     */
    createLayer() {
        this.layer = {
            id: this.layerId,
            type: 'custom',
            onAdd: this.initWebGL.bind(this),
            render: this.render.bind(this),
            onRemove: this.destroy.bind(this)
        };

        this.map.on('load', () => {
            this.map.addLayer(this.layer, this.insertBeforeId);
        });
    }

    /**
     * @private
     * generate the vertex and fragment shaders for the radar data
     * @param {mapboxgl.Map} map 
     * @param {WebGL2RenderingContext} gl 
     */
    initWebGL(map, gl) {
        this.mercatorRenderer = new WebGLRadarRenderer(gl, mercatorVertexShaderSource, fragmentShaderSource);
        this.globeRenderer = new WebGLRadarRenderer(gl, globeVertexShaderSource, fragmentShaderSource);
    }

    /**
     * @private
     * Render the radar data on the map
     * @param {WebGL2RenderingContext} gl 
     * @param {Float32Array} projectionMatrix
     * @param {mapboxgl.Projection} projection
     * @param {Float32Array} globeToMercMatrix
     * @param {number} transition
     * @param {number[]} centerInMercator
     * @param {number} pixelsPerMeterRatio
     */
    render(gl, projectionMatrix, projection, globeToMercMatrix, transition, centerInMercator, pixelsPerMeterRatio) {
        if (projection && projection.name == 'globe') {
            this.globeRenderer.render({
                    u_projection: projectionMatrix,
                    u_globeToMercMatrix: globeToMercMatrix,
                    u_globeToMercatorTransition: transition,
                    u_centerInMercator: centerInMercator,
                    u_pixelsPerMeterRatio: pixelsPerMeterRatio
                });
        } else {
            this.mercatorRenderer.render({ u_matrix: projectionMatrix });
        }
    }

    /**
     * Set the colormap for the radar data, the supported colormaps are hex RRGGBB or with alpha RRGGBBAA, or an array of RGB or RGBA values
     * @param {string[] | number[][]} colormap
     */
    setColormap(colormap) {
        if (!this.mercatorRenderer) {
            // if the webGLRenderer is not initialized, wait for it to be initialized
            requestAnimationFrame(() => {
                this.setColormap(colormap);
            });
            return;
        }

        if (isStringArray(colormap)) {
            this.colormap = hexColormapToRGBA(colormap);
        } else if (isNumberMatrix(colormap)) {
            this.colormap = normalizeColorMap(colormap);
        } else {
            throw new Error('Invalid colormap format');
        }
        this.mercatorRenderer.setColormap(this.colormap);
        this.globeRenderer.setColormap(this.colormap);
    }

    /**
     * Set the minimum and maximum values for the radar data
     * @param {number} min
     * @param {number} max
     */
    setMinMax(min, max) {
        if (!this.mercatorRenderer) {
            // if the webGLRenderer is not initialized, wait for it to be initialized
            requestAnimationFrame(() => {
                this.setMinMax(min, max);
            });
            return;
        }
        this.minValue = min;
        this.maxValue = max;
        this.globeRenderer.setMinMax(min, max);
        this.mercatorRenderer.setMinMax(min, max);
    }

    /**
     * Generate the vertices from the radar data
     * @param {RadarData} radarData 
     * @returns {Float32Array}
     */
    generateVertices(radarData) {
        const { mapboxgl } = globalThis;
        const radarAngle = radarData.azimuth_start - 90;
        const vertices = new Float32Array(radarData.polar.length * radarData.polar[0].length * 6 * 3);
        const center = mapboxgl.MercatorCoordinate.fromLngLat(radarData.location);
        const firstGateOffset = radarData.meters_to_center_of_first_gate * center.meterInMercatorCoordinateUnits();
        const gateSpacing = radarData.meters_between_gates * center.meterInMercatorCoordinateUnits();

        // prevent memory allocation inside the loop
        const tempPoints = [{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }];

        let angleIncrement = 360 / radarData.polar.length;
        let layerCount = radarData.polar[0].length;
        let gateStart = firstGateOffset;
        let angle = 0;
        for (let i = 0; i < radarData.polar.length; i += 1) {
            angle = i * angleIncrement;
            gateStart = firstGateOffset;
            let layers = radarData.polar[i].slice(0, layerCount);
            for (let l = 0; l < layers.length; l++) {
                const value = layers[l];
                gateStart += gateSpacing;
                if (radarData.fill_value == value) continue;
                if (value < this.filter.min || value > this.filter.max) continue;

                const gateEnd = gateStart + gateSpacing;

                const startAngle = (angle + radarAngle) * Math.PI / 180;
                const finalAngle = (angle + angleIncrement + radarAngle) * Math.PI / 180;

                let idx = (i * layerCount + l) * 6 * 3;

                // first triangle
                // first vertex
                const x = Math.cos(startAngle) * gateStart;
                const y = Math.sin(startAngle) * gateStart;
                tempPoints[0].x = center.x + x;
                tempPoints[0].y = center.y + y;
                vertices[idx] = tempPoints[0].x;
                vertices[idx + 1] = tempPoints[0].y;
                vertices[idx + 2] = value;

                // second vertex
                const x2 = Math.cos(startAngle) * gateEnd;
                const y2 = Math.sin(startAngle) * gateEnd;
                tempPoints[1].x = center.x + x2;
                tempPoints[1].y = center.y + y2;
                vertices[idx + 3] = tempPoints[1].x;
                vertices[idx + 4] = tempPoints[1].y;
                vertices[idx + 5] = value;

                // third vertex
                const x3 = Math.cos(finalAngle) * gateStart;
                const y3 = Math.sin(finalAngle) * gateStart;
                tempPoints[2].x = center.x + x3;
                tempPoints[2].y = center.y + y3;
                vertices[idx + 6] = tempPoints[2].x;
                vertices[idx + 7] = tempPoints[2].y;
                vertices[idx + 8] = value;

                //  second triangle
                // first vertex, same as third vertex of first triangle
                vertices[idx + 9] = tempPoints[2].x;
                vertices[idx + 10] = tempPoints[2].y;
                vertices[idx + 11] = value;
                // second vertex, same as second vertex of second triangle
                vertices[idx + 12] = tempPoints[1].x;
                vertices[idx + 13] = tempPoints[1].y;
                vertices[idx + 14] = value;

                // third vertex
                const x4 = Math.cos(finalAngle) * gateEnd;
                const y4 = Math.sin(finalAngle) * gateEnd;
                tempPoints[3].x = center.x + x4;
                tempPoints[3].y = center.y + y4;
                vertices[idx + 15] = tempPoints[3].x;
                vertices[idx + 16] = tempPoints[3].y;
                vertices[idx + 17] = value;
            }
        }
        return vertices;
    }

    /**
     * Set a filter with the minimum and maximum values for the radar data
     * @param {number} min
     * @param {number} max
     */
    setFilter(min, max) {
        this.filter.min = min;
        this.filter.max = max;
    }

    /**
     * Set the opacity for the entire radar renderer
     * @param {number} opacity
     */
    setOpacity(opacity) {
        if (!this.mercatorRenderer) {
            // if the webGLRenderer is not initialized, wait for it to be initialized
            requestAnimationFrame(() => {
                this.setOpacity(opacity);
            });
            return;
        }
        this.opacity = opacity;
        this.mercatorRenderer.setOpacity(opacity);
        this.globeRenderer.setOpacity(opacity);
    }
    
    /**
     * Draw the radar data on the map
     * @param {RadarData} data
     * @see {@link ./types/radar-data.d.ts}
     */
    draw(data) {
        this.radarData = data;
        if (!this.mercatorRenderer) {
            // if the webGLRenderer is not initialized, wait for it to be initialized
            requestAnimationFrame(() => {
                this.draw(data);
            });
            return;
        }

        this.vertices = this.generateVertices(data);
        this.mercatorRenderer.setMinMax(this.minValue, this.maxValue);
        
        this.mercatorRenderer.setAttributes({ a_pos: { data: this.vertices, size: 3 } });
        this.globeRenderer.setAttributes({a_pos_merc: { data: this.vertices, size: 3}});

        this.globeRenderer.setMinMax(this.minValue, this.maxValue);
    }

    /**
     * Check if a point is inside a triangle
     * @param {number} pointX position x of the point to check
     * @param {number} pointY position y of the point to check
     * @param {number} ax position x of the first vertex of the triangle
     * @param {number} ay position y of the first vertex of the triangle
     * @param {number} bx position x of the second vertex of the triangle
     * @param {number} by position y of the second vertex of the triangle
     * @param {number} cx position x of the third vertex of the triangle
     * @param {number} cy position y of the third vertex of the triangle
     * @returns {boolean}
     */
    isPointInTriangle(pointX, pointY, ax, ay, bx, by, cx, cy) {
        const v0 = [cx - ax, cy - ay];
        const v1 = [bx - ax, by - ay];
        const v2 = [pointX - ax, pointY - ay];

        const dot00 = v0[0] * v0[0] + v0[1] * v0[1];
        const dot01 = v0[0] * v1[0] + v0[1] * v1[1];
        const dot02 = v0[0] * v2[0] + v0[1] * v2[1];
        const dot11 = v1[0] * v1[0] + v1[1] * v1[1];
        const dot12 = v1[0] * v2[0] + v1[1] * v2[1];

        const invDenom = 1 / (dot00 * dot11 - dot01 * dot01);
        const u = (dot11 * dot02 - dot01 * dot12) * invDenom;
        const v = (dot00 * dot12 - dot01 * dot02) * invDenom;

        return (u >= 0) && (v >= 0) && (u + v < 1);
    }

    /**
     * get the RGBA color for a specific value
     * @param {number} value 
     * @returns {number[]}
     */
    getRGBAColor(value) {
        const idx = Math.floor((value - this.minValue) / (this.maxValue - this.minValue) * (this.colormap.length - 1));
        const color = this.colormap[idx];
        return color;
    }

    /**
     * get the hex color for a specific value
     * @param {number} value
     * @returns {string}
     */
    getHexColor(value) {
        const color = this.getRGBAColor(value);
        return `#${color.map(c => c.toString(16).padStart(2, '0')).join('')}`;
    }

    /**
     * Get the value at a specific latitude and longitude
     * @param {[number, number]} lngLat
     * @returns {{value:number, rgbaColor: number[], hexColor: string}}
     */
    getValueAt(lngLat){
        if(!this.vertices) return {
            value: this.radarData.fill_value,
            rgbaColor: [0, 0, 0, 0],
            hexColor: "#00000000"
        };
        
        const point = globalThis.mapboxgl.MercatorCoordinate.fromLngLat(lngLat);
        const vertices = this.vertices;
        for(let i = 0; i < vertices.length; i += 18){
            const ax = vertices[i];
            const ay = vertices[i + 1];
            const bx = vertices[i + 3];
            const by = vertices[i + 4];
            const cx = vertices[i + 6];
            const cy = vertices[i + 7];
            if(this.isPointInTriangle(point.x, point.y, ax, ay, bx, by, cx, cy)){
                return {
                    value: vertices[i + 2],
                    rgbaColor: this.getRGBAColor(vertices[i + 2]),
                    hexColor: this.getHexColor(vertices[i + 2])
                }
            }

            const dx = vertices[i + 9];
            const dy = vertices[i + 10];
            const ex = vertices[i + 12];
            const ey = vertices[i + 13];
            const fx = vertices[i + 15];
            const fy = vertices[i + 16];
            if(this.isPointInTriangle(point.x, point.y, dx, dy, ex, ey, fx, fy)){
                return {
                    value: vertices[i + 11],
                    rgbaColor: this.getRGBAColor(vertices[i + 11]),
                    hexColor: this.getHexColor(vertices[i + 11])
                }
            }
        }
        return {
            value: this.radarData.fill_value,
            rgbaColor: [0, 0, 0, 0],
            hexColor: "#00000000"
        }
    }

    /**
     * Clear the layer (just the visibility)
     */
    clear() {
        this.mercatorRenderer.clear();
        this.globeRenderer.clear();
        this.map.triggerRepaint();
    }

    /**
     * Reset the radar renderer to its initial state, the colormap is not reset
     */
    reset() {
        this.filter.min = -Infinity;
        this.filter.max = Infinity;
        this.opacity = 1;
    }

    /**
     * Destroy the radar renderer and cleanup
     */
    destroy() {
        this.map.removeLayer(this.layerId);
        this.mercatorRenderer.destroy();
        this.globeRenderer.destroy();
        this.reset();
        this.mercatorVertices = [];
    }
}