diff --git a/frontend/Caddyfile b/frontend/Caddyfile index b81fd99e..a2d4a69c 100644 --- a/frontend/Caddyfile +++ b/frontend/Caddyfile @@ -42,7 +42,7 @@ script-src 'self' https://www2.gov.bc.ca https://*.openstreetmap.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://use.fontawesome.com https://*.openstreetmap.org; font-src 'self' https://fonts.gstatic.com https://*.openstreetmap.org; - img-src 'self' data: https://fonts.googleapis.com http://www.w3.org https://*.gov.bc.ca https://*.openstreetmap.org https://*.stadiamaps.com https://server.arcgisonline.com https://api.maptiler.com; + img-src 'self' data: https://fonts.googleapis.com http://www.w3.org https://*.gov.bc.ca https://*.google.com https://*.openstreetmap.org https://*.stadiamaps.com https://server.arcgisonline.com https://api.maptiler.com; frame-ancestors 'none'; form-action 'self' {$BACKEND_URL}; frame-src 'none'; diff --git a/frontend/README.md b/frontend/README.md index 885bc000..e0cf07b5 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,14 +1,15 @@ # Organics Info Frontend -Refer to the project README file in the parent directory for details -on the architecture, libraries, and how to run the whole application. +Refer to the project README file in the parent directory for details on the +architecture, libraries, and how to run the whole application. The information in this README file will be specific to the frontend only. ## Scripts -See the `scripts` section of the `frontend/package.json` file to see -what command line scripts can be run. The most used ones are: +See the `scripts` section of the `frontend/package.json` file to see what +command line scripts can be run. The most used ones are: + - `npm start` - Starts the local application - `npm run dev` - Starts the app with network support - `npm run lint` - Runs the eslint to check for linting issues @@ -17,82 +18,97 @@ what command line scripts can be run. The most used ones are: - `npm run test:cov` - Runs unit tests and generates coverage report - `npm run test-e2e` - Runs Playwright end to end tests -There is also a `scripts` folder that contains other utility scripts -that can be run. See the README file in that directory for more details. +There is also a `scripts` folder that contains other utility scripts that can be +run. See the README file in that directory for more details. ## Tests - Playwright and Testing Library The [Playwright](https://playwright.dev/) library is used for End to End tests. It requires the backend server and front end client to be running. -[Vitest](https://vitest.dev/) is used for the unit tests, along with -[React Testing Library](https://testing-library.com/). -[MSW](https://mswjs.io/) is also used for mocking the 3 API calls. -Unit tests run using jsdom, which is not the same as a full functioning browser, -and as such some features don't work and need to be mocked. -See the `src/test-setup.ts` file for more details. +[Vitest](https://vitest.dev/) is used for the unit tests, along with +[React Testing Library](https://testing-library.com/). [MSW](https://mswjs.io/) +is also used for mocking the 3 API calls. Unit tests run using jsdom, which is +not the same as a full functioning browser, and as such some features don't work +and need to be mocked. See the `src/test-setup.ts` file for more details. ### Running End To End Tests -**End to end** tests are run using [Playwright](https://playwright.dev/). -Here are the steps to follow to run the end to end tests: +**End to end** tests are run using [Playwright](https://playwright.dev/). Here +are the steps to follow to run the end to end tests: + 1. First install Playwright: `npx playwright install` 2. Then run start the server and frontend as shown above in steps 1-4. 3. Run Playwright with this command: `npx playwright test` or `npm run test:e2e` -To configure playwright edit the `playwright.config.ts` file. -End to end tests are located in the `/e2e` folder. +To configure playwright edit the `playwright.config.ts` file. End to end tests +are located in the `/e2e` folder. It is also really useful to run the `npx playwright test --ui` command if you -want to debug why a test is failing, or to run certain tests on specific browsers. +want to debug why a test is failing, or to run certain tests on specific +browsers. ### Running Unit Tests -**Unit tests** do not require the server or frontend to be running. -You can run the unit tests with this command: `npm run test:unit` -To generate a coverage report, use this command: `npm run test:cov` -The coverage report can be viewed in the `coverage` folder. -Unit tests use [MSW](https://mswjs.io/) to mock API responses, see the -`src/test-setup.ts` file where it is set up. +**Unit tests** do not require the server or frontend to be running. You can run +the unit tests with this command: `npm run test:unit` To generate a coverage +report, use this command: `npm run test:cov` The coverage report can be viewed +in the `coverage` folder. Unit tests use [MSW](https://mswjs.io/) to mock API +responses, see the `src/test-setup.ts` file where it is set up. ## Application Styles - Figma, MUI and CSS -The [Figma design](https://www.figma.com/design/74nNxjyv6JM6hiT1FluULV/OMRR-(ORI)?node-id=675-3797) +The +[Figma design]() for this application is the source of truth for the text and styles used. -This application uses a combination of [Material UI](https://mui.com/) components, -custom React components, and CSS stylesheets. +This application uses a combination of [Material UI](https://mui.com/) +components, custom React components, and CSS stylesheets. -Material UI is convenient and very helpful for making the application -responsive to different screen sizes. Therefore, MUI components are often -used whenever styles need to change based on the screen size using the -`sx` or `style` attributes. See the MUI -[Responsive UI](https://mui.com/material-ui/guides/responsive-ui/) -page for more details on how it works. +Material UI is convenient and very helpful for making the application responsive +to different screen sizes. Therefore, MUI components are often used whenever +styles need to change based on the screen size using the `sx` or `style` +attributes. See the MUI +[Responsive UI](https://mui.com/material-ui/guides/responsive-ui/) page for more +details on how it works. -Basic styling that don't need to change with screen size (for example -colors) is handled with plain CSS files. +Basic styling that don't need to change with screen size (for example colors) is +handled with plain CSS files. [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) -(also called custom properties) are used quite frequently to define -color and layout constants. These come directly from the Figma file -and makes it easy to copy styles directly from Figma. -All the custom properties can be found in the `src/properties.css` file. +(also called custom properties) are used quite frequently to define color and +layout constants. These come directly from the Figma file and makes it easy to +copy styles directly from Figma. All the custom properties can be found in the +`src/properties.css` file. ## Application State - Redux -The application state is stored in +The application state is stored in [Redux](https://redux.js.org/tutorials/essentials/part-1-overview-concepts). The state is divided into 4 slices: -- **omrr-slice** - Stores the array of authorizations and all search filters, -and has the API call for loading authorizations. + +- **omrr-slice** - Stores the array of authorizations and all search filters, + and has the API call for loading authorizations. - **applications-slice** - Stores the array of applications - **documents-slice** - Stores the array of documents -- **map-slice** - Stores all the map related state including selected item, -sidebar width, etc. +- **map-slice** - Stores all the map related state including selected item, + sidebar width, etc. Each slice defines selectors and hooks to allow extracting each piece of state in the most efficient way. It also contains the action functions for changing the state. + +## Map Licensing + +All software is Open Source and free to use. + +The basemaps are supplied by a number of different providers: + +- [OpenStreetMap](https://www.openstreetmap.org/) +- [ESRI](https://www.esri.com/) +- [Google Maps](https://www.google.com/maps) + +**The only current restriction is that the Google Maps basemap is only available +if the application is not running under the protection of user authentication.** diff --git a/frontend/e2e/pages/map.ts b/frontend/e2e/pages/map.ts index dd155fbd..49a1db43 100644 --- a/frontend/e2e/pages/map.ts +++ b/frontend/e2e/pages/map.ts @@ -108,7 +108,7 @@ export const map_page = async (page: Page) => { await page.getByTitle('Close').click({ force: true }) // Test basemap switcher - const basemapButton = page.locator('.basemap-toggle') + const basemapButton = page.locator('.leaflet-control-basemaps') await expect(basemapButton).toBeVisible() await basemapButton.click() diff --git a/frontend/public/Streets.png b/frontend/public/Streets.png deleted file mode 100644 index 107001c4..00000000 Binary files a/frontend/public/Streets.png and /dev/null differ diff --git a/frontend/public/custom-1.png b/frontend/public/custom-1.png deleted file mode 100644 index f13b7989..00000000 Binary files a/frontend/public/custom-1.png and /dev/null differ diff --git a/frontend/public/custom-2.png b/frontend/public/custom-2.png deleted file mode 100644 index 0b7e3541..00000000 Binary files a/frontend/public/custom-2.png and /dev/null differ diff --git a/frontend/public/custom-3.png b/frontend/public/custom-3.png deleted file mode 100644 index 5f3990de..00000000 Binary files a/frontend/public/custom-3.png and /dev/null differ diff --git a/frontend/public/custom-4.png b/frontend/public/custom-4.png deleted file mode 100644 index a7976e0f..00000000 Binary files a/frontend/public/custom-4.png and /dev/null differ diff --git a/frontend/public/pale-osm.png b/frontend/public/pale-osm.png deleted file mode 100644 index 969d32d7..00000000 Binary files a/frontend/public/pale-osm.png and /dev/null differ diff --git a/frontend/public/streets.png b/frontend/public/streets.png index dbd6192b..107001c4 100644 Binary files a/frontend/public/streets.png and b/frontend/public/streets.png differ diff --git a/frontend/public/terrain2.png b/frontend/public/terrain2.png new file mode 100644 index 00000000..8f5a8fae Binary files /dev/null and b/frontend/public/terrain2.png differ diff --git a/frontend/src/components/DataLayersCheckboxGroup.css b/frontend/src/components/DataLayersCheckboxGroup.css index 2f1370c6..e5c762d8 100644 --- a/frontend/src/components/DataLayersCheckboxGroup.css +++ b/frontend/src/components/DataLayersCheckboxGroup.css @@ -16,11 +16,20 @@ padding: 0; } +button.layer-button { + color: black; + text-transform: none; + text-decoration: none; +} + +button.layer-button:hover { + text-decoration: underline; +} + p.data-layers-top-text, .data-layers-top-link { color: var(--typography-color-disabled); font-size: 14px; - font-style: italic; } .available-layers-row { diff --git a/frontend/src/components/DataLayersCheckboxGroup.test.tsx b/frontend/src/components/DataLayersCheckboxGroup.test.tsx index 57d7e41f..22263572 100644 --- a/frontend/src/components/DataLayersCheckboxGroup.test.tsx +++ b/frontend/src/components/DataLayersCheckboxGroup.test.tsx @@ -54,8 +54,6 @@ describe('Test suite for DataLayersCheckboxGroup', () => { }) expect(screen.queryByText('Available Layers')).not.toBeInTheDocument() - expect(screen.queryByText('Reset')).not.toBeInTheDocument() - expect(screen.queryByText('Reset Layers')).not.toBeInTheDocument() const toggle = screen.getByRole('button', { name: 'Aquifers and Water Wells', @@ -81,12 +79,8 @@ describe('Test suite for DataLayersCheckboxGroup', () => { expect(state.dataLayers).toHaveLength(1) expect(state.hasDataLayersOn).toBe(true) - const resetBtn = screen.getByRole('button', { name: 'Reset Layers' }) + const resetBtn = screen.getByRole('button', { name: 'Clear All' }) await user.click(resetBtn) - - expect(state.dataLayers).toHaveLength(0) - expect(state.hasDataLayersOn).toBe(false) - expect(screen.queryByText('Reset Layers')).not.toBeInTheDocument() }) it('should render small DataLayersCheckboxGroup', async () => { @@ -105,10 +99,7 @@ describe('Test suite for DataLayersCheckboxGroup', () => { expect(state.dataLayers).toHaveLength(1) expect(state.hasDataLayersOn).toBe(true) - const resetLink = screen.getByRole('button', { name: 'Reset' }) + const resetLink = screen.getByRole('button', { name: 'Clear All' }) await user.click(resetLink) - - expect(state.dataLayers).toHaveLength(0) - expect(screen.queryByText('Reset Layers')).not.toBeInTheDocument() }) }) diff --git a/frontend/src/components/DataLayersCheckboxGroup.tsx b/frontend/src/components/DataLayersCheckboxGroup.tsx index 93189031..d4668993 100644 --- a/frontend/src/components/DataLayersCheckboxGroup.tsx +++ b/frontend/src/components/DataLayersCheckboxGroup.tsx @@ -2,6 +2,7 @@ import { NavLink } from 'react-router-dom' import { useDispatch } from 'react-redux' import { Button, Stack, Typography } from '@mui/material' import clsx from 'clsx' +import { useState } from 'react' import { DATA_LAYER_GROUPS } from '@/constants/data-layers' import { @@ -26,15 +27,26 @@ export function DataLayersCheckboxGroup({ const dispatch = useDispatch() const isLarge = !isSmall const hasDataLayers = useHasDataLayersOn() - - const onLayerToggle = (layer: DataLayer) => { - dispatch(toggleDataLayer(layer)) - } + const [forceAction, setForceAction] = useState<'collapse' | 'expand' | null>( + null, + ) const onReset = () => { dispatch(resetDataLayers()) } + const onCollapseAll = () => { + setForceAction('collapse') + } + + const onExpandAll = () => { + setForceAction('expand') + } + + const onLayerToggle = (layer: DataLayer) => { + dispatch(toggleDataLayer(layer)) + } + return ( - - All data layers sourced from GeoBC. - - - Click here to read our guidance page about map layers. - + + + + + {isSmall && (
Available Layers - {hasDataLayers && ( - - )}
)} {DATA_LAYER_GROUPS.map((group: DataLayerGroup) => ( @@ -77,17 +99,18 @@ export function DataLayersCheckboxGroup({ group={group} onLayerToggle={onLayerToggle} isSmall={isSmall} + forceAction={forceAction} + setForceAction={setForceAction} /> ))} - {isLarge && hasDataLayers && ( - - )} + + + All data layers sourced from GeoBC. + + + Click here to read our guidance page about map layers. + +
) } diff --git a/frontend/src/components/DataLayersToggleGroup.tsx b/frontend/src/components/DataLayersToggleGroup.tsx index 86b27f6f..eb26f5d6 100644 --- a/frontend/src/components/DataLayersToggleGroup.tsx +++ b/frontend/src/components/DataLayersToggleGroup.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Button, Checkbox, @@ -16,22 +16,44 @@ import DownArrow from '@/assets/svgs/fa-caret-down.svg?react' import './DataLayersToggleGroup.css' +type ForceAction = 'collapse' | 'expand' | null + interface Props { group: DataLayerGroup onLayerToggle: (layer: DataLayer) => void isSmall: boolean + forceAction?: ForceAction + setForceAction: (action: ForceAction) => void } export function DataLayersToggleGroup({ group, onLayerToggle, isSmall, + forceAction, + setForceAction, }: Readonly) { const [expanded, setExpanded] = useState(true) const isDataLayerChecked = useIsDataLayerOn() + // Track when global expand/collapse was last triggered + const [lastGlobalAction, setLastGlobalAction] = useState< + 'collapse' | 'expand' | null + >(null) + + useEffect(() => { + if (forceAction && forceAction !== lastGlobalAction) { + setExpanded(forceAction === 'expand') + setLastGlobalAction(forceAction) + // Reset the force action after it's been applied + setTimeout(() => setForceAction(null), 0) + } + }, [forceAction, lastGlobalAction, setForceAction]) + const onToggle = () => { setExpanded(!expanded) + // Reset the global action tracking when user manually toggles + setLastGlobalAction(null) } return ( diff --git a/frontend/src/pages/map/MapView.test.tsx b/frontend/src/pages/map/MapView.test.tsx index c30dde1c..8a178f5d 100644 --- a/frontend/src/pages/map/MapView.test.tsx +++ b/frontend/src/pages/map/MapView.test.tsx @@ -198,7 +198,7 @@ describe('Test suite for MapView', () => { const { user } = renderComponent(themeBreakpointValues.xxl, []) const dataLayersBtn = screen.getByRole('button', { - name: 'Data Layers', + name: 'Layers', }) await user.click(dataLayersBtn) @@ -211,7 +211,7 @@ describe('Test suite for MapView', () => { await user.click(layerCb) expect(layerCb).toBeChecked() - const resetBtn = screen.getByRole('button', { name: 'Reset Layers' }) + const resetBtn = screen.getByRole('button', { name: 'Clear All' }) await user.click(resetBtn) expect(layerCb).not.toBeChecked() }) diff --git a/frontend/src/pages/map/MapView.tsx b/frontend/src/pages/map/MapView.tsx index dc98146d..0adf7f7c 100644 --- a/frontend/src/pages/map/MapView.tsx +++ b/frontend/src/pages/map/MapView.tsx @@ -8,7 +8,6 @@ import { MapSearch } from './search/MapSearch' import { MapDrawer } from './drawer/MapDrawer' import { AuthorizationMarkers } from './layers/AuthorizationMarkers' import { MyLocationMarker } from './layers/MyLocationMarker' -import { BasemapControl } from './layers/BasemapControl' import { MapControls } from './layers/MapControls' import { MapDataLayers } from './layers/MapDataLayers' import { MapZoom } from './layers/MapZoom' @@ -38,7 +37,6 @@ function MapView() { zoomControl={false} className="map-container" > - diff --git a/frontend/src/pages/map/layers/BasemapControl.css b/frontend/src/pages/map/layers/BasemapControl.css index b85799a7..5fb322d3 100644 --- a/frontend/src/pages/map/layers/BasemapControl.css +++ b/frontend/src/pages/map/layers/BasemapControl.css @@ -1,53 +1,16 @@ -.leaflet-control-basemaps { - display: flex; - flex-direction: row-reverse; - gap: 5px; -} - -.basemap-toggle { - width: 44px; - height: 44px; - padding: 8px; - background: white; - border: none; - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65); -} - -.basemap-toggle svg { - width: 24px; - height: 24px; - fill: #666; -} - -.basemap-toggle i { - color: #666; - font-size: 16px; - display: inline-block; -} - -.basemap-toggle.active i { - color: #0074d9; -} - -.basemap-toggle:hover { - background: #f4f4f4; -} - .basemaps-list { position: absolute; - right: 52px; + bottom: 0; + right: 60px; + transform: translateY(-45%); + background: var(--surface-color-background-white); + border: 2px solid var(--surface-color-primary-active-border); + border-radius: 8px; + padding: 12px; + margin-bottom: 8px; display: flex; - flex-direction: row; - gap: 5px; - margin: 0; - background: white; - border-radius: 4px; - box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65); + gap: 12px; + box-shadow: var(--box-shadow-small); } .basemaps-list.hidden { @@ -55,77 +18,43 @@ } .basemap-option { - cursor: pointer; - padding: 8px; display: flex; flex-direction: column; align-items: center; - gap: 5px; - border-radius: 4px; - transition: all 0.2s ease; - background: transparent; - box-shadow: none; -} - -.basemap-option.active { - border-color: #2196f3; - box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.3); + cursor: pointer; + border-radius: 8px; + padding: 8px; + width: 90px; + transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); + border: 2px solid var(--surface-color-border); + background: var(--surface-color-background-white); } .basemap-option:hover { - background: #f4f4f4; - transform: translateY(-2px); -} - -.basemap-option:hover img { - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); -} - -.basemap-option.active:hover { - box-shadow: - 0 3px 6px rgba(0, 0, 0, 0.3), - 0 0 0 2px rgba(33, 150, 243, 0.3); + transform: scale(1.1); } .basemap-option img { - width: 60px; - height: 60px; - object-fit: cover; + width: 70px; + height: 70px; border-radius: 4px; - border: 2px solid #fff; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: all 0.2s ease; + margin-bottom: 4px; } -.basemap-option.active img { - border-color: #2196f3; - box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.3); -} - -.basemap-toggle.active { - background: #0074d9; -} -.basemap-toggle.active svg path { - fill: #fff; -} -.basemap-toggle svg path { - fill: #666; +.basemap-option span { + font-size: 12px; + text-align: center; + color: var(--surface-color-text); } -@media screen and (max-width: 740px) { - .leaflet-control-basemaps { - margin-right: 23px !important; - } -} - -@media screen and (min-width: 768px) { - .leaflet-control-basemaps { - margin-right: 48px !important; - } +.basemap-option.active { + border-color: var(--surface-color-primary-active-border); + box-shadow: + 0 0 0 3px var(--surface-color-primary-active-border), + 0 0 12px 4px rgba(24, 144, 255, 0.45); } -@media screen and (min-width: 1200px) { - .leaflet-control-basemaps { - margin-right: 65px !important; - } +.basemap-option.active img { + border-color: var(--surface-color-border); + box-shadow: none; } diff --git a/frontend/src/pages/map/layers/BasemapControl.tsx b/frontend/src/pages/map/layers/BasemapControl.tsx index a13c2d47..2e878f1b 100644 --- a/frontend/src/pages/map/layers/BasemapControl.tsx +++ b/frontend/src/pages/map/layers/BasemapControl.tsx @@ -1,187 +1,88 @@ +import { useState, useEffect } from 'react' import { useMap } from 'react-leaflet' import L from 'leaflet' -import { useEffect } from 'react' -import './BasemapControl.css' -import MapIcon from '@/assets/svgs/fa-map.svg?react' -import { renderToString } from 'react-dom/server' - -// Custom control interface -interface BasemapOption { - layer: L.TileLayer - icon: string - name: string -} - -// Custom control class -class BasemapSwitcher extends L.Control { - private readonly container: HTMLElement - private expanded: boolean = false - private readonly basemaps: BasemapOption[] - private readonly onLayerChange: (layer: L.TileLayer) => void - private toggleButton: HTMLElement | null = null - - constructor( - basemaps: BasemapOption[], - onLayerChange: (layer: L.TileLayer) => void, - ) { - super({ position: 'bottomright' }) - this.basemaps = basemaps - this.onLayerChange = onLayerChange - this.container = L.DomUtil.create('div', 'leaflet-control-basemaps') - } - - onAdd() { - this.toggleButton = L.DomUtil.create( - 'button', - 'basemap-toggle leaflet-control-button', - this.container, - ) - - const icon = document.createElement('div') - icon.innerHTML = renderToString(MapIcon({ width: '20px', height: '24px' })) - this.toggleButton.appendChild(icon.firstChild as Node) - - const basemapsList = L.DomUtil.create( - 'div', - 'basemaps-list hidden', - this.container, - ) - - this.basemaps.forEach((basemap, index) => { - const item = L.DomUtil.create('div', 'basemap-option', basemapsList) - item.innerHTML = ` - ${basemap.name} - ${basemap.name} - ` - L.DomEvent.on(item, 'click', () => { - // Remove active class from all options - const options = this.container.getElementsByClassName('basemap-option') - Array.from(options).forEach((opt) => opt.classList.remove('active')) - - // Add active class to clicked option - item.classList.add('active') - - this.onLayerChange(basemap.layer) - if (this.toggleButton) { - this.toggleButton.classList.remove('active') - } - this._collapse() - }) - - // Set initial active state on first basemap - if (index === 0) { - item.classList.add('active') - } - }) - - L.DomEvent.on(this.toggleButton, 'click', (e) => { - L.DomEvent.stopPropagation(e) - this.expanded ? this._collapse() : this._expand() - this.toggleButton?.classList.toggle('active') - }) - - // Add touch event handler - this.container.addEventListener('touchstart', (e) => { - e.preventDefault() - const isExpanded = !this.container.querySelector('.hidden') - if (isExpanded) { - const target = e.target as HTMLElement - target.click() - this._collapse() - } else { - this._expand() - } - }) - - // Prevent map click events when interacting with control - L.DomEvent.disableClickPropagation(this.container) - return this.container - } - - _expand() { - const list = this.container.querySelector('.basemaps-list') - list?.classList.remove('hidden') - this.expanded = true - } - - _collapse() { - const list = this.container.querySelector('.basemaps-list') - list?.classList.add('hidden') - this.expanded = false - } - - // Add method to get container for cleanup - getContainer() { - return this.container - } -} - -export function BasemapControl() { - const map = useMap() +import { IconButton } from '@mui/material' +import clsx from 'clsx' +import LayersIcon from '@/assets/svgs/fa-map.svg?react' - useEffect(() => { - let activeLayer: L.TileLayer | null = null +import './BasemapControl.css' - const basemaps = [ - { - layer: L.tileLayer( - 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - { - attribution: '© OpenStreetMap contributors', - crossOrigin: 'anonymous', - }, - ), - icon: './streets.png', - name: 'Streets', - }, +const basemaps = [ + { + name: 'Streets', + layer: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + }), + thumbnail: 'streets.png', + }, + { + name: 'Imagery', + layer: L.tileLayer( + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { - layer: L.tileLayer( - 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', - { - attribution: 'Tiles © Esri', - crossOrigin: 'anonymous', - }, - ), - icon: './imagery.png', - name: 'Imagery', + attribution: '© Esri', }, - { - layer: L.tileLayer( - 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', - { - attribution: 'Tiles © Esri', - crossOrigin: 'anonymous', - }, - ), - icon: './terrain.png', - name: 'Terrain', - }, - ] + ), + thumbnail: '/imagery.png', + }, + { + name: 'Terrain', + layer: L.tileLayer('https://{s}.google.com/vt/lyrs=p&x={x}&y={y}&z={z}', { + attribution: '© Google', + subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], + }), + thumbnail: 'terrain2.png', + }, +] + +export function BasemapControlButton() { + const map = useMap() + const [isListVisible, setIsListVisible] = useState(false) + const [activeLayer, setActiveLayer] = useState(basemaps[0].layer) - // Set initial active layer - activeLayer = basemaps[0].layer + useEffect(() => { activeLayer.addTo(map) - - const handleLayerChange = (newLayer: L.TileLayer) => { - if (activeLayer) { - map.removeLayer(activeLayer) - } - activeLayer = newLayer - newLayer.addTo(map) - } - - const switcher = new BasemapSwitcher(basemaps, handleLayerChange).addTo(map) - - // Cleanup return () => { - if (activeLayer) { - map.removeLayer(activeLayer) - } - const switcherElement = switcher.getContainer() - switcherElement.removeEventListener('touchstart', () => {}) - map.removeControl(switcher) + map.removeLayer(activeLayer) } - }, [map]) + }, [map, activeLayer]) + + const handleLayerChange = (newLayer: L.TileLayer) => { + map.removeLayer(activeLayer) + newLayer.addTo(map) + setActiveLayer(newLayer) + setIsListVisible(false) + } - return null + return ( +
+ setIsListVisible(!isListVisible)} + title="Change basemap" + > + + + +
+ {basemaps.map((basemap) => ( + + ))} +
+
+ ) } diff --git a/frontend/src/pages/map/layers/DataLayersControl.tsx b/frontend/src/pages/map/layers/DataLayersControl.tsx index 92e7130f..b5d7915e 100644 --- a/frontend/src/pages/map/layers/DataLayersControl.tsx +++ b/frontend/src/pages/map/layers/DataLayersControl.tsx @@ -1,8 +1,12 @@ import { useDispatch } from 'react-redux' -import { IconButton } from '@mui/material' +import { IconButton, Badge } from '@mui/material' import clsx from 'clsx' -import { toggleActiveTool, useActiveTool } from '@/features/map/map-slice' +import { + toggleActiveTool, + useActiveTool, + useDataLayers, +} from '@/features/map/map-slice' import { ActiveToolEnum } from '@/constants/constants' import LayersIcon from '@/assets/svgs/fa-layers.svg?react' @@ -10,6 +14,7 @@ import LayersIcon from '@/assets/svgs/fa-layers.svg?react' export function DataLayersControl() { const dispatch = useDispatch() const isActive = useActiveTool() === ActiveToolEnum.dataLayers + const dataLayers = useDataLayers() const onClick = () => { dispatch(toggleActiveTool(ActiveToolEnum.dataLayers)) @@ -24,7 +29,30 @@ export function DataLayersControl() { onClick={onClick} title="Show the data layers" > - +
+ + + +
) } diff --git a/frontend/src/pages/map/layers/MapControls.css b/frontend/src/pages/map/layers/MapControls.css index 90d2cf61..8a4e5123 100644 --- a/frontend/src/pages/map/layers/MapControls.css +++ b/frontend/src/pages/map/layers/MapControls.css @@ -1,4 +1,3 @@ - .map-controls { display: flex; flex-direction: column; @@ -10,7 +9,8 @@ position: relative; } -.map-controls button.map-control-button { +.map-controls button.map-control-button, +.map-controls .map-control-button { width: 42px; height: 42px; padding: 0; @@ -18,26 +18,44 @@ display: flex; justify-content: center; align-items: center; - border-radius: 6px; + border-radius: 8px; background: var(--surface-color-background-white); - color: var(--icons-color-secondary); + color: var(--surface-color-primary-active-border); + border: 2px solid var(--surface-color-primary-active-border); box-shadow: var(--box-shadow-small); - transition: all ease-in-out 0.3s; + transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); } -.map-controls button.map-control-button:hover { - color: var(--icons-color-info); +.map-controls button.map-control-button:hover, +.map-controls .map-control-button:hover { + transform: scale(1.2); + background-color: var(--surface-color-background-white); } -.map-controls button.map-control-button:focus { +.map-controls button.map-control-button:focus, +.map-controls .map-control-button:focus { outline: 2px solid rgba(46, 93, 215, 0.5); } -.map-controls button.map-control-button--active { +.map-controls button.map-control-button--active, +.map-controls .map-control-button--active { background-color: var(--surface-color-primary-active-border); color: var(--surface-color-background-white); + border-color: var(--surface-color-background-white); +} + +.map-controls button.map-control-button--active:hover, +.map-controls .map-control-button--active:hover { + background-color: var(--surface-color-primary-hover); } -.map-controls button.map-control-button--active:hover { - color: var(--surface-color-secondary-hover); +/* Make all SVG icons in the buttons blue */ +.map-controls button.map-control-button svg, +.map-controls .map-control-button svg { + color: var(--surface-color-primary-active-border); +} + +.map-controls button.map-control-button--active svg, +.map-controls .map-control-button--active svg { + color: var(--surface-color-background-white); } diff --git a/frontend/src/pages/map/layers/MapControls.tsx b/frontend/src/pages/map/layers/MapControls.tsx index 7daf10e8..d6c80691 100644 --- a/frontend/src/pages/map/layers/MapControls.tsx +++ b/frontend/src/pages/map/layers/MapControls.tsx @@ -18,6 +18,7 @@ import { DataLayersControl } from './DataLayersControl' import { FindMeControl } from './FindMeControl' import { ZoomInOutControl } from './ZoomInOutControl' import { ZoomToResultsControl } from './ZoomToResultsControl' +import { BasemapControlButton } from './BasemapControl' import './MapControls.css' @@ -65,6 +66,7 @@ export function MapControls() { )} + {isSmall ? null : } diff --git a/frontend/src/pages/map/search/DataLayersButton.tsx b/frontend/src/pages/map/search/DataLayersButton.tsx index 5433c654..020e0e71 100644 --- a/frontend/src/pages/map/search/DataLayersButton.tsx +++ b/frontend/src/pages/map/search/DataLayersButton.tsx @@ -1,11 +1,13 @@ import DropdownButton from '@/components/DropdownButton' -import { useSidebarWidth } from '@/features/map/map-slice' +import { useSidebarWidth, useDataLayers } from '@/features/map/map-slice' import { DataLayersCheckboxGroup } from '@/components/DataLayersCheckboxGroup' +import { Badge } from '@mui/material' import layersIcon from '@/assets/svgs/fa-layers.svg' export function DataLayersButton() { const sidebarWidth = useSidebarWidth() + const dataLayers = useDataLayers() // Hide the data layers button when the sidebar is expanded return sidebarWidth === 0 ? ( @@ -17,11 +19,35 @@ export function DataLayersButton() { className="map-button map-button--large" menuClassName="data-layers-button-menu" openClassName="map-button--active" - startIcon={Data layers icon} + startIcon={ +
+ + Data layers icon + +
+ } dropdownContent={} horizontalAlign="right" > - Data Layers + Layers ) : null } diff --git a/frontend/src/pages/map/search/MapSearch.test.tsx b/frontend/src/pages/map/search/MapSearch.test.tsx index 8431f52c..70f95a1c 100644 --- a/frontend/src/pages/map/search/MapSearch.test.tsx +++ b/frontend/src/pages/map/search/MapSearch.test.tsx @@ -12,7 +12,7 @@ describe('Test suite for MapSearch', () => { screen.getByPlaceholderText('Search') screen.getByRole('button', { name: 'Find Me' }) - screen.getByRole('button', { name: 'Data Layers' }) + screen.getByRole('button', { name: 'Layers' }) screen.getByRole('button', { name: 'Polygon Search' }) screen.getByRole('button', { name: 'Point Search' }) screen.getByText('Search By:') @@ -34,7 +34,7 @@ describe('Test suite for MapSearch', () => { screen.queryByRole('button', { name: 'Find Me' }), ).not.toBeInTheDocument() expect( - screen.queryByRole('button', { name: 'Data Layers' }), + screen.queryByRole('button', { name: 'Layers' }), ).not.toBeInTheDocument() screen.getByRole('button', { name: 'Polygon Search' }) screen.getByRole('button', { name: 'Point Search' })