From 5bc45e740ea31ea58dc13bac0700844e2b27fd54 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 22 May 2026 16:37:45 +0200 Subject: [PATCH] mockup: add layer, layer sources, add layer source --- i18n/en.pot | 37 +- .../layerSources/AddSourceWizard.jsx | 1685 +++++++++++++++++ .../layerSources/LayerSourceCard.jsx | 208 ++ .../layerSources/LayerSourceCatalogue.jsx | 501 +++++ .../layerSources/ManageLayerSourcesModal.jsx | 213 ++- .../layerSources/mockCatalogueSources.js | 753 ++++++++ .../layerSources/mockFavoritesStore.js | 27 + .../styles/AddSourceWizard.module.css | 553 ++++++ .../styles/LayerSourceCard.module.css | 225 +++ .../styles/LayerSourceCatalogue.module.css | 360 ++++ .../styles/ManageLayerSourcesModal.module.css | 59 +- .../layers/basemaps/BasemapCard.jsx | 28 +- .../layers/basemaps/BasemapList.jsx | 10 +- .../layers/overlays/AddLayerButton.jsx | 9 +- .../layers/overlays/AddLayerPopover.jsx | 164 +- .../styles/AddLayerPopover.module.css | 166 ++ .../layers/overlays/styles/Layer.module.css | 16 +- .../overlays/styles/LayerList.module.css | 4 +- .../layers/toolbar/LayerToolbar.jsx | 3 + .../toolbar/styles/LayerToolbar.module.css | 2 + 20 files changed, 4904 insertions(+), 119 deletions(-) create mode 100644 src/components/layerSources/AddSourceWizard.jsx create mode 100644 src/components/layerSources/LayerSourceCard.jsx create mode 100644 src/components/layerSources/LayerSourceCatalogue.jsx create mode 100644 src/components/layerSources/mockCatalogueSources.js create mode 100644 src/components/layerSources/mockFavoritesStore.js create mode 100644 src/components/layerSources/styles/AddSourceWizard.module.css create mode 100644 src/components/layerSources/styles/LayerSourceCard.module.css create mode 100644 src/components/layerSources/styles/LayerSourceCatalogue.module.css create mode 100644 src/components/layers/overlays/styles/AddLayerPopover.module.css diff --git a/i18n/en.pot b/i18n/en.pot index df97c59df..f1501174a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-03-12T18:11:56.380Z\n" -"PO-Revision-Date: 2026-03-12T18:11:56.380Z\n" +"POT-Creation-Date: 2026-05-22T14:31:31.976Z\n" +"PO-Revision-Date: 2026-05-22T14:31:31.976Z\n" msgid "2020" msgstr "2020" @@ -564,15 +564,26 @@ msgstr "Style by group set" msgid "Manage available layer sources" msgstr "Manage available layer sources" -msgid "Configure available layer sources" -msgstr "Configure available layer sources" +msgid "Edit layer source" +msgstr "Edit layer source" -msgid "" -"Choose which layer sources are available to add to maps. This selection " -"applies to all users." -msgstr "" -"Choose which layer sources are available to add to maps. This selection " -"applies to all users." +msgid "Add layer source" +msgstr "Add layer source" + +msgid "Layer sources" +msgstr "Layer sources" + +msgid "Add source" +msgstr "Add source" + +msgid "Delete layer source" +msgstr "Delete layer source" + +msgid "This layer source will be permanently removed from the catalogue. Continue?" +msgstr "This layer source will be permanently removed from the catalogue. Continue?" + +msgid "Delete" +msgstr "Delete" msgid "Collapse" msgstr "Collapse" @@ -586,6 +597,9 @@ msgstr "External basemap" msgid "Basemap" msgstr "Basemap" +msgid "Manage β†’" +msgstr "Manage β†’" + msgid "Download Layer Data" msgstr "Download Layer Data" @@ -760,9 +774,6 @@ msgstr "Click the next position" msgid "Click where you want to start the measurement" msgstr "Click where you want to start the measurement" -msgid "Delete" -msgstr "Delete" - msgid "Distance" msgstr "Distance" diff --git a/src/components/layerSources/AddSourceWizard.jsx b/src/components/layerSources/AddSourceWizard.jsx new file mode 100644 index 000000000..618d74438 --- /dev/null +++ b/src/components/layerSources/AddSourceWizard.jsx @@ -0,0 +1,1685 @@ +import { Button, ButtonStrip } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useState } from 'react' +import getEarthEngineLayers from '../../constants/earthEngineLayers/index.js' +import { ORIGIN } from './mockCatalogueSources.js' +import styles from './styles/AddSourceWizard.module.css' + +const ORIGIN_TO_TYPE_ID = { + [ORIGIN.EARTH_ENGINE]: 'ee-preset', + [ORIGIN.STAC]: 'stac', + [ORIGIN.COPERNICUS_CDS]: 'copernicus', + [ORIGIN.ARCGIS]: 'arcgis', + [ORIGIN.WMS]: 'wms', + [ORIGIN.XYZ]: 'xyz', + [ORIGIN.GEOJSON_URL]: 'geojson-url', + [ORIGIN.ORG_DATA]: 'org-data', + [ORIGIN.USER_DATA]: 'user-data', +} + +// layerOnly: true means this source type cannot be used as a basemap +const SOURCE_TYPES = [ + // OGC / Open standards + { + id: 'wms', + label: 'WMS', + subLabel: 'Web Map Service', + icon: 'πŸ”²', + adminOnly: true, + layerOnly: false, + }, + { + id: 'wmts', + label: 'WMTS', + subLabel: 'Web Map Tile Service', + icon: 'πŸ—ƒοΈ', + adminOnly: true, + layerOnly: false, + }, + { + id: 'wfs', + label: 'WFS', + subLabel: 'Web Feature Service', + icon: 'πŸ”·', + adminOnly: true, + layerOnly: true, + }, + { + id: 'ogc-api-features', + label: 'OGC API Features', + subLabel: 'Features API / WFS 3', + icon: '🌐', + adminOnly: true, + layerOnly: true, + }, + { + id: 'mvt', + label: 'Vector Tiles', + subLabel: 'Mapbox / MVT', + icon: '🎨', + adminOnly: true, + layerOnly: false, + }, + { + id: 'xyz', + label: 'TMS / XYZ', + subLabel: 'Raster tile layer', + icon: '🧩', + adminOnly: true, + layerOnly: false, + }, + { + id: 'cog', + label: 'COG', + subLabel: 'Cloud-Optimized GeoTIFF', + icon: 'πŸ–ΌοΈ', + adminOnly: true, + layerOnly: true, + }, + { + id: 'stac', + label: 'STAC Catalogue', + subLabel: 'WorldPop, Sentinel…', + icon: 'πŸ“‘', + adminOnly: true, + layerOnly: true, + }, + { + id: 'geojson-url', + label: 'GeoJSON URL', + subLabel: 'Remote file', + icon: 'πŸ“„', + adminOnly: true, + layerOnly: true, + }, + { + id: 'qms', + label: 'QuickMapServices', + subLabel: 'NextGIS catalog', + icon: 'πŸ”', + adminOnly: true, + layerOnly: false, + }, + // Curated data services + { + id: 'copernicus', + label: 'Copernicus CDS', + subLabel: 'Climate Data Store', + icon: '🌀️', + adminOnly: true, + layerOnly: true, + }, + { + id: 'climate-api', + label: 'Climate API', + subLabel: 'DHIS2 internal service', + icon: '🌑️', + adminOnly: true, + layerOnly: true, + }, + // Commercial vendors + { + id: 'arcgis', + label: 'ArcGIS', + subLabel: 'Feature / Map Service', + icon: 'πŸ—ΊοΈ', + adminOnly: true, + layerOnly: false, + }, + { + id: 'ee-preset', + label: 'Earth Engine', + subLabel: 'Preset dataset', + icon: '🌍', + adminOnly: false, + layerOnly: true, + }, + { + id: 'ee-custom', + label: 'Earth Engine', + subLabel: 'Custom asset', + icon: 'βš™οΈ', + adminOnly: true, + layerOnly: true, + }, + // Org / Personal data + { + id: 'org-data', + label: 'Org data', + subLabel: 'Draw or upload', + icon: '✏️', + adminOnly: true, + layerOnly: true, + }, + { + id: 'user-data', + label: 'Personal data', + subLabel: 'Draw, upload or share', + icon: 'πŸ‘€', + adminOnly: false, + layerOnly: true, + }, + { + id: 'shared-with-me', + label: 'Shared with me', + subLabel: 'From other users', + icon: 'πŸ‘₯', + adminOnly: false, + layerOnly: true, + }, +] + +const SOURCE_TYPE_BY_ID = Object.fromEntries(SOURCE_TYPES.map((t) => [t.id, t])) + +const GROUPS = [ + { + label: 'OGC / Open standards', + ids: [ + 'cog', + 'geojson-url', + 'mvt', + 'ogc-api-features', + 'qms', + 'stac', + 'wfs', + 'wms', + 'wmts', + 'xyz', + ], + }, + { + label: 'Curated data services', + ids: ['climate-api', 'copernicus'], + }, + { + label: 'Cloud GIS platforms', + ids: ['arcgis', 'ee-custom', 'ee-preset'], + }, + { + label: 'Org / Personal data', + ids: ['org-data', 'user-data', 'shared-with-me'], + }, +] + +const STEPS = ['Choose type', 'Configure', 'Name & style'] + +const MOCK_TAGS = [ + 'population', + 'climate', + 'health', + 'global', + 'local', + 'annual', + 'monthly', +] + +const EE_PRESETS = getEarthEngineLayers() + .filter((l) => !l.legacy) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, 12) + +const MOCK_SHARED_SOURCES = [ + { + id: 'sh1', + name: 'District catchment areas', + owner: 'Alice Smith', + type: 'GeoJSON', + date: 'Jun 2024', + }, + { + id: 'sh2', + name: 'Vaccination coverage zones', + owner: 'Mohammed Al-Hassan', + type: 'GeoJSON', + date: 'Jul 2024', + }, + { + id: 'sh3', + name: 'Custom admin boundaries 2024', + owner: 'GIS Team', + type: 'GeoJSON', + date: 'Aug 2024', + }, + { + id: 'sh4', + name: 'Health facility service areas', + owner: 'Nguyen Thi Lan', + type: 'GeoJSON', + date: 'Sep 2024', + }, +] + +const MOCK_USERS = [ + 'Alice Smith', + 'Mohammed Al-Hassan', + 'Nguyen Thi Lan', + 'Sarah Johnson', +] +const MOCK_GROUPS = [ + 'GIS Team', + 'National Analytics', + 'Public Health Division', + 'Regional Coordinators', +] + +const QMS_MOCK = [ + { + id: 1, + name: 'OpenStreetMap Standard', + type: 'TMS', + group: 'OpenStreetMap', + }, + { id: 2, name: 'OSM Humanitarian', type: 'TMS', group: 'OpenStreetMap' }, + { id: 3, name: 'OpenTopoMap', type: 'TMS', group: 'Topography' }, + { id: 4, name: 'Stamen Terrain', type: 'TMS', group: 'Stamen' }, + { id: 5, name: 'Stamen Toner', type: 'TMS', group: 'Stamen' }, + { id: 6, name: 'CartoDB Positron', type: 'TMS', group: 'CartoDB' }, + { id: 7, name: 'CartoDB Dark Matter', type: 'TMS', group: 'CartoDB' }, + { id: 8, name: 'Esri World Imagery', type: 'TMS', group: 'Esri' }, + { id: 9, name: 'Esri World Street Map', type: 'TMS', group: 'Esri' }, + { id: 10, name: 'Bing Maps Aerial', type: 'TMS', group: 'Microsoft' }, + { id: 11, name: 'Google Maps', type: 'TMS', group: 'Google' }, + { id: 12, name: 'Google Satellite', type: 'TMS', group: 'Google' }, +] + +const MOCK_PERSONAL_QUOTA = { + total: 5 * 1e6, + used: 2.1 * 1e6, + layerSize: 0.4 * 1e6, +} + +const MOCK_ORG_QUOTA = { + total: 1 * 1e9, + used: 340 * 1e6, + layerSize: 12.8 * 1e6, +} + +const formatBytes = (bytes) => { + if (bytes >= 1e9) { + return `${(bytes / 1e9).toFixed(1)} GB` + } + if (bytes >= 1e6) { + return `${(bytes / 1e6).toFixed(1)} MB` + } + return `${(bytes / 1e3).toFixed(0)} KB` +} + +const QuotaBar = ({ label, used, total, layerSize }) => { + const otherUsed = Math.max(used - layerSize, 0) + const otherPct = Math.min((otherUsed / total) * 100, 100) + const layerPct = Math.min((layerSize / total) * 100, 100 - otherPct) + const totalPct = otherPct + layerPct + const isCritical = totalPct > 90 + const isWarning = !isCritical && totalPct > 75 + return ( +
+
+ {label} + + {formatBytes(used)} / {formatBytes(total)} used + +
+
+
+
+
+
+ + + Other layers: {formatBytes(otherUsed)} + + + + This layer: {formatBytes(layerSize)} + +
+
+ ) +} + +QuotaBar.propTypes = { + label: PropTypes.string.isRequired, + layerSize: PropTypes.number.isRequired, + total: PropTypes.number.isRequired, + used: PropTypes.number.isRequired, +} + +const ConfigStep = ({ sourceTypeId }) => { + const [url, setUrl] = useState('') + const [layerName, setLayerName] = useState('') + const [assetId, setAssetId] = useState('') + const [selectedPreset, setSelectedPreset] = useState('') + const [stacExpanded, setStacExpanded] = useState(false) + const [cdsExpanded, setCdsExpanded] = useState(false) + const [apiKey, setApiKey] = useState('') + const [qmsSearch, setQmsSearch] = useState('') + const [qmsSelected, setQmsSelected] = useState(null) + const [sharingMode, setSharingMode] = useState('private') + const [sharedUsers, setSharedUsers] = useState([]) + const [sharedGroups, setSharedGroups] = useState([]) + const [sharedSearch, setSharedSearch] = useState('') + const [sharedSelected, setSharedSelected] = useState(null) + + if (sourceTypeId === 'ee-preset') { + return ( +
+
+ + + + Only non-legacy Earth Engine datasets are listed. + +
+
+ ) + } + + if (sourceTypeId === 'ee-custom') { + return ( +
+
+ + setAssetId(e.target.value)} + /> + + The asset must be publicly shared or the service account + must have read access. + +
+
+ + + Browse bands (connect asset first) + +
+
+ ) + } + + if (sourceTypeId === 'arcgis') { + return ( +
+
+ + setUrl(e.target.value)} + /> +
+
+ + + Browse ArcGIS catalogue (coming soon) + +
+
+ ) + } + + if (sourceTypeId === 'stac') { + return ( +
+
+ + setUrl(e.target.value)} + /> + + Supports STAC Catalogue and STAC Collection endpoints. + +
+
+ +
+
setStacExpanded(!stacExpanded)} + > + {stacExpanded ? 'β–Ό' : 'β–Ά'} WorldPop STAC +
+ {stacExpanded && ( + <> +
+ πŸ“¦ Global Mosaic 100m (2000–2020) +
+
+ πŸ“¦ Age & Sex Structure 2020 +
+
+ πŸ“¦ Urban Change 2000–2015 +
+
+ πŸ“¦ Population Density 2000–2030 +
+ + )} +
+
+
+ ) + } + + if (sourceTypeId === 'copernicus') { + return ( +
+
+ + setApiKey(e.target.value)} + /> + + Obtain your key at cds.climate.copernicus.eu β†’ Profile β†’ + API key. + +
+
+ +
+
setCdsExpanded(!cdsExpanded)} + > + {cdsExpanded ? 'β–Ό' : 'β–Ά'} Copernicus Climate Data + Store +
+ {cdsExpanded && ( + <> +
+ πŸ“¦ ERA5 β€” Monthly averaged data on single + levels +
+
+ πŸ“¦ ERA5 β€” Hourly data on pressure levels +
+
+ πŸ“¦ ERA5-Land β€” Monthly averaged data +
+
+ πŸ“¦ CERRA β€” Regional reanalysis for Europe +
+
+ πŸ“¦ Seasonal forecast β€” Monthly statistics +
+ + )} +
+ + Protocol: CDS API. STAC endpoints available for select + datasets. + +
+
+ ) + } + + if (sourceTypeId === 'qms') { + const results = QMS_MOCK.filter( + (s) => + !qmsSearch || + s.name.toLowerCase().includes(qmsSearch.toLowerCase()) || + s.group.toLowerCase().includes(qmsSearch.toLowerCase()) + ) + return ( +
+
+ + setQmsSearch(e.target.value)} + /> + + Searching{' '} + + qms.nextgis.com + {' '} + β€” {results.length} result + {results.length !== 1 ? 's' : ''} + +
+
+
+ {results.map((s) => ( +
setQmsSelected(s.id)} + > +
+ {s.name} +
+
+ {s.group} Β· {s.type} +
+
+ ))} +
+
+
+ ) + } + + if (sourceTypeId === 'wfs') { + return ( +
+
+ + setUrl(e.target.value)} + /> +
+
+ + setLayerName(e.target.value)} + /> +
+
+ + +
+
+ + + + OGC Common Query Language expression to filter features. + +
+
+ ) + } + + if (sourceTypeId === 'wmts') { + return ( +
+
+ + setUrl(e.target.value)} + /> +
+
+ +
+
setStacExpanded(!stacExpanded)} + > + {stacExpanded ? 'β–Ό' : 'β–Ά'} Available layers +
+ {stacExpanded && ( + <> +
+ πŸ—ΊοΈ ortho_2023 β€” Orthophoto 2023 +
+
+ πŸ—ΊοΈ admin_boundaries β€” Administrative + boundaries +
+
+ πŸ—ΊοΈ hillshade β€” Terrain hillshade +
+ + )} +
+ + Enter the URL above to browse available layers. + +
+
+ + +
+
+ ) + } + + if (sourceTypeId === 'ogc-api-features') { + return ( +
+
+ + setUrl(e.target.value)} + /> + + OGC API Features root endpoint. The service must support + JSON output. + +
+
+ +
+
setCdsExpanded(!cdsExpanded)} + > + {cdsExpanded ? 'β–Ό' : 'β–Ά'} Collections +
+ {cdsExpanded && ( + <> +
+ ⬑ health_facilities +
+
+ ⬑ admin_boundaries_level2 +
+
+ ⬑ waterbodies +
+
+ ⬑ roads_primary +
+ + )} +
+
+
+ + +
+
+ ) + } + + if (sourceTypeId === 'mvt') { + return ( +
+
+ + setUrl(e.target.value)} + /> + + Use {'{z}'}, {'{x}'}, {'{y}'} placeholders. Tiles must + be served as application/x-protobuf. + +
+
+ + + + Provide a GL style JSON to control layer rendering. If + omitted a default style will be applied. + +
+
+ ) + } + + if (sourceTypeId === 'cog') { + return ( +
+
+ + setUrl(e.target.value)} + /> + + Cloud-Optimized GeoTIFF must be publicly accessible via + HTTP range requests. + +
+
+ + + Inspect bands (connect URL first) + +
+
+ + + Configure colour ramp (not implemented in mockup) + +
+
+ ) + } + + if (sourceTypeId === 'climate-api') { + return ( +
+
+ + setUrl(e.target.value)} + /> + + Internal DHIS2 Climate API. Contact your system + administrator for the endpoint URL. + +
+
+ +
+
setCdsExpanded(!cdsExpanded)} + > + {cdsExpanded ? 'β–Ό' : 'β–Ά'} Climate variables +
+ {cdsExpanded && ( + <> +
+ 🌑️ Temperature (2m) β€” daily / monthly +
+
+ 🌧️ Precipitation β€” daily / monthly +
+
+ πŸ’¨ Relative humidity β€” daily +
+
+ β˜€οΈ Solar radiation β€” daily +
+
+ 🌊 NDVI / Vegetation index β€” monthly +
+ + )} +
+
+
+ ) + } + + if (sourceTypeId === 'wms') { + return ( +
+
+ + setUrl(e.target.value)} + /> +
+
+ + setLayerName(e.target.value)} + /> +
+
+ + +
+
+ ) + } + + if (sourceTypeId === 'xyz') { + return ( +
+
+ + setUrl(e.target.value)} + /> + + Use {'{z}'}, {'{x}'}, {'{y}'} placeholders for zoom and + tile coordinates. + +
+
+ ) + } + + if (sourceTypeId === 'geojson-url') { + return ( +
+
+ + setUrl(e.target.value)} + /> + + The URL must be publicly accessible or return + CORS-enabled responses. + +
+
+ +
+ Enter URL to preview +
+
+
+ ) + } + + if (sourceTypeId === 'org-data') { + return ( +
+
+ +
+ + ✏️ Draw on map + + +
+ + Stored in the organisation data store and visible to all + users. + +
+
+ + +
+ +
+ ) + } + + if (sourceTypeId === 'user-data') { + const availableUsers = MOCK_USERS.filter( + (u) => !sharedUsers.includes(u) + ) + const availableGroups = MOCK_GROUPS.filter( + (g) => !sharedGroups.includes(g) + ) + return ( +
+
+ +
+ + ✏️ Draw on map + + +
+
+ +
+ +
+ + +
+
+ + {sharingMode === 'shared' && ( + <> +
+ +
+ {sharedUsers.map((u) => ( + + {u} + + + ))} + {availableUsers.length > 0 && ( + + )} +
+
+
+ +
+ {sharedGroups.map((g) => ( + + {g} + + + ))} + {availableGroups.length > 0 && ( + + )} +
+
+
+ + + +
+ + )} + +
+ ) + } + + if (sourceTypeId === 'shared-with-me') { + const results = MOCK_SHARED_SOURCES.filter( + (s) => + !sharedSearch || + s.name.toLowerCase().includes(sharedSearch.toLowerCase()) || + s.owner.toLowerCase().includes(sharedSearch.toLowerCase()) + ) + return ( +
+
+ + setSharedSearch(e.target.value)} + /> + + {results.length} source + {results.length !== 1 ? 's' : ''} shared with you + +
+
+
+ {results.length === 0 ? ( +
+ No sources match your search. +
+ ) : ( + results.map((s) => ( +
setSharedSelected(s.id)} + > +
+ {s.name} +
+
+ Shared by {s.owner} Β· {s.type} Β·{' '} + {s.date} +
+
+ )) + )} +
+
+
+ ) + } + + return null +} + +ConfigStep.propTypes = { + sourceTypeId: PropTypes.string.isRequired, +} + +const MetaStep = ({ isAdmin }) => { + const [name, setName] = useState('') + const [thumbnailUrl, setThumbnailUrl] = useState('') + const [tags, setTags] = useState(['health', 'global']) + const [favoriteScope, setFavoriteScope] = useState('none') + + const removeTag = (tag) => setTags(tags.filter((t) => t !== tag)) + + return ( +
+
+
+
+ + setName(e.target.value)} + /> +
+
+ + setThumbnailUrl(e.target.value)} + /> + +
+
+ +
+ {tags.map((tag) => ( + + {tag} + + + ))} + +
+
+
+ + + Configure style… (not implemented in mockup) + +
+ {isAdmin && ( +
+ +
+ + + +
+
+ )} +
+
+
+ {thumbnailUrl ? ( + preview + ) : ( + 'Thumbnail preview' + )} +
+
+
+
+ ) +} + +MetaStep.propTypes = { + isAdmin: PropTypes.bool.isRequired, +} + +const AddSourceWizard = ({ onBack, onCancel, isAdmin, isEdit, editSource }) => { + const displaySteps = isEdit ? STEPS.slice(1) : STEPS + const [step, setStep] = useState(0) + // In edit mode skip type selection β€” derive type from source origin + const [selectedType, setSelectedType] = useState( + isEdit ? ORIGIN_TO_TYPE_ID[editSource?.origin] ?? 'wms' : null + ) + const [useAs, setUseAs] = useState('layer') + + const contentStep = isEdit ? step + 1 : step + const canNext = contentStep === 0 ? selectedType !== null : true + const isLastStep = step === displaySteps.length - 1 + + const handleNext = () => { + if (isLastStep) { + onBack() + } else { + setStep(step + 1) + } + } + + const handleUseAsChange = (val) => { + setUseAs(val) + if (val === 'basemap' && selectedType) { + const type = SOURCE_TYPES.find((t) => t.id === selectedType) + if (type?.layerOnly) { + setSelectedType(null) + } + } + } + + return ( +
+
+ {displaySteps.map((label, i) => ( + +
+ {i < step ? 'βœ“' : i + 1} +
+ + {label} + + {i < displaySteps.length - 1 && ( +
+ )} + + ))} +
+ + {contentStep === 0 && ( +
+ Use as: + {[ + { value: 'layer', label: 'Layer' }, + { value: 'basemap', label: 'Basemap' }, + ].map(({ value, label }) => ( + + ))} +
+ )} + +
+ {contentStep === 0 && ( + <> +
+ {GROUPS.map((group) => ( +
+

+ {group.label} +

+
+ {group.ids.map((id) => { + const type = SOURCE_TYPE_BY_ID[id] + if (!type) { + return null + } + const disabled = + (type.layerOnly && + useAs === 'basemap') || + (type.adminOnly && !isAdmin) + return ( +
+ !disabled && + setSelectedType(type.id) + } + > + {type.adminOnly && ( + + Admin + + )} + + {type.icon} + + + {type.label} + + + {type.subLabel} + +
+ ) + })} +
+
+ ))} +
+ + )} + + {contentStep === 1 && selectedType && ( + + )} + + {contentStep === 2 && } +
+ +
+ + + {step > 0 && ( + + )} + + +
+
+ ) +} + +AddSourceWizard.propTypes = { + isAdmin: PropTypes.bool.isRequired, + onBack: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + editSource: PropTypes.object, + isEdit: PropTypes.bool, +} + +export default AddSourceWizard diff --git a/src/components/layerSources/LayerSourceCard.jsx b/src/components/layerSources/LayerSourceCard.jsx new file mode 100644 index 000000000..6743dd8f2 --- /dev/null +++ b/src/components/layerSources/LayerSourceCard.jsx @@ -0,0 +1,208 @@ +import { IconEdit16, IconDelete16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useState } from 'react' +import styles from './styles/LayerSourceCard.module.css' + +const LayerSourceCard = ({ + source, + isFavorite, + onToggleFavorite, + onAddToMap, + onEdit, + onDelete, +}) => { + const [expanded, setExpanded] = useState(false) + + const { + name, + description, + extendedDescription, + img, + origin, + periodType, + resolution, + tags, + periodCovered, + spatialResolution, + providerUrl, + license, + } = source + + const metaParts = [periodType, resolution].filter(Boolean) + const hasDetails = + extendedDescription || + periodCovered || + spatialResolution || + providerUrl || + license + + return ( +
setExpanded((e) => !e)} + > + +
+
+
+

{name}

+

{origin}

+
+
+ + {onEdit && ( + + )} + {onDelete && ( + + )} + +
+
+

{description}

+
+ {tags.map((tag) => ( + + {tag} + + ))} + {metaParts.length > 0 && ( + + {metaParts.join(' Β· ')} + + )} + {hasDetails && ( + + {expanded ? 'β–΄' : 'β–Ύ'} + + )} +
+ {expanded && hasDetails && ( +
+ {extendedDescription && ( +

+ {extendedDescription} +

+ )} +
+ {periodCovered && ( +
+ + Period + + + {periodCovered} + +
+ )} + {spatialResolution && ( +
+ + Spatial res. + + + {spatialResolution} + +
+ )} + {license && ( +
+ + License + + + {license} + +
+ )} + {providerUrl && ( + + )} +
+
+ )} +
+
+ ) +} + +LayerSourceCard.propTypes = { + isFavorite: PropTypes.bool.isRequired, + source: PropTypes.shape({ + description: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + img: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + origin: PropTypes.string.isRequired, + tags: PropTypes.arrayOf(PropTypes.string).isRequired, + extendedDescription: PropTypes.string, + license: PropTypes.string, + periodCovered: PropTypes.string, + periodType: PropTypes.string, + providerUrl: PropTypes.string, + resolution: PropTypes.string, + spatialResolution: PropTypes.string, + }).isRequired, + onAddToMap: PropTypes.func.isRequired, + onToggleFavorite: PropTypes.func.isRequired, + onDelete: PropTypes.func, + onEdit: PropTypes.func, +} + +export default LayerSourceCard diff --git a/src/components/layerSources/LayerSourceCatalogue.jsx b/src/components/layerSources/LayerSourceCatalogue.jsx new file mode 100644 index 000000000..cafc4a4dc --- /dev/null +++ b/src/components/layerSources/LayerSourceCatalogue.jsx @@ -0,0 +1,501 @@ +import PropTypes from 'prop-types' +import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react' +import LayerSourceCard from './LayerSourceCard.jsx' +import { + MAP_TYPE, + ORIGIN, + PERIOD_TYPE, + RESOLUTION, + mockCatalogueSources, +} from './mockCatalogueSources.js' +import styles from './styles/LayerSourceCatalogue.module.css' + +const DATE_FILTER_MIN = 1940 +const DATE_FILTER_MAX = 2025 + +const ORIGIN_FILTERS = [ + { label: 'All', value: null }, + { label: 'β˜… Favorites', value: '__favorites__' }, + { label: 'Earth Engine', value: ORIGIN.EARTH_ENGINE }, + { label: 'STAC', value: ORIGIN.STAC }, + { label: 'Copernicus CDS', value: ORIGIN.COPERNICUS_CDS }, + { label: 'ArcGIS', value: ORIGIN.ARCGIS }, + { label: 'WMS / TMS / XYZ', value: '__tile__' }, + { label: 'GeoJSON', value: ORIGIN.GEOJSON_URL }, + { label: 'Org / User data', value: '__user__' }, + { label: 'Shared with me', value: ORIGIN.SHARED_WITH_ME }, +] + +const SIDEBAR_TAGS = [ + 'population', + 'climate', + 'health', + 'vegetation', + 'elevation', + 'land cover', + 'boundaries', + 'satellite', +] + +const matchesTileOrigin = (origin) => [ORIGIN.WMS, ORIGIN.XYZ].includes(origin) +const matchesUserOrigin = (origin) => + [ORIGIN.ORG_DATA, ORIGIN.USER_DATA].includes(origin) + +const MAP_TYPE_OPTIONS = [ + { label: 'Layer', value: MAP_TYPE.LAYER }, + { label: 'Basemap', value: MAP_TYPE.BASEMAP }, +] + +const pct = (year) => + ((year - DATE_FILTER_MIN) / (DATE_FILTER_MAX - DATE_FILTER_MIN)) * 100 + +const LayerSourceCatalogue = ({ + favorites, + onToggleFavorite, + onAddToMap, + sources = mockCatalogueSources, + onEditSource, + onDeleteSource, + initialMapType = null, +}) => { + const [search, setSearch] = useState('') + const [activeMapType, setActiveMapType] = useState(initialMapType) + const [activeChip, setActiveChip] = useState(null) + const [periodFilters, setPeriodFilters] = useState([]) + const [resolutionFilters, setResolutionFilters] = useState([]) + const [tagFilters, setTagFilters] = useState([]) + const [sortBy, setSortBy] = useState('default') + const [yearRange, setYearRange] = useState([ + DATE_FILTER_MIN, + DATE_FILTER_MAX, + ]) + const [showAllPeriods, setShowAllPeriods] = useState(false) + const [showAllTags, setShowAllTags] = useState(false) + const chipsRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + + const checkChipsScroll = useCallback(() => { + const el = chipsRef.current + if (!el) { + return + } + setCanScrollLeft(el.scrollLeft > 0) + setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1) + }, []) + + useEffect(() => { + const el = chipsRef.current + if (!el) { + return + } + checkChipsScroll() + el.addEventListener('scroll', checkChipsScroll, { passive: true }) + const ro = new ResizeObserver(checkChipsScroll) + ro.observe(el) + return () => { + el.removeEventListener('scroll', checkChipsScroll) + ro.disconnect() + } + }, [checkChipsScroll]) + + const scrollChips = (dir) => { + chipsRef.current?.scrollBy({ left: dir * 160, behavior: 'smooth' }) + } + + const toggleFilter = (list, setList, value) => { + setList( + list.includes(value) + ? list.filter((v) => v !== value) + : [...list, value] + ) + } + + const dateFilterActive = + yearRange[0] > DATE_FILTER_MIN || yearRange[1] < DATE_FILTER_MAX + + const filtered = useMemo(() => { + let result = sources.filter((source) => { + if (activeMapType && source.mapType !== activeMapType) { + return false + } + + if (search) { + const q = search.toLowerCase() + if ( + !source.name.toLowerCase().includes(q) && + !source.description.toLowerCase().includes(q) && + !source.tags.some((t) => t.toLowerCase().includes(q)) + ) { + return false + } + } + + if (activeChip) { + if ( + activeChip === '__favorites__' && + !favorites.has(source.id) + ) { + return false + } + if ( + activeChip === '__tile__' && + !matchesTileOrigin(source.origin) + ) { + return false + } + if ( + activeChip === '__user__' && + !matchesUserOrigin(source.origin) + ) { + return false + } + if ( + activeChip !== '__favorites__' && + activeChip !== '__tile__' && + activeChip !== '__user__' && + source.origin !== activeChip + ) { + return false + } + } + + if ( + periodFilters.length > 0 && + !periodFilters.includes(source.periodType) + ) { + return false + } + if ( + resolutionFilters.length > 0 && + !resolutionFilters.includes(source.resolution) + ) { + return false + } + if ( + tagFilters.length > 0 && + !tagFilters.every((t) => source.tags.includes(t)) + ) { + return false + } + + if (dateFilterActive && source.startYear != null) { + const srcEnd = source.endYear ?? DATE_FILTER_MAX + if (srcEnd < yearRange[0] || source.startYear > yearRange[1]) { + return false + } + } + + return true + }) + + if (sortBy === 'az') { + result = [...result].sort((a, b) => a.name.localeCompare(b.name)) + } else if (sortBy === 'za') { + result = [...result].sort((a, b) => b.name.localeCompare(a.name)) + } else if (sortBy === 'origin') { + result = [...result].sort((a, b) => + a.origin.localeCompare(b.origin) + ) + } + + return result + }, [ + sources, + search, + activeMapType, + activeChip, + favorites, + periodFilters, + resolutionFilters, + tagFilters, + sortBy, + yearRange, + dateFilterActive, + ]) + + return ( +
+
+ setSearch(e.target.value)} + /> +
+ {canScrollLeft && ( + + )} +
+ {ORIGIN_FILTERS.map(({ label, value }) => ( + + ))} +
+ {canScrollRight && ( + + )} +
+
+ +
+
+
+

