/**
* @module state/Map.js
* @name MapState
* @copyright 2023 3Liz
* @author DHONT René-Luc
* @license MPL-2.0
*/
import { ValidationError } from './../Errors.js';
import EventDispatcher from './../../utils/EventDispatcher.js';
import { convertNumber, convertBoolean } from './../utils/Converters.js';
import { Extent } from './../utils/Extent.js';
import { OptionsConfig } from './../config/Options.js';
import Utils from './../Utils.js';
import { get as getProjection, transformExtent } from 'ol/proj.js';
/**
* Build scales
* @param {OptionsConfig} [options] - main configuration options
* @returns {number[]} scales in descending order
*/
export const buildScales = (options) => {
let scales = Array.from(options.mapScales);
if (scales.length < 2){
scales = [options.maxScale, options.minScale];
}
scales.sort(function(a, b) {
return Number(b) - Number(a);
});
if (!options.use_native_zoom_levels) {
return scales;
}
let projRef = options.projection.ref;
if (!projRef || projRef == 'EPSG:3857') {
const proj = getProjection('EPSG:3857');
const metersPerUnit = proj.getMetersPerUnit();
const zoomLevelNumber = 24;
const resolutions = [];
let maxRes = Utils.getResolutionFromScale(scales.at(0), metersPerUnit);
let minRes = Utils.getResolutionFromScale(scales.at(-1), metersPerUnit);
let res = 156543.03390625;
let n = 1;
while ( res > minRes && n < zoomLevelNumber) {
if ( res < maxRes ) {
//Add extra scale
resolutions.push(res);
}
res = res/2;
n++;
}
scales = resolutions.map(res => Utils.getScaleFromResolution(res, metersPerUnit));
} else {
const maxScale = scales.at(0);
const minScale = scales.at(-1);
let nativeScales = [];
let n=1;
while (10*Math.pow(10,n)-1 < maxScale) {
nativeScales = nativeScales.concat([10, 25, 50].map((x) => Math.pow(10,n)*x));
n++;
}
scales = [];
for (const scale of nativeScales) {
if (scale < minScale) {
continue;
}
if (scale > maxScale) {
break;
}
scales.push(scale);
}
scales.sort(function(a, b) {
return Number(b) - Number(a);
});
}
return scales;
}
const mapStateProperties = {
projection: {type: 'string'},
center: {type: 'array'},
zoom: {type: 'number'},
size: {type: 'array'},
extent: {type: 'extent'},
resolution: {type: 'number'},
scaleDenominator: {type: 'number'},
pointResolution: {type: 'number'},
pointScaleDenominator: {type: 'number'},
};
/**
* Map state ready.
* @event MapState#map.state.ready
* @type {object}
* @property {string} type - map.state.ready
* @property {boolean} ready - true
*/
/**
* Map state changed
* @event MapState#map.state.changed
* @type {object}
* @property {string} type - map.state.changed
* @property {string} [projection] - the map projection code if it changed
* @property {number[]} [center] - the map center if it changed
* @property {number} [zoom] - the map zoom if it changed
* @property {number[]} [size] - the map size if it changed
* @property {number[]} [extent] - the map extent (calculate by the map view) if it changed
* @property {number} [resolution] - the map resolution if it changed
* @property {number} [scaleDenominator] - the map scale denominator if it changed
* @property {number} [pointResolution] - the map resolution (calculate from the center) if it changed
* @property {number} [pointScaleDenominator] - the map scale denominator (calculate from the center) if it changed
*/
/**
* Class representing the map state
* @class
* @augments EventDispatcher
*/
export class MapState extends EventDispatcher {
/**
* Creating the map state
* @param {OptionsConfig} [options] - main configuration options
* @param {string|undefined} [startupFeatures] - The features to highlight at startup in GeoJSON
*/
constructor(options, startupFeatures) {
super();
this._ready = false;
// default values
this._projection = 'EPSG:3857';
this._center = [0, 0];
this._zoom = -1;
this._minZoom = 0;
this._maxZoom = -1;
this._scales = [];
this._size = [0, 0];
this._extent = new Extent(0, 0, 0, 0);
this._initialExtent = new Extent(0, 0, 0, 0);
this._resolution = -1;
this._scaleDenominator = -1;
this._pointResolution = -1;
this._pointScaleDenominator = -1;
// Values from options
this._singleWMSLayer = false;
if (options) {
this._singleWMSLayer = options.wms_single_request_for_all_layers; // default value is defined as false
this._scales = buildScales(options);
this._maxZoom = this._scales.length - 1;
this._projection = options.projection.ref;
this._initialExtent = new Extent(...(options.initialExtent));
this._center = this._initialExtent.center;
}
this._startupFeatures = startupFeatures;
}
/**
* Update the map state
* @param {object} evt - the map state changed object
* @param {string} [evt.projection] - the map projection code
* @param {number[]} [evt.center] - the map center
* @param {number} [evt.zoom] - the map zoom
* @param {number[]} [evt.size] - the map size
* @param {number[]} [evt.extent] - the map extent (calculate by the map view)
* @param {number} [evt.resolution] - the map resolution
* @param {number} [evt.scaleDenominator] - the map scale denominator
* @param {number} [evt.pointResolution] - the map resolution (calculate from the center)
* @param {number} [evt.pointScaleDenominator] - the map scale denominator (calculate from the center)
* @fires MapState#map.state.ready
* @fires MapState#map.state.changed
*/
update(evt) {
const oldProjection = this._projection;
let updatedProperties = {};
for (const prop in mapStateProperties) {
if (evt.hasOwnProperty(prop)) {
// Get definition
const def = mapStateProperties[prop];
// save old value
const oldValue = this['_'+prop];
// convert value
switch (def.type){
case 'boolean':
this['_'+prop] = convertBoolean(evt[prop]);
break;
case 'number':
this['_'+prop] = convertNumber(evt[prop]);
break;
case 'extent':
if (typeof(evt[prop]) == 'string'
|| typeof(evt[prop]) == 'number'
|| !(evt[prop] instanceof Array)) {
throw new ValidationError('The value for `'+prop+'` has to be an array!');
}
if (oldValue.length != evt[prop].length) {
throw new ValidationError('The length for `'+prop+'` is not expected! It has to be: '+oldValue.length);
}
this['_'+prop] = new Extent(...evt[prop]);
break;
case 'array':
if (typeof(evt[prop]) == 'string'
|| typeof(evt[prop]) == 'number'
|| !(evt[prop] instanceof Array)) {
throw new ValidationError('The value for `'+prop+'` has to be an array!');
}
this['_'+prop] = evt[prop];
break;
default:
this['_'+prop] = evt[prop];
}
// Check if the value has changed
if (def.type == 'extent') {
if (!oldValue.equals([...this['_'+prop]])){
updatedProperties[prop] = evt[prop];
}
} else if (def.type == 'array') {
if (oldValue.filter((v, i) => {return evt[prop][i] != v}).length != 0) {
updatedProperties[prop] = evt[prop];
}
} else if (oldValue != this['_'+prop]) {
updatedProperties[prop] = evt[prop];
}
}
}
// If projection has changed some extents have to be updated
if (updatedProperties.hasOwnProperty('projection') && oldProjection && updatedProperties['projection']) {
const newProjection = updatedProperties['projection']
// The initial extent
if (this._initialExtent && !this._initialExtent.equals([0,0,0,0])) {
this._initialExtent = new Extent(...(transformExtent(this._initialExtent, oldProjection, newProjection)));
}
// The extent if it has not been yet updated
if (!updatedProperties.hasOwnProperty('extent') && this._extent && !this._extent.equals([0,0,0,0])) {
this._extent = new Extent(...(transformExtent(this._extent, oldProjection, newProjection)));
this._center = this._extent.center;
updatedProperties['extent'] = new Extent(...this._extent);
updatedProperties['center'] = [...this.center];
}
}
// Dispatch event only if something have changed
if (Object.getOwnPropertyNames(updatedProperties).length != 0) {
const neededProperties = ['center', 'size', 'extent', 'resolution'];
if (!this._ready && Object.getOwnPropertyNames(updatedProperties).filter(v => neededProperties.includes(v)).length == 4) {
this._ready = true;
this.dispatch({
type: 'map.state.ready',
ready: true,
});
}
this.dispatch(
Object.assign({
type: 'map.state.changed'
}, updatedProperties)
);
}
}
/**
* Map is ready
* @type {boolean}
*/
get isReady() {
return this._ready;
}
/**
* Map projection code
* @type {string}
*/
get projection() {
return this._projection;
}
/**
* Map center
* @type {number[]}
*/
get center() {
return this._center;
}
/**
* Map zoom
* @type {number}
*/
get zoom() {
return this._zoom;
}
/**
* Map min zoom
* @type {number}
*/
get minZoom() {
return this._minZoom;
}
/**
* Map max zoom
* @type {number}
*/
get maxZoom() {
return this._maxZoom;
}
/**
* Map scales
* @type {number[]}
*/
get scales() {
return this._scales;
}
/**
* Map size
* @type {number[]}
*/
get size() {
return this._size;
}
/**
* Map extent (calculate by the map view)
* @type {Extent}
*/
get extent() {
return this._extent;
}
/**
* Map initial extent (provided by lizmap config)
* @type {Extent}
*/
get initialExtent() {
return this._initialExtent;
}
/**
* Map resolution
* @type {number}
*/
get resolution() {
return this._resolution;
}
/**
* Map scale denominator
* @type {number}
*/
get scaleDenominator() {
return this._scaleDenominator;
}
/**
* Map resolution (calculate from the center)
* @type {number}
*/
get pointResolution() {
return this._pointResolution;
}
/**
* Map scale denominator (calculate from the center)
* @type {number}
*/
get pointScaleDenominator() {
return this._pointScaleDenominator;
}
/**
* The features to highlight at startup in GeoJSON
* @type {string|undefined}
*/
get startupFeatures() {
return this._startupFeatures;
}
/**
* Config singleWMSLayer
* @type {boolean}
*/
get singleWMSLayer(){
return this._singleWMSLayer;
}
/**
* Zoom in
*/
zoomIn() {
const newZoom = this._zoom + 1
if (newZoom <= this._maxZoom) {
this.update({ 'zoom': newZoom });
}
}
/**
* Zoom out
*/
zoomOut() {
const newZoom = this._zoom - 1
if (newZoom >= this._minZoom) {
this.update({ 'zoom': newZoom });
}
}
/**
* Zoom to initial extent
*/
zoomToInitialExtent() {
this.update({ 'extent': this._initialExtent });
}
}