tethys.backend/resources/js/Components/Map/map.component.vue
Arno Kaimbacher 4229001572
Some checks failed
build.yaml / Enhance Map Zoom Control and Improve Map Page Layout (push) Failing after 0s
Enhance Map Zoom Control and Improve Map Page Layout
- Refactored zoom control component for better accessibility and styling.
- Added hover effects and improved button states for zoom in/out buttons.
- Updated map page layout with enhanced dataset card design and responsive styles.
- Introduced empty state for no datasets found and improved results header.
- Added icons for dataset cards and improved author display.
2025-11-05 13:15:23 +01:00

361 lines
No EOL
13 KiB
Vue

<template>
<div class="relative w-full">
<!-- Map Container -->
<div
:id="mapId"
class="relative h-[600px] w-full bg-gray-50 dark:bg-gray-900 rounded-xl overflow-hidden"
>
<div class="relative w-full h-full">
<ZoomControlComponent ref="zoom" :mapId="mapId" />
<DrawControlComponent ref="draw" :mapId="mapId" :southWest="southWest" :northEast="northEast" />
</div>
</div>
<!-- Validate Button -->
<div class="absolute left-4 top-44 z-[1000] select-none">
<button
class="group flex items-center justify-center relative overflow-visible outline-none font-semibold text-sm transition-all duration-300 ease-in-out
w-10 h-10 rounded-xl border-2 shadow-md
focus-visible:outline focus-visible:outline-3 focus-visible:outline-offset-2"
:class="[
validBoundingBox
? 'bg-gradient-to-br from-lime-500 to-lime-700 border-lime-700 text-white shadow-lime-500/40 cursor-default gap-2 w-auto px-4 focus-visible:outline-lime-500/50'
: 'bg-white dark:bg-gray-800 border-red-500 text-red-600 dark:text-red-400 gap-0 hover:bg-red-50 dark:hover:bg-gray-900 hover:border-red-500 hover:text-red-700 dark:hover:text-red-300 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-red-500/30 hover:w-auto hover:px-4 hover:gap-2 focus-visible:outline-red-500/50'
]"
type="button"
@click.stop.prevent="validateBoundingBox"
:aria-label="validBoundingBox ? 'Bounding box is valid' : 'Validate bounding box'"
>
<!-- Icon -->
<BaseIcon
v-if="mdiMapCheckOutline"
:path="mdiMapCheckOutline"
:size="20"
:class="[
'transition-transform duration-300',
validBoundingBox && 'animate-[checkPulse_2s_ease-in-out_infinite]'
]"
/>
<!-- Status badge -->
<span
class="text-xs font-bold uppercase tracking-wider whitespace-nowrap transition-all duration-300 overflow-hidden"
:class="[
validBoundingBox
? 'max-w-[100px] opacity-100 text-white drop-shadow'
: 'max-w-0 opacity-0 group-hover:max-w-[100px] group-hover:opacity-100'
]"
>
{{ label }}
</span>
<!-- Pulse animation for valid state -->
<span
v-if="validBoundingBox"
class="absolute top-1/2 left-1/2 w-full h-full bg-white/30 rounded-xl -translate-x-1/2 -translate-y-1/2 animate-[pulse_2s_ease-out_infinite] pointer-events-none"
></span>
<!-- Ripple effect on click -->
<span
class="absolute top-1/2 left-1/2 w-0 h-0 rounded-full bg-white/30 -translate-x-1/2 -translate-y-1/2 transition-all duration-[600ms] active:w-[300px] active:h-[300px]"
></span>
<!-- Tooltip -->
<span
v-if="!validBoundingBox"
class="absolute left-[calc(100%+0.5rem)] top-1/2 -translate-y-1/2 px-3 py-1.5 bg-gray-800 text-white text-xs rounded-md whitespace-nowrap opacity-0 pointer-events-none transition-opacity duration-200 z-[1001] group-hover:opacity-100 group-hover:animate-[fadeInTooltip_0.2s_ease_0.5s_forwards]"
>
Click to validate
</span>
</button>
</div>
</div>
</template>
<script lang="ts">
import { EventEmitter } from './EventEmitter';
import { Component, Vue, Prop, Ref } from 'vue-facing-decorator';
import { Map } from 'leaflet/src/map/index';
import { Control } from 'leaflet/src/control/Control';
import { LatLngBoundsExpression, LatLngBounds } from 'leaflet/src/geo/LatLngBounds';
import { LatLng } from 'leaflet';
import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS';
import { Attribution } from 'leaflet/src/control/Control.Attribution';
import { mdiMapCheckOutline } from '@mdi/js';
import BaseIcon from '@/Components/BaseIcon.vue';
import { MapOptions } from './MapOptions';
import { LayerOptions, LayerMap } from './LayerOptions';
import { MapService } from '@/Stores/map.service';
import { ZoomControlComponent } from './zoom.component.vue';
import { DrawControlComponent } from './draw.component.vue';
import { Coverage } from '@/Dataset';
import { canvas } from 'leaflet/src/layer/vector/Canvas';
import { svg } from 'leaflet/src/layer/vector/SVG';
import Notification from '@/utils/toast';
Map.include({
getRenderer: function (layer) {
var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer;
if (!renderer) {
renderer = this._renderer = this._createRenderer();
}
if (!this.hasLayer(renderer)) {
this.addLayer(renderer);
}
return renderer;
},
_getPaneRenderer: function (name) {
if (name === 'overlayPane' || name === undefined) {
return false;
}
var renderer = this._paneRenderers[name];
if (renderer === undefined) {
renderer = this._createRenderer({ pane: name });
this._paneRenderers[name] = renderer;
}
return renderer;
},
_createRenderer: function (options) {
return (this.options.preferCanvas && canvas(options)) || svg(options);
},
});
const DEFAULT_BASE_LAYER_NAME = 'BaseLayer';
const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
@Component({
name: 'MapComponent',
components: {
ZoomControlComponent,
DrawControlComponent,
BaseIcon,
},
})
export class MapComponent extends Vue {
@Prop()
public mapId: string;
@Prop()
public mapOptions: MapOptions;
@Prop()
public coverage: Coverage;
@Prop({ default: null })
public fitBounds: LatLngBoundsExpression;
@Prop()
public zoomControlOptions: Control.ZoomOptions;
@Prop()
public baseMaps: LayerMap;
get label(): string {
return this.validBoundingBox ? 'Valid' : 'Invalid';
}
get validBoundingBox(): boolean {
let isValidNumber =
(typeof this.coverage.x_min === 'number' || !isNaN(Number(this.coverage.x_min))) &&
(typeof this.coverage.y_min === 'number' || !isNaN(Number(this.coverage.y_min))) &&
(typeof this.coverage.x_max === 'number' || !isNaN(Number(this.coverage.x_max))) &&
(typeof this.coverage.y_max === 'number' || !isNaN(Number(this.coverage.y_max)));
let isBoundValid = true;
if (isValidNumber) {
let _southWest: LatLng = new LatLng(this.coverage.y_min!, this.coverage.x_min!);
let _northEast: LatLng = new LatLng(this.coverage.y_max!, this.coverage.x_max!);
const bounds = new LatLngBounds(this.southWest, this.northEast);
if (!bounds.isValid() || !(_southWest.lat < _northEast.lat && _southWest.lng < _northEast.lng)) {
isBoundValid = false;
}
}
return isValidNumber && isBoundValid;
}
@Ref('zoom')
private zoom: ZoomControlComponent;
@Ref('draw')
private draw: DrawControlComponent;
mapService = MapService();
mdiMapCheckOutline = mdiMapCheckOutline;
southWest: LatLng;
northEast: LatLng;
public onMapInitializedEvent: EventEmitter<string> = new EventEmitter<string>();
public map!: Map;
validateBoundingBox() {
if (this.validBoundingBox == false) {
this.draw.removeShape();
Notification.showError('Bounds are not valid.');
return;
}
this.map.control && this.map.control.disable();
var _this = this;
let _southWest: LatLng = new LatLng(this.coverage.y_min!, this.coverage.x_min!);
let _northEast: LatLng = new LatLng(this.coverage.y_max!, this.coverage.x_max!);
const bounds = new LatLngBounds(this.southWest, this.northEast);
if (!bounds.isValid() || !(_southWest.lat < _northEast.lat && _southWest.lng < _northEast.lng)) {
this.draw.removeShape();
Notification.showTemporary('Bounds are not valid.');
} else {
try {
this.draw.drawShape(_southWest, _northEast);
_this.map.fitBounds(bounds);
Notification.showSuccess('Valid bounding box');
} catch (err) {
Notification.showTemporary('An error occurred while drawing bounding box');
throw err;
}
}
}
mounted(): void {
this.initMap();
}
unmounted() {
this.map.off('zoomend zoomlevelschange');
}
protected initMap(): void {
let map: Map = (this.map = new Map(this.mapId, this.mapOptions));
this.mapService.setMap(this.mapId, map);
map.scrollWheelZoom.disable();
this.onMapInitializedEvent.emit(this.mapId);
this.addBaseMap();
const attributionControl = new Attribution().addTo(this.map);
attributionControl.setPrefix(false);
map.on(
'Draw.Event.CREATED',
(event: any) => {
var layer = event.layer;
var bounds = layer.getBounds();
this.coverage.x_min = bounds.getSouthWest().lng;
this.coverage.y_min = bounds.getSouthWest().lat;
this.coverage.x_max = bounds.getNorthEast().lng;
this.coverage.y_max = bounds.getNorthEast().lat;
},
);
this.map.on('zoomend zoomlevelschange', this.zoom.updateDisabled, this.zoom);
if (this.coverage.x_min && this.coverage.y_min) {
this.southWest = new LatLng(this.coverage.y_min!, this.coverage.x_min!);
} else {
this.southWest = new LatLng(46.5, 9.9);
}
if (this.coverage.x_max && this.coverage.y_max) {
this.northEast = new LatLng(this.coverage.y_max!, this.coverage.x_max!);
} else {
this.northEast = new LatLng(48.9, 16.9);
}
const bounds = new LatLngBounds(this.southWest, this.northEast);
map.fitBounds(bounds);
if (this.coverage.x_min && this.coverage.x_max && this.coverage.y_min && this.coverage.y_max) {
let _southWest: LatLng;
let _northEast: LatLng;
if (this.coverage.x_min && this.coverage.y_min) {
_southWest = new LatLng(this.coverage.y_min, this.coverage.x_min);
}
if (this.coverage.x_max && this.coverage.y_max) {
_northEast = new LatLng(this.coverage.y_max, this.coverage.x_max);
}
this.draw.drawShape(_southWest, _northEast);
}
}
private addBaseMap(layerOptions?: LayerOptions): void {
if (this.map) {
if (!this.baseMaps || this.baseMaps.size === 0) {
let osmGgray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', {
format: 'image/png',
attribution: DEFAULT_BASE_LAYER_ATTRIBUTION,
layers: 'OSM-WMS',
});
layerOptions = {
label: DEFAULT_BASE_LAYER_NAME,
visible: true,
layer: osmGgray,
};
layerOptions.layer.addTo(this.map);
}
}
}
}
export default MapComponent;
</script>
<style scoped>
/* Leaflet container - only what can't be done with Tailwind */
:deep(.leaflet-container) {
height: 600px;
width: 100%;
background: transparent;
}
:deep(.leaflet-container .leaflet-pane) {
z-index: 30 !important;
}
/* Custom animations */
@keyframes checkPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes pulse {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
}
@keyframes fadeInTooltip {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
:deep(.leaflet-container) {
height: 100%;
}
}
@media (max-width: 640px) {
:deep(.leaflet-container) {
height: 100%;
}
}
</style>