@fmidev/smartmet-alert-client
Version:
Web application for viewing weather and flood alerts
734 lines (675 loc) • 19.7 kB
text/typescript
/**
* Utility functions and constants composable
*
* Provides helper functions for warning data processing, time handling,
* and region visualization.
*/
import { computed, type Ref, type ComputedRef } from 'vue'
import { DOMParser } from '@xmldom/xmldom'
import he from 'he'
import xpath from 'xpath'
import type {
Warning,
WarningsMap,
Day,
LegendItem,
RegionsData,
RegionType,
Severity,
GeometryCollection,
ThemeColorMap,
LocalizedText,
} from '@/types'
// ============================================================================
// Constants
// ============================================================================
export const NUMBER_OF_DAYS = 5
export const REGION_LAND = 'land'
export const REGION_SEA = 'sea'
export const REGION_LAKE = 'lake'
// Property keys
export const WEATHER_UPDATE_TIME = 'weather_update_time'
export const FLOOD_UPDATE_TIME = 'flood_update_time'
export const UPDATE_TIME = 'update_time'
export const WEATHER_WARNINGS = 'weather_finland_active_all'
export const FLOOD_WARNINGS = 'flood_finland_active_all'
export const INFO_FI = 'info_fi'
export const INFO_SV = 'info_sv'
export const INFO_EN = 'info_en'
export const PHYSICAL_DIRECTION = 'physical_direction'
export const PHYSICAL_VALUE = 'physical_value'
export const EFFECTIVE_FROM = 'effective_from'
export const EFFECTIVE_UNTIL = 'effective_until'
export const ONSET = 'onset'
export const EXPIRES = 'expires'
export const WARNING_CONTEXT = 'warning_context'
export const SEVERITY = 'severity'
export const CONTEXT_EXTENSION = 'context_extension'
export const WIND = 'wind'
export const SEA_WIND = 'sea-wind'
export const FLOOD_LEVEL_TYPE = 'floodLevel'
export const MULTIPLE = 'multiple'
export const WARNING_LEVELS = ['level-1', 'level-2', 'level-3', 'level-4']
export const FLOOD_LEVELS: Record<string, number> = {
minor: 1,
moderate: 2,
severe: 3,
extreme: 4,
}
// ============================================================================
// Pure Utility Functions
// ============================================================================
/**
* Uncapitalize first letter of a string
*/
export function uncapitalize(value: string | null | undefined): string {
if (!value) return ''
const stringValue = value.toString()
return stringValue.charAt(0).toLowerCase() + stringValue.slice(1)
}
/**
* Format number with leading zero if needed
*/
export function twoDigits(value: number): string {
return `0${value}`.slice(-2)
}
/**
* Check if running in browser environment
*/
export function isClientSide(): boolean {
return typeof document !== 'undefined' && !!document
}
/**
* Extract warning type from properties
*/
export function warningType(properties: Record<string, unknown>): string {
return uncapitalize(
(
(properties[WARNING_CONTEXT] as string) +
(properties[CONTEXT_EXTENSION] ? `-${properties[CONTEXT_EXTENSION]}` : '')
)
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
)
}
/**
* Extract region ID from reference URL
*/
export function regionFromReference(reference: string): string {
return reference
.split(',')
.map((url) => {
let subUrl = url.substring(url.lastIndexOf('#') + 1)
// Saimaa special case
if (subUrl.indexOf('.') !== subUrl.lastIndexOf('.')) {
subUrl = subUrl.replace('.', '_')
}
return subUrl
})
.reduce((regionId, rawId, index, array) => {
const parts = rawId.split('.')
if (index === 0) {
regionId += parts[0] ?? ''
}
return (
regionId + (index === array.length - 1 ? '.' : '_') + (parts[1] ?? '')
)
}, '')
}
/**
* Extract relative coverage from reference URL
*/
export function relativeCoverageFromReference(
reference: string | null | undefined
): number {
if (reference == null) {
return 0
}
const urlSplit = reference.split('?')
if (urlSplit.length <= 1) {
return 0
}
const paramString = (urlSplit[1] ?? '').split('#')[0] ?? ''
const searchParams = new URLSearchParams(paramString)
const relativeCoverage = searchParams.get('c')
if (relativeCoverage == null) {
return 0
}
return Number(relativeCoverage)
}
/**
* Get warning text based on properties
*/
export function getWarningText(properties: Record<string, unknown>): string {
return properties[WARNING_CONTEXT] === SEA_WIND
? String(properties[PHYSICAL_VALUE] ?? '')
: ''
}
/**
* Create default regions structure
*/
export function regionsDefault(): RegionsData {
return Array.from({ length: NUMBER_OF_DAYS }, () => ({
land: [],
sea: [],
}))
}
// ============================================================================
// Date/Time Formatting Interface
// ============================================================================
export interface TimeZoneMoment {
year: number
month: number
day: number
weekday: string
hour: number
minute: number
second: number
millisecond: number
timeZoneName?: string
timeZone?: string
}
/**
* Convert DateTimeFormat parts to whole object
*/
export function partsToWhole(parts: Intl.DateTimeFormatPart[]): TimeZoneMoment {
const whole: TimeZoneMoment = {
year: 0,
month: 0,
day: 0,
weekday: '',
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
}
parts.forEach((part) => {
const val: string = part.value
const partType = part.type as string
switch (partType) {
case 'literal':
return
case 'timeZoneName':
whole.timeZoneName = val
break
case 'month':
whole.month = parseInt(val, 10)
break
case 'weekday':
whole.weekday = val
break
case 'hour':
whole.hour = parseInt(val, 10) % 24
break
case 'fractionalSecond':
whole.millisecond = parseInt(val, 10)
return
case 'year':
whole.year = parseInt(val, 10)
break
case 'day':
whole.day = parseInt(val, 10)
break
case 'second':
whole.second = parseInt(val, 10)
break
case 'minute':
whole.minute = parseInt(val, 10)
break
default:
break
}
})
return whole
}
/**
* Convert date to timezone-specific moment
*/
export function toTimeZone(
date: Date | number | string,
timeZone: string,
locale: string
): TimeZoneMoment {
const dateObj = new Date(date)
const parts = new Intl.DateTimeFormat(locale, {
timeZoneName: 'short',
timeZone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
weekday: 'short',
hour12: false,
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
fractionalSecondDigits: 3,
} as Intl.DateTimeFormatOptions).formatToParts(dateObj)
const whole = partsToWhole(parts)
whole.timeZone = timeZone
return whole
}
/**
* Format a single time moment as an HTML <time> element
*/
function formatTimeMoment(
moment: TimeZoneMoment,
): string {
return `${moment.day}.${moment.month}. ${twoDigits(
moment.hour
)}:${twoDigits(moment.minute)}`
}
/**
* Format valid interval string with HTML <time> elements
*/
export function validInterval(
start: string,
end: string,
timeZone: string,
locale: string
): string {
const startMoment = toTimeZone(start, timeZone, locale)
const endMoment = toTimeZone(end, timeZone, locale)
return `${formatTimeMoment(startMoment)} – ${formatTimeMoment(
endMoment
)}`
}
/**
* Format a single time moment as an ARIA label
*/
function formatTimeMomentAriaLabel(
moment: TimeZoneMoment,
t: (key: string) => string
): string {
const monthName = t(`month${moment.month}`)
return `${moment.day}. ${monthName}${t('monthPartitive')} ${twoDigits(
moment.hour
)}:${twoDigits(moment.minute)}`
}
/**
* Format valid interval ARIA label
*/
export function validIntervalAriaLabel(
start: string,
end: string,
timeZone: string,
locale: string,
t: (key: string) => string
): string {
const startMoment = toTimeZone(start, timeZone, locale)
const endMoment = toTimeZone(end, timeZone, locale)
return `${formatTimeMomentAriaLabel(startMoment, t)} – ${formatTimeMomentAriaLabel(
endMoment,
t
)}`
}
/**
* Calculate milliseconds since start of day
*/
export function msSinceStartOfDay(
timestamp: number,
timeZone: string,
locale: string
): number {
const moment = toTimeZone(timestamp, timeZone, locale)
const ms =
((moment.hour * 60 + moment.minute) * 60 + moment.second) * 1000 +
moment.millisecond
// Daylight saving time adjustment
const ref = toTimeZone(timestamp - ms, timeZone, locale)
if (ref.day !== moment.day) {
return ms - 60 * 60 * 1000
}
return ms + ref.hour * 60 * 60 * 1000
}
// ============================================================================
// Coverage Data Parsing
// ============================================================================
export interface CoveragePathData {
path: string
reference: [number, number] | []
}
/**
* Parse coverage data from SVG string
*/
export function coverageData(coverage: string): CoveragePathData[] {
const doc = new DOMParser().parseFromString(coverage, 'text/xml')
const paths = xpath.select(
'//*[name()="svg"]//*[local-name()="path" and @id!="bbox"]',
doc
) as Element[]
const circle = xpath.select(
'//*[name()="svg"]//*[local-name()="circle" and @id="reference"]',
doc
) as Element[]
return paths.map((path, index) => {
const firstCircle = circle[0]
return {
path: path.getAttribute('d') ?? '',
reference:
index === 0 && circle.length > 0 && firstCircle
? ([
Number(firstCircle.getAttribute('cx')),
Number(firstCircle.getAttribute('cy')),
] as [number, number])
: ([] as []),
}
})
}
// ============================================================================
// Warning Creation Helpers
// ============================================================================
export interface WarningCreationContext {
geometryId: string
geometries: GeometryCollection
timeZone: string
locale: string
currentTime: number
updatedAt: number | null
startFrom: string
staticDays: boolean
timeOffset: number
dailyWarningTypes: string[]
warningTypes: Map<string, RegionType>
t: (key: string) => string
handleError: (error: string) => void
}
/**
* Calculate effective days for a warning
*/
export function effectiveDays(
start: string,
end: string,
dailyWarning: boolean,
context: WarningCreationContext
): boolean[] {
const { timeOffset, startFrom, updatedAt, currentTime, timeZone, locale } =
context
const referenceTime =
startFrom === 'updated' ? updatedAt ?? currentTime : currentTime
const day = 1000 * 60 * 60 * 24
return Array.from({ length: NUMBER_OF_DAYS }, (_, index) => {
const dayTime = referenceTime + index * day
const dayStartOffset = msSinceStartOfDay(dayTime, timeZone, locale)
let startOfDay = dayTime - dayStartOffset
const nextDayTime = referenceTime + (index + 1) * day
const nextDayStartOffset = msSinceStartOfDay(nextDayTime, timeZone, locale)
let startOfNextDay = nextDayTime - nextDayStartOffset
if (!dailyWarning) {
startOfDay = startOfDay + timeOffset
startOfNextDay = startOfNextDay + timeOffset
}
return (
new Date(start).getTime() < startOfNextDay &&
new Date(end).getTime() > startOfDay
)
})
}
/**
* Create weather warning from raw data
*/
export function createWeatherWarning(
warning: { properties: Record<string, unknown> },
context: WarningCreationContext
): Warning {
const { geometryId, geometries, timeZone, locale, dailyWarningTypes, t } =
context
let direction = 0
let severity = Number(
String(warning.properties.severity ?? '').slice(-1)
) as Severity
switch (warning.properties[WARNING_CONTEXT]) {
case SEA_WIND:
direction =
((warning.properties[PHYSICAL_DIRECTION] as number) ?? 0) - 180
if (warning.properties[SEVERITY] === WARNING_LEVELS[0]) {
severity = (severity + 1) as Severity
}
break
case WIND:
direction = ((warning.properties[PHYSICAL_DIRECTION] as number) ?? 0) - 90
break
default:
}
const regionId = regionFromReference(warning.properties.reference as string)
const type = warningType(warning.properties)
const geometryData = geometries[geometryId]
return {
type,
id: warning.properties.identifier as string,
regions: geometryData?.[regionId] ? { [regionId]: true } : {},
covRegions: new Map(),
coveragesLarge: [],
coveragesSmall: [],
effectiveFrom: warning.properties[EFFECTIVE_FROM] as string,
effectiveUntil: warning.properties[EFFECTIVE_UNTIL] as string,
effectiveDays: effectiveDays(
warning.properties[EFFECTIVE_FROM] as string,
warning.properties[EFFECTIVE_UNTIL] as string,
dailyWarningTypes.includes(type),
context
),
validInterval: validInterval(
warning.properties[EFFECTIVE_FROM] as string,
warning.properties[EFFECTIVE_UNTIL] as string,
timeZone,
locale
),
validIntervalAriaLabel: validIntervalAriaLabel(
warning.properties[EFFECTIVE_FROM] as string,
warning.properties[EFFECTIVE_UNTIL] as string,
timeZone,
locale,
t
),
severity,
direction,
value: (warning.properties[PHYSICAL_VALUE] as number) ?? 0,
text: getWarningText(warning.properties),
info: {
fi: warning.properties[INFO_FI]
? he.decode(warning.properties[INFO_FI] as string)
: '',
sv: warning.properties[INFO_SV]
? he.decode(warning.properties[INFO_SV] as string)
: '',
en: warning.properties[INFO_EN]
? he.decode(warning.properties[INFO_EN] as string)
: '',
},
link: '',
linkText: '',
}
}
/**
* Create flood warning from raw data
*/
export function createFloodWarning(
warning: { properties: Record<string, unknown> },
context: WarningCreationContext
): Warning {
const { timeZone, locale, dailyWarningTypes, t, handleError } = context
let info = ''
try {
info = JSON.parse(
decodeURIComponent(
warning.properties.description != null
? (warning.properties.description as string)
: '[%22%22]'
).replace(/[\n|\t]/g, ' ')
)[0]
} catch (e) {
handleError((e as Error).name)
}
const regionId = regionFromReference(warning.properties.reference as string)
const langKey = (warning.properties.language as string)
?.substring(0, 2)
?.toLowerCase() as keyof LocalizedText
return {
type: FLOOD_LEVEL_TYPE,
id: warning.properties.identifier as string,
regions: { [regionId]: true },
covRegions: new Map(),
coveragesLarge: [],
coveragesSmall: [],
effectiveFrom: warning.properties[ONSET] as string,
effectiveUntil: warning.properties[EXPIRES] as string,
effectiveDays: effectiveDays(
warning.properties[ONSET] as string,
warning.properties[EXPIRES] as string,
dailyWarningTypes.includes(FLOOD_LEVEL_TYPE),
context
),
validInterval: validInterval(
warning.properties[ONSET] as string,
warning.properties[EXPIRES] as string,
timeZone,
locale
),
validIntervalAriaLabel: validIntervalAriaLabel(
warning.properties[ONSET] as string,
warning.properties[EXPIRES] as string,
timeZone,
locale,
t
),
severity: (FLOOD_LEVELS[
(warning.properties.severity as string)?.toLowerCase()
] ?? 0) as Severity,
direction: 0,
value: 0,
text: '',
info: { [langKey]: info },
link: t('floodLink'),
linkText: t('floodLinkText'),
}
}
// ============================================================================
// Data Processing Functions
// ============================================================================
/**
* Create days array from warnings
*/
export function createDays(
warnings: WarningsMap,
updatedAt: number | null,
currentTime: number,
startFrom: string,
timeZone: string,
locale: string
): Day[] {
const updatedAtTz = updatedAt ? toTimeZone(updatedAt, timeZone, locale) : null
const updatedDate = updatedAtTz
? `${updatedAtTz.day}.${updatedAtTz.month}.${updatedAtTz.year}`
: ''
const updatedTime = updatedAtTz
? `${twoDigits(updatedAtTz.hour)}:${twoDigits(updatedAtTz.minute)}`
: ''
const referenceTime =
startFrom === 'updated' ? updatedAt ?? currentTime : currentTime
return Array.from({ length: NUMBER_OF_DAYS }, (_, index) => {
const date = new Date(referenceTime)
date.setDate(date.getDate() + index)
const moment = toTimeZone(date, timeZone, locale)
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) as Severity)
: maxSeverity,
0 as Severity
),
updatedDate,
updatedTime,
}
})
}
/**
* Get maximum severities by warning type
*/
export function getMaxSeverities(
warnings: WarningsMap
): Record<string, Severity> {
return Object.values(warnings).reduce(
(maxSeverities, warning) => {
const currentMax = maxSeverities[warning.type]
if (
warning.effectiveDays.some((effectiveDay) => effectiveDay) &&
(currentMax == null || currentMax < warning.severity)
) {
maxSeverities[warning.type] = warning.severity
}
return maxSeverities
},
{} as Record<string, Severity>
)
}
/**
* Create legend from severities
*/
export function createLegend(
severities: Record<string, Severity>,
warningTypes: Map<string, RegionType>
): LegendItem[] {
const warningKeys = Object.keys(severities)
return [4, 3, 2].reduce<LegendItem[]>((orderedSeverities, severity) => {
const warningTypesBySeverity = warningKeys.filter(
(key) => severities[key] === severity
)
warningTypes.forEach((_, warnType) => {
if (warningTypesBySeverity.includes(warnType)) {
const warnSeverity = severities[warnType]
if (warnSeverity !== undefined) {
orderedSeverities.push({
type: warnType,
severity: warnSeverity,
visible: true,
})
}
}
})
return orderedSeverities
}, [])
}
// ============================================================================
// Composable
// ============================================================================
export interface UseUtilsOptions {
theme: Ref<string>
geometryId: Ref<string>
geometries: Ref<GeometryCollection>
colors: Ref<ThemeColorMap>
warnings: Ref<WarningsMap | null>
visibleWarnings: Ref<string[]>
index: Ref<number>
size: Ref<string>
strokeWidth: Ref<number>
regionIds: Ref<string[]>
warningTypes: Ref<Map<string, RegionType>>
coverageCriterion: Ref<number>
timeZone: Ref<string>
locale: Ref<string>
}
export interface UseUtilsReturn {
strokeColor: ComputedRef<string>
// Add more computed values as needed
}
/**
* Utils composable for reactive computed values
*/
export function useUtils(options: UseUtilsOptions): UseUtilsReturn {
const { theme, colors } = options
const strokeColor = computed<string>(() => {
return (
colors.value?.[theme.value as keyof ThemeColorMap]?.stroke ??
'DarkSlateGray'
)
})
return {
strokeColor,
}
}