Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/__tests__/Map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ import VuetifyNotifier from 'vuetify-notifier'
import MatchMediaMock from 'vitest-matchmedia-mock'

vi.mock('../../layers/Global-Predictions-Layer', async () => {
const { default: VectorTileLayer } = await import('ol/layer/VectorTile')
const { default: TileLayer } = await import('ol/layer/Tile')
return {
createGlobalPredictionsLayer: vi.fn(() => new VectorTileLayer()),
createGlobalPredictionsLayer: vi.fn(() => ({
layer: new TileLayer(),
update: vi.fn(),
dispose: vi.fn(),
})),
}
})
vi.mock('../../layers/Global-Overview-Layers', async () => {
Expand Down
90 changes: 30 additions & 60 deletions src/composables/useMap.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { ref, shallowRef, watch, computed } from 'vue'
import type TileSource from 'ol/source/Tile'
import { ref, shallowRef, watch } from 'vue'
import type Map from 'ol/Map'
import VectorSource from 'ol/source/Vector'
import VectorLayer from 'ol/layer/Vector'
import VectorTileLayer from 'ol/layer/VectorTile'
import GlTileLayer from 'ol/layer/WebGLTile.js'
import TileLayer from 'ol/layer/Tile'
import type XYZ from 'ol/source/XYZ'
Expand All @@ -17,7 +15,7 @@ import createCloudlessLayer from '../layers/S2-Cloudless-Layer'
import createS2GridLayer from '../layers/S2-Grid-Layer'
import {
createGlobalPredictionsLayer,
updateGlobalPredictionsLayer,
type GlobalPredictionsController,
} from '../layers/Global-Predictions-Layer'
import { Fill, Stroke, Style } from 'ol/style'
import { type FeatureLike } from 'ol/Feature'
Expand All @@ -29,23 +27,8 @@ import { inferenceStyle } from '../layers/color-scales'

let featureId = 0

const loadingCount = ref(0)
export const isLayerLoading = computed(() => loadingCount.value > 0)

export function trackTileSource(source: TileSource): () => void {
const onStart = () => {
loadingCount.value++
}
const onEnd = () => {
loadingCount.value = Math.max(0, loadingCount.value - 1)
}
source.on('tileloadstart', onStart)
source.on(['tileloadend', 'tileloaderror'], onEnd)
return () => {
source.un('tileloadstart', onStart)
source.un(['tileloadend', 'tileloaderror'], onEnd)
}
}
const isLayerLoading = ref(false)
export { isLayerLoading }

export interface AreaValues {
min_area_km2: number
Expand All @@ -56,6 +39,15 @@ export interface AreaValues {
const { settings } = useSettings()

export const map = shallowRef<Map | null>(null)
watch(map, (newMap) => {
if (!newMap) return
newMap.on('loadstart', () => {
isLayerLoading.value = true
})
newMap.on('loadend', () => {
isLayerLoading.value = false
})
})
const areaValues = ref<AreaValues>({
min_area_km2: 100,
max_area_km2: 500,
Expand All @@ -75,13 +67,6 @@ export const geoJsonResults = shallowRef<any[]>([])
// Cloudless layer management
const cloudlessLayer = shallowRef<TileLayer<XYZ> | null>(null)

let untrackCloudless: (() => void) | null = null
watch(cloudlessLayer, (newLayer) => {
untrackCloudless?.()
const src = newLayer?.getSource() as TileSource | null
untrackCloudless = src ? trackTileSource(src) : null
})

// Watch for year changes and update the cloudless layer
watch(
() => settings.value.year,
Expand All @@ -100,14 +85,9 @@ watch(
// Insert at index 0 to keep it as the base layer
map.value.getLayers().insertAt(0, cloudlessLayer.value)

if (settings.value.mode === 'global') {
if (globalPredictionsLayer.value) {
map.value.removeLayer(globalPredictionsLayer.value)
globalPredictionsLayer.value = null
}

globalPredictionsLayer.value = createGlobalPredictionsLayer(settings.value)
map.value.addLayer(globalPredictionsLayer.value)
if (settings.value.mode === 'global' && globalPredictionsController.value) {
removeGlobalPredictionsLayer()
updateLayers()
}
},
)
Expand All @@ -122,23 +102,9 @@ const initCloudlessLayer = () => {

// Global predictions and S2 grid layer management
const s2GridLayer = shallowRef<VectorLayer<VectorSource> | null>(null)
const globalPredictionsLayer = shallowRef<VectorTileLayer | null>(null)
const globalPredictionsController = shallowRef<GlobalPredictionsController | null>(null)
const globalOverviewLayer = shallowRef<GlTileLayer | null>(null)

let untrackGlobalPredictions: (() => void) | null = null
watch(globalPredictionsLayer, (newLayer) => {
untrackGlobalPredictions?.()
const src = newLayer?.getSource() as TileSource | null
untrackGlobalPredictions = src ? trackTileSource(src) : null
})

let untrackGlobalOverview: (() => void) | null = null
watch(globalOverviewLayer, (newLayer) => {
untrackGlobalOverview?.()
const src = newLayer?.getSource() as TileSource | null
untrackGlobalOverview = src ? trackTileSource(src) : null
})

let thresholdDebounce: ReturnType<typeof setTimeout> | null = null
watch(
() => settings.value.threshold,
Expand All @@ -148,13 +114,20 @@ watch(
if (globalOverviewLayer.value) {
updateGlobalOverviewLayer(globalOverviewLayer.value, settings.value)
}
if (globalPredictionsLayer.value) {
updateGlobalPredictionsLayer(globalPredictionsLayer.value, settings.value)
if (globalPredictionsController.value) {
globalPredictionsController.value.update(settings.value)
}
}, 80)
},
)

const removeGlobalPredictionsLayer = () => {
if (!map.value || !globalPredictionsController.value) return
map.value.removeLayer(globalPredictionsController.value.layer)
globalPredictionsController.value.dispose()
globalPredictionsController.value = null
}

const updateLayers = () => {
if (!map.value) {
return
Expand All @@ -167,10 +140,10 @@ const updateLayers = () => {
}

// Initialize with global predictions layers
if (!globalPredictionsLayer.value) {
if (!globalPredictionsController.value) {
// Only handle first initialization here, year changes are handled by a watcher on year above
globalPredictionsLayer.value = createGlobalPredictionsLayer(settings.value)
map.value.addLayer(globalPredictionsLayer.value)
globalPredictionsController.value = createGlobalPredictionsLayer(settings.value)
map.value.addLayer(globalPredictionsController.value.layer)
}
if (!globalOverviewLayer.value) {
globalOverviewLayer.value = createGlobalOverviewLayer(settings.value)
Expand All @@ -184,10 +157,7 @@ const updateLayers = () => {
}

// Remove global predictions layers if they exist
if (globalPredictionsLayer.value) {
map.value.removeLayer(globalPredictionsLayer.value)
globalPredictionsLayer.value = null
}
removeGlobalPredictionsLayer()
if (globalOverviewLayer.value) {
map.value.removeLayer(globalOverviewLayer.value)
globalOverviewLayer.value = null
Expand Down
9 changes: 1 addition & 8 deletions src/composables/useStacLayer.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { shallowRef, watch } from 'vue'
import { ImageTile } from 'ol/source'
import TileLayer from 'ol/layer/Tile'
import { map, trackTileSource } from './useMap'
import { map } from './useMap'
import { getTileById, activeTileId, secondActiveTileId } from './useAreaOfInterest'
import { transformExtent } from 'ol/proj'
import useSettings from './useSettings'

let currentStacLayer: TileLayer<ImageTile> | null = null
let untrackStac: (() => void) | null = null
const stacPreviewTileId = shallowRef<string | null>(null)
/** Stores the preview tile id while in global mode so it can be restored */
let savedPreviewTileId: string | null = null
Expand Down Expand Up @@ -39,18 +38,12 @@ async function addStacLayer() {
})
// Add the new layer to the map
map.value.addLayer(currentStacLayer)
const src = currentStacLayer.getSource()
if (src) {
untrackStac = trackTileSource(src)
}
}

function removeStacLayer() {
if (!currentStacLayer) {
return
}
untrackStac?.()
untrackStac = null
map.value?.removeLayer(currentStacLayer)
currentStacLayer = null
}
Expand Down
143 changes: 101 additions & 42 deletions src/layers/Global-Predictions-Layer.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,113 @@
import VectorTileLayer from 'ol/layer/VectorTile'
import { PMTilesVectorSource } from 'ol-pmtiles'
import { Fill, Stroke, Style } from 'ol/style'
import TileLayer from 'ol/layer/Tile'
import ImageTileSource from 'ol/source/ImageTile'
import {
GLOBAL_DATA_PMTILES_THRESHOLD_METRIC,
GLOBAL_DATA_MAP_FIELD_START_ZOOM_LEVEL,
get_global_pmtiles_url,
type Settings,
} from '../composables/useSettings'
import { confidenceColorScale, getColorForValue } from './color-scales'

export function createGlobalPredictionsLayer(settings: Settings) {
const layer = new VectorTileLayer({
declutter: false,
source: new PMTilesVectorSource({
overlaps: false,
url: get_global_pmtiles_url(settings.year),
}),
minZoom: GLOBAL_DATA_MAP_FIELD_START_ZOOM_LEVEL,
properties: {
name: `global-predictions`,
},
})
updateGlobalPredictionsLayer(layer, settings)
return layer
export interface GlobalPredictionsController {
layer: TileLayer<ImageTileSource>
update(settings: Settings): void
dispose(): void
}

export function updateGlobalPredictionsLayer(layer: VectorTileLayer, settings: Settings) {
const key = `confidence_${GLOBAL_DATA_PMTILES_THRESHOLD_METRIC}`
const stroke = new Stroke({
color: '',
width: 1,
lineCap: 'butt',
lineJoin: 'miter',
miterLimit: 1,
export function createGlobalPredictionsLayer(settings: Settings): GlobalPredictionsController {
const worker = new Worker(new URL('../workers/predictions-worker.ts', import.meta.url), {
type: 'module',
})

worker.postMessage({
action: 'init',
url: get_global_pmtiles_url(settings.year),
threshold: settings.threshold,
})

let revision = 0
const tileQueue: Array<() => void> = []
const disposeController = new AbortController()
const disposeSignal = disposeController.signal

const source = new ImageTileSource({
tileSize: 512,
loader: (z, x, y, { signal }) => {
return new Promise<ImageBitmap>((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason)
return
}
if (disposeSignal.aborted) {
reject(disposeSignal.reason)
return
}
const abandon = (reason: unknown) => {
reject(reason)
tileQueue.shift()
tileQueue[0]?.()
}
const loadTile = () => {
if (signal.aborted) {
abandon(signal.reason)
return
}
if (disposeSignal.aborted) {
abandon(disposeSignal.reason)
return
}
let settled = false
const handleMessage = ({ data: { action, imageData } }: MessageEvent) => {
if (action !== 'rendered' && action !== 'error') return
if (settled) return
settled = true
worker.removeEventListener('message', handleMessage)
if (action === 'error') {
reject(new Error('Worker failed to render tile'))
} else {
resolve(imageData)
}
tileQueue.shift()
tileQueue[0]?.()
}
const onAbort = (reason: unknown) => {
if (settled) return
settled = true
worker.removeEventListener('message', handleMessage)
abandon(reason)
}
signal.addEventListener('abort', () => onAbort(signal.reason), { once: true })
disposeSignal.addEventListener('abort', () => onAbort(disposeSignal.reason), {
once: true,
})
worker.addEventListener('message', handleMessage)
worker.postMessage({ action: 'render', tile: [z, x, y] })
}
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
disposeSignal.addEventListener('abort', () => reject(disposeSignal.reason), { once: true })
if (tileQueue.length === 0) {
loadTile()
}
tileQueue.push(loadTile)
})
},
})
const fill = new Fill({ color: '' })
const polyStyle = new Style({ stroke, fill })
const smallStyle = new Style({ stroke })
layer.setStyle((feature, resolution) => {
const confidence = feature.get(key)
if (confidence <= settings.threshold) return undefined
const strokeColor = getColorForValue(confidenceColorScale, confidence, 1)
stroke.setColor(strokeColor)
const extent = feature.getGeometry()!.getExtent()
const widthPx = (extent[2] - extent[0]) / resolution
const heightPx = (extent[3] - extent[1]) / resolution
if (widthPx < 3 && heightPx < 3) {
return smallStyle
}
fill.setColor(getColorForValue(confidenceColorScale, confidence, 0.3))
return polyStyle

const layer = new TileLayer({
source,
minZoom: GLOBAL_DATA_MAP_FIELD_START_ZOOM_LEVEL,
properties: { name: 'global-predictions' },
})

return {
layer,
update(newSettings: Settings) {
worker.postMessage({ action: 'updateThreshold', threshold: newSettings.threshold })
revision++
;(source as any).setKey(`${GLOBAL_DATA_PMTILES_THRESHOLD_METRIC}-${revision}`)
},
dispose() {
disposeController.abort(new Error('Layer disposed'))
worker.terminate()
},
}
}
Loading
Loading