layer_KML.ts

import type { IBillboardParams } from "../entity/billboard/Billboard";
import { Billboard } from "../entity/billboard/Billboard";
import { Entity } from "../entity/Entity";
import { Extent } from "../Extent";
import { LonLat } from "../LonLat";
import type { IVectorParams } from "./Vector";
import { Vector } from "./Vector";
import type { NumberArray3 } from "../math/Vec3";

interface IKMLParams extends IVectorParams {
    color?: string;
    billboard?: IBillboardParams;
}

/**
 * Layer to render KML files.
 * @class
 * @extends {Vector}
 * @param {string} name - Layer name.
 * @param {IKMLParams} [options] - KML layer options.
 */
export class KML extends Vector {
    protected _color: string;
    protected _billboard: IBillboardParams;

    constructor(name: string, options: IKMLParams = {}) {
        super(name, options);
        this._billboard = options.billboard || {
            src: "https://openglobus.org/examples/billboards/carrot.png"
        };

        this._color = options.color || "#6689db";
    }

    public override get instanceName() {
        return "KML";
    }

    protected _extractCoordonatesFromKml(xmlDoc: XMLDocument) {
        const raw = Array.from(xmlDoc.getElementsByTagName("coordinates"));
        const rawText = raw.map((item) => item.textContent!.trim());
        return rawText.map((item) =>
            item
                .replace(/\n/g, " ")
                .replace(/\t/g, " ")
                .replace(/ +/g, " ")
                .split(" ")
                .map((co) => co.split(",").map(parseFloat))
        );
    }

    protected _AGBRtoRGBA(agbr: string): string | undefined {
        if (!agbr || agbr.length != 8) return;

        const a = parseInt(agbr.slice(0, 2), 16) / 255;
        const b = parseInt(agbr.slice(2, 4), 16);
        const g = parseInt(agbr.slice(4, 6), 16);
        const r = parseInt(agbr.slice(6, 8), 16);

        return `rgba(${r},${g},${b},${a})`;
    }

    /**
     * @protected
     * @returns {number[][]} Array of `[longitude, latitude, altitude?]`.
     */
    protected _parseKMLcoordinates(coords: Element): number[][] {
        return coords.innerHTML
            .trim()
            .replace(/\n/g, " ")
            .replace(/\t/g, " ")
            .replace(/ +/g, " ")
            .split(" ")
            .map((co) => co.split(",").map(parseFloat));
    }

    protected _kmlPlacemarkToEntity(placemark: Element | undefined | null, extent: Extent): Entity | undefined {
        if (!placemark) return;

        const nameTags: Element[] = Array.from(placemark.getElementsByTagName("name"));
        const name: string = nameTags && nameTags.length > 0 ? nameTags[0].innerHTML.trim() : "";

        const { iconHeading, iconURL, iconColor, lineWidth, lineColor } = this._extractStyle(placemark);

        // TODO handle MultiGeometry

        const lonLats: LonLat[] = [];
        for (const coord of placemark.getElementsByTagName("coordinates")) {
            const coordinates = this._parseKMLcoordinates(coord) || [[0, 0, 0]];

            for (const lonlatalt of coordinates) {
                const [lon, lat, alt] = lonlatalt;

                lonLats.push(new LonLat(lon, lat, alt));

                if (lon < extent.southWest.lon) extent.southWest.lon = lon;
                if (lat < extent.southWest.lat) extent.southWest.lat = lat;
                if (lon > extent.northEast.lon) extent.northEast.lon = lon;
                if (lat > extent.northEast.lat) extent.northEast.lat = lat;
            }
        }

        if (lonLats.length === 1) {
            const hdgrad = iconHeading * 0.01745329; // radians

            return new Entity({
                name,
                lonlat: lonLats[0],
                billboard: {
                    src: iconURL,
                    size: [24, 24],
                    color: iconColor,
                    rotation: hdgrad
                },
                properties: {
                    color: iconColor,
                    heading: iconHeading
                }
            });
        } else {
            return new Entity({
                name,
                polyline: {
                    pathLonLat: [lonLats],
                    thickness: lineWidth,
                    color: [lineColor]
                },
                properties: {
                    name: name
                }
            });
        }
    }

