@fmidev/smartmet-alert-client
Version:
Web application for viewing weather and flood alerts
884 lines (873 loc) • 29.6 kB
JavaScript
import 'url-search-params-polyfill'
import { DOMParser } from '@xmldom/xmldom'
import he from 'he'
import xpath from 'xpath'
import config from './config'
import geojsonsvg from './geojsonsvg'
export default {
mixins: [config, geojsonsvg],
computed: {
NUMBER_OF_DAYS: () => 5,
REGION_LAND: () => 'land',
REGION_SEA: () => 'sea',
REGION_LAKE: () => 'lake',
WEATHER_UPDATE_TIME: () => 'weather_update_time',
FLOOD_UPDATE_TIME: () => 'flood_update_time',
UPDATE_TIME: () => 'update_time',
WEATHER_WARNINGS: () => 'weather_finland_active_all',
FLOOD_WARNINGS: () => 'flood_finland_active_all',
INFO_FI: () => 'info_fi',
INFO_SV: () => 'info_sv',
INFO_EN: () => 'info_en',
PHYSICAL_DIRECTION: () => 'physical_direction',
PHYSICAL_VALUE: () => 'physical_value',
EFFECTIVE_FROM: () => 'effective_from',
EFFECTIVE_UNTIL: () => 'effective_until',
ONSET: () => 'onset',
EXPIRES: () => 'expires',
WARNING_CONTEXT: () => 'warning_context',
SEVERITY: () => 'severity',
CONTEXT_EXTENSION: () => 'context_extension',
WIND: () => 'wind',
SEA_WIND: () => 'sea-wind',
FLOOD_LEVEL_TYPE: () => 'floodLevel',
MULTIPLE: () => 'multiple',
WARNING_LEVELS: () => ['level-1', 'level-2', 'level-3', 'level-4'],
FLOOD_LEVELS: () => ({
minor: 1,
moderate: 2,
severe: 3,
extreme: 4,
}),
strokeColor() {
return this.colors[this.theme].stroke
},
bluePaths() {
return this.paths({
type: this.REGION_SEA,
})
},
greenPaths() {
return this.paths({
type: this.REGION_LAND,
severity: 0,
})
},
yellowPaths() {
return this.paths({
type: this.REGION_LAND,
severity: 2,
})
},
orangePaths() {
return this.paths({
type: this.REGION_LAND,
severity: 3,
})
},
redPaths() {
return this.paths({
type: this.REGION_LAND,
severity: 4,
})
},
overlayPaths() {
return this.regionIds.reduce((regions, regionId) => {
if (
this.geometries[this.geometryId][regionId].pathLarge &&
(this.geometries[this.geometryId][regionId].type === 'land' ||
this.geometries[this.geometryId][regionId].subType === 'lake')
) {
const visualization = this.regionVisualization(regionId)
regions.push({
key: `${regionId}${this.size}${this.index}Overlay`,
d: visualization.visible
? visualization.geom[`path${this.size}`]
: '',
opacity: '1',
strokeWidth: this.strokeWidth,
})
}
return regions
}, [])
},
landBorders() {
return this.areaBorders('land')
},
seaBorders() {
return this.areaBorders('sea')
},
yellowCoverages() {
return this.coverageGeom(`coverages${this.size}`, 0, 1, 2)
},
orangeCoverages() {
return this.coverageGeom(`coverages${this.size}`, 0, 1, 3)
},
redCoverages() {
return this.coverageGeom(`coverages${this.size}`, 0, 1, 4)
},
overlayCoverages() {
return this.coverageGeom(
`coverages${this.size}`,
1.1 * this.strokeWidth,
0
)
},
},
methods: {
uncapitalize(value) {
if (!value) return ''
const stringValue = value.toString()
return stringValue.charAt(0).toLowerCase() + stringValue.slice(1)
},
warningType(properties) {
return this.uncapitalize(
(
properties[this.WARNING_CONTEXT] +
(properties[this.CONTEXT_EXTENSION]
? `-${properties[this.CONTEXT_EXTENSION]}`
: '')
)
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
)
},
areaBorders(area) {
return [
{
key: `border.${area}`,
d: this.geometries[this.geometryId]['borders'][area][`path${this.size}`],
opacity: '1',
strokeWidth: this.strokeWidth,
},
]
},
relativeCoverageFromReference(reference) {
if (reference == null) {
return 0
}
let paramString = ''
const urlSplit = reference.split('?')
if (urlSplit.length <= 1) {
return 0
}
paramString = urlSplit[1].split('#')[0]
const searchParams = new URLSearchParams(paramString)
const relativeCoverage = searchParams.get('c')
if (relativeCoverage == null) {
return 0
}
return Number(relativeCoverage)
},
regionFromReference(reference) {
return reference
.split(',')
.map((url) => {
let subUrl = url.substring(url.lastIndexOf('#') + 1)
// Saimaa
if (subUrl.indexOf('.') !== subUrl.lastIndexOf('.')) {
subUrl = subUrl.replace('.', '_')
}
return subUrl
})
.reduce((regionId, rawId, index, array) => {
const parts = rawId.split('.')
if (index === 0) {
// eslint-disable-next-line no-param-reassign
regionId += parts[0]
}
return regionId + (index === array.length - 1 ? '.' : '_') + parts[1]
}, '')
},
validInterval(start, end) {
return [this.toTimeZone(start), this.toTimeZone(end)]
.map(
(moment) =>
`${moment.day}.${moment.month}. ${this.twoDigits(
moment.hour
)}:${this.twoDigits(moment.minute)}`
)
.join(' – ')
},
msSinceStartOfDay(timestamp) {
const moment = this.toTimeZone(timestamp)
const ms = ((moment.hour * 60 + moment.minute) * 60 + moment.second) * 1000 + moment.millisecond
// Daylight saving time
const ref = this.toTimeZone(timestamp - ms)
if (ref.day !== moment.day) {
return ms - 60 * 60 * 1000
}
return ms + ref.hour * 60 * 60 * 1000
},
effectiveDays(start, end, dailyWarning) {
const offset = this.timeOffset
const referenceTime =
this.startFrom === 'updated' ? this.updatedAt : this.currentTime
const day = 1000 * 60 * 60 * 24
return [...Array(this.NUMBER_OF_DAYS).keys()].map((index) => {
const dayTime = referenceTime + index * day
const dayStartOffset = this.msSinceStartOfDay(dayTime)
let startOfDay = dayTime - dayStartOffset
const nextDayTime = referenceTime + (index + 1) * day
const nextDayStartOffset = this.msSinceStartOfDay(nextDayTime)
let startOfNextDay = nextDayTime - nextDayStartOffset
if (!dailyWarning) {
startOfDay = startOfDay + offset
startOfNextDay = startOfNextDay + offset
}
return (
new Date(start).getTime() < startOfNextDay &&
new Date(end).getTime() > startOfDay
)
})
},
text(properties) {
return properties[this.WARNING_CONTEXT] === this.SEA_WIND
? properties[this.PHYSICAL_VALUE]
: ''
},
createWeatherWarning(warning) {
let direction = 0
let severity = Number(warning.properties.severity.slice(-1))
switch (warning.properties[this.WARNING_CONTEXT]) {
case this.SEA_WIND:
direction = warning.properties[this.PHYSICAL_DIRECTION] - 180
if (warning.properties[this.SEVERITY] === this.WARNING_LEVELS[0]) {
severity += 1
}
break
case this.WIND:
direction = warning.properties[this.PHYSICAL_DIRECTION] - 90
break
default:
}
const regionId = this.regionFromReference(warning.properties.reference)
const type = this.warningType(warning.properties)
return {
type,
id: warning.properties.identifier,
regions: this.geometries[this.geometryId][regionId]
? {
[this.regionFromReference(warning.properties.reference)]: true,
}
: {},
covRegions: new Map(),
coveragesLarge: [],
coveragesSmall: [],
effectiveFrom: warning.properties[this.EFFECTIVE_FROM],
effectiveUntil: warning.properties[this.EFFECTIVE_UNTIL],
effectiveDays: this.effectiveDays(
warning.properties[this.EFFECTIVE_FROM],
warning.properties[this.EFFECTIVE_UNTIL],
this.dailyWarningTypes.includes(type)
),
validInterval: this.validInterval(
warning.properties[this.EFFECTIVE_FROM],
warning.properties[this.EFFECTIVE_UNTIL]
),
severity,
direction,
value: warning.properties[this.PHYSICAL_VALUE],
text: this.text(warning.properties),
info: {
fi:
warning.properties[this.INFO_FI] != null
? he.decode(warning.properties[this.INFO_FI])
: '',
sv:
warning.properties[this.INFO_SV] != null
? he.decode(warning.properties[this.INFO_SV])
: '',
en:
warning.properties[this.INFO_EN] != null
? he.decode(warning.properties[this.INFO_EN])
: '',
},
link: '',
linkText: '',
}
},
createFloodWarning(warning) {
let info = ''
try {
info = JSON.parse(
decodeURIComponent(
warning.properties.description != null
? warning.properties.description
: '[%22%22]'
).replace(/[\n|\t]/g, ' ')
)[0]
} catch (e) {
this.handleError(e.name)
}
return {
type: this.FLOOD_LEVEL_TYPE,
id: warning.properties.identifier,
regions: {
[this.regionFromReference(warning.properties.reference)]: true,
},
covRegions: new Map(),
coveragesLarge: [],
coveragesSmall: [],
effectiveFrom: warning.properties[this.ONSET],
effectiveUntil: warning.properties[this.EXPIRES],
effectiveDays: this.effectiveDays(
warning.properties[this.ONSET],
warning.properties[this.EXPIRES],
this.dailyWarningTypes.includes(this.FLOOD_LEVEL_TYPE)
),
validInterval: this.validInterval(
warning.properties[this.ONSET],
warning.properties[this.EXPIRES]
),
severity: this.FLOOD_LEVELS[warning.properties.severity.toLowerCase()],
direction: 0,
value: 0,
text: '',
info: {
[warning.properties.language.substr(0, 2).toLowerCase()]: info,
},
link: this.t('floodLink'),
linkText: this.t('floodLinkText'),
}
},
createDays(warnings) {
const updatedAtTz = this.toTimeZone(this.updatedAt)
const updatedDate =
this.updatedAt != null
? `${updatedAtTz.day}.${updatedAtTz.month}.${updatedAtTz.year}`
: ''
const updatedTime =
this.updatedAt != null
? `${this.twoDigits(updatedAtTz.hour)}:${this.twoDigits(
updatedAtTz.minute
)}`
: ''
return [...Array(this.NUMBER_OF_DAYS).keys()].map((index) => {
const referenceTime =
this.startFrom === 'updated' ? this.updatedAt : this.currentTime
const date = new Date(referenceTime)
date.setDate(date.getDate() + index)
const moment = this.toTimeZone(date)
return {
weekdayName: moment.weekday,
day: moment.day,
month: moment.month,
year: moment.year,
severity: Object.values(warnings).reduce(
(maxSeverity, warning) =>
warning.effectiveDays[index]
? Math.max(warning.severity, maxSeverity)
: maxSeverity,
0
),
updatedDate,
updatedTime,
}
})
},
getMaxSeverities(warnings) {
return Object.values(warnings).reduce((maxSeverities, warning) => {
if (
warning.effectiveDays.some((effectiveDay) => effectiveDay) &&
(maxSeverities[warning.type] == null ||
maxSeverities[warning.type] < warning.severity)
) {
// eslint-disable-next-line no-param-reassign
maxSeverities[warning.type] = warning.severity
}
return maxSeverities
}, {})
},
createLegend(severities) {
const warningKeys = Object.keys(severities)
return [4, 3, 2].reduce((orderedSeverities, severity) => {
const warningTypesBySeverity = warningKeys.filter(
(key) => severities[key] === severity
)
this.warningTypes.forEach((regionType, warningType) => {
if (warningTypesBySeverity.includes(warningType)) {
orderedSeverities.push({
type: warningType,
severity: severities[warningType],
visible: true,
})
}
})
return orderedSeverities
}, [])
},
createRegions(warnings) {
const warningKeys = Object.keys(warnings)
return [4, 3, 2].reduce(
(regionWarnings, severity) => {
const warningsBySeverity = warningKeys.filter(
(key) => warnings[key].severity === severity
)
;[...Array(this.NUMBER_OF_DAYS).keys()].forEach((day) => {
const warningsByDay = warningsBySeverity.filter(
(key) => warnings[key].effectiveDays[day]
)
this.warningTypes.forEach((regionType, warningType) => {
const warningsByType = warningsByDay.filter(
(key) => warnings[key].type === warningType
)
warningsByType.sort((key1, key2) => {
if (warnings[key1].severity !== warnings[key2].severity) {
return warnings[key2].severity - warnings[key1].severity
}
if (warnings[key1].value !== warnings[key2].value) {
return warnings[key2].value - warnings[key1].value
}
const effectiveFrom1 = new Date(
warnings[key1].effectiveFrom
).getTime()
const effectiveFrom2 = new Date(
warnings[key2].effectiveFrom
).getTime()
if (effectiveFrom1 !== effectiveFrom2) {
return effectiveFrom1 - effectiveFrom2
}
const effectiveUntil1 = new Date(
warnings[key1].effectiveUntil
).getTime()
const effectiveUntil2 = new Date(
warnings[key2].effectiveUntil
).getTime()
return effectiveUntil1 - effectiveUntil2
})
warningsByType.forEach((key) => {
this.regionIds.forEach((regionId, regionIndex) => {
if (warnings[key].regions[regionId]) {
const regionItems =
regionWarnings[day][
this.geometries[this.geometryId][regionId].type
]
let regionItem = regionItems.find(
(regionWarning) => regionWarning.key === regionId
)
if (regionItem == null) {
regionItem = {
key: regionId,
regionIndex,
name: this.geometries[this.geometryId][regionId].name,
warnings: [],
}
regionItems.push(regionItem)
}
let warningItem = regionItem.warnings.find(
(warning) => warning.type === warningType
)
if (warningItem == null) {
warningItem = {
type: warningType,
identifiers: [],
coverage: 0,
}
regionItem.warnings.push(warningItem)
}
if (!warningItem.identifiers.includes(key)) {
warningItem.identifiers.push(key)
}
const covRegions = warnings[key].covRegions
if (covRegions.has(regionId)) {
warningItem.coverage += covRegions.get(regionId)
} else {
warningItem.coverage = 1
}
}
})
})
})
})
return regionWarnings
},
[...Array(this.NUMBER_OF_DAYS).keys()].map(() => ({
[this.REGION_LAND]: [],
[this.REGION_SEA]: [],
}))
)
},
isValid(warning) {
if (warning == null || warning.properties == null) {
return false
}
const regionId = this.regionFromReference(warning.properties.reference)
if (
warning.geometry == null &&
this.geometries[this.geometryId][regionId] == null
) {
return false
}
const warningType =
warning.properties.warning_context != null
? this.warningType(warning.properties)
: 'floodLevel'
if (
this.geometries[this.geometryId][regionId] != null &&
this.warningTypes.get(warningType) !==
this.geometries[this.geometryId][regionId].type
) {
return false
}
// Valid flood warning
if (
warning.properties.severity != null &&
Object.keys(this.FLOOD_LEVELS).includes(
warning.properties.severity.toLowerCase()
)
) {
return true
}
return (
this.WARNING_LEVELS.slice(1).includes(warning.properties.severity) ||
(warning.properties[this.WARNING_CONTEXT] === this.SEA_WIND &&
this.WARNING_LEVELS.includes(warning.properties.severity))
)
},
coverageGeom(coverageProperty, strokeWidth, fillOpacity, severity) {
const coverageData = []
const visibleWarnings = this.visibleWarnings
Object.keys(this.warnings ?? {}).forEach((key) => {
if (
(severity == null || this.warnings[key].severity === severity) &&
this.warnings[key].effectiveDays[this.index] &&
visibleWarnings.includes(this.warnings[key].type) &&
this.warnings[key].coveragesLarge.length > 0
) {
if (!this.coverageWarnings.includes(key)) {
;[...this.warnings[key].covRegions.keys()].forEach((covRegion) => {
if (
this.coverageRegions[covRegion] == null ||
this.coverageRegions[covRegion] < this.warnings[key].severity
) {
this.coverageRegions[covRegion] = this.warnings[key].severity
}
})
this.coverageWarnings.push(key)
}
this.warnings[key][coverageProperty].forEach((coverage) => {
coverageData.push({
key: `${key}${this.size}${this.index}${fillOpacity}Coverage`,
d: coverage.path,
fillOpacity,
strokeWidth,
fill: this.colors[this.theme].levels[this.warnings[key].severity],
})
})
}
})
return coverageData
},
createCoverage(coverage, width, height, reference) {
const data = {
type: 'FeatureCollection',
features: [coverage, this.bbox],
totalFeatures: 2,
crs: {
type: 'name',
properties: {
name: 'urn:ogc:def:crs:EPSG::3067',
},
},
}
if (reference != null) {
data.features.push({
type: 'Feature',
id: 'reference',
properties: {},
geometry: {
type: 'Point',
coordinates: reference,
},
})
data.totalFeatures++
}
return this.geoJSONToSVG(data, width, height)
},
coverageData(coverage) {
const doc = new DOMParser().parseFromString(coverage)
const paths = xpath.select(
'//*[name()="svg"]//*[local-name()="path" and @id!="bbox"]',
doc
)
const circle = xpath.select(
'//*[name()="svg"]//*[local-name()="circle" and @id="reference"]',
doc
)
return paths.map((path, index) => ({
path: path.getAttribute('d'),
reference:
index === 0 && circle.length > 0
? [
Number(circle[0].getAttribute('cx')),
Number(circle[0].getAttribute('cy')),
]
: [],
}))
},
handleMapWarnings(data) {
const warnings = {}
const parents = {}
this.errors = []
const allUpdateTimes = [this.WEATHER_UPDATE_TIME, this.FLOOD_UPDATE_TIME]
.filter((warningUpdateTime) => data[warningUpdateTime] != null)
.reduce((updateTimes, warningUpdateTime) => {
if (
data[warningUpdateTime].features != null &&
data[warningUpdateTime].features.length > 0 &&
data[warningUpdateTime].features[0].properties != null
) {
const updateTime = new Date(
data[warningUpdateTime].features[0].properties[this.UPDATE_TIME]
).getTime()
updateTimes.push(updateTime)
if (
this.currentTime - updateTime >
this.maxUpdateDelay[warningUpdateTime]
) {
this.handleError(`${warningUpdateTime}_outdated`)
}
} else {
this.handleError(warningUpdateTime)
}
return updateTimes
}, [])
.sort()
.reverse()
this.updatedAt = allUpdateTimes.length > 0 ? allUpdateTimes[0] : null
if (!this.staticDays) {
const startTime =
this.startFrom === 'updated' ? this.updatedAt : this.currentTime
this.timeOffset = this.msSinceStartOfDay(startTime)
}
const createWarnings = {
[this.WEATHER_WARNINGS]: this.createWeatherWarning,
[this.FLOOD_WARNINGS]: this.createFloodWarning,
}
const warningTypes = Object.keys(createWarnings)
for (const warningType of warningTypes) {
let features = []
if (data[warningType] == null) {
this.handleError(`Missing data: ${warningType}`)
this.onDataError()
// eslint-disable-next-line no-continue
continue
}
features = data[warningType].features
for (const warning of features) {
if (this.isValid(warning)) {
let regionId
const regionIds = []
const warningId = warning.properties.identifier
if (warnings[warningId] == null) {
warnings[warningId] = createWarnings[warningType](warning)
const warningRegions = Object.keys(warnings[warningId].regions)
if (warningRegions.length > 0) {
regionId = warningRegions[0]
}
if (this.dailyWarningTypes.includes(warnings[warningId].type)) {
warnings[warningId].dailyWarning = true
}
} else {
regionId = this.regionFromReference(warning.properties.reference)
if (this.geometries[this.geometryId][regionId]) {
warnings[warningId].regions[regionId] = true
}
}
if (warning.properties.coverage_references != null) {
// Space after comma is needed for merged areas
warning.properties.coverage_references
.split(', ')
.filter((reference) => reference.length > 0)
.forEach((reference) => {
const refRegionId = this.regionFromReference(reference)
const regionCoverage =
this.relativeCoverageFromReference(reference) / 100
if (this.geometries[this.geometryId][refRegionId]) {
warnings[warningId].regions[refRegionId] = true
warnings[warningId].covRegions.set(
refRegionId,
regionCoverage
)
regionIds.push(refRegionId)
}
})
if (warning.geometry != null) {
const coverage = this.createCoverage(warning, 440, 550, [
warning.properties.representative_x,
warning.properties.representative_y,
])
const coverageSmall = this.createCoverage(warning, 75, 120)
warnings[warningId].coveragesLarge = this.coverageData(coverage)
warnings[warningId].coveragesSmall =
this.coverageData(coverageSmall)
}
}
if (
regionId != null &&
this.geometries[this.geometryId][regionId]
) {
this.geometries[this.geometryId][regionId].children.forEach(
(id) => {
warnings[warningId].regions[id] = true
}
)
if (regionIds.length === 0) {
regionIds.push(regionId)
}
}
regionIds.forEach((id) => {
const parentId = this.geometries[this.geometryId][id].parent
if (parentId) {
if (parents[parentId] == null) {
parents[parentId] = [false, false, false, false, false]
}
warnings[warningId].effectiveDays.forEach((override, index) => {
if (override) {
parents[parentId][index] = true
}
})
}
})
}
}
}
const days = this.createDays(warnings)
const maxSeverities = this.getMaxSeverities(warnings)
const legend = this.createLegend(maxSeverities)
const regions = this.createRegions(warnings)
return {
warnings,
days,
regions,
parents,
legend,
}
},
isClientSide() {
return typeof document !== 'undefined' && document
},
regionData(regionId) {
const regionType = this.geometries[this.geometryId][regionId].type
return this.input[regionType].find(
(regionData) => regionData.key === regionId
)
},
regionSeverity(regionId) {
const region = this.regionData(regionId)
let severity = 0
if (region != null) {
region.warnings.find((warning) => {
if (this.visibleWarnings.includes(warning.type)) {
const topIdentifier = warning.identifiers.find(
(id) =>
this.warnings[id] && this.warnings[id].covRegions.size === 0
)
if (topIdentifier != null) {
severity = this.warnings[topIdentifier].severity
return true
}
}
return false
})
}
const parentId = this.geometries[this.geometryId][regionId].parent
if (parentId) {
severity = Math.max(severity, this.regionSeverity(parentId))
}
return severity
},
regionVisualization(regionId) {
const geom = this.geometries[this.geometryId][regionId]
const severity = this.regionSeverity(regionId)
const isLand =
this.geometries[this.geometryId][regionId].type === this.REGION_LAND
const color =
severity || isLand
? this.colors[this.theme].levels[severity]
: this.colors[this.theme].sea
const visible = severity > 0 || geom.subType !== this.REGION_LAKE
return {
geom,
severity,
color,
visible,
}
},
regionsDefault() {
return [
{
land: [],
sea: [],
},
{
land: [],
sea: [],
},
{
land: [],
sea: [],
},
{
land: [],
sea: [],
},
{
land: [],
sea: [],
},
]
},
twoDigits(value) {
return `0${value}`.slice(-2)
},
toTimeZone(date) {
date = new Date(date)
const parts = new Intl.DateTimeFormat(this.dateTimeFormatLocale, {
timeZoneName: 'short',
timeZone: this.timeZone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
weekday: 'short',
hour12: false,
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
fractionalSecondDigits: 3,
}).formatToParts(date)
const whole = this.partsToWhole(parts)
whole.timeZone = this.timeZone
return whole
},
partsToWhole(parts) {
const whole = { millisecond: 0 }
parts.forEach(function (part) {
let val = part.value
switch (part.type) {
case 'literal':
return
case 'timeZoneName':
break
case 'month':
val = parseInt(val, 10)
break
case 'weekday':
break
case 'hour':
val = parseInt(val, 10) % 24
break
case 'fractionalSecond':
whole.millisecond = parseInt(val, 10)
return
default:
val = parseInt(val, 10)
}
whole[part.type] = val
})
return whole
},
},
}