/**
* @module modules/Digitizing.js
* @name Digitizing
* @copyright 2023 3Liz
* @license MPL-2.0
*/
import { mainLizmap, mainEventDispatcher } from '../modules/Globals.js';
import { deepFreeze } from './config/Tools.js';
import { createEnum } from './utils/Enums.js';
import Utils from '../modules/Utils.js';
import GeoJSON from 'ol/format/GeoJSON.js';
import GPX from 'ol/format/GPX.js';
import KML from 'ol/format/KML.js';
import WKT from 'ol/format/WKT.js';
import { Draw, Modify, Select, Translate } from 'ol/interaction.js';
import { createBox } from 'ol/interaction/Draw.js';
import { Circle, Fill, Stroke, RegularShape, Style, Text } from 'ol/style.js';
import { Vector as VectorSource } from 'ol/source.js';
import { Vector as VectorLayer } from 'ol/layer.js';
import { Feature } from 'ol';
import { Point, LineString, Polygon, Circle as CircleGeom, MultiPoint, GeometryCollection } from 'ol/geom.js';
import { circular, fromCircle } from 'ol/geom/Polygon.js';
import { getArea, getLength } from 'ol/sphere.js';
import Overlay from 'ol/Overlay.js';
import { unByKey } from 'ol/Observable.js';
import { transform } from 'ol/proj.js';
import shp from 'shpjs';
import * as flatgeobuf from 'flatgeobuf';
import { register } from "ol/proj/proj4.js";
import proj4 from "proj4";
/**
* List of digitizing available tools
* @name DigitizingAvailableTools
* @constant {Array<string>}
*/
export const DigitizingAvailableTools = deepFreeze(['deactivate', 'point', 'line', 'polygon', 'box', 'circle', 'freehand', 'text']);
export const DigitizingTools = createEnum({
'Deactivate': 'deactivate',
'Point': 'point',
'Line': 'line',
'Polygon': 'polygon',
'Box': 'box',
'Circle': 'circle',
'Freehand': 'freehand',
'Text': 'text'
});
/**
* @class
* @name Digitizing
*/
export class Digitizing {
constructor() {
// defined a context to separate drawn features
this._context = 'draw';
this._contextFeatures = {};
this._tools = DigitizingAvailableTools;
this._toolSelected = this._tools[0];
this._repoAndProjectString = globalThis['lizUrls'].params.repository + '_' + globalThis['lizUrls'].params.project;
// Set draw color to value in local storage if any or default (red)
this._drawColor = localStorage.getItem(this._repoAndProjectString + '_drawColor') || '#ff0000';
this._isEdited = false;
this._hasMeasureVisible = false;
this._isSaved = false;
this._isErasing = false;
this._drawInteraction;
this._segmentMeasureTooltipElement;
this._totalMeasureTooltipElement;
// Array with pair of tooltips
// First is for current segment measure
// Second is for total geom measure
this._measureTooltips = new Set();
this._pointRadius = 8;
this._fillOpacity = 0.2;
this._strokeWidth = 2;
this._selectInteraction = new Select({
wrapX: false,
style: (feature) => {
let color = feature.get('color') || this._drawColor;
if (feature.get('mode') === 'textonly') {
color = '#FFF0';
}
const featureText = feature.get('text');
const featureTextRotation = feature.get('rotation') * (Math.PI / 180.0) || null;
const featureTextScale = feature.get('scale');
return [
new Style({
image: new Circle({
fill: new Fill({
color: color,
}),
stroke: new Stroke({
color: 'rgba(255, 255, 255, 0.5)',
width: this._strokeWidth + 4
}),
radius: this._pointRadius,
}),
fill: new Fill({
color: color + '33', // Opacity: 0.2
}),
stroke: new Stroke({
color: 'rgba(255, 255, 255, 0.5)',
width: this._strokeWidth + 8
}),
text: new Text({
text: featureText,
rotation: featureTextRotation,
scale: featureTextScale,
overflow: true,
fill: new Fill({
color: '#000',
}),
stroke: new Stroke({
color: '#fff',
width: 4,
}),
})
}),
new Style({
stroke: new Stroke({
color: color,
width: this._strokeWidth
}),
}),
new Style({
image: new Circle({
radius: 5,
fill: new Fill({
color: color,
}),
}),
geometry: feature => {
const geometryType = feature.getGeometry().getType();
if (geometryType === "LineString") {
return new MultiPoint(feature.getGeometry().getCoordinates());
}
if (geometryType === "Polygon") {
// return the coordinates of the first ring of the polygon
return new MultiPoint(feature.getGeometry().getCoordinates()[0]);
}
},
}),
];
}
});
// Set draw color from selected feature color
this._selectInteraction.on('select', (event) => {
if (event.selected.length) {
this.drawColor = event.selected[0].get('color');
} else {
// When a feature is deselected, set the color from the first selected feature if any
const selectedFeatures = this._selectInteraction.getFeatures().getArray();
if (selectedFeatures.length) {
this.drawColor = selectedFeatures[0].get('color');
}
}
});
this._modifyInteraction = new Modify({
features: this._selectInteraction.getFeatures(),
});
this._translateInteraction = new Translate({
features: this._selectInteraction.getFeatures(),
});
this._drawStyleFunction = (feature) => {
let color = feature.get('color') || this._drawColor;
if (feature.get('mode') === 'textonly') {
color = '#FFF0';
}
const featureText = feature.get('text');
const featureTextRotation = feature.get('rotation') * (Math.PI / 180.0) || null;
const featureTextScale = feature.get('scale');
const style = new Style({
image: new Circle({
fill: new Fill({
color: color,
}),
radius: this._pointRadius,
}),
fill: new Fill({
color: color + '33', // Opacity: 0.2
}),
stroke: new Stroke({
color: color,
width: this._strokeWidth
}),
text: new Text({
text: featureText,
rotation: featureTextRotation,
scale: featureTextScale,
overflow: true,
fill: new Fill({
color: '#000',
}),
stroke: new Stroke({
color: '#fff',
width: 4,
}),
})
});
return style;
};
this._drawSource = new VectorSource({ wrapX: false });
this._drawSource.on('addfeature', (event) => {
// Set main color if feature does not have one
if(!event.feature.get('color')){
event.feature.set('color', this._drawColor);
}
// Launch edition mode when text tool is selected
if (this._toolSelected === 'text') {
event.feature.set('text', lizDict['digitizing.toolbar.newText']);
// Set mode 'textonly' to not display point geometry
event.feature.set('mode', 'textonly');
this.isEdited = true;
}
// Save features drawn in localStorage
this.saveFeatureDrawn();
mainEventDispatcher.dispatch('digitizing.featureDrawn');
});
this._drawLayer = new VectorLayer({
source: this._drawSource,
style: this._drawStyleFunction
});
this._drawLayer.setProperties({
name: 'LizmapDigitizingDrawLayer'
});
mainLizmap.map.addToolLayer(this._drawLayer);
// Constraint layer
this._constraintLayer = new VectorLayer({
source: new VectorSource({ wrapX: false }),
style: new Style({
image: new RegularShape({
fill: new Fill({
color: 'black',
}),
stroke: new Stroke({
color: 'black',
}),
points: 4,
radius: 10,
radius2: 0,
angle: 0,
}),
stroke: new Stroke({
color: 'black',
lineDash: [10]
}),
})
});
this._constraintLayer.setProperties({
name: 'LizmapDigitizingConstraintLayer'
});
mainLizmap.map.addToolLayer(this._constraintLayer);
// Constraints values
this._distanceConstraint = 0;
this._angleConstraint = 0;
// Load and display saved feature if any
this.loadFeatureDrawnToMap();
// Disable drawing tool when measure tool is activated
mainLizmap.lizmap3.events.on({
minidockopened: (e) => {
if (e.id == 'measure') {
this.toolSelected = this._tools[0];
} else if (['draw', 'selectiontool', 'print'].includes(e.id)) {
// Display draw for print redlining
this.context = e.id === 'print' ? 'draw' : e.id;
}
}
});
}
get drawLayer() {
return this._drawLayer;
}
get context() {
return this._context;
}
get editedFeatures() {
return this._selectInteraction.getFeatures().getArray();
}
get editedFeatureText() {
if (this.editedFeatures.length === 1) {
return this.editedFeatures[0].get('text') || '';
}
return '';
}
set editedFeatureText(text) {
if (this.editedFeatures.length !== 0) {
this.editedFeatures.forEach(feature => feature.set('text', text));
mainEventDispatcher.dispatch('digitizing.editedFeatureText');
}
}
get editedFeatureTextRotation() {
if (this.editedFeatures.length === 1) {
return this.editedFeatures[0].get('rotation') || '';
}
return '';
}
set editedFeatureTextRotation(rotation) {
if (this.editedFeatures.length !== 0) {
this.editedFeatures.forEach(feature => feature.set('rotation', rotation));
mainEventDispatcher.dispatch('digitizing.editedFeatureRotation');
}
}
get editedFeatureTextScale() {
if (this.editedFeatures.length !== 0) {
return this.editedFeatures[0].get('scale') || 1;
}
return 1;
}
set editedFeatureTextScale(scale) {
if(isNaN(scale)){
scale = 1;
}
if (this.editedFeatures.length !== 0) {
this.editedFeatures.forEach(feature => feature.set('scale', scale));
mainEventDispatcher.dispatch('digitizing.editedFeatureScale');
}
}
set context(aContext) {
if (this._context == aContext) {
return;
}
if (this.featureDrawn) {
this._contextFeatures[this._context] = this.featureDrawn;
} else {
this._contextFeatures[this._context] = null;
}
this._isSaved = (localStorage.getItem(this._repoAndProjectString + '_' + this._context + '_drawLayer') !== null);
this._measureTooltips.forEach((measureTooltip) => {
mainLizmap.map.removeOverlay(measureTooltip[0]);
mainLizmap.map.removeOverlay(measureTooltip[1]);
this._measureTooltips.delete(measureTooltip);
});
this._drawLayer.getSource().clear();
this._context = aContext;
if (this._contextFeatures[this._context]) {
const OL6features = this._contextFeatures[this._context];
if (OL6features) {
// Add imported features to map and zoom to their extent
this._drawSource.addFeatures(OL6features);
}
} else {
this.loadFeatureDrawnToMap();
}
}
get toolSelected() {
return this._toolSelected;
}
set toolSelected(tool) {
if (this._tools.includes(tool)) {
// Disable all tools
mainLizmap.map.removeInteraction(this._drawInteraction);
// If tool === 'deactivate' or current selected tool is selected again => deactivate
if (tool === this._toolSelected || tool === this._tools[0]) {
this._toolSelected = this._tools[0];
} else {
const drawOptions = {
source: this._drawLayer.getSource(),
style: this._drawStyleFunction
};
switch (tool) {
case this._tools[1]:
drawOptions.type = 'Point';
break;
case this._tools[2]:
drawOptions.type = 'LineString';
drawOptions.geometryFunction = (coords, geom) => this._contraintsHandler(coords, geom, drawOptions.type);
break;
case this._tools[3]:
drawOptions.type = 'Polygon';
drawOptions.geometryFunction = (coords, geom) => this._contraintsHandler(coords, geom, drawOptions.type);
break;
case this._tools[4]:
drawOptions.type = 'Circle';
drawOptions.geometryFunction = createBox();
break;
case this._tools[5]:
drawOptions.type = 'Circle';
break;
case this._tools[6]:
drawOptions.type = 'Polygon';
drawOptions.freehand = true;
break;
case this._tools[7]:
drawOptions.type = 'Point';
drawOptions.style = new Style({
text: new Text({
text: lizDict['digitizing.toolbar.newText'],
fill: new Fill({
color: '#000',
}),
stroke: new Stroke({
color: '#fff',
width: 4,
}),
})
});
break;
}
this._drawInteraction = new Draw(drawOptions);
this._drawInteraction.on('drawstart', event => {
this.createMeasureTooltips();
this._listener = event.feature.getGeometry().on('change', evt => {
const geom = evt.target;
if (geom instanceof Polygon) {
this._updateTooltips(geom.getCoordinates()[0], geom, 'Polygon');
} else if (geom instanceof LineString) {
this._updateTooltips(geom.getCoordinates(), geom, 'LineString');
} else if (geom instanceof CircleGeom) {
this._updateTooltips([geom.getFirstCoordinate(), geom.getLastCoordinate()], geom, 'Circle');
}
});
});
this._drawInteraction.on('drawend', event => {
const geom = event.feature.getGeometry();
// Close linear ring if needed
if (geom instanceof Polygon) {
const coordsLinearRing = geom.getCoordinates()[0];
if (coordsLinearRing[0] !== coordsLinearRing[coordsLinearRing.length - 1]) {
coordsLinearRing.push(coordsLinearRing[0]);
geom.setCoordinates([coordsLinearRing]);
}
}
// Attach total overlay to its geom to update
// content when the geom is modified
geom.set('totalOverlay', Array.from(this._measureTooltips).pop()[1], true);
geom.on('change', (e) => {
const geom = e.target;
this._setTooltipContentByGeom(geom);
});
this._constraintLayer.setVisible(false);
// Remove segment measure and change total measure tooltip style
this._segmentMeasureTooltipElement.remove();
this._totalMeasureTooltipElement.className = 'ol-tooltip ol-tooltip-static';
this._totalMeasureTooltipElement.classList.toggle('hide', !this._hasMeasureVisible);
// unset tooltips so that new ones can be created
this._segmentMeasureTooltipElement = null;
this._totalMeasureTooltipElement = null;
unByKey(this._listener);
});
mainLizmap.map.addInteraction(this._drawInteraction);
this._toolSelected = tool;
// Disable edition and erasing when tool changes
this.isEdited = false;
this.isErasing = false;
}
mainEventDispatcher.dispatch('digitizing.toolSelected');
}
}
get drawColor() {
return this._drawColor;
}
set drawColor(color) {
this._drawColor = color;
// Save color
localStorage.setItem(this._repoAndProjectString + '_drawColor', this._drawColor);
mainEventDispatcher.dispatch('digitizing.drawColor');
}
get featureDrawn() {
const features = this._drawLayer.getSource().getFeatures();
if (features.length) {
return features;
}
return null;
}
/**
* Is digitizing tool active or not
* @todo active state should be set on UI's events
* @readonly
* @memberof Digitizing
* @returns {boolean}
*/
get isActive() {
const isActive = document.getElementById('button-draw')?.parentElement?.classList?.contains('active');
return isActive ? true : false;
}
get isEdited() {
return this._isEdited;
}
set isEdited(edited) {
if (this._isEdited !== edited) {
this._isEdited = edited;
if (this._isEdited) {
// Automatically edit the feature if unique
if (this.featureDrawn.length === 1) {
this._selectInteraction.getFeatures().push(this.featureDrawn[0]);
this.drawColor = this.featureDrawn[0].get('color');
}
mainLizmap.map.removeInteraction(this._drawInteraction);
mainLizmap.map.addInteraction(this._translateInteraction);
mainLizmap.map.addInteraction(this._selectInteraction);
mainLizmap.map.addInteraction(this._modifyInteraction);
this.toolSelected = 'deactivate';
this.isErasing = false;
mainEventDispatcher.dispatch('digitizing.editionBegins');
} else {
// Clear selection
this._selectInteraction.getFeatures().clear();
mainLizmap.map.removeInteraction(this._translateInteraction);
mainLizmap.map.removeInteraction(this._selectInteraction);
mainLizmap.map.removeInteraction(this._modifyInteraction);
this.saveFeatureDrawn();
mainEventDispatcher.dispatch('digitizing.editionEnds');
}
}
}
get isErasing() {
return this._isErasing;
}
set isErasing(isErasing) {
if (this._isErasing !== isErasing) {
this._isErasing = isErasing;
if (this._isErasing) {
// deactivate draw and edition
this.toolSelected = 'deactivate';
this.isEdited = false;
this._erasingCallBack = event => {
const features = mainLizmap.map.getFeaturesAtPixel(event.pixel, {
layerFilter: layer => {
return layer === this._drawLayer;
},
hitTolerance: 8
});
if(features.length){
if (!confirm(lizDict['digitizing.confirm.erase'])) {
return false;
}
this._eraseFeature(features[0]);
// Stop erasing mode when no features left
if(this._drawSource.getFeatures().length === 0){
this.isErasing = false;
}
this.saveFeatureDrawn();
mainEventDispatcher.dispatch('digitizing.erase');
}
};
mainLizmap.map.on('singleclick', this._erasingCallBack );
mainEventDispatcher.dispatch('digitizing.erasingBegins');
} else {
mainLizmap.map.un('singleclick', this._erasingCallBack );
mainEventDispatcher.dispatch('digitizing.erasingEnds');
}
}
}
get hasMeasureVisible() {
return this._hasMeasureVisible;
}
set hasMeasureVisible(visible) {
this._hasMeasureVisible = visible;
for (const overlays of this._measureTooltips) {
overlays[0].getElement().classList.toggle('hide', !visible);
overlays[1].getElement().classList.toggle('hide', !visible);
}
mainEventDispatcher.dispatch('digitizing.measure');
}
get hasConstraintsPanelVisible() {
return this._hasMeasureVisible && ['line', 'polygon'].includes(this.toolSelected);
}
get isSaved() {
return this._isSaved;
}
set distanceConstraint(distanceConstraint){
this._distanceConstraint = parseInt(distanceConstraint)
}
set angleConstraint(angleConstraint){
this._angleConstraint = parseInt(angleConstraint)
}
_eraseFeature(feature) {
const totalOverlay = feature.getGeometry().get('totalOverlay');
if (totalOverlay) {
this._measureTooltips.forEach((measureTooltip) => {
if(measureTooltip[1] === totalOverlay){
mainLizmap.map.removeOverlay(measureTooltip[0]);
mainLizmap.map.removeOverlay(measureTooltip[1]);
this._measureTooltips.delete(measureTooltip);
return;
}
});
}
this._drawSource.removeFeature(feature);
}
_userChangedColor(color) {
this._drawColor = color;
this._selectInteraction.getFeatures().forEach(feature => {
feature.set('color', color);
});
// Save color
localStorage.setItem(this._repoAndProjectString + '_drawColor', this._drawColor);
mainEventDispatcher.dispatch('digitizing.drawColor');
}
_contraintsHandler(coords, geom, geomType) {
// Create geom if undefined
if (!geom) {
if (geomType === 'Polygon') {
geom = new Polygon(coords);
} else {
geom = new LineString(coords);
}
}
let _coords;
if (geomType === 'Polygon') {
// Handle first linearRing in polygon
// TODO: Polygons with holes are not handled yet
_coords = coords[0];
} else {
_coords = coords;
}
if (this._distanceConstraint || this._angleConstraint) {
// Clear previous visual constraint features
this._constraintLayer.getSource().clear();
// Display constraint layer
this._constraintLayer.setVisible(true);
// Last point drawn on click
const lastDrawnPointCoords = _coords[_coords.length - 2];
// Point under cursor
const cursorPointCoords = _coords[_coords.length - 1];
// Contraint where point will be drawn on click
let constrainedPointCoords = cursorPointCoords;
if (this._distanceConstraint) {
// Draw circle with distanceConstraint as radius
const circle = circular(
transform(lastDrawnPointCoords, 'EPSG:3857', 'EPSG:4326'),
this._distanceConstraint,
128
);
constrainedPointCoords = transform(circle.getClosestPoint(transform(cursorPointCoords, 'EPSG:3857', 'EPSG:4326')), 'EPSG:4326', 'EPSG:3857');
// Draw visual constraint features
this._constraintLayer.getSource().addFeature(
new Feature({
geometry: circle.transform('EPSG:4326', 'EPSG:3857')
})
);
if (!this._angleConstraint) {
this._constraintLayer.getSource().addFeature(
new Feature({
geometry: new Point(constrainedPointCoords)
})
);
}
}
if (this._angleConstraint && _coords.length > 2) {
const constrainedAngleClockwise = new LineString([_coords[_coords.length - 3], lastDrawnPointCoords]);
const constrainedAngleAntiClockwise = constrainedAngleClockwise.clone();
// Rotate clockwise
constrainedAngleClockwise.rotate(-1 * this._angleConstraint * (Math.PI / 180.0), lastDrawnPointCoords);
const closestClockwise = constrainedAngleClockwise.getClosestPoint(cursorPointCoords);
// Rotate anticlockwise
constrainedAngleAntiClockwise.rotate(this._angleConstraint * (Math.PI / 180.0), lastDrawnPointCoords);
const closestAntiClockwise = constrainedAngleAntiClockwise.getClosestPoint(cursorPointCoords);
// Stretch lines
const scaleFactor = 50;
constrainedAngleClockwise.scale(scaleFactor, scaleFactor, lastDrawnPointCoords);
constrainedAngleAntiClockwise.scale(scaleFactor, scaleFactor, lastDrawnPointCoords);
this._constraintLayer.getSource().addFeatures([
new Feature({
geometry: constrainedAngleClockwise
}),
new Feature({
geometry: constrainedAngleAntiClockwise
})
]);
let constrainedAngleLineString;
// Display clockwise or anticlockwise angle
// Closest from cursor is displayed
if (getLength(new LineString([closestClockwise, cursorPointCoords])) < getLength(new LineString([closestAntiClockwise, cursorPointCoords]))) {
constrainedAngleLineString = constrainedAngleClockwise.clone();
} else {
constrainedAngleLineString = constrainedAngleAntiClockwise.clone();
}
if (this._distanceConstraint) {
const ratio = this._distanceConstraint / getLength(constrainedAngleLineString);
constrainedAngleLineString.scale(ratio, ratio, constrainedAngleLineString.getLastCoordinate());
constrainedPointCoords = constrainedAngleLineString.getFirstCoordinate();
} else {
constrainedPointCoords = constrainedAngleLineString.getClosestPoint(cursorPointCoords);
}
}
_coords[_coords.length - 1] = constrainedPointCoords;
}
if (geomType === 'Polygon') {
geom.setCoordinates([_coords]);
} else {
geom.setCoordinates(_coords);
}
return geom;
}
// Display draw measures in tooltips
_updateTooltips(coords, geom, geomType) {
// Current segment length
let segmentTooltipContent = this.formatLength(new LineString([coords[coords.length - 1], coords[coords.length - 2]]));
// Total length for LineStrings
// Perimeter and area for Polygons
// Radius and area for Circles
if(geomType == 'Circle') {
this._updateTotalMeasureTooltip(coords, geom, geomType, Array.from(this._measureTooltips).pop()[1]);
} else if (coords.length > 2) {
this._updateTotalMeasureTooltip(coords, geom, geomType, Array.from(this._measureTooltips).pop()[1]);
// Display angle ABC between three points. B is center
const A = coords[coords.length - 1];
const B = coords[coords.length - 2];
const C = coords[coords.length - 3];
const AB = Math.sqrt(Math.pow(B[0] - A[0], 2) + Math.pow(B[1] - A[1], 2));
const BC = Math.sqrt(Math.pow(B[0] - C[0], 2) + Math.pow(B[1] - C[1], 2));
const AC = Math.sqrt(Math.pow(C[0] - A[0], 2) + Math.pow(C[1] - A[1], 2));
let angleInDegrees = (Math.acos((BC * BC + AB * AB - AC * AC) / (2 * BC * AB)) * 180) / Math.PI;
angleInDegrees = Math.round(angleInDegrees * 100) / 100;
if (isNaN(angleInDegrees)) {
angleInDegrees = 0;
}
segmentTooltipContent += '<br>' + angleInDegrees + '°';
}
// Display current segment measure only when drawing lines or polygons
if (['line', 'polygon'].includes(this.toolSelected)) {
this._segmentMeasureTooltipElement.innerHTML = segmentTooltipContent;
Array.from(this._measureTooltips).pop()[0].setPosition(geom.getLastCoordinate());
}
}
_updateTotalMeasureTooltip(coords, geom, geomType, overlay) {
if (geomType === 'Polygon') {
// Close LinearRing to get its perimeter
const perimeterCoords = Array.from(coords);
perimeterCoords.push(Array.from(coords[0]));
let totalTooltipContent = this.formatLength(new Polygon([perimeterCoords]));
totalTooltipContent += '<br>' + this.formatArea(geom);
overlay.getElement().innerHTML = totalTooltipContent;
overlay.setPosition(geom.getInteriorPoint().getCoordinates());
} else if(geomType == 'Circle') {
// get polygon from circular geometry by approximating the circle with a 128-sided polygon
let circularGeom = fromCircle(geom,128);
let totalTooltipContent = this.formatLength(new LineString([coords[0], coords[1]]));
totalTooltipContent += '<br>' + this.formatArea(circularGeom);
overlay.getElement().innerHTML = totalTooltipContent;
overlay.setPosition(circularGeom.getInteriorPoint().getCoordinates());
}
else {
overlay.getElement().innerHTML = this.formatLength(geom);
overlay.setPosition(geom.getCoordinateAt(0.5));
}
}
/**
* Format length output.
* @param {Geometry} geom The geom.
* @returns {string} The formatted length.
*/
formatLength(geom) {
const length = getLength(geom, {projection: mainLizmap.map.getView().getProjection()});
let output;
if (length > 100) {
output = Math.round((length / 1000) * 100) / 100 + ' ' + 'km';
} else {
output = Math.round(length * 100) / 100 + ' ' + 'm';
}
return output;
}
/**
* Format area output.
* @param {Polygon} polygon The polygon.
* @returns {string} Formatted area.
*/
formatArea(polygon) {
const area = getArea(polygon, {projection: mainLizmap.map.getView().getProjection()});
let output;
if (area > 10000) {
output = Math.round((area / 1000000) * 100) / 100 + ' ' + 'km<sup>2</sup>';
} else {
output = Math.round(area * 100) / 100 + ' ' + 'm<sup>2</sup>';
}
return output;
}
/**
* Initializes measure tooltip and change event on a feature loaded from local storage.
* @param {Geometry} geom The geometry.
*/
_initMeasureTooltipOnLoadedFeatures(geom){
// create overlays
this.createMeasureTooltips();
geom.set('totalOverlay', Array.from(this._measureTooltips).pop()[1], true);
// calculate measures
this._setTooltipContentByGeom(geom);
geom.on('change', (e) => {
const geom = e.target;
this._setTooltipContentByGeom(geom);
});
// make measure tooltip static and hidden
this._totalMeasureTooltipElement.className = 'ol-tooltip ol-tooltip-static';
this._totalMeasureTooltipElement.classList.toggle('hide', !this._hasMeasureVisible);
// reset measureTooltip element
this._totalMeasureTooltipElement = null;
}
/**
* Calculates measuements for a specific geometry.
* @param {Geometry} geom The geometry.
*/
_setTooltipContentByGeom(geom){
if (geom instanceof Polygon) {
this._updateTotalMeasureTooltip(geom.getCoordinates()[0], geom, 'Polygon', geom.get('totalOverlay'));
} else if (geom instanceof LineString) {
this._updateTotalMeasureTooltip(geom.getCoordinates(), geom, 'Linestring', geom.get('totalOverlay'));
} else if ( geom instanceof CircleGeom) {
this._updateTotalMeasureTooltip([geom.getFirstCoordinate(), geom.getLastCoordinate()], geom, 'Circle', geom.get('totalOverlay'));
}
}
/**
* Creates measure tooltips
*/
createMeasureTooltips() {
if (this._segmentMeasureTooltipElement) {
this._segmentMeasureTooltipElement.parentNode.removeChild(this._segmentMeasureTooltipElement);
}
this._segmentMeasureTooltipElement = document.createElement('div');
this._segmentMeasureTooltipElement.className = 'ol-tooltip ol-tooltip-measure';
this._segmentMeasureTooltipElement.classList.toggle('hide', !this._hasMeasureVisible);
const segmentOverlay = new Overlay({
element: this._segmentMeasureTooltipElement,
offset: [0, -15],
positioning: 'bottom-center',
stopEvent: false,
insertFirst: false,
});
if (this._totalMeasureTooltipElement) {
this._totalMeasureTooltipElement.parentNode.removeChild(this._totalMeasureTooltipElement);
}
this._totalMeasureTooltipElement = document.createElement('div');
this._totalMeasureTooltipElement.className = 'ol-tooltip ol-tooltip-measure';
this._totalMeasureTooltipElement.classList.toggle('hide', !this._hasMeasureVisible);
const totalOverlay = new Overlay({
element: this._totalMeasureTooltipElement,
offset: [0, -15],
positioning: 'bottom-center',
stopEvent: false,
insertFirst: false,
});
this._measureTooltips.add([segmentOverlay, totalOverlay]);
mainLizmap.map.addOverlay(segmentOverlay);
mainLizmap.map.addOverlay(totalOverlay);
}
// Get SLD for featureDrawn[index]
getFeatureDrawnSLD(index) {
if (!this.featureDrawn[index]) {
return null;
}
const color = this.featureDrawn[index].get('color') || this._drawColor;
let opacityFactor = this.featureDrawn[index].get('mode') == 'textonly' ? 0 : 1;
let symbolizer = '';
let strokeAndFill =
`<Stroke>
<SvgParameter name="stroke">${color}</SvgParameter>
<SvgParameter name="stroke-opacity">${1*opacityFactor}</SvgParameter>
<SvgParameter name="stroke-width">${this._strokeWidth}</SvgParameter>
</Stroke>
<Fill>
<SvgParameter name="fill">${color}</SvgParameter>
<SvgParameter name="fill-opacity">${this._fillOpacity*opacityFactor}</SvgParameter>
</Fill>`;
// We consider LINESTRING and POLYGON together currently
if (this.featureDrawn[index].getGeometry().getType() === 'Point') {
symbolizer =
`<PointSymbolizer>
<Graphic>
<Mark>
<WellKnownName>circle</WellKnownName>
${strokeAndFill}
</Mark>
<Size>${2 * this._pointRadius}</Size>
</Graphic>
</PointSymbolizer>`;
} else {
symbolizer =
`<PolygonSymbolizer>
${strokeAndFill}
</PolygonSymbolizer>`;
}
const sld =
`<?xml version="1.0" encoding="UTF-8"?>
<StyledLayerDescriptor xmlns="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xsi:schemaLocation="http://www.opengis.net/sld http://schemas.opengis.net/sld/1.1.0/StyledLayerDescriptor.xsd" xmlns:se="http://www.opengis.net/se">
<UserStyle>
<FeatureTypeStyle>
<Rule>
${symbolizer}
</Rule>
</FeatureTypeStyle>
</UserStyle>
</StyledLayerDescriptor>`;
// Remove indentation to avoid big queries full of unecessary spaces
return sld.replace(' ', '');
}
get visibility(){
return this._drawLayer.getVisible();
}
/**
* Set visibility or toggle if not defined
* @param {boolean} visible
*/
toggleVisibility(visible = !this.visibility) {
this._drawLayer.setVisible(visible);
for (const overlays of this._measureTooltips) {
overlays[0].getElement().classList.toggle('hide', !(this._hasMeasureVisible && visible));
overlays[1].getElement().classList.toggle('hide', !(this._hasMeasureVisible && visible));
}
mainEventDispatcher.dispatch('digitizing.visibility');
}
toggleEdit() {
this.isEdited = !this.isEdited;
}
toggleMeasure() {
this.hasMeasureVisible = !this.hasMeasureVisible;
}
toggleErasing() {
this.isErasing = !this._isErasing;
}
toggleSave() {
this._isSaved = !this._isSaved;
this.saveFeatureDrawn();
mainEventDispatcher.dispatch('digitizing.save');
}
eraseAll() {
this._measureTooltips.forEach((measureTooltip) => {
mainLizmap.map.removeOverlay(measureTooltip[0]);
mainLizmap.map.removeOverlay(measureTooltip[1]);
this._measureTooltips.delete(measureTooltip);
});
this._drawSource.clear();
this.saveFeatureDrawn();
mainEventDispatcher.dispatch('digitizing.erase.all');
mainEventDispatcher.dispatch('digitizing.erase');
}
/**
* Save all drawn features in local storage
*/
saveFeatureDrawn() {
if (this._isSaved) {
if(this.featureDrawn){
const savedFeatures = [];
for(const feature of this.featureDrawn){
const geomType = feature.getGeometry().getType();
if( geomType === 'Circle'){
savedFeatures.push({
type: geomType,
color: feature.get('color'),
center: feature.getGeometry().getCenter(),
radius: feature.getGeometry().getRadius()
});
} else {
savedFeatures.push({
type: geomType,
color: feature.get('color'),
coords: feature.getGeometry().getCoordinates()
});
}
}
localStorage.setItem(this._repoAndProjectString + '_' + this._context + '_drawLayer', JSON.stringify(savedFeatures));
} else {
localStorage.removeItem(this._repoAndProjectString + '_' + this._context + '_drawLayer');
}
} else {
localStorage.removeItem(this._repoAndProjectString + '_' + this._context + '_drawLayer');
}
}
/**
* Load all drawn features from local storage
*/
loadFeatureDrawnToMap() {
// get saved data without context for draw
const oldSavedGeomJSON = this._context === 'draw' ? localStorage.getItem(this._repoAndProjectString + '_drawLayer') : null;
// Clear old saved data without context for draw from localStorage
if (oldSavedGeomJSON !== null) {
localStorage.removeItem(this._repoAndProjectString + '_drawLayer');
localStorage.setItem(this._repoAndProjectString + '_' + this._context + '_drawLayer', oldSavedGeomJSON);
}
// keep saved data without context for draw or get saved data with context
const savedGeomJSON = oldSavedGeomJSON !== null ? oldSavedGeomJSON : localStorage.getItem(this._repoAndProjectString + '_' + this._context + '_drawLayer');
if (savedGeomJSON) {
let loadedFeatures = [];
// the saved data could be an invalid JSON
try {
const savedFeatures = JSON.parse(savedGeomJSON);
// convert saved data to features
for(const feature of savedFeatures){
let loadedGeom;
if(feature.type === 'Point'){
loadedGeom = new Point(feature.coords);
} else if(feature.type === 'LineString'){
loadedGeom = new LineString(feature.coords);
} else if(feature.type === 'Polygon'){
loadedGeom = new Polygon(feature.coords);
} else if(feature.type === 'Circle'){
loadedGeom = new CircleGeom(feature.center, feature.radius);
}
if(loadedGeom){
const loadedFeature = new Feature(loadedGeom);
// init measure tooltip
this._initMeasureTooltipOnLoadedFeatures(loadedFeature.getGeometry());
loadedFeature.set('color', feature.color);
loadedFeatures.push(loadedFeature);
}
}
} catch(json_error) {
// the saved data is an invalid JSON
console.log('`'+savedGeomJSON+'` is not a JSON!');
// the saved data could be a WKT from previous lizmap version
try {
const formatWKT = new WKT();
loadedFeatures = formatWKT.readFeatures(savedGeomJSON);
console.log(loadedFeatures.length+' features read from WKT!');
// set color
for(const loadedFeature of loadedFeatures){
// init measure tooltip
this._initMeasureTooltipOnLoadedFeatures(loadedFeature.getGeometry());
loadedFeature.set('color', this._drawColor);
}
// No features read from localStorage so remove the data
if (loadedFeatures.length == 0) {
localStorage.removeItem(this._repoAndProjectString + '_' + this._context + '_drawLayer');
}
} catch(wkt_error) {
console.log('`'+savedGeomJSON+'` is not a WKT!');
console.error(json_error);
console.error(wkt_error);
}
}
// Draw features
this._isSaved = (loadedFeatures.length > 0);
this._drawSource.addFeatures(loadedFeatures);
}
}
download(format) {
if (this.featureDrawn) {
const options = {
featureProjection: mainLizmap.projection,
dataProjection: 'EPSG:4326'
};
if (format === 'geojson') {
const geoJSON = (new GeoJSON()).writeFeatures(this.featureDrawn, options);
Utils.downloadFileFromString(geoJSON, 'application/geo+json', 'export.geojson');
} else if (format === 'gpx') {
const gpx = (new GPX()).writeFeatures(this.featureDrawn, options);
Utils.downloadFileFromString(gpx, 'application/gpx+xml', 'export.gpx');
} else if (format === 'kml') {
const kml = (new KML()).writeFeatures(this.featureDrawn, options);
Utils.downloadFileFromString(kml, 'application/vnd.google-earth.kml+xml', 'export.kml');
} else if (format === 'fgb') {
// We create a temp GeoJSON in order to fill the metadata with the projection code
const tempGeoJSON = (new GeoJSON()).writeFeaturesObject(this.featureDrawn);
let projCode = options.featureProjection.split(":")[1];
projCode = parseInt(projCode);
// The 'serialize' func from GeoJSON allows us to add a projection code
const fgb = flatgeobuf.geojson.serialize(tempGeoJSON, projCode);
Utils.downloadFileFromString(fgb, 'application/octet-stream', 'export.fgb');
}
}
}
import(file) {
const reader = new FileReader();
// Get file extension
const fileExtension = file.name.split('.').pop().toLowerCase();
// Prepare for flatgeobuf
let projFGB;
/**
* Handle meta data of the FlatGeobuf file
* @param {object} headerMeta - Meta data of the FlatGeobuf file
*/
function handleHeaderMeta(headerMeta) {
const crsFGB = headerMeta.crs;
projFGB = (crsFGB ? "EPSG:" + crsFGB.code : null);
}
/**
* Reproject all features from a sourceProj to a targetProj
* @param {Feature} features - Features to reproject
* @param {string} sourceProj - Source projection
* @param {string} targetProj - Target projection
* @returns {Feature} - Reprojected features
*/
function reprojAll(features, sourceProj, targetProj) {
for (let i = 0; i < features.length; i++) {
features[i].getGeometry().transform(sourceProj, targetProj);
}
return features;
}
// if (fileExtension === 'zip') {
// reader.onload = (() => {
// return (e) => {
// const buffershp = e.target.result;
// shp(buffershp).then(response => {
// let OL6features = (new GeoJSON()).readFeatures(response, {featureProjection: mainLizmap.projection});
// if (OL6features) {
// // Add imported features to map and zoom to their extent
// this._drawSource.addFeatures(OL6features);
// }
// });
// };
// })(this);
// reader.readAsArrayBuffer(file);
// }
reader.onload = (() => {
return async (e) => {
const fileContent = e.target.result;
let OL6features;
// Handle GeoJSON, GPX or KML strings
try {
const options = {
featureProjection: mainLizmap.projection
};
// Check extension for format type
if (['json', 'geojson'].includes(fileExtension)) {
OL6features = (new GeoJSON()).readFeatures(fileContent, options);
} else if (fileExtension === 'gpx') {
OL6features = (new GPX()).readFeatures(fileContent, options);
} else if (fileExtension === 'kml') {
// Remove features default style to display layer style
OL6features = (new KML({ extractStyles: false })).readFeatures(fileContent, options);
} else if (fileExtension === 'zip') {
const geojson = await shp(fileContent);
if (geojson) {
OL6features = (new GeoJSON()).readFeatures(geojson, options);
}
} else if (fileExtension === 'fgb') {
let features = [];
const blob = new Blob([fileContent]);
const stream = blob.stream();
for await (const feature of flatgeobuf.ol.deserialize(stream, null, handleHeaderMeta)) {
features.push(feature);
}
if (projFGB !== null) {
const projCode = projFGB.split(":")[1];
// Verifiy if the projection is already included in proj4
if (Object.keys(proj4.defs).filter((name) => name.includes(projFGB)).length === 0) {
// We need a reprojection to be done because flatgeobuf files doessn't have a precise projection
// We neither used wkt located in headers due to some errors to define it with proj4.
// Nor 'fromEPSGcode()' because it returns a 'Projection' object that sometimes throws errors.
let descriptor = await Utils.fetch('https://epsg.io/' + projCode + '.proj4')
.then((res) => res.text())
.catch((error) => {
throw new Error(lizDict["digitizing.import.fetch.error"] + " : " + error.message);
});
proj4.defs(projFGB, descriptor);
register(proj4);
}
features = reprojAll(features, projFGB, mainLizmap.projection);
} else {
lizMap.addMessage(lizDict["digitizing.import.metadata.error"] + " : " +
mainLizmap.projection
, 'info'
, true)
}
OL6features = features;
}
} catch (error) {
lizMap.addMessage(error, 'danger', true)
}
if (OL6features) {
// Add imported features to map
this._drawSource.addFeatures(OL6features);
// And zoom to their bounding extent
const featuresGeometry = OL6features.map(feature => feature.getGeometry());
const featuresGeometryCollection = new GeometryCollection(featuresGeometry);
const extent = featuresGeometryCollection.getExtent();
mainLizmap.map.getView().fit(extent);
}
};
})(this);
if (fileExtension === 'zip'){
reader.readAsArrayBuffer(file);
} else if (fileExtension === 'fgb'){
reader.readAsArrayBuffer(file);
} else {
reader.readAsText(file);
}
}
}