Skip to content

Commit f9a54e5

Browse files
authored
Render prediction fields in a worker, simplify loading indicator (#257)
1 parent 8cb36b6 commit f9a54e5

5 files changed

Lines changed: 265 additions & 112 deletions

File tree

src/components/__tests__/Map.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ import VuetifyNotifier from 'vuetify-notifier'
99
import MatchMediaMock from 'vitest-matchmedia-mock'
1010

1111
vi.mock('../../layers/Global-Predictions-Layer', async () => {
12-
const { default: VectorTileLayer } = await import('ol/layer/VectorTile')
12+
const { default: TileLayer } = await import('ol/layer/Tile')
1313
return {
14-
createGlobalPredictionsLayer: vi.fn(() => new VectorTileLayer()),
14+
createGlobalPredictionsLayer: vi.fn(() => ({
15+
layer: new TileLayer(),
16+
update: vi.fn(),
17+
dispose: vi.fn(),
18+
})),
1519
}
1620
})
1721
vi.mock('../../layers/Global-Overview-Layers', async () => {

src/composables/useMap.ts

Lines changed: 30 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { ref, shallowRef, watch, computed } from 'vue'
2-
import type TileSource from 'ol/source/Tile'
1+
import { ref, shallowRef, watch } from 'vue'
32
import type Map from 'ol/Map'
43
import VectorSource from 'ol/source/Vector'
54
import VectorLayer from 'ol/layer/Vector'
6-
import VectorTileLayer from 'ol/layer/VectorTile'
75
import GlTileLayer from 'ol/layer/WebGLTile.js'
86
import TileLayer from 'ol/layer/Tile'
97
import type XYZ from 'ol/source/XYZ'
@@ -17,7 +15,7 @@ import createCloudlessLayer from '../layers/S2-Cloudless-Layer'
1715
import createS2GridLayer from '../layers/S2-Grid-Layer'
1816
import {
1917
createGlobalPredictionsLayer,
20-
updateGlobalPredictionsLayer,
18+
type GlobalPredictionsController,
2119
} from '../layers/Global-Predictions-Layer'
2220
import { Fill, Stroke, Style } from 'ol/style'
2321
import { type FeatureLike } from 'ol/Feature'
@@ -29,23 +27,8 @@ import { inferenceStyle } from '../layers/color-scales'
2927

3028
let featureId = 0
3129

32-
const loadingCount = ref(0)
33-
export const isLayerLoading = computed(() => loadingCount.value > 0)
34-
35-
export function trackTileSource(source: TileSource): () => void {
36-
const onStart = () => {
37-
loadingCount.value++
38-
}
39-
const onEnd = () => {
40-
loadingCount.value = Math.max(0, loadingCount.value - 1)
41-
}
42-
source.on('tileloadstart', onStart)
43-
source.on(['tileloadend', 'tileloaderror'], onEnd)
44-
return () => {
45-
source.un('tileloadstart', onStart)
46-
source.un(['tileloadend', 'tileloaderror'], onEnd)
47-
}
48-
}
30+
const isLayerLoading = ref(false)
31+
export { isLayerLoading }
4932

5033
export interface AreaValues {
5134
min_area_km2: number
@@ -56,6 +39,15 @@ export interface AreaValues {
5639
const { settings } = useSettings()
5740

5841
export const map = shallowRef<Map | null>(null)
42+
watch(map, (newMap) => {
43+
if (!newMap) return
44+
newMap.on('loadstart', () => {
45+
isLayerLoading.value = true
46+
})
47+
newMap.on('loadend', () => {
48+
isLayerLoading.value = false
49+
})
50+
})
5951
const areaValues = ref<AreaValues>({
6052
min_area_km2: 100,
6153
max_area_km2: 500,
@@ -75,13 +67,6 @@ export const geoJsonResults = shallowRef<any[]>([])
7567
// Cloudless layer management
7668
const cloudlessLayer = shallowRef<TileLayer<XYZ> | null>(null)
7769

78-
let untrackCloudless: (() => void) | null = null
79-
watch(cloudlessLayer, (newLayer) => {
80-
untrackCloudless?.()
81-
const src = newLayer?.getSource() as TileSource | null
82-
untrackCloudless = src ? trackTileSource(src) : null
83-
})
84-
8570
// Watch for year changes and update the cloudless layer
8671
watch(
8772
() => settings.value.year,
@@ -100,14 +85,9 @@ watch(
10085
// Insert at index 0 to keep it as the base layer
10186
map.value.getLayers().insertAt(0, cloudlessLayer.value)
10287

103-
if (settings.value.mode === 'global') {
104-
if (globalPredictionsLayer.value) {
105-
map.value.removeLayer(globalPredictionsLayer.value)
106-
globalPredictionsLayer.value = null
107-
}
108-
109-
globalPredictionsLayer.value = createGlobalPredictionsLayer(settings.value)
110-
map.value.addLayer(globalPredictionsLayer.value)
88+
if (settings.value.mode === 'global' && globalPredictionsController.value) {
89+
removeGlobalPredictionsLayer()
90+
updateLayers()
11191
}
11292
},
11393
)
@@ -122,23 +102,9 @@ const initCloudlessLayer = () => {
122102

123103
// Global predictions and S2 grid layer management
124104
const s2GridLayer = shallowRef<VectorLayer<VectorSource> | null>(null)
125-
const globalPredictionsLayer = shallowRef<VectorTileLayer | null>(null)
105+
const globalPredictionsController = shallowRef<GlobalPredictionsController | null>(null)
126106
const globalOverviewLayer = shallowRef<GlTileLayer | null>(null)
127107

128-
let untrackGlobalPredictions: (() => void) | null = null
129-
watch(globalPredictionsLayer, (newLayer) => {
130-
untrackGlobalPredictions?.()
131-
const src = newLayer?.getSource() as TileSource | null
132-
untrackGlobalPredictions = src ? trackTileSource(src) : null
133-
})
134-
135-
let untrackGlobalOverview: (() => void) | null = null
136-
watch(globalOverviewLayer, (newLayer) => {
137-
untrackGlobalOverview?.()
138-
const src = newLayer?.getSource() as TileSource | null
139-
untrackGlobalOverview = src ? trackTileSource(src) : null
140-
})
141-
142108
let thresholdDebounce: ReturnType<typeof setTimeout> | null = null
143109
watch(
144110
() => settings.value.threshold,
@@ -148,13 +114,20 @@ watch(
148114
if (globalOverviewLayer.value) {
149115
updateGlobalOverviewLayer(globalOverviewLayer.value, settings.value)
150116
}
151-
if (globalPredictionsLayer.value) {
152-
updateGlobalPredictionsLayer(globalPredictionsLayer.value, settings.value)
117+
if (globalPredictionsController.value) {
118+
globalPredictionsController.value.update(settings.value)
153119
}
154120
}, 80)
155121
},
156122
)
157123

124+
const removeGlobalPredictionsLayer = () => {
125+
if (!map.value || !globalPredictionsController.value) return
126+
map.value.removeLayer(globalPredictionsController.value.layer)
127+
globalPredictionsController.value.dispose()
128+
globalPredictionsController.value = null
129+
}
130+
158131
const updateLayers = () => {
159132
if (!map.value) {
160133
return
@@ -167,10 +140,10 @@ const updateLayers = () => {
167140
}
168141

169142
// Initialize with global predictions layers
170-
if (!globalPredictionsLayer.value) {
143+
if (!globalPredictionsController.value) {
171144
// Only handle first initialization here, year changes are handled by a watcher on year above
172-
globalPredictionsLayer.value = createGlobalPredictionsLayer(settings.value)
173-
map.value.addLayer(globalPredictionsLayer.value)
145+
globalPredictionsController.value = createGlobalPredictionsLayer(settings.value)
146+
map.value.addLayer(globalPredictionsController.value.layer)
174147
}
175148
if (!globalOverviewLayer.value) {
176149
globalOverviewLayer.value = createGlobalOverviewLayer(settings.value)
@@ -184,10 +157,7 @@ const updateLayers = () => {
184157
}
185158

186159
// Remove global predictions layers if they exist
187-
if (globalPredictionsLayer.value) {
188-
map.value.removeLayer(globalPredictionsLayer.value)
189-
globalPredictionsLayer.value = null
190-
}
160+
removeGlobalPredictionsLayer()
191161
if (globalOverviewLayer.value) {
192162
map.value.removeLayer(globalOverviewLayer.value)
193163
globalOverviewLayer.value = null

src/composables/useStacLayer.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { shallowRef, watch } from 'vue'
22
import { ImageTile } from 'ol/source'
33
import TileLayer from 'ol/layer/Tile'
4-
import { map, trackTileSource } from './useMap'
4+
import { map } from './useMap'
55
import { getTileById, activeTileId, secondActiveTileId } from './useAreaOfInterest'
66
import { transformExtent } from 'ol/proj'
77
import useSettings from './useSettings'
88

99
let currentStacLayer: TileLayer<ImageTile> | null = null
10-
let untrackStac: (() => void) | null = null
1110
const stacPreviewTileId = shallowRef<string | null>(null)
1211
/** Stores the preview tile id while in global mode so it can be restored */
1312
let savedPreviewTileId: string | null = null
@@ -39,18 +38,12 @@ async function addStacLayer() {
3938
})
4039
// Add the new layer to the map
4140
map.value.addLayer(currentStacLayer)
42-
const src = currentStacLayer.getSource()
43-
if (src) {
44-
untrackStac = trackTileSource(src)
45-
}
4641
}
4742

4843
function removeStacLayer() {
4944
if (!currentStacLayer) {
5045
return
5146
}
52-
untrackStac?.()
53-
untrackStac = null
5447
map.value?.removeLayer(currentStacLayer)
5548
currentStacLayer = null
5649
}
Lines changed: 101 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,113 @@
1-
import VectorTileLayer from 'ol/layer/VectorTile'
2-
import { PMTilesVectorSource } from 'ol-pmtiles'
3-
import { Fill, Stroke, Style } from 'ol/style'
1+
import TileLayer from 'ol/layer/Tile'
2+
import ImageTileSource from 'ol/source/ImageTile'
43
import {
54
GLOBAL_DATA_PMTILES_THRESHOLD_METRIC,
65
GLOBAL_DATA_MAP_FIELD_START_ZOOM_LEVEL,
76
get_global_pmtiles_url,
87
type Settings,
98
} from '../composables/useSettings'
10-
import { confidenceColorScale, getColorForValue } from './color-scales'
119

12-
export function createGlobalPredictionsLayer(settings: Settings) {
13-
const layer = new VectorTileLayer({
14-
declutter: false,
15-
source: new PMTilesVectorSource({
16-
overlaps: false,
17-
url: get_global_pmtiles_url(settings.year),
18-
}),
19-
minZoom: GLOBAL_DATA_MAP_FIELD_START_ZOOM_LEVEL,
20-
properties: {
21-
name: `global-predictions`,
22-
},
23-
})
24-
updateGlobalPredictionsLayer(layer, settings)
25-
return layer
10+
export interface GlobalPredictionsController {
11+
layer: TileLayer<ImageTileSource>
12+
update(settings: Settings): void
13+
dispose(): void
2614
}
2715

28-
export function updateGlobalPredictionsLayer(layer: VectorTileLayer, settings: Settings) {
29-
const key = `confidence_${GLOBAL_DATA_PMTILES_THRESHOLD_METRIC}`
30-
const stroke = new Stroke({
31-
color: '',
32-
width: 1,
33-
lineCap: 'butt',
34-
lineJoin: 'miter',
35-
miterLimit: 1,
16+
export function createGlobalPredictionsLayer(settings: Settings): GlobalPredictionsController {
17+
const worker = new Worker(new URL('../workers/predictions-worker.ts', import.meta.url), {
18+
type: 'module',
19+
})
20+
21+
worker.postMessage({
22+
action: 'init',
23+
url: get_global_pmtiles_url(settings.year),
24+
threshold: settings.threshold,
25+
})
26+
27+
let revision = 0
28+
const tileQueue: Array<() => void> = []
29+
const disposeController = new AbortController()
30+
const disposeSignal = disposeController.signal
31+
32+
const source = new ImageTileSource({
33+
tileSize: 512,
34+
loader: (z, x, y, { signal }) => {
35+
return new Promise<ImageBitmap>((resolve, reject) => {
36+
if (signal.aborted) {
37+
reject(signal.reason)
38+
return
39+
}
40+
if (disposeSignal.aborted) {
41+
reject(disposeSignal.reason)
42+
return
43+
}
44+
const abandon = (reason: unknown) => {
45+
reject(reason)
46+
tileQueue.shift()
47+
tileQueue[0]?.()
48+
}
49+
const loadTile = () => {
50+
if (signal.aborted) {
51+
abandon(signal.reason)
52+
return
53+
}
54+
if (disposeSignal.aborted) {
55+
abandon(disposeSignal.reason)
56+
return
57+
}
58+
let settled = false
59+
const handleMessage = ({ data: { action, imageData } }: MessageEvent) => {
60+
if (action !== 'rendered' && action !== 'error') return
61+
if (settled) return
62+
settled = true
63+
worker.removeEventListener('message', handleMessage)
64+
if (action === 'error') {
65+
reject(new Error('Worker failed to render tile'))
66+
} else {
67+
resolve(imageData)
68+
}
69+
tileQueue.shift()
70+
tileQueue[0]?.()
71+
}
72+
const onAbort = (reason: unknown) => {
73+
if (settled) return
74+
settled = true
75+
worker.removeEventListener('message', handleMessage)
76+
abandon(reason)
77+
}
78+
signal.addEventListener('abort', () => onAbort(signal.reason), { once: true })
79+
disposeSignal.addEventListener('abort', () => onAbort(disposeSignal.reason), {
80+
once: true,
81+
})
82+
worker.addEventListener('message', handleMessage)
83+
worker.postMessage({ action: 'render', tile: [z, x, y] })
84+
}
85+
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
86+
disposeSignal.addEventListener('abort', () => reject(disposeSignal.reason), { once: true })
87+
if (tileQueue.length === 0) {
88+
loadTile()
89+
}
90+
tileQueue.push(loadTile)
91+
})
92+
},
3693
})
37-
const fill = new Fill({ color: '' })
38-
const polyStyle = new Style({ stroke, fill })
39-
const smallStyle = new Style({ stroke })
40-
layer.setStyle((feature, resolution) => {
41-
const confidence = feature.get(key)
42-
if (confidence <= settings.threshold) return undefined
43-
const strokeColor = getColorForValue(confidenceColorScale, confidence, 1)
44-
stroke.setColor(strokeColor)
45-
const extent = feature.getGeometry()!.getExtent()
46-
const widthPx = (extent[2] - extent[0]) / resolution
47-
const heightPx = (extent[3] - extent[1]) / resolution
48-
if (widthPx < 3 && heightPx < 3) {
49-
return smallStyle
50-
}
51-
fill.setColor(getColorForValue(confidenceColorScale, confidence, 0.3))
52-
return polyStyle
94+
95+
const layer = new TileLayer({
96+
source,
97+
minZoom: GLOBAL_DATA_MAP_FIELD_START_ZOOM_LEVEL,
98+
properties: { name: 'global-predictions' },
5399
})
100+
101+
return {
102+
layer,
103+
update(newSettings: Settings) {
104+
worker.postMessage({ action: 'updateThreshold', threshold: newSettings.threshold })
105+
revision++
106+
;(source as any).setKey(`${GLOBAL_DATA_PMTILES_THRESHOLD_METRIC}-${revision}`)
107+
},
108+
dispose() {
109+
disposeController.abort(new Error('Layer disposed'))
110+
worker.terminate()
111+
},
112+
}
54113
}

0 commit comments

Comments
 (0)