@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.
Files changed (123) hide show
  1. package/.eslintignore +2 -14
  2. package/.github/workflows/test.yaml +26 -0
  3. package/.nvmrc +1 -0
  4. package/AGENTS.md +26 -0
  5. package/index.html +1 -1
  6. package/package.json +80 -22
  7. package/src/AlertClientVue.vue +160 -0
  8. package/src/App.vue +154 -296
  9. package/src/assets/img/ui/arrow-down.svg +4 -11
  10. package/src/assets/img/ui/arrow-up.svg +4 -11
  11. package/src/assets/img/ui/clear.svg +7 -21
  12. package/src/assets/img/ui/close.svg +4 -15
  13. package/src/assets/img/ui/toggle-selected.svg +5 -6
  14. package/src/assets/img/ui/toggle-unselected.svg +5 -6
  15. package/src/assets/img/warning/cold-weather.svg +3 -6
  16. package/src/assets/img/warning/flood-level-3.svg +4 -7
  17. package/src/assets/img/warning/forest-fire-weather.svg +2 -6
  18. package/src/assets/img/warning/grass-fire-weather.svg +2 -6
  19. package/src/assets/img/warning/hot-weather.svg +3 -6
  20. package/src/assets/img/warning/pedestrian-safety.svg +3 -7
  21. package/src/assets/img/warning/rain.svg +2 -7
  22. package/src/assets/img/warning/sea-icing.svg +2 -6
  23. package/src/assets/img/warning/sea-thunder-storm.svg +2 -5
  24. package/src/assets/img/warning/sea-water-height-high-water.svg +3 -8
  25. package/src/assets/img/warning/sea-water-height-shallow-water.svg +3 -7
  26. package/src/assets/img/warning/sea-wave-height.svg +4 -7
  27. package/src/assets/img/warning/sea-wind-legend.svg +2 -5
  28. package/src/assets/img/warning/sea-wind.svg +2 -5
  29. package/src/assets/img/warning/several.svg +2 -5
  30. package/src/assets/img/warning/thunder-storm.svg +2 -5
  31. package/src/assets/img/warning/traffic-weather.svg +2 -6
  32. package/src/assets/img/warning/uv-note.svg +2 -6
  33. package/src/assets/img/warning/wind.svg +2 -5
  34. package/src/components/AlertClient.vue +330 -251
  35. package/src/components/CollapsiblePanel.vue +281 -0
  36. package/src/components/DayLarge.vue +146 -110
  37. package/src/components/DaySmall.vue +97 -81
  38. package/src/components/Days.vue +229 -159
  39. package/src/components/DescriptionWarning.vue +63 -38
  40. package/src/components/GrayScaleToggle.vue +58 -54
  41. package/src/components/Legend.vue +102 -325
  42. package/src/components/MapLarge.vue +574 -351
  43. package/src/components/MapSmall.vue +137 -122
  44. package/src/components/PopupRow.vue +24 -12
  45. package/src/components/Region.vue +168 -118
  46. package/src/components/RegionWarning.vue +40 -33
  47. package/src/components/Regions.vue +189 -105
  48. package/src/components/Warning.vue +70 -45
  49. package/src/components/Warnings.vue +136 -72
  50. package/src/composables/useAlertClient.ts +360 -0
  51. package/src/composables/useConfig.ts +573 -0
  52. package/src/composables/useFields.ts +66 -0
  53. package/src/composables/useI18n.ts +62 -0
  54. package/src/composables/useKeyCodes.ts +16 -0
  55. package/src/composables/useMapPaths.ts +477 -0
  56. package/src/composables/useUtils.ts +683 -0
  57. package/src/composables/useWarningsProcessor.ts +1007 -0
  58. package/src/data/geometries.json +993 -0
  59. package/src/{main.js → main.ts} +1 -0
  60. package/src/mixins/geojsonsvg.d.ts +57 -0
  61. package/src/mixins/geojsonsvg.js +5 -3
  62. package/src/plugins/index.ts +5 -0
  63. package/src/scss/_utilities.scss +193 -0
  64. package/src/scss/constants.scss +2 -1
  65. package/src/scss/warningImages.scss +8 -3
  66. package/src/types/index.ts +509 -0
  67. package/src/vite-env.d.ts +23 -0
  68. package/src/vue.ts +41 -0
  69. package/svgo.config.js +45 -0
  70. package/tests/README.md +430 -0
  71. package/tests/fixtures/mockWarningData.ts +152 -0
  72. package/tests/integration/warning-flow.spec.ts +445 -0
  73. package/tests/setup.ts +41 -0
  74. package/tests/unit/components/AlertClient.spec.ts +701 -0
  75. package/tests/unit/components/DayLarge.spec.ts +348 -0
  76. package/tests/unit/components/DaySmall.spec.ts +352 -0
  77. package/tests/unit/components/Days.spec.ts +548 -0
  78. package/tests/unit/components/DescriptionWarning.spec.ts +385 -0
  79. package/tests/unit/components/GrayScaleToggle.spec.ts +318 -0
  80. package/tests/unit/components/Legend.spec.ts +295 -0
  81. package/tests/unit/components/MapLarge.spec.ts +448 -0
  82. package/tests/unit/components/MapSmall.spec.ts +367 -0
  83. package/tests/unit/components/PopupRow.spec.ts +270 -0
  84. package/tests/unit/components/Region.spec.ts +373 -0
  85. package/tests/unit/components/RegionWarning.snapshot.spec.ts +361 -0
  86. package/tests/unit/components/RegionWarning.spec.ts +381 -0
  87. package/tests/unit/components/Regions.spec.ts +503 -0
  88. package/tests/unit/components/Warning.snapshot.spec.ts +483 -0
  89. package/tests/unit/components/Warning.spec.ts +489 -0
  90. package/tests/unit/components/Warnings.spec.ts +343 -0
  91. package/tests/unit/components/__snapshots__/RegionWarning.snapshot.spec.ts.snap +41 -0
  92. package/tests/unit/components/__snapshots__/Warning.snapshot.spec.ts.snap +433 -0
  93. package/tests/unit/composables/useConfig.spec.ts +279 -0
  94. package/tests/unit/composables/useI18n.spec.ts +116 -0
  95. package/tests/unit/composables/useKeyCodes.spec.ts +27 -0
  96. package/tests/unit/composables/useUtils.spec.ts +213 -0
  97. package/tsconfig.json +43 -0
  98. package/tsconfig.node.json +11 -0
  99. package/vite.config.js +96 -26
  100. package/vitest.config.js +40 -0
  101. package/dist/favicon.ico +0 -0
  102. package/dist/index.dark.html +0 -20
  103. package/dist/index.en.html +0 -15
  104. package/dist/index.fi.html +0 -15
  105. package/dist/index.html +0 -15
  106. package/dist/index.js +0 -281
  107. package/dist/index.mjs +0 -281
  108. package/dist/index.mjs.map +0 -1
  109. package/dist/index.relative.html +0 -19
  110. package/dist/index.start.html +0 -20
  111. package/dist/index.sv.html +0 -15
  112. package/playwright.config.ts +0 -18
  113. package/public/index.relative.html +0 -19
  114. package/public/index.start.html +0 -20
  115. package/src/mixins/config.js +0 -1378
  116. package/src/mixins/fields.js +0 -26
  117. package/src/mixins/i18n.js +0 -25
  118. package/src/mixins/keycodes.js +0 -10
  119. package/src/mixins/panzoom.js +0 -900
  120. package/src/mixins/utils.js +0 -900
  121. package/src/plugins/index.js +0 -3
  122. package/test/snapshot.test.ts +0 -126
  123. package/vitest.config.ts +0 -6
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * Warnings Processor Composable
3
+ *
4
+ * Handles processing of raw warning data from API into structured format
5
+ * for display in the AlertClient component.
6
+ *
7
+ * This composable provides the handleMapWarnings function and all its
8
+ * dependencies, migrated from the utils mixin.
9
+ */
10
+
11
+ import { type Ref } from 'vue'
12
+ import type {
13
+ Warning,
14
+ WarningsMap,
15
+ Day,
16
+ LegendItem,
17
+ RegionsData,
18
+ DayRegions,
19
+ RegionType,
20
+ Severity,
21
+ WarningsDataResponse,
22
+ GeoJSONFeature,
23
+ GeoJSONFeatureCollection,
24
+ GeometryCollection,
25
+ RegionGeometry,
26
+ RegionListItem,
27
+ RegionWarningItem,
28
+ } from '@/types'
29
+ import {
30
+ NUMBER_OF_DAYS,
31
+ WEATHER_UPDATE_TIME,
32
+ FLOOD_UPDATE_TIME,
33
+ UPDATE_TIME,
34
+ WEATHER_WARNINGS,
35
+ FLOOD_WARNINGS,
36
+ WARNING_CONTEXT,
37
+ CONTEXT_EXTENSION,
38
+ SEVERITY,
39
+ EFFECTIVE_FROM,
40
+ EFFECTIVE_UNTIL,
41
+ ONSET,
42
+ EXPIRES,
43
+ PHYSICAL_DIRECTION,
44
+ PHYSICAL_VALUE,
45
+ INFO_FI,
46
+ INFO_SV,
47
+ INFO_EN,
48
+ SEA_WIND,
49
+ WIND,
50
+ FLOOD_LEVEL_TYPE,
51
+ WARNING_LEVELS,
52
+ FLOOD_LEVELS,
53
+ REGION_LAND,
54
+ REGION_SEA,
55
+ uncapitalize,
56
+ twoDigits,
57
+ toTimeZone,
58
+ validInterval,
59
+ msSinceStartOfDay,
60
+ coverageData,
61
+ } from './useUtils'
62
+ import he from 'he'
63
+
64
+ // ============================================================================
65
+ // Types
66
+ // ============================================================================
67
+
68
+ export interface ParentsMap {
69
+ [key: string]: boolean[]
70
+ }
71
+
72
+ export interface HandleMapWarningsResult {
73
+ warnings: WarningsMap
74
+ days: Day[]
75
+ regions: RegionsData
76
+ parents: ParentsMap
77
+ legend: LegendItem[]
78
+ }
79
+
80
+ export interface WarningsProcessorContext {
81
+ geometryId: string
82
+ geometries: GeometryCollection
83
+ regionIds: string[]
84
+ warningTypes: Map<string, RegionType>
85
+ timeZone: string
86
+ locale: string
87
+ currentTime: number
88
+ startFrom: string
89
+ staticDays: boolean
90
+ dailyWarningTypes: string[]
91
+ maxUpdateDelay: { weather_update_time: number; flood_update_time: number }
92
+ bbox: GeoJSONFeature
93
+ geoJSONToSVG: (data: object, width: number, height: number) => string
94
+ t: (key: string) => string
95
+ handleError: (error: string) => void
96
+ onDataError: () => void
97
+ }
98
+
99
+ export interface UseWarningsProcessorOptions {
100
+ geometryId: Ref<string>
101
+ geometries: Ref<GeometryCollection>
102
+ regionIds: Ref<string[]>
103
+ warningTypes: Ref<Map<string, RegionType>>
104
+ timeZone: Ref<string>
105
+ locale: Ref<string>
106
+ currentTime: Ref<number>
107
+ startFrom: Ref<string>
108
+ staticDays: Ref<boolean>
109
+ dailyWarningTypes: Ref<string[]>
110
+ maxUpdateDelay: Ref<{
111
+ weather_update_time: number
112
+ flood_update_time: number
113
+ }>
114
+ bbox: Ref<GeoJSONFeature>
115
+ geoJSONToSVG: (data: object, width: number, height: number) => string
116
+ t: (key: string) => string
117
+ handleError: (error: string) => void
118
+ onDataError: () => void
119
+ }
120
+
121
+ // ============================================================================
122
+ // Helper Functions (Pure)
123
+ // ============================================================================
124
+
125
+ /**
126
+ * Extract warning type from properties
127
+ */
128
+ function getWarningType(properties: Record<string, unknown>): string {
129
+ return uncapitalize(
130
+ (
131
+ (properties[WARNING_CONTEXT] as string) +
132
+ (properties[CONTEXT_EXTENSION] ? `-${properties[CONTEXT_EXTENSION]}` : '')
133
+ )
134
+ .split('-')
135
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
136
+ .join('')
137
+ )
138
+ }
139
+
140
+ /**
141
+ * Extract region ID from reference URL
142
+ */
143
+ function regionFromReference(reference: string): string {
144
+ return reference
145
+ .split(',')
146
+ .map((url) => {
147
+ let subUrl = url.substring(url.lastIndexOf('#') + 1)
148
+ // Saimaa special case
149
+ if (subUrl.indexOf('.') !== subUrl.lastIndexOf('.')) {
150
+ subUrl = subUrl.replace('.', '_')
151
+ }
152
+ return subUrl
153
+ })
154
+ .reduce((regionId, rawId, index, array) => {
155
+ const parts = rawId.split('.')
156
+ if (index === 0) {
157
+ regionId += parts[0] ?? ''
158
+ }
159
+ return (
160
+ regionId + (index === array.length - 1 ? '.' : '_') + (parts[1] ?? '')
161
+ )
162
+ }, '')
163
+ }
164
+
165
+ /**
166
+ * Extract relative coverage from reference URL
167
+ */
168
+ function relativeCoverageFromReference(
169
+ reference: string | null | undefined
170
+ ): number {
171
+ if (reference == null) {
172
+ return 0
173
+ }
174
+ const urlSplit = reference.split('?')
175
+ if (urlSplit.length <= 1) {
176
+ return 0
177
+ }
178
+ const paramString = (urlSplit[1] ?? '').split('#')[0] ?? ''
179
+ const searchParams = new URLSearchParams(paramString)
180
+ const relativeCoverage = searchParams.get('c')
181
+ if (relativeCoverage == null) {
182
+ return 0
183
+ }
184
+ return Number(relativeCoverage)
185
+ }
186
+
187
+ /**
188
+ * Get warning text based on properties
189
+ */
190
+ function getWarningText(properties: Record<string, unknown>): string {
191
+ return properties[WARNING_CONTEXT] === SEA_WIND
192
+ ? String(properties[PHYSICAL_VALUE] ?? '')
193
+ : ''
194
+ }
195
+
196
+ /**
197
+ * Calculate effective days for a warning
198
+ */
199
+ function calculateEffectiveDays(
200
+ start: string,
201
+ end: string,
202
+ dailyWarning: boolean,
203
+ updatedAt: number | null,
204
+ currentTime: number,
205
+ startFrom: string,
206
+ timeOffset: number,
207
+ timeZone: string,
208
+ locale: string
209
+ ): boolean[] {
210
+ const referenceTime =
211
+ startFrom === 'updated' ? updatedAt ?? currentTime : currentTime
212
+ const day = 1000 * 60 * 60 * 24
213
+
214
+ return Array.from({ length: NUMBER_OF_DAYS }, (_, index) => {
215
+ const dayTime = referenceTime + index * day
216
+ const dayStartOffset = msSinceStartOfDay(dayTime, timeZone, locale)
217
+ let startOfDay = dayTime - dayStartOffset
218
+
219
+ const nextDayTime = referenceTime + (index + 1) * day
220
+ const nextDayStartOffset = msSinceStartOfDay(nextDayTime, timeZone, locale)
221
+ let startOfNextDay = nextDayTime - nextDayStartOffset
222
+
223
+ if (!dailyWarning) {
224
+ startOfDay = startOfDay + timeOffset
225
+ startOfNextDay = startOfNextDay + timeOffset
226
+ }
227
+
228
+ return (
229
+ new Date(start).getTime() < startOfNextDay &&
230
+ new Date(end).getTime() > startOfDay
231
+ )
232
+ })
233
+ }
234
+
235
+ /**
236
+ * Check if a warning is valid
237
+ */
238
+ function isValidWarning(
239
+ warning: GeoJSONFeature | null,
240
+ geometryId: string,
241
+ geometries: GeometryCollection,
242
+ warningTypes: Map<string, RegionType>
243
+ ): boolean {
244
+ if (warning == null || warning.properties == null) {
245
+ return false
246
+ }
247
+
248
+ const regionId = regionFromReference(warning.properties.reference as string)
249
+ const geometryData = geometries[geometryId]
250
+
251
+ if (warning.geometry == null && geometryData?.[regionId] == null) {
252
+ return false
253
+ }
254
+
255
+ const warningType =
256
+ warning.properties.warning_context != null
257
+ ? getWarningType(warning.properties)
258
+ : FLOOD_LEVEL_TYPE
259
+
260
+ const regionGeom = geometryData?.[regionId] as RegionGeometry | undefined
261
+ if (regionGeom != null && warningTypes.get(warningType) !== regionGeom.type) {
262
+ return false
263
+ }
264
+
265
+ // Valid flood warning
266
+ if (
267
+ warning.properties.severity != null &&
268
+ Object.keys(FLOOD_LEVELS).includes(
269
+ (warning.properties.severity as string).toLowerCase()
270
+ )
271
+ ) {
272
+ return true
273
+ }
274
+
275
+ return (
276
+ WARNING_LEVELS.slice(1).includes(warning.properties.severity as string) ||
277
+ (warning.properties[WARNING_CONTEXT] === SEA_WIND &&
278
+ WARNING_LEVELS.includes(warning.properties.severity as string))
279
+ )
280
+ }
281
+
282
+ /**
283
+ * Create a weather warning from raw data
284
+ */
285
+ function createWeatherWarning(
286
+ warning: GeoJSONFeature,
287
+ geometryId: string,
288
+ geometries: GeometryCollection,
289
+ dailyWarningTypes: string[],
290
+ updatedAt: number | null,
291
+ currentTime: number,
292
+ startFrom: string,
293
+ timeOffset: number,
294
+ timeZone: string,
295
+ locale: string
296
+ ): Warning {
297
+ const properties = warning.properties
298
+ let direction = 0
299
+ let severity = Number(String(properties.severity ?? '').slice(-1)) as Severity
300
+
301
+ switch (properties[WARNING_CONTEXT]) {
302
+ case SEA_WIND:
303
+ direction = ((properties[PHYSICAL_DIRECTION] as number) ?? 0) - 180
304
+ if (properties[SEVERITY] === WARNING_LEVELS[0]) {
305
+ severity = (severity + 1) as Severity
306
+ }
307
+ break
308
+ case WIND:
309
+ direction = ((properties[PHYSICAL_DIRECTION] as number) ?? 0) - 90
310
+ break
311
+ default:
312
+ }
313
+
314
+ const regionId = regionFromReference(properties.reference as string)
315
+ const type = getWarningType(properties)
316
+ const geometryData = geometries[geometryId]
317
+
318
+ return {
319
+ type,
320
+ id: properties.identifier as string,
321
+ regions: geometryData?.[regionId] ? { [regionId]: true } : {},
322
+ covRegions: new Map(),
323
+ coveragesLarge: [],
324
+ coveragesSmall: [],
325
+ effectiveFrom: properties[EFFECTIVE_FROM] as string,
326
+ effectiveUntil: properties[EFFECTIVE_UNTIL] as string,
327
+ effectiveDays: calculateEffectiveDays(
328
+ properties[EFFECTIVE_FROM] as string,
329
+ properties[EFFECTIVE_UNTIL] as string,
330
+ dailyWarningTypes.includes(type),
331
+ updatedAt,
332
+ currentTime,
333
+ startFrom,
334
+ timeOffset,
335
+ timeZone,
336
+ locale
337
+ ),
338
+ validInterval: validInterval(
339
+ properties[EFFECTIVE_FROM] as string,
340
+ properties[EFFECTIVE_UNTIL] as string,
341
+ timeZone,
342
+ locale
343
+ ),
344
+ severity,
345
+ direction,
346
+ value: (properties[PHYSICAL_VALUE] as number) ?? 0,
347
+ text: getWarningText(properties),
348
+ info: {
349
+ fi: properties[INFO_FI] ? he.decode(properties[INFO_FI] as string) : '',
350
+ sv: properties[INFO_SV] ? he.decode(properties[INFO_SV] as string) : '',
351
+ en: properties[INFO_EN] ? he.decode(properties[INFO_EN] as string) : '',
352
+ },
353
+ link: '',
354
+ linkText: '',
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Create a flood warning from raw data
360
+ */
361
+ function createFloodWarning(
362
+ warning: GeoJSONFeature,
363
+ dailyWarningTypes: string[],
364
+ updatedAt: number | null,
365
+ currentTime: number,
366
+ startFrom: string,
367
+ timeOffset: number,
368
+ timeZone: string,
369
+ locale: string,
370
+ t: (key: string) => string,
371
+ handleError: (error: string) => void
372
+ ): Warning {
373
+ const properties = warning.properties
374
+ let info = ''
375
+
376
+ try {
377
+ info = JSON.parse(
378
+ decodeURIComponent(
379
+ properties.description != null
380
+ ? (properties.description as string)
381
+ : '[%22%22]'
382
+ ).replace(/[\n|\t]/g, ' ')
383
+ )[0]
384
+ } catch (e) {
385
+ handleError((e as Error).name)
386
+ }
387
+
388
+ const regionId = regionFromReference(properties.reference as string)
389
+ const langKey = (properties.language as string)
390
+ ?.substring(0, 2)
391
+ ?.toLowerCase() as 'fi' | 'sv' | 'en'
392
+
393
+ return {
394
+ type: FLOOD_LEVEL_TYPE,
395
+ id: properties.identifier as string,
396
+ regions: { [regionId]: true },
397
+ covRegions: new Map(),
398
+ coveragesLarge: [],
399
+ coveragesSmall: [],
400
+ effectiveFrom: properties[ONSET] as string,
401
+ effectiveUntil: properties[EXPIRES] as string,
402
+ effectiveDays: calculateEffectiveDays(
403
+ properties[ONSET] as string,
404
+ properties[EXPIRES] as string,
405
+ dailyWarningTypes.includes(FLOOD_LEVEL_TYPE),
406
+ updatedAt,
407
+ currentTime,
408
+ startFrom,
409
+ timeOffset,
410
+ timeZone,
411
+ locale
412
+ ),
413
+ validInterval: validInterval(
414
+ properties[ONSET] as string,
415
+ properties[EXPIRES] as string,
416
+ timeZone,
417
+ locale
418
+ ),
419
+ severity: (FLOOD_LEVELS[(properties.severity as string)?.toLowerCase()] ??
420
+ 0) as Severity,
421
+ direction: 0,
422
+ value: 0,
423
+ text: '',
424
+ info: { [langKey]: info },
425
+ link: t('floodLink'),
426
+ linkText: t('floodLinkText'),
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Create days array from warnings
432
+ */
433
+ function createDays(
434
+ warnings: WarningsMap,
435
+ updatedAt: number | null,
436
+ currentTime: number,
437
+ startFrom: string,
438
+ timeZone: string,
439
+ locale: string
440
+ ): Day[] {
441
+ const updatedAtTz = updatedAt ? toTimeZone(updatedAt, timeZone, locale) : null
442
+ const updatedDate = updatedAtTz
443
+ ? `${updatedAtTz.day}.${updatedAtTz.month}.${updatedAtTz.year}`
444
+ : ''
445
+ const updatedTime = updatedAtTz
446
+ ? `${twoDigits(updatedAtTz.hour)}:${twoDigits(updatedAtTz.minute)}`
447
+ : ''
448
+
449
+ const referenceTime =
450
+ startFrom === 'updated' ? updatedAt ?? currentTime : currentTime
451
+
452
+ return Array.from({ length: NUMBER_OF_DAYS }, (_, index) => {
453
+ const date = new Date(referenceTime)
454
+ date.setDate(date.getDate() + index)
455
+ const moment = toTimeZone(date, timeZone, locale)
456
+
457
+ return {
458
+ weekdayName: moment.weekday,
459
+ day: moment.day,
460
+ month: moment.month,
461
+ year: moment.year,
462
+ severity: Object.values(warnings).reduce(
463
+ (maxSeverity, warning) =>
464
+ warning.effectiveDays[index]
465
+ ? (Math.max(warning.severity, maxSeverity) as Severity)
466
+ : maxSeverity,
467
+ 0 as Severity
468
+ ),
469
+ updatedDate,
470
+ updatedTime,
471
+ }
472
+ })
473
+ }
474
+
475
+ /**
476
+ * Get maximum severities by warning type
477
+ */
478
+ function getMaxSeverities(warnings: WarningsMap): Record<string, Severity> {
479
+ return Object.values(warnings).reduce(
480
+ (maxSeverities, warning) => {
481
+ const currentMax = maxSeverities[warning.type]
482
+ if (
483
+ warning.effectiveDays.some((effectiveDay) => effectiveDay) &&
484
+ (currentMax == null || currentMax < warning.severity)
485
+ ) {
486
+ maxSeverities[warning.type] = warning.severity
487
+ }
488
+ return maxSeverities
489
+ },
490
+ {} as Record<string, Severity>
491
+ )
492
+ }
493
+
494
+ /**
495
+ * Create legend from severities
496
+ */
497
+ function createLegend(
498
+ severities: Record<string, Severity>,
499
+ warningTypes: Map<string, RegionType>
500
+ ): LegendItem[] {
501
+ const warningKeys = Object.keys(severities)
502
+ return [4, 3, 2].reduce<LegendItem[]>((orderedSeverities, severity) => {
503
+ const warningTypesBySeverity = warningKeys.filter(
504
+ (key) => severities[key] === severity
505
+ )
506
+ warningTypes.forEach((_, warnType) => {
507
+ if (warningTypesBySeverity.includes(warnType)) {
508
+ const warnSeverity = severities[warnType]
509
+ if (warnSeverity !== undefined) {
510
+ orderedSeverities.push({
511
+ type: warnType,
512
+ severity: warnSeverity,
513
+ visible: true,
514
+ })
515
+ }
516
+ }
517
+ })
518
+ return orderedSeverities
519
+ }, [])
520
+ }
521
+
522
+ /**
523
+ * Create regions data structure from warnings
524
+ */
525
+ function createRegions(
526
+ warnings: WarningsMap,
527
+ geometryId: string,
528
+ geometries: GeometryCollection,
529
+ regionIds: string[],
530
+ warningTypes: Map<string, RegionType>
531
+ ): RegionsData {
532
+ const warningKeys = Object.keys(warnings)
533
+ const geometryData = geometries[geometryId]
534
+
535
+ return [4, 3, 2].reduce(
536
+ (regionWarnings, severity) => {
537
+ const warningsBySeverity = warningKeys.filter(
538
+ (key) => warnings[key]?.severity === severity
539
+ )
540
+ ;[...Array(NUMBER_OF_DAYS).keys()].forEach((day) => {
541
+ const warningsByDay = warningsBySeverity.filter(
542
+ (key) => warnings[key]?.effectiveDays[day]
543
+ )
544
+ warningTypes.forEach((_regionType, warningType) => {
545
+ const warningsByType = warningsByDay.filter(
546
+ (key) => warnings[key]?.type === warningType
547
+ )
548
+ warningsByType.sort((key1, key2) => {
549
+ const w1 = warnings[key1]
550
+ const w2 = warnings[key2]
551
+ if (!w1 || !w2) return 0
552
+ if (w1.severity !== w2.severity) {
553
+ return w2.severity - w1.severity
554
+ }
555
+ if (w1.value !== w2.value) {
556
+ return w2.value - w1.value
557
+ }
558
+ const effectiveFrom1 = new Date(w1.effectiveFrom).getTime()
559
+ const effectiveFrom2 = new Date(w2.effectiveFrom).getTime()
560
+ if (effectiveFrom1 !== effectiveFrom2) {
561
+ return effectiveFrom1 - effectiveFrom2
562
+ }
563
+ const effectiveUntil1 = new Date(w1.effectiveUntil).getTime()
564
+ const effectiveUntil2 = new Date(w2.effectiveUntil).getTime()
565
+ return effectiveUntil1 - effectiveUntil2
566
+ })
567
+ warningsByType.forEach((key) => {
568
+ const warning = warnings[key]
569
+ if (!warning) return
570
+ regionIds.forEach((regionId, regionIndex) => {
571
+ if (warning.regions[regionId]) {
572
+ const regionGeom = geometryData?.[regionId] as
573
+ | RegionGeometry
574
+ | undefined
575
+ if (!regionGeom) return
576
+
577
+ const dayRegions = regionWarnings[day]
578
+ if (!dayRegions) return
579
+ const regionItems =
580
+ dayRegions[regionGeom.type as keyof DayRegions]
581
+ let regionItem = regionItems.find(
582
+ (regionWarning: RegionListItem) =>
583
+ regionWarning.key === regionId
584
+ )
585
+ if (regionItem == null) {
586
+ regionItem = {
587
+ key: regionId,
588
+ regionIndex,
589
+ name: regionGeom.name,
590
+ warnings: [],
591
+ }
592
+ regionItems.push(regionItem)
593
+ }
594
+ let warningItem = regionItem.warnings.find(
595
+ (w: RegionWarningItem) => w.type === warningType
596
+ )
597
+ if (warningItem == null) {
598
+ warningItem = {
599
+ type: warningType,
600
+ identifiers: [],
601
+ coverage: 0,
602
+ }
603
+ regionItem.warnings.push(warningItem)
604
+ }
605
+ if (!warningItem.identifiers.includes(key)) {
606
+ warningItem.identifiers.push(key)
607
+ }
608
+ const covRegions = warning.covRegions
609
+ if (covRegions.has(regionId)) {
610
+ warningItem.coverage += covRegions.get(regionId) ?? 0
611
+ } else {
612
+ warningItem.coverage = 100
613
+ }
614
+ }
615
+ })
616
+ })
617
+ })
618
+ })
619
+ return regionWarnings
620
+ },
621
+ [...Array(NUMBER_OF_DAYS).keys()].map(() => ({
622
+ [REGION_LAND]: [] as RegionListItem[],
623
+ [REGION_SEA]: [] as RegionListItem[],
624
+ })) as RegionsData
625
+ )
626
+ }
627
+
628
+ /**
629
+ * Create coverage SVG from geometry
630
+ */
631
+ function createCoverage(
632
+ coverage: GeoJSONFeature,
633
+ width: number,
634
+ height: number,
635
+ reference: [number, number] | null,
636
+ bbox: GeoJSONFeature,
637
+ geoJSONToSVG: (data: object, width: number, height: number) => string
638
+ ): string {
639
+ const data = {
640
+ type: 'FeatureCollection',
641
+ features: [coverage, bbox],
642
+ totalFeatures: 2,
643
+ crs: {
644
+ type: 'name',
645
+ properties: {
646
+ name: 'urn:ogc:def:crs:EPSG::3067',
647
+ },
648
+ },
649
+ } as GeoJSONFeatureCollection & { totalFeatures: number }
650
+
651
+ if (reference != null) {
652
+ data.features.push({
653
+ type: 'Feature',
654
+ id: 'reference',
655
+ properties: {},
656
+ geometry: {
657
+ type: 'Point',
658
+ coordinates: reference,
659
+ },
660
+ })
661
+ data.totalFeatures++
662
+ }
663
+
664
+ return geoJSONToSVG(data, width, height)
665
+ }
666
+
667
+ /**
668
+ * Optimize coverage regions to prevent overlapping symbols in Saimaa
669
+ */
670
+ function optimizeCovRegions(
671
+ warnings: WarningsMap,
672
+ regions: RegionsData,
673
+ geometryId: string,
674
+ geometries: GeometryCollection
675
+ ): void {
676
+ const geometryData = geometries[geometryId]
677
+ if (!geometryData) return
678
+
679
+ Object.keys(geometryData)
680
+ .filter((regionId) => {
681
+ const region = geometryData[regionId] as RegionGeometry | undefined
682
+ return region?.type === 'sea' && region?.subType === 'lake'
683
+ })
684
+ .filter((regionId) =>
685
+ regions.some((day) => day.sea.some((region) => region.key === regionId))
686
+ )
687
+ .forEach((regionId) =>
688
+ Object.keys(warnings)
689
+ .filter((warningKey) => {
690
+ const w = warnings[warningKey]
691
+ return w && w.covRegions.size > 0
692
+ })
693
+ .forEach((warningKey) => {
694
+ const w = warnings[warningKey]
695
+ if (w) w.covRegions.set(regionId, 0)
696
+ })
697
+ )
698
+ }
699
+
700
+ // ============================================================================
701
+ // Main Processing Function
702
+ // ============================================================================
703
+
704
+ /**
705
+ * Process raw warning data into structured format
706
+ */
707
+ export function processWarnings(
708
+ data: WarningsDataResponse,
709
+ ctx: WarningsProcessorContext
710
+ ): HandleMapWarningsResult & { updatedAt: number | null; timeOffset: number } {
711
+ const warnings: WarningsMap = {}
712
+ const parents: ParentsMap = {}
713
+ let updatedAt: number | null = null
714
+ let timeOffset = 0
715
+
716
+ // Process update times
717
+ const allUpdateTimes = [WEATHER_UPDATE_TIME, FLOOD_UPDATE_TIME]
718
+ .filter(
719
+ (warningUpdateTime) =>
720
+ data[warningUpdateTime as keyof WarningsDataResponse] != null
721
+ )
722
+ .reduce((updateTimes: number[], warningUpdateTime) => {
723
+ const updateData = data[
724
+ warningUpdateTime as keyof WarningsDataResponse
725
+ ] as GeoJSONFeatureCollection | undefined
726
+
727
+ if (
728
+ updateData?.features != null &&
729
+ updateData.features.length > 0 &&
730
+ updateData.features[0]?.properties != null
731
+ ) {
732
+ const updateTime = new Date(
733
+ updateData.features[0].properties[UPDATE_TIME] as string
734
+ ).getTime()
735
+ updateTimes.push(updateTime)
736
+
737
+ const maxDelay =
738
+ ctx.maxUpdateDelay[
739
+ warningUpdateTime as keyof typeof ctx.maxUpdateDelay
740
+ ]
741
+ if (ctx.currentTime - updateTime > maxDelay) {
742
+ ctx.handleError(`${warningUpdateTime}_outdated`)
743
+ }
744
+ } else {
745
+ ctx.handleError(warningUpdateTime)
746
+ }
747
+ return updateTimes
748
+ }, [])
749
+ .sort()
750
+ .reverse()
751
+
752
+ const firstUpdateTime = allUpdateTimes[0]
753
+ updatedAt =
754
+ allUpdateTimes.length > 0 && firstUpdateTime != null
755
+ ? firstUpdateTime
756
+ : null
757
+
758
+ if (!ctx.staticDays) {
759
+ const startTime = ctx.startFrom === 'updated' ? updatedAt : ctx.currentTime
760
+ if (startTime != null) {
761
+ timeOffset = msSinceStartOfDay(startTime, ctx.timeZone, ctx.locale)
762
+ }
763
+ }
764
+
765
+ // Create warning factories
766
+ const createWarnings: Record<string, (warning: GeoJSONFeature) => Warning> = {
767
+ [WEATHER_WARNINGS]: (warning: GeoJSONFeature) =>
768
+ createWeatherWarning(
769
+ warning,
770
+ ctx.geometryId,
771
+ ctx.geometries,
772
+ ctx.dailyWarningTypes,
773
+ updatedAt,
774
+ ctx.currentTime,
775
+ ctx.startFrom,
776
+ timeOffset,
777
+ ctx.timeZone,
778
+ ctx.locale
779
+ ),
780
+ [FLOOD_WARNINGS]: (warning: GeoJSONFeature) =>
781
+ createFloodWarning(
782
+ warning,
783
+ ctx.dailyWarningTypes,
784
+ updatedAt,
785
+ ctx.currentTime,
786
+ ctx.startFrom,
787
+ timeOffset,
788
+ ctx.timeZone,
789
+ ctx.locale,
790
+ ctx.t,
791
+ ctx.handleError
792
+ ),
793
+ }
794
+
795
+ // Process warnings
796
+ const warningTypes = Object.keys(createWarnings)
797
+ for (const warningType of warningTypes) {
798
+ const warningData = data[warningType as keyof WarningsDataResponse] as
799
+ | GeoJSONFeatureCollection
800
+ | undefined
801
+
802
+ if (warningData == null) {
803
+ ctx.handleError(`Missing data: ${warningType}`)
804
+ ctx.onDataError()
805
+ continue
806
+ }
807
+
808
+ const features = warningData.features ?? []
809
+
810
+ for (const warning of features) {
811
+ if (
812
+ isValidWarning(
813
+ warning,
814
+ ctx.geometryId,
815
+ ctx.geometries,
816
+ ctx.warningTypes
817
+ )
818
+ ) {
819
+ let regionId: string | undefined
820
+ const regionIds: string[] = []
821
+ const warningId = warning.properties.identifier as string
822
+
823
+ if (warnings[warningId] == null) {
824
+ const createFn = createWarnings[warningType]
825
+ if (!createFn) continue
826
+ warnings[warningId] = createFn(warning)
827
+ const createdWarning = warnings[warningId]
828
+ if (!createdWarning) continue
829
+ const warningRegions = Object.keys(createdWarning.regions)
830
+ if (warningRegions.length > 0) {
831
+ regionId = warningRegions[0]
832
+ }
833
+ if (ctx.dailyWarningTypes.includes(createdWarning.type)) {
834
+ createdWarning.dailyWarning = true
835
+ }
836
+ } else {
837
+ regionId = regionFromReference(warning.properties.reference as string)
838
+ const geometryData = ctx.geometries[ctx.geometryId]
839
+ const existingWarning = warnings[warningId]
840
+ if (geometryData?.[regionId] && existingWarning) {
841
+ existingWarning.regions[regionId] = true
842
+ }
843
+ }
844
+
845
+ // Get the current warning object
846
+ const currentWarning = warnings[warningId]
847
+ if (!currentWarning) continue
848
+
849
+ // Handle coverage references
850
+ if (warning.properties.coverage_references != null) {
851
+ // Space after comma is needed for merged areas
852
+ ;(warning.properties.coverage_references as string)
853
+ .split(', ')
854
+ .filter((reference) => reference.length > 0)
855
+ .forEach((reference) => {
856
+ const refRegionId = regionFromReference(reference)
857
+ const regionCoverage =
858
+ relativeCoverageFromReference(reference) / 100
859
+ const geometryData = ctx.geometries[ctx.geometryId]
860
+ if (geometryData?.[refRegionId]) {
861
+ currentWarning.regions[refRegionId] = true
862
+ currentWarning.covRegions.set(refRegionId, regionCoverage)
863
+ regionIds.push(refRegionId)
864
+ }
865
+ })
866
+
867
+ if (warning.geometry != null) {
868
+ const coverageSvg = createCoverage(
869
+ warning,
870
+ 440,
871
+ 550,
872
+ [
873
+ warning.properties.representative_x as number,
874
+ warning.properties.representative_y as number,
875
+ ],
876
+ ctx.bbox,
877
+ ctx.geoJSONToSVG
878
+ )
879
+ const coverageSmallSvg = createCoverage(
880
+ warning,
881
+ 75,
882
+ 120,
883
+ null,
884
+ ctx.bbox,
885
+ ctx.geoJSONToSVG
886
+ )
887
+ currentWarning.coveragesLarge = coverageData(coverageSvg)
888
+ currentWarning.coveragesSmall = coverageData(coverageSmallSvg)
889
+ }
890
+ }
891
+
892
+ // Handle children and parents
893
+ const geometryData = ctx.geometries[ctx.geometryId]
894
+ if (regionId != null && geometryData?.[regionId]) {
895
+ const regionGeom = geometryData[regionId] as RegionGeometry
896
+ regionGeom.children?.forEach((id) => {
897
+ currentWarning.regions[id] = true
898
+ })
899
+ if (regionIds.length === 0) {
900
+ regionIds.push(regionId)
901
+ }
902
+ }
903
+
904
+ regionIds.forEach((id) => {
905
+ const regionGeom = geometryData?.[id] as RegionGeometry | undefined
906
+ const parentId = regionGeom?.parent
907
+ if (parentId) {
908
+ if (parents[parentId] == null) {
909
+ parents[parentId] = [false, false, false, false, false]
910
+ }
911
+ const parentDays = parents[parentId]
912
+ if (parentDays) {
913
+ currentWarning.effectiveDays.forEach((override, index) => {
914
+ if (override) {
915
+ parentDays[index] = true
916
+ }
917
+ })
918
+ }
919
+ }
920
+ })
921
+ }
922
+ }
923
+ }
924
+
925
+ // Create derived data
926
+ const days = createDays(
927
+ warnings,
928
+ updatedAt,
929
+ ctx.currentTime,
930
+ ctx.startFrom,
931
+ ctx.timeZone,
932
+ ctx.locale
933
+ )
934
+ const maxSeverities = getMaxSeverities(warnings)
935
+ const legend = createLegend(maxSeverities, ctx.warningTypes)
936
+ const regions = createRegions(
937
+ warnings,
938
+ ctx.geometryId,
939
+ ctx.geometries,
940
+ ctx.regionIds,
941
+ ctx.warningTypes
942
+ )
943
+ optimizeCovRegions(warnings, regions, ctx.geometryId, ctx.geometries)
944
+
945
+ return {
946
+ warnings,
947
+ days,
948
+ regions,
949
+ parents,
950
+ legend,
951
+ updatedAt,
952
+ timeOffset,
953
+ }
954
+ }
955
+
956
+ // ============================================================================
957
+ // Composable
958
+ // ============================================================================
959
+
960
+ export interface UseWarningsProcessorReturn {
961
+ handleMapWarnings: (data: WarningsDataResponse) => HandleMapWarningsResult & {
962
+ updatedAt: number | null
963
+ timeOffset: number
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Warnings processor composable
969
+ *
970
+ * Provides the handleMapWarnings function for processing raw API data
971
+ * into structured warning data for display.
972
+ */
973
+ export function useWarningsProcessor(
974
+ options: UseWarningsProcessorOptions
975
+ ): UseWarningsProcessorReturn {
976
+ const handleMapWarnings = (
977
+ data: WarningsDataResponse
978
+ ): HandleMapWarningsResult & {
979
+ updatedAt: number | null
980
+ timeOffset: number
981
+ } => {
982
+ const ctx: WarningsProcessorContext = {
983
+ geometryId: options.geometryId.value,
984
+ geometries: options.geometries.value,
985
+ regionIds: options.regionIds.value,
986
+ warningTypes: options.warningTypes.value,
987
+ timeZone: options.timeZone.value,
988
+ locale: options.locale.value,
989
+ currentTime: options.currentTime.value,
990
+ startFrom: options.startFrom.value,
991
+ staticDays: options.staticDays.value,
992
+ dailyWarningTypes: options.dailyWarningTypes.value,
993
+ maxUpdateDelay: options.maxUpdateDelay.value,
994
+ bbox: options.bbox.value,
995
+ geoJSONToSVG: options.geoJSONToSVG,
996
+ t: options.t,
997
+ handleError: options.handleError,
998
+ onDataError: options.onDataError,
999
+ }
1000
+
1001
+ return processWarnings(data, ctx)
1002
+ }
1003
+
1004
+ return {
1005
+ handleMapWarnings,
1006
+ }
1007
+ }