-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
1799 lines (1663 loc) · 135 KB
/
Copy pathapp.js
File metadata and controls
1799 lines (1663 loc) · 135 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import qrcode from "./assets/vendor/qrcode.mjs";
import { stringToBytes as utf8ToBytes } from "./assets/vendor/qrcode_utf8.mjs";
qrcode.stringToBytes = utf8ToBytes;
const DAY_MS = 24 * 60 * 60 * 1000;
const DB_NAME = "proxaid-offline-v1";
const DB_VERSION = 2;
const SHELL_CACHE_NAME = "proxaid-shell-v10";
const DATA_REVISION = "2026-08-16.5";
const DEFAULT_CENTER = [46.4345, 16.9009];
const DEFAULT_EMERGENCY_NUMBER = "112";
const ONLINE_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OVERPASS_ENDPOINTS = ["https://overpass-api.de/api/interpreter", "https://overpass.kumi.systems/api/interpreter"];
const GLOBAL_SEARCH_RADIUS_M = 15000;
const GLOBAL_MAP_RADIUS_M = 12000;
const GLOBAL_CELL_SCALE = 20;
const CATEGORY_LABELS = {
all: { hu: "Mind", en: "All" }, emergency_call: { hu: "Segélyhívó", en: "Emergency" },
ambulance: { hu: "Mentő", en: "Ambulance" }, hospital_emergency: { hu: "Sürgősségi", en: "Emergency care" },
hospital: { hu: "Kórház", en: "Hospital" }, urgent_care: { hu: "Ügyelet", en: "Urgent care" },
clinic: { hu: "Klinika", en: "Clinic" }, doctor: { hu: "Orvos", en: "Doctor" },
pharmacy: { hu: "Gyógyszertár", en: "Pharmacy" }, medical_supply: { hu: "Gyógyászati segédeszköz", en: "Medical supply" }, aed: { hu: "Defibrillátor (AED)", en: "Defibrillator (AED)" },
police: { hu: "Rendőrség", en: "Police" }, fire_station: { hu: "Tűzoltóság", en: "Fire station" },
shelter: { hu: "Menedék", en: "Shelter" }, drinking_water: { hu: "Ivóvíz", en: "Drinking water" },
toilets: { hu: "WC", en: "Toilet" }, accessible_toilets: { hu: "Akadálymentes WC", en: "Accessible toilet" },
public_phone: { hu: "Nyilvános telefon", en: "Public phone" },
shower: { hu: "Tisztálkodás", en: "Shower" }, homeless_shelter: { hu: "Éjszakai menedék", en: "Night shelter" },
washing: { hu: "Mosdás", en: "Washing" }, laundry: { hu: "Mosoda", en: "Laundry" }, baby_changing: { hu: "Pelenkázó", en: "Baby changing" },
warming_center: { hu: "Melegedő", en: "Warming centre" }, cooling_center: { hu: "Hűsölőhely", en: "Cooling centre" }, food_assistance: { hu: "Élelmiszersegély", en: "Food assistance" },
alpine_hut: { hu: "Hegyi hajlék", en: "Alpine hut" }, wilderness_hut: { hu: "Erdei hajlék", en: "Wilderness hut" },
mountain_rescue: { hu: "Hegyi mentés", en: "Mountain rescue" }, water_rescue: { hu: "Vízi mentés", en: "Water rescue" },
lifeguard: { hu: "Vízimentő", en: "Lifeguard" }, disaster_response: { hu: "Katasztrófavédelem", en: "Disaster response" },
emergency_phone: { hu: "Segélytelefon", en: "Emergency phone" }, assembly_point: { hu: "Gyülekezési pont", en: "Assembly point" },
first_aid: { hu: "Elsősegélypont", en: "First-aid point" }, internet_access: { hu: "Internetelérés", en: "Internet access" },
fuel: { hu: "Benzinkút", en: "Fuel" }, charging: { hu: "Töltőpont", en: "Charging" }, embassy: { hu: "Külképviselet", en: "Embassy" }
};
const FILTERS = ["all", "urgent", "healthcare", "aed", "pharmacy", "water", "hygiene", "shelter", "rescue", "connection"];
const FILTER_LABELS = {
all: { hu: "Mind", en: "All" }, urgent: { hu: "Sürgősségi + ügyelet", en: "Emergency + urgent" },
healthcare: { hu: "Kórház + orvos", en: "Hospital + doctor" }, aed: CATEGORY_LABELS.aed,
pharmacy: CATEGORY_LABELS.pharmacy, water: CATEGORY_LABELS.drinking_water,
hygiene: { hu: "WC + tisztálkodás", en: "Toilet + hygiene" }, shelter: { hu: "Menedék + hajlék", en: "Shelter" },
rescue: { hu: "Mentés + hatóság", en: "Rescue + services" }, connection: { hu: "Telefon + kapcsolat", en: "Phone + connection" }
};
const FILTER_MEMBERS = {
urgent: ["emergency_call", "ambulance", "hospital_emergency", "urgent_care", "hospital", "clinic"],
healthcare: ["hospital_emergency", "hospital", "urgent_care", "clinic", "doctor", "medical_supply"],
aed: ["aed"], pharmacy: ["pharmacy"], water: ["drinking_water"],
hygiene: ["toilets", "accessible_toilets", "shower", "washing", "baby_changing", "laundry", "sanitary_dump"],
shelter: ["shelter", "homeless_shelter", "night_shelter", "warming_center", "cooling_center", "alpine_hut", "wilderness_hut", "food_assistance"],
rescue: ["ambulance", "police", "fire_station", "mountain_rescue", "water_rescue", "lifeguard", "assembly_point", "disaster_response"],
connection: ["public_phone", "emergency_phone", "internet_access"]
};
const UI = {
hu: {
online: "ONLINE // FRISSÍTHETŐ", offline: "OFFLINE // HELYI ADAT", results: "találat",
noResults: "Nincs találat a letöltött rekordokban.", loaded: "LETÖLTVE", missing: "HIÁNYZIK",
source: "FORRÁS ↗", website: "WEBOLDAL ↗", show: "MUTASD", route: "ÚTVONAL", call: "HÍVÁS", details: "ÚTMUTATÓ", recordDetails: "RÉSZLETEK",
closestLoaded: "A legközelebbi betöltött rekordok távolság szerint.", micOn: "MIC ON", micListening: "FIGYELEK…", micStop: "MIK LEÁLLÍTÁSA",
aiSearch: "ONLINE KERESÉSI PROMPT", copied: "Másolva"
},
en: {
online: "ONLINE // UPDATE READY", offline: "OFFLINE // LOCAL DATA", results: "results",
noResults: "No match in the downloaded records.", loaded: "DOWNLOADED", missing: "MISSING",
source: "SOURCE ↗", website: "WEBSITE ↗", show: "SHOW", route: "ROUTE", call: "CALL", details: "GUIDE", recordDetails: "DETAILS",
closestLoaded: "Nearest downloaded records ordered by distance.", micOn: "MIC ON", micListening: "LISTENING…", micStop: "STOP MIC",
aiSearch: "ONLINE SEARCH PROMPT", copied: "Copied"
}
};
const state = {
db: null,
memoryRecords: [],
records: [],
visibleRecords: [],
category: "all",
query: "",
userLocation: null,
catalog: null,
firstAid: null,
activeIntent: null,
language: browserLanguage(),
ttsEnabled: readLocal("proxaid-tts") !== "off",
emergencyNumber: safePhone(readLocal("proxaid-emergency-number")) || detectedEmergencyNumber(),
region: detectedRegion(),
syncing: false,
installPrompt: null,
map: null,
mapData: null,
mapLayer: null,
mapCasingLayer: null,
mapLabelLayer: null,
onlineTileLayer: null,
onlineTileReady: false,
localMapLoaded: false,
localMapBounds: null,
localMapMinZoom: 2,
activeMapDescriptor: null,
poiLayer: null,
userLayer: null,
recordMarkers: new Map(),
heroHubPayload: "",
recognition: null,
micSession: null,
mapContextCache: new Map(),
cpr: { mode: null, running: false, count: 0, timer: null, audioContext: null, wakeLock: null }
};
const $ = (id) => document.getElementById(id);
const els = Object.fromEntries([
"networkBadge", "themeButton", "languageSelect", "ttsButton", "emergencyCall", "searchInput", "clearSearch", "micButton",
"categoryFilters", "firstAidSuggestion", "locateButton", "mapStatus", "zoomIn", "zoomOut", "resetMap",
"recordCount", "lastSync", "readyScore", "shellState", "mapState", "recordState", "guideState",
"storageWarning", "storageHelpButton", "syncButton", "syncMessage", "packInput", "packButton", "installButton",
"resultsList", "resultCount", "handsOnlyButton", "breathsButton", "cprNow", "cprCounter", "cprSteps",
"cprStartButton", "cprStopButton", "narratedAudioButton", "cprOnlineLink", "cprSource", "cprOfflineWarning", "cprAudio",
"guideDialog", "guideTitle", "guideSummary", "guideSteps", "guideCprModes", "guideSources", "guideOfflineWarning", "guideSpeakButton",
"callOptionsButton", "callDialog", "callOptions", "callNumberLabel", "callNumberInput", "useCallNumberButton", "storageDialog", "storageInstructions", "nfcButton", "meshButton",
"shareLocationButton", "deviceMessage", "nfcDialog", "nfcPayload", "nfcReadButton", "nfcWriteButton", "nfcShareButton", "nfcMessage",
"installDialog", "installInstructions", "heroButton", "heroHubButton", "heroHubDialog", "heroHubForm", "hubLocation", "hubHazards",
"hubTotal", "hubCritical", "hubBleeding", "hubTrapped", "hubNotes", "hubContact", "hubBuildButton", "hubClearButton", "heroHubQr",
"heroHubPreview", "hubNfcButton", "hubShareButton", "hubCopyButton", "hubDownloadButton", "heroHubMessage", "docDialog", "docDialogTitle",
"docContent", "readmeButton", "userGuideButton", "sourcesButton", "inviteButton", "callQr", "medicalCardButton", "medicalCardDialog",
"medicalCardForm", "medicalName", "medicalBirthDate", "medicalBloodType", "medicalDonor", "medicalAllergies", "medicalMedications",
"medicalConditions", "medicalImplants", "medicalContact", "medicalNotes", "medicalSaveButton", "medicalNfcButton", "medicalShareButton",
"medicalClearButton", "medicalCardQr", "medicalCardPreview", "medicalCardMessage", "medicalCardTitle", "medicalCardRisk"
].map((id) => [id, $(id)]));
function readLocal(key) { try { return localStorage.getItem(key); } catch { return null; } }
function writeLocal(key, value) { try { localStorage.setItem(key, value); return true; } catch { return false; } }
function removeLocal(key) { try { localStorage.removeItem(key); } catch {} }
function readSession(key) { try { return sessionStorage.getItem(key); } catch { return null; } }
function writeSession(key, value) { try { sessionStorage.setItem(key, value); return true; } catch { return false; } }
function detectedRegion() {
const locale = String(navigator.languages?.[0] || navigator.language || "").replace("_", "-");
return locale.match(/-([A-Za-z]{2})(?:-|$)/)?.[1]?.toUpperCase() || "";
}
function detectedEmergencyNumber() {
const region = detectedRegion();
if (["US", "CA", "MX"].includes(region)) return "911";
if (["GB", "GG", "IM", "JE"].includes(region)) return "999";
if (region === "AU") return "000";
if (region === "NZ") return "111";
if (["JP", "KR"].includes(region)) return "119";
return DEFAULT_EMERGENCY_NUMBER;
}
function browserLanguage() {
const language = String(navigator.languages?.[0] || navigator.language || "en").toLowerCase();
return language.startsWith("hu") ? "hu" : "en";
}
function text(value) {
if (typeof value === "string") return value;
return value?.[state.language] || value?.en || value?.hu || "";
}
function ui(key) { return UI[state.language]?.[key] || UI.en[key] || key; }
function categoryLabel(category) { return text(CATEGORY_LABELS[category]) || category; }
function filterLabel(filter) { return text(FILTER_LABELS[filter]) || categoryLabel(filter); }
function recordCategories(record) { return [...new Set([record.category, ...(record.categories || [])].filter(Boolean))]; }
function matchesFilter(record, filter) { return filter === "all" || recordCategories(record).some((category) => (FILTER_MEMBERS[filter] || [filter]).includes(category)); }
function recordApplicable(record) {
if (record.coordinates || !record.country) return true;
if (record.country === "EU") return ["AT", "BE", "BG", "HR", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK"].includes(state.region);
return !state.region || String(record.country).toUpperCase() === state.region;
}
function normalize(value) { return String(value ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9+]+/g, " ").trim(); }
function safePhone(phone) { return String(phone ?? "").replace(/[^+\d,;*#]/g, ""); }
function safeUrl(url) { try { const parsed = new URL(url); return ["https:", "http:"].includes(parsed.protocol) ? parsed.href : null; } catch { return null; } }
function formatDate(value) { if (!value) return state.language === "hu" ? "még nem történt" : "not yet"; try { return new Intl.DateTimeFormat(state.language === "hu" ? "hu-HU" : "en", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); } catch { return String(value); } }
function pointInBbox(point, bbox) { return point && bbox && point.lon >= bbox[0] && point.lat >= bbox[1] && point.lon <= bbox[2] && point.lat <= bbox[3]; }
function haversine(a, b) {
if (!a || !b) return null;
const rad = Math.PI / 180, dLat = (b.lat - a.lat) * rad, dLon = (b.lon - a.lon) * rad;
const x = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));
}
function openDialog(dialog) {
if (!dialog) return;
if (typeof dialog.showModal === "function") dialog.showModal();
else { dialog.setAttribute("open", ""); dialog.setAttribute("role", "dialog"); dialog.setAttribute("aria-modal", "true"); }
}
function closeDialog(dialog) {
if (!dialog) return;
if (typeof dialog.close === "function") dialog.close(); else dialog.removeAttribute("open");
}
function speak(message, { interrupt = true } = {}) {
if (!state.ttsEnabled || !message || !("speechSynthesis" in window)) return;
if (interrupt) speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(String(message));
utterance.lang = state.language === "hu" ? "hu-HU" : "en-US";
utterance.rate = .96;
speechSynthesis.speak(utterance);
}
function setupSpeechOutput() {
els.ttsButton.setAttribute("aria-pressed", String(state.ttsEnabled));
els.ttsButton.textContent = state.ttsEnabled ? (state.language === "hu" ? "🔊 HANG BE" : "🔊 SPEECH ON") : (state.language === "hu" ? "🔇 HANG KI" : "🔇 SPEECH OFF");
els.ttsButton.addEventListener("click", () => {
state.ttsEnabled = !state.ttsEnabled;
writeLocal("proxaid-tts", state.ttsEnabled ? "on" : "off");
els.ttsButton.setAttribute("aria-pressed", String(state.ttsEnabled));
els.ttsButton.textContent = state.ttsEnabled ? (state.language === "hu" ? "🔊 HANG BE" : "🔊 SPEECH ON") : (state.language === "hu" ? "🔇 HANG KI" : "🔇 SPEECH OFF");
if (state.ttsEnabled) speak(state.language === "hu" ? "Felolvasás bekapcsolva" : "Speech on");
else window.speechSynthesis?.cancel();
});
document.addEventListener("click", (event) => {
const target = event.target.closest("button, a");
const activationSensitive = [els.ttsButton, els.micButton, els.guideSpeakButton, els.narratedAudioButton, els.cprStartButton, els.cprStopButton];
if (!target || !state.ttsEnabled || target.dataset.noSpeak === "true" || activationSensitive.includes(target)) return;
const label = target.dataset.speak || target.getAttribute("aria-label") || target.textContent.trim();
if (label) speak(label.slice(0, 180));
});
}
function setupLanguage() {
els.languageSelect.value = state.language;
document.documentElement.lang = state.language;
els.languageSelect.addEventListener("change", () => {
state.language = els.languageSelect.value;
document.documentElement.lang = state.language;
writeLocal("proxaid-language", state.language);
setupFilters();
applyStaticLanguage();
updateNetworkStatus();
applyFilters();
if (state.cpr.mode) selectCprMode(state.cpr.mode, false);
speak(state.language === "hu" ? "Magyar nyelv" : "English language");
});
const stored = readLocal("proxaid-language");
if (["hu", "en"].includes(stored)) { state.language = stored; els.languageSelect.value = stored; document.documentElement.lang = stored; }
applyStaticLanguage();
}
function applyStaticLanguage() {
const hu = state.language === "hu";
const set = (selector, huText, enText) => { const node = document.querySelector(selector); if (node) node.textContent = hu ? huText : enText; };
const setLabel = (selector, huText, enText) => { const node = document.querySelector(selector); if (node?.firstChild?.nodeType === Node.TEXT_NODE) node.firstChild.nodeValue = hu ? huText : enText; };
document.title = hu ? "PROXAID Offline" : "PROXAID Offline";
set(".hero-pitch .eyebrow", "PROXAID // GLOBÁLIS OFF-GRID VÉSZHELYZETI RÉTEG", "PROXAID // GLOBAL OFF-GRID EMERGENCY LAYER");
set("#heroPitchTitle", "Ha baj van, de nincs internet, esetleg telefon sem, GPS lefedettség sem! Nem csak adrenalin vadászok számára!", "When trouble hits but there is no internet — perhaps no phone or GPS coverage either. Not just for adrenaline seekers.");
set(".hero-pitch > p:last-child", "Offline is működő vészhelyzeti térkép és elsősegélynyújtás támogatással.", "An offline-capable emergency map with first-aid support.");
set(".emergency-strip .eyebrow", "AZONNALI ÉLETVESZÉLY", "IMMEDIATE DANGER");
set(".emergency-strip h1", "Hívd a helyi segélyhívót, majd kövesd a segélyirányító utasításait.", "Call the local emergency number, then follow the dispatcher.");
updateEmergencyNumberUi();
els.callOptionsButton.textContent = hu ? "MÁS HÍVÁSI MÓD" : "OTHER CALLING METHOD";
set(".command-panel .eyebrow", "HELYKERESÉS + ELSŐSEGÉLY", "PLACE SEARCH + FIRST AID");
set("#searchTitle", "Mi történt, vagy mit keresel?", "What happened, or what do you need?");
els.searchInput.placeholder = hu ? "Pl. félrenyelt, vérzés, defibrillátor (AED), gyógyszertár, WC…" : "E.g. choking, bleeding, defibrillator (AED), pharmacy, toilet…";
els.ttsButton.textContent = state.ttsEnabled ? (hu ? "🔊 HANG BE" : "🔊 SPEECH ON") : (hu ? "🔇 HANG KI" : "🔇 SPEECH OFF");
if (!state.userLocation) els.locateButton.textContent = hu ? "⌖ HELYZETEM" : "⌖ MY LOCATION";
els.micButton.textContent = ui("micOn");
set("#heroTitle", "HERO segítség + HeroHUB helyzetlap", "HERO help + HeroHUB incident card");
set(".hero-panel .eyebrow", "⛑️ TÖMEGBALESET", "⛑️ MULTIPLE CASUALTIES");
const heroActions = document.querySelectorAll(".hero-action");
if (heroActions[0]) heroActions[0].querySelector("span").textContent = hu ? "Azonnali, felolvasott teendők több sérültnél" : "Immediate spoken actions for multiple casualties";
if (heroActions[1]) heroActions[1].querySelector("span").textContent = hu ? "Helyzetadat rögzítése és átadása QR-rel, NFC-vel vagy megosztással" : "Record and hand over incident data by QR, NFC or sharing";
set("#cprTitle", "Válaszd a megfelelő módot", "Choose the appropriate mode");
set(".cpr-panel .eyebrow", "FELNŐTT ÚJRAÉLESZTÉS", "ADULT CPR");
els.handsOnlyButton.querySelector("strong").textContent = hu ? "CSAK MELLKASI NYOMÁS" : "HANDS-ONLY CPR";
els.handsOnlyButton.querySelector("span").textContent = hu ? "Folyamatos kompresszió" : "Continuous compressions";
els.breathsButton.querySelector("strong").textContent = hu ? "30 NYOMÁS + 2 BEFÚVÁS" : "30 COMPRESSIONS + 2 BREATHS";
els.breathsButton.querySelector("span").textContent = hu ? "Képzett és kész segélynyújtónak" : "For a trained and willing rescuer";
els.cprStartButton.textContent = hu ? "ÜTEM INDÍTÁSA" : "START RHYTHM"; els.cprStopButton.textContent = hu ? "LEÁLLÍTÁS" : "STOP";
set("#dataTitle", "Letöltött elemek", "Downloaded items"); set("#resultsTitle", "Legközelebbi segítségpontok", "Nearest assistance points");
set(".map-panel .eyebrow", "ONLINE + OFFLINE UTCATÉRKÉP", "ONLINE + OFFLINE STREET MAP");
set(".data-panel .eyebrow", "OFFLINE KÉSZENLÉT", "OFFLINE READINESS");
set(".results-panel .eyebrow", "BETÖLTÖTT REKORDOK", "STORED RECORDS");
const readinessLabels = document.querySelectorAll(".readiness-list span");
const readinessHu = ["Alkalmazás", "Utcatérkép", "Tárolt segítségpontok", "Elsősegély és hang"], readinessEn = ["Application", "Street map", "Stored assistance points", "First aid and audio"];
readinessLabels.forEach((node, index) => { node.textContent = (hu ? readinessHu : readinessEn)[index] || node.textContent; });
const statsLabels = document.querySelectorAll(".data-stats dt");
if (statsLabels[0]) statsLabels[0].textContent = hu ? "Tárolt rekord" : "Stored records";
if (statsLabels[1]) statsLabels[1].textContent = hu ? "Utolsó frissítés" : "Last refresh";
els.syncButton.textContent = hu ? "↻ FRISSÍTÉS MOST" : "↻ REFRESH NOW"; els.packButton.textContent = hu ? "+ ADAT- / TÉRKÉPCSOMAG IMPORTÁLÁSA" : "+ IMPORT DATA / MAP PACK";
els.installButton.textContent = hu ? "⇩ TELEPÍTÉS" : "⇩ INSTALL";
els.medicalCardButton.textContent = hu ? "🩺 HELYI VÉSZKÁRTYA" : "🩺 LOCAL MEDICAL CARD";
els.nfcButton.textContent = hu ? "NFC OLVASÁS / ÍRÁS" : "NFC READ / WRITE"; els.meshButton.textContent = hu ? "MESH SEGÉLYCSOMAG" : "MESH EMERGENCY PACKET"; els.shareLocationButton.textContent = hu ? "HELYZET MEGOSZTÁSA" : "SHARE LOCATION";
set(".device-panel .eyebrow", "KÖZELI ADATCSERE", "NEARBY DATA HANDOFF"); set("#deviceTitle", "NFC és MESH", "NFC and MESH");
const deviceNotes = document.querySelectorAll(".device-explain p");
if (deviceNotes[0]) deviceNotes[0].innerHTML = hu ? "📳 <strong>NFC</strong> — a telefont egy kompatibilis címkéhez érintve vészkártyát olvas vagy ír. Gyors, néhány centiméteres adatátadásra való." : "📳 <strong>NFC</strong> — touch a compatible tag to read or write the emergency card. It is a short-range handoff measured in centimetres.";
if (deviceNotes[1]) deviceNotes[1].innerHTML = hu ? "🛰️ <strong>MESH</strong> — az SOS- vagy HeroHUB-csomagot a készülék megosztási menüjén át egy telepített közeli/MESH alkalmazásnak adja. A fogadó alkalmazás továbbíthatja internet nélkül is, ha erre ténylegesen képes." : "🛰️ <strong>MESH</strong> — hands the SOS or HeroHUB packet to an installed nearby/MESH app through the system share sheet. A capable target app may relay it without internet.";
els.inviteButton.textContent = hu ? "+ MEGHÍVÁS" : "+ INVITE";
els.readmeButton.textContent = "README"; els.userGuideButton.textContent = hu ? "HASZNÁLATI ÚTMUTATÓ" : "USER GUIDE"; els.sourcesButton.textContent = hu ? "FORRÁSOK" : "SOURCES";
set("footer > p:first-child", "PROXAID v1.0 // offline készenléti réteg", "PROXAID v1.0 // OFFLINE READINESS LAYER");
set(".map-note", "Online globális OpenStreetMap; a Helyzetem vagy a Frissítés most a kiválasztott körzet utcáit offline-ra is menti. A Mutasd gomb utcaszintre visz.", "Global OpenStreetMap online; My Location or Refresh now also saves the selected area's streets for offline use. Show moves to street level.");
set(".invite-note", "Egy felkészült telefon: még egy offline útmutató, térkép és lehetséges továbbító pont.", "One prepared phone: another offline guide, map and possible relay point.");
const trustCards = document.querySelectorAll(".trust-grid article");
if (trustCards[0]) { trustCards[0].querySelector("h3").textContent = hu ? "Vészhelyzeti használat" : "Emergency use"; trustCards[0].querySelector("p").textContent = hu ? "Vészhelyzetben hívd a helyi segélyhívót, és kövesd a segélyirányító utasításait. A hely-, nyitvatartási és hozzáférési adatok változhatnak." : "In an emergency call the local emergency number and follow the dispatcher. Place, opening and access data can change."; }
if (trustCards[1]) { trustCards[1].querySelector("h3").textContent = hu ? "Helyi adatkezelés" : "Local data control"; trustCards[1].querySelector("p").textContent = hu ? "A keresés és a HeroHUB helyben marad. Online térkép/gyűjtéskor a kért terület az OpenStreetMap szolgáltatásaihoz kerül; nincs analitika vagy reklámkövetés." : "Search and HeroHUB stay local. Online map/discovery sends the requested area to OpenStreetMap services; there is no analytics or advertising tracking."; }
if (trustCards[2]) { trustCards[2].querySelector("h3").textContent = hu ? "Szakmai alap" : "Guidance basis"; trustCards[2].querySelector("p").textContent = hu ? "A beépített elsősegély-logika WHO, IFRC és Resuscitation Council útmutatóra támaszkodik; a segélyirányító utasítása elsőbbséget élvez." : "Built-in first-aid logic is based on WHO, IFRC and Resuscitation Council guidance; dispatcher instructions take priority."; }
els.hubBuildButton.textContent = hu ? "HELYZETLAP FRISSÍTÉSE" : "REFRESH INCIDENT CARD"; els.hubClearButton.textContent = hu ? "ÜRÍTÉS" : "CLEAR";
els.hubCopyButton.textContent = hu ? "MÁSOLÁS" : "COPY"; els.hubDownloadButton.textContent = hu ? "JSON MENTÉS" : "SAVE JSON";
set("#callDialogTitle", "Hívási lehetőségek", "Calling options"); set("#storageDialogTitle", "Tárhely felszabadítása", "Free storage"); set("#nfcDialogTitle", "NFC vészkártya", "NFC emergency card"); set("#installDialogTitle", "Telepítés", "Install");
set("#medicalCardTitle", "Helyi vészkártya", "Local medical card");
setLabel("#callNumberLabel", "Helyi segélyhívó száma", "Local emergency number"); els.useCallNumberButton.textContent = hu ? "SZÁM HASZNÁLATA" : "USE NUMBER";
els.medicalCardRisk.textContent = hu ? "⚠️ Csak azt add meg, amit vészhelyzetben megmutatnál. A feloldott készülékhez, QR-kódhoz vagy megírt NFC-címkéhez hozzáférő személy elolvashatja. A böngészőadatok törlése a helyi kártyát is törölheti." : "⚠️ Enter only what you would disclose in an emergency. Anyone with access to the unlocked device, QR code or written NFC tag can read it. Clearing browser data may delete the local card.";
const medicalLabels = document.querySelectorAll("#medicalCardForm label");
const medicalHu = ["Név — opcionális", "Születési dátum — opcionális", "Vércsoport", "Donornyilatkozat", "Allergiák", "Gyógyszerek / véralvadásgátló", "Betegségek / fontos kórelőzmény", "Implantátum / beültetett eszköz", "Vészhelyzeti kapcsolat", "Egyéb fontos információ"];
const medicalEn = ["Name — optional", "Date of birth — optional", "Blood type", "Donor declaration", "Allergies", "Medication / anticoagulant", "Conditions / relevant history", "Implant / implanted device", "Emergency contact", "Other critical information"];
medicalLabels.forEach((node, index) => { if (node.firstChild?.nodeType === Node.TEXT_NODE) node.firstChild.nodeValue = (hu ? medicalHu : medicalEn)[index] || node.firstChild.nodeValue; });
const bloodOptions = els.medicalBloodType.options; if (bloodOptions[0]) bloodOptions[0].textContent = hu ? "Ismeretlen" : "Unknown";
const donorOptions = els.medicalDonor.options; if (donorOptions[0]) donorOptions[0].textContent = hu ? "Nincs megadva" : "Not specified"; if (donorOptions[1]) donorOptions[1].textContent = hu ? "Igen" : "Yes"; if (donorOptions[2]) donorOptions[2].textContent = hu ? "Nem" : "No";
els.medicalSaveButton.textContent = hu ? "MENTÉS + QR" : "SAVE + QR"; els.medicalNfcButton.textContent = "📳 NFC"; els.medicalShareButton.textContent = hu ? "MEGOSZTÁS" : "SHARE"; els.medicalClearButton.textContent = hu ? "TÖRLÉS" : "DELETE";
set("#guideSpeakButton", "FELOLVASÁS", "READ ALOUD"); set("#nfcReadButton", "OLVASÁS", "READ"); set("#nfcWriteButton", "ÍRÁS", "WRITE"); set("#nfcShareButton", "MEGOSZTÁS", "SHARE");
setLabel("#heroHubForm label:nth-of-type(1)", "Helyszín / találkozási pont", "Location / meeting point");
setLabel("#heroHubForm label:nth-of-type(2)", "Veszély / esemény", "Hazard / event");
setLabel("#heroHubForm label:nth-of-type(3)", "Becsült érintett", "Estimated affected");
setLabel("#heroHubForm label:nth-of-type(4)", "Nem reagál / nem lélegzik normálisan", "Unresponsive / abnormal breathing");
setLabel("#heroHubForm label:nth-of-type(5)", "Súlyos vérzés", "Severe bleeding");
setLabel("#heroHubForm label:nth-of-type(6)", "Beszorult", "Trapped");
setLabel("#heroHubForm label:nth-of-type(7)", "Megközelítés / rövid megjegyzés", "Access / short note");
setLabel("#heroHubForm label:nth-of-type(8)", "Kapcsolat — opcionális", "Contact — optional");
}
function setupTheme() {
const saved = readLocal("proxaid-theme");
const initial = saved || (window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark");
document.documentElement.dataset.theme = initial;
updateThemeColor(initial);
els.themeButton.addEventListener("click", () => {
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
document.documentElement.dataset.theme = next;
writeLocal("proxaid-theme", next);
updateThemeColor(next);
});
}
function updateThemeColor(theme) { document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "light" ? "#eff7f3" : "#061410"); }
function openDatabase() {
return new Promise((resolve, reject) => {
if (!("indexedDB" in window)) return reject(new Error("IndexedDB unavailable"));
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("records")) {
const records = db.createObjectStore("records", { keyPath: "id" });
records.createIndex("packId", "_packId", { unique: false });
}
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta", { keyPath: "key" });
if (!db.objectStoreNames.contains("maps")) db.createObjectStore("maps", { keyPath: "id" });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function getMeta(key) {
if (!state.db) return Promise.resolve(null);
return new Promise((resolve, reject) => {
const request = state.db.transaction("meta", "readonly").objectStore("meta").get(key);
request.onsuccess = () => resolve(request.result?.value ?? null);
request.onerror = () => reject(request.error);
});
}
function setMeta(key, value) {
if (!state.db) return Promise.resolve();
return new Promise((resolve, reject) => {
const tx = state.db.transaction("meta", "readwrite");
tx.objectStore("meta").put({ key, value });
tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error);
});
}
function readAllRecords() {
if (!state.db) return Promise.resolve([...state.memoryRecords]);
return new Promise((resolve, reject) => {
const request = state.db.transaction("records", "readonly").objectStore("records").getAll();
request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error);
});
}
function getStoredMap(id) {
if (!state.db || !id || !state.db.objectStoreNames.contains("maps")) return Promise.resolve(null);
return new Promise((resolve, reject) => {
const request = state.db.transaction("maps", "readonly").objectStore("maps").get(id);
request.onsuccess = () => resolve(request.result || null); request.onerror = () => reject(request.error);
});
}
function storeMapPackage(entry) {
if (!state.db || !state.db.objectStoreNames.contains("maps")) return Promise.reject(new Error("Map storage unavailable"));
return new Promise((resolve, reject) => {
const tx = state.db.transaction("maps", "readwrite"); tx.objectStore("maps").put(entry);
tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error);
});
}
function validRecord(record) {
if (!record || !record.id || !record.name || !record.category) return false;
if (!record.coordinates) return true;
const { lat, lon } = record.coordinates;
return Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180;
}
async function storePack(pack) {
if (!pack || pack.schemaVersion !== 1 || !pack.packId || !Array.isArray(pack.records)) throw new Error("Ismeretlen adatcsomag");
const accepted = pack.records.filter(validRecord).map((record) => ({ ...record, _packId: pack.packId, _packVersion: pack.version, _storedAt: new Date().toISOString() }));
if (!state.db) {
state.memoryRecords = [...state.memoryRecords.filter((item) => item._packId !== pack.packId), ...accepted];
return accepted.length;
}
await new Promise((resolve, reject) => {
const tx = state.db.transaction("records", "readwrite"), store = tx.objectStore("records");
const cursor = store.index("packId").openKeyCursor(IDBKeyRange.only(pack.packId));
cursor.onsuccess = () => {
if (cursor.result) { store.delete(cursor.result.primaryKey); cursor.result.continue(); }
else accepted.forEach((record) => store.put(record));
};
cursor.onerror = () => tx.abort(); tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error);
});
return accepted.length;
}
async function fetchJson(path, preferNetwork = true) {
const response = await fetch(new URL(path, document.baseURI), { cache: preferNetwork ? "no-store" : "default", credentials: "same-origin", headers: { Accept: "application/json" } });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.json();
}
function globalCellId(point) {
const lat = Math.floor((point.lat + 90) * GLOBAL_CELL_SCALE), lon = Math.floor((point.lon + 180) * GLOBAL_CELL_SCALE);
return `osm-live-${lat}-${lon}`;
}
function globalMapId(point) { return globalCellId(point).replace("osm-live-", "osm-map-"); }
function overpassQuery(point) {
const around = `(around:${GLOBAL_SEARCH_RADIUS_M},${point.lat.toFixed(6)},${point.lon.toFixed(6)})`;
return `[out:json][timeout:90];(
nwr["emergency"~"^(defibrillator|phone|ambulance_station|mountain_rescue|assembly_point|water_rescue|lifeguard|access_point|emergency_ward_entrance)$"]${around};
nwr["amenity"~"^(hospital|clinic|doctors|pharmacy|police|fire_station|shelter|social_facility|drinking_water|toilets|shower|telephone)$"]${around};
nwr["healthcare"~"^(hospital|clinic|doctor|pharmacy|first_aid)$"]${around};
nwr["shop"~"^(chemist|medical_supply)$"]${around};
nwr["tourism"~"^(alpine_hut|wilderness_hut)$"]${around};
nwr["amenity"~"^(lavoir|fuel|charging_station|internet_cafe)$"]${around};
nwr["shop"="laundry"]${around};
nwr["changing_table"="yes"]${around};
nwr["office"="diplomatic"]${around};
);out center tags qt;`;
}
function overpassMapQuery(point) {
const around = `(around:${GLOBAL_MAP_RADIUS_M},${point.lat.toFixed(6)},${point.lon.toFixed(6)})`;
return `[out:json][timeout:120];(
node["place"~"^(city|town|village|hamlet|suburb|neighbourhood|locality)$"]${around};
way["highway"~"^(motorway|motorway_link|trunk|trunk_link|primary|primary_link|secondary|secondary_link|tertiary|tertiary_link|residential|unclassified|living_street|pedestrian|road|service|cycleway|footway|path|track)$"]${around};
way["waterway"~"^(river|stream|canal|drain)$"]${around};
way["natural"="water"]${around};
way["railway"]["railway"!~"^(abandoned|disused|razed)$"]${around};
);out tags geom qt;`;
}
function normalizeOnlineMap(payload, point, retrievedAt) {
const features = [];
for (const element of payload.elements || []) {
const tags = element.tags || {};
if (element.type === "node" && tags.place && Number.isFinite(element.lat) && Number.isFinite(element.lon)) {
features.push({ type: "Feature", id: `osm-node-${element.id}`, properties: { layer: "place", class: tags.place, name: tags["name:hu"] || tags["name:en"] || tags.name || null, source: "OpenStreetMap" }, geometry: { type: "Point", coordinates: [element.lon, element.lat] } });
continue;
}
const coordinates = (element.geometry || []).map((item) => [item.lon, item.lat]).filter((item) => item.every(Number.isFinite));
if (element.type !== "way" || coordinates.length < 2) continue;
let layer = null, klass = null;
if (tags.highway) { layer = "road"; klass = tags.highway; }
else if (tags.waterway) { layer = "waterway"; klass = tags.waterway; }
else if (tags.natural === "water") { layer = "water"; klass = tags.water || "water"; }
else if (tags.railway) { layer = "railway"; klass = tags.railway; }
if (!layer) continue;
const lastCoordinate = coordinates[coordinates.length - 1];
const closed = coordinates.length > 3 && coordinates[0][0] === lastCoordinate[0] && coordinates[0][1] === lastCoordinate[1];
features.push({ type: "Feature", id: `osm-way-${element.id}`, properties: { layer, class: klass, name: tags["name:hu"] || tags["name:en"] || tags.name || null, ref: tags.ref || null, source: "OpenStreetMap" }, geometry: closed && layer === "water" ? { type: "Polygon", coordinates: [coordinates] } : { type: "LineString", coordinates } });
if (features.length >= 30000) break;
}
const mapData = { type: "FeatureCollection", name: "PROXAID on-demand offline street map", metadata: { source: "OpenStreetMap contributors", retrievedAt, center: point, radiusMeters: GLOBAL_MAP_RADIUS_M, zoomRange: [10, 19] }, features };
mapData.bbox = geoJsonBbox(mapData);
return validMapData(mapData) ? mapData : null;
}
async function fetchNearbyOfflineMap(point, { force = false } = {}) {
if (!point) return null;
const mapId = globalMapId(point), stored = await getStoredMap(mapId).catch(() => null);
const refreshHours = Number(state.catalog?.globalDiscovery?.offlineMap?.refreshHours || state.catalog?.minimumRefreshHours || 720);
const refreshedAt = stored?.storedAt ? Date.parse(stored.storedAt) : 0;
if (stored?.data && (!force || Date.now() - refreshedAt < refreshHours * 60 * 60 * 1000)) { await setMeta("activeMapId", mapId); await applyOfflineMap(stored.data, stored, false); return stored; }
if (!navigator.onLine) return stored || null;
const body = new URLSearchParams({ data: overpassMapQuery(point) });
let payload = null, lastError = null;
for (const endpoint of OVERPASS_ENDPOINTS) {
try {
const response = await fetchWithTimeout(endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" }, body }, 125000);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
payload = await response.json(); break;
} catch (error) { lastError = error; }
}
if (!payload) throw lastError || new Error("Offline map lookup failed");
const retrievedAt = new Date().toISOString(), mapData = normalizeOnlineMap(payload, point, retrievedAt);
if (!mapData || mapData.features.length < 10) throw new Error("No usable street map returned");
const entry = { id: mapId, name: "On-demand offline street map", version: retrievedAt.slice(0, 10), bbox: mapData.bbox, source: "OpenStreetMap contributors", storedAt: retrievedAt, data: mapData };
await storeMapPackage(entry); await setMeta("activeMapId", mapId); await applyOfflineMap(mapData, entry, false); return entry;
}
async function fetchNearbyGlobalPack(point, { force = false } = {}) {
if (!navigator.onLine || !point) return null;
const packId = globalCellId(point), refreshHours = Number(state.catalog?.minimumRefreshHours || 720);
const lastFetch = await getMeta(`globalFetch:${packId}`).catch(() => null);
if (!force && lastFetch && Date.now() - Date.parse(lastFetch) < refreshHours * 60 * 60 * 1000) return null;
const body = new URLSearchParams({ data: overpassQuery(point) });
let payload = null, lastError = null;
for (const endpoint of OVERPASS_ENDPOINTS) {
try {
const response = await fetchWithTimeout(endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" }, body }, 95000);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
payload = await response.json(); break;
} catch (error) { lastError = error; }
}
if (!payload) throw lastError || new Error("Global POI lookup failed");
const retrievedAt = new Date().toISOString(), records = (payload.elements || []).map((element) => normalizeOnlineElement(element, retrievedAt)).filter(Boolean).slice(0, 20000);
if (!records.length) throw new Error("No usable global records returned");
const pack = { schemaVersion: 1, packId, version: retrievedAt.slice(0, 10).replaceAll("-", "."), generatedAt: retrievedAt, license: "OpenStreetMap contributors, ODbL 1.0", records };
await setMeta(`globalFetch:${packId}`, retrievedAt).catch(() => {});
return pack;
}
function fetchWithTimeout(url, options, timeoutMs) {
if (!("AbortController" in window)) return fetch(url, options);
const controller = new AbortController(), timer = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer));
}
function onlineCategory(tags) {
if (tags.emergency === "defibrillator") return "aed";
if (tags.emergency === "phone") return "emergency_phone";
if (tags.emergency === "ambulance_station") return "ambulance";
if (tags.emergency === "mountain_rescue") return "mountain_rescue";
if (tags.emergency === "assembly_point") return "assembly_point";
if (tags.emergency === "disaster_response") return "disaster_response";
if (tags.emergency === "water_rescue") return "water_rescue";
if (tags.emergency === "lifeguard") return "lifeguard";
if (tags.emergency === "access_point") return "emergency_access_point";
if (tags.emergency === "emergency_ward_entrance") return "hospital_emergency";
const healthcare = { hospital: "hospital", clinic: "clinic", doctor: "doctor", pharmacy: "pharmacy", first_aid: "first_aid" };
if (healthcare[tags.healthcare]) return healthcare[tags.healthcare];
const amenity = {
hospital: "hospital", clinic: "clinic", doctors: "doctor", pharmacy: "pharmacy", police: "police", fire_station: "fire_station",
shelter: "shelter", drinking_water: "drinking_water", toilets: tags.wheelchair === "yes" ? "accessible_toilets" : "toilets",
shower: "shower", telephone: "public_phone"
};
if (tags.amenity === "social_facility") return ["food_bank", "soup_kitchen"].includes(tags.social_facility) ? "food_assistance" : tags.social_facility === "shelter" ? "homeless_shelter" : "shelter";
if (amenity[tags.amenity]) return amenity[tags.amenity];
if (tags.tourism === "alpine_hut") return "alpine_hut";
if (tags.tourism === "wilderness_hut") return "wilderness_hut";
if (tags.shop === "chemist") return "pharmacy";
if (tags.shop === "medical_supply") return "medical_supply";
if (tags.shop === "laundry") return "laundry";
if (tags.amenity === "lavoir") return "washing";
if (tags.amenity === "fuel") return "fuel";
if (tags.amenity === "charging_station") return "charging";
if (tags.amenity === "internet_cafe") return "internet_access";
if (tags.office === "diplomatic") return "embassy";
if (tags.changing_table === "yes") return "baby_changing";
return null;
}
function onlineMemberships(category) {
if (["hospital", "clinic", "doctor", "pharmacy", "medical_supply"].includes(category)) return ["healthcare"];
if (["hospital_emergency", "aed", "ambulance", "emergency_phone"].includes(category)) return ["urgent", "healthcare"];
if (["police", "fire_station", "mountain_rescue", "water_rescue", "lifeguard", "assembly_point"].includes(category)) return ["rescue"];
if (["toilets", "accessible_toilets", "shower"].includes(category)) return ["hygiene"];
if (["washing", "laundry", "baby_changing"].includes(category)) return ["hygiene"];
if (["shelter", "homeless_shelter", "alpine_hut", "wilderness_hut", "food_assistance"].includes(category)) return ["shelter"];
if (category === "drinking_water") return ["water"];
if (["public_phone", "emergency_phone", "internet_access"].includes(category)) return ["connection"];
return [];
}
function onlineFallbackName(category) {
return state.language === "hu" ? (CATEGORY_LABELS[category]?.hu || FILTER_LABELS[category]?.hu || "Segítségpont") : (CATEGORY_LABELS[category]?.en || FILTER_LABELS[category]?.en || "Assistance point");
}
function normalizeOnlineElement(element, retrievedAt) {
const tags = element.tags || {}, category = onlineCategory(tags);
const lat = element.lat ?? element.center?.lat, lon = element.lon ?? element.center?.lon;
if (!category || !Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const languageTag = state.language === "hu" ? tags["name:hu"] : tags["name:en"];
const address = tags["addr:full"] || [tags["addr:postcode"], tags["addr:city"] || tags["addr:place"], tags["addr:street"], tags["addr:housenumber"]].filter(Boolean).join(" ") || null;
const website = normalizeOnlineUrl(tags["contact:website"] || tags.website || tags.url);
return {
id: `osm-${element.type}-${element.id}`, name: languageTag || tags.name || tags.operator || onlineFallbackName(category), category,
categories: onlineMemberships(category), kind: "place", description: tags.description || tags["defibrillator:location"] || tags.operator || null,
address, landmark: tags.loc_name || tags["addr:place"] || null, locality: tags["addr:city"] || tags["addr:place"] || null,
region: tags["addr:state"] || null, country: tags["addr:country"] || null,
phone: safePhone(tags["contact:phone"] || tags.phone) || null, email: tags["contact:email"] || tags.email || null, website,
contacts: {
mobile: safePhone(tags["contact:mobile"] || tags.mobile) || null, fax: safePhone(tags["contact:fax"] || tags.fax) || null,
facebook: normalizeOnlineUrl(tags["contact:facebook"] || tags.facebook), instagram: normalizeOnlineUrl(tags["contact:instagram"] || tags.instagram),
linkedin: normalizeOnlineUrl(tags["contact:linkedin"] || tags.linkedin), twitter: normalizeOnlineUrl(tags["contact:twitter"] || tags.twitter),
mastodon: normalizeOnlineUrl(tags["contact:mastodon"] || tags.mastodon), youtube: normalizeOnlineUrl(tags["contact:youtube"] || tags.youtube),
telegram: String(tags["contact:telegram"] || tags.telegram || "").trim() || null, whatsapp: String(tags["contact:whatsapp"] || tags.whatsapp || "").trim() || null
},
coordinates: { lat, lon }, openingHours: tags.opening_hours || null,
access: tags.access || null, wheelchair: tags.wheelchair || null, verification: "community_source",
confidence: tags.source || tags.check_date ? "medium" : "unverified", tags: [tags.operator, tags.brand, tags.description].filter(Boolean),
source: { name: "OpenStreetMap contributors", url: `https://www.openstreetmap.org/${element.type}/${element.id}`, retrievedAt: retrievedAt.slice(0, 10), checkedAt: retrievedAt }
};
}
function normalizeOnlineUrl(value) {
if (!value) return null;
try { return new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`).href; }
catch { return null; }
}
async function syncData({ force = false, reason = "manual" } = {}) {
if (state.syncing) return;
state.syncing = true; els.syncButton.disabled = true;
els.syncMessage.textContent = state.language === "hu" ? "Adatok ellenőrzése…" : "Checking data…";
try {
state.catalog = await fetchJson("./data/catalog.json", navigator.onLine);
const installed = new Set(await getMeta("installedPackIds").catch(() => []) || []);
const installedVersions = await getMeta("installedPackVersions").catch(() => ({})) || {};
let total = 0;
for (const descriptor of state.catalog.packs || []) {
const inRegion = state.userLocation && pointInBbox(state.userLocation, descriptor.bbox);
if (!(descriptor.required || descriptor.defaultInstall || installed.has(descriptor.id) || (descriptor.autoInstall && inRegion))) continue;
if (!force && installed.has(descriptor.id) && installedVersions[descriptor.id] === descriptor.version) continue;
const pack = await fetchJson(descriptor.url, navigator.onLine);
total += await storePack(pack); installed.add(descriptor.id); installedVersions[descriptor.id] = descriptor.version;
}
let globalCount = 0, mapFeatureCount = 0;
const shouldDiscover = navigator.onLine && ["manual", "location", "online", "startup", "migration"].includes(reason);
if (shouldDiscover) {
const mapCenter = reason === "manual" ? state.map?.getCenter() : null;
const discoveryPoint = mapCenter ? { lat: mapCenter.lat, lon: mapCenter.lng } : state.userLocation;
if (discoveryPoint) {
const [mapResult, poiResult] = await Promise.allSettled([
ensureOfflineMapForPoint(discoveryPoint, { force }),
fetchNearbyGlobalPack(discoveryPoint, { force })
]);
if (mapResult.status === "fulfilled") mapFeatureCount = mapResult.value?.data?.features?.length || state.mapData?.features?.length || 0;
if (poiResult.status === "fulfilled" && poiResult.value) {
const globalPack = poiResult.value; globalCount = await storePack(globalPack); installed.add(globalPack.packId); installedVersions[globalPack.packId] = globalPack.version; total += globalCount;
}
if (mapResult.status === "rejected" && poiResult.status === "rejected") els.syncMessage.textContent = state.language === "hu" ? "Az online területgyűjtés most nem válaszolt; a letöltött adatok megmaradtak." : "Online area discovery did not respond; downloaded data remains available.";
}
}
const now = new Date().toISOString();
await setMeta("lastSync", now); await setMeta("lastSyncReason", reason); await setMeta("installedPackIds", [...installed]); await setMeta("installedPackVersions", installedVersions); await setMeta("dataRevision", DATA_REVISION);
state.records = await readAllRecords();
els.syncMessage.textContent = total
? (state.language === "hu" ? `${total} rekord frissítve${globalCount ? `, ebből ${globalCount} a jelenlegi 15 km-es körzetből` : ""}${mapFeatureCount ? `; ${mapFeatureCount} offline térképelem` : ""}. ${state.records.length} rekord használatra kész.` : `${total} records refreshed${globalCount ? `, including ${globalCount} within the current 15 km area` : ""}${mapFeatureCount ? `; ${mapFeatureCount} offline map features` : ""}. ${state.records.length} records ready.`)
: (state.language === "hu" ? `${state.records.length} rekord naprakész${mapFeatureCount ? `; ${mapFeatureCount} offline térképelem használatra kész` : ""}.` : `${state.records.length} records are current${mapFeatureCount ? `; ${mapFeatureCount} offline map features ready` : ""}.`);
navigator.serviceWorker?.controller?.postMessage({ type: "SYNC_NOW" });
} catch {
els.syncMessage.textContent = state.records.length ? (state.language === "hu" ? "A korábbi helyi adatok használhatók." : "Existing local data is ready.") : (state.language === "hu" ? "Az induló adatcsomag nem tölthető be." : "The starter data pack could not be loaded.");
} finally {
state.syncing = false; els.syncButton.disabled = false; await updateStats(); applyFilters();
}
}
function setupFilters() {
const fragment = document.createDocumentFragment();
FILTERS.forEach((category) => {
const button = document.createElement("button");
button.type = "button"; button.className = "filter-chip"; button.dataset.category = category;
button.textContent = filterLabel(category); button.setAttribute("aria-pressed", String(category === state.category));
button.addEventListener("click", () => {
state.category = category;
state.query = "";
els.searchInput.value = "";
[...els.categoryFilters.children].forEach((item) => item.setAttribute("aria-pressed", String(item.dataset.category === category)));
applyFilters();
});
fragment.append(button);
});
els.categoryFilters.replaceChildren(fragment);
updateFilterCounts();
}
function updateFilterCounts() {
const unique = dedupeRecords(state.records).filter(recordApplicable);
[...els.categoryFilters.children].forEach((button) => {
const category = button.dataset.category;
const count = unique.filter((record) => matchesFilter(record, category)).length;
button.textContent = `${filterLabel(category)} · ${count}`;
button.setAttribute("aria-label", `${filterLabel(category)}: ${count}`);
});
}
function recordSearchText(record) {
const categories = recordCategories(record);
return normalize([record.name, ...categories, ...categories.map(categoryLabel), record.description, record.address, record.landmark, record.locality, record.region, record.country, record.operator, record.phone, record.email, record.website, ...(record.tags || [])].join(" "));
}
function editDistance(a, b) {
if (Math.abs(a.length - b.length) > 3) return 99;
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
for (let i = 1; i <= a.length; i += 1) {
let previous = row[0]; row[0] = i;
for (let j = 1; j <= b.length; j += 1) {
const saved = row[j]; row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1)); previous = saved;
}
}
return row[b.length];
}
function fuzzyMatch(haystack, query) {
if (!query || haystack.includes(query)) return true;
const queryWords = query.split(" ").filter(Boolean), words = haystack.split(" ").filter(Boolean);
return queryWords.every((queryWord) => words.some((word) => word.startsWith(queryWord) || queryWord.startsWith(word) || (queryWord.length >= 5 && editDistance(word, queryWord) <= 2)));
}
function detectIntent(query) {
const normalizedQuery = normalize(query);
if (!normalizedQuery || !state.firstAid) return null;
let best = null;
for (const intent of state.firstAid.intents || []) {
let score = 0;
for (const keyword of intent.keywords || []) {
const normalizedKeyword = normalize(keyword);
if (normalizedQuery.includes(normalizedKeyword)) score += 100 + normalizedKeyword.length;
else if (fuzzyMatch(normalizedQuery, normalizedKeyword) || fuzzyMatch(normalizedKeyword, normalizedQuery)) score += 25;
}
if (score && (!best || score + intent.priority > best.score)) best = { intent, score: score + intent.priority };
}
return best?.intent || null;
}
function dedupeRecords(records) {
const rank = { official_directory: 3, source_linked: 2, community_source: 1 };
const kept = [];
[...records].sort((a, b) => (rank[b.verification] || 0) - (rank[a.verification] || 0)).forEach((record) => {
const duplicate = record.coordinates && kept.some((other) => {
if (!other.coordinates || other.category !== record.category) return false;
const distance = haversine(record.coordinates, other.coordinates);
return distance != null && distance <= (record.category === "aed" ? .12 : .025)
&& (record.category === "aed" || normalize(record.name) === normalize(other.name));
});
if (!duplicate) kept.push(record);
});
return kept;
}
function renderIntentSuggestion(intent) {
state.activeIntent = intent;
if (!intent) { els.firstAidSuggestion.hidden = true; els.firstAidSuggestion.replaceChildren(); return; }
const copy = document.createElement("div"), title = document.createElement("strong"), summary = document.createElement("span"), button = document.createElement("button");
title.textContent = text(intent.title); summary.textContent = text(intent.summary); button.type = "button"; button.textContent = ui("details");
button.addEventListener("click", () => openGuide(intent)); copy.append(title, summary); els.firstAidSuggestion.replaceChildren(copy, button); els.firstAidSuggestion.hidden = false;
}
function applyFilters() {
const query = normalize(state.query), intent = detectIntent(state.query); renderIntentSuggestion(intent);
state.visibleRecords = dedupeRecords(state.records)
.filter(recordApplicable)
.filter((record) => matchesFilter(record, state.category))
.filter((record) => !query || fuzzyMatch(recordSearchText(record), query))
.map((record) => ({ ...record, _distance: record.coordinates && state.userLocation ? haversine(state.userLocation, record.coordinates) : null }))
.sort((a, b) => {
if (a._distance != null && b._distance != null) return a._distance - b._distance;
if (a._distance != null) return -1; if (b._distance != null) return 1;
return a.name.localeCompare(b.name, state.language);
});
updateFilterCounts(); renderResults(state.visibleRecords); renderPoiLayer();
}
function renderResults(records) {
els.resultCount.textContent = `${records.length} ${ui("results")}`;
const fragment = document.createDocumentFragment();
if (!records.length) {
const empty = document.createElement("div"); empty.className = "empty-state";
const message = document.createElement("p"); message.textContent = ui("noResults"); empty.append(message);
if (navigator.onLine) {
const aiButton = document.createElement("button"); aiButton.type = "button"; aiButton.className = "secondary-button"; aiButton.textContent = ui("aiSearch");
aiButton.addEventListener("click", () => shareSearchPrompt()); empty.append(aiButton);
}
fragment.append(empty);
}
records.slice(0, 100).forEach((record) => fragment.append(createResultCard(record)));
els.resultsList.replaceChildren(fragment);
}
function searchPrompt() {
const center = state.map?.getCenter();
const location = state.userLocation
? `${state.userLocation.lat.toFixed(6)}, ${state.userLocation.lon.toFixed(6)}`
: center ? `${center.lat.toFixed(6)}, ${center.lng.toFixed(6)}` : (state.language === "hu" ? "a felhasználó jelenlegi helye" : "the user's current location");
const need = state.query || filterLabel(state.category);
return state.language === "hu"
? `Keress most nyitva és ténylegesen elérhető ${need} helyet ${location} közelében. Adj pontos nevet, címet vagy tájékozódási pontot, GPS-koordinátát, minden publikus telefonos és online elérhetőséget, élő hivatalos URL-t, teljes nyitvatartást, hozzáférést, két közvetlen forráslinket és ISO ellenőrzési időt. Minden URL-t ellenőrizz, a találatot lehetőleg két független aktuális forrással erősítsd meg. Hiányzó adatot ne találj ki.`
: `Find an actually accessible ${need} near ${location} that is open now. Return exact name, address or landmark, GPS coordinates, every public phone and online contact, a live official URL, full opening hours, access, two direct source links and an ISO verification time. Check every URL and confirm the result with two independent current sources where possible. Do not invent missing data.`;
}
function shareSearchPrompt() { sharePayload(searchPrompt(), "PROXAID search prompt"); }
function createResultCard(record) {
const article = document.createElement("article"); article.className = "result-card";
const titleRow = document.createElement("div"); titleRow.className = "result-title-row";
const title = document.createElement("h3"); title.textContent = record.name;
const tag = document.createElement("span"); tag.className = `tag ${record.verification === "official_directory" ? "verified" : ""}`; tag.textContent = record.verification === "official_directory" ? (state.language === "hu" ? "hivatalos" : "official") : (state.language === "hu" ? "forrásjelölt" : "source-linked");
titleRow.append(title, tag);
const description = document.createElement("p"); description.textContent = record.description || categoryLabel(record.category);
const meta = document.createElement("div"); meta.className = "result-meta";
const location = recordLocation(record);
if (location) appendMeta(meta, `⌖ ${location}`, "location");
if (record.coordinates) appendMeta(meta, `GPS ${record.coordinates.lat.toFixed(6)}, ${record.coordinates.lon.toFixed(6)}`, "coordinates");
if (record._distance != null) appendMeta(meta, record._distance < 10 ? `${record._distance.toFixed(1)} km` : `${Math.round(record._distance)} km`, "distance");
const today = todayOpening(record.openingHours);
if (today) appendMeta(meta, `◷ ${today}`, "open-today");
const currentStatus = openingNow(record.openingHours);
if (currentStatus) appendMeta(meta, currentStatus.label, `opening-now ${currentStatus.open ? "open" : "closed"}`);
if (record.source?.retrievedAt) appendMeta(meta, `${state.language === "hu" ? "adat" : "data"}: ${record.source.retrievedAt}`);
const actions = document.createElement("div"); actions.className = "result-actions";
const phone = safePhone(record.phone);
if (phone) { const call = document.createElement("button"); call.type = "button"; call.className = `call ${phone === "1830" ? "lower-level" : ""}`; call.textContent = `${ui("call")} ${record.phone}`; call.addEventListener("click", () => openCallOptions(phone)); actions.append(call); }
if (record.coordinates) { const show = document.createElement("button"); show.type = "button"; show.textContent = ui("show"); show.addEventListener("click", () => focusRecord(record)); actions.append(show); }
if (record.coordinates) { const route = document.createElement("button"); route.type = "button"; route.textContent = ui("route"); route.addEventListener("click", () => openNavigationOptions(record)); actions.append(route); }
const details = document.createElement("button"); details.type = "button"; details.textContent = ui("recordDetails"); details.addEventListener("click", () => openRecordDetails(record)); actions.append(details);
const websiteUrl = navigator.onLine ? safeUrl(record.website) : null;
if (websiteUrl) { const website = document.createElement("a"); website.href = websiteUrl; website.target = "_blank"; website.rel = "noopener noreferrer"; website.textContent = ui("website"); actions.append(website); }
article.append(titleRow, description, meta, actions); return article;
}
function appendMeta(container, value, className = "") { const span = document.createElement("span"); span.textContent = value; if (className) span.className = className; container.append(span); }
function detailRow(label, value, { href = null } = {}) {
if (!value) return null;
const paragraph = document.createElement("p"), strong = document.createElement("strong"); strong.textContent = `${label}: `; paragraph.append(strong);
if (href) { const link = document.createElement("a"); link.href = href; link.textContent = value; link.target = href.startsWith("http") ? "_blank" : "_self"; link.rel = "noopener noreferrer"; paragraph.append(link); }
else paragraph.append(document.createTextNode(value));
return paragraph;
}
function contactUrl(service, value) {
const direct = safeUrl(value); if (direct) return direct;
const raw = String(value || "").trim(); if (!raw) return null;
if (service === "Telegram") return `https://t.me/${raw.replace(/^@/, "")}`;
if (service === "WhatsApp") { const digits = raw.replace(/\D/g, ""); return digits ? `https://wa.me/${digits}` : null; }
return null;
}
function openingHoursBlock(value) {
const section = document.createElement("section"), heading = document.createElement("h3"), list = document.createElement("ul");
heading.textContent = state.language === "hu" ? "Teljes nyitvatartás" : "Full opening hours";
const rules = String(value || "").split(";").map((item) => item.trim()).filter(Boolean);
(rules.length ? rules : [state.language === "hu" ? "Nincs közzétéve." : "Not published."]).forEach((rule) => { const item = document.createElement("li"); item.textContent = rule.replaceAll("-", "–"); list.append(item); });
section.append(heading, list); return section;
}
function openRecordDetails(record) {
els.docDialogTitle.textContent = record.name;
const nodes = [], location = recordLocation(record), phone = safePhone(record.phone), website = safeUrl(record.website), email = String(record.email || "").trim();
nodes.push(detailRow(state.language === "hu" ? "Kategória" : "Category", categoryLabel(record.category)));
nodes.push(detailRow(state.language === "hu" ? "Leírás" : "Description", record.description));
nodes.push(detailRow(state.language === "hu" ? "Cím / tájékozódási pont" : "Address / landmark", location));
if (record.coordinates) nodes.push(detailRow("GPS", `${record.coordinates.lat.toFixed(6)}, ${record.coordinates.lon.toFixed(6)}`, { href: navigator.onLine ? `https://www.openstreetmap.org/?mlat=${record.coordinates.lat}&mlon=${record.coordinates.lon}#map=17/${record.coordinates.lat}/${record.coordinates.lon}` : null }));
nodes.push(detailRow(state.language === "hu" ? "Mai nyitvatartás" : "Today", todayOpening(record.openingHours)));
nodes.push(detailRow(state.language === "hu" ? "Aktuális állapot" : "Current status", openingNow(record.openingHours)?.label));
nodes.push(openingHoursBlock(record.openingHours));
nodes.push(detailRow(state.language === "hu" ? "Telefon" : "Phone", record.phone, { href: phone ? `tel:${phone}` : null }));
nodes.push(detailRow(state.language === "hu" ? "Mobil" : "Mobile", record.contacts?.mobile, { href: record.contacts?.mobile ? `tel:${safePhone(record.contacts.mobile)}` : null }));
nodes.push(detailRow("Fax", record.contacts?.fax));
nodes.push(detailRow("E-mail", email, { href: /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) ? `mailto:${email}` : null }));
if (navigator.onLine) {
nodes.push(detailRow(state.language === "hu" ? "Weboldal" : "Website", website, { href: website }));
nodes.push(detailRow("Facebook", safeUrl(record.contacts?.facebook), { href: safeUrl(record.contacts?.facebook) }));
nodes.push(detailRow("Instagram", safeUrl(record.contacts?.instagram), { href: safeUrl(record.contacts?.instagram) }));
for (const service of ["LinkedIn", "Twitter", "Mastodon", "YouTube", "Telegram", "WhatsApp"]) {
const key = service.toLowerCase(), url = contactUrl(service, record.contacts?.[key]); nodes.push(detailRow(service, url, { href: url }));
}
}
nodes.push(detailRow(state.language === "hu" ? "Hozzáférés" : "Access", record.access || record.accessibility?.access));
nodes.push(detailRow(state.language === "hu" ? "Akadálymentesség" : "Wheelchair", record.wheelchair || record.accessibility?.wheelchair));
const sourceUrls = [...new Set([record.source?.url, ...(record.source?.urls || [])].map(safeUrl).filter(Boolean))];
sourceUrls.forEach((url, index) => nodes.push(detailRow(`${state.language === "hu" ? "Forrás" : "Source"}${sourceUrls.length > 1 ? ` ${index + 1}` : ""}`, record.source?.name && index === 0 ? record.source.name : url, { href: navigator.onLine ? url : null })));
nodes.push(detailRow(state.language === "hu" ? "Adat ellenőrizve" : "Data checked", record.source?.checkedAt || record.source?.retrievedAt));
if (record.coordinates) {
const navigation = document.createElement("button"); navigation.type = "button"; navigation.className = "primary-button"; navigation.textContent = state.language === "hu" ? "ÚTVONAL / NAVIGÁCIÓ" : "ROUTE / NAVIGATION"; navigation.addEventListener("click", () => openNavigationOptions(record)); nodes.unshift(navigation);
}
els.docContent.replaceChildren(...nodes.filter(Boolean)); openDialog(els.docDialog);
}
function todayOpening(value, date = new Date()) {
if (!value) return null;
const raw = String(value).trim();
if (!raw) return null;
if (raw === "24/7") return state.language === "hu" ? "Ma: 0–24" : "Today: 24 hours";
const dayCodes = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], today = dayCodes[date.getDay()];
let parsedDayRule = false;
for (const part of raw.split(";").map((item) => item.trim()).filter(Boolean)) {
const match = part.match(/^(Mo|Tu|We|Th|Fr|Sa|Su)(?:-(Mo|Tu|We|Th|Fr|Sa|Su))?\s+(.+)$/);
if (!match) {
if (/^\d{2}:\d{2}-/.test(part)) return `${state.language === "hu" ? "Ma" : "Today"}: ${part.replaceAll("-", "–")}`;
continue;
}
parsedDayRule = true;
const start = dayCodes.indexOf(match[1]), end = dayCodes.indexOf(match[2] || match[1]), current = dayCodes.indexOf(today);
const applies = start <= end ? current >= start && current <= end : current >= start || current <= end;
if (applies) return `${state.language === "hu" ? "Ma" : "Today"}: ${match[3].replaceAll("-", "–")}`;
}
if (parsedDayRule) return state.language === "hu" ? "Ma: zárva" : "Today: closed";
return `${state.language === "hu" ? "Nyitvatartás" : "Hours"}: ${raw}`;
}
function openingNow(value, date = new Date()) {
if (!value) return null;
const raw = String(value).trim();
if (raw === "24/7") return { open: true, label: state.language === "hu" ? "● NYITVA MOST" : "● OPEN NOW" };
const dayCodes = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], currentDay = date.getDay(), minutesNow = date.getHours() * 60 + date.getMinutes();
let matchedDay = false;
for (const part of raw.split(";").map((item) => item.trim()).filter(Boolean)) {
const match = part.match(/^(Mo|Tu|We|Th|Fr|Sa|Su)(?:-(Mo|Tu|We|Th|Fr|Sa|Su))?\s+(.+)$/);
if (!match) continue;
const start = dayCodes.indexOf(match[1]), end = dayCodes.indexOf(match[2] || match[1]);
const applies = start <= end ? currentDay >= start && currentDay <= end : currentDay >= start || currentDay <= end;
if (!applies) continue;
matchedDay = true;
if (/^(off|closed)$/i.test(match[3])) return { open: false, label: state.language === "hu" ? "● ZÁRVA" : "● CLOSED" };
for (const range of match[3].split(",").map((item) => item.trim())) {
const time = range.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/); if (!time) continue;
const from = Number(time[1]) * 60 + Number(time[2]), to = Number(time[3]) * 60 + Number(time[4]);
const open = from <= to ? minutesNow >= from && minutesNow < to : minutesNow >= from || minutesNow < to;
if (open) return { open: true, label: state.language === "hu" ? "● NYITVA MOST" : "● OPEN NOW" };
}
}
if (matchedDay) return { open: false, label: state.language === "hu" ? "● ZÁRVA" : "● CLOSED" };
return null;
}
function recordLocation(record) {
if (typeof record.address === "string" && record.address.trim()) return record.address.trim();
if (record.address && typeof record.address === "object") {
const street = [record.address.street || record.address.place, record.address.housenumber].filter(Boolean).join(" ");
const address = [record.address.postcode, record.address.city || record.locality, street].filter(Boolean).join(" ");
if (address) return address;
}
if (record.landmark) return [record.locality, record.landmark].filter(Boolean).join(" · ");
const context = nearestMapContext(record);
const parts = [record.locality || context.place, context.road ? `${context.road} ${state.language === "hu" ? "közelében" : "nearby"}` : null].filter(Boolean);
return parts.join(" · ") || [record.region, record.country].filter(Boolean).join(" · ");
}
function nearestMapContext(record) {
if (!record?.coordinates || !state.mapData?.features) return {};
if (state.mapContextCache.has(record.id)) return state.mapContextCache.get(record.id);
let bestRoad = null, bestRoadKm = Infinity, bestPlace = null, bestPlaceKm = Infinity;
for (const feature of state.mapData.features) {
const properties = feature.properties || {}, point = featureMidpoint(feature);
if (!point || (!properties.name && !properties.ref)) continue;
const distance = haversine(record.coordinates, { lat: point[0], lon: point[1] });
if (properties.layer === "place" && distance < bestPlaceKm) { bestPlaceKm = distance; bestPlace = properties.name; }
if (properties.layer === "road" && distance < bestRoadKm) { bestRoadKm = distance; bestRoad = properties.name || properties.ref; }
}
const result = { road: bestRoadKm <= 2 ? bestRoad : null, place: bestPlaceKm <= 25 ? bestPlace : null };
state.mapContextCache.set(record.id, result); return result;
}
async function loadFirstAid() {
try { state.firstAid = await fetchJson("./data/first-aid.json", false); els.guideState.textContent = ui("loaded"); els.guideState.className = "ready"; }
catch { state.firstAid = null; els.guideState.textContent = ui("missing"); els.guideState.className = "missing"; }
}
function localEmergencyText(value) {
const message = String(value || "");
return state.emergencyNumber && state.emergencyNumber !== "112" ? message.replace(/\b112\b/g, state.emergencyNumber) : message;
}
function localGuideSteps(item) { return (item?.steps?.[state.language] || item?.steps?.en || []).map(localEmergencyText); }
function openGuide(intent) {
state.activeIntent = intent; els.guideTitle.textContent = text(intent.title); els.guideSummary.textContent = text(intent.summary);
els.guideSteps.replaceChildren(...localGuideSteps(intent).map((step) => { const li = document.createElement("li"); li.textContent = step; return li; }));
const modeButtons = (intent.cprModes || []).map((modeId) => {
const button = document.createElement("button"); button.type = "button"; button.className = "cpr-mode";
const strong = document.createElement("strong"), span = document.createElement("span"); const mode = state.firstAid.cprModes[modeId];
strong.textContent = text(mode.label); span.textContent = text(mode.when); button.append(strong, span);
button.addEventListener("click", () => { closeDialog(els.guideDialog); selectCprMode(modeId, true); document.querySelector(".cpr-panel")?.scrollIntoView({ behavior: "smooth", block: "start" }); });
return button;
});
els.guideCprModes.replaceChildren(...modeButtons);
const sourceLinks = (intent.sources || []).map((sourceId) => {
const sourceData = state.firstAid.sources[sourceId], link = document.createElement("a"); link.textContent = sourceData?.name || sourceId;
link.href = sourceData?.url || "#"; link.target = "_blank"; link.rel = "noopener noreferrer";
if (!navigator.onLine) { link.removeAttribute("href"); link.setAttribute("aria-disabled", "true"); }
return link;
});
const note = document.createElement("em"); note.textContent = `*${text(state.firstAid.sourceNote)}*`; els.guideSources.replaceChildren(note, ...sourceLinks);
els.guideOfflineWarning.textContent = state.language === "hu" ? "*Ez a forrás offline nem érhető el. Elnézésed kérjük!" : "*This source is unavailable offline. Sorry!";
els.guideOfflineWarning.hidden = navigator.onLine; openDialog(els.guideDialog);
speak([text(intent.title), text(intent.summary), ...localGuideSteps(intent)].join(". "));
}