import { cloneDeep } from "lodash";
import { Layer } from "../Layer/Layer.js";
import { DataFrame } from "../../../data/DataFrame.js"
import { getColorFromScale, copyColorScale } from "../../../ui/color/Utils.js";
import { GeoJSONLayerProxy } from "../GeoJSONLayer/GeoJSONLayer.proxy.js";
import { GeoJSONLayerProxyLeaflet } from "../GeoJSONLayer/GeoJSONLayer.proxy.leaflet.js";
import { MeasurementsLayerSchema } from "./MeasurementsLayer.schema.js";
import { filterDataFrame } from "../../../data/utils.js";
export { MeasurementsLayer }
/**
* Layer for displaying Measurements from a DataFrame Object
*
* @author rhess <robin.hess@awi.de>
* @memberof vef.map.layer
*/
class MeasurementsLayer extends Layer {
/**
* Set all properties for the layer based on the options object.
*
* @param {object} config
* @param {object} cache
* @param {string} id
*/
constructor(config, cache, id) {
// remove the dataframe to prevent exporting it when saving a viewer
let dataFrame = config?.dataFrame || cache?.dataFrame || null
if (config?.dataFrame) delete config.dataFrame;
if (cache?.dataFrame) delete cache.dataFrame;
// calling parent constructor
super(config, cache, id);
this.setSchema_(MeasurementsLayerSchema.getSchema());
// remove availableCrs to always use default
if ("availableCrs" in this.config) delete this.config.availableCrs;
if ("availableCrs" in this.cache) delete this.cache.availableCrs;
// override defaults based on other properties
if (!this.defaults.title) this.defaults.title = this.columnData;
this.dataFrameOriginal = null;
this.dataFrame = null;
// private properties
this.clickedFeatures_ = [];
this.activeGeoJSON_ = null;
if (dataFrame) this.setDataFrame(dataFrame);
// framework specific map implementations
this.layerProxies_.leaflet = GeoJSONLayerProxyLeaflet;
this.activeLayer_ = new GeoJSONLayerProxy();
this.previousActiveLayer_ = null;
}
/**
* create a map layer based on the service options
*
* @param {string} type e.g. "leaflet"
* @private
*/
createMapLayer_(type, options) {
const proxy = super.createMapLayer_(type, this);
if (proxy) {
proxy.on("layerproxy_click", (e) => this.fire("layer_click", e));
proxy.on("layerproxy_mouseover", (e) => this.fire("layer_mouseover", e));
proxy.on("layerproxy_mouseout", (e) => this.fire("layer_mouseout", e));
}
return proxy;
}
setDataFrame(dataFrame, groupByColumn) {
if (groupByColumn) this.groupByColumn = groupByColumn;
if (dataFrame instanceof DataFrame) {
this.dataFrameOriginal = dataFrame;
if (this.groupByColumn) {
if (typeof this.groupByColumn === 'string') {
this.dataFrame = filterDataFrame(dataFrame, this.groupByColumn);
} else if (typeof this.groupByColumn === 'function') {
this.dataFrame = dataFrame.filter(this.groupByColumn);
}
} else {
this.dataFrame = dataFrame;
}
} else {
throw new Error("invalid dataFrame");
}
}
applyFilter_() {
let dataFrame = this.dataFrame;
if (this.filter) {
const cols = dataFrame.columnMap;
const defaultTimeColumn = cols["Date/Time"] || cols["date/time"] || cols["datetime"] || cols["date_time"] || cols["time"];
dataFrame = dataFrame.filter((i, row) => {
for (let i = 0; i < this.filter.length; ++i) {
let type = this.filter[i].type.toLowerCase();
// Use the filter.column if defined, else default time column name if no column is found and the filter.type is "time"
const column = this.filter[i].column
? cols[this.filter[i].column]
: (type === "time" ? defaultTimeColumn : null);
if (column === null || column === undefined) continue;
if (Number.isFinite(column)) {
for (let j = 0; j < this.filter[i].values.length; ++j) {
const values = this.filter[i].values[j];
const dataFrameVal = ((type == "time")) ? new Date(row[column]).getTime() : row[column];
const filterVal1 = ((type == "time")) ? new Date(values[1]).getTime() : values[1];
// checks are inverted, to return false if they don't match
switch (values[0].toLowerCase()) {
case "bt":
const filterVal2 = ((type == "time")) ? new Date(values[2]).getTime() : values[2];
if ((dataFrameVal < filterVal1) || (dataFrameVal > filterVal2)) return false;
break;
case "eq":
if (dataFrameVal != filterVal1) return false;
break;
case "lt":
if (dataFrameVal > filterVal1) return false;
break;
case "gt":
if (dataFrameVal < filterVal1) return false;
break;
}
}
}
}
return true;
});
}
return new Promise(resolve => resolve(dataFrame));
}
/**
* helper method for finding a column index containing a certain set of strings
*
* @param {DataFrame} dataFrame to search for columns
* @param {string | string[]} names single column name or an array of names
*/
getColumnIndex(dataFrame, names) {
if (!Array.isArray(names)) names = [names];
// first iteration for exact matches
for (let name of names) {
if (name && (name in dataFrame.columnMap)) return dataFrame.columnMap[name];
}
// second iteration for checking containing strings
for (let col in dataFrame.columnMap) {
for (let name of names) {
if (name && (col.toLowerCase().includes(name.toLowerCase()))) return dataFrame.columnMap[col];
}
}
return null;
}
/**
* helper method for finding a column name from an index
*
* @param {DataFrame} dataFrame to search for columns
* @param {number} index
*/
getColumnName(dataFrame, index) {
for (let col in dataFrame.columnMap) {
if (dataFrame.columnMap[col] == index) return col;
}
return null;
}
/**
* Helper method to update the mapLayer according to
* the current state. Might be necessary after switching the map type
*
* @private
*/
updateMapLayer_() {
this.applyFilter();
}
async createGeoJSON_() {
this.activeGeoJSON_ = null;
this.fire("layer_loading");
let dataFrame = await this.applyFilter_();
this.fire("layer_loaded");
const geoJSON = {
type: "FeatureCollection",
features: []
};
// using fixed positions for lat/lon
if (!this.columnLatitude || !this.columnLongitude) {
if (!this.position) {
console.warn("invalid lat/lon for fixed position");
return;
}
let position = cloneDeep(this.position);
if (!Array.isArray(position)) position = [position];
position.forEach(pos => {
geoJSON.features.push({
type: "Feature",
geometry: pos
});
});
this.activeGeoJSON_ = geoJSON;
return;
}
const cols = dataFrame.columnMap;
const rows = dataFrame.rows;
const colIndex = this.getColumnIndex(dataFrame, this.columnData);
if (typeof colIndex != "number") {
console.warn("invalid columnData");
return;
}
const lat = this.getColumnIndex(dataFrame, [this.columnLatitude, "Latitude", "latitude", "lat"]);
const lng = this.getColumnIndex(dataFrame, [this.columnLongitude, "Longitude", "longitude", "lng"]);
if ((typeof lat != "number") || (typeof lng != "number")) {
console.warn("invalid lat/lon");
return;
}
for (let i = 0; i < rows.length; ++i) {
const row = rows[i];
if (!Number.isFinite(row[lat]) || !Number.isFinite(row[lng])) continue;
const feature = {
type: "Feature",
geometry: {
type: "Point",
coordinates: [row[lng], row[lat]]
},
properties: {
color: this.getColorOptions_(row[colIndex]).color
},
_data: row
}
for (let key in cols) {
feature.properties[key] = rows[i][cols[key]];
}
geoJSON.features.push(feature);
}
this.activeGeoJSON_ = geoJSON;
}
/**
* Set on the layer. Uses generic
* filter syntax and transforms it internally
*
* @override
*/
applyFilter() {
this.createGeoJSON_().then(() => {
if (this.activeGeoJSON_) this.activeLayer_.setData(this.activeGeoJSON_);
});
}
/**
* get the color options for a single marker
* @private
*/
getColorOptions_(value) {
if (Number.isFinite(value) && this.colorScale && (this.colorScale.length > 0)) {
const color = getColorFromScale(this.colorScale, value);
return {
color: color.color,
opacity: this.opacity,
fillOpacity: this.opacity * this.opacityRatio,
stroke: false
}
} else {
return {
color: '#3388ff',
opacity: this.opacity,
fillOpacity: this.opacity * this.opacityRatio,
stroke: false
}
}
}
/**
* Set the colorscale for the layer
*
* @param colorScale
*/
setColorScale(colorScale) {
this.colorScale = colorScale;
if (this.activeGeoJSON_) {
this.activeGeoJSON_.features.forEach(feature => {
const value = (this.columnData in feature.properties) ? feature.properties[this.columnData] : null;
const style = this.getColorOptions_(value);
feature.properties.color = style.color;
});
this.activeLayer_.updateStyle();
}
}
/**
* Get the active colorscale for the layer
*/
getColorScale() {
return (this.colorScale) ? copyColorScale(this.colorScale) : null;
}
getOptions() {
const options = super.getOptions();
options.type = "measurement";
return options;
}
/**
* Get Info of the last clicked features
* @returns {Promise} feature info
*/
getFeatureInfo() {
return new Promise(resolve => {
resolve(this.activeLayer_.getClickedFeatures());
});
}
/**
* Set the transparency between 0 and 1
* @override
* @param {number} opacity
*/
setOpacity(opacity) {
this.opacity = opacity;
this.activeLayer_.updateStyle();
}
/**
* Get the bounding box as an object
*
* @returns {object} { min: {x, y}, max: {x, y}, crs: "CRS:84" }
*/
getBounds() {
const bounds = this.activeLayer_.getBounds();
return (bounds) ? bounds : super.getBounds();
}
}