    protected _extractStyle(placemark: Element): any {
        let iconColor;
        let iconHeading;
        let iconURL;
        let lineColor;
        let lineWidth;

        const style = placemark.getElementsByTagName("Style")[0];
        if (style) {
            let iconstyle = style.getElementsByTagName("IconStyle")[0];
            if (iconstyle) {
                let color = iconstyle.getElementsByTagName("color")[0];
                if (color) iconColor = this._AGBRtoRGBA(color.innerHTML.trim());

                let heading = iconstyle.getElementsByTagName("heading")[0];
                if (heading) {
                    const hdg = parseFloat(heading.innerHTML.trim());
                    if (hdg >= 0 && hdg <= 360) iconHeading = hdg % 360;
                }

                let icon = iconstyle.getElementsByTagName("Icon")[0];
                if (icon) {
                    let href = icon.getElementsByTagName("href")[0];
                    if (href) {
                        iconURL = href.innerHTML.trim();
                    }
                }
            }

            let linestyle = style.getElementsByTagName("LineStyle")[0];
            if (linestyle) {
                let color = linestyle.getElementsByTagName("color")[0];
                if (color) lineColor = this._AGBRtoRGBA(color.innerHTML.trim());
                let width = linestyle.getElementsByTagName("width")[0];
                if (width !== undefined) lineWidth = parseFloat(width.innerHTML.trim());
            }
        }

        if (!iconColor) iconColor = "#FFFFFF";
        if (!iconHeading) iconHeading = 0;
        if (!iconURL) iconURL = "https://openglobus.org/examples/billboards/carrot.png";

        if (!lineColor) lineColor = "#FFFFFF";
        if (!lineWidth) lineWidth = 1;

        return { iconHeading, iconURL, iconColor, lineWidth, lineColor };
    }

    protected _parseKML(xml: XMLDocument, extent: Extent, entities?: Entity[]): Entity[] {
        if (!entities) entities = [];

        if (xml.documentElement.nodeName !== "kml") return entities;

        for (const placemark of xml.getElementsByTagName("Placemark")) {
            const entity = this._kmlPlacemarkToEntity(placemark, extent);
            if (entity) entities.push(entity);
        }

        return entities;
    }

    protected _convertKMLintoEntities(xml: XMLDocument): any {
        const extent = new Extent(new LonLat(180.0, 90.0), new LonLat(-180.0, -90.0));
        const entities = this._parseKML(xml, extent);

        return { entities, extent };
    }

    /**
     * Creates billboards or polylines from array of lonlat.
     * @protected
     * @param {number[][][][]} coordinates - Coordinates grouped by files and paths.
     * @param {string} color - Polyline color.
     * @param {IBillboardParams} [billboard] - Billboard options.
     * @returns {{entities: Array.<(Entity|undefined)>, extent: Extent}}
     */
    protected _convertCoordonatesIntoEntities(
        coordinates: number[][][][],
        color: string,
        billboard?: IBillboardParams
    ): any {
        const extent = new Extent(new LonLat(180.0, 90.0), new LonLat(-180.0, -90.0));
        const addToExtent = (c: number[]) => {
            const lon = c[0],
                lat = c[1];
            if (lon < extent.southWest.lon) extent.southWest.lon = lon;
            if (lat < extent.southWest.lat) extent.southWest.lat = lat;
            if (lon > extent.northEast.lon) extent.northEast.lon = lon;
            if (lat > extent.northEast.lat) extent.northEast.lat = lat;
        };
        const _pathes: number[][][] = [];

        coordinates.forEach((kmlFile: number[][][]) => kmlFile.forEach((p: number[][]) => _pathes.push(p)));

        const entities = _pathes.map((path) => {
            if (path.length === 1) {
                const lonlat = path[0] as NumberArray3;
                const _entity = new Entity({ lonlat, billboard });
                addToExtent(lonlat);
                return _entity;
            } else if (path.length > 1) {
                const pathLonLat = path.map((item: number[]) => {
                    addToExtent(item);
                    return new LonLat(item[0], item[1], item[2]);
                });

                return new Entity({
                    polyline: { pathLonLat: [pathLonLat], thickness: 3, color: [color] }
                });
            }
        });
        return { entities, extent };
    }

