Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

migrate Spatial display #314

Merged
merged 11 commits into from
Oct 6, 2023
4,446 changes: 4,085 additions & 361 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
"@deltares/fews-pi-requests": "^0.6.3",
"@deltares/fews-ssd-webcomponent": "^0.3.0-alpha.0",
"@deltares/fews-web-oc-charts": "^3.0.0-alpha.14",
"@deltares/fews-wms-requests": "^0.1.11-alpha.4",
"@studiometa/vue-mapbox-gl": "^2.1.1",
"@turf/helpers": "^6.5.0",
"@turf/projection": "^6.5.0",
"crypto-js": "^4.1.1",
"d3": "^7.8.5",
"lodash-es": "^4.17.21",
Expand All @@ -35,7 +38,9 @@
"@types/d3": "^7.4.1",
"@types/lodash-es": "^4.17.9",
"@types/luxon": "^3.3.2",
"@types/mapbox-gl": "^2.7.10",
"@types/node": "^20.5.3",
"@types/turf": "^3.5.32",
"@vitejs/plugin-vue": "^4.2.3",
"@vue/eslint-config-typescript": "^11.0.3",
"csstype": "^3.1.2",
Expand Down
20 changes: 15 additions & 5 deletions src/components/general/DateTimeSlider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,16 @@ const doFollowNow = ref(props.doFollowNow)
let followNowIntervalTimer: ReturnType<typeof setInterval> | null = null

// Synchronise selectedDate property and local index variable.
watch(dateIndex, (index) => emit('update:selectedDate', props.dates[index]))
watch(dateIndex, (index) => {
emit('update:selectedDate', props.dates[index] ?? new Date())
})

watch(
() => props.selectedDate,
(selectedDate) => {
dateIndex.value = findIndexForDate(selectedDate)
let index = findIndexForDate(selectedDate)
if (index == dateIndex.value) return
dateIndex.value = index
},
)

Expand All @@ -109,7 +114,12 @@ watch(
},
)

const maxIndex = computed(() => Math.max(props.dates.length - 1, 0))
const maxIndex = computed(() => {
if (props.dates === undefined) {
return 0
}
return Math.max(props.dates.length - 1, 0)
})