Map type

+ {MAP_TYPE_OPTIONS.map(({ label, value }) => ( + + ))} +
+ +
+

Period type

+ {(showAllPeriods + ? Object.values(PERIOD_TYPE) + : Object.values(PERIOD_TYPE).slice(0, 4) + ).map((pt) => ( + + ))} + {Object.values(PERIOD_TYPE).length > 4 && ( + + )} +
+ +
+

Date range

+
+ {yearRange[0]} + + {yearRange[1] >= DATE_FILTER_MAX + ? 'present' + : yearRange[1]} + +
+
+
+
+
+ { + const val = Math.min( + Number(e.target.value), + yearRange[1] - 1 + ) + setYearRange([val, yearRange[1]]) + }} + /> + { + const val = Math.max( + Number(e.target.value), + yearRange[0] + 1 + ) + setYearRange([yearRange[0], val]) + }} + /> +
+
+ {DATE_FILTER_MIN} + present +
+ {dateFilterActive && ( + + )} +
+ +
+

Resolution

+ {Object.values(RESOLUTION).map((res) => ( + + ))} +
+ +
+

Tags

+
+ {(showAllTags + ? SIDEBAR_TAGS + : SIDEBAR_TAGS.slice(0, 5) + ).map((tag) => ( + + toggleFilter( + tagFilters, + setTagFilters, + tag + ) + } + > + {tag} + + ))} +
+ {SIDEBAR_TAGS.length > 5 && ( + + )} +
+
+ +
+
+

