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 };
}
}