Skip to content

Commit

Permalink
[POC] Support on-the-fly LV95 reprojection
Browse files Browse the repository at this point in the history
copying geoblocks/ol-maplibre-layer/ locally to be able to edit it and add the on the fly capabilities (will create a PR there when done)

Making our coordinate system calculate resolution without threshold (especially for LV95) so that it can then be used to calculate a mercator zoom level
  • Loading branch information
pakb committed Sep 20, 2024
1 parent 2559300 commit 41e4b94
Show file tree
Hide file tree
Showing 15 changed files with 517 additions and 66 deletions.
4 changes: 2 additions & 2 deletions src/config/map.config.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { WEBMERCATOR } from '@/utils/coordinates/coordinateSystems'
import { LV95 } from '@/utils/coordinates/coordinateSystems'

/**
* Default projection to be used throughout the application
*
* @type {CoordinateSystem}
*/
export const DEFAULT_PROJECTION = WEBMERCATOR
export const DEFAULT_PROJECTION = LV95

/**
* Default tile size to use when requesting WMS tiles with our internal WMSs (512px)
Expand Down
13 changes: 12 additions & 1 deletion src/modules/map/components/openlayers/OpenLayersVectorLayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
* Most of the specific code found bellow, plus import of layer ID should be removed then.
*/
import { MapLibreLayer } from '@geoblocks/ol-maplibre-layer'
import { Source } from 'ol/source'
import { computed, inject, toRefs, watch } from 'vue'
import { useStore } from 'vuex'
import GeoAdminVectorLayer from '@/api/layers/GeoAdminVectorLayer.class'
import MapLibreLayer from '@/modules/map/components/openlayers/utils/ol-maplibre-layer/MapLibreLayer'
import useAddLayerToMap from '@/modules/map/components/openlayers/utils/useAddLayerToMap.composable'
import SwissCoordinateSystem from '@/utils/coordinates/SwissCoordinateSystem.class'
const props = defineProps({
vectorLayerConfig: {
Expand All @@ -32,6 +34,9 @@ const props = defineProps({
})
const { vectorLayerConfig, parentLayerOpacity, zIndex } = toRefs(props)
const store = useStore()
const currentProjection = computed(() => store.state.position.projection)
// extracting useful info from what we've linked so far
const layerId = computed(() => vectorLayerConfig.value.vectorStyleId)
const opacity = computed(() => parentLayerOpacity.value ?? vectorLayerConfig.value.opacity)
Expand All @@ -47,6 +52,12 @@ const layer = new MapLibreLayer({
source: new Source({
attribution: [vectorLayerConfig.value.attribution],
}),
translateZoom: (zoom) => {
if (currentProjection.value instanceof SwissCoordinateSystem) {
return currentProjection.value.transformCustomZoomLevelToStandard(zoom)
}
return zoom
},
})
const olMap = inject('olMap')
Expand Down
6 changes: 2 additions & 4 deletions src/modules/map/components/openlayers/OpenLayersWMTSLayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,13 @@ function getTransformedXYZUrl() {
function createTileGridForProjection() {
const maxResolutionIndex = indexOfMaxResolution(projection.value, maxResolution.value)
let resolutions = projection.value.getResolutions()
let matrixIds = projection.value.getMatrixIds()
if (resolutions.length > maxResolutionIndex) {
resolutions = resolutions.slice(0, maxResolutionIndex + 1)
matrixIds = matrixIds.slice(0, maxResolutionIndex + 1)
}
return new WMTSTileGrid({
resolutions,
resolutions: resolutions.map((resolution) => resolution.resolution),
origin: projection.value.getTileOrigin(),
matrixIds,
matrixIds: resolutions.map((_, index) => index),
extent: projection.value.bounds.flatten,
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { MapOptions, QueryRenderedFeaturesOptions } from 'maplibre-gl'
import { Map as MapLibreMap } from 'maplibre-gl'
import type { Map } from 'ol'
import type { Options as LayerOptions } from 'ol/layer/Layer.js'
import Layer from 'ol/layer/Layer.js'
import type { EventsKey } from 'ol/events.js'
import BaseEvent from 'ol/events/Event.js'
import { unByKey } from 'ol/Observable.js'
import { Source } from 'ol/source.js'
import MapLibreLayerRenderer from './MapLibreLayerRenderer.js'
import getMapLibreAttributions from './getMapLibreAttributions.js'

export type MapLibreOptions = Omit<MapOptions, 'container'>;

export type MapLibreLayerOptions = LayerOptions & {
mapLibreOptions: MapLibreOptions;
queryRenderedFeaturesOptions?: QueryRenderedFeaturesOptions;
translateZoom?: Function
};

export default class MapLibreLayer extends Layer {
mapLibreMap?: MapLibreMap;

loaded: boolean = false;

private olListenersKeys: EventsKey[] = [];

constructor(options: MapLibreLayerOptions) {
super({
source: new Source({
attributions: () => {
return getMapLibreAttributions(this.mapLibreMap);
},
}),
...options,
});
}

override disposeInternal() {
unByKey(this.olListenersKeys);
this.loaded = false;
if (this.mapLibreMap) {
// Some asynchronous repaints are triggered even if the MapLibreMap has been removed,
// to avoid display of errors we set an empty function.
this.mapLibreMap.triggerRepaint = () => {};
this.mapLibreMap.remove();
}
super.disposeInternal();
}

override setMapInternal(map: Map) {
super.setMapInternal(map);
if (map) {
this.loadMapLibreMap();
} else {
// TODO: I'm not sure if it's the right call
this.dispose();
}
}

private loadMapLibreMap() {
this.loaded = false;
const map = this.getMapInternal();
if (map) {
this.olListenersKeys.push(
map.on('change:target', this.loadMapLibreMap.bind(this)),
);
}

if (!map?.getTargetElement()) {
return;
}

if (!this.getVisible()) {
// On next change of visibility we load the map
this.olListenersKeys.push(
this.once('change:visible', this.loadMapLibreMap.bind(this)),
);
return;
}

const container = document.createElement('div');
container.style.position = 'absolute';
container.style.width = '100%';
container.style.height = '100%';

const mapLibreOptions = this.get('mapLibreOptions') as MapLibreOptions;

this.mapLibreMap = new MapLibreMap(
Object.assign({}, mapLibreOptions, {
container: container,
attributionControl: false,
interactive: false,
trackResize: false,
}),
);

this.mapLibreMap.on('sourcedata', () => {
this.getSource()?.refresh(); // Refresh attribution
});

this.mapLibreMap.once('load', () => {
this.loaded = true;
this.dispatchEvent(new BaseEvent('load'));
});
}

override createRenderer(): MapLibreLayerRenderer {
const translateZoom = this.get('translateZoom') as Function | undefined;
return new MapLibreLayerRenderer(this, translateZoom);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import type { MapGeoJSONFeature, QueryRenderedFeaturesOptions } from 'maplibre-gl'
import type { FrameState } from 'ol/Map.js'
import { toDegrees } from 'ol/math.js'
import { toLonLat } from 'ol/proj.js'
import LayerRenderer from 'ol/renderer/Layer.js'
import GeoJSON from 'ol/format/GeoJSON.js'
import type { Coordinate } from 'ol/coordinate.js'
import type { FeatureCallback } from 'ol/renderer/vector.js'
import type { Feature } from 'ol'
import type { Geometry } from 'ol/geom.js'
import { SimpleGeometry } from 'ol/geom.js'
import type { Pixel } from 'ol/pixel.js'
import type MapLibreLayer from './MapLibreLayer.js'

const VECTOR_TILE_FEATURE_PROPERTY = 'vectorTileFeature';

const formats: {
[key: string]: GeoJSON;
} = {
'EPSG:3857': new GeoJSON({
featureProjection: 'EPSG:3857',
}),
};

/**
* This class is a renderer for MapLibre Layer to be able to use the native ol
* functionalities like map.getFeaturesAtPixel or map.hasFeatureAtPixel.
*/
export default class MapLibreLayerRenderer extends LayerRenderer<MapLibreLayer> {
private readonly translateZoom: Function | undefined

constructor(layer: MapLibreLayer, translateZoom: Function | undefined) {
super(layer)
this.translateZoom = translateZoom
}

getFeaturesAtCoordinate(
coordinate: Coordinate | undefined,
hitTolerance: number = 5
): Feature<Geometry>[] {
const pixels = this.getMapLibrePixels(coordinate, hitTolerance);

if (!pixels) {
return [];
}

const queryRenderedFeaturesOptions =
(this.getLayer().get(
'queryRenderedFeaturesOptions',
) as QueryRenderedFeaturesOptions) || {};

// At this point we get GeoJSON MapLibre feature, we transform it to an OpenLayers
// feature to be consistent with other layers.
const features = this.getLayer()
.mapLibreMap?.queryRenderedFeatures(pixels, queryRenderedFeaturesOptions)
.map((feature) => {
return this.toOlFeature(feature);
});

return features || [];
}

override prepareFrame(): boolean {
return true;
}

override renderFrame(frameState: FrameState): HTMLElement {
const layer = this.getLayer();
const {mapLibreMap} = layer;
const map = layer.getMapInternal();
if (!layer || !map || !mapLibreMap) {
return null;
}

const mapLibreCanvas = mapLibreMap.getCanvas();
const {viewState} = frameState;
// adjust view parameters in MapLibre
mapLibreMap.jumpTo({
center: toLonLat(viewState.center, viewState.projection) as [number, number],
zoom: (this.translateZoom ? this.translateZoom(viewState.zoom) : viewState.zoom) - 1 ,
bearing: toDegrees(-viewState.rotation),
});

const opacity = layer.getOpacity().toString();
if (mapLibreCanvas && opacity !== mapLibreCanvas.style.opacity) {
mapLibreCanvas.style.opacity = opacity;
}

if (!mapLibreCanvas.isConnected) {
// The canvas is not connected to the DOM, request a map rendering at the next animation frame
// to set the canvas size.
map.render();
} else if (!sameSize(mapLibreCanvas, frameState)) {
mapLibreMap.resize();
}

mapLibreMap.redraw();

return mapLibreMap.getContainer();
}

override getFeatures(pixel: Pixel): Promise<Feature<Geometry>[]> {
const coordinate = this.getLayer()
.getMapInternal()
?.getCoordinateFromPixel(pixel);
return Promise.resolve(this.getFeaturesAtCoordinate(coordinate));
}

override forEachFeatureAtCoordinate<Feature>(
coordinate: Coordinate,
_frameState: FrameState,
hitTolerance: number,
callback: FeatureCallback<Feature>,
): Feature | undefined {
const features = this.getFeaturesAtCoordinate(coordinate, hitTolerance);
features.forEach((feature) => {
const geometry = feature.getGeometry();
if (geometry instanceof SimpleGeometry) {
callback(feature, this.getLayer(), geometry);
}
});
return features?.[0] as Feature;
}

private getMapLibrePixels(
coordinate?: Coordinate,
hitTolerance?: number,
): [[number, number], [number, number]] | [number, number] | undefined {
if (!coordinate) {
return undefined;
}

const pixel = this.getLayer().mapLibreMap?.project(
toLonLat(coordinate) as [number, number],
);

if (pixel?.x === undefined || pixel?.y === undefined) {
return undefined;
}

let pixels: [[number, number], [number, number]] | [number, number] = [
pixel.x,
pixel.y,
];

if (hitTolerance) {
const [x, y] = pixels as [number, number];
pixels = [
[x - hitTolerance, y - hitTolerance],
[x + hitTolerance, y + hitTolerance],
];
}
return pixels;
}

private toOlFeature(feature: MapGeoJSONFeature): Feature<Geometry> {
const layer = this.getLayer();
const map = layer.getMapInternal();

const projection =
map?.getView()?.getProjection()?.getCode() || 'EPSG:3857';

if (!formats[projection]) {
formats[projection] = new GeoJSON({
featureProjection: projection,
});
}

const olFeature = formats[projection].readFeature(feature) as Feature;
if (olFeature) {
// We save the original MapLibre feature to avoid losing information
// potentially needed for others functionalities like highlighting
// (id, layer id, source, sourceLayer ...)
olFeature.set(VECTOR_TILE_FEATURE_PROPERTY, feature, true);
}
return olFeature;
}
}

function sameSize(canvas: HTMLCanvasElement, frameState: FrameState): boolean {
return (
canvas.width === Math.floor(frameState.size[0] * frameState.pixelRatio) &&
canvas.height === Math.floor(frameState.size[1] * frameState.pixelRatio)
);
}
Loading

0 comments on commit 41e4b94

Please sign in to comment.