+ {filtered.length} source + {filtered.length !== 1 ? 's' : ''} +

+
+ Sort: + +
+
+
+ {filtered.length === 0 ? ( +
+ No sources match your filters. +
+ ) : ( + filtered.map((source) => ( + + )) + )} +
+
+
+
+ ) +} + +LayerSourceCatalogue.propTypes = { + favorites: PropTypes.instanceOf(Set).isRequired, + onAddToMap: PropTypes.func.isRequired, + onToggleFavorite: PropTypes.func.isRequired, + initialMapType: PropTypes.string, + sources: PropTypes.array, + onDeleteSource: PropTypes.func, + onEditSource: PropTypes.func, +} + +export default LayerSourceCatalogue diff --git a/src/components/layerSources/ManageLayerSourcesModal.jsx b/src/components/layerSources/ManageLayerSourcesModal.jsx index 3e6f8d870..b0b67a9a3 100644 --- a/src/components/layerSources/ManageLayerSourcesModal.jsx +++ b/src/components/layerSources/ManageLayerSourcesModal.jsx @@ -4,73 +4,184 @@ import { ModalTitle, ModalContent, ModalActions, - Button, ButtonStrip, + Button, } from '@dhis2/ui' import PropTypes from 'prop-types' -import React from 'react' -import getEarthEngineLayers from '../../constants/earthEngineLayers/index.js' +import React, { useState, useCallback } from 'react' +import { MAPS_ADMIN_AUTHORITY_IDS } from '../../constants/settings.js' import useKeyDown from '../../hooks/useKeyDown.js' -import useManagedLayerSourcesStore from '../../hooks/useManagedLayerSourcesStore.js' -import LayerSource from './LayerSource.jsx' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import AddSourceWizard from './AddSourceWizard.jsx' +import LayerSourceCatalogue from './LayerSourceCatalogue.jsx' +import { mockCatalogueSources } from './mockCatalogueSources.js' +import { useFavorites } from './mockFavoritesStore.js' import styles from './styles/ManageLayerSourcesModal.module.css' -const nonLegacyEarthEngineLayers = getEarthEngineLayers() - .filter((l) => !l.legacy) - .sort((a, b) => a.name.localeCompare(b.name)) -const layerSources = [...nonLegacyEarthEngineLayers] +const ManageLayerSourcesModal = ({ onClose, onAddToMap, initialMapType }) => { + const { currentUser } = useCachedData() + const isAdmin = MAPS_ADMIN_AUTHORITY_IDS.some((id) => + currentUser.authorities.has(id) + ) + + const [showWizard, setShowWizard] = useState(false) + const [isEditMode, setIsEditMode] = useState(false) + const [editingSource, setEditingSource] = useState(null) + const [sources, setSources] = useState([...mockCatalogueSources]) + const [favorites, toggleFavorite] = useFavorites() + const [pendingDeleteId, setPendingDeleteId] = useState(null) + + const handleEscape = useCallback(() => { + if (showWizard) { + setShowWizard(false) + setIsEditMode(false) + setEditingSource(null) + } else { + onClose() + } + }, [showWizard, onClose]) + + useKeyDown('Escape', handleEscape) + + const handleToggleFavorite = toggleFavorite -const ManageLayerSourcesModal = ({ onClose }) => { - const { managedLayerSources, showLayerSource, hideLayerSource } = - useManagedLayerSourcesStore() + const handleAddToMap = (source) => { + if (onAddToMap) { + onAddToMap(source) + } + onClose() + } - useKeyDown('Escape', onClose) + const handleDeleteSource = (id) => { + setPendingDeleteId(id) + } + + const handleConfirmDelete = () => { + const id = pendingDeleteId + setSources((prev) => prev.filter((s) => s.id !== id)) + if (favorites.has(id)) { + toggleFavorite(id) + } + setPendingDeleteId(null) + } + + const handleCancelDelete = () => { + setPendingDeleteId(null) + } + + const handleEditSource = (source) => { + setEditingSource(source) + setIsEditMode(true) + setShowWizard(true) + } + + const handleWizardBack = () => { + setShowWizard(false) + setIsEditMode(false) + setEditingSource(null) + } + + const wizardTitle = isEditMode + ? i18n.t('Edit layer source') + : i18n.t('Add layer source') return ( - - - {i18n.t('Configure available layer sources')} - - -
- {i18n.t( - 'Choose which layer sources are available to add to maps. This selection applies to all users.' - )} -
- {layerSources.map((layerSource) => ( - + + +
+
+

+ {showWizard + ? wizardTitle + : i18n.t('Layer sources')} +

+ {isAdmin && ( + + )} +
+ +
+ {showWizard ? ( + + ) : ( + + )} +
+
+
+
+ + {pendingDeleteId && ( + + {i18n.t('Delete layer source')} + + {i18n.t( + 'This layer source will be permanently removed from the catalogue. Continue?' )} - onShow={showLayerSource} - onHide={hideLayerSource} - /> - ))} - - - - - - - +
+ + + + + + +
+ )} + ) } ManageLayerSourcesModal.propTypes = { onClose: PropTypes.func.isRequired, + initialMapType: PropTypes.string, + onAddToMap: PropTypes.func, } export default ManageLayerSourcesModal diff --git a/src/components/layerSources/mockCatalogueSources.js b/src/components/layerSources/mockCatalogueSources.js new file mode 100644 index 000000000..b263a83dd --- /dev/null +++ b/src/components/layerSources/mockCatalogueSources.js @@ -0,0 +1,753 @@ +export const ORIGIN = { + EARTH_ENGINE: 'Earth Engine', + STAC: 'STAC', + COPERNICUS_CDS: 'Copernicus CDS', + ARCGIS: 'ArcGIS', + WMS: 'WMS', + XYZ: 'XYZ / TMS', + GEOJSON_URL: 'GeoJSON URL', + ORG_DATA: 'Org data', + USER_DATA: 'User data', + SHARED_WITH_ME: 'Shared with me', +} + +export const MAP_TYPE = { + LAYER: 'layer', + BASEMAP: 'basemap', +} + +export const PERIOD_TYPE = { + DAILY: 'Daily', + WEEKLY: 'Weekly', + MONTHLY: 'Monthly', + ANNUAL: 'Annual', + OTHER: 'Other', +} + +export const RESOLUTION = { + GLOBAL: 'Global', + REGIONAL: 'Regional', + LOCAL: 'Local', +} + +export const mockCatalogueSources = [ + // Built-in basemaps β€” IDs match the real basemap IDs in basemaps.js + { + id: 'osmLight', + name: 'OSM Light', + description: 'Light CartoDB basemap built on OpenStreetMap data.', + extendedDescription: null, + img: 'images/osmlight.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'openstreetmap'], + isFavorite: true, + startYear: null, + endYear: null, + periodCovered: 'Continuously updated', + spatialResolution: null, + providerUrl: 'https://www.openstreetmap.org/copyright', + license: 'ODbL 1.0', + }, + { + id: 'openStreetMap', + name: 'OSM Detailed', + description: + 'Standard OpenStreetMap tiles with full road and POI detail.', + extendedDescription: null, + img: 'images/osm.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'openstreetmap'], + isFavorite: true, + startYear: null, + endYear: null, + periodCovered: 'Continuously updated', + spatialResolution: null, + providerUrl: 'https://www.openstreetmap.org/copyright', + license: 'ODbL 1.0', + }, + { + id: 'sentinel2eox', + name: 'Sentinel-2 EOX', + description: + 'Cloudless Sentinel-2 satellite imagery composite for the current year.', + extendedDescription: null, + img: 'images/s2eox.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.ANNUAL, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'satellite'], + isFavorite: true, + startYear: 2015, + endYear: null, + periodCovered: '2015 – present (annual composites)', + spatialResolution: '10 m', + providerUrl: 'https://s2maps.eu/', + license: 'Copernicus Sentinel data terms', + }, + { + id: 'bingLight', + name: 'Bing Road', + description: 'Microsoft Bing road map in a light canvas style.', + extendedDescription: null, + img: 'images/bingroad.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'bing', 'road'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Continuously updated', + spatialResolution: null, + providerUrl: 'https://www.bing.com/maps', + license: 'Microsoft Bing Maps terms', + }, + { + id: 'bingDark', + name: 'Bing Dark', + description: 'Microsoft Bing road map in a dark canvas style.', + extendedDescription: null, + img: 'images/bingdark.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'bing', 'dark'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Continuously updated', + spatialResolution: null, + providerUrl: 'https://www.bing.com/maps', + license: 'Microsoft Bing Maps terms', + }, + { + id: 'bingAerial', + name: 'Bing Aerial', + description: 'Microsoft Bing aerial / satellite imagery.', + extendedDescription: null, + img: 'images/bingaerial.jpeg', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'bing', 'satellite'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Varies by region', + spatialResolution: null, + providerUrl: 'https://www.bing.com/maps', + license: 'Microsoft Bing Maps terms', + }, + { + id: 'bingHybrid', + name: 'Bing Aerial Labels', + description: 'Microsoft Bing aerial imagery with road labels overlay.', + extendedDescription: null, + img: 'images/binghybrid.jpeg', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'bing', 'satellite'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Varies by region', + spatialResolution: null, + providerUrl: 'https://www.bing.com/maps', + license: 'Microsoft Bing Maps terms', + }, + { + id: 'azureLight', + name: 'Azure Road', + description: 'Microsoft Azure Maps road basemap in light style.', + extendedDescription: null, + img: 'images/azureroad.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'azure', 'road'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Continuously updated', + spatialResolution: null, + providerUrl: 'https://azure.microsoft.com/en-us/products/azure-maps', + license: 'Microsoft Azure Maps terms', + }, + { + id: 'azureDark', + name: 'Azure Dark', + description: 'Microsoft Azure Maps road basemap in dark style.', + extendedDescription: null, + img: 'images/azuredark.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'azure', 'dark'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Continuously updated', + spatialResolution: null, + providerUrl: 'https://azure.microsoft.com/en-us/products/azure-maps', + license: 'Microsoft Azure Maps terms', + }, + { + id: 'azureAerial', + name: 'Azure Aerial', + description: 'Microsoft Azure Maps aerial imagery basemap.', + extendedDescription: null, + img: 'images/azureaerial.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'azure', 'satellite'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Varies by region', + spatialResolution: null, + providerUrl: 'https://azure.microsoft.com/en-us/products/azure-maps', + license: 'Microsoft Azure Maps terms', + }, + { + id: 'azureHybrid', + name: 'Azure Aerial Labels', + description: + 'Microsoft Azure Maps aerial imagery with road labels overlay.', + extendedDescription: null, + img: 'images/azurehybrid.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'azure', 'satellite'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Varies by region', + spatialResolution: null, + providerUrl: 'https://azure.microsoft.com/en-us/products/azure-maps', + license: 'Microsoft Azure Maps terms', + }, + // Earth Engine β€” layers only + { + id: 'ee-population', + name: 'Population Total', + description: + 'Estimated number of people living in an area at ~100m resolution, annually updated.', + extendedDescription: + 'WorldPop Global High Resolution Population Denominators provide gridded population counts adjusted to match UN national estimates. Both unconstrained (modelled) and constrained (building-footprint-informed) variants are available. Commonly used as denominators in health burden calculations and small-area demographic analyses.', + img: 'images/population.png', + origin: ORIGIN.EARTH_ENGINE, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.ANNUAL, + resolution: RESOLUTION.GLOBAL, + tags: ['population', 'global', 'annual'], + isFavorite: true, + startYear: 2000, + endYear: null, + periodCovered: '2000–present', + spatialResolution: '~100m', + providerUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/WorldPop_GP_100m_pop', + license: 'CC BY 4.0', + }, + { + id: 'ee-temperature', + name: 'Temperature Monthly', + description: + 'Average monthly 2m air temperature from ERA5-Land reanalysis at ~11km resolution.', + extendedDescription: + 'ERA5-Land is a reanalysis dataset produced by ECMWF providing a consistent account of land surface variables from 1950 to the present at roughly 9km native resolution. Monthly aggregates of 2-metre air temperature are available globally. Widely used in climate impact studies, disease modelling, and agricultural assessments.', + img: 'images/temperature.png', + origin: ORIGIN.EARTH_ENGINE, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.MONTHLY, + resolution: RESOLUTION.GLOBAL, + tags: ['climate', 'temperature', 'monthly'], + isFavorite: true, + startYear: 1950, + endYear: null, + periodCovered: '1950–present', + spatialResolution: '~11km', + providerUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/ECMWF_ERA5_LAND_MONTHLY_AGGR', + license: 'Copernicus License', + }, + { + id: 'ee-precipitation', + name: 'Precipitation Monthly', + description: + 'Monthly total precipitation from CHIRPS dataset at ~5km resolution.', + extendedDescription: + 'CHIRPS blends satellite imagery with ground station observations to produce quasi-global rainfall estimates. Covers latitudes 50Β°S–50Β°N and is updated monthly with a ~3-week lag. Extensively used in food security, drought monitoring, and malaria transmission modelling.', + img: 'images/precipitation.png', + origin: ORIGIN.EARTH_ENGINE, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.MONTHLY, + resolution: RESOLUTION.GLOBAL, + tags: ['climate', 'precipitation', 'monthly'], + isFavorite: true, + startYear: 1981, + endYear: null, + periodCovered: '1981–present', + spatialResolution: '~5km', + providerUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/UCSB-CHG_CHIRPS_MONTHLY', + license: 'Public Domain (CC0)', + }, + { + id: 'ee-vegetation', + name: 'Vegetation (NDVI)', + description: + 'Normalized Difference Vegetation Index from MODIS at ~500m resolution.', + extendedDescription: + 'The MOD13A1 product provides NDVI and EVI indices derived from MODIS Terra satellite observations as 16-day composites. Cloud contamination is minimised through temporal compositing. NDVI is widely used as a proxy for vegetation health, seasonal land cover change, and habitat suitability in epidemiological research.', + img: 'images/vegetation.png', + origin: ORIGIN.EARTH_ENGINE, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.MONTHLY, + resolution: RESOLUTION.GLOBAL, + tags: ['vegetation', 'ndvi', 'monthly'], + isFavorite: true, + startYear: 2000, + endYear: null, + periodCovered: '2000–present', + spatialResolution: '~500m', + providerUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/MODIS_061_MOD13A1', + license: 'Public Domain (CC0)', + }, + { + id: 'ee-elevation', + name: 'Elevation (SRTM)', + description: + 'Digital elevation model at ~30m resolution from the Shuttle Radar Topography Mission.', + extendedDescription: + 'The SRTM DEM was acquired during an 11-day Space Shuttle mission in February 2000. It provides near-global coverage between 60Β°N and 56Β°S at 1 arc-second resolution. The dataset is static (single acquisition) and commonly used for terrain analysis, watershed delineation, and accessibility modelling.', + img: 'images/elevation.png', + origin: ORIGIN.EARTH_ENGINE, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['elevation', 'terrain', 'static'], + isFavorite: true, + startYear: 2000, + endYear: 2000, + periodCovered: 'February 2000 (static)', + spatialResolution: '~30m', + providerUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/USGS_SRTMGL1_003', + license: 'Public Domain (CC0)', + }, + // STAC β€” layers only + { + id: 'stac-worldpop-total', + name: 'WorldPop Global Mosaic 100m', + description: + 'Global high-resolution population count grid at 100m, from the WorldPop STAC catalogue.', + extendedDescription: + 'WorldPop produces gridded population estimates using a random forest model trained on census data, satellite imagery, and ancillary datasets. The global mosaic tiles are served via a STAC-compliant endpoint at worldpop.data.gov.uk, enabling direct COG access per country and year. Both total and age/sex disaggregated variants are available.', + img: 'images/population.png', + origin: ORIGIN.STAC, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.ANNUAL, + resolution: RESOLUTION.GLOBAL, + tags: ['population', 'worldpop', 'global', 'stac'], + isFavorite: true, + stacEndpoint: 'https://worldpop.data.gov.uk/stac', + startYear: 2000, + endYear: 2020, + periodCovered: '2000–2020', + spatialResolution: '~100m', + providerUrl: 'https://www.worldpop.org', + license: 'CC BY 4.0', + }, + { + id: 'stac-worldpop-agesex', + name: 'WorldPop Age & Sex Structure 2020', + description: + 'Population disaggregated by age group and sex at 100m grid level for the year 2020.', + extendedDescription: + 'Separate rasters for 5-year age groups (0–4, 5–9, …, 80+) split by male and female for each country. Particularly useful for computing age-standardised incidence rates or modelling burden of age-specific diseases such as malaria in children under five.', + img: 'images/population.png', + origin: ORIGIN.STAC, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.ANNUAL, + resolution: RESOLUTION.GLOBAL, + tags: ['population', 'worldpop', 'age-sex', 'demographics', 'stac'], + isFavorite: false, + stacEndpoint: 'https://worldpop.data.gov.uk/stac', + startYear: 2020, + endYear: 2020, + periodCovered: '2020 (single year)', + spatialResolution: '~100m', + providerUrl: 'https://www.worldpop.org', + license: 'CC BY 4.0', + }, + { + id: 'stac-sentinel2', + name: 'Sentinel-2 Imagery', + description: + 'Multi-spectral optical satellite imagery at 10m resolution from the ESA Copernicus programme.', + extendedDescription: + 'Sentinel-2 (S2A and S2B) provides 13-band multispectral imagery with a 5-day revisit time. Level-2A (surface reflectance) scenes are available via STAC on AWS. Bands range from 10m (visible, NIR) to 60m (coastal aerosol, SWIR cirrus). Suitable for land cover mapping, flood extent mapping, and crop monitoring.', + img: 'images/s2eox.png', + origin: ORIGIN.STAC, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.DAILY, + resolution: RESOLUTION.REGIONAL, + tags: ['satellite', 'imagery', 'optical', 'copernicus', 'stac'], + isFavorite: false, + startYear: 2015, + endYear: null, + periodCovered: '2015–present', + spatialResolution: '10–60m (band dependent)', + providerUrl: 'https://registry.opendata.aws/sentinel-2/', + license: 'Copernicus Sentinel Data Terms', + }, + // Copernicus CDS β€” layers only + { + id: 'cds-era5-temperature', + name: 'ERA5 Reanalysis Temperature', + description: + 'Global climate reanalysis providing hourly and monthly temperature back to 1940 at ~31km resolution.', + extendedDescription: + 'ERA5 is the fifth generation ECMWF atmospheric reanalysis. It assimilates vast amounts of historical observations into a consistent model output and provides uncertainty estimates alongside central values. Accessed via the Copernicus Climate Data Store API.', + img: 'images/temperature.png', + origin: ORIGIN.COPERNICUS_CDS, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.MONTHLY, + resolution: RESOLUTION.GLOBAL, + tags: ['climate', 'temperature', 'era5', 'reanalysis'], + isFavorite: true, + startYear: 1940, + endYear: null, + periodCovered: '1940–present', + spatialResolution: '~31km', + providerUrl: + 'https://cds.climate.copernicus.eu/datasets/reanalysis-era5-land-monthly-means', + license: 'Copernicus License', + }, + { + id: 'cds-era5-precipitation', + name: 'ERA5 Total Precipitation', + description: + 'Accumulated liquid and frozen water from ERA5, including rain and snow at ~31km resolution.', + extendedDescription: + 'ERA5 total precipitation is the sum of large-scale and convective precipitation expressed as depth of water per grid cell. Monthly accumulations are available globally from 1940. Preferable to CHIRPS for polar regions or areas with sparse rain gauge networks.', + img: 'images/precipitation.png', + origin: ORIGIN.COPERNICUS_CDS, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.MONTHLY, + resolution: RESOLUTION.GLOBAL, + tags: ['climate', 'precipitation', 'era5', 'reanalysis'], + isFavorite: false, + startYear: 1940, + endYear: null, + periodCovered: '1940–present', + spatialResolution: '~31km', + providerUrl: + 'https://cds.climate.copernicus.eu/datasets/reanalysis-era5-land-monthly-means', + license: 'Copernicus License', + }, + // ArcGIS β€” mixed + { + id: 'arcgis-boundaries', + name: 'Administrative Boundaries', + description: + 'Country and sub-national administrative boundaries from ArcGIS Living Atlas of the World.', + extendedDescription: + 'Multi-level administrative boundaries (country, province, district) sourced from national mapping agencies and harmonised globally. Attribute data includes ISO codes, population estimates, and area statistics. Accessible via ArcGIS REST API as a vector feature service.', + img: 'images/featurelayer.png', + origin: ORIGIN.ARCGIS, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['boundaries', 'administrative', 'arcgis'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Updated annually', + spatialResolution: null, + providerUrl: + 'https://www.arcgis.com/home/item.html?id=2b93b06dc0dc4e809d3c8db5cb96ba69', + license: 'Esri Master License Agreement', + }, + { + id: 'arcgis-roads', + name: 'Road Network Basemap', + description: + 'Global road network basemap tiles from ArcGIS Living Atlas, suitable as a background map.', + extendedDescription: + 'Pre-rendered basemap tile service combining OpenStreetMap road data with Esri cartographic styling. Tiles are cached at zoom levels 0–19 for most regions. Supports both WMTS and XYZ tile access patterns.', + img: 'images/featurelayer.png', + origin: ORIGIN.ARCGIS, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['roads', 'transport', 'basemap', 'arcgis'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Updated quarterly', + spatialResolution: null, + providerUrl: + 'https://www.arcgis.com/home/item.html?id=de26a3cf4cc9451298ea173c4b324736', + license: 'Esri Master License Agreement', + }, + // WMS β€” mixed + { + id: 'wms-osm', + name: 'OpenStreetMap WMS', + description: + 'OpenStreetMap rendered tiles served via WMS, suitable as a background basemap.', + extendedDescription: + 'OpenStreetMap is a collaborative, freely editable map of the world maintained by a global volunteer community. This WMS endpoint serves Mapnik-rendered raster tiles. The ODbL license requires attribution and share-alike for derivative works.', + img: 'images/osm.png', + origin: ORIGIN.WMS, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'openstreetmap', 'wms'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'Continuously updated', + spatialResolution: null, + providerUrl: 'https://www.openstreetmap.org/copyright', + license: 'ODbL 1.0', + }, + { + id: 'wms-health-facilities', + name: 'National Health Facilities WMS', + description: + 'Health facility locations and service areas served via WMS from the national GIS portal.', + extendedDescription: + 'Published by the national Ministry of Health GIS unit, providing point locations for all registered health facilities along with service area polygons. Attributes include facility type, ownership, functional status, and catchment population.', + img: 'images/facilities.png', + origin: ORIGIN.WMS, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.LOCAL, + tags: ['health', 'facilities', 'wms', 'national'], + isFavorite: true, + startYear: 2024, + endYear: 2024, + periodCovered: '2024', + spatialResolution: null, + providerUrl: null, + license: 'Internal use only', + }, + // XYZ / TMS β€” basemaps + { + id: 'xyz-satellite', + name: 'Custom Satellite Basemap', + description: + 'High-resolution satellite imagery tiles in XYZ/TMS format from a custom tile server.', + extendedDescription: + 'Custom XYZ tile service hosting recent high-resolution satellite imagery for the region of interest. Available at zoom levels 0–17; native imagery resolution is approximately 50cm at the highest zoom.', + img: 'images/s2eox.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.REGIONAL, + tags: ['basemap', 'satellite', 'xyz'], + isFavorite: false, + startYear: 2024, + endYear: null, + periodCovered: '2024', + spatialResolution: '~50cm (native)', + providerUrl: null, + license: 'Custom', + }, + { + id: 'xyz-terrain', + name: 'Terrain Basemap', + description: + 'Shaded relief terrain tiles showing elevation and topography as a background map.', + extendedDescription: + 'Hillshade and contour-based terrain tiles generated from SRTM 30m elevation data using multi-directional shading. Available globally from zoom 0 to 14.', + img: 'images/terrain.png', + origin: ORIGIN.XYZ, + mapType: MAP_TYPE.BASEMAP, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.GLOBAL, + tags: ['basemap', 'terrain', 'elevation', 'xyz'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: 'February 2000 (static source)', + spatialResolution: '~30m (source)', + providerUrl: null, + license: 'Custom', + }, + // GeoJSON URL β€” layers only + { + id: 'geojson-districts', + name: 'District Boundaries', + description: + 'District-level administrative boundaries as GeoJSON served from the national data portal.', + extendedDescription: + 'District polygons published by the national statistics office. Includes district codes, names, and regional groupings. Served with CORS headers; can be consumed directly by the Maps app without a proxy.', + img: 'images/orgunits.png', + origin: ORIGIN.GEOJSON_URL, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.LOCAL, + tags: ['boundaries', 'districts', 'geojson'], + isFavorite: false, + startYear: 2024, + endYear: 2024, + periodCovered: '2024', + spatialResolution: null, + providerUrl: null, + license: 'Open Government License', + }, + { + id: 'geojson-catchment', + name: 'Health Catchment Areas', + description: + 'Catchment area polygons for all registered health facilities, updated quarterly.', + extendedDescription: + 'Service area polygons derived from Voronoi tessellation adjusted for road network travel times. Each polygon carries attributes for facility ID, name, estimated catchment population, and date of last revision.', + img: 'images/thematic.png', + origin: ORIGIN.GEOJSON_URL, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.LOCAL, + tags: ['health', 'catchment', 'geojson'], + isFavorite: false, + startYear: 2024, + endYear: 2024, + periodCovered: 'Q2 2024', + spatialResolution: null, + providerUrl: null, + license: 'Internal', + }, + // Org / User data β€” layers only + { + id: 'org-drawn-polygons', + name: 'Drawn Polygon Set', + description: + 'Custom polygon areas drawn in the Maps app and saved to the organisation data store.', + extendedDescription: + 'Manually digitised polygons created using the Maps app drawing tool and persisted in the DHIS2 organisation data store. These areas represent custom operational zones that do not correspond to official administrative boundaries.', + img: 'images/thematic.png', + origin: ORIGIN.ORG_DATA, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.LOCAL, + tags: ['custom', 'drawn', 'org-data'], + isFavorite: false, + startYear: null, + endYear: null, + periodCovered: null, + spatialResolution: null, + providerUrl: null, + license: null, + }, + { + id: 'org-health-zones', + name: 'Uploaded Health Zones', + description: + 'Health zone boundaries uploaded as GeoJSON and stored in the organisation data store.', + extendedDescription: + 'GeoJSON boundaries uploaded by the GIS team to represent operational health zones used for programme planning. Zones do not align with administrative districts and reflect programme-specific catchment logic.', + img: 'images/thematic.png', + origin: ORIGIN.ORG_DATA, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.LOCAL, + tags: ['health', 'zones', 'uploaded', 'org-data'], + isFavorite: false, + startYear: 2023, + endYear: 2023, + periodCovered: '2023', + spatialResolution: null, + providerUrl: null, + license: null, + }, + // Shared with me + { + id: 'shared-catchment-areas', + name: 'District Catchment Areas', + description: + 'Custom catchment polygons shared by Alice Smith β€” GIS team.', + extendedDescription: + 'Service area polygons derived from road network travel times. Shared for use in the national malaria programme analysis. Contact Alice Smith (GIS team) for updates.', + img: 'images/thematic.png', + origin: ORIGIN.SHARED_WITH_ME, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.LOCAL, + tags: ['health', 'catchment', 'shared'], + isFavorite: false, + startYear: 2024, + endYear: 2024, + periodCovered: 'Jun 2024', + spatialResolution: null, + providerUrl: null, + license: null, + sharedBy: 'Alice Smith', + }, + { + id: 'shared-vaccination-zones', + name: 'Vaccination Coverage Zones', + description: + 'Operational zones for the 2024 vaccination campaign, shared by Mohammed Al-Hassan.', + extendedDescription: + 'Campaign-specific operational zones used to track vaccination coverage and identify unreached populations. Shared for cross-programme analysis. Contact the national immunisation team for the latest version.', + img: 'images/thematic.png', + origin: ORIGIN.SHARED_WITH_ME, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.LOCAL, + tags: ['health', 'vaccination', 'shared'], + isFavorite: false, + startYear: 2024, + endYear: 2024, + periodCovered: 'Jul 2024', + spatialResolution: null, + providerUrl: null, + license: null, + sharedBy: 'Mohammed Al-Hassan', + }, + { + id: 'shared-custom-boundaries', + name: 'Custom Admin Boundaries 2024', + description: + 'Updated administrative boundaries shared by the GIS Team β€” includes boundary revisions.', + extendedDescription: + 'Revised district boundaries reflecting the 2024 administrative reorganisation. Shared by the national GIS team pending official publication. Not to be used for official reporting until gazetted.', + img: 'images/orgunits.png', + origin: ORIGIN.SHARED_WITH_ME, + mapType: MAP_TYPE.LAYER, + periodType: PERIOD_TYPE.OTHER, + resolution: RESOLUTION.LOCAL, + tags: ['boundaries', 'administrative', 'shared'], + isFavorite: false, + startYear: 2024, + endYear: 2024, + periodCovered: 'Aug 2024', + spatialResolution: null, + providerUrl: null, + license: null, + sharedBy: 'GIS Team', + }, +] diff --git a/src/components/layerSources/mockFavoritesStore.js b/src/components/layerSources/mockFavoritesStore.js new file mode 100644 index 000000000..1542be8be --- /dev/null +++ b/src/components/layerSources/mockFavoritesStore.js @@ -0,0 +1,27 @@ +import { useState, useEffect } from 'react' +import { mockCatalogueSources } from './mockCatalogueSources.js' + +// Module-level shared state so favourites are consistent across the popover and modal +const favIds = new Set( + mockCatalogueSources.filter((s) => s.isFavorite).map((s) => s.id) +) +const listeners = new Set() + +const toggle = (id) => { + if (favIds.has(id)) { + favIds.delete(id) + } else { + favIds.add(id) + } + const snapshot = new Set(favIds) + listeners.forEach((fn) => fn(snapshot)) +} + +export const useFavorites = () => { + const [favorites, setFavorites] = useState(() => new Set(favIds)) + useEffect(() => { + listeners.add(setFavorites) + return () => listeners.delete(setFavorites) + }, []) + return [favorites, toggle] +} diff --git a/src/components/layerSources/styles/AddSourceWizard.module.css b/src/components/layerSources/styles/AddSourceWizard.module.css new file mode 100644 index 000000000..ee4318729 --- /dev/null +++ b/src/components/layerSources/styles/AddSourceWizard.module.css @@ -0,0 +1,553 @@ +.wizard { + display: flex; + flex-direction: column; + flex: 1; + width: 100%; + min-height: 0; +} + +/* Step indicator */ +.stepIndicator { + display: flex; + align-items: center; + gap: var(--spacers-dp8); + margin-bottom: var(--spacers-dp24); +} + +.stepDot { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + line-height: 1; + border: 2px solid var(--colors-grey300); + color: var(--colors-grey600); + background: #fff; + flex-shrink: 0; +} + +.stepDot.active { + border-color: var(--colors-blue600); + color: var(--colors-blue600); +} + +.stepDot.done { + border-color: var(--colors-blue600); + background: var(--colors-blue600); + color: #fff; +} + +.stepLabel { + font-size: 12px; + color: var(--colors-grey600); +} + +.stepLabel.active { + color: var(--colors-grey900); + font-weight: 500; +} + +.stepConnector { + flex: 1; + height: 2px; + background: var(--colors-grey300); + max-width: 40px; +} + +/* Scrollable step content area */ +.stepContent { + flex: 1; + overflow-y: auto; + min-height: 0; + padding-bottom: var(--spacers-dp8); + padding-right: var(--spacers-dp8); + scrollbar-gutter: stable; +} + +/* Use as toggle */ +.useAsToggle { + display: flex; + align-items: center; + gap: var(--spacers-dp16); + margin-bottom: var(--spacers-dp16); + padding: var(--spacers-dp8) var(--spacers-dp12); + background: var(--colors-grey050, #fafafa); + border: 1px solid var(--colors-grey200); + border-radius: 4px; +} + +.useAsLabel { + font-size: 13px; + font-weight: 500; + color: var(--colors-grey700); + margin-right: var(--spacers-dp4); +} + +.useAsOption { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + font-size: 13px; + color: var(--colors-grey800); + cursor: pointer; +} + +/* Step 1 β€” Grouped type selection */ +.typeGroups { + display: flex; + flex-direction: column; + gap: var(--spacers-dp24); +} + +.typeGroup { + display: flex; + flex-direction: column; + gap: var(--spacers-dp12); +} + +.typeGroupLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--colors-grey600); + margin: 0; + padding-bottom: var(--spacers-dp8); + border-bottom: 1px solid var(--colors-grey200); +} + +.typeGroupTiles { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: var(--spacers-dp8); +} + +.typeTile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacers-dp4); + padding: var(--spacers-dp12) var(--spacers-dp8); + border: 2px solid var(--colors-grey300); + border-radius: 6px; + cursor: pointer; + text-align: center; + background: #fff; + transition: border-color 0.1s, background 0.1s; + position: relative; +} + +.typeTile:hover { + border-color: var(--colors-blue400); + background: var(--colors-blue050, #e8f4fb); +} + +.typeTile.selected { + border-color: var(--colors-blue600); + background: var(--colors-blue050, #e8f4fb); +} + +.typeTile.disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.typeTile.disabled:hover { + border-color: var(--colors-grey300); + background: #fff; +} + +.typeTileIcon { + font-size: 22px; + line-height: 1; +} + +.typeTileLabel { + font-size: 12px; + font-weight: 500; + color: var(--colors-grey800); + line-height: 1.3; +} + +.typeTileSubLabel { + font-size: 11px; + color: var(--colors-grey600); +} + +.adminBadge { + position: absolute; + top: 6px; + right: 6px; + font-size: 10px; + background: var(--colors-grey700); + color: #fff; + border-radius: 3px; + padding: 1px 5px; +} + +/* Step 2 β€” Config forms */ +.configForm { + display: flex; + flex-direction: column; + gap: var(--spacers-dp16); +} + +.fieldGroup { + display: flex; + flex-direction: column; + gap: var(--spacers-dp4); +} + +.fieldLabel { + font-size: 13px; + font-weight: 500; + color: var(--colors-grey800); +} + +.fieldInput { + padding: 8px 10px; + border: 1px solid var(--colors-grey400); + border-radius: 3px; + font-size: 13px; + color: var(--colors-grey900); + width: 100%; + box-sizing: border-box; +} + +.fieldInput:focus { + outline: none; + border-color: var(--colors-blue600); + box-shadow: 0 0 0 2px rgba(0, 110, 168, 0.2); +} + +.fieldHint { + font-size: 11px; + color: var(--colors-grey600); +} + +.mockTree { + border: 1px solid var(--colors-grey300); + border-radius: 3px; + padding: var(--spacers-dp8); + font-size: 13px; + color: var(--colors-grey700); + background: var(--colors-grey050, #fafafa); +} + +.mockTreeItem { + padding: 4px var(--spacers-dp8); + cursor: pointer; + border-radius: 3px; +} + +.mockTreeItem:hover { + background: var(--colors-grey100); +} + +.mockTreeItem.expanded { + font-weight: 500; + color: var(--colors-grey900); +} + +.mockTreeChild { + padding: 3px var(--spacers-dp8) 3px 24px; + cursor: pointer; + border-radius: 3px; + font-size: 12px; +} + +.mockTreeChild:hover { + background: var(--colors-grey100); +} + +.disabledButton { + padding: 8px 16px; + border: 1px solid var(--colors-grey300); + border-radius: 3px; + background: var(--colors-grey100); + color: var(--colors-grey500); + font-size: 13px; + cursor: not-allowed; + display: inline-block; +} + +.uploadLabel { + display: inline-flex; + align-items: center; + gap: var(--spacers-dp4); + padding: 8px 16px; + border: 1px solid var(--colors-grey400); + border-radius: 3px; + background: #fff; + color: var(--colors-grey800); + font-size: 13px; + cursor: pointer; +} + +.uploadLabel:hover { + background: var(--colors-grey100); + border-color: var(--colors-grey600); +} + +/* QMS catalog list */ +.qmsList { + border: 1px solid var(--colors-grey300); + border-radius: 3px; + overflow-y: auto; + max-height: 220px; +} + +.qmsRow { + display: flex; + flex-direction: column; + padding: var(--spacers-dp8) var(--spacers-dp12); + cursor: pointer; + border-bottom: 1px solid var(--colors-grey200); + transition: background 0.1s; +} + +.qmsRow:last-child { + border-bottom: none; +} + +.qmsRow:hover { + background: var(--colors-grey050, #fafafa); +} + +.qmsRow.qmsSelected { + background: var(--colors-blue050, #e8f4fb); +} + +.qmsRowName { + font-size: 13px; + color: var(--colors-grey900); + font-weight: 500; +} + +.qmsRowMeta { + font-size: 11px; + color: var(--colors-grey600); + margin-top: 2px; +} + +/* Step 3 β€” Meta form */ +.metaRow { + display: flex; + gap: var(--spacers-dp16); + align-items: flex-start; +} + +.metaFields { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacers-dp16); +} + +.thumbnailPreview { + width: 120px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--spacers-dp8); +} + +.thumbnailBox { + width: 120px; + height: 120px; + border: 1px solid var(--colors-grey300); + border-radius: 4px; + background: var(--colors-grey100); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--colors-grey500); + text-align: center; +} + +.tagRow { + display: flex; + flex-wrap: wrap; + gap: var(--spacers-dp4); + align-items: center; + padding: 6px 8px; + border: 1px solid var(--colors-grey400); + border-radius: 3px; + min-height: 36px; +} + +.tagChip { + background: var(--colors-blue050, #e8f4fb); + color: var(--colors-blue700, #005a8e); + border-radius: 10px; + padding: 2px 10px; + font-size: 12px; + display: flex; + align-items: center; + gap: 4px; +} + +.tagChipRemove { + background: none; + border: none; + cursor: pointer; + color: var(--colors-blue700, #005a8e); + font-size: 14px; + padding: 0; + line-height: 1; +} + +.radioGroup { + display: flex; + flex-direction: column; + gap: var(--spacers-dp8); +} + +.radioLabel { + display: flex; + align-items: center; + gap: var(--spacers-dp8); + font-size: 13px; + color: var(--colors-grey800); + cursor: pointer; +} + +/* Quota bar */ +.quotaSection { + margin-top: var(--spacers-dp8); + padding: var(--spacers-dp12); + border: 1px solid var(--colors-grey200); + border-radius: 4px; + background: var(--colors-grey050, #fafafa); + display: flex; + flex-direction: column; + gap: var(--spacers-dp8); +} + +.quotaHeader { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.quotaLabel { + font-size: 12px; + font-weight: 600; + color: var(--colors-grey700); +} + +.quotaNumbers { + font-size: 11px; + color: var(--colors-grey600); +} + +.quotaBar { + height: 8px; + background: var(--colors-grey200); + border-radius: 4px; + overflow: hidden; + display: flex; +} + +.quotaFillOther { + height: 100%; + background: var(--colors-blue600); + transition: width 0.3s; +} + +.quotaFillCurrent { + height: 100%; + background: var(--colors-orange600, #d14d00); + transition: width 0.3s; +} + +.quotaFillWarning { + background: var(--colors-yellow600, #d4860b); +} + +.quotaFillCritical { + background: var(--colors-red600, #c0392b); +} + +.quotaLegend { + display: flex; + gap: var(--spacers-dp16); +} + +.quotaLegendItem { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + font-size: 11px; + color: var(--colors-grey600); +} + +.quotaLegendDotOther { + width: 8px; + height: 8px; + border-radius: 2px; + background: var(--colors-blue600); + flex-shrink: 0; +} + +.quotaLegendDotCurrent { + width: 8px; + height: 8px; + border-radius: 2px; + background: var(--colors-orange600, #d14d00); + flex-shrink: 0; +} + +/* Navigation */ +.wizardNav { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacers-dp8); + flex-shrink: 0; + padding-top: var(--spacers-dp16); + margin-top: var(--spacers-dp8); + border-top: 1px solid var(--colors-grey200); +} + +.navButton { + padding: 8px 20px; + border-radius: 3px; + font-size: 14px; + cursor: pointer; + border: 1px solid transparent; +} + +.navButtonSecondary { + composes: navButton; + background: #fff; + border-color: var(--colors-grey400); + color: var(--colors-grey800); +} + +.navButtonSecondary:hover { + background: var(--colors-grey100); +} + +.navButtonPrimary { + composes: navButton; + background: var(--colors-blue600); + color: #fff; + border-color: var(--colors-blue600); +} + +.navButtonPrimary:hover { + background: var(--colors-blue700, #005a8e); +} + +.navButtonPrimary:disabled { + background: var(--colors-grey300); + border-color: var(--colors-grey300); + cursor: not-allowed; +} diff --git a/src/components/layerSources/styles/LayerSourceCard.module.css b/src/components/layerSources/styles/LayerSourceCard.module.css new file mode 100644 index 000000000..5b8a17dbf --- /dev/null +++ b/src/components/layerSources/styles/LayerSourceCard.module.css @@ -0,0 +1,225 @@ +.card { + display: flex; + align-items: flex-start; + gap: var(--spacers-dp16); + padding: var(--spacers-dp12) var(--spacers-dp16); + border: 1px solid var(--colors-grey300); + border-radius: 4px; + margin-bottom: var(--spacers-dp8); + cursor: pointer; + background: #fff; + transition: border-color 0.1s, box-shadow 0.1s; +} + +.card:hover { + border-color: var(--colors-grey500); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); +} + +.cardExpanded { + border-color: var(--colors-blue400); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); +} + +.cardExpanded:hover { + border-color: var(--colors-blue500, #3085c1); +} + +.thumbnail { + flex-shrink: 0; + width: 80px; + height: 80px; + border: 1px solid var(--colors-grey300); + border-radius: 3px; + object-fit: cover; + background: var(--colors-grey100); +} + +.body { + flex: 1; + min-width: 0; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacers-dp8); + margin-bottom: var(--spacers-dp4); +} + +.titleGroup { + min-width: 0; +} + +.name { + font-size: 14px; + font-weight: 500; + color: var(--colors-grey900); + margin: 0 0 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.subtitle { + font-size: 12px; + color: var(--colors-grey600); + margin: 0; +} + +.actions { + display: flex; + align-items: center; + gap: var(--spacers-dp8); + flex-shrink: 0; +} + +.starButton { + background: none; + border: none; + cursor: pointer; + font-size: 18px; + color: var(--colors-grey400); + padding: 2px 4px; + border-radius: 3px; + line-height: 1; + transition: color 0.1s; +} + +.starButton:hover { + color: var(--colors-yellow500); + background: var(--colors-yellow050); +} + +.starButton.favorited { + color: var(--colors-yellow500); +} + +.iconButton { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--colors-grey500); + padding: 3px; + border-radius: 3px; + line-height: 1; + transition: color 0.1s, background 0.1s; +} + +.iconButton:hover { + color: var(--colors-grey800); + background: var(--colors-grey100); +} + +.deleteIconButton:hover { + color: var(--colors-red600, #c00); + background: var(--colors-red050, #fff3f3); +} + +.addToMapButton { + white-space: nowrap; + font-size: 12px; + padding: 4px 10px; + border: 1px solid var(--colors-grey400); + border-radius: 3px; + background: #fff; + color: var(--colors-grey800); + cursor: pointer; + transition: background 0.1s, border-color 0.1s; +} + +.addToMapButton:hover { + background: var(--colors-grey100); + border-color: var(--colors-grey600); +} + +.description { + font-size: 13px; + color: var(--colors-grey700); + margin: 0 0 var(--spacers-dp8); + line-height: 1.4; +} + +.footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--spacers-dp4); +} + +.tag { + font-size: 11px; + background: var(--colors-grey150, #f0f0f0); + color: var(--colors-grey700); + border-radius: 10px; + padding: 1px 8px; +} + +.meta { + font-size: 11px; + color: var(--colors-grey600); + margin-left: auto; + white-space: nowrap; +} + +.expandHint { + font-size: 10px; + color: var(--colors-grey400); + margin-left: var(--spacers-dp4); + line-height: 1; + user-select: none; +} + +/* Expanded detail panel */ +.details { + margin-top: var(--spacers-dp8); + padding-top: var(--spacers-dp8); + border-top: 1px solid var(--colors-grey200); + display: flex; + flex-direction: column; + gap: var(--spacers-dp8); +} + +.extendedDescription { + font-size: 12px; + color: var(--colors-grey700); + line-height: 1.5; + margin: 0; +} + +.detailGrid { + display: grid; + grid-template-columns: auto 1fr; + gap: 3px var(--spacers-dp16); + align-items: baseline; +} + +.detailRow { + display: contents; + font-size: 12px; +} + +.detailLabel { + font-size: 12px; + color: var(--colors-grey600); + white-space: nowrap; +} + +.detailValue { + font-size: 12px; + color: var(--colors-grey800); +} + +.detailLink { + font-size: 12px; + color: var(--colors-blue700, #005a8e); + text-decoration: none; +} + +.detailLink:hover { + text-decoration: underline; +} diff --git a/src/components/layerSources/styles/LayerSourceCatalogue.module.css b/src/components/layerSources/styles/LayerSourceCatalogue.module.css new file mode 100644 index 000000000..8a6aec856 --- /dev/null +++ b/src/components/layerSources/styles/LayerSourceCatalogue.module.css @@ -0,0 +1,360 @@ +.catalogue { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +/* Search + chips row */ +.searchRow { + display: flex; + flex-direction: column; + gap: var(--spacers-dp8); + margin-bottom: var(--spacers-dp16); +} + +.searchInput { + width: 100%; + box-sizing: border-box; + padding: 8px 12px 8px 36px; + border: 1px solid var(--colors-grey400); + border-radius: 4px; + font-size: 14px; + color: var(--colors-grey900); + background: #fff + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23767676' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E") + no-repeat 10px center; + background-size: 16px; +} + +.searchInput:focus { + outline: none; + border-color: var(--colors-blue600); + box-shadow: 0 0 0 2px rgba(0, 110, 168, 0.2); +} + +.chipRows { + display: flex; + flex-direction: column; + gap: var(--spacers-dp4); +} + +.chipsWrapper { + position: relative; + min-width: 0; +} + +.chips { + display: flex; + flex-wrap: nowrap; + gap: var(--spacers-dp4); + overflow-x: auto; + scrollbar-width: none; +} + +.chips::-webkit-scrollbar { + display: none; +} + +.chipScrollBtn { + position: absolute; + top: 0; + bottom: 0; + z-index: 2; + border: none; + cursor: pointer; + display: flex; + align-items: center; + font-size: 18px; + line-height: 1; + color: var(--colors-grey700); + padding: 0; +} + +.chipScrollLeft { + left: 0; + padding-right: 20px; + background: linear-gradient(to right, #fff 55%, transparent); +} + +.chipScrollRight { + right: 0; + padding-left: 20px; + background: linear-gradient(to left, #fff 55%, transparent); +} + +.chip { + padding: 4px 12px; + border-radius: 16px; + border: 1px solid var(--colors-grey400); + background: #fff; + font-size: 12px; + color: var(--colors-grey700); + cursor: pointer; + transition: background 0.1s, border-color 0.1s, color 0.1s; + white-space: nowrap; +} + +.chip:hover { + border-color: var(--colors-grey600); + background: var(--colors-grey050, #fafafa); +} + +.chip.active { + background: var(--colors-blue600); + border-color: var(--colors-blue600); + color: #fff; +} + +/* Main two-column layout */ +.body { + display: flex; + gap: var(--spacers-dp16); + flex: 1; + min-height: 0; +} + +/* Sidebar */ +.sidebar { + width: 190px; + flex-shrink: 0; + border-right: 1px solid var(--colors-grey200); + padding-right: var(--spacers-dp16); + overflow-y: auto; +} + +@media (max-width: 560px) { + .sidebar { + display: none; + } +} + +.filterSection { + margin-bottom: var(--spacers-dp16); +} + +.filterSectionTitle { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--colors-grey600); + margin: 0 0 var(--spacers-dp8); +} + +.filterOption { + display: flex; + align-items: center; + gap: var(--spacers-dp8); + padding: 3px 0; + font-size: 13px; + color: var(--colors-grey700); + cursor: pointer; +} + +.filterOption input[type='checkbox'] { + cursor: pointer; +} + +.tagFilterChip { + display: inline-block; + margin: 2px 4px 2px 0; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid var(--colors-grey300); + font-size: 11px; + color: var(--colors-grey700); + cursor: pointer; + background: #fff; + transition: background 0.1s, border-color 0.1s; +} + +.tagFilterChip:hover, +.tagFilterChip.active { + background: var(--colors-blue050, #e8f4fb); + border-color: var(--colors-blue400); + color: var(--colors-blue700, #005a8e); +} + +.showMoreButton { + background: none; + border: none; + padding: 2px 0; + font-size: 11px; + color: var(--colors-blue600); + cursor: pointer; + text-decoration: underline; +} + +/* Card list */ +.cardList { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.cardListHeader { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding-bottom: var(--spacers-dp8); + border-bottom: 1px solid var(--colors-grey200); + margin-bottom: var(--spacers-dp8); +} + +.cardListScroll { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + scrollbar-gutter: stable; + padding-right: var(--spacers-dp4); +} + +.resultsCount { + font-size: 12px; + color: var(--colors-grey600); + margin: 0; +} + +.sortControl { + display: flex; + align-items: center; + gap: var(--spacers-dp4); +} + +.sortLabel { + font-size: 12px; + color: var(--colors-grey600); +} + +.sortSelect { + font-size: 12px; + padding: 2px 6px; + border: 1px solid var(--colors-grey300); + border-radius: 3px; + color: var(--colors-grey800); + background: #fff; + cursor: pointer; +} + +.emptyState { + text-align: center; + padding: 48px var(--spacers-dp16); + color: var(--colors-grey600); + font-size: 14px; +} + +/* Dual-handle date range slider */ +.yearRangeValues { + display: flex; + justify-content: space-between; + font-size: 12px; + font-weight: 500; + color: var(--colors-grey800); + margin-bottom: var(--spacers-dp4); +} + +.dualRangeWrapper { + position: relative; + height: 20px; + margin: 0 2px; +} + +.rangeTrack { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 3px; + transform: translateY(-50%); + background: var(--colors-grey300); + border-radius: 2px; + pointer-events: none; +} + +.rangeFill { + position: absolute; + top: 0; + height: 100%; + background: var(--colors-blue400, #63a0cc); + border-radius: 2px; +} + +.rangeInput { + position: absolute; + width: 100%; + top: 0; + left: 0; + height: 100%; + margin: 0; + padding: 0; + appearance: none; + -webkit-appearance: none; + background: transparent; + pointer-events: none; + outline: none; +} + +.rangeInput::-webkit-slider-runnable-track { + background: transparent; + height: 3px; +} + +.rangeInput::-moz-range-track { + background: transparent; + height: 3px; + border: none; +} + +.rangeInput::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--colors-blue600); + cursor: pointer; + border: 2px solid #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + pointer-events: auto; + margin-top: -5.5px; +} + +.rangeInput::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--colors-blue600); + cursor: pointer; + border: 2px solid #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + pointer-events: auto; +} + +.rangeInput::-webkit-slider-thumb:hover, +.rangeInput::-moz-range-thumb:hover { + background: var(--colors-blue700, #005a8e); +} + +.yearRangeLabels { + display: flex; + justify-content: space-between; + font-size: 10px; + color: var(--colors-grey500); + margin-top: var(--spacers-dp4); +} + +.resetDateFilter { + margin-top: var(--spacers-dp4); + font-size: 11px; + color: var(--colors-blue600); + background: none; + border: none; + cursor: pointer; + padding: 0; + text-decoration: underline; +} diff --git a/src/components/layerSources/styles/ManageLayerSourcesModal.module.css b/src/components/layerSources/styles/ManageLayerSourcesModal.module.css index b3f2cd5e2..f9dea3ccf 100644 --- a/src/components/layerSources/styles/ManageLayerSourcesModal.module.css +++ b/src/components/layerSources/styles/ManageLayerSourcesModal.module.css @@ -1,10 +1,55 @@ -.description { - font-size: 0.9rem; - line-height: 1.5rem; - padding-bottom: var(--spacers-dp12); +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacers-dp16); } -.layersTable { - border-collapse: collapse; - width: 100%; +.modalTitle { + font-size: 18px; + font-weight: 500; + color: var(--colors-grey900); + margin: 0; +} + +.addSourceButton { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: var(--colors-blue600); + color: #fff; + border: none; + border-radius: 3px; + font-size: 14px; + cursor: pointer; + transition: background 0.1s; +} + +.addSourceButton:hover { + background: var(--colors-blue700, #005a8e); +} + +.content { + display: flex; + flex-direction: column; + width: min(920px, calc(100vw - 48px)); + height: min(760px, calc(100svh - 160px)); + min-height: min(400px, calc(100svh - 160px)); + overflow: hidden; + box-sizing: border-box; +} + +@media (max-width: 560px) { + .content { + width: calc(100vw - 32px); + } +} + +.viewWrapper { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; } diff --git a/src/components/layers/basemaps/BasemapCard.jsx b/src/components/layers/basemaps/BasemapCard.jsx index 7feaa6200..d12aa98f9 100644 --- a/src/components/layers/basemaps/BasemapCard.jsx +++ b/src/components/layers/basemaps/BasemapCard.jsx @@ -1,7 +1,7 @@ import i18n from '@dhis2/d2-i18n' import { ComponentCover, CenteredContent, CircularLoader } from '@dhis2/ui' import PropTypes from 'prop-types' -import React from 'react' +import React, { useState } from 'react' import { connect } from 'react-redux' import { changeBasemapOpacity, @@ -11,9 +11,20 @@ import { } from '../../../actions/basemap.js' import { VECTOR_STYLE } from '../../../constants/layers.js' import useBasemapConfig from '../../../hooks/useBasemapConfig.js' +import ManageLayerSourcesModal from '../../layerSources/ManageLayerSourcesModal.jsx' import LayerCard from '../LayerCard.jsx' import BasemapList from './BasemapList.jsx' +const manageLinkStyle = { + fontSize: 12, + color: 'var(--colors-blue600)', + background: 'none', + border: 'none', + cursor: 'pointer', + padding: '0 var(--spacers-dp8)', + whiteSpace: 'nowrap', +} + const BasemapCard = (props) => { const { subtitle = i18n.t('Basemap'), @@ -23,6 +34,7 @@ const BasemapCard = (props) => { selectBasemap, } = props const basemap = useBasemapConfig(props.basemap) + const [isManaging, setIsManaging] = useState(false) return ( <> @@ -45,6 +57,14 @@ const BasemapCard = (props) => { onOpacityChange={changeBasemapOpacity} toggleExpand={toggleBasemapExpand} toggleLayerVisibility={toggleBasemapVisibility} + manageAction={ + + } > { /> )} + {isManaging && ( + setIsManaging(false)} + initialMapType="basemap" + /> + )} ) } diff --git a/src/components/layers/basemaps/BasemapList.jsx b/src/components/layers/basemaps/BasemapList.jsx index f5c0d2ec4..d67be4a80 100644 --- a/src/components/layers/basemaps/BasemapList.jsx +++ b/src/components/layers/basemaps/BasemapList.jsx @@ -1,14 +1,22 @@ import PropTypes from 'prop-types' import React from 'react' import { useCachedData } from '../../cachedDataProvider/CachedDataProvider.jsx' +import { useFavorites } from '../../layerSources/mockFavoritesStore.js' import Basemap from './Basemap.jsx' import styles from './styles/BasemapList.module.css' const BasemapList = ({ selectedID, selectBasemap }) => { const { basemaps } = useCachedData() + const [favorites] = useFavorites() + + const visible = + favorites.size > 0 + ? basemaps.filter((b) => favorites.has(b.id)) + : basemaps + return (
- {basemaps.map((basemap, index) => ( + {visible.map((basemap, index) => ( { data-test="add-layer-button" > - + {i18n.t('Add layer')} @@ -39,7 +39,10 @@ const AddLayerButton = () => { /> )} {isManaging && ( - setIsManaging(false)} /> + setIsManaging(false)} + initialMapType="layer" + /> )} ) diff --git a/src/components/layers/overlays/AddLayerPopover.jsx b/src/components/layers/overlays/AddLayerPopover.jsx index f4ef65a7b..bf27e2f60 100644 --- a/src/components/layers/overlays/AddLayerPopover.jsx +++ b/src/components/layers/overlays/AddLayerPopover.jsx @@ -1,32 +1,19 @@ import { Popover } from '@dhis2/ui' import PropTypes from 'prop-types' -import React from 'react' +import React, { useState, useMemo } from 'react' import { useSelector, useDispatch } from 'react-redux' import { addLayer, editLayer } from '../../../actions/layers.js' -import getEarthEngineLayers from '../../../constants/earthEngineLayers/index.js' import { EXTERNAL_LAYER } from '../../../constants/layers.js' import useKeyDown from '../../../hooks/useKeyDown.js' -import useManagedLayerSourcesStore from '../../../hooks/useManagedLayerSourcesStore.js' import { isSplitViewMap } from '../../../util/helpers.js' import { groupLayerSources } from '../../../util/layerSources.js' import { useCachedData } from '../../cachedDataProvider/CachedDataProvider.jsx' -import ManageLayerSourcesButton from '../../layerSources/ManageLayerSourcesButton.jsx' +import { mockCatalogueSources } from '../../layerSources/mockCatalogueSources.js' +import { useFavorites } from '../../layerSources/mockFavoritesStore.js' import LayerList from './LayerList.jsx' +import styles from './styles/AddLayerPopover.module.css' -const includeEarthEngineLayers = (defaultLayerSources, managedLayerSources) => { - // Earth Engine layers that are added to this DHIS2 instance - const managedEarthEngineLayers = getEarthEngineLayers().filter( - (l) => !l.legacy && managedLayerSources.includes(l.layerId) - ) - - // Make copy before slicing below - const layerSources = [...defaultLayerSources] - - // Insert Earth Engine layers before external layers - layerSources.splice(5, 0, ...managedEarthEngineLayers) - - return layerSources -} +const FAVORITES_SEARCH_THRESHOLD = 5 const AddLayerPopover = ({ anchorEl, onClose, onManaging }) => { const isSplitView = useSelector((state) => @@ -34,12 +21,41 @@ const AddLayerPopover = ({ anchorEl, onClose, onManaging }) => { ) const dispatch = useDispatch() const { defaultLayerSources } = useCachedData() - const { managedLayerSources } = useManagedLayerSourcesStore() - const layerSources = includeEarthEngineLayers( - defaultLayerSources, - managedLayerSources + + // Built-in layer types (first 5 entries before any EE layers are spliced in) + const builtInTypes = groupLayerSources(defaultLayerSources.slice(0, 5)) + + const [favorites] = useFavorites() + const [favSearch, setFavSearch] = useState('') + const [favSort, setFavSort] = useState('most-used') + + const allFavorites = useMemo( + () => + Array.from(favorites) + .map((id) => mockCatalogueSources.find((s) => s.id === id)) + .filter(Boolean), + [favorites] ) - const groupedLayerSources = groupLayerSources(layerSources) + + const displayedFavorites = useMemo(() => { + const list = favSearch + ? allFavorites.filter( + (s) => + s.name.toLowerCase().includes(favSearch.toLowerCase()) || + s.origin.toLowerCase().includes(favSearch.toLowerCase()) + ) + : [...allFavorites] + if (favSort === 'az') { + list.sort((a, b) => a.name.localeCompare(b.name)) + } + if (favSort === 'za') { + list.sort((a, b) => b.name.localeCompare(a.name)) + } + if (favSort === 'origin') { + list.sort((a, b) => a.origin.localeCompare(b.origin)) + } + return list + }, [allFavorites, favSearch, favSort]) useKeyDown('Escape', onClose) @@ -49,14 +65,15 @@ const AddLayerPopover = ({ anchorEl, onClose, onManaging }) => { selectedLayer = layer.items[0]?.items?.[0] || layer.items[0] delete selectedLayer.id } - const config = { ...selectedLayer } const layerType = selectedLayer.layer - dispatch( layerType === EXTERNAL_LAYER ? addLayer(config) : editLayer(config) ) + onClose() + } + const onFavoriteSelect = () => { onClose() } @@ -65,16 +82,99 @@ const AddLayerPopover = ({ anchorEl, onClose, onManaging }) => { arrow={false} reference={anchorEl} placement="bottom-start" - maxWidth={700} + maxWidth={590} onClickOutside={onClose} dataTest="addlayerpopover" > - - {!isSplitView && } + {/* Built-in layer types */} +
+
+ + Built-in layer types + +
+ +
+ + {/* Favorites */} + {!isSplitView && ( +
+
+ β˜… Favorites + +
+ +
+ {allFavorites.length > FAVORITES_SEARCH_THRESHOLD && ( +
+ + setFavSearch(e.target.value) + } + /> +
+ )} + +
+ +
+ {displayedFavorites.length === 0 ? ( +
+ {allFavorites.length === 0 + ? 'No favorites yet β€” star sources in the catalogue.' + : 'No favorites match your search.'} +
+ ) : ( + displayedFavorites.map((source) => ( +
onFavoriteSelect(source)} + > + +
+
+ {source.name} +
+
+ {source.origin} +
+
+ + + + +
+ )) + )} +
+
+ )} ) } diff --git a/src/components/layers/overlays/styles/AddLayerPopover.module.css b/src/components/layers/overlays/styles/AddLayerPopover.module.css new file mode 100644 index 000000000..4f7ae49fe --- /dev/null +++ b/src/components/layers/overlays/styles/AddLayerPopover.module.css @@ -0,0 +1,166 @@ +.section { + padding: var(--spacers-dp12) var(--spacers-dp8) var(--spacers-dp4); +} + +.section + .section { + border-top: 1px solid var(--colors-grey200); +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacers-dp8); +} + +.sectionTitle { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--colors-grey600); +} + +.manageLink { + font-size: 12px; + color: var(--colors-blue600); + cursor: pointer; + background: none; + border: none; + padding: 0; + text-decoration: none; +} + +.manageLink:hover { + text-decoration: underline; +} + +.sortSelect { + font-size: 11px; + color: var(--colors-grey700); + border: 1px solid var(--colors-grey300); + border-radius: 3px; + padding: 2px 4px; + background: #fff; + cursor: pointer; +} + +/* Favorites list */ +.favoritesControls { + display: flex; + gap: var(--spacers-dp4); + margin-bottom: var(--spacers-dp8); +} + +.favoritesSearch { + flex: 1; +} + +.favoritesSearchInput { + width: 100%; + box-sizing: border-box; + padding: 6px 10px 6px 30px; + border: 1px solid var(--colors-grey300); + border-radius: 3px; + font-size: 13px; + color: var(--colors-grey900); + background: #fff + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E") + no-repeat 8px center; + background-size: 14px; +} + +.favoritesSearchInput:focus { + outline: none; + border-color: var(--colors-blue600); +} + +.favoritesList { + max-height: 200px; + overflow-y: auto; +} + +.favoriteRow { + display: flex; + align-items: center; + gap: var(--spacers-dp8); + padding: 6px var(--spacers-dp4); + border-radius: 3px; + cursor: pointer; + transition: background 0.1s; +} + +.favoriteRow:hover { + background: var(--colors-grey100); +} + +.favoriteThumb { + width: 36px; + height: 36px; + border-radius: 3px; + border: 1px solid var(--colors-grey300); + object-fit: cover; + flex-shrink: 0; + background: var(--colors-grey100); +} + +.favoriteInfo { + flex: 1; + min-width: 0; +} + +.favoriteName { + font-size: 13px; + font-weight: 500; + color: var(--colors-grey900); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.favoriteOrigin { + font-size: 11px; + color: var(--colors-grey600); +} + +.favoriteAdd { + font-size: 18px; + color: var(--colors-grey500); + flex-shrink: 0; + font-weight: 300; + line-height: 1; +} + +.favoriteRow:hover .favoriteAdd { + color: var(--colors-blue600); +} + +.emptyFavorites { + padding: var(--spacers-dp8) var(--spacers-dp4); + font-size: 12px; + color: var(--colors-grey500); + font-style: italic; +} + +/* Browse button */ +.browseRow { + padding: var(--spacers-dp8) var(--spacers-dp12); + border-top: 1px solid var(--colors-grey200); +} + +.browseButton { + width: 100%; + padding: 8px; + text-align: center; + font-size: 13px; + color: var(--colors-blue600); + background: var(--colors-blue050, #e8f4fb); + border: 1px dashed var(--colors-blue300, #80bfe0); + border-radius: 3px; + cursor: pointer; + transition: background 0.1s; +} + +.browseButton:hover { + background: var(--colors-blue100, #c8e6f7); +} diff --git a/src/components/layers/overlays/styles/Layer.module.css b/src/components/layers/overlays/styles/Layer.module.css index 972b8aa69..780f79401 100644 --- a/src/components/layers/overlays/styles/Layer.module.css +++ b/src/components/layers/overlays/styles/Layer.module.css @@ -1,7 +1,7 @@ .container { float: left; - width: 128px; - margin-right: var(--spacers-dp4); + width: 112px; + margin-right: 2px; margin-bottom: var(--spacers-dp4); padding: var(--spacers-dp4); cursor: pointer; @@ -17,8 +17,8 @@ .image { box-sizing: border-box; border: 1px solid var(--colors-grey400); - width: 120px; - height: 120px; + width: 96px; + height: 96px; border-radius: 2px; } .container:hover .image { @@ -28,10 +28,10 @@ .noImage { box-sizing: border-box; border: 1px solid var(--colors-grey400); - width: 120px; - height: 120px; + width: 96px; + height: 96px; border-radius: 2px; - line-height: 120px; + line-height: 96px; background: var(--colors-grey200); color: var(--colors-grey600); font-size: 12px; @@ -43,7 +43,7 @@ } .name { - font-size: 14px; + font-size: 12px; color: var(--colors-grey800); padding-bottom: var(--spacers-dp4); white-space: nowrap; diff --git a/src/components/layers/overlays/styles/LayerList.module.css b/src/components/layers/overlays/styles/LayerList.module.css index 7ebeec2dd..c830d7607 100644 --- a/src/components/layers/overlays/styles/LayerList.module.css +++ b/src/components/layers/overlays/styles/LayerList.module.css @@ -3,10 +3,8 @@ } .list { - max-width: 684px; max-height: calc(100vh - 150px); - padding: var(--spacers-dp8) var(--spacers-dp4) var(--spacers-dp8) - var(--spacers-dp12); + padding: var(--spacers-dp8) 0; overflow-y: auto; } diff --git a/src/components/layers/toolbar/LayerToolbar.jsx b/src/components/layers/toolbar/LayerToolbar.jsx index 476fcc999..2dad25b75 100644 --- a/src/components/layers/toolbar/LayerToolbar.jsx +++ b/src/components/layers/toolbar/LayerToolbar.jsx @@ -15,6 +15,7 @@ const LayerToolbar = ({ onOpacityChange, toggleLayerVisibility, hasError, + manageAction, ...expansionMenuProps }) => { const onEdit = expansionMenuProps.onEdit @@ -61,6 +62,7 @@ const LayerToolbar = ({
+ {manageAction && manageAction}