@fmidev/smartmet-alert-client 4.4.19 → 4.7.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintignore +2 -14
- package/.github/workflows/test.yaml +26 -0
- package/.nvmrc +1 -0
- package/AGENTS.md +26 -0
- package/index.html +1 -1
- package/package.json +80 -22
- package/src/AlertClientVue.vue +160 -0
- package/src/App.vue +154 -296
- package/src/assets/img/ui/arrow-down.svg +4 -11
- package/src/assets/img/ui/arrow-up.svg +4 -11
- package/src/assets/img/ui/clear.svg +7 -21
- package/src/assets/img/ui/close.svg +4 -15
- package/src/assets/img/ui/toggle-selected.svg +5 -6
- package/src/assets/img/ui/toggle-unselected.svg +5 -6
- package/src/assets/img/warning/cold-weather.svg +3 -6
- package/src/assets/img/warning/flood-level-3.svg +4 -7
- package/src/assets/img/warning/forest-fire-weather.svg +2 -6
- package/src/assets/img/warning/grass-fire-weather.svg +2 -6
- package/src/assets/img/warning/hot-weather.svg +3 -6
- package/src/assets/img/warning/pedestrian-safety.svg +3 -7
- package/src/assets/img/warning/rain.svg +2 -7
- package/src/assets/img/warning/sea-icing.svg +2 -6
- package/src/assets/img/warning/sea-thunder-storm.svg +2 -5
- package/src/assets/img/warning/sea-water-height-high-water.svg +3 -8
- package/src/assets/img/warning/sea-water-height-shallow-water.svg +3 -7
- package/src/assets/img/warning/sea-wave-height.svg +4 -7
- package/src/assets/img/warning/sea-wind-legend.svg +2 -5
- package/src/assets/img/warning/sea-wind.svg +2 -5
- package/src/assets/img/warning/several.svg +2 -5
- package/src/assets/img/warning/thunder-storm.svg +2 -5
- package/src/assets/img/warning/traffic-weather.svg +2 -6
- package/src/assets/img/warning/uv-note.svg +2 -6
- package/src/assets/img/warning/wind.svg +2 -5
- package/src/components/AlertClient.vue +330 -251
- package/src/components/CollapsiblePanel.vue +281 -0
- package/src/components/DayLarge.vue +146 -110
- package/src/components/DaySmall.vue +97 -81
- package/src/components/Days.vue +229 -159
- package/src/components/DescriptionWarning.vue +63 -38
- package/src/components/GrayScaleToggle.vue +58 -54
- package/src/components/Legend.vue +102 -325
- package/src/components/MapLarge.vue +574 -351
- package/src/components/MapSmall.vue +137 -122
- package/src/components/PopupRow.vue +24 -12
- package/src/components/Region.vue +168 -118
- package/src/components/RegionWarning.vue +40 -33
- package/src/components/Regions.vue +189 -105
- package/src/components/Warning.vue +70 -45
- package/src/components/Warnings.vue +136 -72
- package/src/composables/useAlertClient.ts +360 -0
- package/src/composables/useConfig.ts +573 -0
- package/src/composables/useFields.ts +66 -0
- package/src/composables/useI18n.ts +62 -0
- package/src/composables/useKeyCodes.ts +16 -0
- package/src/composables/useMapPaths.ts +477 -0
- package/src/composables/useUtils.ts +683 -0
- package/src/composables/useWarningsProcessor.ts +1007 -0
- package/src/data/geometries.json +993 -0
- package/src/{main.js → main.ts} +1 -0
- package/src/mixins/geojsonsvg.d.ts +57 -0
- package/src/mixins/geojsonsvg.js +5 -3
- package/src/plugins/index.ts +5 -0
- package/src/scss/_utilities.scss +193 -0
- package/src/scss/constants.scss +2 -1
- package/src/scss/warningImages.scss +8 -3
- package/src/types/index.ts +509 -0
- package/src/vite-env.d.ts +23 -0
- package/src/vue.ts +41 -0
- package/svgo.config.js +45 -0
- package/tests/README.md +430 -0
- package/tests/fixtures/mockWarningData.ts +152 -0
- package/tests/integration/warning-flow.spec.ts +445 -0
- package/tests/setup.ts +41 -0
- package/tests/unit/components/AlertClient.spec.ts +701 -0
- package/tests/unit/components/DayLarge.spec.ts +348 -0
- package/tests/unit/components/DaySmall.spec.ts +352 -0
- package/tests/unit/components/Days.spec.ts +548 -0
- package/tests/unit/components/DescriptionWarning.spec.ts +385 -0
- package/tests/unit/components/GrayScaleToggle.spec.ts +318 -0
- package/tests/unit/components/Legend.spec.ts +295 -0
- package/tests/unit/components/MapLarge.spec.ts +448 -0
- package/tests/unit/components/MapSmall.spec.ts +367 -0
- package/tests/unit/components/PopupRow.spec.ts +270 -0
- package/tests/unit/components/Region.spec.ts +373 -0
- package/tests/unit/components/RegionWarning.snapshot.spec.ts +361 -0
- package/tests/unit/components/RegionWarning.spec.ts +381 -0
- package/tests/unit/components/Regions.spec.ts +503 -0
- package/tests/unit/components/Warning.snapshot.spec.ts +483 -0
- package/tests/unit/components/Warning.spec.ts +489 -0
- package/tests/unit/components/Warnings.spec.ts +343 -0
- package/tests/unit/components/__snapshots__/RegionWarning.snapshot.spec.ts.snap +41 -0
- package/tests/unit/components/__snapshots__/Warning.snapshot.spec.ts.snap +433 -0
- package/tests/unit/composables/useConfig.spec.ts +279 -0
- package/tests/unit/composables/useI18n.spec.ts +116 -0
- package/tests/unit/composables/useKeyCodes.spec.ts +27 -0
- package/tests/unit/composables/useUtils.spec.ts +213 -0
- package/tsconfig.json +43 -0
- package/tsconfig.node.json +11 -0
- package/vite.config.js +96 -26
- package/vitest.config.js +40 -0
- package/dist/favicon.ico +0 -0
- package/dist/index.dark.html +0 -20
- package/dist/index.en.html +0 -15
- package/dist/index.fi.html +0 -15
- package/dist/index.html +0 -15
- package/dist/index.js +0 -281
- package/dist/index.mjs +0 -281
- package/dist/index.mjs.map +0 -1
- package/dist/index.relative.html +0 -19
- package/dist/index.start.html +0 -20
- package/dist/index.sv.html +0 -15
- package/playwright.config.ts +0 -18
- package/public/index.relative.html +0 -19
- package/public/index.start.html +0 -20
- package/src/mixins/config.js +0 -1378
- package/src/mixins/fields.js +0 -26
- package/src/mixins/i18n.js +0 -25
- package/src/mixins/keycodes.js +0 -10
- package/src/mixins/panzoom.js +0 -900
- package/src/mixins/utils.js +0 -900
- package/src/plugins/index.js +0 -3
- package/test/snapshot.test.ts +0 -126
- package/vitest.config.ts +0 -6
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions and constants composable
|
|
3
|
+
*
|
|
4
|
+
* Provides helper functions for warning data processing, time handling,
|
|
5
|
+
* and region visualization.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { computed, type Ref, type ComputedRef } from 'vue'
|
|
9
|
+
import { DOMParser } from '@xmldom/xmldom'
|
|
10
|
+
import he from 'he'
|
|
11
|
+
import xpath from 'xpath'
|
|
12
|
+
import type {
|
|
13
|
+
Warning,
|
|
14
|
+
WarningsMap,
|
|
15
|
+
Day,
|
|
16
|
+
LegendItem,
|
|
17
|
+
RegionsData,
|
|
18
|
+
RegionType,
|
|
19
|
+
Severity,
|
|
20
|
+
GeometryCollection,
|
|
21
|
+
ThemeColorMap,
|
|
22
|
+
LocalizedText,
|
|
23
|
+
} from '@/types'
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Constants
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
export const NUMBER_OF_DAYS = 5
|
|
30
|
+
export const REGION_LAND = 'land'
|
|
31
|
+
export const REGION_SEA = 'sea'
|
|
32
|
+
export const REGION_LAKE = 'lake'
|
|
33
|
+
|
|
34
|
+
// Property keys
|
|
35
|
+
export const WEATHER_UPDATE_TIME = 'weather_update_time'
|
|
36
|
+
export const FLOOD_UPDATE_TIME = 'flood_update_time'
|
|
37
|
+
export const UPDATE_TIME = 'update_time'
|
|
38
|
+
export const WEATHER_WARNINGS = 'weather_finland_active_all'
|
|
39
|
+
export const FLOOD_WARNINGS = 'flood_finland_active_all'
|
|
40
|
+
export const INFO_FI = 'info_fi'
|
|
41
|
+
export const INFO_SV = 'info_sv'
|
|
42
|
+
export const INFO_EN = 'info_en'
|
|
43
|
+
export const PHYSICAL_DIRECTION = 'physical_direction'
|
|
44
|
+
export const PHYSICAL_VALUE = 'physical_value'
|
|
45
|
+
export const EFFECTIVE_FROM = 'effective_from'
|
|
46
|
+
export const EFFECTIVE_UNTIL = 'effective_until'
|
|
47
|
+
export const ONSET = 'onset'
|
|
48
|
+
export const EXPIRES = 'expires'
|
|
49
|
+
export const WARNING_CONTEXT = 'warning_context'
|
|
50
|
+
export const SEVERITY = 'severity'
|
|
51
|
+
export const CONTEXT_EXTENSION = 'context_extension'
|
|
52
|
+
export const WIND = 'wind'
|
|
53
|
+
export const SEA_WIND = 'sea-wind'
|
|
54
|
+
export const FLOOD_LEVEL_TYPE = 'floodLevel'
|
|
55
|
+
export const MULTIPLE = 'multiple'
|
|
56
|
+
|
|
57
|
+
export const WARNING_LEVELS = ['level-1', 'level-2', 'level-3', 'level-4']
|
|
58
|
+
export const FLOOD_LEVELS: Record<string, number> = {
|
|
59
|
+
minor: 1,
|
|
60
|
+
moderate: 2,
|
|
61
|
+
severe: 3,
|
|
62
|
+
extreme: 4,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Pure Utility Functions
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Uncapitalize first letter of a string
|
|
71
|
+
*/
|
|
72
|
+
export function uncapitalize(value: string | null | undefined): string {
|
|
73
|
+
if (!value) return ''
|
|
74
|
+
const stringValue = value.toString()
|
|
75
|
+
return stringValue.charAt(0).toLowerCase() + stringValue.slice(1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Format number with leading zero if needed
|
|
80
|
+
*/
|
|
81
|
+
export function twoDigits(value: number): string {
|
|
82
|
+
return `0${value}`.slice(-2)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if running in browser environment
|
|
87
|
+
*/
|
|
88
|
+
export function isClientSide(): boolean {
|
|
89
|
+
return typeof document !== 'undefined' && !!document
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract warning type from properties
|
|
94
|
+
*/
|
|
95
|
+
export function warningType(properties: Record<string, unknown>): string {
|
|
96
|
+
return uncapitalize(
|
|
97
|
+
(
|
|
98
|
+
(properties[WARNING_CONTEXT] as string) +
|
|
99
|
+
(properties[CONTEXT_EXTENSION] ? `-${properties[CONTEXT_EXTENSION]}` : '')
|
|
100
|
+
)
|
|
101
|
+
.split('-')
|
|
102
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
103
|
+
.join('')
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract region ID from reference URL
|
|
109
|
+
*/
|
|
110
|
+
export function regionFromReference(reference: string): string {
|
|
111
|
+
return reference
|
|
112
|
+
.split(',')
|
|
113
|
+
.map((url) => {
|
|
114
|
+
let subUrl = url.substring(url.lastIndexOf('#') + 1)
|
|
115
|
+
// Saimaa special case
|
|
116
|
+
if (subUrl.indexOf('.') !== subUrl.lastIndexOf('.')) {
|
|
117
|
+
subUrl = subUrl.replace('.', '_')
|
|
118
|
+
}
|
|
119
|
+
return subUrl
|
|
120
|
+
})
|
|
121
|
+
.reduce((regionId, rawId, index, array) => {
|
|
122
|
+
const parts = rawId.split('.')
|
|
123
|
+
if (index === 0) {
|
|
124
|
+
regionId += parts[0] ?? ''
|
|
125
|
+
}
|
|
126
|
+
return (
|
|
127
|
+
regionId + (index === array.length - 1 ? '.' : '_') + (parts[1] ?? '')
|
|
128
|
+
)
|
|
129
|
+
}, '')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Extract relative coverage from reference URL
|
|
134
|
+
*/
|
|
135
|
+
export function relativeCoverageFromReference(
|
|
136
|
+
reference: string | null | undefined
|
|
137
|
+
): number {
|
|
138
|
+
if (reference == null) {
|
|
139
|
+
return 0
|
|
140
|
+
}
|
|
141
|
+
const urlSplit = reference.split('?')
|
|
142
|
+
if (urlSplit.length <= 1) {
|
|
143
|
+
return 0
|
|
144
|
+
}
|
|
145
|
+
const paramString = (urlSplit[1] ?? '').split('#')[0] ?? ''
|
|
146
|
+
const searchParams = new URLSearchParams(paramString)
|
|
147
|
+
const relativeCoverage = searchParams.get('c')
|
|
148
|
+
if (relativeCoverage == null) {
|
|
149
|
+
return 0
|
|
150
|
+
}
|
|
151
|
+
return Number(relativeCoverage)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get warning text based on properties
|
|
156
|
+
*/
|
|
157
|
+
export function getWarningText(properties: Record<string, unknown>): string {
|
|
158
|
+
return properties[WARNING_CONTEXT] === SEA_WIND
|
|
159
|
+
? String(properties[PHYSICAL_VALUE] ?? '')
|
|
160
|
+
: ''
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Create default regions structure
|
|
165
|
+
*/
|
|
166
|
+
export function regionsDefault(): RegionsData {
|
|
167
|
+
return Array.from({ length: NUMBER_OF_DAYS }, () => ({
|
|
168
|
+
land: [],
|
|
169
|
+
sea: [],
|
|
170
|
+
}))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============================================================================
|
|
174
|
+
// Date/Time Formatting Interface
|
|
175
|
+
// ============================================================================
|
|
176
|
+
|
|
177
|
+
export interface TimeZoneMoment {
|
|
178
|
+
year: number
|
|
179
|
+
month: number
|
|
180
|
+
day: number
|
|
181
|
+
weekday: string
|
|
182
|
+
hour: number
|
|
183
|
+
minute: number
|
|
184
|
+
second: number
|
|
185
|
+
millisecond: number
|
|
186
|
+
timeZoneName?: string
|
|
187
|
+
timeZone?: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Convert DateTimeFormat parts to whole object
|
|
192
|
+
*/
|
|
193
|
+
export function partsToWhole(parts: Intl.DateTimeFormatPart[]): TimeZoneMoment {
|
|
194
|
+
const whole: TimeZoneMoment = {
|
|
195
|
+
year: 0,
|
|
196
|
+
month: 0,
|
|
197
|
+
day: 0,
|
|
198
|
+
weekday: '',
|
|
199
|
+
hour: 0,
|
|
200
|
+
minute: 0,
|
|
201
|
+
second: 0,
|
|
202
|
+
millisecond: 0,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
parts.forEach((part) => {
|
|
206
|
+
const val: string = part.value
|
|
207
|
+
const partType = part.type as string
|
|
208
|
+
switch (partType) {
|
|
209
|
+
case 'literal':
|
|
210
|
+
return
|
|
211
|
+
case 'timeZoneName':
|
|
212
|
+
whole.timeZoneName = val
|
|
213
|
+
break
|
|
214
|
+
case 'month':
|
|
215
|
+
whole.month = parseInt(val, 10)
|
|
216
|
+
break
|
|
217
|
+
case 'weekday':
|
|
218
|
+
whole.weekday = val
|
|
219
|
+
break
|
|
220
|
+
case 'hour':
|
|
221
|
+
whole.hour = parseInt(val, 10) % 24
|
|
222
|
+
break
|
|
223
|
+
case 'fractionalSecond':
|
|
224
|
+
whole.millisecond = parseInt(val, 10)
|
|
225
|
+
return
|
|
226
|
+
case 'year':
|
|
227
|
+
whole.year = parseInt(val, 10)
|
|
228
|
+
break
|
|
229
|
+
case 'day':
|
|
230
|
+
whole.day = parseInt(val, 10)
|
|
231
|
+
break
|
|
232
|
+
case 'second':
|
|
233
|
+
whole.second = parseInt(val, 10)
|
|
234
|
+
break
|
|
235
|
+
case 'minute':
|
|
236
|
+
whole.minute = parseInt(val, 10)
|
|
237
|
+
break
|
|
238
|
+
default:
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
return whole
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Convert date to timezone-specific moment
|
|
248
|
+
*/
|
|
249
|
+
export function toTimeZone(
|
|
250
|
+
date: Date | number | string,
|
|
251
|
+
timeZone: string,
|
|
252
|
+
locale: string
|
|
253
|
+
): TimeZoneMoment {
|
|
254
|
+
const dateObj = new Date(date)
|
|
255
|
+
const parts = new Intl.DateTimeFormat(locale, {
|
|
256
|
+
timeZoneName: 'short',
|
|
257
|
+
timeZone,
|
|
258
|
+
year: 'numeric',
|
|
259
|
+
month: 'numeric',
|
|
260
|
+
day: 'numeric',
|
|
261
|
+
weekday: 'short',
|
|
262
|
+
hour12: false,
|
|
263
|
+
hour: 'numeric',
|
|
264
|
+
minute: 'numeric',
|
|
265
|
+
second: 'numeric',
|
|
266
|
+
fractionalSecondDigits: 3,
|
|
267
|
+
} as Intl.DateTimeFormatOptions).formatToParts(dateObj)
|
|
268
|
+
|
|
269
|
+
const whole = partsToWhole(parts)
|
|
270
|
+
whole.timeZone = timeZone
|
|
271
|
+
return whole
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Format valid interval string
|
|
276
|
+
*/
|
|
277
|
+
export function validInterval(
|
|
278
|
+
start: string,
|
|
279
|
+
end: string,
|
|
280
|
+
timeZone: string,
|
|
281
|
+
locale: string
|
|
282
|
+
): string {
|
|
283
|
+
return [
|
|
284
|
+
toTimeZone(start, timeZone, locale),
|
|
285
|
+
toTimeZone(end, timeZone, locale),
|
|
286
|
+
]
|
|
287
|
+
.map(
|
|
288
|
+
(moment) =>
|
|
289
|
+
`${moment.day}.${moment.month}. ${twoDigits(moment.hour)}:${twoDigits(
|
|
290
|
+
moment.minute
|
|
291
|
+
)}`
|
|
292
|
+
)
|
|
293
|
+
.join(' – ')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Calculate milliseconds since start of day
|
|
298
|
+
*/
|
|
299
|
+
export function msSinceStartOfDay(
|
|
300
|
+
timestamp: number,
|
|
301
|
+
timeZone: string,
|
|
302
|
+
locale: string
|
|
303
|
+
): number {
|
|
304
|
+
const moment = toTimeZone(timestamp, timeZone, locale)
|
|
305
|
+
const ms =
|
|
306
|
+
((moment.hour * 60 + moment.minute) * 60 + moment.second) * 1000 +
|
|
307
|
+
moment.millisecond
|
|
308
|
+
// Daylight saving time adjustment
|
|
309
|
+
const ref = toTimeZone(timestamp - ms, timeZone, locale)
|
|
310
|
+
if (ref.day !== moment.day) {
|
|
311
|
+
return ms - 60 * 60 * 1000
|
|
312
|
+
}
|
|
313
|
+
return ms + ref.hour * 60 * 60 * 1000
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ============================================================================
|
|
317
|
+
// Coverage Data Parsing
|
|
318
|
+
// ============================================================================
|
|
319
|
+
|
|
320
|
+
export interface CoveragePathData {
|
|
321
|
+
path: string
|
|
322
|
+
reference: [number, number] | []
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Parse coverage data from SVG string
|
|
327
|
+
*/
|
|
328
|
+
export function coverageData(coverage: string): CoveragePathData[] {
|
|
329
|
+
const doc = new DOMParser().parseFromString(coverage, 'text/xml')
|
|
330
|
+
const paths = xpath.select(
|
|
331
|
+
'//*[name()="svg"]//*[local-name()="path" and @id!="bbox"]',
|
|
332
|
+
doc
|
|
333
|
+
) as Element[]
|
|
334
|
+
const circle = xpath.select(
|
|
335
|
+
'//*[name()="svg"]//*[local-name()="circle" and @id="reference"]',
|
|
336
|
+
doc
|
|
337
|
+
) as Element[]
|
|
338
|
+
|
|
339
|
+
return paths.map((path, index) => {
|
|
340
|
+
const firstCircle = circle[0]
|
|
341
|
+
return {
|
|
342
|
+
path: path.getAttribute('d') ?? '',
|
|
343
|
+
reference:
|
|
344
|
+
index === 0 && circle.length > 0 && firstCircle
|
|
345
|
+
? ([
|
|
346
|
+
Number(firstCircle.getAttribute('cx')),
|
|
347
|
+
Number(firstCircle.getAttribute('cy')),
|
|
348
|
+
] as [number, number])
|
|
349
|
+
: ([] as []),
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ============================================================================
|
|
355
|
+
// Warning Creation Helpers
|
|
356
|
+
// ============================================================================
|
|
357
|
+
|
|
358
|
+
export interface WarningCreationContext {
|
|
359
|
+
geometryId: string
|
|
360
|
+
geometries: GeometryCollection
|
|
361
|
+
timeZone: string
|
|
362
|
+
locale: string
|
|
363
|
+
currentTime: number
|
|
364
|
+
updatedAt: number | null
|
|
365
|
+
startFrom: string
|
|
366
|
+
staticDays: boolean
|
|
367
|
+
timeOffset: number
|
|
368
|
+
dailyWarningTypes: string[]
|
|
369
|
+
warningTypes: Map<string, RegionType>
|
|
370
|
+
t: (key: string) => string
|
|
371
|
+
handleError: (error: string) => void
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Calculate effective days for a warning
|
|
376
|
+
*/
|
|
377
|
+
export function effectiveDays(
|
|
378
|
+
start: string,
|
|
379
|
+
end: string,
|
|
380
|
+
dailyWarning: boolean,
|
|
381
|
+
context: WarningCreationContext
|
|
382
|
+
): boolean[] {
|
|
383
|
+
const { timeOffset, startFrom, updatedAt, currentTime, timeZone, locale } =
|
|
384
|
+
context
|
|
385
|
+
const referenceTime =
|
|
386
|
+
startFrom === 'updated' ? updatedAt ?? currentTime : currentTime
|
|
387
|
+
const day = 1000 * 60 * 60 * 24
|
|
388
|
+
|
|
389
|
+
return Array.from({ length: NUMBER_OF_DAYS }, (_, index) => {
|
|
390
|
+
const dayTime = referenceTime + index * day
|
|
391
|
+
const dayStartOffset = msSinceStartOfDay(dayTime, timeZone, locale)
|
|
392
|
+
let startOfDay = dayTime - dayStartOffset
|
|
393
|
+
|
|
394
|
+
const nextDayTime = referenceTime + (index + 1) * day
|
|
395
|
+
const nextDayStartOffset = msSinceStartOfDay(nextDayTime, timeZone, locale)
|
|
396
|
+
let startOfNextDay = nextDayTime - nextDayStartOffset
|
|
397
|
+
|
|
398
|
+
if (!dailyWarning) {
|
|
399
|
+
startOfDay = startOfDay + timeOffset
|
|
400
|
+
startOfNextDay = startOfNextDay + timeOffset
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
new Date(start).getTime() < startOfNextDay &&
|
|
405
|
+
new Date(end).getTime() > startOfDay
|
|
406
|
+
)
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Create weather warning from raw data
|
|
412
|
+
*/
|
|
413
|
+
export function createWeatherWarning(
|
|
414
|
+
warning: { properties: Record<string, unknown> },
|
|
415
|
+
context: WarningCreationContext
|
|
416
|
+
): Warning {
|
|
417
|
+
const { geometryId, geometries, timeZone, locale, dailyWarningTypes } =
|
|
418
|
+
context
|
|
419
|
+
let direction = 0
|
|
420
|
+
let severity = Number(
|
|
421
|
+
String(warning.properties.severity ?? '').slice(-1)
|
|
422
|
+
) as Severity
|
|
423
|
+
|
|
424
|
+
switch (warning.properties[WARNING_CONTEXT]) {
|
|
425
|
+
case SEA_WIND:
|
|
426
|
+
direction =
|
|
427
|
+
((warning.properties[PHYSICAL_DIRECTION] as number) ?? 0) - 180
|
|
428
|
+
if (warning.properties[SEVERITY] === WARNING_LEVELS[0]) {
|
|
429
|
+
severity = (severity + 1) as Severity
|
|
430
|
+
}
|
|
431
|
+
break
|
|
432
|
+
case WIND:
|
|
433
|
+
direction = ((warning.properties[PHYSICAL_DIRECTION] as number) ?? 0) - 90
|
|
434
|
+
break
|
|
435
|
+
default:
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const regionId = regionFromReference(warning.properties.reference as string)
|
|
439
|
+
const type = warningType(warning.properties)
|
|
440
|
+
const geometryData = geometries[geometryId]
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
type,
|
|
444
|
+
id: warning.properties.identifier as string,
|
|
445
|
+
regions: geometryData?.[regionId] ? { [regionId]: true } : {},
|
|
446
|
+
covRegions: new Map(),
|
|
447
|
+
coveragesLarge: [],
|
|
448
|
+
coveragesSmall: [],
|
|
449
|
+
effectiveFrom: warning.properties[EFFECTIVE_FROM] as string,
|
|
450
|
+
effectiveUntil: warning.properties[EFFECTIVE_UNTIL] as string,
|
|
451
|
+
effectiveDays: effectiveDays(
|
|
452
|
+
warning.properties[EFFECTIVE_FROM] as string,
|
|
453
|
+
warning.properties[EFFECTIVE_UNTIL] as string,
|
|
454
|
+
dailyWarningTypes.includes(type),
|
|
455
|
+
context
|
|
456
|
+
),
|
|
457
|
+
validInterval: validInterval(
|
|
458
|
+
warning.properties[EFFECTIVE_FROM] as string,
|
|
459
|
+
warning.properties[EFFECTIVE_UNTIL] as string,
|
|
460
|
+
timeZone,
|
|
461
|
+
locale
|
|
462
|
+
),
|
|
463
|
+
severity,
|
|
464
|
+
direction,
|
|
465
|
+
value: (warning.properties[PHYSICAL_VALUE] as number) ?? 0,
|
|
466
|
+
text: getWarningText(warning.properties),
|
|
467
|
+
info: {
|
|
468
|
+
fi: warning.properties[INFO_FI]
|
|
469
|
+
? he.decode(warning.properties[INFO_FI] as string)
|
|
470
|
+
: '',
|
|
471
|
+
sv: warning.properties[INFO_SV]
|
|
472
|
+
? he.decode(warning.properties[INFO_SV] as string)
|
|
473
|
+
: '',
|
|
474
|
+
en: warning.properties[INFO_EN]
|
|
475
|
+
? he.decode(warning.properties[INFO_EN] as string)
|
|
476
|
+
: '',
|
|
477
|
+
},
|
|
478
|
+
link: '',
|
|
479
|
+
linkText: '',
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Create flood warning from raw data
|
|
485
|
+
*/
|
|
486
|
+
export function createFloodWarning(
|
|
487
|
+
warning: { properties: Record<string, unknown> },
|
|
488
|
+
context: WarningCreationContext
|
|
489
|
+
): Warning {
|
|
490
|
+
const { timeZone, locale, dailyWarningTypes, t, handleError } = context
|
|
491
|
+
|
|
492
|
+
let info = ''
|
|
493
|
+
try {
|
|
494
|
+
info = JSON.parse(
|
|
495
|
+
decodeURIComponent(
|
|
496
|
+
warning.properties.description != null
|
|
497
|
+
? (warning.properties.description as string)
|
|
498
|
+
: '[%22%22]'
|
|
499
|
+
).replace(/[\n|\t]/g, ' ')
|
|
500
|
+
)[0]
|
|
501
|
+
} catch (e) {
|
|
502
|
+
handleError((e as Error).name)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const regionId = regionFromReference(warning.properties.reference as string)
|
|
506
|
+
const langKey = (warning.properties.language as string)
|
|
507
|
+
?.substring(0, 2)
|
|
508
|
+
?.toLowerCase() as keyof LocalizedText
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
type: FLOOD_LEVEL_TYPE,
|
|
512
|
+
id: warning.properties.identifier as string,
|
|
513
|
+
regions: { [regionId]: true },
|
|
514
|
+
covRegions: new Map(),
|
|
515
|
+
coveragesLarge: [],
|
|
516
|
+
coveragesSmall: [],
|
|
517
|
+
effectiveFrom: warning.properties[ONSET] as string,
|
|
518
|
+
effectiveUntil: warning.properties[EXPIRES] as string,
|
|
519
|
+
effectiveDays: effectiveDays(
|
|
520
|
+
warning.properties[ONSET] as string,
|
|
521
|
+
warning.properties[EXPIRES] as string,
|
|
522
|
+
dailyWarningTypes.includes(FLOOD_LEVEL_TYPE),
|
|
523
|
+
context
|
|
524
|
+
),
|
|
525
|
+
validInterval: validInterval(
|
|
526
|
+
warning.properties[ONSET] as string,
|
|
527
|
+
warning.properties[EXPIRES] as string,
|
|
528
|
+
timeZone,
|
|
529
|
+
locale
|
|
530
|
+
),
|
|
531
|
+
severity: (FLOOD_LEVELS[
|
|
532
|
+
(warning.properties.severity as string)?.toLowerCase()
|
|
533
|
+
] ?? 0) as Severity,
|
|
534
|
+
direction: 0,
|
|
535
|
+
value: 0,
|
|
536
|
+
text: '',
|
|
537
|
+
info: { [langKey]: info },
|
|
538
|
+
link: t('floodLink'),
|
|
539
|
+
linkText: t('floodLinkText'),
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ============================================================================
|
|
544
|
+
// Data Processing Functions
|
|
545
|
+
// ============================================================================
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Create days array from warnings
|
|
549
|
+
*/
|
|
550
|
+
export function createDays(
|
|
551
|
+
warnings: WarningsMap,
|
|
552
|
+
updatedAt: number | null,
|
|
553
|
+
currentTime: number,
|
|
554
|
+
startFrom: string,
|
|
555
|
+
timeZone: string,
|
|
556
|
+
locale: string
|
|
557
|
+
): Day[] {
|
|
558
|
+
const updatedAtTz = updatedAt ? toTimeZone(updatedAt, timeZone, locale) : null
|
|
559
|
+
const updatedDate = updatedAtTz
|
|
560
|
+
? `${updatedAtTz.day}.${updatedAtTz.month}.${updatedAtTz.year}`
|
|
561
|
+
: ''
|
|
562
|
+
const updatedTime = updatedAtTz
|
|
563
|
+
? `${twoDigits(updatedAtTz.hour)}:${twoDigits(updatedAtTz.minute)}`
|
|
564
|
+
: ''
|
|
565
|
+
|
|
566
|
+
const referenceTime =
|
|
567
|
+
startFrom === 'updated' ? updatedAt ?? currentTime : currentTime
|
|
568
|
+
|
|
569
|
+
return Array.from({ length: NUMBER_OF_DAYS }, (_, index) => {
|
|
570
|
+
const date = new Date(referenceTime)
|
|
571
|
+
date.setDate(date.getDate() + index)
|
|
572
|
+
const moment = toTimeZone(date, timeZone, locale)
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
weekdayName: moment.weekday,
|
|
576
|
+
day: moment.day,
|
|
577
|
+
month: moment.month,
|
|
578
|
+
year: moment.year,
|
|
579
|
+
severity: Object.values(warnings).reduce(
|
|
580
|
+
(maxSeverity, warning) =>
|
|
581
|
+
warning.effectiveDays[index]
|
|
582
|
+
? (Math.max(warning.severity, maxSeverity) as Severity)
|
|
583
|
+
: maxSeverity,
|
|
584
|
+
0 as Severity
|
|
585
|
+
),
|
|
586
|
+
updatedDate,
|
|
587
|
+
updatedTime,
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Get maximum severities by warning type
|
|
594
|
+
*/
|
|
595
|
+
export function getMaxSeverities(
|
|
596
|
+
warnings: WarningsMap
|
|
597
|
+
): Record<string, Severity> {
|
|
598
|
+
return Object.values(warnings).reduce(
|
|
599
|
+
(maxSeverities, warning) => {
|
|
600
|
+
const currentMax = maxSeverities[warning.type]
|
|
601
|
+
if (
|
|
602
|
+
warning.effectiveDays.some((effectiveDay) => effectiveDay) &&
|
|
603
|
+
(currentMax == null || currentMax < warning.severity)
|
|
604
|
+
) {
|
|
605
|
+
maxSeverities[warning.type] = warning.severity
|
|
606
|
+
}
|
|
607
|
+
return maxSeverities
|
|
608
|
+
},
|
|
609
|
+
{} as Record<string, Severity>
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Create legend from severities
|
|
615
|
+
*/
|
|
616
|
+
export function createLegend(
|
|
617
|
+
severities: Record<string, Severity>,
|
|
618
|
+
warningTypes: Map<string, RegionType>
|
|
619
|
+
): LegendItem[] {
|
|
620
|
+
const warningKeys = Object.keys(severities)
|
|
621
|
+
return [4, 3, 2].reduce<LegendItem[]>((orderedSeverities, severity) => {
|
|
622
|
+
const warningTypesBySeverity = warningKeys.filter(
|
|
623
|
+
(key) => severities[key] === severity
|
|
624
|
+
)
|
|
625
|
+
warningTypes.forEach((_, warnType) => {
|
|
626
|
+
if (warningTypesBySeverity.includes(warnType)) {
|
|
627
|
+
const warnSeverity = severities[warnType]
|
|
628
|
+
if (warnSeverity !== undefined) {
|
|
629
|
+
orderedSeverities.push({
|
|
630
|
+
type: warnType,
|
|
631
|
+
severity: warnSeverity,
|
|
632
|
+
visible: true,
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
})
|
|
637
|
+
return orderedSeverities
|
|
638
|
+
}, [])
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ============================================================================
|
|
642
|
+
// Composable
|
|
643
|
+
// ============================================================================
|
|
644
|
+
|
|
645
|
+
export interface UseUtilsOptions {
|
|
646
|
+
theme: Ref<string>
|
|
647
|
+
geometryId: Ref<string>
|
|
648
|
+
geometries: Ref<GeometryCollection>
|
|
649
|
+
colors: Ref<ThemeColorMap>
|
|
650
|
+
warnings: Ref<WarningsMap | null>
|
|
651
|
+
visibleWarnings: Ref<string[]>
|
|
652
|
+
index: Ref<number>
|
|
653
|
+
size: Ref<string>
|
|
654
|
+
strokeWidth: Ref<number>
|
|
655
|
+
regionIds: Ref<string[]>
|
|
656
|
+
warningTypes: Ref<Map<string, RegionType>>
|
|
657
|
+
coverageCriterion: Ref<number>
|
|
658
|
+
timeZone: Ref<string>
|
|
659
|
+
locale: Ref<string>
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export interface UseUtilsReturn {
|
|
663
|
+
strokeColor: ComputedRef<string>
|
|
664
|
+
// Add more computed values as needed
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Utils composable for reactive computed values
|
|
669
|
+
*/
|
|
670
|
+
export function useUtils(options: UseUtilsOptions): UseUtilsReturn {
|
|
671
|
+
const { theme, colors } = options
|
|
672
|
+
|
|
673
|
+
const strokeColor = computed<string>(() => {
|
|
674
|
+
return (
|
|
675
|
+
colors.value?.[theme.value as keyof ThemeColorMap]?.stroke ??
|
|
676
|
+
'DarkSlateGray'
|
|
677
|
+
)
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
strokeColor,
|
|
682
|
+
}
|
|
683
|
+
}
|