-
-
+ @if (mapProvider() === 'leaflet') {
+
+
+ }
+ @if (mapProvider() === 'maplibre') {
+
+ }
diff --git a/OV_DB/OVDBFrontend/src/app/single-route-map/single-route-map.component.scss b/OV_DB/OVDBFrontend/src/app/single-route-map/single-route-map.component.scss
index e8b36892..9637fdc1 100644
--- a/OV_DB/OVDBFrontend/src/app/single-route-map/single-route-map.component.scss
+++ b/OV_DB/OVDBFrontend/src/app/single-route-map/single-route-map.component.scss
@@ -53,4 +53,11 @@
min-height: 100%;
position: relative;
flex: 1;
+}
+
+.map-toggle-button {
+ position: absolute !important;
+ bottom: 20px;
+ right: 20px;
+ z-index: 1000;
}
\ No newline at end of file
diff --git a/OV_DB/OVDBFrontend/src/app/single-route-map/single-route-map.component.ts b/OV_DB/OVDBFrontend/src/app/single-route-map/single-route-map.component.ts
index 5a630594..8662b181 100644
--- a/OV_DB/OVDBFrontend/src/app/single-route-map/single-route-map.component.ts
+++ b/OV_DB/OVDBFrontend/src/app/single-route-map/single-route-map.component.ts
@@ -1,4 +1,4 @@
-import { Component, OnInit, viewChild, inject } from '@angular/core';
+import { Component, OnInit, viewChild, inject, ElementRef, OnDestroy } from '@angular/core';
import { LatLngBounds, LatLng, geoJSON } from 'leaflet';
import { tileLayer } from 'leaflet';
import { TranslateService, TranslateModule } from '@ngx-translate/core';
@@ -8,6 +8,9 @@ import { ActivatedRoute } from '@angular/router';
import { LeafletModule } from '@bluehalo/ngx-leaflet';
import { NgClass } from '@angular/common';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
+import { MapProviderService } from '../services/map-provider.service';
+import { MapConfigService } from '../services/map-config.service';
+import * as maplibregl from 'maplibre-gl';
@Component({
selector: 'app-single-route-map',
@@ -15,13 +18,19 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
styleUrls: ['./single-route-map.component.scss'],
imports: [LeafletModule, NgClass, MatProgressSpinner, TranslateModule]
})
-export class SingleRouteMapComponent implements OnInit {
+export class SingleRouteMapComponent implements OnInit, OnDestroy {
private translateService = inject(TranslateService);
private translationService = inject(TranslationService);
private activatedRoute = inject(ActivatedRoute);
private apiService = inject(ApiService);
+ private mapProviderService = inject(MapProviderService);
+ private mapConfigService = inject(MapConfigService);
readonly mapContainer = viewChild
('mapContainer');
+ readonly maplibreContainer = viewChild>('maplibreContainer');
+
+ mapProvider = this.mapProviderService.currentProvider;
+ private maplibreMap: maplibregl.Map | null = null;
loading = false;
layers = [];
error: boolean;
@@ -82,6 +91,11 @@ export class SingleRouteMapComponent implements OnInit {
this.getRoute();
});
this.translationService.languageChanged.subscribe(() => this.getRoute());
+
+ // Initialize MapLibre if it's the active provider
+ if (this.mapProvider() === 'maplibre') {
+ setTimeout(() => this.initMapLibre(), 0);
+ }
}
@@ -128,10 +142,128 @@ export class SingleRouteMapComponent implements OnInit {
this.layers = [track];
this.bounds = track.getBounds();
this.loading = false;
+
+ // Also update MapLibre if it's the active provider
+ if (this.mapProvider() === 'maplibre') {
+ setTimeout(() => this.showRouteOnMapLibre(), 100);
+ }
}
catch {
this.error = true;
}
}
+ ngOnDestroy() {
+ if (this.maplibreMap) {
+ this.maplibreMap.remove();
+ this.maplibreMap = null;
+ }
+ }
+
+ private initMapLibre() {
+ const container = this.maplibreContainer();
+ if (!container) {
+ return;
+ }
+
+ const style = this.mapConfigService.getMapLibreStyle('OpenStreetMap Mat');
+
+ this.maplibreMap = new maplibregl.Map({
+ container: container.nativeElement,
+ style: style,
+ center: [5.5, 52.0],
+ zoom: 7,
+ transformRequest: this.mapConfigService.getMapLibreTransformRequest()
+ });
+
+ this.maplibreMap.on('load', () => {
+ this.maplibreMap!.addControl(new maplibregl.NavigationControl(), 'top-right');
+
+ // If route is already loaded, show it now
+ if (this.layers.length > 0) {
+ this.showRouteOnMapLibre();
+ }
+ });
+ }
+
+ private showRouteOnMapLibre() {
+ if (!this.maplibreMap || this.layers.length === 0) {
+ return;
+ }
+
+ // Wait for map to be loaded
+ if (!this.maplibreMap.loaded()) {
+ this.maplibreMap.once('load', () => {
+ this.showRouteOnMapLibre();
+ });
+ return;
+ }
+
+ const leafletLayer = this.layers[0];
+ const geojsonData = (leafletLayer as any).toGeoJSON();
+
+ if (!this.maplibreMap.getSource('route')) {
+ this.maplibreMap.addSource('route', {
+ type: 'geojson',
+ data: geojsonData
+ });
+
+ this.maplibreMap.addLayer({
+ id: 'route-layer',
+ type: 'line',
+ source: 'route',
+ paint: {
+ 'line-color': ['get', 'stroke'],
+ 'line-width': 3
+ }
+ });
+
+ this.maplibreMap.on('click', 'route-layer', (e) => {
+ if (e.features && e.features.length > 0) {
+ const feature = e.features[0];
+ const properties = feature.properties as any;
+
+ let popup = '' + properties.name + '
';
+ popup += this.translateService.instant('MAP.POPUP.TYPE') + ': ' + properties.type;
+ if (properties.description) {
+ popup += '
' + this.translateService.instant('MAP.POPUP.REMARK') + ': ' + properties.description;
+ }
+ if (properties.lineNumber) {
+ popup += '
' + this.translateService.instant('MAP.POPUP.LINENUMBER') + ': ' + properties.lineNumber;
+ }
+ if (properties.operatingCompany) {
+ popup += '
' + this.translateService.instant('MAP.POPUP.OPERATINGCOMPANY') + ': ' + properties.operatingCompany;
+ }
+ popup += '
';
+
+ new maplibregl.Popup()
+ .setLngLat(e.lngLat)
+ .setHTML(popup)
+ .addTo(this.maplibreMap!);
+ }
+ });
+
+ this.maplibreMap.on('mouseenter', 'route-layer', () => {
+ this.maplibreMap!.getCanvas().style.cursor = 'pointer';
+ });
+ this.maplibreMap.on('mouseleave', 'route-layer', () => {
+ this.maplibreMap!.getCanvas().style.cursor = '';
+ });
+ } else {
+ const source = this.maplibreMap.getSource('route') as maplibregl.GeoJSONSource;
+ if (source) {
+ source.setData(geojsonData);
+ }
+ }
+
+ if (this.bounds && this.bounds.isValid()) {
+ const sw = this.bounds.getSouthWest();
+ const ne = this.bounds.getNorthEast();
+ this.maplibreMap.fitBounds([
+ [sw.lng, sw.lat],
+ [ne.lng, ne.lat]
+ ], { padding: 50 });
+ }
+ }
+
}
diff --git a/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.html b/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.html
index 50eeb807..9ce1dea2 100644
--- a/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.html
+++ b/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.html
@@ -1,7 +1,12 @@
-
+ @if (mapProvider() === 'leaflet') {
+
+ }
+ @if (mapProvider() === 'maplibre') {
+
+ }
diff --git a/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.ts b/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.ts
index 60bc66c0..f253a59f 100644
--- a/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.ts
+++ b/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.ts
@@ -2,9 +2,12 @@ import {
ChangeDetectorRef,
Component,
OnInit,
+ OnDestroy,
inject,
input,
- signal
+ signal,
+ ElementRef,
+ viewChild
} from "@angular/core";
import { MatCheckboxChange, MatCheckbox } from "@angular/material/checkbox";
import { LatLngBounds, LatLng, markerClusterGroup, divIcon, circleMarker } from "leaflet";
@@ -14,6 +17,9 @@ import { StationAdminProperties } from "src/app/models/stationAdminProperties.mo
import { ApiService } from "src/app/services/api.service";
import { RegionsService } from "src/app/services/regions.service";
import { TranslationService } from "src/app/services/translation.service";
+import { MapProviderService } from "src/app/services/map-provider.service";
+import { MapConfigService } from "src/app/services/map-config.service";
+import * as maplibregl from 'maplibre-gl';
import { LeafletModule } from "@bluehalo/ngx-leaflet";
import { KeyValuePipe, NgClass } from "@angular/common";
import { MatProgressSpinner } from "@angular/material/progress-spinner";
@@ -57,13 +63,20 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
KeyValuePipe,
]
})
-export class AdminStationsMapComponent implements OnInit {
+export class AdminStationsMapComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private cd = inject(ChangeDetectorRef);
+ private mapProviderService = inject(MapProviderService);
+ private mapConfigService = inject(MapConfigService);
regionsService = inject(RegionsService);
translationService = inject(TranslationService);
signalRService = inject(SignalRService);
+
+ readonly maplibreContainer = viewChild
>("maplibreContainer");
+ mapProvider = this.mapProviderService.currentProvider;
+ private maplibreMap: maplibregl.Map | null = null;
+ private stationsData: any[] = [];
baseLayers = {
OpenStreetMap: tileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
@@ -145,7 +158,16 @@ export class AdminStationsMapComponent implements OnInit {
ngOnInit(): void {
this.getData(true);
this.getRegions();
+ if (this.mapProvider() === 'maplibre') {
+ setTimeout(() => this.initMapLibre(), 100);
+ }
+ }
+ ngOnDestroy(): void {
+ if (this.maplibreMap) {
+ this.maplibreMap.remove();
+ this.maplibreMap = null;
+ }
}
getRegions() {
@@ -170,6 +192,16 @@ export class AdminStationsMapComponent implements OnInit {
const text = await this.apiService
.getStationsAdminMap(this.selectedRegions)
.toPromise();
+ this.stationsData = text;
+
+ if (this.mapProvider() === 'leaflet') {
+ this.setupLeafletMap(text, updateBounds);
+ } else {
+ this.setupMapLibreMap(text, updateBounds);
+ }
+ }
+
+ private setupLeafletMap(text: any[], updateBounds: boolean) {
const parent = this;
const markers = markerClusterGroup({
iconCreateFunction: (cluster) => {
@@ -225,6 +257,183 @@ export class AdminStationsMapComponent implements OnInit {
this.cd.detectChanges();
}
+ private initMapLibre() {
+ const container = this.maplibreContainer();
+ if (!container || this.maplibreMap) {
+ return;
+ }
+
+ const style = this.mapConfigService.getMapLibreStyle('OpenStreetMap Mat');
+
+ this.maplibreMap = new maplibregl.Map({
+ container: container.nativeElement,
+ style: style,
+ center: [5.5, 52.0],
+ zoom: 7,
+ transformRequest: this.mapConfigService.getMapLibreTransformRequest()
+ });
+
+ this.maplibreMap.on('load', () => {
+ this.maplibreMap!.addControl(new maplibregl.NavigationControl(), 'top-right');
+ if (this.stationsData && this.stationsData.length > 0) {
+ this.setupMapLibreMap(this.stationsData, true);
+ }
+ });
+ }
+
+ private setupMapLibreMap(text: any[], updateBounds: boolean) {
+ if (!this.maplibreMap || !this.maplibreMap.isStyleLoaded()) {
+ return;
+ }
+
+ // Create GeoJSON features
+ const features = text.map((station: any) => ({
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [station.longitude, station.lattitude]
+ },
+ properties: {
+ id: station.id,
+ hidden: station.hidden,
+ special: station.special,
+ ...station
+ }
+ }));
+
+ const geojson = {
+ type: 'FeatureCollection',
+ features: features
+ };
+
+ // Remove existing layers and source if they exist
+ if (this.maplibreMap.getLayer('clusters')) {
+ this.maplibreMap.removeLayer('clusters');
+ }
+ if (this.maplibreMap.getLayer('cluster-count')) {
+ this.maplibreMap.removeLayer('cluster-count');
+ }
+ if (this.maplibreMap.getLayer('unclustered-point')) {
+ this.maplibreMap.removeLayer('unclustered-point');
+ }
+ if (this.maplibreMap.getSource('stations')) {
+ this.maplibreMap.removeSource('stations');
+ }
+
+ // Add source
+ this.maplibreMap.addSource('stations', {
+ type: 'geojson',
+ data: geojson as any,
+ cluster: true,
+ clusterMaxZoom: 9,
+ clusterRadius: 40
+ });
+
+ // Add cluster circles
+ this.maplibreMap.addLayer({
+ id: 'clusters',
+ type: 'circle',
+ source: 'stations',
+ filter: ['has', 'point_count'],
+ paint: {
+ 'circle-color': '#FFA500',
+ 'circle-radius': [
+ 'step',
+ ['get', 'point_count'],
+ 15,
+ 10, 20,
+ 30, 25
+ ]
+ }
+ });
+
+ // Add cluster count
+ this.maplibreMap.addLayer({
+ id: 'cluster-count',
+ type: 'symbol',
+ source: 'stations',
+ filter: ['has', 'point_count'],
+ layout: {
+ 'text-field': '{point_count_abbreviated}',
+ 'text-font': ['Open Sans Bold'],
+ 'text-size': 12
+ }
+ });
+
+ // Add unclustered points
+ this.maplibreMap.addLayer({
+ id: 'unclustered-point',
+ type: 'circle',
+ source: 'stations',
+ filter: ['!', ['has', 'point_count']],
+ paint: {
+ 'circle-color': [
+ 'case',
+ ['get', 'hidden'], '#FF0000',
+ ['get', 'special'], '#0000FF',
+ '#00FF00'
+ ],
+ 'circle-radius': 6,
+ 'circle-stroke-width': 1,
+ 'circle-stroke-color': '#000',
+ 'circle-opacity': 0.65,
+ 'circle-stroke-opacity': 1
+ }
+ });
+
+ // Handle click on unclustered point
+ this.maplibreMap.on('click', 'unclustered-point', (e) => {
+ if (e.features && e.features.length > 0) {
+ const feature = e.features[0];
+ this.selectedStation.set(feature.properties as StationAdminProperties);
+ this.cd.detectChanges();
+ }
+ });
+
+ // Handle click on cluster
+ this.maplibreMap.on('click', 'clusters', (e) => {
+ const features = this.maplibreMap!.queryRenderedFeatures(e.point, {
+ layers: ['clusters']
+ });
+ const clusterId = features[0].properties?.cluster_id;
+ const source = this.maplibreMap!.getSource('stations') as maplibregl.GeoJSONSource;
+
+ (source as any).getClusterExpansionZoom(clusterId, (err: any, zoom: any) => {
+ if (err) return;
+ this.maplibreMap!.easeTo({
+ center: (features[0].geometry as any).coordinates,
+ zoom: zoom
+ });
+ });
+ });
+
+ // Change cursor on hover
+ this.maplibreMap.on('mouseenter', 'clusters', () => {
+ this.maplibreMap!.getCanvas().style.cursor = 'pointer';
+ });
+ this.maplibreMap.on('mouseleave', 'clusters', () => {
+ this.maplibreMap!.getCanvas().style.cursor = '';
+ });
+ this.maplibreMap.on('mouseenter', 'unclustered-point', () => {
+ this.maplibreMap!.getCanvas().style.cursor = 'pointer';
+ });
+ this.maplibreMap.on('mouseleave', 'unclustered-point', () => {
+ this.maplibreMap!.getCanvas().style.cursor = '';
+ });
+
+ // Fit bounds
+ if (updateBounds && features.length > 0) {
+ const bounds = new maplibregl.LngLatBounds();
+ features.forEach((feature: any) => {
+ bounds.extend(feature.geometry.coordinates);
+ });
+ this.maplibreMap.fitBounds(bounds, { padding: 50 });
+ }
+
+ this.loading = false;
+ this.cd.detectChanges();
+ }
+
isRegionChecked(id: number) {
return this.selectedRegions.includes(id);
}
diff --git a/OV_DB/OVDBFrontend/src/app/stations/station-map/station-map.component.html b/OV_DB/OVDBFrontend/src/app/stations/station-map/station-map.component.html
index bd167139..ab9d194e 100644
--- a/OV_DB/OVDBFrontend/src/app/stations/station-map/station-map.component.html
+++ b/OV_DB/OVDBFrontend/src/app/stations/station-map/station-map.component.html
@@ -5,14 +5,19 @@
}
-
+ @if (mapProvider() === 'leaflet') {
+
+ }
+ @if (mapProvider() === 'maplibre') {
+
+ }
diff --git a/OV_DB/OVDBFrontend/src/app/stations/station-map/station-map.component.ts b/OV_DB/OVDBFrontend/src/app/stations/station-map/station-map.component.ts
index 46818a9d..a969dab4 100644
--- a/OV_DB/OVDBFrontend/src/app/stations/station-map/station-map.component.ts
+++ b/OV_DB/OVDBFrontend/src/app/stations/station-map/station-map.component.ts
@@ -1,4 +1,4 @@
-import { ChangeDetectorRef, Component, OnInit, input, inject } from "@angular/core";
+import { ChangeDetectorRef, Component, OnInit, OnDestroy, input, inject, ElementRef, viewChild } from "@angular/core";
import {
LatLngBounds,
LatLng,
@@ -11,6 +11,9 @@ import {
import 'leaflet.markercluster';
import { ApiService } from "src/app/services/api.service";
import { TranslationService } from "src/app/services/translation.service";
+import { MapProviderService } from "src/app/services/map-provider.service";
+import { MapConfigService } from "src/app/services/map-config.service";
+import * as maplibregl from 'maplibre-gl';
import { LeafletModule } from "@bluehalo/ngx-leaflet";
import { NgClass } from "@angular/common";
import { MatProgressSpinner } from "@angular/material/progress-spinner";
@@ -27,11 +30,17 @@ import { LeafletMarkerClusterModule } from "@bluehalo/ngx-leaflet-markercluster"
LeafletMarkerClusterModule,
]
})
-export class StationMapComponent implements OnInit {
+export class StationMapComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private translationService = inject(TranslationService);
private cd = inject(ChangeDetectorRef);
+ private mapProviderService = inject(MapProviderService);
+ private mapConfigService = inject(MapConfigService);
+ readonly maplibreContainer = viewChild
>("maplibreContainer");
+ mapProvider = this.mapProviderService.currentProvider;
+ private maplibreMap: maplibregl.Map | null = null;
+ private stationsData: any = null;
baseLayers = {
OpenStreetMap: tileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
@@ -95,12 +104,23 @@ export class StationMapComponent implements OnInit {
}
ngOnInit(): void {
this.getData();
+ if (this.mapProvider() === 'maplibre') {
+ setTimeout(() => this.initMapLibre(), 100);
+ }
+ }
+
+ ngOnDestroy(): void {
+ if (this.maplibreMap) {
+ this.maplibreMap.remove();
+ this.maplibreMap = null;
+ }
}
async getData() {
this.loading = true;
const text = await this.apiService.getStationMap(this.guid()).toPromise();
+ this.stationsData = text;
const parent = this;
this.total = text.total;
this.visited = text.visited;
@@ -108,6 +128,15 @@ export class StationMapComponent implements OnInit {
name: text.name,
nameNL: text.nameNL,
};
+
+ if (this.mapProvider() === 'leaflet') {
+ this.setupLeafletMap(text);
+ } else {
+ this.setupMapLibreMap(text);
+ }
+ }
+
+ private setupLeafletMap(text: any) {
const markers = window.L.markerClusterGroup({
iconCreateFunction: (cluster) => {
return divIcon({
@@ -148,6 +177,7 @@ export class StationMapComponent implements OnInit {
};
markers.addLayer(marker);
});
+ const parent = this;
markers.addEventListener("click", async (f: LeafletEvent) => {
if (!f.propagatedFrom.feature.properties.visited) {
f.propagatedFrom.feature.properties.visited = true;
@@ -187,6 +217,207 @@ export class StationMapComponent implements OnInit {
this.loading = false;
}
+ private initMapLibre() {
+ const container = this.maplibreContainer();
+ if (!container || this.maplibreMap) {
+ return;
+ }
+
+ const style = this.mapConfigService.getMapLibreStyle('OpenStreetMap Mat');
+
+ this.maplibreMap = new maplibregl.Map({
+ container: container.nativeElement,
+ style: style,
+ center: [5.5, 52.0],
+ zoom: 7,
+ transformRequest: this.mapConfigService.getMapLibreTransformRequest()
+ });
+
+ this.maplibreMap.on('load', () => {
+ this.maplibreMap!.addControl(new maplibregl.NavigationControl(), 'top-right');
+ if (this.stationsData) {
+ this.setupMapLibreMap(this.stationsData);
+ }
+ });
+ }
+
+ private setupMapLibreMap(text: any) {
+ if (!this.maplibreMap || !this.maplibreMap.isStyleLoaded()) {
+ return;
+ }
+
+ // Create GeoJSON features from stations
+ const features = text.stations.map((station: any) => ({
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [station.longitude, station.lattitude]
+ },
+ properties: {
+ id: station.id,
+ visited: station.visited
+ }
+ }));
+
+ const geojson = {
+ type: 'FeatureCollection',
+ features: features
+ };
+
+ // Add source for stations
+ this.maplibreMap.addSource('stations', {
+ type: 'geojson',
+ data: geojson as any,
+ cluster: true,
+ clusterMaxZoom: 9, // Max zoom to cluster points on (disable clustering at zoom 10+)
+ clusterRadius: 40,
+ clusterProperties: {
+ // Count visited and total stations in each cluster
+ 'visited_count': ['+', ['case', ['get', 'visited'], 1, 0]],
+ 'total_count': ['+', 1]
+ }
+ });
+
+ // Add cluster circles with color based on visited status
+ this.maplibreMap.addLayer({
+ id: 'clusters',
+ type: 'circle',
+ source: 'stations',
+ filter: ['has', 'point_count'],
+ paint: {
+ 'circle-color': [
+ 'case',
+ // All visited (green)
+ ['==', ['get', 'visited_count'], ['get', 'total_count']], '#00FF00',
+ // None visited (red)
+ ['==', ['get', 'visited_count'], 0], '#FF0000',
+ // Partially visited (orange)
+ '#FFA500'
+ ],
+ 'circle-radius': [
+ 'step',
+ ['get', 'point_count'],
+ 15,
+ 10, 20,
+ 30, 25
+ ]
+ }
+ });
+
+ // Add cluster count
+ this.maplibreMap.addLayer({
+ id: 'cluster-count',
+ type: 'symbol',
+ source: 'stations',
+ filter: ['has', 'point_count'],
+ layout: {
+ 'text-field': '{point_count_abbreviated}',
+ 'text-font': ['Open Sans Bold'],
+ 'text-size': 12
+ }
+ });
+
+ // Add unclustered points
+ this.maplibreMap.addLayer({
+ id: 'unclustered-point',
+ type: 'circle',
+ source: 'stations',
+ filter: ['!', ['has', 'point_count']],
+ paint: {
+ 'circle-color': [
+ 'case',
+ ['get', 'visited'], '#00FF00',
+ '#FF0000'
+ ],
+ 'circle-radius': [
+ 'case',
+ ['get', 'visited'], 8,
+ 4
+ ],
+ 'circle-stroke-width': 1,
+ 'circle-stroke-color': '#000',
+ 'circle-opacity': 1,
+ 'circle-stroke-opacity': 1
+ }
+ });
+
+ // Handle click on unclustered point
+ this.maplibreMap.on('click', 'unclustered-point', async (e) => {
+ if (e.features && e.features.length > 0) {
+ const feature = e.features[0];
+ const stationId = feature.properties?.id;
+ const currentVisited = feature.properties?.visited;
+
+ if (stationId !== undefined) {
+ const newVisited = !currentVisited;
+
+ // Update via API
+ await this.apiService.updateStation(stationId, newVisited).toPromise();
+
+ // Update local state
+ if (newVisited) {
+ this.visited++;
+ } else {
+ this.visited--;
+ }
+ this.cd.detectChanges();
+
+ // Update the feature in the source
+ const source = this.maplibreMap!.getSource('stations') as maplibregl.GeoJSONSource;
+ const data = source._data as any;
+ const featureToUpdate = data.features.find((f: any) => f.properties.id === stationId);
+ if (featureToUpdate) {
+ featureToUpdate.properties.visited = newVisited;
+ source.setData(data);
+ }
+ }
+ }
+ });
+
+ // Handle click on cluster
+ this.maplibreMap.on('click', 'clusters', (e) => {
+ const features = this.maplibreMap!.queryRenderedFeatures(e.point, {
+ layers: ['clusters']
+ });
+ const clusterId = features[0].properties?.cluster_id;
+ const source = this.maplibreMap!.getSource('stations') as maplibregl.GeoJSONSource;
+
+ (source as any).getClusterExpansionZoom(clusterId, (err: any, zoom: any) => {
+ if (err) return;
+
+ this.maplibreMap!.easeTo({
+ center: (features[0].geometry as any).coordinates,
+ zoom: zoom
+ });
+ });
+ });
+
+ // Change cursor on hover
+ this.maplibreMap.on('mouseenter', 'clusters', () => {
+ this.maplibreMap!.getCanvas().style.cursor = 'pointer';
+ });
+ this.maplibreMap.on('mouseleave', 'clusters', () => {
+ this.maplibreMap!.getCanvas().style.cursor = '';
+ });
+ this.maplibreMap.on('mouseenter', 'unclustered-point', () => {
+ this.maplibreMap!.getCanvas().style.cursor = 'pointer';
+ });
+ this.maplibreMap.on('mouseleave', 'unclustered-point', () => {
+ this.maplibreMap!.getCanvas().style.cursor = '';
+ });
+
+ // Fit bounds
+ if (features.length > 0) {
+ const bounds = new maplibregl.LngLatBounds();
+ features.forEach((feature: any) => {
+ bounds.extend(feature.geometry.coordinates);
+ });
+ this.maplibreMap.fitBounds(bounds, { padding: 50 });
+ }
+
+ this.loading = false;
+ }
+
getName(object) {
return this.translationService.getNameForItem(object);
}
diff --git a/OV_DB/OVDBFrontend/src/app/stats/time-stats/time-stats.component.ts b/OV_DB/OVDBFrontend/src/app/stats/time-stats/time-stats.component.ts
index c7017916..164387f0 100644
--- a/OV_DB/OVDBFrontend/src/app/stats/time-stats/time-stats.component.ts
+++ b/OV_DB/OVDBFrontend/src/app/stats/time-stats/time-stats.component.ts
@@ -1,5 +1,5 @@
import { NgClass } from '@angular/common';
-import { Component, Signal, viewChild, OnInit, inject } from '@angular/core';
+import { Component, Signal, viewChild, OnInit, OnDestroy, inject, ElementRef } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { MatCard, MatCardContent, MatCardTitle } from '@angular/material/card';
@@ -14,6 +14,9 @@ import saveAs from 'file-saver';
import { LatLngBounds, LatLng, tileLayer, marker, icon, Rectangle } from 'leaflet';
import { ApiService } from 'src/app/services/api.service';
import { TranslationService } from 'src/app/services/translation.service';
+import { MapProviderService } from 'src/app/services/map-provider.service';
+import { MapConfigService } from 'src/app/services/map-config.service';
+import * as maplibregl from 'maplibre-gl';
import { Map } from 'src/app/models/map.model';
import { BaseChartDirective } from 'ng2-charts';
import 'chartjs-adapter-luxon';
@@ -24,11 +27,18 @@ import { MatTabsModule } from '@angular/material/tabs';
templateUrl: './time-stats.component.html',
styleUrl: './time-stats.component.scss'
})
-export class TimeStatsComponent implements OnInit {
+export class TimeStatsComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private translationService = inject(TranslationService);
+ private mapProviderService = inject(MapProviderService);
+ private mapConfigService = inject(MapConfigService);
translateService = inject(TranslateService);
+ readonly maplibreContainer = viewChild>('maplibreContainer');
+ mapProvider = this.mapProviderService.currentProvider;
+ private maplibreMap: maplibregl.Map | null = null;
+ private extremesData: any = null;
+
data: ChartConfiguration['data'];
singleData: any;
@@ -107,6 +117,12 @@ export class TimeStatsComponent implements OnInit {
});
}
+ ngOnDestroy(): void {
+ if (this.maplibreMap) {
+ this.maplibreMap.remove();
+ this.maplibreMap = null;
+ }
+ }
changeMap(mapGuid: string) {
this.selectedMap = mapGuid;
diff --git a/OV_DB/OVDBFrontend/src/assets/i18n/en.json b/OV_DB/OVDBFrontend/src/assets/i18n/en.json
index a90b9c48..45659d41 100644
--- a/OV_DB/OVDBFrontend/src/assets/i18n/en.json
+++ b/OV_DB/OVDBFrontend/src/assets/i18n/en.json
@@ -330,7 +330,8 @@
"TRAEWELLING_CONNECTED_SUCCESSFULLY": "Träwelling account connected successfully",
"TRAEWELLING_DISCONNECTED_SUCCESSFULLY": "Träwelling account disconnected successfully",
"TRAEWELLING_ERROR_CONNECTING": "Error connecting to Träwelling",
- "TRAEWELLING_ERROR_DISCONNECTING": "Error disconnecting from Träwelling"
+ "TRAEWELLING_ERROR_DISCONNECTING": "Error disconnecting from Träwelling",
+ "PREFERRED_MAP_PROVIDER": "Preferred map provider"
},
"TRAEWELLING": {
"TITLE": "Träwelling connection",
@@ -426,4 +427,4 @@
"END_TIME": "Arrival",
"DESCRIPTION": "Description"
}
-}
\ No newline at end of file
+}
diff --git a/OV_DB/OVDBFrontend/src/assets/i18n/nl.json b/OV_DB/OVDBFrontend/src/assets/i18n/nl.json
index 16181f5c..0ff1bac6 100644
--- a/OV_DB/OVDBFrontend/src/assets/i18n/nl.json
+++ b/OV_DB/OVDBFrontend/src/assets/i18n/nl.json
@@ -331,7 +331,8 @@
"TRAEWELLING_CONNECTED_SUCCESSFULLY": "Träwelling account succesvol verbonden",
"TRAEWELLING_DISCONNECTED_SUCCESSFULLY": "Träwelling account succesvol ontkoppeld",
"TRAEWELLING_ERROR_CONNECTING": "Fout bij verbinden met Träwelling",
- "TRAEWELLING_ERROR_DISCONNECTING": "Fout bij ontkoppelen van Träwelling"
+ "TRAEWELLING_ERROR_DISCONNECTING": "Fout bij ontkoppelen van Träwelling",
+ "PREFERRED_MAP_PROVIDER": "Voorkeur kaartprovider"
},
"TRAEWELLING": {
"TITLE": "Träwelling koppeling",
@@ -427,4 +428,4 @@
"END_TIME": "Aankomst",
"DESCRIPTION": "Beschrijving"
}
-}
\ No newline at end of file
+}
diff --git a/OV_DB/Startup.cs b/OV_DB/Startup.cs
index ff973b4a..4a8c8603 100644
--- a/OV_DB/Startup.cs
+++ b/OV_DB/Startup.cs
@@ -122,8 +122,7 @@ public void ConfigureServices(IServiceCollection services)
options.JsonSerializerOptions.Converters.Add(new NetTopologySuite.IO.Converters.GeoJsonConverterFactory());
});
- // Add response caching
- services.AddResponseCaching();
+ services.AddResponseCompression();
services.AddSpaStaticFiles(configuration =>
{
diff --git a/docs/maplibre-integration.md b/docs/maplibre-integration.md
new file mode 100644
index 00000000..3ecf2806
--- /dev/null
+++ b/docs/maplibre-integration.md
@@ -0,0 +1,78 @@
+# MapLibre Integration
+
+This document describes the MapLibre integration added to OVDB.
+
+## Overview
+
+Users can now toggle between Leaflet and MapLibre GL as their preferred map provider. The preference is saved to the database and persists across sessions.
+
+## Implementation
+
+### Backend
+
+1. **Database Model** (`OVDB_database/Models/User.cs`)
+ - Added `PreferredMapProvider` enum field to User model
+ - Migration: `20251114130000_AddPreferredMapProvider.cs`
+
+2. **API** (`OV_DB/Controllers/UserController.cs`)
+ - `GET /api/user/profile` - Returns user's map provider preference
+ - `PUT /api/user/profile` - Updates user's map provider preference
+
+3. **Enums** (`OVDB_database/Enums/PreferredMapProvider.cs`)
+ - Leaflet = 0
+ - MapLibre = 1
+
+### Frontend
+
+1. **Services**
+ - `MapProviderService`: Manages current map provider state
+ - `MapConfigService`: Provides layer configurations for both providers
+ - `UserPreferenceService`: Loads and applies user preferences
+
+2. **Components Updated**
+ - `map.component`: Main routes map - Full MapLibre support with toggle
+ - `single-route-map.component`: Single route view - Full MapLibre support with toggle
+ - `profile.component`: User can select preferred map provider
+
+3. **Features**
+ - Toggle button on maps to switch providers (when logged in)
+ - Same tile layers (OSM, Esri) available for both providers
+ - Referrer header sent to OSM tiles as required
+ - Route visualization with popups works on both providers
+ - User preference saved to database
+
+## Components Not Yet Updated
+
+The following components still use Leaflet only (can be updated in future):
+
+- `station-map.component`: Station map with clustering
+- `admin-stations-map.component`: Admin station map
+- `time-stats.component`: Statistics map with markers
+
+These components require clustering support which needs additional work with MapLibre's clustering approach.
+
+## Testing
+
+To test:
+
+1. Login to OVDB
+2. Go to Profile page
+3. Select "MapLibre" as preferred map provider
+4. Save profile
+5. Navigate to a map page
+6. Click the map toggle button (map icon) to switch between providers
+7. Preference persists across page loads
+
+## Technical Notes
+
+- MapLibre GL v5.12.0 is used
+- Clustering for station maps would require implementing with GeoJSON sources and expressions
+- MapLibre uses WebGL for rendering (better performance for large datasets)
+- Leaflet uses Canvas/SVG (better browser compatibility)
+
+## Future Enhancements
+
+1. Add MapLibre clustering to station map components
+2. Add more base layer options
+3. Add 3D terrain support (MapLibre exclusive feature)
+4. Add custom styling options