Skip to content

Commit

Permalink
Refactor service worker
Browse files Browse the repository at this point in the history
  • Loading branch information
louptheron committed Sep 28, 2023
1 parent c3792f1 commit 2f9ba42
Show file tree
Hide file tree
Showing 11 changed files with 253 additions and 194 deletions.
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
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()
const [cachedRequestsLength, setCachedRequestsLength] = useState(0)
const [usage, setUsage] = useState<string>('')
const [isDownloading, setIsDownloading] = useState<boolean>(false)

const percent = ((cachedRequestsLength * 100) / MAX_LOADED_REQUESTS).toFixed(1)
const percent = ((cachedRequestsLength * 100) / TOTAL_DOWNLOAD_REQUESTS).toFixed(1)

useEffect(() => {
registerServiceWorker()
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
Expand All @@ -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 (
<Wrapper>
<>
<LoadBox>
<Title>Préchargement (mode navigation)</Title>
<Title>Préchargement</Title>
<p>
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.
</p>
{(isDownloading || parseInt(percent, 10) > 1) && (
{(isDownloading || parseInt(percent, 10) > 0) && (
<StyledProgress percent={parseFloat(percent)} status={getStatus()} strokeWidth={10} />
)}
{!isDownloading && (
{}
{!isDownloading && parseInt(percent, 10) < 100 && (
<StyledButton accent={Accent.PRIMARY} Icon={Icon.Download} onClick={downloadAll}>
Télécharger
</StyledButton>
)}
{isDownloading && <FulfillingBouncingCircleSpinner className="loader" color={COLORS.white} size={30} />}
{parseInt(percent, 10) > 95 && <p>Toutes les données ont été chargées.</p>}
{isDownloading && (
<>
<FulfillingBouncingCircleSpinner className="loader" color={THEME.color.white} size={30} />
<p>{remainingMinutes} minutes restantes</p>
</>
)}
{parseInt(percent, 10) >= 100 && <p>Toutes les données ont été chargées.</p>}
</LoadBox>
{cachedRequestsLength} tuiles sauvegardées (utilisation de {usage} MB)
<br />
<ToastContainer />
</Wrapper>
{cachedRequestsLength} tuiles sauvegardées ({usage} MB)
</>
)
}

Expand Down Expand Up @@ -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;
`
70 changes: 70 additions & 0 deletions frontend/src/features/LoadOffline/constants.ts
Original file line number Diff line number Diff line change
@@ -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 */
82 changes: 82 additions & 0 deletions frontend/src/features/LoadOffline/utils.ts
Original file line number Diff line number Diff line change
@@ -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()
})
}
11 changes: 8 additions & 3 deletions frontend/src/features/map/layers/BaseLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string, string>())
Expand Down Expand Up @@ -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`
)
Expand Down Expand Up @@ -100,7 +105,7 @@ function UnmemoizedBaseLayer({ map }: BaseLayerProps) {
zIndex: 0
})
}),
[loadTileFromCacheOrFetch]
[loadTileFromCacheOrFetch, isInNavigationMode]
)

useEffect(() => {
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/pages/NavBackoffice.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Wrapper>
<LoadOffline />
<ToastContainer />
</Wrapper>
)
}

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;
`
Loading

0 comments on commit 2f9ba42

Please sign in to comment.