/**
* @module MGRS.js
* @name MGRS
* @copyright 2023 3Liz
* @author BOISTEAULT Nicolas
* @license MPL-2.0
*/
import Graticule from 'ol/layer/Graticule.js';
import Point from 'ol/geom/Point.js';
import LineString from 'ol/geom/LineString.js';
import Feature from 'ol/Feature.js';
import {Text, Fill, Stroke, Style} from 'ol/style.js';
import {Coordinate} from 'ol/coordinate.js'
import {
applyTransform,
equals,
getCenter,
isEmpty,
getIntersection,
intersects,
Extent
} from 'ol/extent.js';
import {
equivalent as equivalentProjection,
transform,
getTransform,
get as getProjection,
} from 'ol/proj.js';
import Projection from 'ol/proj/Projection.js';
import {clamp} from 'ol/math.js';
import { forward, toPoint } from '../dependencies/mgrs.js';
import { mainLizmap } from './Globals.js';
/**
* @typedef {object} Options
* @property {string} [className='ol-layer'] A CSS class name to set to the layer element.
* @property {number} [opacity=1] Opacity (0, 1).
* @property {boolean} [visible=true] Visibility.
* @property {Extent} [extent] The bounding extent for layer rendering. The layer will not be
* rendered outside of this extent.
* @property {number} [zIndex] The z-index for layer rendering. At rendering time, the layers
* will be ordered, first by Z-index and then by position. When `undefined`, a `zIndex` of 0 is assumed
* for layers that are added to the map's `layers` collection, or `Infinity` when the layer's `setMap()`
* method was used.
* @property {number} [minResolution] The minimum resolution (inclusive) at which this layer will be
* visible.
* @property {number} [maxResolution] The maximum resolution (exclusive) below which this layer will
* be visible.
* @property {number} [minZoom] The minimum view zoom level (exclusive) above which this layer will be
* visible.
* @property {number} [maxZoom] The maximum view zoom level (inclusive) at which this layer will
* be visible.
* @property {number} [maxLines=100] The maximum number of meridians and
* parallels from the center of the map. The default value of 100 means that at
* most 200 meridians and 200 parallels will be displayed. The default value is
* appropriate for conformal projections like Spherical Mercator. If you
* increase the value, more lines will be drawn and the drawing performance will
* decrease.
* @property {Stroke} [strokeStyle] The
* stroke style to use for drawing the graticule. If not provided, the following stroke will be used:
* ```js
* new Stroke({
* color: 'rgba(0, 0, 0, 0.2)' // a not fully opaque black
* });
* ```
* @property {number} [targetSize=100] The target size of the graticule cells,
* in pixels.
* @property {boolean} [showLabels=false] Render a label with the respective
* latitude/longitude for each graticule line.
* @property {function(number):string} [lonLabelFormatter] Label formatter for
* longitudes. This function is called with the longitude as argument, and
* should return a formatted string representing the longitude. By default,
* labels are formatted as degrees, minutes, seconds and hemisphere.
* @property {function(number):string} [latLabelFormatter] Label formatter for
* latitudes. This function is called with the latitude as argument, and
* should return a formatted string representing the latitude. By default,
* labels are formatted as degrees, minutes, seconds and hemisphere.
* @property {number} [lonLabelPosition=0] Longitude label position in fractions
* (0..1) of view extent. 0 means at the bottom of the viewport, 1 means at the
* top.
* @property {number} [latLabelPosition=1] Latitude label position in fractions
* (0..1) of view extent. 0 means at the left of the viewport, 1 means at the
* right.
* @property {Text} [lonLabelStyle] Longitude label text
* style. If not provided, the following style will be used:
* ```js
* new Text({
* font: '12px Calibri,sans-serif',
* textBaseline: 'bottom',
* fill: new Fill({
* color: 'rgba(0,0,0,1)'
* }),
* stroke: new Stroke({
* color: 'rgba(255,255,255,1)',
* width: 3
* })
* });
* ```
* Note that the default's `textBaseline` configuration will not work well for
* `lonLabelPosition` configurations that position labels close to the top of
* the viewport.
* @property {Text} [latLabelStyle] Latitude label text style.
* If not provided, the following style will be used:
* ```js
* new Text({
* font: '12px Calibri,sans-serif',
* textAlign: 'end',
* fill: new Fill({
* color: 'rgba(0,0,0,1)'
* }),
* stroke: Stroke({
* color: 'rgba(255,255,255,1)',
* width: 3
* })
* });
* ```
* Note that the default's `textAlign` configuration will not work well for
* `latLabelPosition` configurations that position labels close to the left of
* the viewport.
* @property {Array<number>} [intervals=[90, 45, 30, 20, 10, 5, 2, 1, 30/60, 20/60, 10/60, 5/60, 2/60, 1/60, 30/3600, 20/3600, 10/3600, 5/3600, 2/3600, 1/3600]]
* Intervals (in degrees) for the graticule. Example to limit graticules to 30 and 10 degrees intervals:
* ```js
* [30, 10]
* ```
* @property {boolean} [wrapX=true] Whether to repeat the graticule horizontally.
* @property {object} [properties] Arbitrary observable properties. Can be accessed with `#get()` and `#set()`.
*/
/**
* @class
* @name MGRS
* @augments Graticule
*/
class MGRS extends Graticule {
/**
* @param {Options} [options] Options.
*/
constructor(options) {
super(options);
/**
* @type {Array<LineString>}
* @private
*/
this.lines_ = [];
this.latLabelFormatter_ = () => {
return '';
};
this.lonLabelFormatter_ = () => {
return '';
};
}
/**
* @param {number} lon Longitude.
* @param {number} minLat Minimal latitude.
* @param {number} maxLat Maximal latitude.
* @param {number} squaredTolerance Squared tolerance.
* @param {Extent} extent Extent.
* @param {number} index Index.
* @returns {number} Index.
* @private
*/
addMeridian_(lon, minLat, maxLat, squaredTolerance, extent, index) {
const lineString = this.getMeridian_(
lon,
minLat,
maxLat,
squaredTolerance,
index
);
if (intersects(lineString.getExtent(), extent)) {
if (this.meridiansLabels_) {
const text = forward([lon, minLat], 0).slice(0, -2);
if (index in this.meridiansLabels_) {
this.meridiansLabels_[index].text = text;
} else {
this.meridiansLabels_[index] = {
geom: new Point([]),
text: text,
};
}
}
this.meridians_[index++] = lineString;
}
return index;
}
calculateIntersection_(p1, p2, p3, p4) {
var c2x = p3.x - p4.x; // (x3 - x4)
var c3x = p1.x - p2.x; // (x1 - x2)
var c2y = p3.y - p4.y; // (y3 - y4)
var c3y = p1.y - p2.y; // (y1 - y2)
// down part of intersection point formula
var d = c3x * c2y - c3y * c2x;
if (d == 0) {
throw new Error('Number of intersection points is zero or infinity.');
}
// upper part of intersection point formula
var u1 = p1.x * p2.y - p1.y * p2.x; // (x1 * y2 - y1 * x2)
var u4 = p3.x * p4.y - p3.y * p4.x; // (x3 * y4 - y3 * x4)
// intersection point formula
var px = (u1 * c2x - c3x * u4) / d;
var py = (u1 * c2y - c3y * u4) / d;
var p = { x: px, y: py };
return p;
}
/**
* Update geometries in the source based on current view
* @param {Extent} extent Extent
* @param {number} resolution Resolution
* @param {Projection} projection Projection
*/
loaderFunction(extent, resolution, projection) {
this.loadedExtent_ = extent;
const source = this.getSource();
// only consider the intersection between our own extent & the requested one
const layerExtent = this.getExtent() || [
-Infinity,
-Infinity,
Infinity,
Infinity,
];
const renderExtent = getIntersection(layerExtent, extent);
if (
this.renderedExtent_ &&
equals(this.renderedExtent_, renderExtent) &&
this.renderedResolution_ === resolution
) {
return;
}
this.renderedExtent_ = renderExtent;
this.renderedResolution_ = resolution;
// bail out if nothing to render
if (isEmpty(renderExtent)) {
return;
}
// update projection info
const center = getCenter(renderExtent);
const squaredTolerance = (resolution * resolution) / 4;
const updateProjectionInfo =
!this.projection_ || !equivalentProjection(this.projection_, projection);
if (updateProjectionInfo) {
this.updateProjectionInfo_(projection);
}
this.createGraticule_(renderExtent, center, resolution, squaredTolerance);
// first make sure we have enough features in the pool
let featureCount = this.meridians_.length + this.parallels_.length + this.lines_.length;
if (this.meridiansLabels_) {
featureCount += this.meridians_.length;
}
if (this.parallelsLabels_) {
featureCount += this.parallels_.length;
}
let feature;
while (featureCount > this.featurePool_.length) {
feature = new Feature();
this.featurePool_.push(feature);
}
const featuresColl = source.getFeaturesCollection();
featuresColl.clear();
let poolIndex = 0;
// add features for the lines & labels
let i, l;
for (i = 0, l = this.meridians_.length; i < l; ++i) {
feature = this.featurePool_[poolIndex++];
feature.setGeometry(this.meridians_[i]);
feature.setStyle(this.lineStyle_);
featuresColl.push(feature);
}
for (i = 0, l = this.parallels_.length; i < l; ++i) {
feature = this.featurePool_[poolIndex++];
feature.setGeometry(this.parallels_[i]);
feature.setStyle(this.lineStyle_);
featuresColl.push(feature);
}
// 100km
for (i = 0, l = this.lines_.length; i < l; ++i) {
feature = this.featurePool_[poolIndex++];
feature.setGeometry(this.lines_[i]);
feature.setStyle((feature) => {
return new Style({
stroke: new Stroke({
color: '#000',
width: 1.25,
}),
text: new Text({
text: feature.getGeometry().get('label'),
offsetY: -10,
fill: new Fill({
color: '#000',
}),
stroke: new Stroke({
color: '#fff',
width: 4,
}),
})
})
});
featuresColl.push(feature);
}
}
/**
* @param {Extent} extent Extent.
* @param {Coordinate} center Center.
* @param {number} resolution Resolution.
* @param {number} squaredTolerance Squared tolerance.
* @private
*/
createGraticule_(extent, center, resolution, squaredTolerance) {
const zoom = mainLizmap.map.getView().getZoomForResolution(resolution);
const zoomSwitch = 4;
const validExtent = applyTransform(
extent,
getTransform(this.projection_, getProjection('EPSG:4326')),
undefined,
8
);
// Force minLat and maxLat for MGRS
const MGRSMaxLat = 72;
const MGRSMinLat = -80;
let lat, lon;
const lonInterval = 6;
let latInterval = 8;
const maxLat = clamp(Math.floor(validExtent[3] / latInterval) * latInterval + latInterval, MGRSMinLat, MGRSMaxLat) ;
const maxLon = clamp(Math.floor(validExtent[2] / lonInterval) * lonInterval + lonInterval, this.minLon_, this.maxLon_);
const minLat = clamp(Math.floor(validExtent[1] / latInterval) * latInterval, MGRSMinLat, MGRSMaxLat);
const minLon = clamp(Math.floor(validExtent[0] / lonInterval) * lonInterval, this.minLon_, this.maxLon_);
let idxParallels = 0;
let idxMeridians = 0;
// GZD grid
if (zoom <= zoomSwitch) {
for (lon = minLon; lon <= maxLon; lon += lonInterval) {
for (lat = minLat; lat <= maxLat; lat += latInterval) {
// The northmost latitude band, X, is 12° high
if (lat == 72) {
latInterval = 12
} else {
latInterval = 8;
}
idxParallels = this.addParallel_(
lat,
lon,
lon + lonInterval,
squaredTolerance,
extent,
idxParallels
);
// Special cases
// Norway
if (lat === 56 && lon === 6) {
continue;
}
// Svalbard
if (lat === 72 && lon >= 6 && lon <= 36) {
continue;
}
idxMeridians = this.addMeridian_(
lon,
lat,
lat + latInterval,
squaredTolerance,
extent,
idxMeridians
);
}
}
// Special cases
// Norway
idxMeridians = this.addMeridian_(
3,
56,
64,
squaredTolerance,
extent,
idxMeridians
);
// Svalbard
for (const lon of [9, 21, 33]) {
idxMeridians = this.addMeridian_(
lon,
72,
84,
squaredTolerance,
extent,
idxMeridians
);
}
}
this.parallels_.length = idxParallels;
if (this.parallelsLabels_) {
this.parallelsLabels_.length = idxParallels;
}
this.meridians_.length = idxMeridians;
if (this.meridiansLabels_) {
this.meridiansLabels_.length = idxMeridians;
}
// 100KM grid
this.lines_ = [];
if (zoom > zoomSwitch) {
// Get code inside grid
const delta = 0.01;
for (lon = minLon; lon < maxLon; lon += lonInterval) {
for (lat = minLat; lat <= maxLat; lat += latInterval) {
const leftBottom = forward([lon, lat], 0);
const rightColumnLetter = forward([lon + lonInterval - delta, lat], 0).slice(-2, -1).charCodeAt();
const rightTop = forward([lon + lonInterval - delta, lat + latInterval - delta], 0);
let columnLetter = leftBottom.slice(-2, -1).charCodeAt();
while (columnLetter != rightColumnLetter + 1) {
// Discard I and O
if (columnLetter === 73 || columnLetter === 79) {
columnLetter++;
continue;
}
let rowLetter = leftBottom.slice(-1).charCodeAt();
while (rowLetter != rightTop.slice(-1).charCodeAt()) {
// Discard I and O
if (rowLetter === 73 || rowLetter === 79) {
rowLetter++;
continue;
}
// Next letters
let columnLetterNext = columnLetter + 1;
// Column letter stops at 'Z' => after we go back to 'A'
if (columnLetterNext >= 91) {
columnLetterNext = 65;
}
// Discard I and O
if (columnLetterNext === 73 || columnLetterNext === 79) {
columnLetterNext++;
}
let rowLetterNext = rowLetter + 1;
// Row letter stops at 'V' => after we go back to 'A'
if (rowLetterNext >= 87) {
rowLetterNext = 65;
}
// Discard I and O
if (rowLetterNext === 73 || rowLetterNext === 79) {
rowLetterNext++;
}
let leftBottomCoords = toPoint(leftBottom.slice(0, -2) + String.fromCharCode(columnLetter) + String.fromCharCode(rowLetter));
let rightBottomCoords = toPoint(leftBottom.slice(0, -2) + String.fromCharCode(columnLetterNext) + String.fromCharCode(rowLetter));
let leftTopCoords = toPoint(leftBottom.slice(0, -2) + String.fromCharCode(columnLetter) + String.fromCharCode(rowLetterNext));
// Make lines don't exceed their GZD cell
if (leftBottomCoords[0] < lon) {
const intersectionPointWithLon = this.calculateIntersection_(
{ x: lon, y: lat + latInterval },
{ x: lon, y: lat - latInterval },
{ x: leftBottomCoords[0], y: leftBottomCoords[1] },
{ x: rightBottomCoords[0], y: rightBottomCoords[1] }
);
leftBottomCoords[0] = intersectionPointWithLon.x;
leftBottomCoords[1] = intersectionPointWithLon.y;
}
if (leftTopCoords[0] < lon) {
leftTopCoords[0] = lon;
}
if (leftTopCoords[1] > lat + latInterval) {
leftTopCoords[1] = lat + latInterval;
}
if (rightBottomCoords[0] > lon + lonInterval) {
const intersectionPointWithLon = this.calculateIntersection_(
{ x: lon + lonInterval, y: lat + latInterval },
{ x: lon + lonInterval, y: lat - latInterval },
{ x: leftBottomCoords[0], y: leftBottomCoords[1] },
{ x: rightBottomCoords[0], y: rightBottomCoords[1] }
);
rightBottomCoords[0] = intersectionPointWithLon.x;
rightBottomCoords[1] = intersectionPointWithLon.y;
}
if (leftBottomCoords[0] <= lon + lonInterval) {
const parallel = new LineString([
transform(leftBottomCoords, 'EPSG:4326', this.projection_),
transform(rightBottomCoords, 'EPSG:4326', this.projection_)
]);
// Display label on parallel
let label = '';
try {
label = forward([leftBottomCoords[0] + delta, leftBottomCoords[1] + delta], 0);
} catch (error) {
console.log(error);
}
parallel.set('label', label, true);
this.lines_.push(parallel);
this.lines_.push(new LineString([
transform(leftBottomCoords, 'EPSG:4326', this.projection_),
transform(leftTopCoords, 'EPSG:4326', this.projection_)
]));
}
// Increment rowLetter
rowLetter++;
// Row letter stops at 'V' => after we go back to 'A'
if (rowLetter >= 87) {
rowLetter = 65;
}
}
// Increment columnLetter
columnLetter++;
// Column letter stops at 'Z' => after we go back to 'A'
if (columnLetter >= 91 && columnLetter != rightColumnLetter + 1) {
columnLetter = 65;
}
}
}
}
}
}
}
export default MGRS;