From 3d9bcfe62d58edb99dae271bec2dc1d5e66618b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20N=C3=BCst?= Date: Mon, 27 Oct 2025 12:21:21 +0100 Subject: [PATCH 01/11] Adds gazetteer/geocoding functionality to the map --- optimap/__init__.py | 2 +- optimap/context_processors.py | 11 ++ optimap/settings.py | 8 + publications/static/js/main.js | 9 + publications/static/js/map-gazetteer.js | 194 +++++++++++++++++++++ publications/urls.py | 5 + publications/views_gazetteer.py | 217 ++++++++++++++++++++++++ 7 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 publications/static/js/map-gazetteer.js create mode 100644 publications/views_gazetteer.py diff --git a/optimap/__init__.py b/optimap/__init__.py index a471784..83af610 100644 --- a/optimap/__init__.py +++ b/optimap/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.5.0" +__version__ = "0.6.0" VERSION = __version__ \ No newline at end of file diff --git a/optimap/context_processors.py b/optimap/context_processors.py index 2125cdd..b1240cb 100644 --- a/optimap/context_processors.py +++ b/optimap/context_processors.py @@ -1,7 +1,18 @@ import optimap +from django.conf import settings def get_version(request): """ Return package version as listed in `__version__` in `init.py`. """ return {"optimap_version": optimap.__version__} + +def gazetteer_settings(request): + """ + Return gazetteer/geocoding settings for use in templates. + """ + return { + "gazetteer_provider": getattr(settings, 'GAZETTEER_PROVIDER', 'nominatim'), + "gazetteer_placeholder": getattr(settings, 'GAZETTEER_PLACEHOLDER', 'Search for a location...'), + "gazetteer_api_key": getattr(settings, 'GAZETTEER_API_KEY', ''), + } diff --git a/optimap/settings.py b/optimap/settings.py index beffc06..10d8d3e 100644 --- a/optimap/settings.py +++ b/optimap/settings.py @@ -277,6 +277,7 @@ 'django.contrib.messages.context_processors.messages', 'optimap.urls.site', 'optimap.context_processors.get_version', + 'optimap.context_processors.gazetteer_settings', ], }, }, @@ -376,3 +377,10 @@ ADMINS = [('OPTIMAP', 'login@optimap.science')] FEED_MAX_ITEMS = 20 + +# Gazetteer / Geocoding Settings +# Configures the location search (gazetteer) feature on the map +GAZETTEER_PROVIDER = env('OPTIMAP_GAZETTEER_PROVIDER', default='nominatim') +GAZETTEER_PLACEHOLDER = env('OPTIMAP_GAZETTEER_PLACEHOLDER', default='Search for a location...') +# Optional API key for commercial providers (not required for Nominatim) +GAZETTEER_API_KEY = env('OPTIMAP_GAZETTEER_API_KEY', default='') diff --git a/publications/static/js/main.js b/publications/static/js/main.js index 4512e82..2e3b246 100644 --- a/publications/static/js/main.js +++ b/publications/static/js/main.js @@ -59,6 +59,15 @@ async function initMap() { const interactionManager = new MapInteractionManager(map, pubsLayer); console.log('Enhanced map interaction enabled: overlapping polygon selection and geometry highlighting'); } + // Initialize gazetteer (location search) + if (typeof MapGazetteerManager !== 'undefined' && window.OPTIMAP_SETTINGS?.gazetteer) { + const gazetteerManager = new MapGazetteerManager(map, window.OPTIMAP_SETTINGS.gazetteer); + console.log('Gazetteer enabled'); + + // Make gazetteer manager globally available + window.mapGazetteerManager = gazetteerManager; + } + // Fit map to markers if (publicationsGroup.getBounds().isValid()) { diff --git a/publications/static/js/map-gazetteer.js b/publications/static/js/map-gazetteer.js new file mode 100644 index 0000000..ded2ae0 --- /dev/null +++ b/publications/static/js/map-gazetteer.js @@ -0,0 +1,194 @@ +// publications/static/js/map-gazetteer.js +// Gazetteer (location search) functionality for the map + +/** + * Map Gazetteer Manager + * Provides location search using configurable geocoding providers + * - Separate from publication search (doesn't filter publications) + * - Pans/zooms map to searched location + * - Supports multiple geocoding services (Nominatim, Photon, etc.) + */ +class MapGazetteerManager { + constructor(map, options = {}) { + this.map = map; + this.provider = options.provider || 'nominatim'; + this.placeholder = options.placeholder || 'Search for a location...'; + this.geocoder = null; + + console.group('📍 Map Gazetteer Initialization'); + console.log('Provider:', this.provider); + console.log('Placeholder:', this.placeholder); + + this.init(); + console.groupEnd(); + } + + /** + * Initialize the geocoder control + */ + init() { + if (!this.map) { + console.warn('⚠️ Map not found, cannot initialize gazetteer'); + return; + } + + if (typeof L === 'undefined' || !L.Control || !L.Control.Geocoder) { + console.warn('⚠️ Leaflet Control Geocoder not loaded, cannot initialize gazetteer'); + return; + } + + // Get the geocoder instance based on provider + const geocoderInstance = this.getGeocoderInstance(); + + if (!geocoderInstance) { + console.warn('⚠️ Unknown geocoder provider:', this.provider); + return; + } + + // Create the geocoder control + this.geocoder = L.Control.geocoder({ + geocoder: geocoderInstance, + placeholder: this.placeholder, + defaultMarkGeocode: false, // Custom handling + position: 'topleft', + collapsed: true, + errorMessage: 'No location found', + }); + + // Add custom handler for geocoding results + this.geocoder.on('markgeocode', (e) => { + this.handleGeocode(e); + }); + + // Add to map + this.geocoder.addTo(this.map); + + // Add accessibility attributes to the geocoder button + this.addAccessibilityAttributes(); + + console.log('✅ Gazetteer initialized with', this.provider); + } + + /** + * Add accessibility attributes to the geocoder button + */ + addAccessibilityAttributes() { + // Wait for DOM to be ready + setTimeout(() => { + const geocoderButton = document.querySelector('.leaflet-control-geocoder-icon'); + if (geocoderButton) { + geocoderButton.setAttribute('title', 'Search locations on the map'); + geocoderButton.setAttribute('aria-label', 'Search locations on the map'); + console.log('✅ Added accessibility attributes to gazetteer button'); + } else { + console.warn('⚠️ Could not find geocoder button to add accessibility attributes'); + } + }, 100); + } + + /** + * Get geocoder instance based on provider name + */ + getGeocoderInstance() { + const provider = this.provider.toLowerCase(); + + switch (provider) { + case 'nominatim': + // Use built-in Nominatim geocoder with proxy + // Need full URL (with protocol and host) for URL constructor + const nominatimUrl = `${window.location.origin}/api/v1/gazetteer/nominatim/`; + console.log('Using built-in Nominatim geocoder with proxy URL:', nominatimUrl); + return L.Control.Geocoder.nominatim({ + serviceUrl: nominatimUrl, + geocodingQueryParams: { + format: 'json', + addressdetails: 1 + } + }); + + case 'photon': + // Use built-in Photon geocoder with proxy + const photonUrl = `${window.location.origin}/api/v1/gazetteer/photon/`; + console.log('Using built-in Photon geocoder with proxy URL:', photonUrl); + return L.Control.Geocoder.photon({ + serviceUrl: photonUrl + }); + + default: + console.warn('⚠️ Unknown geocoder provider:', provider); + return null; + } + } + + /** + * Handle geocoding result + * Pans to location and adds temporary marker + */ + handleGeocode(e) { + const result = e.geocode; + const latlng = result.center; + + console.group('📍 Gazetteer Result'); + console.log('Name:', result.name); + console.log('Location:', latlng); + console.log('Bounds:', result.bbox); + console.groupEnd(); + + // Fit to bounds if available, otherwise pan to point + if (result.bbox) { + const bbox = result.bbox; + const bounds = L.latLngBounds( + L.latLng(bbox.getSouth(), bbox.getWest()), + L.latLng(bbox.getNorth(), bbox.getEast()) + ); + this.map.fitBounds(bounds, { maxZoom: 16 }); + } else { + this.map.setView(latlng, 13); + } + + // Add temporary marker that disappears after 5 seconds + const marker = L.marker(latlng, { + icon: L.divIcon({ + className: 'gazetteer-marker', + html: '', + iconSize: [32, 32], + iconAnchor: [16, 32], + }) + }) + .addTo(this.map) + .bindPopup(result.name) + .openPopup(); + + // Remove marker after 5 seconds + setTimeout(() => { + this.map.removeLayer(marker); + console.log('🗑️ Temporary gazetteer marker removed'); + }, 5000); + } + + /** + * Programmatically search for a location + */ + search(query) { + if (!this.geocoder) { + console.warn('⚠️ Gazetteer not initialized'); + return; + } + + console.log('🔍 Searching for location:', query); + + const geocoderInstance = this.geocoder.options.geocoder; + geocoderInstance.geocode(query, (results) => { + if (results && results.length > 0) { + console.log(`📍 Found ${results.length} location(s)`); + const result = results[0]; + this.handleGeocode({ geocode: result }); + } else { + console.warn('⚠️ No location found for query:', query); + } + }); + } +} + +// Make available globally +window.MapGazetteerManager = MapGazetteerManager; diff --git a/publications/urls.py b/publications/urls.py index 0532d95..53e74a1 100644 --- a/publications/urls.py +++ b/publications/urls.py @@ -6,6 +6,7 @@ from publications import views from publications import views_geometry from publications import views_feeds +from publications import views_gazetteer from .feeds import GeoFeed from .feeds_v2 import GlobalGeoFeed, RegionalGeoFeed from django.views.generic import RedirectView @@ -26,6 +27,10 @@ path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/ui/', SpectacularRedocView.as_view(url_name='optimap:schema'), name='redoc'), + # API v1 Gazetteer proxy endpoints + path('api/v1/gazetteer//search/', views_gazetteer.gazetteer_search, name='gazetteer-search'), + path('api/v1/gazetteer//reverse/', views_gazetteer.gazetteer_reverse, name='gazetteer-reverse'), + # API v1 Feed endpoints - GeoRSS format (with .rss extension) path('api/v1/feeds/optimap-global.rss', GlobalGeoFeed(feed_type_variant="georss"), name='api-feed-georss'), path('api/v1/feeds/optimap-.rss', RegionalGeoFeed(feed_type_variant="georss"), name='api-continent-georss'), diff --git a/publications/views_gazetteer.py b/publications/views_gazetteer.py new file mode 100644 index 0000000..15ef057 --- /dev/null +++ b/publications/views_gazetteer.py @@ -0,0 +1,217 @@ +""" +OPTIMAP gazetteer proxy views. +Provides CORS-safe proxying for geocoding services. +""" + +import requests +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.conf import settings +import logging + +logger = logging.getLogger(__name__) + +# Geocoding service configurations +GEOCODING_SERVICES = { + 'nominatim': { + 'search_url': 'https://nominatim.openstreetmap.org/search', + 'reverse_url': 'https://nominatim.openstreetmap.org/reverse', + 'requires_key': False, + 'user_agent': 'OPTIMAP/1.0', + }, + 'photon': { + 'search_url': 'https://photon.komoot.io/api/', + 'reverse_url': 'https://photon.komoot.io/reverse', + 'requires_key': False, + }, +} + + +@require_http_methods(["GET"]) +def gazetteer_search(request, provider): + """ + Proxy geocoding search requests to avoid CORS issues. + + Args: + request: Django request object + provider: Geocoding provider name (nominatim, photon, etc.) + + Returns: + JsonResponse with geocoding results + """ + # Validate provider + provider = provider.lower() + if provider not in GEOCODING_SERVICES: + return JsonResponse({ + 'error': f'Unknown provider: {provider}', + 'available_providers': list(GEOCODING_SERVICES.keys()) + }, status=400) + + service_config = GEOCODING_SERVICES[provider] + + # Check if API key is required + if service_config.get('requires_key', False): + api_key = getattr(settings, 'GAZETTEER_API_KEY', '') + if not api_key: + return JsonResponse({ + 'error': f'Provider {provider} requires an API key' + }, status=400) + + # Get search query + query = request.GET.get('q', '').strip() + if not query: + return JsonResponse({ + 'error': 'Missing search query parameter "q"' + }, status=400) + + try: + # Build request parameters based on provider + if provider == 'nominatim': + params = { + 'q': query, + 'format': request.GET.get('format', 'json'), + 'limit': request.GET.get('limit', '5'), + 'addressdetails': request.GET.get('addressdetails', '1'), + } + headers = { + 'User-Agent': service_config.get('user_agent', 'OPTIMAP/1.0'), + } + + elif provider == 'photon': + params = { + 'q': query, + 'limit': request.GET.get('limit', '5'), + 'lang': request.GET.get('lang', 'en'), + } + headers = {} + + else: + # Generic parameter passthrough + params = dict(request.GET) + params['q'] = query + headers = {} + + # Make request to geocoding service + logger.info(f'Geocoding request: {provider} - {query}') + + response = requests.get( + service_config['search_url'], + params=params, + headers=headers, + timeout=10 + ) + + response.raise_for_status() + + # Return the response as-is + try: + data = response.json() + except ValueError: + return JsonResponse({ + 'error': 'Invalid JSON response from geocoding service' + }, status=502) + + logger.info(f'Geocoding results: {len(data) if isinstance(data, list) else 1} results') + + return JsonResponse(data, safe=False) + + except requests.exceptions.Timeout: + logger.error(f'Geocoding timeout: {provider}') + return JsonResponse({ + 'error': 'Geocoding service timeout' + }, status=504) + + except requests.exceptions.RequestException as e: + logger.error(f'Geocoding error: {provider} - {str(e)}') + return JsonResponse({ + 'error': f'Geocoding service error: {str(e)}' + }, status=502) + + +@require_http_methods(["GET"]) +def gazetteer_reverse(request, provider): + """ + Proxy reverse geocoding requests (coordinates to address). + + Args: + request: Django request object + provider: Geocoding provider name + + Returns: + JsonResponse with reverse geocoding result + """ + # Validate provider + provider = provider.lower() + if provider not in GEOCODING_SERVICES: + return JsonResponse({ + 'error': f'Unknown provider: {provider}', + 'available_providers': list(GEOCODING_SERVICES.keys()) + }, status=400) + + service_config = GEOCODING_SERVICES[provider] + + # Get coordinates + lat = request.GET.get('lat', '').strip() + lon = request.GET.get('lon', '').strip() + + if not lat or not lon: + return JsonResponse({ + 'error': 'Missing lat/lon parameters' + }, status=400) + + try: + # Validate coordinates + lat_float = float(lat) + lon_float = float(lon) + + if not (-90 <= lat_float <= 90): + return JsonResponse({'error': 'Invalid latitude'}, status=400) + if not (-180 <= lon_float <= 180): + return JsonResponse({'error': 'Invalid longitude'}, status=400) + + except ValueError: + return JsonResponse({'error': 'Invalid coordinate format'}, status=400) + + try: + # Build request parameters + if provider == 'nominatim': + params = { + 'lat': lat, + 'lon': lon, + 'format': request.GET.get('format', 'json'), + } + headers = { + 'User-Agent': service_config.get('user_agent', 'OPTIMAP/1.0'), + } + + elif provider == 'photon': + params = { + 'lat': lat, + 'lon': lon, + } + headers = {} + + else: + params = dict(request.GET) + headers = {} + + # Make request + logger.info(f'Reverse geocoding: {provider} - {lat},{lon}') + + response = requests.get( + service_config['reverse_url'], + params=params, + headers=headers, + timeout=10 + ) + + response.raise_for_status() + data = response.json() + + return JsonResponse(data, safe=False) + + except requests.exceptions.RequestException as e: + logger.error(f'Reverse geocoding error: {provider} - {str(e)}') + return JsonResponse({ + 'error': f'Reverse geocoding service error: {str(e)}' + }, status=502) From fb41439078f789af76f224c9f78280b0cc2832fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20N=C3=BCst?= Date: Mon, 27 Oct 2025 12:30:43 +0100 Subject: [PATCH 02/11] Improves accessibility and add map search features --- optimap/__init__.py | 2 +- publications/static/css/accessibility.css | 457 ++++++++++++++++ publications/static/css/main.css | 38 +- publications/static/css/map-search.css | 316 +++++++++++ .../static/js/accessibility-toggle.js | 158 ++++++ publications/static/js/main.js | 47 +- .../static/js/map-keyboard-navigation.js | 281 ++++++++++ publications/static/js/map-search.js | 517 ++++++++++++++++++ publications/static/js/map-zoom-to-all.js | 123 +++++ publications/templates/base.html | 53 +- publications/templates/main.html | 16 + .../templates/unified_menu_snippet.html | 9 +- publications/templates/work_landing_page.html | 39 +- publications/templates/works.html | 4 +- 14 files changed, 2016 insertions(+), 44 deletions(-) create mode 100644 publications/static/css/accessibility.css create mode 100644 publications/static/css/map-search.css create mode 100644 publications/static/js/accessibility-toggle.js create mode 100644 publications/static/js/map-keyboard-navigation.js create mode 100644 publications/static/js/map-search.js create mode 100644 publications/static/js/map-zoom-to-all.js diff --git a/optimap/__init__.py b/optimap/__init__.py index 83af610..facd813 100644 --- a/optimap/__init__.py +++ b/optimap/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.6.0" +__version__ = "0.7.0" VERSION = __version__ \ No newline at end of file diff --git a/publications/static/css/accessibility.css b/publications/static/css/accessibility.css new file mode 100644 index 0000000..e065fc8 --- /dev/null +++ b/publications/static/css/accessibility.css @@ -0,0 +1,457 @@ +/* + * Accessibility Enhancements for OPTIMAP + * Implements WCAG 2.1 AA compliance features + * - High contrast theme + * - Focus indicators + * - Screen reader utilities + */ + +/* ========================================================================== + Screen Reader Only Utility Class + ========================================================================== */ + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.sr-only-focusable:active, +.sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + overflow: visible; + clip: auto; + white-space: normal; +} + +/* ========================================================================== + Focus Indicators (WCAG 2.4.7) + High contrast, visible focus indicators for all interactive elements + Only shown when high contrast mode is enabled + ========================================================================== */ + +/* Focus indicators only in high contrast mode */ +body.high-contrast *:focus { + outline: 3px solid #FF6B35 !important; + outline-offset: 2px; +} + +body.high-contrast a:focus, +body.high-contrast button:focus, +body.high-contrast input:focus, +body.high-contrast select:focus, +body.high-contrast textarea:focus, +body.high-contrast [tabindex]:focus { + outline: 3px solid #FF6B35 !important; + outline-offset: 2px; +} + +/* Focus indicators for dark backgrounds (navbar, footer) in high contrast mode */ +body.high-contrast .navbar *:focus, +body.high-contrast .footer *:focus, +body.high-contrast .bg-primary *:focus { + outline: 3px solid #FFD700 !important; /* Gold for better visibility on dark */ + outline-offset: 2px; +} + +/* Focus for Bootstrap buttons in high contrast mode */ +body.high-contrast .btn:focus, +body.high-contrast .btn:active:focus { + outline: 3px solid #FF6B35 !important; + outline-offset: 2px; + box-shadow: 0 0 0 0.2rem rgba(255, 107, 53, 0.25) !important; +} + +body.high-contrast .btn-primary:focus, +body.high-contrast .btn-primary:active:focus { + box-shadow: 0 0 0 0.2rem rgba(21, 143, 155, 0.5) !important; +} + +/* Focus for dropdown items in high contrast mode */ +body.high-contrast .dropdown-item:focus { + outline: 2px solid #FF6B35 !important; + outline-offset: -2px; + background-color: #f8f9fa; +} + +/* Focus for form controls in high contrast mode */ +body.high-contrast .form-control:focus { + border-color: #FF6B35; + box-shadow: 0 0 0 0.2rem rgba(255, 107, 53, 0.25); + outline: 2px solid #FF6B35; +} + +/* ========================================================================== + Skip Link (WCAG 2.4.1) + ========================================================================== */ + +.skip-link { + position: absolute; + top: -40px; + left: 0; + background: #000; + color: #fff; + padding: 8px 12px; + z-index: 10000; + text-decoration: none; + font-weight: bold; +} + +.skip-link:focus { + top: 0; +} + +body.high-contrast .skip-link:focus { + outline: 3px solid #FFD700; + outline-offset: 0; +} + +/* ========================================================================== + High Contrast Theme + Activated via JavaScript toggle, saved in localStorage + ========================================================================== */ + +body.high-contrast { + /* Enhanced contrast colors */ + background-color: #000000 !important; + color: #FFFFFF !important; +} + +body.high-contrast .navbar { + background-color: #000000 !important; + border-bottom: 3px solid #FFFFFF; +} + +body.high-contrast .footer-copyright { + background-color: #000000 !important; + border-top: 3px solid #FFFFFF; +} + +body.high-contrast .bg-primary { + background-color: #000000 !important; + color: #FFFF00 !important; /* Yellow for maximum contrast */ +} + +body.high-contrast a { + color: #00FFFF !important; /* Cyan for links */ + text-decoration: underline; + font-weight: bold; +} + +body.high-contrast a:hover, +body.high-contrast a:focus { + color: #FFFF00 !important; /* Yellow on hover */ + text-decoration: underline; + background-color: #000080; /* Dark blue background */ +} + +body.high-contrast .btn-primary { + background-color: #FFFF00 !important; + color: #000000 !important; + border: 3px solid #FFFFFF !important; + font-weight: bold; +} + +body.high-contrast .btn-primary:hover, +body.high-contrast .btn-primary:focus { + background-color: #FFFFFF !important; + color: #000000 !important; + border: 3px solid #FFFF00 !important; +} + +body.high-contrast .btn-secondary { + background-color: #808080 !important; + color: #FFFFFF !important; + border: 3px solid #FFFFFF !important; +} + +body.high-contrast .btn-danger { + background-color: #FF0000 !important; + color: #FFFFFF !important; + border: 3px solid #FFFFFF !important; +} + +body.high-contrast .card { + background-color: #1A1A1A !important; + border: 2px solid #FFFFFF !important; + color: #FFFFFF !important; +} + +body.high-contrast .card-header { + background-color: #000000 !important; + border-bottom: 2px solid #FFFFFF !important; + color: #FFFF00 !important; +} + +body.high-contrast .alert { + border: 3px solid #FFFFFF !important; + font-weight: bold; +} + +body.high-contrast .alert-primary { + background-color: #000080 !important; + color: #FFFF00 !important; +} + +body.high-contrast .alert-success { + background-color: #006400 !important; + color: #00FF00 !important; +} + +body.high-contrast .alert-danger { + background-color: #8B0000 !important; + color: #FF6347 !important; +} + +body.high-contrast .form-control { + background-color: #FFFFFF !important; + color: #000000 !important; + border: 2px solid #000000 !important; +} + +body.high-contrast .form-control:focus { + outline: 3px solid #FFFF00 !important; + border-color: #FFFF00 !important; +} + +body.high-contrast .dropdown-menu { + background-color: #1A1A1A !important; + border: 2px solid #FFFFFF !important; +} + +body.high-contrast .dropdown-item { + color: #FFFFFF !important; +} + +body.high-contrast .dropdown-item:hover, +body.high-contrast .dropdown-item:focus { + background-color: #000080 !important; + color: #FFFF00 !important; +} + +body.high-contrast .dropdown-divider { + border-top: 2px solid #FFFFFF !important; +} + +/* High contrast for map elements */ +body.high-contrast #map { + border: 3px solid #FFFFFF !important; +} + +body.high-contrast .leaflet-popup-content-wrapper { + background-color: #1A1A1A !important; + color: #FFFFFF !important; + border: 3px solid #FFFFFF !important; +} + +body.high-contrast .leaflet-popup-content-wrapper a { + color: #00FFFF !important; +} + +body.high-contrast .leaflet-popup-tip { + background-color: #1A1A1A !important; + border: 2px solid #FFFFFF !important; +} + +/* High contrast focus indicators for Leaflet map controls */ +body.high-contrast .leaflet-bar a:focus, +body.high-contrast .leaflet-control a:focus, +body.high-contrast .leaflet-control button:focus { + outline: 3px solid #FFD700 !important; + outline-offset: 2px; +} + +body.high-contrast .leaflet-container a.leaflet-popup-close-button:focus { + outline: 3px solid #FFD700 !important; + outline-offset: 2px; +} + +body.high-contrast .leaflet-control-attribution a:focus { + outline: 3px solid #FFD700 !important; + outline-offset: 2px; +} + +/* High contrast for tables */ +body.high-contrast table { + border: 2px solid #FFFFFF !important; +} + +body.high-contrast th { + background-color: #000000 !important; + color: #FFFF00 !important; + border: 2px solid #FFFFFF !important; +} + +body.high-contrast td { + border: 1px solid #FFFFFF !important; +} + +/* ========================================================================== + Accessibility Toggle Button + ========================================================================== */ + +#accessibility-toggle { + position: fixed; + bottom: 15px; + right: 15px; + z-index: 9999; + background-color: #158F9B; + color: #FFFFFF; + border: 2px solid #FFFFFF; + border-radius: 50%; + width: 42px; + height: 42px; + font-size: 18px; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; +} + +#accessibility-toggle:hover { + background-color: #0A6870; + transform: scale(1.05); +} + +body.high-contrast #accessibility-toggle:focus { + outline: 3px solid #FFD700; + outline-offset: 3px; +} + +body.high-contrast #accessibility-toggle { + background-color: #FFFF00; + color: #000000; + border: 3px solid #FFFFFF; +} + +body.high-contrast #accessibility-toggle:hover { + background-color: #FFFFFF; + color: #000000; +} + +/* Tooltip for accessibility toggle */ +#accessibility-toggle[data-tooltip]:before { + content: attr(data-tooltip); + position: absolute; + right: 100%; + margin-right: 10px; + padding: 8px 12px; + background-color: #000; + color: #fff; + white-space: nowrap; + border-radius: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; +} + +#accessibility-toggle:hover[data-tooltip]:before, +#accessibility-toggle:focus[data-tooltip]:before { + opacity: 1; +} + +/* ========================================================================== + ARIA Live Regions + ========================================================================== */ + +#announcer, +#map-announcer { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +/* ========================================================================== + Improved Link Visibility + ========================================================================== */ + +/* External link indicators should be more visible */ +a[target="_blank"]::after, +a[rel*="external"]::after { + content: " \f35d"; /* FontAwesome external-link-alt icon */ + font-family: "Font Awesome 5 Free"; + font-weight: 900; + font-size: 0.8em; + margin-left: 0.2em; + opacity: 0.7; +} + +/* High contrast external link indicators */ +body.high-contrast a[target="_blank"]::after, +body.high-contrast a[rel*="external"]::after { + opacity: 1; + color: inherit; +} + +/* ========================================================================== + Improved Button States + ========================================================================== */ + +/* Make disabled state more obvious */ +button:disabled, +.btn:disabled, +input:disabled, +select:disabled, +textarea:disabled { + opacity: 0.5; + cursor: not-allowed; + outline: 2px dashed #999; +} + +body.high-contrast button:disabled, +body.high-contrast .btn:disabled, +body.high-contrast input:disabled { + background-color: #4D4D4D !important; + color: #999999 !important; + border: 2px dashed #FFFFFF !important; +} + +/* ========================================================================== + Reduced Motion (prefers-reduced-motion) + ========================================================================== */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ========================================================================== + Print Styles + ========================================================================== */ + +@media print { + .skip-link, + #accessibility-toggle, + .navbar, + .footer, + #map-announcer, + #announcer { + display: none !important; + } + + a[href]::after { + content: " (" attr(href) ")"; + } +} diff --git a/publications/static/css/main.css b/publications/static/css/main.css index 8d4709e..36edb74 100644 --- a/publications/static/css/main.css +++ b/publications/static/css/main.css @@ -253,21 +253,45 @@ h1.page-title { } /* Custom zoom to all features button */ -.leaflet-control-zoom-all { +.leaflet-control-zoom-to-all { + margin-top: 10px; +} + +.leaflet-control-zoom-to-all-button { background-color: white; border: 2px solid rgba(0,0,0,0.2); border-radius: 4px; - width: 26px; - height: 26px; - line-height: 26px; + width: 30px; + height: 30px; + line-height: 30px; text-align: center; cursor: pointer; - font-size: 18px; - font-weight: bold; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + color: #333; + text-decoration: none; + transition: background-color 0.2s ease; +} + +.leaflet-control-zoom-to-all-button i { + font-size: 14px; } -.leaflet-control-zoom-all:hover { +.leaflet-control-zoom-to-all-button:hover { background-color: #f4f4f4; + color: #000; +} + +.leaflet-control-zoom-to-all-button:active { + background-color: #e0e0e0; +} + +/* Focus outline only in high contrast mode */ +body.high-contrast .leaflet-control-zoom-to-all-button:focus { + outline: 3px solid #FFD700; + outline-offset: 2px; } /* Works page styles */ diff --git a/publications/static/css/map-search.css b/publications/static/css/map-search.css new file mode 100644 index 0000000..1a715c7 --- /dev/null +++ b/publications/static/css/map-search.css @@ -0,0 +1,316 @@ +/* + * Map Search Component Styles + * Search bar integrated into navbar for filtering map publications + */ + +/* ========================================================================== + Navbar Search Container + ========================================================================== */ + +.navbar-search-container { + display: flex; + align-items: center; + margin: 0 1rem; + flex-grow: 1; + max-width: 500px; +} + +.navbar-search-form { + width: 100%; + margin: 0; +} + +.search-input-wrapper { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +/* ========================================================================== + Search Input + ========================================================================== */ + +.navbar-search-input { + width: 100%; + padding: 0.5rem 2.5rem 0.5rem 2.5rem; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 25px; + background-color: rgba(255, 255, 255, 0.15); + color: #FFFFFF; + font-size: 14px; + transition: all 0.3s ease; + outline: none; +} + +.navbar-search-input::placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.navbar-search-input:hover { + background-color: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.5); +} + +.navbar-search-input:focus { + background-color: rgba(255, 255, 255, 0.95); + color: #000000; +} + +.navbar-search-input:focus::placeholder { + color: rgba(0, 0, 0, 0.5); +} + +/* Focus outline only in high contrast mode */ +body.high-contrast .navbar-search-input:focus { + border-color: #FFD700; + box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.3); +} + +/* Search icon */ +.search-input-wrapper .search-icon { + position: absolute; + left: 12px; + color: rgba(255, 255, 255, 0.7); + pointer-events: none; + z-index: 1; + transition: color 0.3s ease; +} + +.navbar-search-input:focus ~ .search-icon, +.navbar-search-input:focus + .search-icon { + color: #158F9B; +} + +/* Ensure icon is before input in DOM but appears correctly */ +.search-input-wrapper { + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; +} + +.search-input-wrapper .navbar-search-input { + order: 2; +} + +.search-input-wrapper .search-icon { + order: 1; +} + +/* ========================================================================== + Search Submit Button (Magnifying Glass) + ========================================================================== */ + +.navbar-search-submit-btn { + position: absolute; + right: 40px; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.8); + padding: 0; + cursor: pointer; + border-radius: 50%; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + z-index: 2; + font-size: 16px; +} + +.navbar-search-submit-btn:hover { + background-color: rgba(255, 255, 255, 0.2); + color: #FFFFFF; +} + +.navbar-search-submit-btn:focus { + background-color: rgba(255, 255, 255, 0.3); + color: #FFFFFF; +} + +/* Focus outline only in high contrast mode */ +body.high-contrast .navbar-search-submit-btn:focus { + outline: 2px solid #FFD700; + outline-offset: 2px; +} + +.navbar-search-input:focus ~ .navbar-search-submit-btn { + color: #158F9B; +} + +.navbar-search-input:focus ~ .navbar-search-submit-btn:hover { + background-color: rgba(21, 143, 155, 0.1); + color: #0A6870; +} + +/* High contrast mode */ +body.high-contrast .navbar-search-submit-btn { + color: #FFFF00; +} + +body.high-contrast .navbar-search-submit-btn:hover { + color: #FFFFFF; +} + +/* ========================================================================== + Clear Button + ========================================================================== */ + +.navbar-clear-search-btn { + position: absolute; + right: 4px; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.7); + padding: 0; + cursor: pointer; + border-radius: 50%; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + z-index: 2; + font-size: 16px; +} + +.navbar-clear-search-btn:hover { + background-color: rgba(255, 255, 255, 0.2); + color: #FFFFFF; +} + +.navbar-clear-search-btn:focus { + background-color: rgba(255, 255, 255, 0.3); + color: #FFFFFF; +} + +/* Focus outline only in high contrast mode */ +body.high-contrast .navbar-clear-search-btn:focus { + outline: 2px solid #FFD700; + outline-offset: 2px; +} + +.navbar-search-input:focus ~ .navbar-clear-search-btn { + color: #158F9B; +} + +.navbar-search-input:focus ~ .navbar-clear-search-btn:hover { + background-color: rgba(21, 143, 155, 0.1); + color: #0A6870; +} + +/* ========================================================================== + Responsive Design + ========================================================================== */ + +@media (max-width: 991px) { + .navbar-search-container { + max-width: 300px; + margin: 0 0.5rem; + } + + .navbar-search-input { + font-size: 13px; + padding: 0.4rem 2.25rem 0.4rem 2.25rem; + } +} + +@media (max-width: 767px) { + .navbar-search-container { + display: none; /* Hide on very small screens, could be replaced with a toggle */ + } + + /* Alternative: Make it full width on mobile */ + /* .navbar-search-container { + position: absolute; + top: 50px; + left: 0; + right: 0; + max-width: none; + padding: 0.5rem; + background-color: #158F9B; + border-bottom: 1px solid rgba(255, 255, 255, 0.3); + } */ +} + +@media (min-width: 768px) and (max-width: 1199px) { + .tagline { + font-size: 1.2em !important; + } +} + +/* ========================================================================== + High Contrast Mode Adjustments + ========================================================================== */ + +body.high-contrast .navbar-search-input { + background-color: #FFFFFF; + color: #000000; + border-color: #FFFF00; +} + +body.high-contrast .navbar-search-input::placeholder { + color: #666666; +} + +body.high-contrast .navbar-search-input:focus { + background-color: #FFFF00; + color: #000000; + border-color: #FFFFFF; + box-shadow: 0 0 0 3px #FFFFFF; +} + +body.high-contrast .search-input-wrapper .search-icon { + color: #000000; +} + +body.high-contrast .navbar-clear-search-btn { + color: #000000; + background-color: rgba(255, 255, 0, 0.2); +} + +body.high-contrast .navbar-clear-search-btn:hover { + background-color: #FFFF00; + color: #000000; + border: 2px solid #000000; +} + +/* ========================================================================== + Loading State + ========================================================================== */ + +.navbar-search-input.searching { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath fill='%23158F9B' d='M10 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm0 14a6 6 0 1 1 0-12 6 6 0 0 1 0 12z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: calc(100% - 40px) center; + background-size: 16px 16px; +} + +/* ========================================================================== + Search Results Badge (optional - for showing count) + ========================================================================== */ + +.search-results-badge { + position: absolute; + top: -8px; + right: -8px; + background-color: #FF6B35; + color: #FFFFFF; + border-radius: 12px; + padding: 2px 8px; + font-size: 11px; + font-weight: bold; + min-width: 20px; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + pointer-events: none; +} + +body.high-contrast .search-results-badge { + background-color: #FFFF00; + color: #000000; + border: 2px solid #FFFFFF; +} diff --git a/publications/static/js/accessibility-toggle.js b/publications/static/js/accessibility-toggle.js new file mode 100644 index 0000000..07e901a --- /dev/null +++ b/publications/static/js/accessibility-toggle.js @@ -0,0 +1,158 @@ +// publications/static/js/accessibility-toggle.js +// High contrast theme toggle with localStorage persistence + +(function() { + 'use strict'; + + /** + * Accessibility Toggle Manager + * Handles high contrast mode toggle and persistence + */ + class AccessibilityToggle { + constructor() { + this.storageKey = 'optimap-high-contrast'; + this.bodyElement = document.body; + this.toggleButton = null; + + this.init(); + } + + init() { + // Load saved preference + this.loadPreference(); + + // Create toggle button + this.createToggleButton(); + + // Add event listeners + this.setupEventListeners(); + + // Announce current state to screen readers + this.announceState(); + } + + /** + * Load user preference from localStorage + */ + loadPreference() { + const saved = localStorage.getItem(this.storageKey); + if (saved === 'true') { + this.enable(); + } + } + + /** + * Save user preference to localStorage + */ + savePreference(enabled) { + localStorage.setItem(this.storageKey, enabled.toString()); + } + + /** + * Create the floating toggle button + */ + createToggleButton() { + this.toggleButton = document.createElement('button'); + this.toggleButton.id = 'accessibility-toggle'; + this.toggleButton.setAttribute('aria-label', 'Toggle high contrast mode'); + this.toggleButton.setAttribute('title', 'Toggle High Contrast Mode'); + this.toggleButton.setAttribute('data-tooltip', 'Toggle High Contrast'); + this.toggleButton.innerHTML = ''; + + document.body.appendChild(this.toggleButton); + } + + /** + * Setup event listeners + */ + setupEventListeners() { + if (!this.toggleButton) return; + + this.toggleButton.addEventListener('click', () => { + this.toggle(); + }); + + // Keyboard shortcut: Ctrl+Alt+H + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.altKey && e.key === 'h') { + e.preventDefault(); + this.toggle(); + } + }); + } + + /** + * Toggle high contrast mode + */ + toggle() { + if (this.isEnabled()) { + this.disable(); + } else { + this.enable(); + } + } + + /** + * Enable high contrast mode + */ + enable() { + this.bodyElement.classList.add('high-contrast'); + this.savePreference(true); + this.announceState(); + console.log('High contrast mode enabled'); + } + + /** + * Disable high contrast mode + */ + disable() { + this.bodyElement.classList.remove('high-contrast'); + this.savePreference(false); + this.announceState(); + console.log('High contrast mode disabled'); + } + + /** + * Check if high contrast mode is enabled + */ + isEnabled() { + return this.bodyElement.classList.contains('high-contrast'); + } + + /** + * Announce state change to screen readers + */ + announceState() { + let announcer = document.getElementById('announcer'); + if (!announcer) { + announcer = document.createElement('div'); + announcer.id = 'announcer'; + announcer.className = 'sr-only'; + announcer.setAttribute('role', 'status'); + announcer.setAttribute('aria-live', 'polite'); + announcer.setAttribute('aria-atomic', 'true'); + document.body.appendChild(announcer); + } + + const state = this.isEnabled() ? 'enabled' : 'disabled'; + announcer.textContent = `High contrast mode ${state}`; + + // Update button label + if (this.toggleButton) { + this.toggleButton.setAttribute( + 'aria-label', + `Toggle high contrast mode (currently ${state})` + ); + } + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + new AccessibilityToggle(); + }); + } else { + new AccessibilityToggle(); + } +})(); diff --git a/publications/static/js/main.js b/publications/static/js/main.js index 2e3b246..206672f 100644 --- a/publications/static/js/main.js +++ b/publications/static/js/main.js @@ -39,13 +39,16 @@ async function initMap() { // Controls: scale and layer switcher L.control.scale({ position: 'bottomright' }).addTo(map); - L.control + const layerControl = L.control .layers( { 'OpenStreetMap': osmLayer }, - { Publications: publicationsGroup } + { 'All works': publicationsGroup } ) .addTo(map); + // Make layer control globally available for search manager + window.mapLayerControl = layerControl; + // Fetch data and add to map const pubs = await load_publications(); const pubsLayer = L.geoJSON(pubs, { @@ -54,11 +57,49 @@ async function initMap() { }); pubsLayer.eachLayer((layer) => publicationsGroup.addLayer(layer)); + // Make style and popup functions globally available for search manager + window.publicationStyle = publicationStyle; + window.publicationPopup = publicationPopup; + // Initialize enhanced interaction manager for handling overlapping polygons + let interactionManager = null; if (typeof MapInteractionManager !== 'undefined') { - const interactionManager = new MapInteractionManager(map, pubsLayer); + interactionManager = new MapInteractionManager(map, pubsLayer); console.log('Enhanced map interaction enabled: overlapping polygon selection and geometry highlighting'); } + + // Initialize keyboard navigation for accessibility + if (typeof MapKeyboardNavigation !== 'undefined' && interactionManager) { + const keyboardNav = new MapKeyboardNavigation(map, pubsLayer, interactionManager); + console.log('Keyboard navigation enabled for accessibility'); + } + + // Initialize map search functionality + if (typeof MapSearchManager !== 'undefined') { + const searchManager = new MapSearchManager(map, pubsLayer, pubs, publicationsGroup); + console.log('Map search enabled'); + + // Make search manager globally available for potential use by other components + window.mapSearchManager = searchManager; + } + + // Initialize gazetteer (location search) + if (typeof MapGazetteerManager !== 'undefined' && window.OPTIMAP_SETTINGS?.gazetteer) { + const gazetteerManager = new MapGazetteerManager(map, window.OPTIMAP_SETTINGS.gazetteer); + console.log('Gazetteer enabled'); + + // Make gazetteer manager globally available + window.mapGazetteerManager = gazetteerManager; + } + + // Initialize zoom to all features control + if (typeof MapZoomToAllControl !== 'undefined') { + const zoomToAllControl = new MapZoomToAllControl(map, publicationsGroup); + console.log('Zoom to all features control enabled'); + + // Make zoom control globally available + window.mapZoomToAllControl = zoomToAllControl; + } // Initialize gazetteer (location search) if (typeof MapGazetteerManager !== 'undefined' && window.OPTIMAP_SETTINGS?.gazetteer) { const gazetteerManager = new MapGazetteerManager(map, window.OPTIMAP_SETTINGS.gazetteer); diff --git a/publications/static/js/map-keyboard-navigation.js b/publications/static/js/map-keyboard-navigation.js new file mode 100644 index 0000000..9be334c --- /dev/null +++ b/publications/static/js/map-keyboard-navigation.js @@ -0,0 +1,281 @@ +// publications/static/js/map-keyboard-navigation.js +// Keyboard navigation accessibility for interactive map + +/** + * Map Keyboard Navigation Manager + * Provides keyboard accessibility for the Leaflet map + * - Arrow keys: Pan map + * - +/- keys: Zoom in/out + * - Enter/Space: Activate focused feature + * - Tab: Cycle through features + * - Escape: Close popup + */ +class MapKeyboardNavigation { + constructor(map, publicationsLayer, interactionManager) { + this.map = map; + this.publicationsLayer = publicationsLayer; + this.interactionManager = interactionManager; + this.focusedFeatureIndex = -1; + this.features = []; + this.isMapFocused = false; + + this.init(); + } + + init() { + // Make map container focusable + const mapContainer = this.map.getContainer(); + mapContainer.setAttribute('tabindex', '0'); + mapContainer.setAttribute('role', 'application'); + mapContainer.setAttribute('aria-label', 'Interactive map of publications. Use arrow keys to pan, plus and minus keys to zoom, tab to cycle through publications, enter to select.'); + + // Collect all features + this.collectFeatures(); + + // Add keyboard event listeners + this.setupKeyboardHandlers(); + + // Add focus/blur handlers + this.setupFocusHandlers(); + } + + /** + * Collect all features from the publications layer + */ + collectFeatures() { + this.features = []; + this.publicationsLayer.eachLayer((layer) => { + if (layer.feature) { + this.features.push({ + layer: layer, + feature: layer.feature, + publicationId: layer.feature.id || layer.feature.properties.id + }); + } + }); + console.log(`Keyboard navigation: ${this.features.length} features available`); + } + + /** + * Setup keyboard event handlers + */ + setupKeyboardHandlers() { + const mapContainer = this.map.getContainer(); + + mapContainer.addEventListener('keydown', (e) => { + if (!this.isMapFocused) return; + + const handled = this.handleKeyPress(e); + if (handled) { + e.preventDefault(); + e.stopPropagation(); + } + }); + } + + /** + * Setup focus handlers to track when map has focus + */ + setupFocusHandlers() { + const mapContainer = this.map.getContainer(); + + mapContainer.addEventListener('focus', () => { + this.isMapFocused = true; + console.log('Map focused - keyboard navigation active'); + this.announce('Map focused. Use arrow keys to pan, plus and minus to zoom, tab to cycle through publications.'); + }); + + mapContainer.addEventListener('blur', () => { + this.isMapFocused = false; + console.log('Map unfocused - keyboard navigation inactive'); + }); + } + + /** + * Handle keyboard input + */ + handleKeyPress(e) { + const key = e.key; + const panAmount = 100; // pixels + + switch(key) { + // Arrow keys - pan map + case 'ArrowUp': + this.map.panBy([0, -panAmount]); + this.announce('Panned up'); + return true; + + case 'ArrowDown': + this.map.panBy([0, panAmount]); + this.announce('Panned down'); + return true; + + case 'ArrowLeft': + this.map.panBy([-panAmount, 0]); + this.announce('Panned left'); + return true; + + case 'ArrowRight': + this.map.panBy([panAmount, 0]); + this.announce('Panned right'); + return true; + + // Zoom keys + case '+': + case '=': + this.map.zoomIn(); + this.announce(`Zoomed in to level ${this.map.getZoom()}`); + return true; + + case '-': + case '_': + this.map.zoomOut(); + this.announce(`Zoomed out to level ${this.map.getZoom()}`); + return true; + + // Tab - cycle through features + case 'Tab': + if (e.shiftKey) { + this.focusPreviousFeature(); + } else { + this.focusNextFeature(); + } + return true; + + // Enter or Space - activate focused feature + case 'Enter': + case ' ': + this.activateFocusedFeature(); + return true; + + // Escape - close popup + case 'Escape': + this.map.closePopup(); + this.focusedFeatureIndex = -1; + this.announce('Popup closed'); + return true; + + // Home - zoom to all features + case 'Home': + if (this.publicationsLayer.getBounds && this.publicationsLayer.getBounds().isValid()) { + this.map.fitBounds(this.publicationsLayer.getBounds()); + this.announce('Zoomed to show all publications'); + } + return true; + + default: + return false; + } + } + + /** + * Focus next feature in the list + */ + focusNextFeature() { + if (this.features.length === 0) { + this.announce('No publications available'); + return; + } + + this.focusedFeatureIndex = (this.focusedFeatureIndex + 1) % this.features.length; + this.focusFeature(this.focusedFeatureIndex); + } + + /** + * Focus previous feature in the list + */ + focusPreviousFeature() { + if (this.features.length === 0) { + this.announce('No publications available'); + return; + } + + this.focusedFeatureIndex = (this.focusedFeatureIndex - 1 + this.features.length) % this.features.length; + this.focusFeature(this.focusedFeatureIndex); + } + + /** + * Focus a specific feature + */ + focusFeature(index) { + if (index < 0 || index >= this.features.length) return; + + const featureData = this.features[index]; + const layer = featureData.layer; + const properties = featureData.feature.properties; + + // Pan to feature + if (layer.getBounds) { + this.map.fitBounds(layer.getBounds(), { padding: [50, 50] }); + } else if (layer.getLatLng) { + this.map.setView(layer.getLatLng(), Math.max(this.map.getZoom(), 10)); + } + + // Highlight feature + if (this.interactionManager) { + this.interactionManager.selectPublication(featureData); + } + + // Announce feature + const title = properties.title || 'Untitled publication'; + const doi = properties.doi || ''; + this.announce(`Publication ${index + 1} of ${this.features.length}: ${title}. Press Enter to view details.`); + } + + /** + * Activate the currently focused feature + */ + activateFocusedFeature() { + if (this.focusedFeatureIndex < 0 || this.focusedFeatureIndex >= this.features.length) { + this.announce('No publication selected. Use Tab to select a publication.'); + return; + } + + const featureData = this.features[this.focusedFeatureIndex]; + const layer = featureData.layer; + + // Get center point for popup + let latlng; + if (layer.getBounds) { + latlng = layer.getBounds().getCenter(); + } else if (layer.getLatLng) { + latlng = layer.getLatLng(); + } + + if (latlng && this.interactionManager) { + // Check for overlapping features at this location + const overlapping = this.interactionManager.findOverlappingFeatures(latlng); + + if (overlapping.length > 1) { + this.interactionManager.showPaginatedPopup(overlapping, latlng); + this.announce(`Multiple publications at this location. Use arrow buttons to navigate.`); + } else { + this.interactionManager.showPublicationPopup(featureData, latlng); + this.announce('Publication details opened'); + } + } + } + + /** + * Announce message to screen readers + */ + announce(message) { + // Find or create announcer element + let announcer = document.getElementById('map-announcer'); + if (!announcer) { + announcer = document.createElement('div'); + announcer.id = 'map-announcer'; + announcer.className = 'sr-only'; + announcer.setAttribute('role', 'status'); + announcer.setAttribute('aria-live', 'polite'); + announcer.setAttribute('aria-atomic', 'true'); + document.body.appendChild(announcer); + } + + // Update message + announcer.textContent = message; + + // Log for debugging + console.log('Screen reader announcement:', message); + } +} diff --git a/publications/static/js/map-search.js b/publications/static/js/map-search.js new file mode 100644 index 0000000..0f62412 --- /dev/null +++ b/publications/static/js/map-search.js @@ -0,0 +1,517 @@ +// publications/static/js/map-search.js +// Full-text search filtering for map publications + +/** + * Map Search Manager + * Provides real-time filtering of publications on the map + * - Searches across all text fields in publication data + * - Minimum 3 characters to activate + * - Debounced for performance + * - Accessible with keyboard and screen readers + */ +class MapSearchManager { + constructor(map, publicationsLayer, allPublications, publicationsGroup = null) { + this.map = map; + this.publicationsLayer = publicationsLayer; // The GeoJSON layer + this.publicationsGroup = publicationsGroup; // The layer group (for layer control) + + // Extract features array from GeoJSON object if needed + if (allPublications && allPublications.type === 'FeatureCollection') { + this.allPublications = allPublications.features || []; + } else if (Array.isArray(allPublications)) { + this.allPublications = allPublications; + } else { + this.allPublications = []; + } + + this.filteredPublications = []; + this.filteredLayer = null; // NEW: Separate layer for search results + this.searchInput = null; + this.searchButton = null; + this.clearButton = null; + this.searchContainer = null; + this.searchForm = null; + this.statusElement = null; + this.searchTimeout = null; + this.minSearchLength = 3; + this.isSearchActive = false; + this.searchStartTime = null; + + console.group('🔍 Map Search Initialization'); + console.log('Publications object type:', allPublications?.type || 'unknown'); + console.log('Total publications loaded:', this.allPublications.length); + if (this.allPublications.length > 0) { + console.log('Sample publication:', this.allPublications[0]); + } + this.init(); + console.groupEnd(); + } + + /** + * Initialize search functionality + */ + init() { + // Find search elements + this.searchInput = document.getElementById('map-search-input'); + this.searchButton = document.getElementById('search-submit-btn'); + this.clearButton = document.getElementById('clear-search-btn'); + this.searchContainer = document.getElementById('navbar-search-container'); + this.searchForm = document.querySelector('.navbar-search-form'); + this.statusElement = document.getElementById('search-results-status'); + + console.log('Search elements found:', { + input: !!this.searchInput, + searchButton: !!this.searchButton, + clearButton: !!this.clearButton, + container: !!this.searchContainer, + form: !!this.searchForm, + statusElement: !!this.statusElement + }); + + if (!this.searchInput) { + console.warn('⚠️ Map search input not found'); + return; + } + + // Setup event listeners + this.setupEventListeners(); + + console.log(`✅ Map search initialized with ${this.allPublications.length} publications`); + } + + /** + * Check if we're on a map page + */ + isMapPage() { + // Check if map element exists + return document.getElementById('map') !== null; + } + + /** + * Setup event listeners + */ + setupEventListeners() { + if (!this.searchInput) return; + + console.log('📋 Setting up event listeners...'); + + // Form submit (Enter key) + if (this.searchForm) { + this.searchForm.addEventListener('submit', (e) => { + e.preventDefault(); + console.log('📝 Form submitted (Enter key pressed)'); + const query = this.searchInput.value; + if (query.trim().length >= this.minSearchLength) { + // Clear debounce and search immediately + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + this.performSearch(query); + } else { + console.warn(`⚠️ Search query too short: "${query}" (minimum ${this.minSearchLength} characters)`); + } + }); + } + + // Search button click + if (this.searchButton) { + this.searchButton.addEventListener('click', (e) => { + e.preventDefault(); + console.log('🔍 Search button clicked'); + const query = this.searchInput.value; + if (query.trim().length >= this.minSearchLength) { + // Clear debounce and search immediately + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + this.performSearch(query); + } else { + console.warn(`⚠️ Search query too short: "${query}" (minimum ${this.minSearchLength} characters)`); + } + }); + } + + // Input event with debouncing + this.searchInput.addEventListener('input', (e) => { + console.log(`⌨️ Input changed: "${e.target.value}"`); + this.handleSearchInput(e.target.value); + }); + + // Keydown for special keys + this.searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + console.log('⎋ Escape key pressed - clearing search'); + this.clearSearch(); + } + }); + + // Clear button + if (this.clearButton) { + this.clearButton.addEventListener('click', () => { + console.log('❌ Clear button clicked'); + this.clearSearch(); + this.searchInput.focus(); + }); + } + + // Focus events for accessibility + this.searchInput.addEventListener('focus', () => { + console.log('🎯 Search field focused'); + this.announce('Search field focused. Type at least 3 characters to filter publications.'); + }); + + console.log('✅ Event listeners set up successfully'); + } + + /** + * Handle search input with debouncing + */ + handleSearchInput(query) { + // Clear previous timeout + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + + // Show/hide clear button + if (this.clearButton) { + this.clearButton.style.display = query.length > 0 ? 'block' : 'none'; + } + + // Debounce search + this.searchTimeout = setTimeout(() => { + this.performSearch(query); + }, 300); + } + + /** + * Perform the actual search + */ + performSearch(query) { + this.searchStartTime = performance.now(); + const trimmedQuery = query.trim(); + + console.group(`🔎 Performing Search`); + console.log('Query:', `"${trimmedQuery}"`); + console.log('Query length:', trimmedQuery.length); + console.log('Minimum required:', this.minSearchLength); + + // Clear search if less than minimum length + if (trimmedQuery.length < this.minSearchLength) { + console.warn(`⚠️ Query too short (${trimmedQuery.length} < ${this.minSearchLength})`); + if (this.isSearchActive) { + console.log('Clearing active search...'); + this.showAllPublications(); + this.announce('Search cleared. Showing all publications.'); + } + console.groupEnd(); + return; + } + + // Add searching class for loading indicator + if (this.searchInput) { + this.searchInput.classList.add('searching'); + } + + // Perform the search + const searchTerms = trimmedQuery.toLowerCase().split(/\s+/); + console.log('Search terms:', searchTerms); + console.log('Total publications to search:', this.allPublications.length); + + const filterStartTime = performance.now(); + this.filteredPublications = this.allPublications.filter(pub => { + return this.matchesSearch(pub, searchTerms); + }); + const filterTime = performance.now() - filterStartTime; + + console.log(`⏱️ Filtering took: ${filterTime.toFixed(2)}ms`); + console.log(`📊 Results: ${this.filteredPublications.length} / ${this.allPublications.length}`); + + // Log sample of matched publications + if (this.filteredPublications.length > 0) { + console.log('Sample matches (first 3):'); + this.filteredPublications.slice(0, 3).forEach((pub, index) => { + console.log(` ${index + 1}. ${pub.properties?.title || 'Untitled'}`); + }); + } + + // Update map + const mapUpdateStart = performance.now(); + this.updateMap(); + const mapUpdateTime = performance.now() - mapUpdateStart; + console.log(`🗺️ Map update took: ${mapUpdateTime.toFixed(2)}ms`); + + // Remove searching class + if (this.searchInput) { + setTimeout(() => { + this.searchInput.classList.remove('searching'); + }, 300); + } + + // Announce results + const count = this.filteredPublications.length; + const total = this.allPublications.length; + const percentage = ((count / total) * 100).toFixed(1); + const totalTime = performance.now() - this.searchStartTime; + + const message = count === 1 + ? `1 publication found matching "${trimmedQuery}"` + : `${count} publications found matching "${trimmedQuery}" (${percentage}% of total)`; + + console.log(`✅ ${message}`); + console.log(`⏱️ Total search time: ${totalTime.toFixed(2)}ms`); + console.groupEnd(); + + this.announce(message); + + this.isSearchActive = true; + } + + /** + * Check if publication matches search terms + * Searches across all text fields in the publication + */ + matchesSearch(publication, searchTerms) { + if (!publication) return false; + + // Build searchable text from all fields + const searchableText = this.buildSearchableText(publication); + + // Check if all search terms are found + return searchTerms.every(term => searchableText.includes(term)); + } + + /** + * Build searchable text from publication object + * Includes all text fields from the API response + */ + buildSearchableText(pub) { + const parts = []; + + // GeoJSON properties (primary source of data) + if (pub.properties) { + const props = pub.properties; + + // Title + if (props.title) parts.push(props.title); + + // DOI + if (props.doi) parts.push(props.doi); + + // Abstract + if (props.abstract) parts.push(props.abstract); + + // Authors (array of strings) + if (Array.isArray(props.authors)) { + parts.push(...props.authors); + } + + // Keywords (array of strings) + if (Array.isArray(props.keywords)) { + parts.push(...props.keywords); + } + + // Topics (array of objects with display_name) + if (Array.isArray(props.topics)) { + props.topics.forEach(topic => { + if (topic.display_name) parts.push(topic.display_name); + if (topic.subfield) parts.push(topic.subfield); + if (topic.field) parts.push(topic.field); + if (topic.domain) parts.push(topic.domain); + }); + } + + // Source details + if (props.source_details) { + const source = props.source_details; + if (source.name) parts.push(source.name); + if (source.display_name) parts.push(source.display_name); + if (source.abbreviated_title) parts.push(source.abbreviated_title); + if (source.publisher_name) parts.push(source.publisher_name); + if (source.issn_l) parts.push(source.issn_l); + } + + // URL + if (props.url) parts.push(props.url); + + // OpenAlex ID + if (props.openalex_id) parts.push(props.openalex_id); + + // PMID, PMCID + if (props.pmid) parts.push(props.pmid); + if (props.pmcid) parts.push(props.pmcid); + + // Time period + if (props.timeperiod_startdate) parts.push(props.timeperiod_startdate); + if (props.timeperiod_enddate) parts.push(props.timeperiod_enddate); + + // Region description + if (props.region_description) parts.push(props.region_description); + } + + // Join all parts and convert to lowercase + return parts.join(' ').toLowerCase(); + } + + /** + * Update map to show only filtered publications + * Uses layer replacement strategy for clean display + */ + updateMap() { + if (!this.map) return; + + console.log('🗺️ Updating map display...'); + console.log('Filtered publications count:', this.filteredPublications.length); + + // Remove existing filtered layer if any + if (this.filteredLayer) { + this.map.removeLayer(this.filteredLayer); + + // Remove from layer control if present + if (window.mapLayerControl) { + window.mapLayerControl.removeLayer(this.filteredLayer); + } + + this.filteredLayer = null; + console.log('🗑️ Removed previous filtered layer'); + } + + // Hide the original publications layer + if (this.publicationsGroup && this.map.hasLayer(this.publicationsGroup)) { + this.map.removeLayer(this.publicationsGroup); + console.log('👻 Hid original "All works" layer'); + } + + // Create a new GeoJSON FeatureCollection with filtered publications + const filteredGeoJSON = { + type: 'FeatureCollection', + features: this.filteredPublications + }; + + console.log('📦 Creating filtered layer with', this.filteredPublications.length, 'features'); + + // Import the style and popup functions from the global scope + const styleFunc = window.publicationStyle || this.publicationsLayer.options.style; + const popupFunc = window.publicationPopup || this.publicationsLayer.options.onEachFeature; + + // Create a new layer with the filtered publications + this.filteredLayer = L.geoJSON(filteredGeoJSON, { + style: styleFunc, + onEachFeature: popupFunc + }); + + // Add the filtered layer to the map + this.filteredLayer.addTo(this.map); + console.log('✅ Added filtered layer to map'); + + // Add to layer control + if (window.mapLayerControl) { + const resultCount = this.filteredPublications.length; + const layerName = `Search results (${resultCount})`; + window.mapLayerControl.addOverlay(this.filteredLayer, layerName); + console.log('📋 Added to layer control as:', layerName); + } + + // Fit map to filtered results + if (this.filteredPublications.length > 0) { + const bounds = this.filteredLayer.getBounds(); + if (bounds.isValid()) { + this.map.fitBounds(bounds, { padding: [50, 50] }); + console.log('🗺️ Map fitted to filtered results'); + } + } + } + + /** + * Show all publications (clear filter) + * Removes filtered layer and restores original layer + */ + showAllPublications() { + if (!this.map) return; + + console.log('🗺️ Showing all publications...'); + + // Remove filtered layer if it exists + if (this.filteredLayer) { + this.map.removeLayer(this.filteredLayer); + + // Remove from layer control + if (window.mapLayerControl) { + window.mapLayerControl.removeLayer(this.filteredLayer); + console.log('📋 Removed from layer control'); + } + + this.filteredLayer = null; + console.log('🗑️ Removed filtered layer'); + } + + // Restore the original publications layer + if (this.publicationsGroup && !this.map.hasLayer(this.publicationsGroup)) { + this.publicationsGroup.addTo(this.map); + console.log('✅ Restored original "All works" layer'); + } + + // Fit to all publications + if (this.publicationsGroup) { + const bounds = this.publicationsGroup.getBounds(); + if (bounds.isValid()) { + this.map.fitBounds(bounds); + console.log('🗺️ Map fitted to all publications'); + } + } + + this.filteredPublications = []; + this.isSearchActive = false; + } + + /** + * Clear search + */ + clearSearch() { + if (this.searchInput) { + this.searchInput.value = ''; + } + + if (this.clearButton) { + this.clearButton.style.display = 'none'; + } + + this.showAllPublications(); + this.announce('Search cleared. Showing all publications.'); + } + + /** + * Announce message to screen readers + */ + announce(message) { + if (!this.statusElement) { + // Try to find or create status element + this.statusElement = document.getElementById('search-results-status'); + if (!this.statusElement) { + this.statusElement = document.createElement('div'); + this.statusElement.id = 'search-results-status'; + this.statusElement.className = 'sr-only'; + this.statusElement.setAttribute('role', 'status'); + this.statusElement.setAttribute('aria-live', 'polite'); + this.statusElement.setAttribute('aria-atomic', 'true'); + document.body.appendChild(this.statusElement); + } + } + + this.statusElement.textContent = message; + console.log('Screen reader announcement:', message); + } + + /** + * Update publications data (called when new data is loaded) + */ + updatePublications(publications) { + this.allPublications = publications || []; + console.log(`Map search updated with ${this.allPublications.length} publications`); + + // If search is active, re-run search + if (this.isSearchActive && this.searchInput && this.searchInput.value.trim().length >= this.minSearchLength) { + this.performSearch(this.searchInput.value); + } + } +} diff --git a/publications/static/js/map-zoom-to-all.js b/publications/static/js/map-zoom-to-all.js new file mode 100644 index 0000000..436db83 --- /dev/null +++ b/publications/static/js/map-zoom-to-all.js @@ -0,0 +1,123 @@ +// publications/static/js/map-zoom-to-all.js + +/** + * MapZoomToAllControl + * + * Adds a custom Leaflet control button that zooms the map to show all features. + * This provides users with an easy way to reset the map view to display all publications. + * + * Usage: + * const zoomControl = new MapZoomToAllControl(map, featureGroup); + * + * @param {L.Map} map - The Leaflet map instance + * @param {L.FeatureGroup} featureGroup - The feature group containing all features to zoom to + */ +class MapZoomToAllControl { + constructor(map, featureGroup) { + this.map = map; + this.featureGroup = featureGroup; + this.control = null; + + this.init(); + } + + /** + * Initialize the control and add it to the map + */ + init() { + const ZoomToAllControl = L.Control.extend({ + options: { + position: 'topleft' + }, + + onAdd: (map) => { + // Create the control container + const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-zoom-to-all'); + + // Create the button + const button = L.DomUtil.create('a', 'leaflet-control-zoom-to-all-button', container); + button.href = '#'; + button.title = 'Zoom to all features'; + button.setAttribute('role', 'button'); + button.setAttribute('aria-label', 'Zoom to all features'); + + // Add icon using FontAwesome icon or Unicode fallback + button.innerHTML = ''; + + // Prevent map interactions when clicking the button + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); + + // Add click event handler + L.DomEvent.on(button, 'click', (e) => { + L.DomEvent.preventDefault(e); + this.zoomToAllFeatures(); + }); + + return container; + } + }); + + // Add the control to the map + this.control = new ZoomToAllControl(); + this.control.addTo(this.map); + + console.log('Zoom to all features control added'); + } + + /** + * Zoom the map to fit all features in the feature group + */ + zoomToAllFeatures() { + const bounds = this.featureGroup.getBounds(); + + if (bounds.isValid()) { + // Fit the map to the bounds with some padding + this.map.fitBounds(bounds, { + padding: [50, 50], + maxZoom: 18 + }); + + console.log('Zoomed to all features'); + + // Announce to screen readers + this.announceToScreenReader('Map zoomed to show all features'); + } else { + console.warn('No valid bounds to zoom to'); + this.announceToScreenReader('No features to display'); + } + } + + /** + * Announce messages to screen readers + * @param {string} message - The message to announce + */ + announceToScreenReader(message) { + // Use existing status element if available, or create a temporary one + let statusElement = document.getElementById('search-results-status'); + + if (!statusElement) { + statusElement = document.createElement('div'); + statusElement.setAttribute('role', 'status'); + statusElement.setAttribute('aria-live', 'polite'); + statusElement.className = 'sr-only'; + document.body.appendChild(statusElement); + } + + statusElement.textContent = message; + } + + /** + * Remove the control from the map + */ + destroy() { + if (this.control) { + this.map.removeControl(this.control); + this.control = null; + console.log('Zoom to all features control removed'); + } + } +} + +// Make the class globally available +window.MapZoomToAllControl = MapZoomToAllControl; diff --git a/publications/templates/base.html b/publications/templates/base.html index ffb423e..a3759c6 100644 --- a/publications/templates/base.html +++ b/publications/templates/base.html @@ -48,9 +48,15 @@ {% endblock head %} + + + + + + -
+
{% block alert %}{% endblock %} {% block content %}{% endblock %}
diff --git a/publications/templates/main.html b/publications/templates/main.html index c0a48f1..229137c 100644 --- a/publications/templates/main.html +++ b/publications/templates/main.html @@ -20,6 +20,9 @@
+ + +
{% endblock content %} @@ -28,6 +31,19 @@ {% block scripts %} + + + + + {# #} diff --git a/publications/templates/unified_menu_snippet.html b/publications/templates/unified_menu_snippet.html index fe61fd0..428c9c4 100644 --- a/publications/templates/unified_menu_snippet.html +++ b/publications/templates/unified_menu_snippet.html @@ -28,7 +28,7 @@
  • Contact
  • Accessibility
  • -
  • Code on GitHub
  • +
  • Code on GitHub
  • {% if request.user.is_authenticated %} @@ -59,13 +59,16 @@ {% endif %}
    +
    @@ -73,7 +76,7 @@

    - Want to stay anonymous? Use Mailinator or check our privacy info. + Want to stay anonymous? Use Mailinator or check our privacy info.

    {% endif %} diff --git a/publications/templates/work_landing_page.html b/publications/templates/work_landing_page.html index 1e38e25..7f1b778 100644 --- a/publications/templates/work_landing_page.html +++ b/publications/templates/work_landing_page.html @@ -242,6 +242,8 @@
    Temporal extent (ti {% endif %} + +

    diff --git a/publications/templates/works.html b/publications/templates/works.html index d9521e6..b043eaa 100644 --- a/publications/templates/works.html +++ b/publications/templates/works.html @@ -4,7 +4,7 @@ {% block content %}

    -

    All Article Links

    +

    All Article Links

    {% if is_admin %}

    Admin view: You can see all publications regardless of status. Status labels are shown next to each entry. @@ -13,7 +13,7 @@

    All Article Links

      {% for item in links %}
    • - {{ item.title }} + {{ item.title }} {% if is_admin and item.status %}
    • +
    • + Geoextent +

      Extract spatial and temporal extent from geospatial data files and remote repositories

      +
    diff --git a/publications/templates/unified_menu_snippet.html b/publications/templates/unified_menu_snippet.html index 428c9c4..bb66c32 100644 --- a/publications/templates/unified_menu_snippet.html +++ b/publications/templates/unified_menu_snippet.html @@ -23,6 +23,7 @@
  • Data & API
  • Feeds
  • +
  • Geoextent
  • About
  • Contact