From 2f9ba42f5143884dd9b518c80c2263e955078942 Mon Sep 17 00:00:00 2001 From: Loup Theron Date: Thu, 28 Sep 2023 12:12:21 +0200 Subject: [PATCH] Refactor service worker --- .../LoadOffline/__tests__/utils.tests.ts} | 18 ++-- .../LoadOffline/components}/LoadOffline.tsx | 72 ++++++++-------- .../src/features/LoadOffline/constants.ts | 70 ++++++++++++++++ frontend/src/features/LoadOffline/utils.ts | 82 +++++++++++++++++++ .../src/features/map/layers/BaseLayer.tsx | 11 ++- frontend/src/pages/NavBackoffice.tsx | 29 +++++++ frontend/src/router.tsx | 4 +- frontend/src/workers/constants.ts | 65 --------------- frontend/src/workers/registerServiceWorker.ts | 15 ++++ frontend/src/workers/settings.ts | 3 + frontend/src/workers/utils.ts | 78 +----------------- 11 files changed, 253 insertions(+), 194 deletions(-) rename frontend/src/{workers/__tests__/getNumberOfTiles.test.ts => features/LoadOffline/__tests__/utils.tests.ts} (63%) rename frontend/src/{pages => features/LoadOffline/components}/LoadOffline.tsx (63%) create mode 100644 frontend/src/features/LoadOffline/constants.ts create mode 100644 frontend/src/features/LoadOffline/utils.ts create mode 100644 frontend/src/pages/NavBackoffice.tsx diff --git a/frontend/src/workers/__tests__/getNumberOfTiles.test.ts b/frontend/src/features/LoadOffline/__tests__/utils.tests.ts similarity index 63% rename from frontend/src/workers/__tests__/getNumberOfTiles.test.ts rename to frontend/src/features/LoadOffline/__tests__/utils.tests.ts index e8ca32cf47..7aae074b41 100644 --- a/frontend/src/workers/__tests__/getNumberOfTiles.test.ts +++ b/frontend/src/features/LoadOffline/__tests__/utils.tests.ts @@ -1,19 +1,22 @@ import { expect } from '@jest/globals' -import { getListOfPath, getMaxXYRange } from '../utils' +import { getMaxXYRange, getZoomToRequestPaths } from '../utils' -describe('workers/utils.ts', () => { +describe('features/LoadOffline/utils.ts', () => { + /** + * This getNumberOfTiles is only used to understand the number of tiles per zoom level. + */ it('getNumberOfTiles should return the max XY of tiles for a range of zoom', () => { - const result = getMaxXYRange(10) + const result = getMaxXYRange(11) - expect(result).toStrictEqual([2, 4, 8, 16, 32, 64, 128, 256, 512, 1024]) + expect(result).toStrictEqual([2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048]) }) - it('getListOfPath should return the max XY of tiles for a range of zoom', () => { - const result = getListOfPath() + it('getListOfPath should return an array of paths corresponding to the specified tiles indices', () => { + const result = getZoomToRequestPaths() // There is 11 zoom levels: from 0 to 10 - expect(result).toHaveLength(11) + expect(result).toHaveLength(12) // Zoom 0 expect(result[0]).toHaveLength(1) @@ -35,5 +38,6 @@ describe('workers/utils.ts', () => { expect(result[8]).toHaveLength(728) expect(result[9]).toHaveLength(2805) expect(result[10]).toHaveLength(10908) + expect(result[11]).toHaveLength(41004) }) }) diff --git a/frontend/src/pages/LoadOffline.tsx b/frontend/src/features/LoadOffline/components/LoadOffline.tsx similarity index 63% rename from frontend/src/pages/LoadOffline.tsx rename to frontend/src/features/LoadOffline/components/LoadOffline.tsx index 541f9c4886..fcb9f0dad7 100644 --- a/frontend/src/pages/LoadOffline.tsx +++ b/frontend/src/features/LoadOffline/components/LoadOffline.tsx @@ -1,19 +1,18 @@ -import { Accent, Button, Icon } from '@mtes-mct/monitor-ui' -import { useEffect, useState } from 'react' +import { Accent, Button, Icon, THEME } from '@mtes-mct/monitor-ui' +import { useEffect, useMemo, useState } from 'react' import { FulfillingBouncingCircleSpinner } from 'react-epic-spinners' -import { ToastContainer } from 'react-toastify' import { Progress } from 'rsuite' import styled from 'styled-components' -import { COLORS } from '../constants/constants' -import { CACHED_REQUEST_SIZE } from '../workers/constants' -import { useGetServiceWorker } from '../workers/hooks/useGetServiceWorker' -import { registerServiceWorker } from '../workers/registerServiceWorker' -import { fetchAllByChunk, getListOfPath } from '../workers/utils' +import { CACHED_REQUEST_SIZE } from '../../../workers/constants' +import { useGetServiceWorker } from '../../../workers/hooks/useGetServiceWorker' +import { registerServiceWorker } from '../../../workers/registerServiceWorker' +import { fetchAllByChunk, getZoomToRequestPaths } from '../utils' const BYTE_TO_MEGA_BYTE_FACTOR = 0.000001 -const MAX_LOADED_REQUESTS = 14724 +const TOTAL_DOWNLOAD_REQUESTS = 55728 // Calculated using `getListOfPath()` const INTERVAL_REFRESH_MS = 5000 +const DOWNLOAD_CHUNK_SIZE = 10 export function LoadOffline() { const { serviceWorker } = useGetServiceWorker() @@ -21,7 +20,7 @@ export function LoadOffline() { const [usage, setUsage] = useState('') const [isDownloading, setIsDownloading] = useState(false) - const percent = ((cachedRequestsLength * 100) / MAX_LOADED_REQUESTS).toFixed(1) + const percent = ((cachedRequestsLength * 100) / TOTAL_DOWNLOAD_REQUESTS).toFixed(1) useEffect(() => { registerServiceWorker() @@ -53,11 +52,10 @@ export function LoadOffline() { } const downloadAll = async () => { - const zoomToPaths = getListOfPath() - const chunkSize = 10 + const zoomToPaths = getZoomToRequestPaths() setIsDownloading(true) - await fetchAllByChunk(zoomToPaths, chunkSize) + await fetchAllByChunk(zoomToPaths, DOWNLOAD_CHUNK_SIZE, cachedRequestsLength) setIsDownloading(false) } @@ -66,7 +64,7 @@ export function LoadOffline() { return undefined } - serviceWorker?.postMessage(CACHED_REQUEST_SIZE) + serviceWorker?.postMessage({ type: CACHED_REQUEST_SIZE }) const intervalId = setInterval(() => { serviceWorker?.postMessage(CACHED_REQUEST_SIZE) }, INTERVAL_REFRESH_MS) @@ -84,29 +82,39 @@ export function LoadOffline() { return 'active' } + const remainingMinutes = useMemo(() => { + const remainingRequests = (TOTAL_DOWNLOAD_REQUESTS - cachedRequestsLength) / DOWNLOAD_CHUNK_SIZE + + return (remainingRequests / 60).toFixed(1) + }, [cachedRequestsLength]) + return ( - + <> - Préchargement (mode navigation) + Préchargement

Cette page permet de télécharger les fonds de cartes et les données lourdes de MonitorFish, avant de passer sur une connexion Internet satellitaire.

- {(isDownloading || parseInt(percent, 10) > 1) && ( + {(isDownloading || parseInt(percent, 10) > 0) && ( )} - {!isDownloading && ( + {} + {!isDownloading && parseInt(percent, 10) < 100 && ( Télécharger )} - {isDownloading && } - {parseInt(percent, 10) > 95 &&

Toutes les données ont été chargées.

} + {isDownloading && ( + <> + +

{remainingMinutes} minutes restantes

+ + )} + {parseInt(percent, 10) >= 100 &&

Toutes les données ont été chargées.

}
- {cachedRequestsLength} tuiles sauvegardées (utilisation de {usage} MB) -
- -
+ {cachedRequestsLength} tuiles sauvegardées ({usage} MB) + ) } @@ -142,19 +150,3 @@ const LoadBox = styled.div` const StyledProgress = styled(Progress.Line)` margin-top: 24px; ` - -const Wrapper = styled.div` - color: white; - font-size: 13px; - text-align: center; - width: 100vw; - padding-top: 35vh; - height: 100vh; - overflow: hidden; - - background: url('landing_background.png') no-repeat center center fixed; - -webkit-background-size: cover; - -moz-background-size: cover; - -o-background-size: cover; - background-size: cover; -` diff --git a/frontend/src/features/LoadOffline/constants.ts b/frontend/src/features/LoadOffline/constants.ts new file mode 100644 index 0000000000..3ed711fd0f --- /dev/null +++ b/frontend/src/features/LoadOffline/constants.ts @@ -0,0 +1,70 @@ +/* eslint-disable sort-keys-fix/sort-keys-fix */ +/** + * This mapping was calculated manually using an online latitude/longitude coordinates to tile z/x/y coordinates converter (see below). + * The goal is to reduce the number of API calls done during the precache of map tiles. + * + * Used coordinates ([lat, lon]): + * - start (top right): [60, -018] + * - end (bottom left): [37, 018] + * + * @notice: The key is the zoom level: we cache from zoom 0 to zoom 11. + * + * @see https://developer.tomtom.com/map-display-api/documentation/zoom-levels-and-tile-grid + */ +export const ZOOM_TO_START_END_TILE_INDICES: Record< + number, + { + end: [number, number] + start: [number, number] + } +> = { + 0: { + end: [0, 0], + start: [0, 0] + }, + 1: { + end: [1, 0], + start: [0, 0] + }, + 2: { + end: [2, 1], + start: [1, 1] + }, + 3: { + end: [4, 3], + start: [3, 2] + }, + 4: { + end: [8, 6], + start: [7, 4] + }, + 5: { + end: [17, 12], + start: [14, 9] + }, + 6: { + end: [35, 24], + start: [28, 18] + }, + 7: { + end: [70, 49], + start: [57, 36] + }, + 8: { + end: [140, 99], + start: [115, 72] + }, + 9: { + end: [281, 199], + start: [231, 145] + }, + 10: { + end: [563, 398], + start: [463, 291] + }, + 11: { + end: [1126, 797], + start: [926, 594] + } +} +/* eslint-enable sort-keys-fix/sort-keys-fix */ diff --git a/frontend/src/features/LoadOffline/utils.ts b/frontend/src/features/LoadOffline/utils.ts new file mode 100644 index 0000000000..ae65eec9aa --- /dev/null +++ b/frontend/src/features/LoadOffline/utils.ts @@ -0,0 +1,82 @@ +import ky from 'ky' +import { chunk, range } from 'lodash' + +import { ZOOM_TO_START_END_TILE_INDICES } from './constants' + +/** + * This function is used to store tiles in the navigator Cache API. + * + * It fetches all paths with a sleep time every 10 chunks (to avoid timeout) + * It restart from where it has been stopped previously (thanks to the `startFromIndex` parameter) + * + * @see `fetch` event of serviceWorker.ts + */ +export async function fetchAllByChunk(zoomToPaths: string[][], chunkSize: number, startFromIndex: number) { + const subDomains = ['a', 'b', 'c', 'd'] + const waitTime = 1000 + let currentIndex = 0 + + // eslint-disable-next-line no-restricted-syntax + for (const paths of zoomToPaths) { + const chunkedPaths = chunk(paths, chunkSize) + + // eslint-disable-next-line no-restricted-syntax + for (const chunkOfPaths of chunkedPaths) { + currentIndex += chunkOfPaths.length + + // If the tiles have already been downloaded, do not retry the download + if (currentIndex < startFromIndex) { + // eslint-disable-next-line no-continue + continue + } + + const subDomain = subDomains[Math.floor(Math.random() * subDomains.length)] + + chunkOfPaths.forEach(path => ky.get(`https://${subDomain}.basemaps.cartocdn.com/light_all/${path}.png`)) + + // An await is used to reduce the number of HTTP requests send per second + // eslint-disable-next-line no-await-in-loop + await sleep(waitTime) + } + } +} + +const sleep = (ms: number) => + new Promise(r => { + setTimeout(r, ms) + }) + +/** + * @see https://developer.tomtom.com/map-display-api/documentation/zoom-levels-and-tile-grid + * @return number[] - the array index is the zoom number + */ +export function getMaxXYRange(maxZoom: number): number[] { + return range(maxZoom).map(zoom => getMaxXYAtZoom(zoom + 1)) +} + +/** + * @see https://developer.tomtom.com/map-display-api/documentation/zoom-levels-and-tile-grid + * @param zoomLevel + */ +export function getMaxXYAtZoom(zoomLevel: number) { + return Math.sqrt(2 ** zoomLevel * 2 ** zoomLevel) +} + +/** + * Return the list of path to requests: + * - The first array index is the zoom number + * - The second array is the list of paths for a given zoom + */ +export function getZoomToRequestPaths() { + return Object.keys(ZOOM_TO_START_END_TILE_INDICES) + .map(key => ZOOM_TO_START_END_TILE_INDICES[key]) + .map((value, index) => { + const zoom = index + + // We add `+ 1` as the `range()` function is not including the `end` number + const xRange = range(value.start[0], value.end[0] + 1) + const yRange = range(value.start[1], value.end[1] + 1) + + return xRange.map(x => yRange.map(y => `${zoom}/${x}/${y}`)).flat() + }) +} diff --git a/frontend/src/features/map/layers/BaseLayer.tsx b/frontend/src/features/map/layers/BaseLayer.tsx index ca898129ee..726e2687e2 100644 --- a/frontend/src/features/map/layers/BaseLayer.tsx +++ b/frontend/src/features/map/layers/BaseLayer.tsx @@ -5,6 +5,7 @@ import XYZ from 'ol/source/XYZ' import React, { useCallback, useEffect, useMemo, useRef } from 'react' import { LayerProperties } from '../../../domain/entities/layers/constants' +import { useIsInNavigationMode } from '../../../hooks/authorization/useIsInNavigationMode' import { useMainAppSelector } from '../../../hooks/useMainAppSelector' import type { ImageTile } from 'ol' @@ -14,6 +15,7 @@ export type BaseLayerProps = { map?: any } function UnmemoizedBaseLayer({ map }: BaseLayerProps) { + const isInNavigationMode = useIsInNavigationMode() const selectedBaseLayer = useMainAppSelector(state => state.map.selectedBaseLayer) const tileCacheMapRef = useRef(new Map()) @@ -56,12 +58,15 @@ function UnmemoizedBaseLayer({ map }: BaseLayerProps) { }), zIndex: 0 }), + /** + * Only the LIGHT layer is cached using the service worker, in navigation mode + */ LIGHT: () => new TileLayer({ className: LayerProperties.BASE_LAYER.code, source: new XYZ({ - maxZoom: 19, - tileLoadFunction: loadTileFromCacheOrFetch, + maxZoom: isInNavigationMode ? 11 : 19, + tileLoadFunction: isInNavigationMode ? undefined : loadTileFromCacheOrFetch, urls: ['a', 'b', 'c', 'd'].map( subdomain => `https://${subdomain}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png` ) @@ -100,7 +105,7 @@ function UnmemoizedBaseLayer({ map }: BaseLayerProps) { zIndex: 0 }) }), - [loadTileFromCacheOrFetch] + [loadTileFromCacheOrFetch, isInNavigationMode] ) useEffect(() => { diff --git a/frontend/src/pages/NavBackoffice.tsx b/frontend/src/pages/NavBackoffice.tsx new file mode 100644 index 0000000000..a70cc934c9 --- /dev/null +++ b/frontend/src/pages/NavBackoffice.tsx @@ -0,0 +1,29 @@ +import { ToastContainer } from 'react-toastify' +import styled from 'styled-components' + +import { LoadOffline } from '../features/LoadOffline/components/LoadOffline' + +export function NavBackoffice() { + return ( + + + + + ) +} + +const Wrapper = styled.div` + color: white; + font-size: 13px; + text-align: center; + width: 100vw; + padding-top: 35vh; + height: 100vh; + overflow: hidden; + + background: url('landing_background.png') no-repeat center center fixed; + -webkit-background-size: cover; + -moz-background-size: cover; + -o-background-size: cover; + background-size: cover; +` diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 0b50a39c3c..4735720fe2 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -8,7 +8,7 @@ import { MainWindow } from './features/MainWindow' import { SideWindow } from './features/SideWindow' import { BackofficePage } from './pages/BackofficePage' import { HomePage } from './pages/HomePage' -import { LoadOffline } from './pages/LoadOffline' +import { NavBackoffice } from './pages/NavBackoffice' import { NavHomePage } from './pages/NavHomePage' /* eslint-disable sort-keys-fix/sort-keys-fix */ @@ -35,7 +35,7 @@ export const routes = [ }, { path: '/load_offline', - element: + element: }, { path: '/backoffice', diff --git a/frontend/src/workers/constants.ts b/frontend/src/workers/constants.ts index 146ab6432f..6dcbb56b72 100644 --- a/frontend/src/workers/constants.ts +++ b/frontend/src/workers/constants.ts @@ -21,68 +21,3 @@ export const APPLICATION_ROUTES = [ ] export const CACHED_REQUEST_SIZE = 'CACHED_REQUEST_SIZE' - -/** - * This mapping was calculated by hand using an online latitude/longitude coordinates to tile z/x/y coordinates converter. - * The goal is to reduce the number of API calls done during precache of map tiles. - * - * Used coordinates ([lat, lon]): - * - start: [60, -018] - * - end: [37, 018] - * - * @notice: The key is the zoom level - * - * @see https://developer.tomtom.com/map-display-api/documentation/zoom-levels-and-tile-grid - */ -export const ZOOM_TO_START_END_TILE_INDICES: Record< - number, - { - end: [number, number] - start: [number, number] - } -> = { - 0: { - end: [0, 0], - start: [0, 0] - }, - 1: { - end: [1, 0], - start: [0, 0] - }, - 10: { - end: [563, 398], - start: [463, 291] - }, - 2: { - end: [2, 1], - start: [1, 1] - }, - 3: { - end: [4, 3], - start: [3, 2] - }, - 4: { - end: [8, 6], - start: [7, 4] - }, - 5: { - end: [17, 12], - start: [14, 9] - }, - 6: { - end: [35, 24], - start: [28, 18] - }, - 7: { - end: [70, 49], - start: [57, 36] - }, - 8: { - end: [140, 99], - start: [115, 72] - }, - 9: { - end: [281, 199], - start: [231, 145] - } -} diff --git a/frontend/src/workers/registerServiceWorker.ts b/frontend/src/workers/registerServiceWorker.ts index b88e7f1ae5..5c71b07d3e 100644 --- a/frontend/src/workers/registerServiceWorker.ts +++ b/frontend/src/workers/registerServiceWorker.ts @@ -10,10 +10,25 @@ import { SERVICE_WORKER_PATH } from './settings' export const registerServiceWorker = async () => { if ('serviceWorker' in navigator) { try { + let refreshing = false + + // detect controller change and refresh the page + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!refreshing) { + window.location.reload() + refreshing = true + } + }) + const registration = await navigator.serviceWorker.register(SERVICE_WORKER_PATH, { scope: '/' }) + registration.addEventListener('updatefound', () => { + // eslint-disable-next-line no-console + console.log('Service Worker update detected!') + }) + if (registration.installing) { // eslint-disable-next-line no-console console.log('Service worker installing') diff --git a/frontend/src/workers/settings.ts b/frontend/src/workers/settings.ts index 704544299b..b209c1ab45 100644 --- a/frontend/src/workers/settings.ts +++ b/frontend/src/workers/settings.ts @@ -1 +1,4 @@ +/** + * /!\ The service worker filename is specified in the package.jon's `bundle-sw` command + */ export const SERVICE_WORKER_PATH = `${process.env.PUBLIC_URL}/service-worker.js` diff --git a/frontend/src/workers/utils.ts b/frontend/src/workers/utils.ts index 4a71768d9b..568eafc627 100644 --- a/frontend/src/workers/utils.ts +++ b/frontend/src/workers/utils.ts @@ -1,13 +1,4 @@ -import ky from 'ky' -import { chunk, range } from 'lodash' - -import { - CARTOCDN_BASEMAP, - MAPBOX_BASEMAP, - OPENSTREETMAP_BASEMAP, - SHOM_BASEMAP, - ZOOM_TO_START_END_TILE_INDICES -} from './constants' +import { CARTOCDN_BASEMAP, MAPBOX_BASEMAP, OPENSTREETMAP_BASEMAP, SHOM_BASEMAP } from './constants' export const getImageCacheKey = src => { if (src.includes(CARTOCDN_BASEMAP)) { @@ -28,70 +19,3 @@ export const getImageCacheKey = src => { return '' } - -/** - * @see https://developer.tomtom.com/map-display-api/documentation/zoom-levels-and-tile-grid - * @return number[] - the array index is the zoom number - */ -export function getMaxXYRange(maxZoom: number): number[] { - return range(maxZoom).map(zoom => getMaxXYAtZoom(zoom + 1)) -} - -/** - * @see https://developer.tomtom.com/map-display-api/documentation/zoom-levels-and-tile-grid - * @param zoomLevel - */ -export function getMaxXYAtZoom(zoomLevel: number) { - return Math.sqrt(2 ** zoomLevel * 2 ** zoomLevel) -} - -/** - * Get the list of path to requests: - * - The first array index is the zoom number - * - The second array is the list of paths for a given zoom - */ -export function getListOfPath() { - return Object.keys(ZOOM_TO_START_END_TILE_INDICES) - .map(key => ZOOM_TO_START_END_TILE_INDICES[key]) - .map((value, index) => { - const zoom = index - - // We add `+ 1` as the `range()` function is not including the `end` number - const xRange = range(value.start[0], value.end[0] + 1) - const yRange = range(value.start[1], value.end[1] + 1) - - return xRange.map(x => yRange.map(y => `${zoom}/${x}/${y}`)).flat() - }) -} - -/** - * This function is used to store tiles in the navigator Cache API. - * - * @desc Fetch all paths with a sleep time every 10 chunks (to avoid timeout) - * @see `fetch` event of serviceWorker.ts - */ -export async function fetchAllByChunk(zoomToPaths: string[][], chunkSize: number) { - const subDomains = ['a', 'b', 'c', 'd'] - const waitTime = 1000 - - // eslint-disable-next-line no-restricted-syntax - for (const paths of zoomToPaths) { - const chunkedPaths = chunk(paths, chunkSize) - - // eslint-disable-next-line no-restricted-syntax - for (const chunkOfPaths of chunkedPaths) { - const subDomain = subDomains[Math.floor(Math.random() * subDomains.length)] - - chunkOfPaths.forEach(path => ky.get(`https://${subDomain}.basemaps.cartocdn.com/light_all/${path}.png`)) - - // An await is used to reduce the number of HTTP requests send per second - // eslint-disable-next-line no-await-in-loop - await sleep(waitTime) - } - } -} - -const sleep = (ms: number) => - new Promise(r => { - setTimeout(r, ms) - })