// Now and play button styling is dependent on properties.
const nowButtonIcon = computed(() =>
Expand Down Expand Up @@ -159,8 +169,8 @@ function setDateToNow(): void {
function findIndexForDate(date: Date): number {
const index = props.dates.findIndex((current) => current >= date)
if (index === -1) {
// No time was found that was larger than the current time, so use the last date.
return maxIndex.value
// No time was found that was larger than the current time, so use the first date.
return 0
} else {
return index
}
Expand Down
219 changes: 219 additions & 0 deletions src/components/wms/AnimatedMapboxLayer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<template>
<div />
</template>

<script setup lang="ts">
import { nextTick, onMounted, Ref, watch } from 'vue'
// @ts-ignore
import { toMercator } from '@turf/projection'
import {
ImageSource,
type ImageSourceOptions,
type ImageSourceRaw,
LngLatBounds,
Map,
type RasterLayer,
} from 'mapbox-gl'
import { configManager } from '@/services/application-config'
import { useMap } from '@studiometa/vue-mapbox-gl'
import { point } from '@turf/helpers'

export interface MapboxLayerOptions {
name?: string
time?: Date
bbox?: LngLatBounds
}

interface Props {
layer?: MapboxLayerOptions
}

const props = withDefaults(defineProps<Props>(), {})

const { map } = useMap() as { map: Ref<Map> }

let newLayerId!: string
let isInitialized = false
let counter = 0
let currentLayer: string = ''

onMounted(() => {
if (map.value.isStyleLoaded()) {
addHooksToMapObject()
isInitialized = true
onLayerChange()
}
})

function getCoordsFromBounds(bounds: LngLatBounds) {
return [
bounds.getNorthWest().toArray(),
bounds.getNorthEast().toArray(),
bounds.getSouthEast().toArray(),
bounds.getSouthWest().toArray(),
]
}

function addHooksToMapObject() {
map.value.once('load', () => {
isInitialized = true
onLayerChange()
})
map.value.on('moveend', () => {
updateSource()
})
map.value.on('data', async (e) => {
if (e.sourceId === newLayerId && e.tile !== undefined && e.isSourceLoaded) {
removeOldLayers()
map.value.setPaintProperty(e.sourceId, 'raster-opacity', 1)
}
})
}

function getImageSourceOptions(): ImageSourceOptions {
if (props.layer === undefined || props.layer.time === undefined) return {}
const baseUrl = configManager.get('VITE_FEWS_WEBSERVICES_URL')
const time = props.layer.time.toISOString()
const bounds = map.value.getBounds()
const canvas = map.value.getCanvas()
const imageSourceOptions = {
url: `${baseUrl}/wms?service=WMS&request=GetMap&version=1.3&layers=${
props.layer.name
}&crs=EPSG:3857&bbox=${getMercatorBboxFromBounds(bounds)}&height=${
canvas.height
}&width=${canvas.width}&time=${time}`,
coordinates: getCoordsFromBounds(bounds),
}
return imageSourceOptions
}

function updateSource() {
const source = map.value.getSource(newLayerId) as ImageSource
source.updateImage(getImageSourceOptions())
}

function getMercatorBboxFromBounds(bounds: LngLatBounds): number[] {
const sw = toMercator(point(bounds.getSouthWest().toArray()))
const ne = toMercator(point(bounds.getNorthEast().toArray()))
return [...sw.geometry.coordinates, ...ne.geometry.coordinates]
}

function setDefaultZoom() {
if (props.layer === undefined || props.layer.bbox === undefined) return
if (map.value) {
const currentBounds = map.value.getBounds()
const bounds = props.layer.bbox
if (isBoundsWithinBounds(currentBounds, bounds)) {
return
} else {
nextTick(() => {
map.value.fitBounds(bounds)
})
}
}
}

function isBoundsWithinBounds(
innerBounds: LngLatBounds,
outerBounds: LngLatBounds,
) {
const innerNorthEast = innerBounds.getNorthEast()
const innerSouthWest = innerBounds.getSouthWest()
const outerNorthEast = outerBounds.getNorthEast()
const outerSouthWest = outerBounds.getSouthWest()

const isLngWithin =
innerSouthWest.lng >= outerSouthWest.lng &&
innerNorthEast.lng <= outerNorthEast.lng
const isLatWithin =
innerSouthWest.lat >= outerSouthWest.lat &&
innerNorthEast.lat <= outerNorthEast.lat
return isLngWithin && isLatWithin
}

function removeLayer() {
if (map.value !== undefined) {
const layerId = getFrameId(currentLayer, counter)
if (map.value.getSource(layerId) !== undefined) {
map.value.removeLayer(layerId)
map.value.removeSource(layerId)
}
}
}

function getFrameId(layerName: string, frame: number): string {
return `${layerName}-${frame}`
}

watch(
() => props.layer,
() => {
onLayerChange()
},
)

function onLayerChange(): void {
if (!isInitialized) return
if (props.layer === undefined) return
if (props.layer === null) {
removeLayer()
removeOldLayers()
return
}
if (props.layer.name === undefined || props.layer.time === undefined) {
return
}

const originalLayerName = currentLayer
if (props.layer.name !== currentLayer) {
counter += 1
removeOldLayers()
counter = 0
currentLayer = props.layer.name
}

counter += 1
newLayerId = getFrameId(props.layer.name, counter)
const source = map.value.getSource(newLayerId)
if (currentLayer !== originalLayerName) {
// set default zoom only if layer is changed
setDefaultZoom()
}

if (source === undefined) {
const rasterSource: ImageSourceRaw = {
type: 'image',
...getImageSourceOptions(),
}
map.value.addSource(newLayerId, rasterSource)
const rasterLayer: RasterLayer = {
id: newLayerId,
type: 'raster',
source: newLayerId,
paint: {
'raster-opacity': 0,
'raster-opacity-transition': {
duration: 0,
delay: 0,
},
'raster-fade-duration': 0,
},
}
map.value.addLayer(rasterLayer, 'boundary_country_outline')
}
}

function removeOldLayers(): void {
for (let i = counter - 1; i > 0; i--) {
const oldLayerId = getFrameId(currentLayer, i)
if (map.value.getLayer(oldLayerId)) {
map.value.removeLayer(oldLayerId)
map.value.removeSource(oldLayerId)
} else {
break
}
}
}
</script>

<style scoped></style>
75 changes: 75 additions & 0 deletions src/components/wms/ColourBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<template>
<div id="legend" :class="isVisible ? 'invisible' : ''">
<svg id="colourbar" width="600" height="100" style="fill: none"></svg>
</div>
</template>

<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import * as d3 from 'd3'
import * as webOcCharts from '@deltares/fews-web-oc-charts'
import { useDisplay } from 'vuetify'

interface Props {
colourMap?: webOcCharts.ColourMap
}

const props = withDefaults(defineProps<Props>(), {
colourMap: undefined,
})

const { mobile } = useDisplay()
const isVisible = ref<boolean>(true)
let group: d3.Selection<SVGGElement, unknown, HTMLElement, any>

watch(
() => props.colourMap,
() => {
updateColourBar()
},
)

watch(
mobile,
() => {
updateColourBar()
},
{
immediate: true,
},
)

onMounted(() => {
const svg = d3.select('#colourbar')
group = svg.append('g').attr('transform', 'translate(50, 50)')
updateColourBar()
})

function updateColourBar() {
if (!props.colourMap) return
if (group == undefined) return

// Remove possible previous colour map.
group.selectAll('*').remove()
// Create new colour bar and make it visible.
const options: webOcCharts.ColourBarOptions = {
type: 'nonlinear',
useGradients: true,
position: webOcCharts.AxisPosition.Bottom,
}
new webOcCharts.ColourBar(
group as any,
props.colourMap,
mobile ? 250 : 400,
30,
options,
)
isVisible.value = true
}
</script>

<style scoped>
#legend .invisible {
display: none;
}
</style>
Loading
Loading