Enhance Map Zoom Control and Improve Map Page Layout
Some checks failed
build.yaml / Enhance Map Zoom Control and Improve Map Page Layout (push) Failing after 0s

- 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.
This commit is contained in:
Kaimbacher 2025-11-05 13:15:23 +01:00
commit 4229001572
7 changed files with 1520 additions and 452 deletions

18
components.d.ts vendored
View file

@ -11,3 +11,21 @@ declare module '@vue/runtime-core' {
NInput: (typeof import('naive-ui'))['NInput']; NInput: (typeof import('naive-ui'))['NInput'];
} }
} }
// types/leaflet-src-dom-DomEvent.d.ts
declare module 'leaflet/src/dom/DomEvent' {
export type DomEventHandler = (e?: any) => void;
// Attach event listeners. `obj` can be any DOM node or object with event handling.
export function on(obj: any, types: string, fn: DomEventHandler, context?: any): void;
// Detach event listeners.
export function off(obj: any, types: string, fn?: DomEventHandler, context?: any): void;
// Prevent default on native events
export function preventDefault(ev?: Event | undefined): void;
// Optional: other helpers you might need later
export function stopPropagation(ev?: Event | undefined): void;
export function stop(ev?: Event | undefined): void;
}

View file

@ -1,7 +1,11 @@
import type { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds'; // import type { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds';
import type { LatLngExpression } from 'leaflet/src/geo/LatLng'; // import type { LatLngExpression } from 'leaflet/src/geo/LatLng';
import type { Layer } from 'leaflet/src/layer/Layer'; // import type { Layer } from 'leaflet/src/layer/Layer';
import type { CRS } from 'leaflet/src/geo/crs/CRS'; // import type { CRS } from 'leaflet/src/geo/crs/CRS';
import type { LatLngBoundsExpression } from 'leaflet';
import type { LatLngExpression } from 'leaflet';
import type { Layer } from 'leaflet';
import type { CRS } from 'leaflet';
export interface MapOptions { export interface MapOptions {
preferCanvas?: boolean | undefined; preferCanvas?: boolean | undefined;

View file

@ -8,7 +8,6 @@ import { svg } from 'leaflet/src/layer/vector/SVG';
import axios from 'axios'; import axios from 'axios';
import { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds'; import { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds';
import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS'; import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS';
// import { TileLayer } from 'leaflet/src/layer/tile/TileLayer';
import { Attribution } from 'leaflet/src/control/Control.Attribution'; import { Attribution } from 'leaflet/src/control/Control.Attribution';
import DrawControlComponent from '@/Components/Map/draw.component.vue'; import DrawControlComponent from '@/Components/Map/draw.component.vue';
import ZoomControlComponent from '@/Components/Map/zoom.component.vue'; import ZoomControlComponent from '@/Components/Map/zoom.component.vue';
@ -17,14 +16,7 @@ import { LayerGroup } from 'leaflet/src/layer/LayerGroup';
import { OpensearchDocument } from '@/Dataset'; import { OpensearchDocument } from '@/Dataset';
Map.include({ Map.include({
// @namespace Map; @method getRenderer(layer: Path): Renderer
// Returns the instance of `Renderer` that should be used to render the given
// `Path`. It will ensure that the `renderer` options of the map and paths
// are respected, and that the renderers do exist on the map.
getRenderer: function (layer) { getRenderer: function (layer) {
// @namespace Path; @option renderer: Renderer
// Use this specific instance of `Renderer` for this path. Takes
// precedence over the map's [default renderer](#map-renderer).
var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer; var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer;
if (!renderer) { if (!renderer) {
@ -51,21 +43,18 @@ Map.include({
}, },
_createRenderer: function (options) { _createRenderer: function (options) {
// @namespace Map; @option preferCanvas: Boolean = false
// Whether `Path`s should be rendered on a `Canvas` renderer.
// By default, all `Path`s are rendered in a `SVG` renderer.
return (this.options.preferCanvas && canvas(options)) || svg(options); return (this.options.preferCanvas && canvas(options)) || svg(options);
}, },
}); });
const DEFAULT_BASE_LAYER_NAME = 'BaseLayer'; const DEFAULT_BASE_LAYER_NAME = 'BaseLayer';
const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors'; const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
const OPENSEARCH_HOST = 'https://catalog.geosphere.at'; const OPENSEARCH_HOST = 'https://catalog.geosphere.at';
// const OPENSEARCH_HOST = `${process.env.OPENSEARCH_HOST}`;
// const OPENSEARCH_HOST = `http://${process.env.OPENSEARCH_PUBLIC_HOST}`;
let map: Map; let map: Map;
const props = defineProps({ const props = defineProps({
dheckable: Boolean, checkable: Boolean,
datasets: { datasets: {
type: Array<OpensearchDocument>, type: Array<OpensearchDocument>,
default: () => [], default: () => [],
@ -89,10 +78,7 @@ const items = computed({
get() { get() {
return props.datasets; return props.datasets;
}, },
// setter
set(value) { set(value) {
// Note: we are using destructuring assignment syntax here.
props.datasets.length = 0; props.datasets.length = 0;
props.datasets.push(...value); props.datasets.push(...value);
}, },
@ -103,15 +89,13 @@ const fitBounds: LatLngBoundsExpression = [
[49.0390742051, 16.9796667823], [49.0390742051, 16.9796667823],
]; ];
// const mapId = 'map';
const drawControl: Ref<DrawControlComponent | null> = ref(null); const drawControl: Ref<DrawControlComponent | null> = ref(null);
const southWest = ref(null); const southWest = ref(null);
const northEast = ref(null); const northEast = ref(null);
const mapService = MapService(); const mapService = MapService();
const isLoading = ref(false);
const filterLayerGroup = new LayerGroup(); const filterLayerGroup = new LayerGroup();
// Replace with your actual data
// const datasets: Ref<OpensearchDocument[]> = ref([]);
onMounted(() => { onMounted(() => {
initMap(); initMap();
@ -122,7 +106,6 @@ onUnmounted(() => {
}); });
const initMap = async () => { const initMap = async () => {
// init leaflet map
map = new Map('map', props.mapOptions); map = new Map('map', props.mapOptions);
mapService.setMap(props.mapId, map); mapService.setMap(props.mapId, map);
map.scrollWheelZoom.disable(); map.scrollWheelZoom.disable();
@ -140,11 +123,6 @@ const initMap = async () => {
layers: 'OSM-WMS', layers: 'OSM-WMS',
}); });
// let baseAt = new TileLayer('https://{s}.wien.gv.at/basemap/bmapgrau/normal/google3857/{z}/{y}/{x}.png', {
// subdomains: ['maps', 'maps1', 'maps2', 'maps3', 'maps4'],
// attribution: DEFAULT_BASE_LAYER_ATTRIBUTION,
// });
let layerOptions = { let layerOptions = {
label: DEFAULT_BASE_LAYER_NAME, label: DEFAULT_BASE_LAYER_NAME,
visible: true, visible: true,
@ -153,62 +131,15 @@ const initMap = async () => {
layerOptions.layer.addTo(map); layerOptions.layer.addTo(map);
map.on('Draw.Event.CREATED', handleDrawEventCreated); map.on('Draw.Event.CREATED', handleDrawEventCreated);
// // const query = {
// // query: {
// // term: {
// // id: "103"
// // }
// // }
// // };
// // to do : call extra method:
// const query = {
// // q: 'id:103'
// // q: 'author:"Iglseder, Christoph" OR title:"Datensatz"',
// // q: 'author:"Iglseder"',
// q: '*',
// _source: 'author,bbox_xmin,bbox_xmax,bbox_ymin,bbox_ymax,abstract,title',
// size: 1000
// // qf:"title^3 author^2 subject^1",
// }
// try {
// let response = await axios({
// method: 'GET',
// url: OPEN_SEARCH_HOST + '/tethys-records/_search',
// headers: { 'Content-Type': 'application/json' },
// params: query
// });
// // Loop through the hits in the response
// response.data.hits.hits.forEach(hit => {
// // Get the geo_location attribute
// // var geo_location = hit._source.geo_location;
// let xMin = hit._source.bbox_xmin;
// let xMax = hit._source.bbox_xmax;
// let yMin = hit._source.bbox_ymin;
// let yMax = hit._source.bbox_ymax;
// var bbox: LatLngBoundsExpression = [[yMin, xMin], [yMax, xMax]];
// // Parse the WKT string to get the bounding box coordinates
// // var bbox = wktToBbox(geo_location);
// // // Add the bounding box to the map as a rectangle
// new Rectangle(bbox, { color: "#ff7800", weight: 1 }).addTo(map);
// // console.log(hit._source);
// });
// } catch (error) {
// console.error(error);
// }
}; };
const handleDrawEventCreated = async (event) => { const handleDrawEventCreated = async (event) => {
isLoading.value = true;
filterLayerGroup.clearLayers(); filterLayerGroup.clearLayers();
items.value = []; items.value = [];
let layer = event.layer; let layer = event.layer;
let bounds = layer.getBounds(); let bounds = layer.getBounds();
// coverage.x_min = bounds.getSouthWest().lng;
// coverage.y_min = bounds.getSouthWest().lat;
// coverage.x_max = bounds.getNorthEast().lng;
// coverage.y_max = bounds.getNorthEast().lat;
try { try {
let response = await axios({ let response = await axios({
@ -225,7 +156,6 @@ const handleDrawEventCreated = async (event) => {
filter: { filter: {
geo_shape: { geo_shape: {
geo_location: { geo_location: {
// replace 'location' with your geo-point field name
shape: { shape: {
type: 'envelope', type: 'envelope',
coordinates: [ coordinates: [
@ -237,16 +167,12 @@ const handleDrawEventCreated = async (event) => {
}, },
}, },
}, },
// _source: 'author,bbox_xmin,bbox_xmax,bbox_ymin,bbox_ymax,abstract,title',
// "size": 1000
}, },
}, },
}, },
}); });
// Loop through the hits in the response
response.data.hits.hits.forEach((hit) => { response.data.hits.hits.forEach((hit) => {
// Get the geo_location attribute
// var geo_location = hit._source.geo_location;
let xMin = hit._source.bbox_xmin; let xMin = hit._source.bbox_xmin;
let xMax = hit._source.bbox_xmax; let xMax = hit._source.bbox_xmax;
let yMin = hit._source.bbox_ymin; let yMin = hit._source.bbox_ymin;
@ -255,46 +181,255 @@ const handleDrawEventCreated = async (event) => {
[yMin, xMin], [yMin, xMin],
[yMax, xMax], [yMax, xMax],
]; ];
// Parse the WKT string to get the bounding box coordinates
// var bbox = wktToBbox(geo_location);
// // Add the bounding box to the map as a rectangle let rect = new Rectangle(bbox, {
let rect = new Rectangle(bbox, { color: '#ff7800', weight: 1 }); color: '#65DC21',
weight: 2,
fillColor: '#65DC21',
fillOpacity: 0.2,
className: 'animated-rectangle',
});
filterLayerGroup.addLayer(rect); filterLayerGroup.addLayer(rect);
// add to result list
items.value.push(hit._source); items.value.push(hit._source);
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally {
isLoading.value = false;
} }
}; };
</script> </script>
<template> <template>
<SectionMain> <SectionMain>
<div id="map" class="map-container mt-6 mb-6 rounded-2xl py-12 px-6 text-center dark:bg-slate-900 bg-white"> <div class="map-container-wrapper">
<!-- Loading Overlay -->
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
<p class="loading-text">Searching datasets...</p>
</div>
<!-- Map Instructions Banner -->
<div class="map-instructions">
<svg class="instruction-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</svg>
<p class="instruction-text">
<strong>Tip:</strong> Use the drawing tool to select an area on the map and discover datasets
</p>
</div>
<div id="map" class="map-container">
<ZoomControlComponent ref="zoomControl" :mapId="mapId"></ZoomControlComponent> <ZoomControlComponent ref="zoomControl" :mapId="mapId"></ZoomControlComponent>
<DrawControlComponent ref="drawControl" :preserve="false" :mapId="mapId" :southWest="southWest" <DrawControlComponent ref="drawControl" :preserve="false" :mapId="mapId" :southWest="southWest" :northEast="northEast">
:northEast="northEast">
</DrawControlComponent> </DrawControlComponent>
</div> </div>
</div>
</SectionMain> </SectionMain>
</template> </template>
<style scoped>
<style scoped lang="css"> .map-container-wrapper {
/* .leaflet-container { position: relative;
height: 600px; border-radius: 1rem;
width: 100%; overflow: hidden;
background-color: transparent; background: white;
outline-offset: 1px; box-shadow:
} */ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
.leaflet-container { 0 2px 4px -1px rgba(0, 0, 0, 0.06);
height: 600px;
width: 100%;
} }
.leaflet-container .leaflet-pane { .dark .map-container-wrapper {
z-index: 30!important; background: #1f2937;
}
/* Map Instructions Banner */
.map-instructions {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%);
border-bottom: 2px solid #e5e7eb;
}
.dark .map-instructions {
background: linear-gradient(135deg, rgba(101, 220, 33, 0.2) 0%, rgba(53, 124, 6, 0.2) 100%);
border-bottom-color: #374151;
}
.instruction-icon {
width: 1.5rem;
height: 1.5rem;
color: #65dc21;
flex-shrink: 0;
}
.instruction-text {
font-size: 0.875rem;
color: #4b5563;
margin: 0;
}
.dark .instruction-text {
color: #d1d5db;
}
.instruction-text strong {
color: #65dc21;
font-weight: 600;
}
/* Loading Overlay */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.dark .loading-overlay {
background: rgba(31, 41, 55, 0.95);
}
.loading-spinner {
width: 3rem;
height: 3rem;
border: 4px solid #e5e7eb;
border-top-color: #65dc21;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.dark .loading-spinner {
border-color: #374151;
border-top-color: #65dc21;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
margin-top: 1rem;
font-size: 0.875rem;
font-weight: 600;
color: #65dc21;
}
/* Map Container */
.map-container {
position: relative;
height: 600px;
width: 100%;
background: #f9fafb;
}
.dark .map-container {
background: #111827;
}
/* Leaflet Overrides */
:deep(.leaflet-container) {
height: 600px;
width: 100%;
background: transparent;
font-family: inherit;
}
:deep(.leaflet-container .leaflet-pane) {
z-index: 30 !important;
}
/* Enhanced Rectangle Styling */
:deep(.animated-rectangle) {
animation: pulseRectangle 2s ease-in-out infinite;
}
@keyframes pulseRectangle {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* Control Enhancements */
:deep(.leaflet-control) {
border-radius: 0.5rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: none;
}
:deep(.leaflet-bar a) {
border-radius: 0.5rem;
transition: all 0.2s ease;
}
:deep(.leaflet-bar a:hover) {
background: #65dc21;
color: white;
}
:deep(.leaflet-draw-toolbar a) {
background: white;
transition: all 0.2s ease;
}
.dark :deep(.leaflet-draw-toolbar a) {
background: #374151;
color: #d1d5db;
}
:deep(.leaflet-draw-toolbar a:hover) {
background: #65dc21;
}
/* Popup Enhancements */
:deep(.leaflet-popup-content-wrapper) {
border-radius: 0.75rem;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:deep(.leaflet-popup-tip) {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Responsive Design */
@media (max-width: 768px) {
.map-container {
height: 400px;
}
.map-instructions {
padding: 0.75rem 1rem;
}
.instruction-text {
font-size: 0.8125rem;
}
}
@media (max-width: 640px) {
.map-container {
height: 350px;
}
} }
</style> </style>

View file

@ -1,14 +1,29 @@
<template> <template>
<div ref="drawControl" class="gba-control-draw btn-group-vertical map-control"> <div class="draw-control-container">
<!-- <button type="button" class="button is-light is-small" (click)="locateUser()" [ngClass]="isToggled ? 'is-primary': 'is-active'"> <button
<fa-icon [icon]="faSearchLocation"></fa-icon> ref="drawButton"
</button> --> class="draw-button"
<!-- --> :class="{ 'is-active': enabled }"
<button ref="inputDraw" type="button"
class="inline-flex cursor-pointer justify-center items-center whitespace-nowrap focus:outline-none transition-colors duration-150 border rounded ring-blue-700 text-black border-teal-50 hover:bg-gray-200 text-sm p-1" @click.stop.prevent="toggleDraw"
type="button" :class="[_enabled ? 'cursor-not-allowed bg-cyan-200' : 'bg-teal-50 is-active']" :aria-label="enabled ? 'Stop drawing' : 'Start drawing'"
@click.prevent="toggleDraw"> :aria-pressed="enabled"
<BaseIcon v-if="mdiDrawPen" :path="mdiDrawPen" /> >
<!-- Icon changes based on state -->
<!-- <BaseIcon
v-if="enabled"
:path="mdiClose"
:size="20"
/> -->
<BaseIcon
:path="mdiVectorRectangle"
:size="20"
/>
<!-- Status indicator -->
<!-- <span class="draw-status-badge" :class="{ 'is-active': enabled }">
{{ enabled ? 'Active' : 'Draw' }}
</span> -->
</button> </button>
</div> </div>
</template> </template>
@ -17,16 +32,14 @@
import { Component, Vue, Prop } from 'vue-facing-decorator'; import { Component, Vue, Prop } from 'vue-facing-decorator';
import BaseIcon from '@/Components/BaseIcon.vue'; import BaseIcon from '@/Components/BaseIcon.vue';
import { mdiDrawPen } from '@mdi/js'; import { mdiVectorRectangle, mdiClose } from '@mdi/js';
import { MapService } from '@/Stores/map.service'; import { MapService } from '@/Stores/map.service';
import { Map } from 'leaflet/src/map/index'; import { Map } from 'leaflet';
// import { LayerGroup } from 'leaflet/src/layer/LayerGroup';
// import { LatLngBounds, Rectangle } from 'leaflet';
import { on, off, preventDefault } from 'leaflet/src/dom/DomEvent'; import { on, off, preventDefault } from 'leaflet/src/dom/DomEvent';
import { Rectangle } from 'leaflet/src/layer/vector/Rectangle';
import { LatLngBounds } from 'leaflet/src/geo/LatLngBounds'; import { Rectangle } from 'leaflet';
import { LatLngBounds } from 'leaflet';
import { LatLng } from 'leaflet'; import { LatLng } from 'leaflet';
import { LeafletMouseEvent } from 'leaflet';
@Component({ @Component({
name: 'draw-control', name: 'draw-control',
@ -34,19 +47,19 @@ import { LeafletMouseEvent } from 'leaflet';
BaseIcon, BaseIcon,
}, },
}) })
export default class DrawControlComponent extends Vue { export class DrawControlComponent extends Vue {
public TYPE = 'rectangle'; public TYPE = 'rectangle';
mdiDrawPen = mdiDrawPen; mdiVectorRectangle = mdiVectorRectangle;
// private featuresLayer; mdiClose = mdiClose;
options = { options = {
shapeOptions: { shapeOptions: {
stroke: true, stroke: true,
color: '#22C55E', color: '#65DC21',
weight: 4, weight: 4,
opacity: 0.5, opacity: 0.5,
fill: true, fill: true,
fillColor: '#22C55E', //same as color by default fillColor: '#65DC21',
fillOpacity: 0.2, fillOpacity: 0.2,
clickable: true, clickable: true,
}, },
@ -56,7 +69,6 @@ export default class DrawControlComponent extends Vue {
}; };
@Prop() public mapId: string; @Prop() public mapId: string;
// @Prop() public map: Map;
@Prop public southWest: LatLng; @Prop public southWest: LatLng;
@Prop public northEast: LatLng; @Prop public northEast: LatLng;
@Prop({ @Prop({
@ -65,13 +77,17 @@ export default class DrawControlComponent extends Vue {
public preserve: boolean; public preserve: boolean;
mapService = MapService(); mapService = MapService();
public _enabled: boolean; private _enabled: boolean;
private _map: Map; private _map: Map;
private _isDrawing: boolean = false; private _isDrawing: boolean = false;
private _startLatLng: LatLng; private _startLatLng: LatLng;
private _mapDraggable: boolean; private _mapDraggable: boolean;
private _shape: Rectangle | undefined; private _shape: Rectangle | undefined;
get enabled() {
return this._enabled;
}
enable() { enable() {
if (this._enabled) { if (this._enabled) {
return this; return this;
@ -93,49 +109,35 @@ export default class DrawControlComponent extends Vue {
return this; return this;
} }
enabled() { // enabled() {
return !!this._enabled; // return !!this._enabled;
} // }
// @Ref('inputDraw') private _inputDraw: HTMLElement;
private addHooks() { private addHooks() {
// L.Draw.Feature.prototype.addHooks.call(this);
this._map = this.mapService.getMap(this.mapId); this._map = this.mapService.getMap(this.mapId);
if (this._map) { if (this._map) {
this._mapDraggable = this._map.dragging.enabled(); this._mapDraggable = this._map.dragging.enabled();
if (this._mapDraggable) { if (this._mapDraggable) {
this._map.dragging.disable(); this._map.dragging.disable();
} }
//TODO refactor: move cursor to styles this._map.getContainer().style.cursor = 'crosshair';
// this._map.domElement.style.cursor = 'crosshair';
this._map._container.style.cursor = 'crosshair';
// this._tooltip.updateContent({text: this._initialLabelText});
this._map this._map
.on('mousedown', this._onMouseDown, this) .on('mousedown', this._onMouseDown, this)
.on('mousemove', this._onMouseMove, this) .on('mousemove', this._onMouseMove, this)
.on('touchstart', this._onMouseDown, this) .on('touchstart', this._onMouseDown, this)
.on('touchmove', this._onMouseMove, this); .on('touchmove', this._onMouseMove, this);
// we should prevent default, otherwise default behavior (scrolling) will fire,
// and that will cause document.touchend to fire and will stop the drawing
// (circle, rectangle) in touch mode.
// (update): we have to send passive now to prevent scroll, because by default it is {passive: true} now, which means,
// handler can't event.preventDefault
// check the news https://developers.google.com/web/updates/2016/06/passive-event-listeners
// document.addEventListener('touchstart', preventDefault, { passive: false });
} }
} }
private removeHooks() { private removeHooks() {
// L.Draw.Feature.prototype.removeHooks.call(this);
if (this._map) { if (this._map) {
if (this._mapDraggable) { if (this._mapDraggable) {
this._map.dragging.enable(); this._map.dragging.enable();
} }
//TODO refactor: move cursor to styles this._map.getContainer().style.cursor = '';
this._map._container.style.cursor = '';
this._map this._map
.off('mousedown', this._onMouseDown, this) .off('mousedown', this._onMouseDown, this)
@ -146,46 +148,36 @@ export default class DrawControlComponent extends Vue {
off(document, 'mouseup', this._onMouseUp, this); off(document, 'mouseup', this._onMouseUp, this);
off(document, 'touchend', this._onMouseUp, this); off(document, 'touchend', this._onMouseUp, this);
// document.removeEventListener('touchstart', preventDefault);
// If the box element doesn't exist they must not have moved the mouse, so don't need to destroy/return
if (this._shape && this.preserve == false) { if (this._shape && this.preserve == false) {
this._map.removeLayer(this._shape); this._map.removeLayer(this._shape);
// delete this._shape;
this._shape = undefined; this._shape = undefined;
} }
} }
this._isDrawing = false; this._isDrawing = false;
} }
private _onMouseDown(e: LeafletMouseEvent) { // private _onMouseDown(e: LeafletMouseEvent) {
private _onMouseDown(e: any) {
this._isDrawing = true; this._isDrawing = true;
this._startLatLng = e.latlng; this._startLatLng = e.latlng;
// DomEvent.on(document, 'mouseup', this._onMouseUp, this)
// .on(document, 'touchend', this._onMouseUp, this)
// .preventDefault(e.originalEvent);
on(document, 'mouseup', this._onMouseUp, this); on(document, 'mouseup', this._onMouseUp, this);
on(document, 'touchend', this._onMouseUp, this); on(document, 'touchend', this._onMouseUp, this);
preventDefault(e.originalEvent); preventDefault(e.originalEvent);
} }
// private _onMouseMove(e: LeafletMouseEvent) {
private _onMouseMove(e: LeafletMouseEvent) { private _onMouseMove(e: any) {
var latlng = e.latlng; var latlng = e.latlng;
// this._tooltip.updatePosition(latlng);
if (this._isDrawing) { if (this._isDrawing) {
// this._tooltip.updateContent(this._getTooltipText());
this._drawShape(latlng); this._drawShape(latlng);
} }
} }
private _onMouseUp() { private _onMouseUp() {
if (this._shape) { if (this._shape) {
this._fireCreatedEvent(this._shape); this._fireCreatedEvent(this._shape);
} }
// this.removeHooks();
this.disable(); this.disable();
if (this.options.repeatMode) { if (this.options.repeatMode) {
this.enable(); this.enable();
@ -194,14 +186,12 @@ export default class DrawControlComponent extends Vue {
private _fireCreatedEvent(shape: Rectangle) { private _fireCreatedEvent(shape: Rectangle) {
var rectangle = new Rectangle(shape.getBounds(), this.options.shapeOptions); var rectangle = new Rectangle(shape.getBounds(), this.options.shapeOptions);
// L.Draw.SimpleShape.prototype._fireCreatedEvent.call(this, rectangle);
this._map.fire('Draw.Event.CREATED', { layer: rectangle, type: this.TYPE }); this._map.fire('Draw.Event.CREATED', { layer: rectangle, type: this.TYPE });
} }
public removeShape() { public removeShape() {
if (this._shape) { if (this._shape) {
this._map.removeLayer(this._shape); this._map.removeLayer(this._shape);
// delete this._shape;
this._shape = undefined; this._shape = undefined;
} }
} }
@ -210,7 +200,6 @@ export default class DrawControlComponent extends Vue {
if (!this._shape) { if (!this._shape) {
const bounds = new LatLngBounds(southWest, northEast); const bounds = new LatLngBounds(southWest, northEast);
this._shape = new Rectangle(bounds, this.options.shapeOptions); this._shape = new Rectangle(bounds, this.options.shapeOptions);
// this._map.addLayer(this._shape);
this._map = this.mapService.getMap(this.mapId); this._map = this.mapService.getMap(this.mapId);
this._shape.addTo(this._map); this._shape.addTo(this._map);
} else { } else {
@ -218,12 +207,10 @@ export default class DrawControlComponent extends Vue {
} }
} }
// from Draw Rectangle
private _drawShape(latlng: LatLng) { private _drawShape(latlng: LatLng) {
if (!this._shape) { if (!this._shape) {
const bounds = new LatLngBounds(this._startLatLng, latlng); const bounds = new LatLngBounds(this._startLatLng, latlng);
this._shape = new Rectangle(bounds, this.options.shapeOptions); this._shape = new Rectangle(bounds, this.options.shapeOptions);
// this._map.addLayer(this._shape);
this._shape.addTo(this._map); this._shape.addTo(this._map);
} else { } else {
this._shape.setBounds(new LatLngBounds(this._startLatLng, latlng)); this._shape.setBounds(new LatLngBounds(this._startLatLng, latlng));
@ -237,44 +224,336 @@ export default class DrawControlComponent extends Vue {
this.enable(); this.enable();
} }
} }
// private enable() {
// //if (this.map.mapTool) this.map.mapTool.on('editable:drawing:start', this.disable.bind(this));
// // dom.addClass(this.map.container, 'measure-enabled');
// //this.fireAndForward('showmeasure');
// this._startMarker(this.southWest, this.options);
// }
// private disable() {
// //if (this.map.mapTool) this.map.mapTool.off('editable:drawing:start', this.disable.bind(this));
// // dom.removeClass(this.map.container, 'measure-enabled');
// // this.featuresLayer.clearLayers();
// // //this.fireAndForward('hidemeasure');
// // if (this._drawingEditor) {
// // this._drawingEditor.cancelDrawing();
// // }
// }
} }
export default DrawControlComponent;
</script> </script>
<style lang="css"> <style scoped>
.gba-control-draw { .draw-control-container {
position: absolute;
left: 1rem;
top: 8rem;
z-index: 1000;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
cursor: pointer;
border-radius: 4px;
position: absolute;
left: 10px;
top: 100px;
z-index: 40;
} }
.btn-group-vertical button { .draw-button {
display: block; display: flex;
align-items: center;
gap: 0;
padding: 0.625rem;
background: white;
border: 2px solid #e5e7eb;
border-radius: 0.75rem;
color: #374151;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
outline: none;
font-size: 0.875rem;
font-weight: 600;
position: relative;
overflow: visible;
width: 2.5rem;
height: 2.5rem;
justify-content: center;
}
margin-left: 0; .dark .draw-button {
margin-top: 0.5em; background: #1f2937;
border-color: #374151;
color: #d1d5db;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
/* Inactive state hover */
.draw-button:not(.is-active):hover {
background: #f9fafb;
border-color: #65DC21;
color: #357C06;
transform: translateY(-2px);
/* box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); */
width: auto;
padding: 0.625rem 1rem;
gap: 0.5rem;
}
.dark .draw-button:not(.is-active):hover {
background: #111827;
border-color: #65DC21;
color: #65DC21;
}
/* Active state */
.draw-button.is-active {
background: linear-gradient(135deg, #65DC21 0%, #357C06 100%);
border-color: #357C06;
color: white;
box-shadow: 0 10px 15px -3px rgba(101, 220, 33, 0.4), 0 4px 6px -2px rgba(101, 220, 33, 0.2);
width: auto;
padding: 0.625rem 1rem;
gap: 0.5rem;
}
.dark .draw-button.is-active {
box-shadow: 0 10px 15px -3px rgba(101, 220, 33, 0.5), 0 4px 6px -2px rgba(101, 220, 33, 0.3);
}
/* Active state hover */
.draw-button.is-active:hover {
background: linear-gradient(135deg, #429E04 0%, #295B09 100%);
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(101, 220, 33, 0.4), 0 10px 10px -5px rgba(101, 220, 33, 0.2);
}
/* Active state press */
.draw-button:active {
transform: translateY(0) scale(0.98);
}
/* Focus state */
.draw-button:focus-visible {
outline: 3px solid rgba(101, 220, 33, 0.5);
outline-offset: 2px;
}
/* Icon styling */
.draw-button :deep(svg) {
width: 1.25rem;
height: 1.25rem;
transition: transform 0.3s ease;
}
/* .draw-button.is-active :deep(svg) {
transform: rotate(90deg);
} */
/* Status badge */
.draw-status-badge {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
transition: all 0.3s ease;
max-width: 0;
opacity: 0;
overflow: hidden;
white-space: nowrap;
}
/* Show badge on hover when inactive */
.draw-button:not(.is-active):hover .draw-status-badge {
max-width: 100px;
opacity: 1;
}
/* Show badge when active */
.draw-button.is-active .draw-status-badge {
max-width: 100px;
opacity: 1;
}
/* Pulse animation for active state */
.draw-button.is-active::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.3);
border-radius: 0.75rem;
transform: translate(-50%, -50%) scale(0);
animation: pulse 2s ease-out infinite;
pointer-events: none;
}
/* @keyframes pulse {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
} */
/* Glow effect for active state */
.draw-button.is-active::after {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(135deg, #65DC21, #357C06);
border-radius: 0.75rem;
opacity: 0;
z-index: -1;
transition: opacity 0.3s ease;
filter: blur(8px);
}
.draw-button.is-active:hover::after {
opacity: 0.6;
}
/* Inactive state indicator */
.draw-button:not(.is-active) .draw-status-badge {
color: #6b7280;
}
.dark .draw-button:not(.is-active) .draw-status-badge {
color: #9ca3af;
}
.draw-button:not(.is-active):hover .draw-status-badge {
color: #357C06;
}
.dark .draw-button:not(.is-active):hover .draw-status-badge {
color: #65DC21;
}
/* Active state indicator */
.draw-button.is-active .draw-status-badge {
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Tooltip on hover */
.draw-button:hover::after {
content: attr(aria-label);
position: absolute;
bottom: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: white;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 1001;
animation: fadeInTooltip 0.2s ease 0.5s forwards;
}
/* @keyframes fadeInTooltip {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
} */
/* Ripple effect on click */
.draw-button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.draw-button:active::before {
width: 300px;
height: 300px;
}
/* Responsive design */
@media (max-width: 768px) {
.draw-control-container {
right: 0.75rem;
top: 0.75rem;
}
.draw-button {
width: 2.25rem;
height: 2.25rem;
padding: 0.5rem;
font-size: 0.8125rem;
}
.draw-button:not(.is-active):hover,
.draw-button.is-active {
padding: 0.5rem 0.875rem;
}
.draw-button :deep(svg) {
width: 1.125rem;
height: 1.125rem;
}
.draw-status-badge {
font-size: 0.6875rem;
}
/* Hide tooltip on mobile */
.draw-button:hover::after {
display: none;
}
}
/* @media (max-width: 640px) {
.draw-control-container {
right: 0.5rem;
top: 0.5rem;
}
.draw-button {
width: 2rem;
height: 2rem;
padding: 0.5rem;
}
.draw-button:not(.is-active):hover,
.draw-button.is-active {
padding: 0.5rem 0.75rem;
}
.draw-button :deep(svg) {
width: 1rem;
height: 1rem;
}
} */
/* Accessibility: reduce motion */
@media (prefers-reduced-motion: reduce) {
.draw-button,
.draw-button :deep(svg),
.draw-status-badge {
transition: none;
}
.draw-button.is-active::before,
.draw-button.is-active::after {
animation: none;
}
}
</style>
<style>
/* Global styles for draw mode */
.leaflet-container.draw-mode-active {
cursor: crosshair !important;
}
.leaflet-container.draw-mode-active * {
cursor: crosshair !important;
} }
</style> </style>

View file

@ -1,21 +1,72 @@
<template> <template>
<div style="position: relative"> <div class="relative w-full">
<!-- <Map className="h-36" :center="state.center" :zoom="state.zoom"> // map component content </Map> --> <!-- Map Container -->
<div :id="mapId" class="rounded"> <div
<div class="dark:bg-slate-900 bg-slate flex flex-col"> :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" /> <ZoomControlComponent ref="zoom" :mapId="mapId" />
<DrawControlComponent ref="draw" :mapId="mapId" :southWest="southWest" :northEast="northEast" /> <DrawControlComponent ref="draw" :mapId="mapId" :southWest="southWest" :northEast="northEast" />
</div> </div>
</div> </div>
<div class="gba-control-validate btn-group-vertical">
<!-- Validate Button -->
<div class="absolute left-4 top-44 z-[1000] select-none">
<button <button
class="min-w-27 inline-flex cursor-pointer justify-center items-center whitespace-nowrap focus:outline-none transition-colors duration-150 border rounded ring-blue-700 text-black text-sm p-1" 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" type="button"
@click.stop.prevent="validateBoundingBox" @click.stop.prevent="validateBoundingBox"
:class="[validBoundingBox ? 'cursor-not-allowed bg-green-500 is-active' : 'bg-red-500 ']" :aria-label="validBoundingBox ? 'Bounding box is valid' : 'Validate bounding box'"
> >
<!-- <BaseIcon v-if="mdiMapCheckOutline" :path="mdiMapCheckOutline" /> --> <!-- Icon -->
{{ label }} <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> </button>
</div> </div>
</div> </div>
@ -27,8 +78,7 @@ import { Component, Vue, Prop, Ref } from 'vue-facing-decorator';
import { Map } from 'leaflet/src/map/index'; import { Map } from 'leaflet/src/map/index';
import { Control } from 'leaflet/src/control/Control'; import { Control } from 'leaflet/src/control/Control';
import { LatLngBoundsExpression, LatLngBounds } from 'leaflet/src/geo/LatLngBounds'; import { LatLngBoundsExpression, LatLngBounds } from 'leaflet/src/geo/LatLngBounds';
// import { toLatLng } from 'leaflet/src/geo/LatLng'; import { LatLng } from 'leaflet';
import { LatLng } from 'leaflet'; //'leaflet/src/geo/LatLng';
import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS'; import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS';
import { Attribution } from 'leaflet/src/control/Control.Attribution'; import { Attribution } from 'leaflet/src/control/Control.Attribution';
import { mdiMapCheckOutline } from '@mdi/js'; import { mdiMapCheckOutline } from '@mdi/js';
@ -37,22 +87,15 @@ import BaseIcon from '@/Components/BaseIcon.vue';
import { MapOptions } from './MapOptions'; import { MapOptions } from './MapOptions';
import { LayerOptions, LayerMap } from './LayerOptions'; import { LayerOptions, LayerMap } from './LayerOptions';
import { MapService } from '@/Stores/map.service'; import { MapService } from '@/Stores/map.service';
import ZoomControlComponent from './zoom.component.vue'; import { ZoomControlComponent } from './zoom.component.vue';
import DrawControlComponent from './draw.component.vue'; import { DrawControlComponent } from './draw.component.vue';
import { Coverage } from '@/Dataset'; import { Coverage } from '@/Dataset';
import { canvas } from 'leaflet/src/layer/vector/Canvas'; import { canvas } from 'leaflet/src/layer/vector/Canvas';
import { svg } from 'leaflet/src/layer/vector/SVG'; import { svg } from 'leaflet/src/layer/vector/SVG';
import Notification from '@/utils/toast'; import Notification from '@/utils/toast';
Map.include({ Map.include({
// @namespace Map; @method getRenderer(layer: Path): Renderer
// Returns the instance of `Renderer` that should be used to render the given
// `Path`. It will ensure that the `renderer` options of the map and paths
// are respected, and that the renderers do exist on the map.
getRenderer: function (layer) { getRenderer: function (layer) {
// @namespace Path; @option renderer: Renderer
// Use this specific instance of `Renderer` for this path. Takes
// precedence over the map's [default renderer](#map-renderer).
var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer; var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer;
if (!renderer) { if (!renderer) {
@ -79,15 +122,11 @@ Map.include({
}, },
_createRenderer: function (options) { _createRenderer: function (options) {
// @namespace Map; @option preferCanvas: Boolean = false
// Whether `Path`s should be rendered on a `Canvas` renderer.
// By default, all `Path`s are rendered in a `SVG` renderer.
return (this.options.preferCanvas && canvas(options)) || svg(options); return (this.options.preferCanvas && canvas(options)) || svg(options);
}, },
}); });
const DEFAULT_BASE_LAYER_NAME = 'BaseLayer'; const DEFAULT_BASE_LAYER_NAME = 'BaseLayer';
// const DEFAULT_BASE_LAYER_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors'; const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
@Component({ @Component({
@ -98,33 +137,19 @@ const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <a target="_blank" href="http://o
BaseIcon, BaseIcon,
}, },
}) })
export default class MapComponent extends Vue { export class MapComponent extends Vue {
/**
* A map with the given ID is created inside this component.
* This ID can be used the get the map instance over the map cache service.
*/
@Prop() @Prop()
public mapId: string; public mapId: string;
/**
* The corresponding leaflet map options (see: https://leafletjs.com/reference-1.3.4.html#map-option)
*/
@Prop() @Prop()
public mapOptions: MapOptions; public mapOptions: MapOptions;
@Prop() @Prop()
public coverage: Coverage; public coverage: Coverage;
// markerService: MarkerService
/**
* Bounds for the map
*/
@Prop({ default: null }) @Prop({ default: null })
public fitBounds: LatLngBoundsExpression; public fitBounds: LatLngBoundsExpression;
/**
* Describes the the zoom control options (see: https://leafletjs.com/reference-1.3.4.html#control-zoom)
*/
@Prop() @Prop()
public zoomControlOptions: Control.ZoomOptions; public zoomControlOptions: Control.ZoomOptions;
@ -132,7 +157,7 @@ export default class MapComponent extends Vue {
public baseMaps: LayerMap; public baseMaps: LayerMap;
get label(): string { get label(): string {
return this.validBoundingBox ? ' valid' : 'invalid'; return this.validBoundingBox ? 'Valid' : 'Invalid';
} }
get validBoundingBox(): boolean { get validBoundingBox(): boolean {
@ -144,35 +169,31 @@ export default class MapComponent extends Vue {
let isBoundValid = true; let isBoundValid = true;
if (isValidNumber) { if (isValidNumber) {
let _southWest: LatLng = new LatLng(this.coverage.y_min, this.coverage.x_min); 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); let _northEast: LatLng = new LatLng(this.coverage.y_max!, this.coverage.x_max!);
const bounds = new LatLngBounds(this.southWest, this.northEast); const bounds = new LatLngBounds(this.southWest, this.northEast);
if (!bounds.isValid() || !(_southWest.lat < _northEast.lat && _southWest.lng < _northEast.lng)) { if (!bounds.isValid() || !(_southWest.lat < _northEast.lat && _southWest.lng < _northEast.lng)) {
// this.draw.removeShape();
// Notification.showTemporary('Bounds are not valid.');
isBoundValid = false; isBoundValid = false;
} }
} }
return isValidNumber && isBoundValid; return isValidNumber && isBoundValid;
} }
@Ref('zoom') private zoom: ZoomControlComponent; @Ref('zoom')
@Ref('draw') private draw: DrawControlComponent; private zoom: ZoomControlComponent;
@Ref('draw')
private draw: DrawControlComponent;
// services:
mapService = MapService(); mapService = MapService();
mdiMapCheckOutline = mdiMapCheckOutline; mdiMapCheckOutline = mdiMapCheckOutline;
southWest: LatLng; southWest: LatLng;
northEast: LatLng; northEast: LatLng;
/**
* Informs when initialization is done with map id.
*/
public onMapInitializedEvent: EventEmitter<string> = new EventEmitter<string>(); public onMapInitializedEvent: EventEmitter<string> = new EventEmitter<string>();
public map!: Map; public map!: Map;
// protected drawnItems!: FeatureGroup<any>;
validateBoundingBox() { validateBoundingBox() {
if (this.validBoundingBox == false) { if (this.validBoundingBox == false) {
@ -182,53 +203,22 @@ export default class MapComponent extends Vue {
} }
this.map.control && this.map.control.disable(); this.map.control && this.map.control.disable();
var _this = this; var _this = this;
// // _this.locationErrors.length = 0;
// this.drawnItems.clearLayers();
// //var xmin = document.getElementById("xmin").value;
// var xmin = (<HTMLInputElement>document.getElementById("xmin")).value;
// // var ymin = document.getElementById("ymin").value;
// var ymin = (<HTMLInputElement>document.getElementById("ymin")).value;
// //var xmax = document.getElementById("xmax").value;
// var xmax = (<HTMLInputElement>document.getElementById("xmax")).value;
// //var ymax = document.getElementById("ymax").value;
// var ymax = (<HTMLInputElement>document.getElementById("ymax")).value;
// var bounds = [[ymin, xmin], [ymax, xmax]];
// let _southWest: LatLng; let _southWest: LatLng = new LatLng(this.coverage.y_min!, this.coverage.x_min!);
// let _northEast: LatLng; let _northEast: LatLng = new LatLng(this.coverage.y_max!, this.coverage.x_max!);
// if (this.coverage.x_min && this.coverage.y_min) {
let _southWest: LatLng = new LatLng(this.coverage.y_min, this.coverage.x_min);
// }
// if (this.coverage.x_max && this.coverage.y_max) {
let _northEast: LatLng = new LatLng(this.coverage.y_max, this.coverage.x_max);
// }
const bounds = new LatLngBounds(this.southWest, this.northEast); const bounds = new LatLngBounds(this.southWest, this.northEast);
if (!bounds.isValid() || !(_southWest.lat < _northEast.lat && _southWest.lng < _northEast.lng)) { if (!bounds.isValid() || !(_southWest.lat < _northEast.lat && _southWest.lng < _northEast.lng)) {
this.draw.removeShape(); this.draw.removeShape();
Notification.showTemporary('Bounds are not valid.'); Notification.showTemporary('Bounds are not valid.');
} else { } else {
// this.draw.drawShape(_southWest, _northEast);
try { try {
this.draw.drawShape(_southWest, _northEast); this.draw.drawShape(_southWest, _northEast);
_this.map.fitBounds(bounds); _this.map.fitBounds(bounds);
// var boundingBox = L.rectangle(bounds, { color: "#005F6A", weight: 1 }); Notification.showSuccess('Valid bounding box');
// // this.geolocation.xmin = xmin;
// // this.geolocation.ymin = ymin;
// // this.geolocation.xmax = xmax;
// // this.geolocation.ymax = ymax;
// _this.drawnItems.addLayer(boundingBox);
// _this.map.fitBounds(bounds);
// this.options.message = "valid bounding box";
// this.$toast.success("valid bounding box", this.options);
Notification.showSuccess('valid bounding box');
} catch (err) { } catch (err) {
// this.options.message = e.message;
// // _this.errors.push(e);
// this.$toast.error(e.message, this.options);
Notification.showTemporary('An error occurred while drawing bounding box'); Notification.showTemporary('An error occurred while drawing bounding box');
// generatingCodes.value = false;
throw err; throw err;
} }
} }
@ -242,16 +232,11 @@ export default class MapComponent extends Vue {
this.map.off('zoomend zoomlevelschange'); this.map.off('zoomend zoomlevelschange');
} }
// @Emit(this.onMapInitializedEvent)
protected initMap(): void { protected initMap(): void {
// let map: Map = (this.map = this.mapService.getMap(this.mapId));
let map: Map = (this.map = new Map(this.mapId, this.mapOptions)); let map: Map = (this.map = new Map(this.mapId, this.mapOptions));
this.mapService.setMap(this.mapId, map); this.mapService.setMap(this.mapId, map);
map.scrollWheelZoom.disable(); map.scrollWheelZoom.disable();
// return this.mapId;
// this.$emit("onMapInitializedEvent", this.mapId);
this.onMapInitializedEvent.emit(this.mapId); this.onMapInitializedEvent.emit(this.mapId);
this.addBaseMap(); this.addBaseMap();
@ -260,45 +245,28 @@ export default class MapComponent extends Vue {
map.on( map.on(
'Draw.Event.CREATED', 'Draw.Event.CREATED',
function (event) { (event: any) => {
// drawnItems.clearLayers();
// var type = event.type;
var layer = event.layer; var layer = event.layer;
// if (type === "rectancle") {
// layer.bindPopup("A popup!" + layer.getBounds().toBBoxString());
var bounds = layer.getBounds(); var bounds = layer.getBounds();
this.coverage.x_min = bounds.getSouthWest().lng; this.coverage.x_min = bounds.getSouthWest().lng;
this.coverage.y_min = bounds.getSouthWest().lat; this.coverage.y_min = bounds.getSouthWest().lat;
// console.log(this.geolocation.xmin);
this.coverage.x_max = bounds.getNorthEast().lng; this.coverage.x_max = bounds.getNorthEast().lng;
this.coverage.y_max = bounds.getNorthEast().lat; this.coverage.y_max = bounds.getNorthEast().lat;
// }
// drawnItems.addLayer(layer);
}, },
this,
); );
// Initialise the FeatureGroup to store editable layers
// let drawnItems = (this.drawnItems = new FeatureGroup());
// map.addLayer(drawnItems);
this.map.on('zoomend zoomlevelschange', this.zoom.updateDisabled, this.zoom); this.map.on('zoomend zoomlevelschange', this.zoom.updateDisabled, this.zoom);
// if (this.fitBounds) {
// this.map.fitBounds(this.fitBounds);
// }
if (this.coverage.x_min && this.coverage.y_min) { if (this.coverage.x_min && this.coverage.y_min) {
this.southWest = new LatLng(this.coverage.y_min, this.coverage.x_min); this.southWest = new LatLng(this.coverage.y_min!, this.coverage.x_min!);
} else { } else {
this.southWest = new LatLng(46.5, 9.9); this.southWest = new LatLng(46.5, 9.9);
} }
if (this.coverage.x_max && this.coverage.y_max) { if (this.coverage.x_max && this.coverage.y_max) {
this.northEast = new LatLng(this.coverage.y_max, this.coverage.x_max); this.northEast = new LatLng(this.coverage.y_max!, this.coverage.x_max!);
} else { } else {
this.northEast = new LatLng(48.9, 16.9); this.northEast = new LatLng(48.9, 16.9);
} // this.northEast = toLatLng(48.9, 16.9); }
const bounds = new LatLngBounds(this.southWest, this.northEast); const bounds = new LatLngBounds(this.southWest, this.northEast);
map.fitBounds(bounds); map.fitBounds(bounds);
@ -318,10 +286,6 @@ export default class MapComponent extends Vue {
private addBaseMap(layerOptions?: LayerOptions): void { private addBaseMap(layerOptions?: LayerOptions): void {
if (this.map) { if (this.map) {
if (!this.baseMaps || this.baseMaps.size === 0) { if (!this.baseMaps || this.baseMaps.size === 0) {
// let bmapgrau = tileLayer('https://{s}.wien.gv.at/basemap/bmapgrau/normal/google3857/{z}/{y}/{x}.png', {
// subdomains: ['maps', 'maps1', 'maps2', 'maps3', 'maps4'],
// attribution: 'Datenquelle: <a href="http://www.basemap.at/">basemap.at</a>',
// });
let osmGgray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', { let osmGgray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', {
format: 'image/png', format: 'image/png',
attribution: DEFAULT_BASE_LAYER_ATTRIBUTION, attribution: DEFAULT_BASE_LAYER_ATTRIBUTION,
@ -337,45 +301,61 @@ export default class MapComponent extends Vue {
} }
} }
} }
export default MapComponent;
</script> </script>
<style scoped lang="css"> <style scoped>
/* .leaflet-container { /* Leaflet container - only what can't be done with Tailwind */
:deep(.leaflet-container) {
height: 600px; height: 600px;
width: 100%; width: 100%;
background-color: transparent; background: transparent;
outline-offset: 1px;
} */
.leaflet-container {
height: 600px;
width: 100%;
background: none;
} }
.gba-control-validate { :deep(.leaflet-container .leaflet-pane) {
-webkit-user-select: none; z-index: 30 !important;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
cursor: pointer;
border-radius: 4px;
position: absolute;
left: 10px;
top: 150px;
z-index: 999;
} }
.btn-group-vertical button { /* Custom animations */
display: block; @keyframes checkPulse {
0%, 100% {
margin-left: 0; transform: scale(1);
margin-top: 0.5em; }
50% {
transform: scale(1.1);
}
} }
.leaflet-container .leaflet-pane { @keyframes pulse {
z-index: 30!important; 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%;
}
} }
/* .leaflet-pane {
z-index: 30;
} */
</style> </style>

View file

@ -1,21 +1,27 @@
<template> <template>
<div class="gba-control-zoom btn-group-vertical"> <div class="zoom-control-container">
<button <button
ref="inputPlus" ref="inputPlus"
class="disabled:bg-gray-200 inline-flex cursor-pointer justify-center items-center whitespace-nowrap focus:outline-none transition-colors duration-150 border rounded ring-blue-700 bg-teal-50 text-black border-teal-50 text-sm p-1" class="zoom-button zoom-button-plus"
type="button" type="button"
@click.stop.prevent="zoomIn" @click.stop.prevent="zoomIn"
:disabled="isZoomInDisabled"
aria-label="Zoom in"
> >
<BaseIcon v-if="mdiPlus" :path="mdiPlus" /> <BaseIcon v-if="mdiPlus" :path="mdiPlus" :size="20" />
</button> </button>
<div class="zoom-separator"></div>
<button <button
ref="inputMinus" ref="inputMinus"
class="disabled:bg-gray-200 inline-flex cursor-pointer justify-center items-center whitespace-nowrap focus:outline-none transition-colors duration-150 border rounded ring-blue-700 bg-teal-50 text-black border-teal-50 text-sm p-1" class="zoom-button zoom-button-minus"
type="button" type="button"
@click.stop.prevent="zoomOut" @click.stop.prevent="zoomOut"
:disabled="isZoomOutDisabled"
aria-label="Zoom out"
> >
<BaseIcon v-if="mdiMinus" :path="mdiMinus" /> <BaseIcon v-if="mdiMinus" :path="mdiMinus" :size="20" />
</button> </button>
</div> </div>
</template> </template>
@ -26,6 +32,7 @@ import { MapService } from '@/Stores/map.service';
import BaseIcon from '@/Components/BaseIcon.vue'; import BaseIcon from '@/Components/BaseIcon.vue';
import { mdiPlus, mdiMinus } from '@mdi/js'; import { mdiPlus, mdiMinus } from '@mdi/js';
import { Map } from 'leaflet';
@Component({ @Component({
name: 'zoom-control', name: 'zoom-control',
@ -33,7 +40,7 @@ import { mdiPlus, mdiMinus } from '@mdi/js';
BaseIcon, BaseIcon,
}, },
}) })
export default class ZoomControlComponent extends Vue { export class ZoomControlComponent extends Vue {
mdiPlus = mdiPlus; mdiPlus = mdiPlus;
mdiMinus = mdiMinus; mdiMinus = mdiMinus;
@ -46,16 +53,23 @@ export default class ZoomControlComponent extends Vue {
@Ref('inputMinus') inputMinus: HTMLButtonElement; @Ref('inputMinus') inputMinus: HTMLButtonElement;
mapService = MapService(); mapService = MapService();
map; map: Map | null = null;
isZoomInDisabled = false;
isZoomOutDisabled = false;
// mounted() { mounted() {
// let map = (this.map = this.mapService.getMap(this.mapId)); let map = (this.map = this.mapService.getMap(this.mapId));
// map.on('zoomend zoomlevelschange', this.updateDisabled, this); if (map) {
// } map.on('zoomend zoomlevelschange', this.updateDisabled, this);
this.updateDisabled();
}
}
// unmounted() { unmounted() {
// this.map.off('zoomend zoomlevelschange'); if (this.map) {
// } this.map.off('zoomend zoomlevelschange', this.updateDisabled, this);
}
}
public zoomIn() { public zoomIn() {
let map = this.mapService.getMap(this.mapId); let map = this.mapService.getMap(this.mapId);
@ -69,44 +83,266 @@ export default class ZoomControlComponent extends Vue {
public updateDisabled() { public updateDisabled() {
let map = this.mapService.getMap(this.mapId); let map = this.mapService.getMap(this.mapId);
// let className = 'leaflet-disabled'; if (!map) return;
this.inputPlus.disabled = false; this.isZoomInDisabled = map.getZoom() >= map.getMaxZoom();
this.inputPlus.setAttribute('aria-disabled', 'false'); this.isZoomOutDisabled = map.getZoom() <= map.getMinZoom();
this.inputMinus.disabled = false; if (this.inputPlus) {
this.inputMinus.setAttribute('aria-disabled', 'false'); this.inputPlus.disabled = this.isZoomInDisabled;
this.inputPlus.setAttribute('aria-disabled', this.isZoomInDisabled.toString());
if (map.getZoom() === map.getMinZoom()) {
this.inputMinus.disabled = true;
this.inputMinus.setAttribute('aria-disabled', 'true');
} }
if (map.getZoom() === map.getMaxZoom()) {
this.inputPlus.disabled = true; if (this.inputMinus) {
this.inputPlus.setAttribute('aria-disabled', 'true'); this.inputMinus.disabled = this.isZoomOutDisabled;
this.inputMinus.setAttribute('aria-disabled', this.isZoomOutDisabled.toString());
} }
} }
} }
export default ZoomControlComponent;
</script> </script>
<style lang="css"> <style scoped>
.gba-control-zoom { .zoom-control-container {
position: absolute;
left: 1rem;
top: 1rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0;
background: white;
border-radius: 0.75rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
overflow: hidden;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
cursor: pointer; transition: box-shadow 0.2s ease;
border-radius: 4px;
position: absolute;
left: 10px;
top: 10px;
z-index: 40;
} }
.btn-group-vertical button { .zoom-control-container:hover {
display: block; box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
margin-left: 0; .dark .zoom-control-container {
margin-top: 0.5em; background: #1f2937;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
.dark .zoom-control-container:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
.zoom-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding: 0;
background: white;
border: none;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
outline: none;
}
.dark .zoom-button {
background: #1f2937;
color: #d1d5db;
}
.zoom-button:hover:not(:disabled) {
background: #65dc21;
color: white;
transform: scale(1.05);
}
.dark .zoom-button:hover:not(:disabled) {
background: #65dc21;
}
.zoom-button:active:not(:disabled) {
transform: scale(0.95);
}
.zoom-button:disabled {
cursor: not-allowed;
opacity: 0.4;
background: #f3f4f6;
color: #9ca3af;
}
.dark .zoom-button:disabled {
background: #111827;
color: #4b5563;
}
.zoom-button:focus-visible {
outline: 2px solid #65dc21;
outline-offset: -2px;
}
/* Icon sizing */
.zoom-button :deep(svg) {
width: 1.25rem;
height: 1.25rem;
}
/* Separator between buttons */
.zoom-separator {
height: 1px;
background: #e5e7eb;
}
.dark .zoom-separator {
background: #374151;
}
/* Hover effect for the plus button */
.zoom-button-plus::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%);
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.zoom-button-plus:hover:not(:disabled)::after {
opacity: 1;
}
/* Hover effect for the minus button */
.zoom-button-minus::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%);
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.zoom-button-minus:hover:not(:disabled)::after {
opacity: 1;
}
/* Responsive design */
@media (max-width: 768px) {
.zoom-control-container {
left: 0.75rem;
top: 0.75rem;
}
.zoom-button {
width: 2.25rem;
height: 2.25rem;
}
.zoom-button :deep(svg) {
width: 1.125rem;
height: 1.125rem;
}
}
@media (max-width: 640px) {
.zoom-control-container {
left: 0.5rem;
top: 0.5rem;
}
.zoom-button {
width: 2rem;
height: 2rem;
}
.zoom-button :deep(svg) {
width: 1rem;
height: 1rem;
}
}
/* Animation for button press */
@keyframes buttonPress {
0% {
transform: scale(1);
}
50% {
transform: scale(0.95);
}
100% {
transform: scale(1);
}
}
.zoom-button:active:not(:disabled) {
animation: buttonPress 0.2s ease;
}
/* Tooltip-like effect on hover (optional) */
.zoom-button-plus:hover:not(:disabled)::before {
content: 'Zoom In';
position: absolute;
left: calc(100% + 0.5rem);
top: 50%;
transform: translateY(-50%);
background: #1f2937;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
animation: fadeIn 0.2s ease 0.5s forwards;
pointer-events: none;
z-index: 1001;
}
.zoom-button-minus:hover:not(:disabled)::before {
content: 'Zoom Out';
position: absolute;
left: calc(100% + 0.5rem);
top: 50%;
transform: translateY(-50%);
background: #1f2937;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
animation: fadeIn 0.2s ease 0.5s forwards;
pointer-events: none;
z-index: 1001;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
/* Hide tooltips on mobile */
@media (max-width: 768px) {
.zoom-button-plus:hover:not(:disabled)::before,
.zoom-button-minus:hover:not(:disabled)::before {
display: none;
}
} }
</style> </style>

View file

@ -1,40 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { ref, Ref } from 'vue'; import { ref, Ref } from 'vue';
import { mdiChartTimelineVariant, mdiGithub } from '@mdi/js'; import { mdiChartTimelineVariant, mdiGithub, mdiMapMarker, mdiCalendar, mdiLockOpenVariant } from '@mdi/js';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue'; import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';
import BaseButton from '@/Components/BaseButton.vue'; import BaseButton from '@/Components/BaseButton.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue'; import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
import { MapOptions } from '@/Components/Map/MapOptions'; import { MapOptions } from '@/Components/Map/MapOptions';
// import { stardust } from '@eidellev/adonis-stardust/client';
import SearchMap from '@/Components/Map/SearchMap.vue'; import SearchMap from '@/Components/Map/SearchMap.vue';
import { OpensearchDocument } from '@/Dataset'; import { OpensearchDocument } from '@/Dataset';
// const fitBounds: LatLngBoundsExpression = [
// [46.4318173285, 9.47996951665],
// [49.0390742051, 16.9796667823],
// ];
// const mapId = 'map';
// const coverage = {
// x_min: undefined,
// y_min: undefined,
// x_max: undefined,
// y_max: undefined,
// elevation_min: undefined,
// elevation_max: undefined,
// elevation_absolut: undefined,
// depth_min: undefined,
// depth_max: undefined,
// depth_absolut: undefined,
// time_min: undefined,
// time_max: undefined,
// time_absolut: undefined,
// };
// Replace with your actual data
const datasets: Ref<OpensearchDocument[]> = ref([]); const datasets: Ref<OpensearchDocument[]> = ref([]);
const openAccessLicences: Array<string> = ['CC-BY-4.0', 'CC-BY-SA-4.0']; const openAccessLicences: Array<string> = ['CC-BY-4.0', 'CC-BY-SA-4.0'];
@ -48,58 +23,499 @@ const mapOptions: MapOptions = {
<template> <template>
<LayoutAuthenticated :showAsideMenu="false"> <LayoutAuthenticated :showAsideMenu="false">
<Head title="Map" /> <Head title="Map" />
<!-- <section class="p-6" v-bind:class="containerMaxW"> -->
<SectionMain> <SectionMain>
<SectionTitleLineWithButton v-bind:icon="mdiChartTimelineVariant" title="Tethys Map" main> <SectionTitleLineWithButton v-bind:icon="mdiChartTimelineVariant" title="Tethys Map" main>
<BaseButton href="https://gitea.geosphere.at/geolba/tethys" target="_blank" :icon="mdiGithub" <BaseButton
label="Star on GeoSPhere Forgejo" color="contrast" rounded-full small /> href="https://gitea.geosphere.at/geolba/tethys"
<!-- <BaseButton :route-name="stardust.route('app.login.show')" label="Login" color="white" rounded-full small /> --> target="_blank"
:icon="mdiGithub"
label="Star on GeoSphere Forgejo"
color="contrast"
rounded-full
small
/>
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<!-- <SectionBannerStarOnGitea /> --> <!-- Map Component with enhanced styling -->
<div class="map-wrapper">
<SearchMap :datasets="datasets" :map-options="mapOptions"></SearchMap>
</div>
<!-- <CardBox> --> <!-- Results Header -->
<!-- <div id="map" class="map-container mapDesktop mt-6 mb-6 rounded-2xl py-12 px-6 text-center"> <div v-if="datasets.length > 0" class="results-header">
<DrawControlComponent ref="draw" :preserve="false" :mapId="mapId" :southWest="southWest" <h2 class="results-title">
:northEast="northEast"> <span class="results-count">{{ datasets.length }}</span>
</DrawControlComponent> {{ datasets.length === 1 ? 'Dataset' : 'Datasets' }} Found
</div> --> </h2>
<SearchMap :datasets="datasets" :map-options="mapOptions"></SearchMap> <p class="results-subtitle">Click on any card to view details</p>
</div>
<div d="search-result-list-wrapper" class="flex flex-wrap col-span-24 h-full"> <!-- Enhanced Results Grid -->
<div v-for="dataset in datasets" :key="dataset.id" class="w-full sm:w-1/2 md:w-1/3 lg:w-1/4 p-4"> <div class="results-grid">
<div class="bg-white rounded shadow p-6"> <div
<h3 class="text-lg leading-tight text-gray-500 dark:text-slate-400"> v-for="(dataset, index) in datasets"
:key="dataset.id"
class="dataset-card"
:style="{ animationDelay: `${index * 50}ms` }"
>
<!-- Card Header with Icon -->
<div class="card-header">
<div class="card-icon">
<svg class="icon" viewBox="0 0 24 24">
<path :d="mdiMapMarker" />
</svg>
</div>
<span class="card-type">{{ dataset.doctype }}</span>
</div>
<!-- Card Content -->
<div class="card-content">
<h3 class="card-title">
{{ dataset.title_output }} {{ dataset.title_output }}
</h3> </h3>
<!-- <h2 class="text-xl font-bold mb-2">{{ dataset.title_output }}</h2> --> <p class="card-abstract">
<p class="text-gray-700 mb-2">{{ dataset.abstract_output }}</p> {{ dataset.abstract_output }}
<div class="text-sm text-gray-600">
<div v-for="author in dataset.author" :key="author" class="mb-1">{{ author }}</div>
</div>
<div class="mt-4">
<span
class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">
{{ dataset.year }}
</span>
<span
class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">
{{ dataset.language }}
</span>
</div>
<p>
<span class="label"><i class="fas fa-file"></i> {{ dataset.doctype }}</span>
<!-- <span>Licence: {{ document.licence }}</span> -->
<span v-if="openAccessLicences.includes(dataset.licence)" class="label titlecase"><i
class="fas fa-lock-open"></i> Open Access</span>
</p> </p>
</div> </div>
<!-- Authors Section -->
<div v-if="dataset.author && dataset.author.length > 0" class="card-authors">
<div class="author-label">Authors:</div>
<div class="author-list">
<span v-for="(author, idx) in dataset.author.slice(0, 3)" :key="idx" class="author-tag">
{{ author }}
</span>
<span v-if="dataset.author.length > 3" class="author-more"> +{{ dataset.author.length - 3 }} more </span>
</div>
</div>
<!-- Card Footer with Metadata -->
<div class="card-footer">
<div class="metadata-tags">
<span class="tag tag-year">
<svg class="tag-icon" viewBox="0 0 24 24">
<path :d="mdiCalendar" />
</svg>
{{ dataset.year }}
</span>
<span class="tag tag-language">
{{ dataset.language?.toUpperCase() }}
</span>
<span v-if="openAccessLicences.includes(dataset.licence)" class="tag tag-open-access">
<svg class="tag-icon" viewBox="0 0 24 24">
<path :d="mdiLockOpenVariant" />
</svg>
Open Access
</span>
</div>
</div>
<!-- Hover Effect Overlay -->
<div class="card-overlay"></div>
</div> </div>
</div> </div>
<!-- Empty State -->
<div v-if="datasets.length === 0" class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h3 class="empty-title">No datasets selected</h3>
<p class="empty-description">Draw a rectangle on the map to search for datasets in that area</p>
</div>
</SectionMain> </SectionMain>
<!-- </section> -->
</LayoutAuthenticated> </LayoutAuthenticated>
</template> </template>
<style scoped>
/* Map Wrapper */
.map-wrapper {
margin-top: 1.5rem;
margin-bottom: 2rem;
position: relative;
border-radius: 1rem;
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.3s ease;
}
.map-wrapper:hover {
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* Results Header */
.results-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e5e7eb;
}
.dark .results-header {
border-bottom-color: #374151;
}
.results-title {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 0.25rem;
}
.dark .results-title {
color: #f9fafb;
}
.results-count {
display: inline-block;
padding: 0.25rem 0.75rem;
background: linear-gradient(135deg, #65dc21 0%, #357c06 100%);
color: white;
border-radius: 0.5rem;
font-size: 1.25rem;
margin-right: 0.5rem;
}
.results-subtitle {
color: #6b7280;
font-size: 0.875rem;
}
.dark .results-subtitle {
color: #9ca3af;
}
/* Results Grid */
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
/* Dataset Card */
.dataset-card {
background: white;
border-radius: 1rem;
overflow: hidden;
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
cursor: pointer;
animation: fadeInUp 0.5s ease-out forwards;
opacity: 0;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dark .dataset-card {
background: #1f2937;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3);
}
.dataset-card:hover {
transform: translateY(-4px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.dark .dataset-card:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
}
/* Card Header */
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: linear-gradient(135deg, #65dc21 0%, #357c06 100%);
color: white;
}
.card-icon {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
}
.icon {
width: 1.25rem;
height: 1.25rem;
fill: currentColor;
}
.card-type {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(255, 255, 255, 0.2);
padding: 0.25rem 0.75rem;
border-radius: 9999px;
}
/* Card Content */
.card-content {
padding: 1.5rem;
}
.card-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.75rem;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dark .card-title {
color: #f9fafb;
}
.card-abstract {
color: #6b7280;
font-size: 0.875rem;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dark .card-abstract {
color: #9ca3af;
}
/* Authors Section */
.card-authors {
padding: 0 1.5rem 1rem;
border-top: 1px solid #f3f4f6;
}
.dark .card-authors {
border-top-color: #374151;
}
.author-label {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.dark .author-label {
color: #9ca3af;
}
.author-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.author-tag {
font-size: 0.75rem;
color: #4b5563;
background: #f3f4f6;
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
white-space: nowrap;
}
.dark .author-tag {
color: #d1d5db;
background: #374151;
}
.author-more {
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
.dark .author-more {
color: #9ca3af;
}
/* Card Footer */
.card-footer {
padding: 1rem 1.5rem;
background: #f9fafb;
border-top: 1px solid #f3f4f6;
}
.dark .card-footer {
background: #111827;
border-top-color: #374151;
}
.metadata-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
transition: transform 0.2s ease;
}
.tag:hover {
transform: scale(1.05);
}
.tag-icon {
width: 0.875rem;
height: 0.875rem;
fill: currentColor;
}
.tag-year {
background: #dbeafe;
color: #1e40af;
}
.dark .tag-year {
background: #1e3a8a;
color: #93c5fd;
}
.tag-language {
background: #fce7f3;
color: #9f1239;
}
.dark .tag-language {
background: #831843;
color: #fbcfe8;
}
.tag-open-access {
background: #d1fae5;
color: #065f46;
}
.dark .tag-open-access {
background: #064e3b;
color: #6ee7b7;
}
/* Card Overlay */
.card-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.dataset-card:hover .card-overlay {
opacity: 1;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 1rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.dark .empty-state {
background: #1f2937;
}
.empty-icon {
width: 4rem;
height: 4rem;
margin: 0 auto 1.5rem;
color: #d1d5db;
}
.dark .empty-icon {
color: #4b5563;
}
.empty-icon svg {
width: 100%;
height: 100%;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.5rem;
}
.dark .empty-title {
color: #f9fafb;
}
.empty-description {
color: #6b7280;
font-size: 0.875rem;
}
.dark .empty-description {
color: #9ca3af;
}
/* Responsive Design */
@media (max-width: 640px) {
.results-grid {
grid-template-columns: 1fr;
}
.results-title {
font-size: 1.25rem;
}
.card-content {
padding: 1rem;
}
}
</style>