    /**
     * @protected
     * @returns {Promise<XMLDocument>}
     */
    protected _getXmlContent(file: Blob): Promise<XMLDocument> {
        return new Promise((resolve) => {
            const fileReader = new FileReader();
            fileReader.onload = async (i) =>
                resolve(new DOMParser().parseFromString(i.target!.result as string, "text/xml"));
            fileReader.readAsText(file);
        });
    }

    protected _expandExtents(extent1: Extent | null | undefined, extent2: Extent): Extent {
        if (!extent1) return extent2;
        if (extent2.southWest.lon < extent1.southWest.lon) extent1.southWest.lon = extent2.southWest.lon;
        if (extent2.southWest.lat < extent1.southWest.lat) extent1.southWest.lat = extent2.southWest.lat;
        if (extent2.northEast.lon > extent1.northEast.lon) extent1.northEast.lon = extent2.northEast.lon;
        if (extent2.northEast.lat > extent1.northEast.lat) extent1.northEast.lat = extent2.northEast.lat;
        return extent1;
    }

    /**
     * @public
     * @param {Blob[]} kmls - KML files.
     * @param {string} [color] - Polyline color.
     * @param {IBillboardParams} [billboard] - Billboard options.
     * @returns {Promise<{entities: Entity[], extent: Extent}>}
     */
    public async addKmlFromFiles(kmls: Blob[], color?: string, billboard?: IBillboardParams) {
        if (!Array.isArray(kmls)) return null;
        const kmlObjs = await Promise.all(kmls.map(this._getXmlContent));
        const coordonates = kmlObjs.map(this._extractCoordonatesFromKml);
        const { entities, extent } = this._convertCoordonatesIntoEntities(
            coordonates,
            color || this._color,
            billboard || this._billboard
        );
        this._extent = this._expandExtents(this._extent, extent);
        entities.forEach(this.add.bind(this));
        return { entities, extent };
    }

    /**
     * @param {string} color - Layer color.
     * @public
     */
    public setColor(color: string) {
        this._color = color;
        this._billboard.color = color;
    }

    protected _getKmlFromUrl(url: string): Promise<Document> {
        return new Promise<Document>((resolve, reject) => {
            const request = new XMLHttpRequest();
            request.open("GET", url, true);
            request.responseType = "document";
            request.overrideMimeType("text/xml");
            request.onload = () => {
                if (request.readyState === request.DONE && request.status === 200) {
                    resolve(request.responseXML!);
                } else {
                    reject(new Error("no valid kml file"));
                }
            };
            request.send();
        });
    }

    /**
     * @public
     * @param {string} url - URL of the KML to display. For example: './myFile.kml' or 'http://mySite/myFile.kml'.
     * @param {string} [color] - Polyline color.
     * @param {Billboard | IBillboardParams} [billboard] - Billboard options.
     * @returns {Promise<{entities: Entity[], extent: Extent}>}
     */
    public async addKmlFromUrl(url: string, color?: string, billboard?: Billboard | IBillboardParams): Promise<any> {
        const kml = await this._getKmlFromUrl(url);
        /*
                const coordonates = this._extractCoordonatesFromKml(kml);
                const { entities, extent } = this._convertCoordonatesIntoEntities(
                    [coordonates],
                    color || this._color,
                    billboard || this._billboard
                );
        */
        const { entities, extent } = this._convertKMLintoEntities(kml);

        this._extent = this._expandExtents(this._extent, extent);

        entities.forEach(this.add.bind(this));

        return { entities, extent };
    }
}