diff --git a/src/app.tsx b/src/app.tsx index e6832db..f735c5a 100755 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,4 +1,4 @@ -import React, { StrictMode, Suspense } from 'react'; +import React, { FC, StrictMode, Suspense } from 'react'; // eslint-disable-next-line import/no-unresolved import { createRoot } from 'react-dom/client'; import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; @@ -7,6 +7,8 @@ import CssBaseline from '@mui/material/CssBaseline'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import axios from 'axios'; +import { Feature, MultiPolygon, Polygon } from '@turf/turf'; import { getData } from './store/api'; import { DataActionDispatcherContext, DataStateContext } from './store/contexts'; import { dataReducers } from './store/reducers'; @@ -15,29 +17,65 @@ import { theme } from './theme'; import routes from './routes'; import Loading from './components/Loading'; +import faoAreasUrl from './files/fao_areas.geojson'; +import { normalizeFaoAreaGeometry } from './components/Map/utils'; + window.API_PATH = `${window.API_SERVER}/api/v1`; window.API_FONTS = `${window.API_SERVER}/fonts`; -const App = (): JSX.Element => { +const App: FC = () => { const [dataState, dataActionDispatcher] = React.useReducer(dataReducers, dataStateInitialValue); + const [initialized, setInitialized] = React.useState(false); + + React.useEffect(function initialize() { + axios.get(faoAreasUrl).then((res) => { + const features = res.data.features as Array< + Feature< + Polygon | MultiPolygon, + { + F_AREA: string; + NAME_EN: string; + OCEAN: string; + } + > + >; + const data = features + .map(({ geometry, properties: { F_AREA, NAME_EN, OCEAN } }) => ({ + code: Number(F_AREA), + name: NAME_EN, + ocean: OCEAN, + geometry: normalizeFaoAreaGeometry(geometry) + })) + .sort((a, b) => a.name.localeCompare(b.name)); - React.useEffect(() => { - getData( - 'species/all/?order_by=matched_canonical_full_name', - (species) => { - dataActionDispatcher({ type: 'updateAllSpecies', species }); - }, - () => undefined - ); - getData( - 'stations/all/?order_by=order', - (stations) => { - dataActionDispatcher({ type: 'updateStations', stations }); - }, - () => undefined - ); + dataActionDispatcher({ + type: 'loadFAOAreas', + faoAreas: data + }); + + Promise.all([ + getData( + 'stations/all/?order_by=order', + (stations) => { + dataActionDispatcher({ type: 'loadStations', stations }); + }, + console.error + ), + getData( + 'species/all/?order_by=matched_canonical_full_name', + (species) => { + dataActionDispatcher({ type: 'updateAllSpecies', species }); + }, + console.error + ) + ]).then(() => { + setInitialized(true); + }); + }, console.error); }, []); + if (!initialized) return null; + return ( diff --git a/src/components/Explore/LeftSidebar/InsetMap.tsx b/src/components/Explore/LeftSidebar/InsetMap.tsx index 9ed0d7e..662d415 100644 --- a/src/components/Explore/LeftSidebar/InsetMap.tsx +++ b/src/components/Explore/LeftSidebar/InsetMap.tsx @@ -54,7 +54,7 @@ const InsetMap: FC = () => { source: 'shadow', type: 'fill', paint: { - 'fill-color': theme.palette.explore.main, + 'fill-color': 'black', 'fill-opacity': 0.4 } } as maplibregl.FillLayerSpecification); diff --git a/src/components/Explore/LeftSidebar/StationDetail.tsx b/src/components/Explore/LeftSidebar/StationDetail.tsx new file mode 100644 index 0000000..6bff0dc --- /dev/null +++ b/src/components/Explore/LeftSidebar/StationDetail.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useContext } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Icon from '@mui/material/Icon'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; + +import { DataActionDispatcherContext, DataStateContext } from '../../../store/contexts'; +import { useStationDetails } from '../../../utils/hooks'; +import DownloadButton from '../../DownloadButton'; +import Loading from '../../Loading'; +import TabsGroup from '../../TabsGroup'; +import StationDetails from '../../Station/Details'; +import StationEnvironment from '../../Station/Environment'; +import StationSpecies from '../../Station/Species'; +import StationText from '../../Station/Text'; + +const StationDetail = () => { + const dataActionDispatcher = useContext(DataActionDispatcherContext); + const { filteredStations, selectedFaoArea, selectedStation } = useContext(DataStateContext); + + const selectedStationDetails = useStationDetails(selectedStation?.name); + + const StationPanel = React.useCallback( + () => (selectedStationDetails ? : null), + [selectedStationDetails] + ); + const EnvironmentPanel = React.useCallback( + () => (selectedStationDetails ? : null), + [selectedStationDetails] + ); + const SpeciesPanel = React.useCallback( + () => (selectedStationDetails ? : null), + [selectedStationDetails] + ); + const TextPanel = React.useCallback( + () => (selectedStationDetails ? : null), + [selectedStationDetails] + ); + + const onNavigate = useCallback( + (navigate_to: string) => { + if (!selectedStation) return; + if (!selectedFaoArea || selectedFaoArea?.code !== selectedStation.fao_area) { + // throw '[Invalid State]: A station can only be selected after a FAO area is selected!'; + return; + } + const group = filteredStations.find((g) => g.faoArea.code === selectedFaoArea.code); + if (!group) { + throw new Error('[Invalid State]: FAO area can only be selected from filtered results!'); + } + const stations = group.stations; + const index = stations.findIndex((station) => station.name === selectedStation?.name); + const newIndex = + navigate_to === 'forward' + ? (index + 1 + stations.length) % stations.length + : (index - 1 + stations.length) % stations.length; + dataActionDispatcher({ + type: 'updateSelectedStation', + station: stations[newIndex] + }); + }, + [selectedStation, selectedFaoArea, filteredStations] + ); + + return ( + + {selectedStationDetails ? ( + + + {selectedFaoArea ? ( + onNavigate('backward')}> + arrow_back + + ) : null} + + Station {selectedStationDetails?.name} + + {selectedFaoArea ? ( + onNavigate('forward')}> + arrow_forward + + ) : null} + + {selectedStationDetails ? ( + <> + + + + + + + + + + ) : ( + + )} + + ) : null} + + ); +}; + +export default StationDetail; diff --git a/src/components/Explore/LeftSidebar/StationList/RegionCard.tsx b/src/components/Explore/LeftSidebar/StationList/RegionCard.tsx new file mode 100644 index 0000000..04dc9a6 --- /dev/null +++ b/src/components/Explore/LeftSidebar/StationList/RegionCard.tsx @@ -0,0 +1,151 @@ +import { Box, Button, Card, Divider, Stack, Typography } from '@mui/material'; +import React, { FC, useContext } from 'react'; +import { LocationOnOutlined } from '@mui/icons-material'; +import * as turf from '@turf/turf'; +import { DataActionDispatcherContext } from '../../../../store/contexts'; +import { theme } from '../../../../theme'; +import RegionIcon from './RegionIcon'; +import { formatLatitude, formatLongitude } from '../../../../utils/format'; + +type Props = { + stationGroup: StationGroup; +}; + +const RegionCard: FC = ({ stationGroup }) => { + const dataActionDispatcher = useContext(DataActionDispatcherContext); + const geometry = stationGroup.faoArea.geometry; + const bbox = turf.bbox( + geometry.type === 'MultiPolygon' ? turf.multiPolygon(geometry.coordinates) : turf.polygon(geometry.coordinates) + ); + return ( + + + + + + + + + {stationGroup.faoArea.name.split(',').map((s) => ( + + {s} + + ))} + + + + + + + + Location + + + + + + SW = {formatLatitude(bbox[1])}, {formatLongitude(bbox[0])} + + + + + + NE = {formatLatitude(bbox[3])}, {formatLongitude(bbox[2])} + + + + + + + + + ); +}; + +export default RegionCard; diff --git a/src/components/Explore/LeftSidebar/StationList/RegionIcon.tsx b/src/components/Explore/LeftSidebar/StationList/RegionIcon.tsx new file mode 100644 index 0000000..f08bb1e --- /dev/null +++ b/src/components/Explore/LeftSidebar/StationList/RegionIcon.tsx @@ -0,0 +1,101 @@ +import React, { FC, useContext, useEffect, useRef } from 'react'; +import maplibre, { LngLatBounds } from 'maplibre-gl'; +import { Box } from '@mui/material'; +import * as turf from '@turf/turf'; +import { IS_WEBGL_SUPPORTED } from '../../../Map/utils'; +import { theme } from '../../../../theme'; +import { DataActionDispatcherContext, DataStateContext } from '../../../../store/contexts'; + +type Props = { + faoArea: FAOArea; + size?: number; + opacity?: number; +}; + +const RegionIcon: FC = ({ faoArea, size = 64, opacity = 1 }) => { + const { faoAreaIcons } = useContext(DataStateContext); + const dataActionDispatcher = useContext(DataActionDispatcherContext); + const mapRef = useRef(null); + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + if (!IS_WEBGL_SUPPORTED) return () => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function + if (faoAreaIcons[faoArea.code]) return () => {}; + + const feature = + faoArea.geometry.type === 'MultiPolygon' + ? turf.multiPolygon(faoArea.geometry.coordinates) + : turf.polygon(faoArea.geometry.coordinates); + const bbox = turf.bbox(feature); + + const containerEl = document.createElement('div'); + containerEl.style.width = '64px'; + containerEl.style.height = '64px'; + containerEl.style.position = 'fixed'; + containerEl.style.left = '-1000px'; + document.body.append(containerEl); + + const map = new maplibre.Map({ + container: containerEl, + style: { + version: 8, + sources: { + faoArea: { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [feature] + } + } + }, + layers: [ + { + id: 'icon', + source: 'faoArea', + type: 'fill', + paint: { + 'fill-color': theme.palette.explore.secondary + } + } + ] + }, + bounds: new LngLatBounds([bbox[0], bbox[1]], [bbox[2], bbox[3]]), + // bounds: [180, -90, -180, 90], + interactive: false + }); + + map.on('load', () => { + dataActionDispatcher({ + type: 'cacheFAOAreaIcons', + faoArea: faoArea.code, + base64Encoded: map.getCanvas().toDataURL() + }); + map.remove(); + mapRef.current = null; + containerEl.remove(); + }); + + mapRef.current = map; + + return () => { + mapRef.current?.remove(); + mapRef.current = null; + containerEl.remove(); + }; + }, []); + + return ( + + ); +}; + +export default RegionIcon; diff --git a/src/components/Explore/LeftSidebar/StationList/StationCard.tsx b/src/components/Explore/LeftSidebar/StationList/StationCard.tsx new file mode 100644 index 0000000..b6019ea --- /dev/null +++ b/src/components/Explore/LeftSidebar/StationList/StationCard.tsx @@ -0,0 +1,205 @@ +import { Box, Button, Card, Chip, Divider, Stack, Typography } from '@mui/material'; +import React, { FC, useContext, useEffect, useRef, useState } from 'react'; +import { LocationOnOutlined, ScienceOutlined, SettingsOutlined } from '@mui/icons-material'; +import { DataActionDispatcherContext, DataStateContext } from '../../../../store/contexts'; +import { theme } from '../../../../theme'; +import { requestScrollIntoView } from '../../../../utils/scrollIntoView'; + +type Props = { + station: StationSummary; +}; + +const StationCard: FC = ({ station }) => { + const dataActionDispatcher = useContext(DataActionDispatcherContext); + const { focusedStation, selectedStation } = useContext(DataStateContext); + const [isHovered, setIsHovered] = useState(false); + const isFocused = focusedStation?.name === station.name; + const isSelected = selectedStation?.name === station.name; + const elementRef = useRef(null); + + useEffect(() => { + let timer = -1; + // eslint-disable-next-line @typescript-eslint/no-empty-function + let abort = () => {}; + if (isFocused || isSelected) { + if (elementRef.current && elementRef.current.parentElement) { + const el = elementRef.current; + // wait until CSS transition has completed + timer = window.setTimeout(() => { + abort = requestScrollIntoView(el, { + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + }, 100); + } + } + return () => { + abort(); // If the timer has expired, the scroll action has been requested + window.clearTimeout(timer); // Otherwise, we just need to cancel the timer + }; + }, [isSelected, isFocused]); + + return ( + + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + > + + Station + + + + {station.name} + + + + + + + + + + + Location + + + {station.location} + + + + + {station.gear ? ( + + + + + Gear + + + {station.gear} + + + + ) : null} + + + + + + Sediment Sample + + + {station.sediment_sample} + + + + + + + + + + ); +}; + +export default StationCard; diff --git a/src/components/Explore/LeftSidebar/StationList/index.tsx b/src/components/Explore/LeftSidebar/StationList/index.tsx new file mode 100644 index 0000000..30245c2 --- /dev/null +++ b/src/components/Explore/LeftSidebar/StationList/index.tsx @@ -0,0 +1,139 @@ +import React, { FC, ReactNode, useContext, useEffect, useRef } from 'react'; +import Box from '@mui/material/Box'; + +import { Stack, Typography } from '@mui/material'; +import { DataActionDispatcherContext, DataStateContext } from '../../../../store/contexts'; +import { theme } from '../../../../theme'; +import RegionCard from './RegionCard'; +import RegionIcon from './RegionIcon'; +import StationCard from './StationCard'; +import { requestScrollIntoView } from '../../../../utils/scrollIntoView'; + +const Scroll: FC<{ children: ReactNode }> = ({ children }) => { + return ( + + {children} + + ); +}; + +const StationsList = () => { + const dataActionDispatcher = useContext(DataActionDispatcherContext); + const { filteredStations, selectedFaoArea } = useContext(DataStateContext); + + const selectedGroup = filteredStations.find((g) => g.faoArea.code === selectedFaoArea?.code) ?? null; + + const activeFaoAreaMenuItemRef = useRef(null); + + useEffect(() => { + if (activeFaoAreaMenuItemRef.current) { + requestScrollIntoView( + activeFaoAreaMenuItemRef.current, + { + behavior: 'smooth', + block: 'center', + inline: 'center' + }, + 1 + ); + } + }, [selectedGroup]); + + return ( + + {selectedGroup ? ( + + + + {filteredStations.map((group) => { + const isActive = group === selectedGroup; + return ( + { + dataActionDispatcher({ + type: 'updateSelectedFaoArea', + faoArea: isActive ? null : group.faoArea + }); + }} + > + + + ); + })} + + + + {selectedGroup.stations.length} station matches + + + + {selectedGroup.stations.map((station) => ( + + ))} + + + ) : ( + + + {filteredStations.length} ocean regions found + + + {filteredStations.map((group) => ( + + ))} + + + )} + + ); +}; + +export default StationsList; diff --git a/src/components/Explore/LeftSidebar/index.tsx b/src/components/Explore/LeftSidebar/index.tsx index 5e9df75..c7be5f3 100644 --- a/src/components/Explore/LeftSidebar/index.tsx +++ b/src/components/Explore/LeftSidebar/index.tsx @@ -1,58 +1,12 @@ -import React, { useContext } from 'react'; +import React from 'react'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Icon from '@mui/material/Icon'; -import IconButton from '@mui/material/IconButton'; import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import { DataActionDispatcherContext, DataStateContext } from '../../../store/contexts'; -import { useStationDetails } from '../../../utils/hooks'; -import DownloadButton from '../../DownloadButton'; -import Loading from '../../Loading'; -import TabsGroup from '../../TabsGroup'; -import StationDetails from '../../Station/Details'; -import StationEnvironment from '../../Station/Environment'; -import StationSpecies from '../../Station/Species'; -import StationText from '../../Station/Text'; import InsetMap from './InsetMap'; +import StationDetail from './StationDetail'; +import StationsList from './StationList'; -const Sidebar = () => { - const dataActionDispatcher = useContext(DataActionDispatcherContext); - const { filteredStations, stationsList, selectedStation } = useContext(DataStateContext); - - const selectedStationDetails = useStationDetails(selectedStation?.name); - - const StationPanel = React.useCallback( - () => (selectedStationDetails ? : null), - [selectedStationDetails] - ); - const EnvironmentPanel = React.useCallback( - () => (selectedStationDetails ? : null), - [selectedStationDetails] - ); - const SpeciesPanel = React.useCallback( - () => (selectedStationDetails ? : null), - [selectedStationDetails] - ); - const TextPanel = React.useCallback( - () => (selectedStationDetails ? : null), - [selectedStationDetails] - ); - - const onNavigate = (selectedStationName: string, navigate_to: string) => { - const stations = filteredStations?.length ? filteredStations : stationsList; - const index = stations.findIndex((station) => station.name === selectedStation?.name); - const newIndex = - navigate_to === 'forward' - ? (index + 1 + stations.length) % stations.length - : (index - 1 + stations.length) % stations.length; - dataActionDispatcher({ - type: 'updateSelectedStation', - station: stations[newIndex] - }); - }; - +const LeftSidebar = () => { return ( { height: '100%' }} > - {selectedStationDetails ? ( - - - onNavigate(selectedStationDetails.name, 'backward')} - > - arrow_back - - - Station {selectedStationDetails?.name} - - onNavigate(selectedStationDetails.name, 'forward')} - > - arrow_forward - - - {selectedStationDetails ? ( - <> - - - - - - - - - - ) : ( - - )} - - ) : null} + + @@ -154,4 +25,4 @@ const Sidebar = () => { ); }; -export default Sidebar; +export default LeftSidebar; diff --git a/src/components/Explore/Map.tsx b/src/components/Explore/Map.tsx index 0697348..d4506bb 100644 --- a/src/components/Explore/Map.tsx +++ b/src/components/Explore/Map.tsx @@ -1,7 +1,8 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import type { Point } from 'geojson'; +import { MapLayerEventType } from 'maplibre-gl'; import { DataStateContext, DataActionDispatcherContext, @@ -10,17 +11,24 @@ import { MapActionDispatcherContext } from '../../store/contexts'; import { layerStyles, mapStyle } from '../Map/styles'; -import { directionArrow, pulsingDot, runWhenReady } from '../Map/utils'; +import { getFeatureBounds, loadStationIcons, runWhenReady } from '../Map/utils'; import Map from '../Map'; import faoAreasUrl from '../../files/fao_areas.geojson'; import { BASEMAPS, INITIAL_BASEMAP } from './basemapConfig'; +const MAX_ZOOM = 6; +const BASE_PADDING = 100; +const APPBAR_H = 64; +const LEFT_PANEL_W = 324; +const INSET_MAP_EXTRA_W = 226; +const DETAIL_W = 478; +const RIGHT_TOOLBAR_W = 48; +const MAP_CONTROL_EXTRA = 32; + const ExploreMap = (): JSX.Element => { const dataActionDispatcher = useContext(DataActionDispatcherContext); - const { journeyPath, stationsBounds, stationsList, selectedStation, filteredStations } = - useContext(DataStateContext); - const selectedStationRef = useRef(null); + const { journeyPath, selectedStation, filteredStations, selectedFaoArea } = useContext(DataStateContext); const [isMapLoaded, setIsMapLoaded] = useState(false); @@ -40,11 +48,7 @@ const ExploreMap = (): JSX.Element => { }, [activeBasemap]); const onMapLoad = (map: maplibregl.Map) => { - // Add a pulsing dot image to the map to be used for selected station - pulsingDot(map, 100); - - // Add direction arrow to the map to be used for journey path - directionArrow(map); + loadStationIcons(map); // Add basemaps BASEMAPS.forEach(({ id, tiles, sourceExtraParams, layerExtraParams }) => { @@ -90,12 +94,6 @@ const ExploreMap = (): JSX.Element => { source: 'journey' } as maplibregl.LineLayerSpecification); - map.addLayer({ - ...layerStyles.journeyPath.direction, - id: 'journey-direction', - source: 'journey' - } as maplibregl.SymbolLayerSpecification); - // Both `stations` and `clustered-stations` sources hold the same data. // For performance reasons, it is better to use separate sources and hide or show // their layers depending on the status of filtered stations. @@ -105,8 +103,9 @@ const ExploreMap = (): JSX.Element => { type: 'FeatureCollection', features: [] }, - cluster: false, // FIXME - Temporarily disable clustering because it is broken with some server configurations. - clusterMinPoints: 5 + cluster: true, + clusterMinPoints: 3, + clusterRadius: 120 }); map.addSource('stations', { @@ -117,14 +116,30 @@ const ExploreMap = (): JSX.Element => { } }); + // The layer for selected station + map.addLayer({ + ...layerStyles.stations.selected, + id: 'stations-selected', + source: 'stations', + filter: ['==', 'name', ''] + } as maplibregl.CircleLayerSpecification); + // The layer for single stations in clustered mode map.addLayer({ ...layerStyles.stations.default, id: 'clustered-stations-single', source: 'clustered-stations', - filter: ['!', ['has', 'point_count']] + filter: ['!has', 'point_count'] } as maplibregl.CircleLayerSpecification); + // The layer for station names in clustered mode + map.addLayer({ + ...layerStyles.stations.name, + id: 'clustered-stations-single-name', + source: 'clustered-stations', + filter: ['!has', 'point_count'] + } as maplibregl.SymbolLayerSpecification); + // The layer for clustered stations in clustered mode map.addLayer({ ...layerStyles.stations.clustered, @@ -139,24 +154,6 @@ const ExploreMap = (): JSX.Element => { source: 'clustered-stations' } as maplibregl.SymbolLayerSpecification); - // The default stations layer in unclustered mode - map.addLayer({ - ...layerStyles.stations.default, - id: 'stations', - source: 'stations', - layout: { - visibility: 'none' - } - } as maplibregl.CircleLayerSpecification); - - // The layer for selected station - map.addLayer({ - ...layerStyles.stations.selected, - id: 'stations-selected', - source: 'stations', - filter: ['==', 'name', ''] - } as maplibregl.CircleLayerSpecification); - // Zoom to and expand the clicked cluster map.on('click', 'clustered-stations-multi', (e) => { const feature = e.features?.[0]; @@ -177,7 +174,7 @@ const ExploreMap = (): JSX.Element => { }); // Use pointer cursor on hover for the following layers - ['stations', 'clustered-stations-single', 'clustered-stations-multi'].forEach((layerName) => { + ['clustered-stations-single', 'clustered-stations-multi'].forEach((layerName) => { // Change the cursor to a pointer when the mouse is over the layer layer. map.on('mouseenter', layerName, () => { map.getCanvas().style.cursor = 'pointer'; @@ -193,15 +190,36 @@ const ExploreMap = (): JSX.Element => { setIsMapLoaded(true); }; + React.useEffect(() => { + const map = mapRef.current; + if (map && isMapLoaded) { + const journeySource = map.getSource('journey') as maplibregl.GeoJSONSource; + if (journeySource) { + journeySource.setData({ + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: journeyPath + } + }); + } + } + }, [journeyPath, isMapLoaded]); + React.useEffect(() => { // When map is ready and stationsList change, update the data for `stations` and `clustered-stations` const map = mapRef.current; if (map && isMapLoaded) { + const visibleStations = selectedFaoArea + ? filteredStations.find((g) => g.faoArea.code === selectedFaoArea.code)?.stations ?? [] + : filteredStations.flatMap((g) => g.stations); + const stationsSource = map.getSource('stations') as maplibregl.GeoJSONSource; if (stationsSource) { stationsSource.setData({ type: 'FeatureCollection', - features: stationsList.map((stationProps) => ({ + features: visibleStations.map((stationProps) => ({ type: 'Feature', geometry: { type: 'Point', @@ -211,11 +229,12 @@ const ExploreMap = (): JSX.Element => { })) }); } + const clusteredStationsSource = map.getSource('clustered-stations') as maplibregl.GeoJSONSource; if (clusteredStationsSource) { clusteredStationsSource.setData({ type: 'FeatureCollection', - features: stationsList.map((stationProps) => ({ + features: visibleStations.map((stationProps) => ({ type: 'Feature', geometry: { type: 'Point', @@ -230,79 +249,95 @@ const ExploreMap = (): JSX.Element => { })) }); } - map.fitBounds(stationsBounds); - const journeySource = map.getSource('journey') as maplibregl.GeoJSONSource; - if (journeySource) { - journeySource.setData({ - type: 'Feature', - properties: {}, - geometry: { - type: 'LineString', - coordinates: journeyPath - } - }); - } // Update selected station on click on the following layers - ['stations', 'clustered-stations-single'].forEach((layerName) => { - map.on('click', layerName, (e) => { - if (e.features && e.features[0]) { - const feature = e.features[0]; - const stationProperties = feature.properties as StationSummary; - let newSelectedStation = - stationProperties.name === selectedStationRef.current?.name ? null : stationProperties; - if (newSelectedStation) { - const index: number = stationsList.findIndex( - ({ name }) => newSelectedStation?.name === name - ); - newSelectedStation = stationsList[index]; - } - dataActionDispatcher({ - type: 'updateSelectedStation', - station: newSelectedStation - }); - selectedStationRef.current = newSelectedStation; + const eventListener = (e: MapLayerEventType['click']) => { + if (e.features && e.features[0]) { + const feature = e.features[0]; + const stationProperties = feature.properties as StationSummary; + let newSelectedStation = + stationProperties.name === selectedStation?.name ? null : stationProperties; + if (newSelectedStation) { + newSelectedStation = + visibleStations.find(({ name }) => newSelectedStation?.name === name) ?? null; } - }); + dataActionDispatcher({ + type: 'updateSelectedStation', + station: newSelectedStation + }); + } + }; + ['clustered-stations-single'].forEach((layerName) => { + map.on('click', layerName, eventListener); }); + return () => { + ['clustered-stations-single'].forEach((layerName) => { + map.off('click', layerName, eventListener); + }); + }; } - }, [stationsList, isMapLoaded]); + // eslint-disable-next-line @typescript-eslint/no-empty-function + return () => {}; + }, [filteredStations, selectedFaoArea, selectedStation, isMapLoaded]); React.useEffect(() => { // Update the filter on `stations-selected` when selected station changes const map = mapRef.current; if (map && isMapLoaded) { - map.setFilter('stations-selected', ['==', 'name', selectedStation ? selectedStation.name : '']); + map.setFilter('stations-selected', ['==', 'name', selectedStation?.name ?? '']); if (selectedStation) { - map.flyTo({ center: [selectedStation.coordinates[0], selectedStation.coordinates[1]], zoom: 6 }); - } else { - map.fitBounds([-180, -90, 180, 90]); - } - } - }, [selectedStation, isMapLoaded]); - - React.useEffect(() => { - // Update visible stations when the given dependencies change - const map = mapRef.current; - if (map && isMapLoaded) { - if (filteredStations) { - map.setFilter('stations', ['in', 'name', ...filteredStations.map((station) => station.name)]); - ['clustered-stations-single', 'clustered-stations-multi', 'clustered-stations-count'].forEach( - (layerName) => { - map.setLayoutProperty(layerName, 'visibility', 'none'); + map.easeTo({ + center: [selectedStation.coordinates[0], selectedStation.coordinates[1]], + zoom: 8, + duration: 1000, + padding: { + left: BASE_PADDING + LEFT_PANEL_W + DETAIL_W, // left panel width + detail panel width + right: BASE_PADDING + RIGHT_TOOLBAR_W + MAP_CONTROL_EXTRA, // right toolbar width + top: BASE_PADDING + APPBAR_H, // appbar height + bottom: BASE_PADDING + MAP_CONTROL_EXTRA } + }); + } else if (selectedFaoArea) { + const stationGroup = filteredStations.find((g) => g.faoArea.code === selectedFaoArea.code); + if (!stationGroup) + throw new Error('[Invalid State]: FAO Area must be selected from filtered stations!'); + + const coordinates = stationGroup.stations.map((s) => s.coordinates); + const maxLng = Math.max(...coordinates.map((c) => c[0])); + const minLng = Math.min(...coordinates.map((c) => c[0])); + const bounds = getFeatureBounds( + coordinates.map(([lng, lat]) => [maxLng - minLng > 180 && lng > 180 ? lng - 360 : lng, lat]) ); - map.setLayoutProperty('stations', 'visibility', 'visible'); - } else { - ['clustered-stations-single', 'clustered-stations-multi', 'clustered-stations-count'].forEach( - (layerName) => { - map.setLayoutProperty(layerName, 'visibility', 'visible'); + map.setPadding({ left: 0, right: 0, top: 0, bottom: 0 }); + map.fitBounds(bounds, { + maxZoom: MAX_ZOOM, + padding: { + left: BASE_PADDING + LEFT_PANEL_W + INSET_MAP_EXTRA_W, // left panel width + right: BASE_PADDING + RIGHT_TOOLBAR_W + MAP_CONTROL_EXTRA, // right toolbar width + top: BASE_PADDING + APPBAR_H, // appbar height + bottom: BASE_PADDING + MAP_CONTROL_EXTRA } + }); + } else { + const coordinates = filteredStations.flatMap((g) => g.stations.map((s) => s.coordinates)); + const maxLng = Math.max(...coordinates.map((c) => c[0])); + const minLng = Math.min(...coordinates.map((c) => c[0])); + const bounds = getFeatureBounds( + coordinates.map(([lng, lat]) => [maxLng - minLng > 180 && lng > 180 ? lng - 360 : lng, lat]) ); - map.setLayoutProperty('stations', 'visibility', 'none'); + map.setPadding({ left: 0, right: 0, top: 0, bottom: 0 }); + map.fitBounds(bounds, { + maxZoom: MAX_ZOOM, + padding: { + left: BASE_PADDING + LEFT_PANEL_W + INSET_MAP_EXTRA_W, // left panel width + right: BASE_PADDING + RIGHT_TOOLBAR_W + MAP_CONTROL_EXTRA, // right toolbar width + top: BASE_PADDING + APPBAR_H, // appbar height + bottom: BASE_PADDING + MAP_CONTROL_EXTRA + } + }); } } - }, [filteredStations, isMapLoaded]); + }, [selectedFaoArea, selectedStation, filteredStations, isMapLoaded]); return ( void; }; -const AdvancedSearch: FC = ({ toggle }) => { +const AdvancedSearch: FC = ({ toggle, onClose }) => { const dataActionDispatcher = useContext(DataActionDispatcherContext); - const { stationsList, allSpeciesList } = useContext(DataStateContext); + const { faoAreas, allStationsList, allSpeciesList } = useContext(DataStateContext); const [joinOperator, setJoinOperator] = useState('AND'); @@ -65,7 +66,6 @@ const AdvancedSearch: FC = ({ toggle }) => { const [stationFilter, setStationFilter] = useState([]); - const faoAreas = useFAOAreas(); const [faoAreaFilter, setFaoAreaFilter] = useState([]); const [startDate, setStartDate] = useState(null); @@ -79,7 +79,7 @@ const AdvancedSearch: FC = ({ toggle }) => { setEndDate(null); dataActionDispatcher({ type: 'updateFilteredStations', - stations: null + stations: allStationsList }); }, []); @@ -100,8 +100,9 @@ const AdvancedSearch: FC = ({ toggle }) => { type: 'updateFilteredStations', stations }); + onClose(); }); - }, [joinOperator, speciesFilter, stationFilter, faoAreaFilter, startDate, endDate]); + }, [joinOperator, speciesFilter, stationFilter, faoAreaFilter, startDate, endDate, onClose]); return ( <> @@ -192,7 +193,7 @@ const AdvancedSearch: FC = ({ toggle }) => { )} getOptionLabel={(option) => `Station ${option.name}`} - options={stationsList} + options={allStationsList} renderTags={(tagValue) => tagValue.map((option) => ( = [ type Props = { toggle: ReactNode; + onClose: () => void; }; -const GeneralSearch: FC = ({ toggle }) => { - const { stationsList, allSpeciesList } = useContext(DataStateContext); +const GeneralSearch: FC = ({ toggle, onClose }) => { + const { faoAreas, allStationsList, allSpeciesList } = useContext(DataStateContext); const dataActionDispatcher = useContext(DataActionDispatcherContext); const speciesDefaultRanks = useMemo(() => new Map(allSpeciesList.map((s, rank) => [s.id, rank])), [allSpeciesList]); @@ -62,7 +63,6 @@ const GeneralSearch: FC = ({ toggle }) => { const [stationFilter, setStationFilter] = useState([]); - const faoAreas = useFAOAreas(); const [faoAreaFilter, setFaoAreaFilter] = useState([]); const [searchType, setSearchType] = useState('species'); @@ -83,7 +83,7 @@ const GeneralSearch: FC = ({ toggle }) => { } dataActionDispatcher({ type: 'updateFilteredStations', - stations: null + stations: allStationsList }); }, [searchType]); @@ -93,7 +93,7 @@ const GeneralSearch: FC = ({ toggle }) => { setFaoAreaFilter([]); dataActionDispatcher({ type: 'updateFilteredStations', - stations: null + stations: allStationsList }); }, [searchType]); @@ -113,8 +113,9 @@ const GeneralSearch: FC = ({ toggle }) => { type: 'updateFilteredStations', stations }); + onClose(); }); - }, [searchType, speciesFilter, stationFilter, faoAreaFilter]); + }, [searchType, speciesFilter, stationFilter, faoAreaFilter, onClose]); return ( <> @@ -204,7 +205,7 @@ const GeneralSearch: FC = ({ toggle }) => { )} getOptionLabel={(option) => `Station ${option.name}`} - options={stationsList} + options={allStationsList} renderTags={(tagValue) => tagValue.map((option) => ( = ({ onClose }) => { const [searchType, setSearchType] = useState('general'); + const { allStationsList } = useContext(DataStateContext); const dataActionDispatcher = useContext(DataActionDispatcherContext); useEffect(() => { dataActionDispatcher({ type: 'updateFilteredStations', - stations: null + stations: allStationsList }); }, [searchType]); @@ -71,7 +72,11 @@ const SearchPanel: FC = ({ onClose }) => { - {searchType === 'general' ? : } + {searchType === 'general' ? ( + + ) : ( + + )} ); }; diff --git a/src/components/Explore/RightSidebar/index.tsx b/src/components/Explore/RightSidebar/index.tsx index 68a3211..df3c0f6 100644 --- a/src/components/Explore/RightSidebar/index.tsx +++ b/src/components/Explore/RightSidebar/index.tsx @@ -1,5 +1,5 @@ import { Box, Button, Stack, Typography } from '@mui/material'; -import React, { FC, useState } from 'react'; +import React, { FC, useCallback, useState } from 'react'; import FilterListIcon from '@mui/icons-material/FilterList'; import { KeyboardDoubleArrowLeftOutlined, KeyboardDoubleArrowRightOutlined, LayersOutlined } from '@mui/icons-material'; import { theme } from '../../../theme'; @@ -12,6 +12,10 @@ const RightSidebar: FC = () => { const [expanded, setExpanded] = useState(false); const [state, setState] = useState(null); + const onClose = useCallback(() => { + setState(null); + }, []); + return ( { }} > - setState(null)} /> + - setState(null)} /> + } } = { stations: { + // `default` and `selected` depend on `loadStationIcons` in `components/Map/utils.ts` default: { - type: 'circle', - paint: { - 'circle-radius': 7, - 'circle-color': '#d5ab1a', - 'circle-opacity': 0.9 + type: 'symbol', + layout: { + 'icon-image': 'station-icon', + 'icon-anchor': 'bottom', + 'icon-offset': [0, 17], + 'icon-allow-overlap': true } }, selected: { - // This style depends on `pulsingDot` in `components/Map/utils.ts` type: 'symbol', layout: { - 'icon-image': 'pulsing-dot' + 'icon-image': 'pulsing-icon', + 'icon-anchor': 'bottom', + 'icon-offset': [0, 17], + 'icon-allow-overlap': true + } + }, + name: { + type: 'symbol', + layout: { + 'text-field': ['get', 'name'], + 'text-font': ['Roboto Bold'], + 'text-size': 12, + 'text-anchor': 'bottom', + 'text-offset': [0, -2] + // 'text-allow-overlap': true + }, + paint: { + 'text-color': theme.palette.explore.main } }, clustered: { type: 'circle', filter: ['has', 'point_count'], paint: { - // Use step expressions (https://docs.mapbox.com/mapbox-gl-js/style-spec/#expressions-step) - // with three steps to implement three types of circles: - // * #14807c, 15px circles when point count is less than 20 - // * #32bb39, 20px circles when point count is between 20 and 40 - // * #98d320, 25px circles when point count is greater than or equal to 40 - 'circle-color': ['step', ['get', 'point_count'], '#14807c', 20, '#32bb39', 40, '#98d320'], - 'circle-radius': ['step', ['get', 'point_count'], 15, 20, 20, 40, 25] + 'circle-radius': 40, + 'circle-color': 'rgba(184, 255, 247, 0.4)', + 'circle-opacity': 0.4, + 'circle-stroke-color': theme.palette.explore.secondary, + 'circle-stroke-opacity': 1, + 'circle-stroke-width': 1 } }, clusterCount: { type: 'symbol', filter: ['has', 'point_count'], layout: { - 'text-field': '{point_count_abbreviated}', - 'text-font': ['Roboto Regular'], - 'text-size': 12 + 'text-field': ['concat', ['get', 'point_count_abbreviated'], '\nStations'], + 'text-font': ['Roboto Bold'], + 'text-size': 14 + }, + paint: { + 'text-color': theme.palette.explore.secondary } } }, @@ -46,19 +68,9 @@ export const layerStyles: { [group: string]: { [state: string]: Partial { + return polyCoords.map((lineCoords) => { + // When the region straddles the 180th meridian, we need to swap subregions on different sides of the meridian + const minLng = Math.min(...lineCoords.map(([lng]) => lng)); + return minLng === -180 ? lineCoords.map(([lng, lat]) => [lng + 360, lat]) : lineCoords; + }); + }) + }; +} export const createJourneyPathFromStationPoints = (coordinates: LineCoordinates): LineCoordinates => { for (let i = 0; i < coordinates.length; i += 1) { @@ -29,78 +42,91 @@ export const getFeatureBounds = (coordinates: LineCoordinates) => { return bounds; }; -export const pulsingDot = (map: maplibregl.Map, size: number): void => { - /** Create a pulsing dot that can be used by symbol styles. - * Set `icon-image` to ``pulsing-dot` under the `layout` attribute of the style. - * @param {maplibregl.Map} map - The map to add the pulsing dot to. - * @param {number} size - The size of the dot in pixels. - */ - const dot: StyleImage = { - context: null, - width: size, - height: size, - data: new Uint8Array(size * size * 4), - - // When the layer is added to the map, - // get the rendering context for the map canvas. - onAdd() { - const canvas = document.createElement('canvas'); - canvas.width = this.width; - canvas.height = this.height; - this.context = canvas.getContext('2d'); - }, - - // Call once before every frame where the icon will be used. +export const loadStationIcons = (map: maplibregl.Map): void => { + const outerRadius = 22 * devicePixelRatio; + const innerRadius = 18 * devicePixelRatio; + const fontSize = 8 * devicePixelRatio; + const tipSize = 5 * devicePixelRatio; + const haloWidth = outerRadius; + const width = 2 * outerRadius + 2 * haloWidth; + const height = 2 * outerRadius + haloWidth + Math.max(haloWidth, tipSize); + const centerX = haloWidth + outerRadius; + const centerY = haloWidth + outerRadius; + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + if (!context) return; + + const drawIcon = () => { + context.beginPath(); + context.arc(centerX, centerY, outerRadius, 0, Math.PI * 2); + context.fillStyle = '#FFFF00'; + context.fill(); + + context.beginPath(); + context.moveTo(centerX, centerY + outerRadius + tipSize); + context.lineTo(centerX - 2 * tipSize, centerY + outerRadius - tipSize); + context.lineTo(centerX + 2 * tipSize, centerY + outerRadius - tipSize); + context.closePath(); + context.fillStyle = '#FFFF00'; + context.fill(); + + context.beginPath(); + context.arc(centerX, centerY, innerRadius, 0, Math.PI * 2); + context.fillStyle = '#FFFFE8'; + context.fill(); + + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.font = `500 ${fontSize}px sans-serif`; + context.fillStyle = 'rgba(29, 51, 70, 0.75)'; + context.fillText('Station', centerX, centerY + 0.5 * innerRadius); + }; + + drawIcon(); + const data = context.getImageData(0, 0, width, height).data; + + const stationIcon: StyleImageInterface = { + width, + height, + data + }; + + const pulsingIcon: StyleImageInterface = { + width, + height, + data, render() { const duration = 1000; const t = (performance.now() % duration) / duration; + const pulseRadius = outerRadius * (0.5 + 1.5 * t); - const radius = (size / 2) * 0.3; - const outerRadius = (size / 2) * 0.7 * t + radius; - const context = this.context; - - if (context) { - // Draw the outer circle. - context.clearRect(0, 0, this.width, this.height); - context.beginPath(); - context.arc(this.width / 2, this.height / 2, outerRadius, 0, Math.PI * 2); - context.fillStyle = `rgba(255, 200, 200, ${1 - t})`; - context.fill(); - - // Draw the inner circle. - context.beginPath(); - context.arc(this.width / 2, this.height / 2, radius, 0, Math.PI * 2); - context.fillStyle = 'rgba(255, 100, 100, 0.7)'; - context.strokeStyle = 'white'; - context.lineWidth = 2 + 4 * (1 - t); - context.fill(); - context.stroke(); - - // Update this image's data with data from the canvas. - this.data = context.getImageData(0, 0, this.width, this.height).data; - - // Continuously repaint the map, resulting - // in the smooth animation of the dot. - map.triggerRepaint(); - - // Return `true` to let the map know that the image was updated. - return true; - } - return false; + context.clearRect(0, 0, width, height); + + // Draw the halo + context.beginPath(); + context.arc(centerX, centerY, pulseRadius, 0, Math.PI * 2); + context.fillStyle = `rgba(255, 255, 0, ${1 - t})`; + context.strokeStyle = '#FFFF00'; + context.fill(); + context.stroke(); + + // Update this image's data with data from the canvas. + this.data = context.getImageData(0, 0, this.width, this.height).data; + + // Continuously repaint the map, resulting in the smooth animation of the dot. + map.triggerRepaint(); + + // Return `true` to let the map know that the image was updated. + return true; } }; - map.addImage('pulsing-dot', dot, { pixelRatio: 2 }); -}; -export const directionArrow = (map: maplibregl.Map): void => { - map.loadImage(directionArrowIcon, (error?: Error | null, image?: HTMLImageElement | ImageBitmap | null) => { - if (error) { - throw error; - } - if (image) { - map.addImage('direction-arrow', image, { pixelRatio: 2 }); - } - }); + map.addImage('station-icon', stationIcon, { pixelRatio: devicePixelRatio }); + map.addImage('pulsing-icon', pulsingIcon, { pixelRatio: devicePixelRatio }); }; /** diff --git a/src/images/direction_arrow_icon.png b/src/images/direction_arrow_icon.png deleted file mode 100644 index 1fdfe9e..0000000 Binary files a/src/images/direction_arrow_icon.png and /dev/null differ diff --git a/src/store/reducers.ts b/src/store/reducers.ts index c8527ea..dc58406 100644 --- a/src/store/reducers.ts +++ b/src/store/reducers.ts @@ -1,29 +1,74 @@ -import { createJourneyPathFromStationPoints, getFeatureBounds } from '../components/Map/utils'; +import { createJourneyPathFromStationPoints } from '../components/Map/utils'; import { setUnitPreferences } from '../utils/localStorage'; export const dataReducers = (state: DataState, action: DataAction): DataState => { switch (action.type) { - case 'updateStations': { + case 'loadFAOAreas': + return { + ...state, + faoAreas: action.faoAreas + }; + case 'cacheFAOAreaIcons': { + return { + ...state, + faoAreaIcons: { + ...state.faoAreaIcons, + [action.faoArea]: action.base64Encoded + } + }; + } + case 'loadStations': { const journeyPath = createJourneyPathFromStationPoints( action.stations.map((station) => station.coordinates) ); // Expects stations to be sorted by date return { ...state, journeyPath, - stationsBounds: getFeatureBounds(journeyPath), - stationsList: action.stations + allStationsList: action.stations, + filteredStations: groupStationsByFaoArea(action.stations, state.faoAreas) }; } case 'updateFilteredStations': { + const stationGroups = groupStationsByFaoArea(action.stations, state.faoAreas); + return { + ...state, + filteredStations: stationGroups, + selectedFaoArea: null, + focusedStation: null, + selectedStation: null + }; + } + case 'updateSelectedFaoArea': { return { ...state, - filteredStations: action.stations, - selectedStation: action.stations ? action.stations[0] : null + selectedFaoArea: action.faoArea, + focusedStation: null, + selectedStation: null + }; + } + case 'updateFocusedStation': { + return { + ...state, + /** + * Update selected FAO area when a station is selected, + * but don't unset FAO area when the station is deselected + */ + selectedFaoArea: action.station + ? state.faoAreas.find((area) => area.code === action.station?.fao_area) ?? null + : state.selectedFaoArea, + focusedStation: action.station }; } case 'updateSelectedStation': return { ...state, + /** + * Update selected FAO area when a station is selected, + * but don't unset FAO area when the station is deselected + */ + selectedFaoArea: action.station + ? state.faoAreas.find((area) => area.code === action.station?.fao_area) ?? null + : state.selectedFaoArea, selectedStation: action.station }; case 'updateStationDetails': @@ -37,11 +82,6 @@ export const dataReducers = (state: DataState, action: DataAction): DataState => case 'updateSpeciesDetails': state.allSpeciesObject[action.species.id] = action.species; return state; - case 'updateFAOAreas': - return { - ...state, - faoAreas: action.faoAreas - }; case 'updateTempToUnit': setUnitPreferences({ Temp: action.unit, @@ -74,3 +114,22 @@ export const mapReducers = (state: MapState, action: MapAction): MapState => { } throw Error(`Received invalid action: ${action}`); }; + +function groupStationsByFaoArea(stations: StationSummary[] | null, allFaoAreas: FAOArea[]): StationGroup[] { + const groups: { + [faoAreaCode: string]: { + faoArea: FAOArea; + stations: StationSummary[]; + }; + } = {}; + stations?.forEach((station) => { + if (!groups[station.fao_area]) { + groups[station.fao_area] = { + faoArea: allFaoAreas.find((area) => area.code === station.fao_area) ?? allFaoAreas[0], + stations: [] + }; + } + groups[station.fao_area].stations.push(station); + }); + return Object.values(groups); +} diff --git a/src/store/states.ts b/src/store/states.ts index d2e584b..eecb087 100644 --- a/src/store/states.ts +++ b/src/store/states.ts @@ -4,15 +4,17 @@ const unitPref = getUnitPreferences(); // This is a fallback value for when a component that is not in a DataStateContext provider tries to access its value. export const dataStateInitialValue: DataState = { - stationsList: [], - filteredStations: null, + faoAreas: [], + faoAreaIcons: {}, + allStationsList: [], stationsObject: {}, - stationsBounds: [-180, -90, 180, 90], journeyPath: [], + filteredStations: [], + selectedFaoArea: null, + focusedStation: null, selectedStation: null, allSpeciesList: [], allSpeciesObject: {}, - faoAreas: [], tempToUnit: unitPref.Temp, depthToUnit: unitPref.Depth }; diff --git a/src/theme.ts b/src/theme.ts index 8be51ee..40af99d 100755 --- a/src/theme.ts +++ b/src/theme.ts @@ -4,6 +4,7 @@ declare module '@mui/material/styles' { type ExplorePaletteKey = | 'main' | 'mainTransparent' + | 'mainDark' | 'selected' | 'unselectedSecondary' | 'selectedSecondary' @@ -25,6 +26,8 @@ declare module '@mui/material/Button' { interface ButtonPropsVariantOverrides { 'explore-text': true; 'explore-contained': true; + 'explore-card': true; + 'explore-card-focus': true; } } @@ -44,6 +47,7 @@ const palette: PaletteOptions = { explore: { main: '#1d3346', mainTransparent: '#1d3346bf', + mainDark: '#040F20E5', selected: '#243c59f2', selectedSecondary: '#89f3e94d', unselectedSecondary: '#8af8ed4d', @@ -94,6 +98,34 @@ export const themeOptions = { 'textTransform': 'none', 'color': '#90fff3' } + }, + { + props: { variant: 'explore-card' }, + style: { + 'textTransform': 'none', + 'backgroundColor': palette.explore.divider, + 'color': 'white', + '&:active, &:hover': { + backgroundColor: palette.explore.secondary, + color: palette.explore.mainTransparent + }, + 'height': '30px', + 'borderRadius': '15px' + } + }, + { + props: { variant: 'explore-card-focus' }, + style: { + 'textTransform': 'none', + 'backgroundColor': palette.explore.secondary, + 'color': palette.explore.mainTransparent, + '&:active, &:hover': { + backgroundColor: palette.explore.secondary, + color: palette.explore.mainTransparent + }, + 'height': '30px', + 'borderRadius': '15px' + } } ] }, diff --git a/src/types/actions.d.ts b/src/types/actions.d.ts index 02df550..c4b24ba 100644 --- a/src/types/actions.d.ts +++ b/src/types/actions.d.ts @@ -1,8 +1,33 @@ -interface UpdateStations { - type: 'updateStations'; +interface loadFAOAreas { + type: 'loadFAOAreas'; + faoAreas: FAOArea[]; +} + +interface CacheFAOAreaIcons { + type: 'cacheFAOAreaIcons'; + faoArea: number; // code + base64Encoded: string; // rendered icon +} + +interface LoadStations { + type: 'loadStations'; stations: StationSummary[]; } +interface UpdateFilteredStations { + type: 'updateFilteredStations'; + stations: StationSummary[] | null; +} + +interface UpdateSelectedFaoArea { + type: 'updateSelectedFaoArea'; + faoArea: FAOArea | null; +} + +interface UpdateFocusedStation { + type: 'updateFocusedStation'; + station: StationSummary | null; +} interface UpdateSelectedStation { type: 'updateSelectedStation'; station: StationSummary | null; @@ -23,16 +48,6 @@ interface UpdateSpeciesDetails { species: SpeciesDetails; } -interface UpdateFAOAreas { - type: 'updateFAOAreas'; - faoAreas: FAOArea[]; -} - -interface UpdateFilteredStations { - type: 'updateFilteredStations'; - stations: StationSummary[] | null; -} - interface UpdateTempToUnit { type: 'updateTempToUnit'; unit: string; @@ -51,12 +66,15 @@ interface UpdateBasemap { type DataAction = | UpdateTempToUnit | UpdateDepthToUnit - | UpdateStations + | LoadStations | UpdateFilteredStations + | UpdateSelectedFaoArea + | UpdateFocusedStation | UpdateSelectedStation | UpdateStationDetails | UpdateAllSpecies | UpdateSpeciesDetails - | UpdateFAOAreas; + | loadFAOAreas + | CacheFAOAreaIcons; type MapAction = UpdateBasemap; diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 688c071..7977eeb 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -16,7 +16,7 @@ interface SearchExpressionGroup { interface StationSearchExpressions { join?: BooleanOperator; stationNames: string[]; - faoAreas: string[]; + faoAreas: number[]; species: string[]; dates: (import('dayjs').Dayjs | null)[]; } diff --git a/src/types/data.d.ts b/src/types/data.d.ts index 57a50b3..64ff945 100644 --- a/src/types/data.d.ts +++ b/src/types/data.d.ts @@ -1,102 +1,118 @@ -interface StationSummary { - name: string; - date: string; // Date in ISO 8601 format (i.e. YYYY-MM-DD) - coordinates: PointCoordinates; -} +import { MultiPolygon, Polygon } from '@turf/turf'; -interface StationDetails extends StationSummary { - sediment_sample?: string; - location: string; - water_body: string; - sea_area?: string; - place?: string; - fao_area: number; - gear?: string; - depth_fathoms?: number; - bottom_water_temp_c?: number; - bottom_water_depth_fathoms?: number; - specific_gravity_at_bottom?: number; - surface_temp_c?: number; - specific_gravity_at_surface?: number; - water_temp_c_at_depth_fathoms: { - [depth: string]: ?number; - }; - text: string; - hathitrust_urls: string[]; - species: SpeciesSummary[]; -} +declare global { + interface FAOArea { + code: number; + name: string; + ocean: string; + geometry: Polygon | MultiPolygon; + } -interface SpeciesSummary { - id: string; - record_id: string; - matched_canonical_full_name: string; - current_name: string; -} + interface StationGroup { + faoArea: FAOArea; + stations: StationSummary[]; + } -interface SpeciesDetails extends SpeciesSummary { - id: string; - matched_name: string; - current_name: string; - current_record_id: string; - matched_canonical_simple_name?: string; - current_canonical_simple_name?: string; - current_canonical_full_name?: string; - common_name?: string; - classification_path?: string; - classification_ranks?: string; - classification_ids?: string; - outlink?: string; - species_extra: SpeciesExtra[]; - species_synonyms: SpeciesSynonyms[]; - species_common_names: SpeciesCommonNames[]; - data_source_id: number; -} + interface StationSummary { + name: string; + date: string; // Date in ISO 8601 format (i.e. YYYY-MM-DD) + coordinates: PointCoordinates; + location: string; + fao_area: number; + gear?: string; + sediment_sample: string; + } -interface SpeciesExtra { - id: string; - scientific_name?: string; - status: boolean; - unaccepted_reason?: string; - valid_name?: string; - lsid?: string; - isBrackish: boolean; - isExtinct: boolean; - isFreshwater: boolean; - isMarine: boolean; - isTerrestrial: boolean; - species_id: string; -} + interface StationDetails extends StationSummary { + sediment_sample?: string; + location: string; + water_body: string; + sea_area?: string; + place?: string; + fao_area: number; + gear?: string; + depth_fathoms?: number; + bottom_water_temp_c?: number; + bottom_water_depth_fathoms?: number; + specific_gravity_at_bottom?: number; + surface_temp_c?: number; + specific_gravity_at_surface?: number; + water_temp_c_at_depth_fathoms: { + [depth: string]: ?number; + }; + text: string; + hathitrust_urls: string[]; + species: SpeciesSummary[]; + } -interface SpeciesSynonyms { - id: string; - scientific_name?: string; - outlink?: string; - species_id: string; -} + interface SpeciesSummary { + id: string; + record_id: string; + matched_canonical_full_name: string; + current_name: string; + } -interface SpeciesCommonNames { - id: string; - language: string; - name: string; - species_id: string; -} + interface SpeciesDetails extends SpeciesSummary { + id: string; + matched_name: string; + current_name: string; + current_record_id: string; + matched_canonical_simple_name?: string; + current_canonical_simple_name?: string; + current_canonical_full_name?: string; + common_name?: string; + classification_path?: string; + classification_ranks?: string; + classification_ids?: string; + outlink?: string; + species_extra: SpeciesExtra[]; + species_synonyms: SpeciesSynonyms[]; + species_common_names: SpeciesCommonNames[]; + data_source_id: number; + } -interface FAOArea { - code: string; - name: string; - ocean: string; -} + interface SpeciesExtra { + id: string; + scientific_name?: string; + status: boolean; + unaccepted_reason?: string; + valid_name?: string; + lsid?: string; + isBrackish: boolean; + isExtinct: boolean; + isFreshwater: boolean; + isMarine: boolean; + isTerrestrial: boolean; + species_id: string; + } + + interface SpeciesSynonyms { + id: string; + scientific_name?: string; + outlink?: string; + species_id: string; + } + + interface SpeciesCommonNames { + id: string; + language: string; + name: string; + species_id: string; + } -interface DataState { - stationsList: StationSummary[]; - filteredStations: StationSummary[] | null; - stationsObject: { [name: string]: StationDetails }; - stationsBounds: import('maplibre-gl').LngLatBoundsLike; - journeyPath: LineCoordinates; - selectedStation: StationSummary | null; - allSpeciesList: SpeciesSummary[]; - allSpeciesObject: { [matched_canonical_full_name: string]: SpeciesDetails }; - faoAreas: FAOArea[]; - tempToUnit: string; - depthToUnit: string; + interface DataState { + faoAreas: FAOArea[]; + faoAreaIcons: { [faoAreaCode: string]: string }; // rendered and base64 encoded + allStationsList: StationSummary[]; + stationsObject: { [name: string]: StationDetails }; + journeyPath: LineCoordinates; + filteredStations: StationGroup[]; + selectedFaoArea: FAOArea | null; // FAO area code + focusedStation: StationSummary | null; // station name + selectedStation: StationSummary | null; // station name + allSpeciesList: SpeciesSummary[]; + allSpeciesObject: { [matched_canonical_full_name: string]: SpeciesDetails }; + tempToUnit: string; + depthToUnit: string; + } } diff --git a/src/utils/format.ts b/src/utils/format.ts index a8058c4..fb167c9 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,3 +1,18 @@ +export const formatLongitude = (lng: number) => { + const wrappedLng = ((((lng + 180) % 360) + 360) % 360) - 180; + const hemisphere = wrappedLng > 0 ? 'E' : 'W'; + const degrees = Math.floor(Math.abs(wrappedLng)); + const minutes = String(Math.round((Math.abs(wrappedLng) % 1) * 60)).padStart(2, '0'); + return `${degrees}° ${minutes}' ${hemisphere}`; +}; + +export const formatLatitude = (lat: number) => { + const hemisphere = lat > 0 ? 'N' : 'S'; + const degrees = Math.floor(Math.abs(lat)); + const minutes = String(Math.round((Math.abs(lat) % 1) * 60)).padStart(2, '0'); + return `${degrees}° ${minutes}' ${hemisphere}`; +}; + export const decimalFormat = (value: number, decimals = 2): string => { const formatter = new Intl.NumberFormat('en-US', { style: 'decimal', diff --git a/src/utils/hooks.tsx b/src/utils/hooks.tsx index 576c6b9..762ce19 100644 --- a/src/utils/hooks.tsx +++ b/src/utils/hooks.tsx @@ -1,11 +1,8 @@ -import axios from 'axios'; import React, { DependencyList } from 'react'; import { getData } from '../store/api'; import { DataActionDispatcherContext, DataStateContext } from '../store/contexts'; -import faoAreasUrl from '../files/fao_areas.geojson'; - export const useStationDetails = (stationName?: string): StationDetails | null => { const dataActionDispatcher = React.useContext(DataActionDispatcherContext); const { stationsObject } = React.useContext(DataStateContext); @@ -61,44 +58,6 @@ export const useSpeciesDetails = (speciesId?: string): SpeciesDetails | null => return speciesDetails; }; -export const useFAOAreas = (): FAOArea[] => { - const dataActionDispatcher = React.useContext(DataActionDispatcherContext); - const { faoAreas } = React.useContext(DataStateContext); - const [faoAreasData, setFAOAreasData] = React.useState(faoAreas); - - React.useEffect(() => { - if (!faoAreas.length) { - axios - .get(faoAreasUrl) - .then((res) => { - const data = ( - res.data.features.map( - ({ - properties: { OCEAN, F_AREA, NAME_EN } - }: { - properties: { OCEAN: string; F_AREA: string; NAME_EN: string }; - }) => ({ - code: F_AREA, - name: NAME_EN, - ocean: OCEAN - }) - ) as FAOArea[] - ).sort((a, b) => a.name.localeCompare(b.name)); - setFAOAreasData(data); - dataActionDispatcher({ - type: 'updateFAOAreas', - faoAreas: data - }); - }) - .catch((err) => { - console.error(err); - }); - } - }, []); - - return faoAreasData; -}; - export const usePagination = (data: SpeciesSummary[], itemsPerPage: number) => { const [currentPage, setCurrentPage] = React.useState(1); const maxPage: number = Math.ceil(data.length / itemsPerPage); diff --git a/src/utils/scrollIntoView.ts b/src/utils/scrollIntoView.ts new file mode 100644 index 0000000..9ed10ac --- /dev/null +++ b/src/utils/scrollIntoView.ts @@ -0,0 +1,49 @@ +type ScrollIntoViewRequest = { + el: HTMLElement; + options: ScrollIntoViewOptions; + priority: number; +}; + +const ESTIMATED_SCROLL_DURATION = 1000; + +let isBrowserScrolling = false; // lock +let pendingRequests: ScrollIntoViewRequest[] = []; + +function next(): ScrollIntoViewRequest | null { + if (!pendingRequests.length) return null; + const req = pendingRequests.shift(); + return req && req.el && req.el.parentElement ? req : next(); +} + +function unlock() { + pendingRequests.sort((a, b) => b.priority - a.priority); + const req = next(); + if (req) { + req.el.scrollIntoView(req.options); + setTimeout(unlock, ESTIMATED_SCROLL_DURATION); + } else { + isBrowserScrolling = false; + } +} + +/** + * This locking mechanism provides a workaround for a bug in Chrome/Chromium that cancels + * all ongoing smooth scrolling actions (e.g. previous calls to `scrollIntoView`) + * See this discussion on StackOverflow: https://stackoverflow.com/a/63563437 + */ +export function requestScrollIntoView(el: HTMLElement, options: ScrollIntoViewOptions, priority = 0) { + // eslint-disable-next-line @typescript-eslint/no-empty-function + let abort = () => {}; + if (!isBrowserScrolling) { + isBrowserScrolling = true; + el.scrollIntoView(options); + setTimeout(unlock, ESTIMATED_SCROLL_DURATION); + } else { + const newReq = { el, options, priority }; + pendingRequests.push(newReq); + abort = () => { + pendingRequests = pendingRequests.filter((req) => req !== newReq); + }; + } + return abort; +}