/**
* @mixin
* @memberof module:geoflo
* @name Features
* @description This module provides the features functionality for the Geoflo application. It allows users to add, remove, update, and retrieve features from the map.
* @returns {Object} Returns the Features object.
* @throws {Error} Throws an error if no map object is provided.
*/
const Features = function () {
const geoflo = this.geoflo;
if (!geoflo.map) { throw new Error('No map object provided!') }
this.featuresMap = new Map();
this.getFeatures = function () {
return Array.from(this.featuresMap.values());
};
this.getFeatureById = function (id) {
if (Array.isArray(id) && id.length > 1) return this.getFeaturesById(id);
if (typeof id === 'object') id = id?.parent || id?.properties?.parent || id?.id || id?.properties?.id;
if (!id) return false;
return getFeatureByIdHelper.call(this, id);
};
this.getFeaturesById = function (ids = []) {
const addedIds = new Set();
const result = [];
ids.forEach((id) => {
const feature = this.getFeatureById(id);
if (feature && !addedIds.has(id)) {
addedIds.add(id);
result.push(feature);
}
});
return result;
};
this.getType = function (feature) {
return getType(feature);
};
this.getUnit = function (feature) {
if (!feature) return false;
const type = feature.properties.type;
if (!type) return false;
if (!geoflo.options.units || !geoflo.options.units[type]) return false;
return geoflo.options.units[type];
};
this.getUnits = function (feature) {
const unit = this.getUnit(feature);
if (!unit || !feature) return false;
let units = 1;
const type = feature.properties.type;
if (type === "Polyline") {
units = turf.length(feature, { units: 'meters' });
} else if (type === 'Polygon' || type === 'Rectangle') {
units = turf.area(feature);
}
return units;
};
this.getFeatureState = function (id) {
if (!id) return false;
const feature = this.getFeatureById(id);
if (!feature) return false;
const isSelected = geoflo.getSelectedFeatures().find(f => f.id === id || f.properties.id === id);
const source = isSelected ? geoflo.statics.constants.sources.SELECT : feature.source;
if (!source) return false;
return geoflo.map.getFeatureState({ source: source, id: id });
};
this.getClonedFeaturesBySource = function (src) {
return Array.from(this.featuresMap.values())
.filter((f) => f.source === src)
.map((f) => ({
type: 'Feature',
geometry: { ...f.geometry },
properties: { ...f.properties },
id: f.id,
source: f.source,
parent: f.parent
}));
};
this.setFeaturesState = function (features = [], state) {
if (!state || features.length === 0) return [];
features.forEach((feature) => {
const id = feature.id || feature.properties.id;
this.setFeatureState(id, state);
});
return features;
};
this.setFeatureState = function (id, state) {
if (!state || !id) return false;
const features = getFeaturesByParentHelper.call(this, id);
features.forEach((feature) => {
const fid = feature.id || feature.properties.id;
geoflo.map.setFeatureState({ source: feature.source, id: fid }, state);
});
return features;
};
this.setText = function (features = []) {
if (!geoflo.options.showFeatureText) return false;
let source = geoflo.statics.constants.sources.HOTTEXT;
let textFeaturesArr = [];
if (features.features) features = features.features;
if (!Array.isArray(features)) features = [features];
features.forEach((feature) => {
const type = feature.properties.type;
if (!type) return;
this.currentType = type;
if (type === 'Polyline' && geoflo.Utilities.isValidLineString(feature) && geoflo.options.showLineUnits) {
turf.segmentEach(feature, setLineText.bind(this));
}
});
geoflo.map.getSource(source).setData(turf.featureCollection(textFeaturesArr));
delete this.currentType;
return true;
};
this.addFeature = function (feature, source, properties = {}) {
if (!feature) return false;
const defaultSource = geoflo.statics.constants.sources.COLD;
feature = turf.truncate(turf.cleanCoords(feature), { precision: 6, coordinates: 3, mutate: true });
feature.properties = geoflo.Utilities.assignDeep(properties, feature.properties || {});
feature.source = source || feature.source || feature.properties.source || defaultSource;
return this.addFeatures(feature)[0];
};
this.addFeatures = function (features) {
if (!features) return false;
if (features?.features) features = features.features;
if (!Array.isArray(features)) features = [features];
if (features.length === 0) return false;
if (!this.addingFeatures) this.addingFeatures = true;
const sources = new Set();
const themeColors = geoflo.getTheme().colors;
const defaultSource = geoflo.statics.constants.sources.COLD;
const cleanedFeatures = features.map((feature) => {
if (!feature) return null;
feature.id = feature.id || feature.properties?.id || crypto.randomUUID();
feature.source = feature.source || feature.properties?.source || defaultSource;
feature.properties = {
...feature.properties,
id: feature.id,
type: this.getType(feature),
style: { ...themeColors, ...feature.properties?.style }
};
feature.geometry.unit = this.getUnit(feature);
feature.geometry.units = this.convertUnits(feature, null, feature.properties.unit || feature.geometry.unit);
// Remove unnecessary properties efficiently
['source', 'painting', 'edit', 'new', 'hidden', 'offset'].forEach(prop => delete feature.properties[prop]);
this.featuresMap.set(feature.id, feature);
sources.add(feature.source);
return feature;
}).filter(Boolean); // Remove nulls if any invalid features were skipped
if (sources.size > 0) {
this.updateSource(Array.from(sources)); // 🔁 run it immediately
}
this.addingFeatures = false;
return cleanedFeatures;
};
this.addUnits = function (feature, convertTo) {
const unit = convertTo || this.getUnit(feature);
if (!unit) return false;
const units = this.convertUnits(feature, null, convertTo);
feature.geometry.units = units;
feature.geometry.unit = unit;
return feature;
};
this.isFeatureHidden = function (id) {
if (!id) return false;
var state = this.getFeatureState(id);
return state.hidden;
}
this.updateFeatures = function (features, options = {}) {
features = features || [].concat(geoflo.getFeatures(), geoflo.getSelectedFeatures());
const sources = new Set();
this.updatingFeatures = true;
features.forEach((feature) => {
const id = feature.id || feature.properties.id;
if (!id) return;
// Check if the feature exists
let originalFeature = this.getFeatureById(id);
// If feature doesn't exist, add it and ensure it's assigned properly
if (!originalFeature) {
console.log(`🔄 Adding new feature with ID: ${id}`);
originalFeature = this.addFeature(feature, feature.source);
}
// Update properties and coordinates
if (originalFeature) {
sources.add(originalFeature.source);
originalFeature.geometry.coordinates = feature.geometry.coordinates;
originalFeature.properties = feature.properties;
// Add units if required
if (options.addUnits) this.addUnits(originalFeature);
}
});
// Update the source if changes were made
/* if (sources.size > 0) Promise.resolve().then(() => {
this.updateSource(Array.from(sources), options);
this.updatingFeatures = false;
}); */
if (sources.size > 0) {
this.updateSource(Array.from(sources), options); // 🔁 run it immediately
}
this.updatingFeatures = false;
};
this.updateSource = function (sources = [], options = {}) {
if (this._updateSourceTimeout) clearTimeout(this._updateSourceTimeout);
geoflo.updatingSource = true;
console.log(`🚀 Updating source(s):`, sources);
const textSource = geoflo.map.getSource(geoflo.statics.constants.sources.COLDTEXT);
const coldSource = geoflo.map.getSource(geoflo.statics.constants.sources.COLD);
this._updateSourceTimeout = setTimeout(() => {
const sourceFeatures = {};
const unsourceFeatures = [];
// Reset sources
if (textSource) textSource.setData(turf.featureCollection([]));
if (coldSource) coldSource.setData(turf.featureCollection([]));
// Gather features by source
Array.from(this.featuresMap.values()).forEach((feature) => {
delete feature.properties.new;
delete feature.properties.offset;
const src = feature.source;
if (sources.length && !sources.includes(src)) return;
if (!src) return unsourceFeatures.push(feature);
if (!sourceFeatures[src]) sourceFeatures[src] = [];
sourceFeatures[src].push(feature);
});
// Update each source with the respective features
Object.entries(sourceFeatures).forEach(([src, features]) => {
if (!geoflo.map.getSource(src)) {
console.warn(`⚠️ Source not found for ${src}`);
return unsourceFeatures.push(features);
}
setLineOffsetHelper(features, src);
});
// Handle unsourced features
setLineOffsetHelper(unsourceFeatures.flat(), geoflo.statics.constants.sources.COLD);
// Fire update after timeout
setTimeout(() => {
const selectedFeatures = geoflo.getSelectedFeatureIds();
this.setFeaturesState(
Array.from(this.featuresMap.values()).filter((f) => !selectedFeatures.includes(f.id)),
{ hidden: false }
);
selectedFeatures.forEach((id) => {
geoflo.selectFeature(id);
});
geoflo.Layers.refresh({ select: true });
geoflo.fire('features.update', {
features: Array.from(this.featuresMap.values()),
selected: selectedFeatures,
options: options
});
geoflo.updatingSource = false;
clearTimeout(this._updateSourceTimeout);
}, 50);
}, 25);
return Array.from(this.featuresMap.values());
};
this.removeFeature = function (id) {
const removedFeatures = [];
if (!this.featuresMap.has(id)) return removedFeatures;
const feature = this.featuresMap.get(id);
this.featuresMap.delete(id);
removedFeatures.push(feature);
this.updateSource([feature.source]);
return removedFeatures;
};
this.removeLayers = function (layerSources = [], options = {}) {
if (options.reset) return this.deleteFeatures();
const removedFeatures = [];
this.featuresMap.forEach((feature, fid) => {
if (layerSources.includes(feature.source)) {
removedFeatures.push(feature);
this.featuresMap.delete(fid);
}
});
this.updateSource(Array.from(new Set(removedFeatures.map(f => f.source))));
};
this.deleteFeatures = function () {
this.featuresMap.clear();
this.updateSource();
};
this.convertUnits = function (feature, units, convertTo) {
if (!feature) return 0;
const type = feature.properties.type;
const unit = convertTo || this.getUnit(feature);
units = units || this.getUnits(feature);
if (type === "Polyline") {
units = Math.round(turf.convertLength(units, 'meters', unit));
} else if (type === 'Polygon' || type === 'Rectangle') {
units = Math.round(turf.convertArea(units, 'meters', unit));
}
units = units ? Number(units.toFixed(2)) : 0;
return units;
};
function getFeatureByIdHelper(id) {
let feature = this.featuresMap.get(id);
if (!feature) feature = geoflo.getSelectedFeatures().find(f => f.id === id || f.properties?.id === id);
return feature;
}
function getFeaturesByParentHelper(id) {
const feature = (typeof id === 'object' && id.id) ? id : getFeatureByIdHelper.call(this, id);
if (!feature || !feature.source) return [];
const sourceData = geoflo.map.getSource(feature.source)?._data;
if (!sourceData) return [];
const field = geoflo.options.offsetOverlappingLines ? 'parent' : 'id';
return sourceData.features.filter(f => f[field] === id || f.properties[field] === id);
}
function createTextFeatures(feature) {
const isLine = geoflo.Utilities.isValidLineString(feature);
const segments = [];
if (isLine) {
turf.segmentEach(feature, function (currentSegment) {
const segment = geoflo.Utilities.cloneDeep(currentSegment);
let footage = Math.round(turf.length(segment, { units: 'miles' }) * 5280);
let mileage = Number(turf.length(segment, { units: 'miles' }).toFixed(3));
footage = Number(footage.toFixed(2));
mileage = Number(mileage.toFixed(2));
segment.properties.footage = footage;
segment.properties.mileage = mileage;
segment.properties.text = `${mileage} miles`;
segments.push(segment);
});
}
return segments;
}
function setLineText(segment) {
const seg = geoflo.Utilities.cloneDeep(segment);
seg.properties.type = this.currentType;
const text = turf.point(seg.geometry.coordinates[1]);
let units = this.getUnits(seg);
const unit = 'feet';
units = this.convertUnits(seg, units, unit);
text.properties.units = units;
text.properties.unit = unit;
text.properties.text = `${units} ${unit}`;
text.properties.transform = 'uppercase';
text.properties.anchor = 'bottom-left';
this.textFeatures.push(text);
return text;
}
function setLineOffsetHelper(features, src) {
if (!src || !geoflo.map.getSource(src)) return false;
const source = geoflo.map.getSource(src);
if (!geoflo.options.offsetOverlappingLines) return source.setData({ type: 'FeatureCollection', features: cloneFeatures(features) });
const mesh = new geoflo.Mesh(features, true);
const offsetFeatures = mesh.getFeatures();
offsetFeatures.forEach((feature) => {
const f = features.find((fe) => fe.id === feature.parent);
if (!f) return;
feature.source = src;
feature.properties.style = f.properties.style || feature.properties.style;
setOverlapOffsetHelper(offsetFeatures, feature);
});
source.setData({ type: 'FeatureCollection', features: cloneFeatures(offsetFeatures) });
geoflo.fire('features.offset', {
features: features,
offset: offsetFeatures,
source: src
});
}
function setOverlapOffsetHelper(features, feature) {
if (!geoflo.options.offsetOverlappingLines) return false;
if (!isPolylineHelper(feature)) return false;
if (feature.properties.offset) return false;
let offset = 6;
const overlaps = [];
features.forEach((f) => {
if (!isPolylineHelper(f)) return false;
if (f.parent === feature.parent) return false;
if (f.properties.offset) return false;
const overlap = turf.booleanOverlap(f, feature) || turf.booleanWithin(f, feature);
if (!overlap) return false;
overlaps.push(f);
});
overlaps.forEach((f) => {
f.properties.offset = offset;
offset = offset * 2;
});
}
function setWithinOffsetHelper(features) {
if (!geoflo.options.offsetOverlappingLines) return false;
const adder = 4;
const miles = 0.00189394; // 10 Feet
const explode = turf.explode(turf.featureCollection(features));
if (!explode || !explode.features.length) return;
explode.features.forEach((feature) => {
if (feature.properties.offset) return false;
const buffer = turf.buffer(feature, miles, { units: 'miles' });
const within = turf.pointsWithinPolygon(explode, buffer);
if (!within || !within.features.length) return;
let offset = adder;
within.features.forEach((f) => {
if (f.properties.id === feature.properties.id || f.properties.offset) return;
f.properties.offset = offset;
offset = offset + adder;
});
});
}
function cloneFeatures(features) {
return features.map((f) => ({
type: 'Feature',
geometry: { ...f.geometry },
properties: { ...f.properties },
id: f.id,
source: f.source,
parent: f.parent
}));
}
function isPolylineHelper(feature) {
if (!feature) return false;
const type = geoflo.Layers.getLayerType(feature.source);
return turf.getType(feature) === 'LineString';
}
function isPolygonHelper(feature) {
if (!feature) return false;
const type = geoflo.Layers.getLayerType(feature.source);
return turf.getType(feature) === 'Polygon' || feature.properties.type === 'Polygon' || type === 'Polygon';
}
function isRectangleHelper(feature) {
if (!feature) return false;
const type = geoflo.Layers.getLayerType(feature.source);
return (turf.getType(feature) === 'Polygon' && feature.properties.type === 'Rectangle') || type === 'Rectangle';
}
function isPointHelper(feature) {
if (!feature) return false;
const type = geoflo.Layers.getLayerType(feature.source);
if (turf.getType(feature) === 'Point' && (!feature.properties.type || feature.properties.type === 'Circle')) return true;
if (turf.getType(feature) === 'Point' && (type === 'Point' || type === 'Circle')) return true;
return turf.getType(feature) === 'Point' && (feature.properties.type !== 'Text' && feature.properties.type !== 'Icon' && feature.properties.type !== 'Image');
}
function isTextHelper(feature) {
if (!feature) return false;
const type = geoflo.Layers.getLayerType(feature.source);
if (type === 'Text') return true;
return turf.getType(feature) === 'Point' && feature.properties.type === 'Text';
}
function isIconHelper(feature) {
if (!feature) return false;
const type = geoflo.Layers.getLayerType(feature.source);
if (type === 'Icon') return true;
return turf.getType(feature) === 'Point' && feature.properties.type === 'Icon';
}
function isImageHelper(feature) {
if (!feature) return false;
const type = geoflo.Layers.getLayerType(feature.source);
if (type === 'Image') return true;
return turf.getType(feature) === 'Point' && feature.properties.type === 'Image';
}
function getType(feature) {
if (!feature) return null;
return isRectangleHelper(feature) ? 'Rectangle' :
isPolygonHelper(feature) ? 'Polygon' :
isPolylineHelper(feature) ? 'Polyline' :
isTextHelper(feature) ? 'Text' :
isIconHelper(feature) ? 'Icon' :
isImageHelper(feature) ? 'Image' :
isPointHelper(feature) ? 'Circle' :
null;
}
};
export default Features;