diff --git a/package-lock.json b/package-lock.json index ac1ffb201..e1550aa07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.4.0", "@testing-library/user-event": "13.5.0", + "@types/leaflet.markercluster": "^1.5.6", "@types/node": "22.18.10", "@types/react": "18.0.27", "@types/react-dom": "18.0.10", @@ -33,6 +34,8 @@ "i18next": "25.6.0", "i18next-http-backend": "3.0.2", "js-md5": "0.8.3", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "moment-timezone": "0.5.43", "react-bootstrap": "2.7.2", "react-bootstrap-icons": "1.11.4", @@ -40,6 +43,7 @@ "react-hook-form": "7.51.2", "react-i18next": "16.2.0", "react-infinite-scroll-hook": "4.1.1", + "react-leaflet": "^4.2.1", "react-loader-spinner": "5.3.4", "react-markdown": "8.0.7", "react-oauth2-code-pkce": "1.22.2", @@ -71,6 +75,7 @@ "@storybook/test-runner": "0.23.0", "@testing-library/cypress": "10.1.0", "@types/chai-as-promised": "7.1.5", + "@types/leaflet": "^1.9.21", "@types/node-sass": "4.11.3", "@types/sinon": "10.0.13", "@typescript-eslint/eslint-plugin": "5.51.0", @@ -5576,6 +5581,17 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -8260,6 +8276,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -8377,6 +8399,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz", + "integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==", + "license": "MIT", + "dependencies": { + "@types/leaflet": "^1.9" + } + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -19293,6 +19333,21 @@ "node": ">= 0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/lerna": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/lerna/-/lerna-9.0.0.tgz", @@ -24857,6 +24912,20 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", diff --git a/package.json b/package.json index 8c6ad439e..112698818 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.4.0", "@testing-library/user-event": "13.5.0", + "@types/leaflet.markercluster": "^1.5.6", "@types/node": "22.18.10", "@types/react": "18.0.27", "@types/react-dom": "18.0.10", @@ -38,6 +39,8 @@ "i18next": "25.6.0", "i18next-http-backend": "3.0.2", "js-md5": "0.8.3", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "moment-timezone": "0.5.43", "react-bootstrap": "2.7.2", "react-bootstrap-icons": "1.11.4", @@ -45,6 +48,7 @@ "react-hook-form": "7.51.2", "react-i18next": "16.2.0", "react-infinite-scroll-hook": "4.1.1", + "react-leaflet": "^4.2.1", "react-loader-spinner": "5.3.4", "react-markdown": "8.0.7", "react-oauth2-code-pkce": "1.22.2", @@ -131,6 +135,7 @@ "@storybook/test-runner": "0.23.0", "@testing-library/cypress": "10.1.0", "@types/chai-as-promised": "7.1.5", + "@types/leaflet": "^1.9.21", "@types/node-sass": "4.11.3", "@types/sinon": "10.0.13", "@typescript-eslint/eslint-plugin": "5.51.0", diff --git a/src/index.tsx b/src/index.tsx index 90ffe98aa..ea691a6a3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,7 @@ import { initAppConfig, requireAppConfig } from './config' import { ConfigError } from './ConfigError' import { ApiConfig } from '@iqss/dataverse-client-javascript' import { DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript/dist/core/infra/repositories/ApiConfig' +import 'leaflet/dist/leaflet.css' const AppEntrypoint = lazy(() => import('./index.app')) diff --git a/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss b/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss index efb6c6204..3769ee8b6 100644 --- a/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss +++ b/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss @@ -1,6 +1,39 @@ @import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; @import 'src/assets/mixins'; +.view-tabs { + display: flex; + gap: 0; + border-bottom: 2px solid #dee2e6; +} + +.tab, +.tab-active { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.9rem; + border: 1px solid #dee2e6; + border-bottom: none; + border-radius: 4px 4px 0 0; + background: none; + cursor: pointer; + font-size: 14px; + color: #0d6efd; + margin-bottom: -2px; +} + +.tab-active { + color: #000; + background: #fff; + border-color: #dee2e6 #dee2e6 #fff; + font-weight: 500; +} + +.hidden { + display: none; +} + .items-panel { display: flex; flex-direction: column; diff --git a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx index b77065686..660e0158c 100644 --- a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx +++ b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { Stack } from '@iqss/dataverse-design-system' import { useTranslation } from 'react-i18next' +import { CollectionMap } from '../collection-map/CollectionMap' import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' import { CollectionSearchCriteria, @@ -27,6 +28,8 @@ import { ItemTypeChange } from '@/sections/collection/collection-items-panel/fil import { SelectedFacets } from '@/sections/collection/collection-items-panel/selected-facets/SelectedFacets' import { RouteWithParams } from '@/sections/Route.enum' import { useCollectionRepositories } from '@/shared/contexts/repositories/RepositoriesProvider' +import listIcon from './assets/list.svg' +import mapIcon from './assets/map.svg' import styles from './CollectionItemsPanel.module.scss' interface CollectionItemsPanelProps { @@ -60,6 +63,18 @@ export const CollectionItemsPanel = ({ const { setIsLoading } = useLoading() const [_, setSearchParams] = useSearchParams() const { t } = useTranslation('collection') + const [activeView, setActiveView] = useState<'list' | 'map'>( + () => (sessionStorage.getItem('collectionActiveTab') as 'list' | 'map' | null) ?? 'list' + ) + const [mapInitialized, setMapInitialized] = useState( + () => sessionStorage.getItem('collectionActiveTab') === 'map' + ) + + const handleTabChange = (view: 'list' | 'map') => { + if (view === 'map') setMapInitialized(true) + sessionStorage.setItem('collectionActiveTab', view) + setActiveView(view) + } useLoadMoreOnPopStateEvent(loadItemsOnBackAndForwardNavigation) // This object will update every time we update a query param in the URL with the setSearchParams setter @@ -378,31 +393,56 @@ export const CollectionItemsPanel = ({ isLoadingCollectionItems={isLoadingItems} /> )} - - 0 - : false - } - sortSelected={currentSearchCriteria.sort} - orderSelected={currentSearchCriteria.order} - paginationInfo={paginationInfo} - onBottomReach={handleLoadMoreOnBottomReach} - onSortChange={handleSortChange} - ref={itemsListContainerRef} - /> +
+ + +
+ {activeView === 'list' && ( + 0 + : false + } + sortSelected={currentSearchCriteria.sort} + orderSelected={currentSearchCriteria.order} + paginationInfo={paginationInfo} + onBottomReach={handleLoadMoreOnBottomReach} + onSortChange={handleSortChange} + ref={itemsListContainerRef} + /> + )} + {/* To prevent problems when leaflet renders a 0px map, the initial render is delayed until the map becomes visible. */} + {mapInitialized && ( + // To prevent re-initializations, the map is hidden via CSS when its tab is closed. +
+ +
+ )} diff --git a/src/sections/collection/collection-map/CollectionMap.module.scss b/src/sections/collection/collection-map/CollectionMap.module.scss new file mode 100644 index 000000000..dca9dad9e --- /dev/null +++ b/src/sections/collection/collection-map/CollectionMap.module.scss @@ -0,0 +1,32 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/typography.module'; + +.map-wrapper { + border: 1px solid #dee2e6; + border-radius: 4px; + overflow: hidden; + background: #f5f5f5; +} + +.map-status { + padding: 0.5rem 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid #dee2e6; + background: #fff; +} + +.map-container { + height: 480px; +} + +.results { + margin-right: 4px; + font-weight: $dv-font-weight-bold; +} + +.error { + padding: 0.75rem; + color: #dc3545; + font-size: 14px; +} diff --git a/src/sections/collection/collection-map/CollectionMap.tsx b/src/sections/collection/collection-map/CollectionMap.tsx new file mode 100644 index 000000000..5ddcd06c7 --- /dev/null +++ b/src/sections/collection/collection-map/CollectionMap.tsx @@ -0,0 +1,84 @@ +import 'leaflet.markercluster/dist/MarkerCluster.css' +import 'leaflet.markercluster/dist/MarkerCluster.Default.css' +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { MapContainer, TileLayer, useMap } from 'react-leaflet' +import { FilterQuery } from '@/collection/domain/models/CollectionSearchCriteria' +import { useCollectionMapData } from './useCollectionMapData' +import { MarkerClusterGroup } from './MarkerClusterGroup' +import styles from './CollectionMap.module.scss' +import { Button } from '@iqss/dataverse-design-system' +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' + +function MapSizeInvalidator({ isVisible }: { isVisible: boolean }) { + // causes a tile grid update after the map is reopened, in case it was stale due to a window size change + const map = useMap() + useEffect(() => { + if (isVisible) { + setTimeout(() => map.invalidateSize(), 0) + } + }, [isVisible, map]) + return null +} + +interface CollectionMapProps { + collectionId: string + searchText?: string + filterQueries?: FilterQuery[] + isVisible: boolean +} + +export function CollectionMap({ + collectionId, + searchText, + filterQueries, + isVisible +}: CollectionMapProps) { + const { t } = useTranslation('shared') + const { items, totalCount, isLoading, error, hasMore, loadMore } = useCollectionMapData( + collectionId, + searchText, + filterQueries + ) + + return ( +
+
+ {isLoading ? ( + + + + ) : ( + + {t('pagination.accumulated.moreThanPageSize', { + accumulated: items.length, + formattedCount: new Intl.NumberFormat().format(totalCount), + item: 'result' + })} + + )} + {hasMore && ( + + )} +
+ + {error &&
Error: {error}
} + + + + + + +
+ ) +} diff --git a/src/sections/collection/collection-map/MarkerClusterGroup.tsx b/src/sections/collection/collection-map/MarkerClusterGroup.tsx new file mode 100644 index 000000000..17a58fa23 --- /dev/null +++ b/src/sections/collection/collection-map/MarkerClusterGroup.tsx @@ -0,0 +1,78 @@ +import { useEffect } from 'react' +import { useMap } from 'react-leaflet' +import L from 'leaflet' +import 'leaflet.markercluster' +import { GeoDatasetItem } from './useCollectionMapData' + +interface Props { + items: GeoDatasetItem[] +} + +export function MarkerClusterGroup({ items }: Props) { + const map = useMap() + + useEffect(() => { + const cluster = L.markerClusterGroup() + const baseUrl = window.location.origin + + const markerList = items.map((item) => { + const marker = L.marker([item.pinLat, item.pinLon]) + const datasetUrl = `${baseUrl}/dataset.xhtml?persistentId=${item.persistentId}` + + let rects: L.Rectangle[] = [] + let inCluster = true + + marker.on('click', () => { + if (inCluster) { + // while a pin's popup is open, clustering logic shouldn't mess with it + cluster.removeLayer(marker) + map.addLayer(marker) + inCluster = false + + rects = item.bboxes.map((bounds) => + L.rectangle(bounds, { color: '#0d6efd', weight: 0.5, fillOpacity: 0.1 }) + ) + rects.forEach((r) => map.addLayer(r)) + const combined = L.featureGroup(rects).getBounds() + if (!map.getBounds().contains(combined)) { + map.fitBounds(combined, { padding: [20, 20] }) + } + } + }) + + marker.bindPopup( + `${item.name}
` + + `${item.authors}; ${item.publicationDate}
` + + `${item.persistentId}` + ) + + marker.on('popupclose', () => { + rects.forEach((r) => map.removeLayer(r)) + rects = [] + if (!inCluster) { + inCluster = true + map.removeLayer(marker) + cluster.addLayer(marker) + } + }) + + return marker + }) + + cluster.addLayers(markerList) + map.addLayer(cluster) + + if (markerList.length > 0) { + map.fitBounds(cluster.getBounds()) + } + + return () => { + for (const m of markerList) { + if (map.hasLayer(m)) map.removeLayer(m) + } + map.removeLayer(cluster) + } + }, [map, items]) + + return null +} diff --git a/src/sections/collection/collection-map/useCollectionMapData.ts b/src/sections/collection/collection-map/useCollectionMapData.ts new file mode 100644 index 000000000..879193424 --- /dev/null +++ b/src/sections/collection/collection-map/useCollectionMapData.ts @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useState } from 'react' +import { FilterQuery } from '@/collection/domain/models/CollectionSearchCriteria' + +export interface GeoDatasetItem { + persistentId: string + name: string + url: string + authors: string + publicationDate: string + pinLat: number + pinLon: number + bboxes: [[number, number], [number, number]][] +} + +const PAGE_SIZE = 50 + +interface BboxValue { + southLatitude?: { value: string } + westLongitude?: { value: string } + northLatitude?: { value: string } + eastLongitude?: { value: string } +} + +interface GeospatialField { + typeName: string + value: BboxValue[] +} + +interface SearchItem { + global_id?: string + name?: string + url?: string + authors?: (string | { name?: string })[] + author_name?: string + published_at?: string + metadataBlocks?: { + geospatial?: { + fields: GeospatialField[] + } + } +} + +interface SearchResponse { + data?: { + total_count?: number + items?: SearchItem[] + } +} + +function extractGeoItem(item: SearchItem): GeoDatasetItem | null { + const fields = item.metadataBlocks?.geospatial?.fields + if (!fields) return null + + const bboxField = fields.find((f) => f.typeName === 'geographicBoundingBox') + if (!bboxField?.value?.length) return null + + const bboxes: [[number, number], [number, number]][] = [] + let latSum = 0 + let lonSum = 0 + + for (const bbox of bboxField.value) { + const south = parseFloat(bbox.southLatitude?.value ?? '') + const west = parseFloat(bbox.westLongitude?.value ?? '') + const north = parseFloat(bbox.northLatitude?.value ?? '') + const east = parseFloat(bbox.eastLongitude?.value ?? '') + if (isNaN(south) || isNaN(west) || isNaN(north) || isNaN(east)) continue + bboxes.push([ + [south, west], + [north, east] + ]) + latSum += (south + north) / 2 + lonSum += (west + east) / 2 + } + + if (bboxes.length === 0) return null + + const authors = Array.isArray(item.authors) + ? item.authors.map((a) => (typeof a === 'string' ? a : a.name ?? '')).join(', ') + : item.author_name ?? '' + + return { + persistentId: item.global_id ?? '', + name: item.name ?? '', + url: item.url ?? '', + authors, + publicationDate: item.published_at?.substring(0, 10) ?? '', + pinLat: latSum / bboxes.length, + pinLon: lonSum / bboxes.length, + bboxes: bboxes + } +} + +function buildSearchUrl( + collectionId: string, + start: number, + searchText?: string, + filterQueries?: FilterQuery[] +) { + const params = new URLSearchParams() + params.set('q', searchText && searchText.length > 0 ? searchText : '*') + params.set('type', 'dataset') + params.set('metadata_fields', 'geospatial:*') + params.set('subtree', collectionId) + params.set('start', String(start)) + params.set('per_page', String(PAGE_SIZE)) + for (const fq of filterQueries ?? []) { + const idx = fq.indexOf(':') + if (idx > 0) { + const key = fq.slice(0, idx).trim() + const value = fq.slice(idx + 1).trim() + params.append('fq', `${key}:"${value}"`) + } else { + params.append('fq', fq) + } + } + return `${window.location.origin}/api/search?${params.toString()}` +} + +export function useCollectionMapData( + collectionId: string, + searchText?: string, + filterQueries?: FilterQuery[] +) { + const [items, setItems] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [start, setStart] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const filterQueriesKey = filterQueries?.join('\0') ?? '' + + const fetchPage = useCallback( + async (pageStart: number, replace: boolean, signal?: AbortSignal) => { + setIsLoading(true) + setError(null) + try { + const fqs = filterQueriesKey ? (filterQueriesKey.split('\0') as FilterQuery[]) : undefined + const url = buildSearchUrl(collectionId, pageStart, searchText, fqs) + const response = await fetch(url, signal ? { signal } : undefined) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const json = (await response.json()) as SearchResponse + const total = json.data?.total_count ?? 0 + const geoItems = (json.data?.items ?? []).flatMap((item) => { + const gi = extractGeoItem(item) + return gi ? [gi] : [] + }) + setTotalCount(total) + setItems((prev) => (replace ? geoItems : [...prev, ...geoItems])) + setStart(pageStart + PAGE_SIZE) + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return + setError(e instanceof Error ? e.message : 'Failed to load map data') + } finally { + if (!signal?.aborted) setIsLoading(false) + } + }, + [collectionId, searchText, filterQueriesKey] + ) + + useEffect(() => { + const controller = new AbortController() + setItems([]) + setStart(0) + setTotalCount(0) + void fetchPage(0, true, controller.signal) + return () => controller.abort() + }, [fetchPage]) + + const loadMore = useCallback(() => { + void fetchPage(start, false) + }, [fetchPage, start]) + + return { + items, + totalCount, + isLoading, + error, + hasMore: items.length < totalCount, + loadMore + } +}