iobroker.jetframe 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +357 -0
- package/admin/SF-Pro.ttf +0 -0
- package/admin/admin.d.ts +65 -0
- package/admin/frame.html +982 -0
- package/admin/frame.html.bak-aircraft-card-real-row-20260518-1608 +1236 -0
- package/admin/frame.html.bak-aircraft-card-structure-20260518-1517 +1236 -0
- package/admin/frame.html.bak-aircraft-logo-id-fix-20260518-1639 +1239 -0
- package/admin/frame.html.bak-shortcut-test +1236 -0
- package/admin/frame.html.bak-tablet-class-20260518-1729 +1239 -0
- package/admin/heatmap.html +216 -0
- package/admin/index.html +268 -0
- package/admin/index_m.html +1749 -0
- package/admin/jetframe.css +1260 -0
- package/admin/jetframe.css.bak-airbus-landscape-fix +4630 -0
- package/admin/jetframe.css.bak-aircraft-card-clean-equal-20260518-1438 +4899 -0
- package/admin/jetframe.css.bak-aircraft-card-real-row-20260518-1608 +4814 -0
- package/admin/jetframe.css.bak-aircraft-card-row-left-20260518-1525 +4604 -0
- package/admin/jetframe.css.bak-aircraft-card-slim-equal-20260518-1446 +4647 -0
- package/admin/jetframe.css.bak-aircraft-card-structure-20260518-1517 +4646 -0
- package/admin/jetframe.css.bak-aircraft-inline-final-20260518-1527 +4654 -0
- package/admin/jetframe.css.bak-aircraft-row-compact-fix-20260518-1639 +4763 -0
- package/admin/jetframe.css.bak-before-aircrafttype-purge +4818 -0
- package/admin/jetframe.css.bak-before-cleanup +4670 -0
- package/admin/jetframe.css.bak-before-remove-tablet-only-20260518-1711 +4896 -0
- package/admin/jetframe.css.bak-before-tablet-layout-rework-20260518-1650 +4914 -0
- package/admin/jetframe.css.bak-clean-duplicate-fonts-20260518-1340 +4975 -0
- package/admin/jetframe.css.bak-clean-old-index-fix-20260518-1937 +5167 -0
- package/admin/jetframe.css.bak-hardleft-airbus +4751 -0
- package/admin/jetframe.css.bak-index-iphone-landscape-20260518-1931 +5030 -0
- package/admin/jetframe.css.bak-index-landscape-final-20260518-1941 +5167 -0
- package/admin/jetframe.css.bak-index-landscape-real-20260518-1936 +5186 -0
- package/admin/jetframe.css.bak-landscape-compact-jumbo-bold-20260518-1343 +4802 -0
- package/admin/jetframe.css.bak-logo-align-final +4551 -0
- package/admin/jetframe.css.bak-logo-final2 +4551 -0
- package/admin/jetframe.css.bak-narrowbody-font-fix +4992 -0
- package/admin/jetframe.css.bak-nuke-airbus-align +4790 -0
- package/admin/jetframe.css.bak-pill-balance-20260518-1603 +4773 -0
- package/admin/jetframe.css.bak-pill-balance-fix +4910 -0
- package/admin/jetframe.css.bak-radar-fix-fonts +4710 -0
- package/admin/jetframe.css.bak-shortcut-test +4899 -0
- package/admin/jetframe.css.bak-smaller-aircraft-card-fonts-20260518-1345 +4897 -0
- package/admin/jetframe.css.bak-tablet-fix-real-20260518-1748 +4945 -0
- package/admin/jetframe.css.bak-tablet-fullscreen-fix-20260518-1804 +4972 -0
- package/admin/jetframe.css.bak-tablet-landscape-layout-20260518-1645 +4802 -0
- package/admin/jetframe.css.bak-tablet-layout-final-20260518-1839 +4802 -0
- package/admin/jetframe.css.bak-tablet-layout-v3-20260518-1729 +4802 -0
- package/admin/jetframe.css.bak-tablet-layout-v4-20260518-1801 +4957 -0
- package/admin/jetframe.css.bak-tablet-layout-v5-20260518-1843 +4970 -0
- package/admin/jetframe.css.bak-tablet-layout-v6-20260518-1848 +4958 -0
- package/admin/jetframe.css.bak-tablet-layout-v7-20260518-1909 +4985 -0
- package/admin/jetframe.css.bak-tablet-only-landscape-v2-20260518-1707 +4802 -0
- package/admin/jetframe.css.bak-tablet-pages-final-20260519-1857 +5188 -0
- package/admin/jetframe.css.bak-tablet-pages-final-20260519-1859 +5347 -0
- package/admin/jetframe.css.bak-tablet-pages-v2-20260519-190807 +5349 -0
- package/admin/jetframe.css.bak-typography-align-final +4818 -0
- package/admin/jetframe.png +0 -0
- package/admin/manifest.webmanifest +15 -0
- package/admin/src/app.tsx +58 -0
- package/admin/src/components/settings.tsx +97 -0
- package/admin/src/i18n/de.json +11 -0
- package/admin/src/i18n/en.json +11 -0
- package/admin/src/i18n/es.json +11 -0
- package/admin/src/i18n/fr.json +11 -0
- package/admin/src/i18n/i18n.d.ts +28 -0
- package/admin/src/i18n/it.json +11 -0
- package/admin/src/i18n/nl.json +11 -0
- package/admin/src/i18n/pl.json +11 -0
- package/admin/src/i18n/pt.json +11 -0
- package/admin/src/i18n/ru.json +11 -0
- package/admin/src/i18n/uk.json +11 -0
- package/admin/src/i18n/zh-cn.json +11 -0
- package/admin/src/index.tsx +25 -0
- package/admin/stats.html +228 -0
- package/admin/style.css +32 -0
- package/admin/tsconfig.json +11 -0
- package/admin/words.js +46 -0
- package/build/lib/adsb.js +218 -0
- package/build/lib/adsb.js.map +7 -0
- package/build/lib/airportNamesDe.js +131 -0
- package/build/lib/airportNamesDe.js.map +7 -0
- package/build/lib/airports.js +281 -0
- package/build/lib/airports.js.map +7 -0
- package/build/lib/classify.js +339 -0
- package/build/lib/classify.js.map +7 -0
- package/build/lib/config.js +103 -0
- package/build/lib/config.js.map +7 -0
- package/build/lib/flightInfo.js +1409 -0
- package/build/lib/flightInfo.js.map +7 -0
- package/build/lib/geo.js +84 -0
- package/build/lib/geo.js.map +7 -0
- package/build/lib/images.js +422 -0
- package/build/lib/images.js.map +7 -0
- package/build/lib/specialLiveries.js +342 -0
- package/build/lib/specialLiveries.js.map +7 -0
- package/build/lib/states.js +971 -0
- package/build/lib/states.js.map +7 -0
- package/build/lib/staticFiles.js +73 -0
- package/build/lib/staticFiles.js.map +7 -0
- package/build/lib/types.js +17 -0
- package/build/lib/types.js.map +7 -0
- package/build/lib/visConfig.js +52 -0
- package/build/lib/visConfig.js.map +7 -0
- package/build/main.js +1454 -0
- package/build/main.js.map +7 -0
- package/io-package.json +169 -0
- package/package.json +82 -0
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="de">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta
|
|
6
|
+
name="viewport"
|
|
7
|
+
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
|
8
|
+
/>
|
|
9
|
+
<title>JetFrame Live</title>
|
|
10
|
+
|
|
11
|
+
<link rel="manifest" href="manifest.webmanifest">
|
|
12
|
+
<link rel="apple-touch-icon" href="jetframe.png">
|
|
13
|
+
<link rel="icon" type="image/png" href="jetframe.png">
|
|
14
|
+
|
|
15
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
16
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
17
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
18
|
+
<meta name="theme-color" content="#05060a">
|
|
19
|
+
<link rel="stylesheet" href="jetframe.css?v=frame-groundup-20260517">
|
|
20
|
+
</head>
|
|
21
|
+
|
|
22
|
+
<body class="jf-page-frame jf-preload">
|
|
23
|
+
<div id="simpleApiWarning" class="simpleApiWarning">
|
|
24
|
+
⚠️ Simple-API nicht erreichbar<br>
|
|
25
|
+
<span id="simpleApiWarningText"></span>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div id="app">
|
|
29
|
+
<div id="wall" class="jf-shell">
|
|
30
|
+
<div class="header">
|
|
31
|
+
<a class="backBtn headerBack" href="index.html">‹</a>
|
|
32
|
+
|
|
33
|
+
<div class="headerTitleBlock">
|
|
34
|
+
<div class="title">✈️ JetFrame</div>
|
|
35
|
+
<div id="frameDateText" class="sub">Heute</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="frameHeaderActions">
|
|
39
|
+
<button id="toggleBtn" class="toggleBtn on jfHiddenToggle" type="button">ON</button>
|
|
40
|
+
<button id="speechBtn" class="toggleBtn speechBtn" type="button">🔇</button>
|
|
41
|
+
<div class="livePill frameLivePill"><span class="dot"></span><span>Live</span></div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="jfFrameMain">
|
|
46
|
+
<div class="photoWrap">
|
|
47
|
+
<img id="planePhoto" src="" alt="" />
|
|
48
|
+
|
|
49
|
+
<div id="placeholder" class="placeholder">
|
|
50
|
+
<div class="radarCircle"></div>
|
|
51
|
+
<div class="radarSweep"></div>
|
|
52
|
+
<div class="idlePlane">✈️</div>
|
|
53
|
+
<div class="idleFlyers" aria-hidden="true"><span>✈️</span><span>🛫</span><span>🛬</span></div>
|
|
54
|
+
<div class="idleText">Warte auf Flug</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="frameIdleRunwaySlot">
|
|
59
|
+
<div id="idleRunwayText" class="idleRunwayText"></div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<section class="info">
|
|
63
|
+
<div id="mode" class="mode">Flight</div>
|
|
64
|
+
|
|
65
|
+
<div class="flightInfoPillRow">
|
|
66
|
+
<div id="windowInfo" class="windowInfo side">
|
|
67
|
+
<span id="windowArrow" class="windowArrow">▲</span>
|
|
68
|
+
<span id="windowInfoText"></span>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div id="runwayInfo" class="runwayInfo"></div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div class="callsigns">
|
|
75
|
+
<div id="iataCallsign" class="iataCallsign">—</div>
|
|
76
|
+
<div id="operatorCallsign" class="operatorCallsign">—</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="routeBig">
|
|
80
|
+
<div id="routeCities" class="routeCities">— → —</div>
|
|
81
|
+
<div id="routeCodes" class="routeCodes">— → —</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div id="aircraftCard" class="aircraftCard">
|
|
85
|
+
<span id="airlineMini" class="airlineMini">
|
|
86
|
+
<img id="airlineLogoImg" class="airlineLogoImg" src="" alt="" />
|
|
87
|
+
<span id="airline" class="airline">—</span>
|
|
88
|
+
</span>
|
|
89
|
+
|
|
90
|
+
<span class="aircraftSep"></span>
|
|
91
|
+
|
|
92
|
+
<span id="manufacturerLogo" class="manufacturerLogo">
|
|
93
|
+
<img id="manufacturerLogoImg" src="" alt="" />
|
|
94
|
+
<span id="manufacturerLogoText" class="manufacturerLogoText">✈</span>
|
|
95
|
+
</span>
|
|
96
|
+
|
|
97
|
+
<span id="aircraftTypeText">—</span>
|
|
98
|
+
<span id="aircraftSize" class="aircraftSize">—</span>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div id="registration" class="reg">Kennzeichen: —</div>
|
|
102
|
+
<div id="special" class="special"></div>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<div class="metrics">
|
|
106
|
+
<div class="metric">
|
|
107
|
+
<div id="altitude" class="metricValue">–</div>
|
|
108
|
+
<div class="metricLabel">HÖHE FT</div>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="metric">
|
|
111
|
+
<div id="speed" class="metricValue">–</div>
|
|
112
|
+
<div class="metricLabel">SPEED KT</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="metric">
|
|
115
|
+
<div id="verticalRate" class="metricValue">–</div>
|
|
116
|
+
<div class="metricLabel">FT/MIN</div>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="metric">
|
|
119
|
+
<div id="track" class="metricValue">–</div>
|
|
120
|
+
<div class="metricLabel">KURS</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<div id="jfFlightSwitch" class="jfFlightSwitch" aria-hidden="true">
|
|
124
|
+
<div class="jfSwitchRadar"></div>
|
|
125
|
+
<div class="jfSwitchPlane jfSwitchPlaneA">✈️</div>
|
|
126
|
+
<div class="jfSwitchPlane jfSwitchPlaneB">🛫</div>
|
|
127
|
+
<div class="jfSwitchPlane jfSwitchPlaneC">🛬</div>
|
|
128
|
+
<div class="jfSwitchPlane jfSwitchPlaneD">✈️</div>
|
|
129
|
+
<div class="jfSwitchText">
|
|
130
|
+
<div class="jfSwitchLabel">Nächster Flug</div>
|
|
131
|
+
<div id="jfSwitchRoute" class="jfSwitchRoute">—</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div class="jfPreloadOverlay" aria-hidden="true">
|
|
136
|
+
<div class="prePhoto"></div>
|
|
137
|
+
|
|
138
|
+
<div class="preInfo">
|
|
139
|
+
<div class="sk skMode"></div>
|
|
140
|
+
<div class="prePillRow">
|
|
141
|
+
<div class="sk skPill skPillA"></div>
|
|
142
|
+
<div class="sk skPill skPillB"></div>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="sk skCall"></div>
|
|
145
|
+
<div class="sk skSmall"></div>
|
|
146
|
+
<div class="sk skRoute"></div>
|
|
147
|
+
<div class="sk skCodes"></div>
|
|
148
|
+
|
|
149
|
+
<div class="preCard">
|
|
150
|
+
<div class="preCardRow">
|
|
151
|
+
<div class="preLogo"></div>
|
|
152
|
+
<div class="sk skAirline"></div>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="preDivider"></div>
|
|
155
|
+
<div class="preCardRow">
|
|
156
|
+
<div class="preLogo"></div>
|
|
157
|
+
<div class="sk skType"></div>
|
|
158
|
+
<div class="sk skSize"></div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="sk skReg"></div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div class="preMetrics">
|
|
166
|
+
<div class="preMetric"><div class="sk"></div></div>
|
|
167
|
+
<div class="preMetric"><div class="sk"></div></div>
|
|
168
|
+
<div class="preMetric"><div class="sk"></div></div>
|
|
169
|
+
<div class="preMetric"><div class="sk"></div></div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div id="flyover" class="flyover"></div>
|
|
177
|
+
<script>
|
|
178
|
+
let API_BASE = (() => {
|
|
179
|
+
const proto = window.location.protocol || 'http:';
|
|
180
|
+
const host = window.location.hostname || '127.0.0.1';
|
|
181
|
+
const url = new URL(window.location.href);
|
|
182
|
+
const apiHost = url.searchParams.get('apiHost') || host;
|
|
183
|
+
const apiPort = url.searchParams.get('apiPort') || '8087';
|
|
184
|
+
|
|
185
|
+
return proto + '//' + apiHost + ':' + apiPort;
|
|
186
|
+
})();
|
|
187
|
+
|
|
188
|
+
const DP_ROOT = (() => {
|
|
189
|
+
const url = new URL(window.location.href);
|
|
190
|
+
const instance = url.searchParams.get('instance') || '0';
|
|
191
|
+
return 'jetframe.' + String(instance).replace(/[^0-9]/g, '');
|
|
192
|
+
})();
|
|
193
|
+
|
|
194
|
+
let VIS_SOURCE = (() => {
|
|
195
|
+
const url = new URL(window.location.href);
|
|
196
|
+
const src = String(url.searchParams.get('source') || 'current').toLowerCase();
|
|
197
|
+
return ['current', 'airport', 'overflight'].includes(src) ? src : 'current';
|
|
198
|
+
})();
|
|
199
|
+
|
|
200
|
+
let CURRENT = DP_ROOT + '.' + VIS_SOURCE;
|
|
201
|
+
let VIS_CONFIG_LOADED = false;
|
|
202
|
+
|
|
203
|
+
const IDS = {
|
|
204
|
+
enabled: DP_ROOT + '.enabled',
|
|
205
|
+
idleRunwayText: DP_ROOT + '.idleRunwayText',
|
|
206
|
+
speechMode: DP_ROOT + '.speechMode',
|
|
207
|
+
speechText: CURRENT + '.speechText',
|
|
208
|
+
speechEnabled: DP_ROOT + '.speechEnabled',
|
|
209
|
+
speechTrigger: CURRENT + '.speechTrigger',
|
|
210
|
+
|
|
211
|
+
callsign: CURRENT + '.callsign',
|
|
212
|
+
operationalCallsign: CURRENT + '.operationalCallsign',
|
|
213
|
+
routeCallsign: CURRENT + '.routeCallsign',
|
|
214
|
+
|
|
215
|
+
modeVisText: CURRENT + '.modeVisText',
|
|
216
|
+
windowPositionText: CURRENT + '.windowPositionText',
|
|
217
|
+
windowPositionClass: CURRENT + '.windowPositionClass',
|
|
218
|
+
windowPositionSpeechText: CURRENT + '.windowPositionSpeechText',
|
|
219
|
+
|
|
220
|
+
probableRunwayText: CURRENT + '.probableRunwayText',
|
|
221
|
+
|
|
222
|
+
routeDisplayText: CURRENT + '.routeDisplayText',
|
|
223
|
+
routeCodesText: CURRENT + '.routeCodesText',
|
|
224
|
+
|
|
225
|
+
airlineName: CURRENT + '.airlineName',
|
|
226
|
+
registration: CURRENT + '.registration',
|
|
227
|
+
|
|
228
|
+
specialDisplayText: CURRENT + '.specialDisplayText',
|
|
229
|
+
specialLiveryVisText: CURRENT + '.specialLiveryVisText',
|
|
230
|
+
squawk: CURRENT + '.squawk',
|
|
231
|
+
emergency: CURRENT + '.emergency',
|
|
232
|
+
emergencyText: CURRENT + '.emergencyText',
|
|
233
|
+
|
|
234
|
+
manufacturerLogoUrl: CURRENT + '.manufacturerLogoUrl',
|
|
235
|
+
manufacturerLogoText: CURRENT + '.manufacturerLogoText',
|
|
236
|
+
aircraftTypeText: CURRENT + '.aircraftTypeText',
|
|
237
|
+
aircraftSize: CURRENT + '.aircraftSize',
|
|
238
|
+
|
|
239
|
+
altitudeFt: CURRENT + '.altitudeFt',
|
|
240
|
+
speedKt: CURRENT + '.speedKt',
|
|
241
|
+
verticalRate: CURRENT + '.verticalRate',
|
|
242
|
+
trackDeg: CURRENT + '.trackDeg',
|
|
243
|
+
|
|
244
|
+
localImageUrl: CURRENT + '.localImageUrl',
|
|
245
|
+
jetphotosImageUrl: CURRENT + '.jetphotosImageUrl',
|
|
246
|
+
localLogoUrl: CURRENT + '.localLogoUrl',
|
|
247
|
+
logoUrl: CURRENT + '.logoUrl',
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
let lastFlightKey = '';
|
|
251
|
+
let lastAirlinePillLogo = '';
|
|
252
|
+
let spokenFlights = new Set();
|
|
253
|
+
let pendingSpeech = null;
|
|
254
|
+
|
|
255
|
+
let speechEnabled = true;
|
|
256
|
+
let audioCtx = null;
|
|
257
|
+
let speechUnlocked = false;
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
function setSimpleApiWarning(visible, text) {
|
|
261
|
+
const box = document.getElementById('simpleApiWarning');
|
|
262
|
+
const msg = document.getElementById('simpleApiWarningText');
|
|
263
|
+
|
|
264
|
+
if (!box) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (msg) {
|
|
269
|
+
msg.textContent = text || API_BASE;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
box.classList.toggle('visible', !!visible);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function checkSimpleApiReachable() {
|
|
276
|
+
try {
|
|
277
|
+
const res = await fetch(API_BASE + '/getPlainValue/' + encodeURIComponent(DP_ROOT + '.status'), {
|
|
278
|
+
cache: 'no-store',
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (!res.ok) {
|
|
282
|
+
throw new Error('HTTP ' + res.status);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
setSimpleApiWarning(false, '');
|
|
286
|
+
return true;
|
|
287
|
+
} catch (e) {
|
|
288
|
+
setSimpleApiWarning(true, API_BASE);
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function $(id) {
|
|
294
|
+
return document.getElementById(id);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function setStatusTextSafe(text) {
|
|
298
|
+
const el = $('statusText');
|
|
299
|
+
if (el) el.textContent = text || '';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function cleanValue(v) {
|
|
303
|
+
if (v === null || v === undefined) return '';
|
|
304
|
+
v = String(v).trim();
|
|
305
|
+
if (v === 'null' || v === 'undefined') return '';
|
|
306
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
307
|
+
v = v.slice(1, -1);
|
|
308
|
+
}
|
|
309
|
+
return v.replace(/\\"/g, '"').replace(/\\n/g, ' ').trim();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function readState(id) {
|
|
313
|
+
if (!VIS_CONFIG_LOADED) {
|
|
314
|
+
console.warn('[JetFrame] readState blockiert: vis-config.json nicht geladen');
|
|
315
|
+
return '';
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const res = await fetch(API_BASE + '/getPlainValue/' + encodeURIComponent(id), {
|
|
320
|
+
cache: 'no-store',
|
|
321
|
+
});
|
|
322
|
+
if (!res.ok) return '';
|
|
323
|
+
|
|
324
|
+
const text = await res.text();
|
|
325
|
+
const cleaned = cleanValue(text);
|
|
326
|
+
|
|
327
|
+
if (
|
|
328
|
+
!cleaned ||
|
|
329
|
+
cleaned === 'null' ||
|
|
330
|
+
cleaned.toLowerCase().startsWith('error:')
|
|
331
|
+
) {
|
|
332
|
+
return '';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return cleaned;
|
|
336
|
+
} catch (e) {
|
|
337
|
+
setSimpleApiWarning(true, API_BASE);
|
|
338
|
+
return '';
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function writeState(id, value) {
|
|
343
|
+
try {
|
|
344
|
+
await fetch(API_BASE + '/set/' + encodeURIComponent(id) + '?value=' + encodeURIComponent(value), {
|
|
345
|
+
cache: 'no-store',
|
|
346
|
+
});
|
|
347
|
+
} catch (e) {}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function readObject(id) {
|
|
351
|
+
try {
|
|
352
|
+
const res = await fetch(API_BASE + '/getObject/' + encodeURIComponent(id), {
|
|
353
|
+
cache: 'no-store',
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
if (!res.ok) return null;
|
|
357
|
+
return await res.json();
|
|
358
|
+
} catch (e) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function rebuildIds() {
|
|
364
|
+
CURRENT = DP_ROOT + '.' + VIS_SOURCE;
|
|
365
|
+
|
|
366
|
+
Object.keys(IDS).forEach(key => {
|
|
367
|
+
if (
|
|
368
|
+
key !== 'enabled' &&
|
|
369
|
+
key !== 'speechMode' &&
|
|
370
|
+
key !== 'speechEnabled' &&
|
|
371
|
+
key !== 'idleRunwayText'
|
|
372
|
+
) {
|
|
373
|
+
IDS[key] = CURRENT + '.' + key;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function loadVisualConfig() {
|
|
379
|
+
const url = new URL(window.location.href);
|
|
380
|
+
const hasApiOverride = url.searchParams.has('apiHost') || url.searchParams.has('apiPort');
|
|
381
|
+
const hasSourceOverride = url.searchParams.has('source');
|
|
382
|
+
|
|
383
|
+
const res = await fetch('/jetframe.admin/vis-config.json?v=' + Date.now(), {
|
|
384
|
+
cache: 'no-store',
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
if (!res.ok) {
|
|
388
|
+
throw new Error('vis-config.json nicht gefunden');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const cfg = await res.json();
|
|
392
|
+
|
|
393
|
+
if (!hasApiOverride) {
|
|
394
|
+
const proto = window.location.protocol || 'http:';
|
|
395
|
+
const host = String(cfg.simpleApiHost || window.location.hostname || '127.0.0.1').trim();
|
|
396
|
+
const port = String(cfg.simpleApiPort || '8087').trim();
|
|
397
|
+
|
|
398
|
+
API_BASE = proto + '//' + host + ':' + port;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!hasSourceOverride) {
|
|
402
|
+
const src = String(cfg.visualSource || 'current').toLowerCase();
|
|
403
|
+
VIS_SOURCE = ['current', 'airport', 'overflight'].includes(src) ? src : 'current';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
VIS_CONFIG_LOADED = true;
|
|
407
|
+
rebuildIds();
|
|
408
|
+
|
|
409
|
+
console.log('[JetFrame] vis-config.json geladen:', cfg, API_BASE, CURRENT);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function readAll() {
|
|
413
|
+
const entries = await Promise.all(Object.entries(IDS).map(async ([k, id]) => [k, await readState(id)]));
|
|
414
|
+
return Object.fromEntries(entries);
|
|
415
|
+
}
|
|
416
|
+
function num(v) {
|
|
417
|
+
const n = Number(String(v).replace(',', '.'));
|
|
418
|
+
return Number.isFinite(n) ? n : 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function formatNumber(v) {
|
|
422
|
+
const n = num(v);
|
|
423
|
+
return n ? Math.round(n).toLocaleString('de-DE') : '\u2013';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function formatSigned(v) {
|
|
427
|
+
const n = num(v);
|
|
428
|
+
if (!n) return '\u2013';
|
|
429
|
+
const r = Math.round(n);
|
|
430
|
+
return (r > 0 ? '+' : '') + r.toLocaleString('de-DE');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function setImage(imgEl, url, placeholderEl) {
|
|
434
|
+
url = cleanValue(url);
|
|
435
|
+
|
|
436
|
+
if (!imgEl) return;
|
|
437
|
+
|
|
438
|
+
if (!url) {
|
|
439
|
+
imgEl.style.display = 'none';
|
|
440
|
+
imgEl.removeAttribute('src');
|
|
441
|
+
if (placeholderEl) placeholderEl.style.display = 'grid';
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
imgEl.onload = function () {
|
|
446
|
+
imgEl.style.display = 'block';
|
|
447
|
+
if (placeholderEl) placeholderEl.style.display = 'none';
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
imgEl.onerror = function () {
|
|
451
|
+
imgEl.style.display = 'none';
|
|
452
|
+
imgEl.removeAttribute('src');
|
|
453
|
+
if (placeholderEl) placeholderEl.style.display = 'grid';
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
imgEl.src = url;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function setManufacturerLogo(s) {
|
|
460
|
+
const img = $('manufacturerLogoImg');
|
|
461
|
+
const txt = $('manufacturerLogoText');
|
|
462
|
+
|
|
463
|
+
const url = cleanValue(s.manufacturerLogoUrl);
|
|
464
|
+
const fallback = cleanValue(s.manufacturerLogoText) || '\u2708';
|
|
465
|
+
|
|
466
|
+
img.onload = function () {
|
|
467
|
+
img.style.display = 'block';
|
|
468
|
+
txt.classList.remove('isFallback');
|
|
469
|
+
txt.style.display = 'none';
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
img.onerror = function () {
|
|
473
|
+
img.style.display = 'none';
|
|
474
|
+
img.removeAttribute('src');
|
|
475
|
+
txt.textContent = fallback;
|
|
476
|
+
txt.classList.add('isFallback');
|
|
477
|
+
txt.style.display = 'grid';
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
if (url) {
|
|
481
|
+
txt.classList.remove('isFallback');
|
|
482
|
+
txt.style.display = 'none';
|
|
483
|
+
img.src = url;
|
|
484
|
+
} else {
|
|
485
|
+
img.style.display = 'none';
|
|
486
|
+
img.removeAttribute('src');
|
|
487
|
+
txt.textContent = fallback;
|
|
488
|
+
txt.classList.add('isFallback');
|
|
489
|
+
txt.style.display = 'grid';
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function setAirlineLogo(s) {
|
|
493
|
+
const img = document.getElementById('airlineLogoImg');
|
|
494
|
+
const mini = document.getElementById('airlineMini');
|
|
495
|
+
if (!img || !mini) return;
|
|
496
|
+
|
|
497
|
+
const url = cleanValue(s.localLogoUrl) || cleanValue(s.logoUrl);
|
|
498
|
+
|
|
499
|
+
if (!url) {
|
|
500
|
+
img.style.display = 'none';
|
|
501
|
+
img.removeAttribute('src');
|
|
502
|
+
mini.classList.remove('hasLogo');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
img.onload = function () {
|
|
507
|
+
img.style.display = 'block';
|
|
508
|
+
mini.classList.add('hasLogo');
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
img.onerror = function () {
|
|
512
|
+
img.style.display = 'none';
|
|
513
|
+
img.removeAttribute('src');
|
|
514
|
+
mini.classList.remove('hasLogo');
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
if (img.getAttribute('src') !== url) {
|
|
518
|
+
img.src = url;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function setAirlinePill(s) {
|
|
523
|
+
setAirlineLogo(s);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function hideAirlinePill() {
|
|
527
|
+
setAirlineLogo({});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function triggerFlyover() {
|
|
531
|
+
const el = $('flyover');
|
|
532
|
+
el.classList.remove('active');
|
|
533
|
+
void el.offsetWidth;
|
|
534
|
+
el.classList.add('active');
|
|
535
|
+
setTimeout(() => el.classList.remove('active'), 3400);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function applyNightMode() {
|
|
539
|
+
const hour = new Date().getHours();
|
|
540
|
+
$('wall').classList.toggle('night', hour >= 20 || hour <= 6);
|
|
541
|
+
}
|
|
542
|
+
setInterval(applyNightMode, 60000);
|
|
543
|
+
applyNightMode();
|
|
544
|
+
|
|
545
|
+
function setEnabledButton(enabled) {
|
|
546
|
+
$('toggleBtn').textContent = enabled ? 'ON' : 'OFF';
|
|
547
|
+
$('toggleBtn').classList.toggle('on', enabled);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function setSpeechButton() {
|
|
551
|
+
const btn = $('speechBtn');
|
|
552
|
+
btn.textContent = speechEnabled ? '\u{1F50A}' : '\u{1F507}';
|
|
553
|
+
btn.classList.toggle('on', speechEnabled);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function unlockAudio() {
|
|
557
|
+
try {
|
|
558
|
+
if (!audioCtx) {
|
|
559
|
+
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
560
|
+
}
|
|
561
|
+
if (audioCtx.state === 'suspended') audioCtx.resume();
|
|
562
|
+
} catch (e) {}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function unlockSpeech() {
|
|
566
|
+
if (speechUnlocked) return;
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
if (!('speechSynthesis' in window)) return;
|
|
570
|
+
|
|
571
|
+
const u = new SpeechSynthesisUtterance(' ');
|
|
572
|
+
u.lang = 'de-DE';
|
|
573
|
+
u.volume = 0.01;
|
|
574
|
+
u.rate = 1;
|
|
575
|
+
|
|
576
|
+
speechSynthesis.cancel();
|
|
577
|
+
speechSynthesis.speak(u);
|
|
578
|
+
speechUnlocked = true;
|
|
579
|
+
|
|
580
|
+
if (pendingSpeech && pendingSpeech.text && !spokenFlights.has(pendingSpeech.key)) {
|
|
581
|
+
spokenFlights.add(pendingSpeech.key);
|
|
582
|
+
setTimeout(() => speakFlight(pendingSpeech.text), 250);
|
|
583
|
+
pendingSpeech = null;
|
|
584
|
+
}
|
|
585
|
+
} catch (e) {}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function playPlaneSound() {
|
|
589
|
+
if (!speechEnabled) return;
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
unlockAudio();
|
|
593
|
+
|
|
594
|
+
const now = audioCtx.currentTime;
|
|
595
|
+
const duration = 1.8;
|
|
596
|
+
|
|
597
|
+
const noiseBuffer = audioCtx.createBuffer(1, audioCtx.sampleRate * duration, audioCtx.sampleRate);
|
|
598
|
+
const data = noiseBuffer.getChannelData(0);
|
|
599
|
+
|
|
600
|
+
for (let i = 0; i < data.length; i++) {
|
|
601
|
+
data[i] = (Math.random() * 2 - 1) * 0.45;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const noise = audioCtx.createBufferSource();
|
|
605
|
+
noise.buffer = noiseBuffer;
|
|
606
|
+
|
|
607
|
+
const lowpass = audioCtx.createBiquadFilter();
|
|
608
|
+
lowpass.type = 'lowpass';
|
|
609
|
+
lowpass.frequency.setValueAtTime(260, now);
|
|
610
|
+
lowpass.frequency.linearRampToValueAtTime(950, now + 0.8);
|
|
611
|
+
lowpass.frequency.linearRampToValueAtTime(420, now + duration);
|
|
612
|
+
|
|
613
|
+
const gain = audioCtx.createGain();
|
|
614
|
+
gain.gain.setValueAtTime(0.0001, now);
|
|
615
|
+
gain.gain.exponentialRampToValueAtTime(0.28, now + 0.35);
|
|
616
|
+
gain.gain.exponentialRampToValueAtTime(0.08, now + 1.25);
|
|
617
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
|
618
|
+
|
|
619
|
+
noise.connect(lowpass);
|
|
620
|
+
lowpass.connect(gain);
|
|
621
|
+
gain.connect(audioCtx.destination);
|
|
622
|
+
|
|
623
|
+
noise.start(now);
|
|
624
|
+
noise.stop(now + duration);
|
|
625
|
+
} catch (e) {}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function speakFlight(text) {
|
|
629
|
+
if (!speechEnabled) return;
|
|
630
|
+
if (!('speechSynthesis' in window)) return;
|
|
631
|
+
|
|
632
|
+
text = cleanValue(text);
|
|
633
|
+
|
|
634
|
+
if (!text) return;
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
speechSynthesis.cancel();
|
|
638
|
+
|
|
639
|
+
const msg = new SpeechSynthesisUtterance(text);
|
|
640
|
+
msg.lang = 'de-DE';
|
|
641
|
+
msg.rate = 0.85;
|
|
642
|
+
msg.pitch = 1.05;
|
|
643
|
+
msg.volume = 1;
|
|
644
|
+
|
|
645
|
+
speechSynthesis.speak(msg);
|
|
646
|
+
} catch (e) {}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
function setRunwayInfo(text) {
|
|
651
|
+
const el = $('runwayInfo');
|
|
652
|
+
|
|
653
|
+
if (!el) return;
|
|
654
|
+
|
|
655
|
+
text = cleanValue(text);
|
|
656
|
+
|
|
657
|
+
if (!text) {
|
|
658
|
+
el.textContent = '';
|
|
659
|
+
el.classList.remove('visible');
|
|
660
|
+
el.style.display = 'none';
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
text = text
|
|
665
|
+
.replace(/vermutlich\s+/i, '')
|
|
666
|
+
.replace(/\s*[·|]\s*(Landung|Start|Traffic).*$/i, '')
|
|
667
|
+
.trim();
|
|
668
|
+
|
|
669
|
+
el.textContent = text;
|
|
670
|
+
el.style.display = 'inline-flex';
|
|
671
|
+
|
|
672
|
+
requestAnimationFrame(() => {
|
|
673
|
+
el.classList.add('visible');
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function renderWindow(s) {
|
|
678
|
+
const el = $('windowInfo');
|
|
679
|
+
const textEl = $('windowInfoText');
|
|
680
|
+
const arrow = $('windowArrow');
|
|
681
|
+
|
|
682
|
+
const text = cleanValue(s.windowPositionText);
|
|
683
|
+
const cls = cleanValue(s.windowPositionClass) || 'side';
|
|
684
|
+
|
|
685
|
+
if (!el) return;
|
|
686
|
+
|
|
687
|
+
el.style.display = text ? 'inline-flex' : 'none';
|
|
688
|
+
|
|
689
|
+
el.classList.remove('center', 'side');
|
|
690
|
+
el.classList.add(cls);
|
|
691
|
+
|
|
692
|
+
const cleanText = text
|
|
693
|
+
.replace(/^[^a-zA-ZäöüÄÖÜß0-9]+\s*/u, '')
|
|
694
|
+
.trim();
|
|
695
|
+
|
|
696
|
+
if (textEl) {
|
|
697
|
+
textEl.textContent = cleanText;
|
|
698
|
+
} else {
|
|
699
|
+
el.textContent = cleanText;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (arrow) {
|
|
703
|
+
const match = text.match(/(\d+(?:[.,]\d+)?)°/);
|
|
704
|
+
|
|
705
|
+
let deg = 0;
|
|
706
|
+
|
|
707
|
+
if (match && match[1]) {
|
|
708
|
+
deg = Number(String(match[1]).replace(',', '.')) || 0;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const lower = text.toLowerCase();
|
|
712
|
+
|
|
713
|
+
if (lower.includes('links')) {
|
|
714
|
+
deg = -deg;
|
|
715
|
+
} else if (lower.includes('rechts')) {
|
|
716
|
+
deg = deg;
|
|
717
|
+
} else {
|
|
718
|
+
deg = 0;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
arrow.textContent = '▲';
|
|
722
|
+
arrow.style.transform = 'rotate(' + deg + 'deg)';
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
function setIdleRunwayText(text) {
|
|
728
|
+
const el = $('idleRunwayText');
|
|
729
|
+
|
|
730
|
+
if (!el) return;
|
|
731
|
+
|
|
732
|
+
text = cleanValue(text);
|
|
733
|
+
|
|
734
|
+
if (!text) {
|
|
735
|
+
el.textContent = '';
|
|
736
|
+
el.classList.remove('visible');
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
let icon = '🛫';
|
|
741
|
+
|
|
742
|
+
if (text.toLowerCase().includes('landung') || text.toLowerCase().includes('landungen')) {
|
|
743
|
+
icon = '🛬';
|
|
744
|
+
} else if (text.toLowerCase().includes('traffic')) {
|
|
745
|
+
icon = '📡';
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
el.textContent = text;
|
|
749
|
+
el.classList.add('visible');
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function render(s) {
|
|
753
|
+
const enabled = cleanValue(s.enabled) === 'true';
|
|
754
|
+
setEnabledButton(enabled);
|
|
755
|
+
|
|
756
|
+
const hasFlight = !!(
|
|
757
|
+
cleanValue(s.callsign) ||
|
|
758
|
+
cleanValue(s.operationalCallsign) ||
|
|
759
|
+
cleanValue(s.routeCallsign) ||
|
|
760
|
+
cleanValue(s.registration) ||
|
|
761
|
+
cleanValue(s.icao24) ||
|
|
762
|
+
cleanValue(s.hex) ||
|
|
763
|
+
cleanValue(s.fromIata) ||
|
|
764
|
+
cleanValue(s.toIata) ||
|
|
765
|
+
cleanValue(s.route)
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
$('wall').classList.toggle('empty', !hasFlight);
|
|
769
|
+
document.body.classList.toggle('jf-no-flight', !hasFlight);
|
|
770
|
+
setStatusTextSafe(hasFlight ? 'Live' : enabled ? 'Warte' : 'Aus');
|
|
771
|
+
|
|
772
|
+
if (!hasFlight) {
|
|
773
|
+
$('mode').textContent = enabled ? '\u2708\uFE0F Warte auf Flug' : '\u23F8\uFE0F JetFrame aus';
|
|
774
|
+
$('windowInfo').style.display = 'none';
|
|
775
|
+
|
|
776
|
+
$('iataCallsign').textContent = '\u2014';
|
|
777
|
+
$('operatorCallsign').textContent = '\u2014';
|
|
778
|
+
$('routeCities').textContent = '\u2014 \u2192 \u2014';
|
|
779
|
+
$('routeCodes').textContent = '\u2014 \u2192 \u2014';
|
|
780
|
+
$('airline').textContent = '\u2014';
|
|
781
|
+
setAirlineLogo({});
|
|
782
|
+
$('aircraftTypeText').textContent = '\u2014';
|
|
783
|
+
$('aircraftSize').textContent = '\u2014';
|
|
784
|
+
$('registration').textContent = 'Kennzeichen: \u2014';
|
|
785
|
+
$('special').textContent = '';
|
|
786
|
+
$('special').style.display = 'none';
|
|
787
|
+
$('special').classList.remove('emergency');
|
|
788
|
+
|
|
789
|
+
$('altitude').textContent = '\u2013';
|
|
790
|
+
$('speed').textContent = '\u2013';
|
|
791
|
+
$('verticalRate').textContent = '\u2013';
|
|
792
|
+
$('track').textContent = '\u2013';
|
|
793
|
+
|
|
794
|
+
setManufacturerLogo({});
|
|
795
|
+
setImage($('planePhoto'), '', $('placeholder'));
|
|
796
|
+
hideAirlinePill();
|
|
797
|
+
setIdleRunwayText(s.probableRunwayText || s.idleRunwayText);
|
|
798
|
+
setRunwayInfo(s.idleRunwayText);
|
|
799
|
+
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const visibleCallsign = cleanValue(s.routeCallsign) || cleanValue(s.callsign) || '\u2014';
|
|
804
|
+
|
|
805
|
+
const opCallsign = cleanValue(s.operationalCallsign) || cleanValue(s.callsign) || '\u2014';
|
|
806
|
+
|
|
807
|
+
$('mode').textContent = cleanValue(s.modeVisText) || '\u2708\uFE0F Flight';
|
|
808
|
+
|
|
809
|
+
renderWindow(s);
|
|
810
|
+
|
|
811
|
+
setRunwayInfo(s.probableRunwayText);
|
|
812
|
+
|
|
813
|
+
$('iataCallsign').textContent = visibleCallsign;
|
|
814
|
+
$('operatorCallsign').textContent = opCallsign;
|
|
815
|
+
|
|
816
|
+
$('routeCities').textContent = cleanValue(s.routeDisplayText) || '\u2014 \u2192 \u2014';
|
|
817
|
+
$('routeCodes').textContent = cleanValue(s.routeCodesText) || '\u2014 \u2192 \u2014';
|
|
818
|
+
|
|
819
|
+
$('airline').textContent = cleanValue(s.airlineName) || '\u2014';
|
|
820
|
+
setAirlineLogo(s);
|
|
821
|
+
|
|
822
|
+
setManufacturerLogo(s);
|
|
823
|
+
|
|
824
|
+
$('aircraftTypeText').textContent = cleanValue(s.aircraftTypeText) || '\u2014';
|
|
825
|
+
$('aircraftSize').textContent = cleanValue(s.aircraftSize) || '\u2014';
|
|
826
|
+
|
|
827
|
+
$('registration').textContent = 'Kennzeichen: ' + (cleanValue(s.registration) || '\u2014');
|
|
828
|
+
|
|
829
|
+
const emergencyText = cleanValue(s.emergencyText);
|
|
830
|
+
|
|
831
|
+
const special = emergencyText || cleanValue(s.specialLiveryVisText) || cleanValue(s.specialDisplayText);
|
|
832
|
+
|
|
833
|
+
$('special').textContent = special ? '⭐ ' + special : '';
|
|
834
|
+
$('special').style.display = special ? 'block' : 'none';
|
|
835
|
+
$('special').classList.toggle('emergency', !!emergencyText);
|
|
836
|
+
|
|
837
|
+
$('altitude').textContent = formatNumber(s.altitudeFt);
|
|
838
|
+
$('speed').textContent = formatNumber(s.speedKt);
|
|
839
|
+
$('verticalRate').textContent = formatSigned(s.verticalRate);
|
|
840
|
+
|
|
841
|
+
const track = num(s.trackDeg);
|
|
842
|
+
$('track').textContent = track ? Math.round(track) + '\u00B0' : '\u2013';
|
|
843
|
+
|
|
844
|
+
setImage(
|
|
845
|
+
$('planePhoto'),
|
|
846
|
+
cleanValue(s.localImageUrl) || cleanValue(s.jetphotosImageUrl),
|
|
847
|
+
$('placeholder'),
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
setIdleRunwayText('');
|
|
851
|
+
|
|
852
|
+
setAirlinePill(s);
|
|
853
|
+
|
|
854
|
+
const key = [cleanValue(s.callsign), cleanValue(s.routeCallsign), cleanValue(s.registration)].join('|');
|
|
855
|
+
|
|
856
|
+
const speechTextNow = cleanValue(s.speechText);
|
|
857
|
+
|
|
858
|
+
const mode = cleanValue(s.speechMode || 'browser').toLowerCase() || 'browser';
|
|
859
|
+
|
|
860
|
+
const hadPreviousFlight = !!lastFlightKey;
|
|
861
|
+
const isNewFlight = key && key !== lastFlightKey;
|
|
862
|
+
|
|
863
|
+
if (isNewFlight) {
|
|
864
|
+
lastFlightKey = key;
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
triggerFlyover();
|
|
868
|
+
|
|
869
|
+
if (mode !== 'off') {
|
|
870
|
+
playPlaneSound();
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Sprache NUR EINMAL pro Flug
|
|
875
|
+
if (isNewFlight && speechTextNow && !spokenFlights.has(key)) {
|
|
876
|
+
if (mode === 'browser' || mode === 'both') {
|
|
877
|
+
if (speechUnlocked) {
|
|
878
|
+
spokenFlights.add(key);
|
|
879
|
+
speakFlight(speechTextNow);
|
|
880
|
+
} else {
|
|
881
|
+
pendingSpeech = {
|
|
882
|
+
key,
|
|
883
|
+
text: speechTextNow,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (mode === 'external' || mode === 'both') {
|
|
889
|
+
if (mode === 'external') {
|
|
890
|
+
spokenFlights.add(key);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
writeState(IDS.speechTrigger, 'true');
|
|
894
|
+
|
|
895
|
+
setTimeout(() => {
|
|
896
|
+
writeState(IDS.speechTrigger, 'false');
|
|
897
|
+
}, 800);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function poll() {
|
|
903
|
+
const data = await readAll();
|
|
904
|
+
|
|
905
|
+
speechEnabled = cleanValue(data.speechEnabled) !== 'false';
|
|
906
|
+
|
|
907
|
+
setSpeechButton();
|
|
908
|
+
|
|
909
|
+
const didFlightSwitch = window.jfMaybeFlightSwitch ? window.jfMaybeFlightSwitch(data) : false;
|
|
910
|
+
|
|
911
|
+
if (didFlightSwitch) {
|
|
912
|
+
setTimeout(() => {
|
|
913
|
+
render(data);
|
|
914
|
+
if (window.jfMaybeFinishFramePreload) window.jfMaybeFinishFramePreload();
|
|
915
|
+
}, 900);
|
|
916
|
+
} else {
|
|
917
|
+
render(data);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (window.jfMaybeFinishFramePreload) window.jfMaybeFinishFramePreload();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
$('toggleBtn').addEventListener('click', async () => {
|
|
924
|
+
unlockAudio();
|
|
925
|
+
unlockSpeech();
|
|
926
|
+
const current = cleanValue(await readState(DP_ROOT + '.enabled')) === 'true';
|
|
927
|
+
await writeState(DP_ROOT + '.enabled', current ? 'false' : 'true');
|
|
928
|
+
poll();
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
$('speechBtn').addEventListener('click', async () => {
|
|
932
|
+
unlockAudio();
|
|
933
|
+
unlockSpeech();
|
|
934
|
+
const current = cleanValue(await readState(IDS.speechEnabled)) !== 'false';
|
|
935
|
+
|
|
936
|
+
const next = !current;
|
|
937
|
+
|
|
938
|
+
await writeState(IDS.speechEnabled, next ? 'true' : 'false');
|
|
939
|
+
|
|
940
|
+
if (!next && 'speechSynthesis' in window) {
|
|
941
|
+
speechSynthesis.cancel();
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
speechEnabled = next;
|
|
945
|
+
setSpeechButton();
|
|
946
|
+
poll();
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
document.addEventListener(
|
|
950
|
+
'click',
|
|
951
|
+
() => {
|
|
952
|
+
unlockAudio();
|
|
953
|
+
unlockSpeech();
|
|
954
|
+
},
|
|
955
|
+
{ once: true },
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
setSpeechButton();
|
|
959
|
+
|
|
960
|
+
loadVisualConfig()
|
|
961
|
+
.then(async () => {
|
|
962
|
+
await checkSimpleApiReachable();
|
|
963
|
+
poll();
|
|
964
|
+
setInterval(poll, 1500);
|
|
965
|
+
})
|
|
966
|
+
.catch(e => {
|
|
967
|
+
VIS_CONFIG_LOADED = false;
|
|
968
|
+
console.error('[JetFrame] Start abgebrochen:', e);
|
|
969
|
+
setStatusTextSafe('Config fehlt');
|
|
970
|
+
});
|
|
971
|
+
</script>
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
<!-- JF_HEADER_SUBLINE_LOCK_START -->
|
|
976
|
+
<script>
|
|
977
|
+
(function () {
|
|
978
|
+
let busy = false;
|
|
979
|
+
function pad(n) { return String(n).padStart(2, '0'); }
|
|
980
|
+
|
|
981
|
+
function wantedText() {
|
|
982
|
+
const d = new Date();
|
|
983
|
+
return 'Heute · ' +
|
|
984
|
+
pad(d.getHours()) + ':' + pad(d.getMinutes()) +
|
|
985
|
+
' · ' +
|
|
986
|
+
pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '.' + d.getFullYear();
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function applyHeaderSubline() {
|
|
990
|
+
if (busy) return;
|
|
991
|
+
busy = true;
|
|
992
|
+
|
|
993
|
+
const sub = document.querySelector('.header .sub, .header .frameTimeSub');
|
|
994
|
+
if (sub) {
|
|
995
|
+
sub.className = 'sub';
|
|
996
|
+
const text = wantedText();
|
|
997
|
+
if (sub.textContent !== text) sub.textContent = text;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
busy = false;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
applyHeaderSubline();
|
|
1004
|
+
|
|
1005
|
+
setInterval(applyHeaderSubline, 1000);
|
|
1006
|
+
|
|
1007
|
+
const target = document.querySelector('.header');
|
|
1008
|
+
if (target) {
|
|
1009
|
+
new MutationObserver(applyHeaderSubline).observe(target, {
|
|
1010
|
+
childList: true,
|
|
1011
|
+
subtree: true,
|
|
1012
|
+
characterData: true
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
})();
|
|
1016
|
+
</script>
|
|
1017
|
+
<!-- JF_HEADER_SUBLINE_LOCK_END -->
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
<!-- JF_DATE_FORMAT_LOCK_START -->
|
|
1021
|
+
<script>
|
|
1022
|
+
(function () {
|
|
1023
|
+
function fmtDateText(text) {
|
|
1024
|
+
return String(text || '').replace(/\b(\d{4})-(\d{2})-(\d{2})\b/g, '$3.$2.$1');
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function applyDateFormat() {
|
|
1028
|
+
const root = document.querySelector('.jf-shell, .card, .wall');
|
|
1029
|
+
if (!root) return;
|
|
1030
|
+
|
|
1031
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
1032
|
+
const nodes = [];
|
|
1033
|
+
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
1034
|
+
|
|
1035
|
+
for (const n of nodes) {
|
|
1036
|
+
const next = fmtDateText(n.nodeValue);
|
|
1037
|
+
if (next !== n.nodeValue) n.nodeValue = next;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
applyDateFormat();
|
|
1042
|
+
setInterval(applyDateFormat, 1000);
|
|
1043
|
+
|
|
1044
|
+
const root = document.querySelector('.jf-shell, .card, .wall');
|
|
1045
|
+
if (root) {
|
|
1046
|
+
new MutationObserver(applyDateFormat).observe(root, {
|
|
1047
|
+
childList: true,
|
|
1048
|
+
subtree: true,
|
|
1049
|
+
characterData: true
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
})();
|
|
1053
|
+
</script>
|
|
1054
|
+
<!-- JF_DATE_FORMAT_LOCK_END -->
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
<!-- JF_FLIGHT_SWITCH_START -->
|
|
1076
|
+
<script>
|
|
1077
|
+
(function () {
|
|
1078
|
+
window.jfLastFlightKey = null;
|
|
1079
|
+
window.jfFlightSwitchTimer = null;
|
|
1080
|
+
|
|
1081
|
+
function clean(v) {
|
|
1082
|
+
return String(v || '').trim();
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function pickFlightKey(s) {
|
|
1086
|
+
return (
|
|
1087
|
+
clean(s.callsign) ||
|
|
1088
|
+
clean(s.operationalCallsign) ||
|
|
1089
|
+
clean(s.routeCallsign) ||
|
|
1090
|
+
clean(s.registration) ||
|
|
1091
|
+
clean(s.hex) ||
|
|
1092
|
+
clean(s.icao24)
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function pickRouteText(s) {
|
|
1097
|
+
const from =
|
|
1098
|
+
clean(s.fromCity) ||
|
|
1099
|
+
clean(s.fromName) ||
|
|
1100
|
+
clean(s.originCity) ||
|
|
1101
|
+
clean(s.fromIata) ||
|
|
1102
|
+
clean(s.origin) ||
|
|
1103
|
+
'';
|
|
1104
|
+
|
|
1105
|
+
const to =
|
|
1106
|
+
clean(s.toCity) ||
|
|
1107
|
+
clean(s.toName) ||
|
|
1108
|
+
clean(s.destinationCity) ||
|
|
1109
|
+
clean(s.toIata) ||
|
|
1110
|
+
clean(s.destination) ||
|
|
1111
|
+
'';
|
|
1112
|
+
|
|
1113
|
+
if (from && to) return from + ' → ' + to;
|
|
1114
|
+
|
|
1115
|
+
const cs =
|
|
1116
|
+
clean(s.callsign) ||
|
|
1117
|
+
clean(s.operationalCallsign) ||
|
|
1118
|
+
clean(s.routeCallsign);
|
|
1119
|
+
|
|
1120
|
+
return cs || 'Flugwechsel';
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
window.jfMaybeFlightSwitch = function (s) {
|
|
1124
|
+
if (!s) return false;
|
|
1125
|
+
if (document.body.classList.contains('jf-preload')) return false;
|
|
1126
|
+
if (document.body.classList.contains('jf-no-flight')) return false;
|
|
1127
|
+
|
|
1128
|
+
const key = pickFlightKey(s);
|
|
1129
|
+
if (!key) return false;
|
|
1130
|
+
|
|
1131
|
+
const last = window.jfLastFlightKey;
|
|
1132
|
+
|
|
1133
|
+
if (!last) {
|
|
1134
|
+
window.jfLastFlightKey = key;
|
|
1135
|
+
return false;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (last === key) return false;
|
|
1139
|
+
|
|
1140
|
+
window.jfLastFlightKey = key;
|
|
1141
|
+
|
|
1142
|
+
const routeEl = document.getElementById('jfSwitchRoute');
|
|
1143
|
+
if (routeEl) routeEl.textContent = pickRouteText(s);
|
|
1144
|
+
|
|
1145
|
+
clearTimeout(window.jfFlightSwitchTimer);
|
|
1146
|
+
|
|
1147
|
+
document.body.classList.remove('jf-flight-switch');
|
|
1148
|
+
void document.body.offsetWidth;
|
|
1149
|
+
document.body.classList.add('jf-flight-switch');
|
|
1150
|
+
|
|
1151
|
+
window.jfFlightSwitchTimer = setTimeout(function () {
|
|
1152
|
+
document.body.classList.remove('jf-flight-switch');
|
|
1153
|
+
}, 1750);
|
|
1154
|
+
|
|
1155
|
+
return true;
|
|
1156
|
+
};
|
|
1157
|
+
})();
|
|
1158
|
+
</script>
|
|
1159
|
+
<!-- JF_FLIGHT_SWITCH_END -->
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
<!-- JF_FRAME_PRELOAD_START -->
|
|
1163
|
+
<script>
|
|
1164
|
+
(function () {
|
|
1165
|
+
const startedAt = Date.now();
|
|
1166
|
+
let finishTimer = null;
|
|
1167
|
+
|
|
1168
|
+
function txt(id) {
|
|
1169
|
+
const el = document.getElementById(id);
|
|
1170
|
+
return el ? String(el.textContent || '').trim() : '';
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function meaningful(v) {
|
|
1174
|
+
v = String(v || '').trim();
|
|
1175
|
+
return !!v && v !== '—' && v !== '–' && v !== '— → —' && v !== 'Kennzeichen: —';
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function imageReady() {
|
|
1179
|
+
const img = document.getElementById('planePhoto');
|
|
1180
|
+
const noFlight = document.body.classList.contains('jf-no-flight');
|
|
1181
|
+
|
|
1182
|
+
// Im Wartezustand reicht Radar/Placeholder.
|
|
1183
|
+
if (noFlight) return true;
|
|
1184
|
+
|
|
1185
|
+
// Bei echtem Flug: erst freigeben, wenn Bild geladen ODER bewusst kein Bild angezeigt wird.
|
|
1186
|
+
if (!img) return true;
|
|
1187
|
+
if (img.complete && img.naturalWidth > 0) return true;
|
|
1188
|
+
|
|
1189
|
+
return false;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function dataReady() {
|
|
1193
|
+
const noFlight = document.body.classList.contains('jf-no-flight');
|
|
1194
|
+
|
|
1195
|
+
if (noFlight) {
|
|
1196
|
+
return meaningful(txt('mode'));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
return (
|
|
1200
|
+
meaningful(txt('iataCallsign')) &&
|
|
1201
|
+
meaningful(txt('routeCities')) &&
|
|
1202
|
+
(
|
|
1203
|
+
meaningful(txt('airline')) ||
|
|
1204
|
+
meaningful(txt('aircraftTypeText')) ||
|
|
1205
|
+
meaningful(txt('registration'))
|
|
1206
|
+
)
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function finish() {
|
|
1211
|
+
if (!document.body.classList.contains('jf-preload')) return;
|
|
1212
|
+
|
|
1213
|
+
document.body.classList.remove('jf-preload');
|
|
1214
|
+
document.body.classList.add('jf-loaded');
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
window.jfMaybeFinishFramePreload = function () {
|
|
1218
|
+
clearTimeout(finishTimer);
|
|
1219
|
+
|
|
1220
|
+
finishTimer = setTimeout(() => {
|
|
1221
|
+
if (!dataReady() || !imageReady()) return;
|
|
1222
|
+
|
|
1223
|
+
const minVisibleMs = 1500;
|
|
1224
|
+
const rest = Math.max(0, minVisibleMs - (Date.now() - startedAt));
|
|
1225
|
+
|
|
1226
|
+
setTimeout(() => {
|
|
1227
|
+
requestAnimationFrame(() => requestAnimationFrame(finish));
|
|
1228
|
+
}, rest);
|
|
1229
|
+
}, 250);
|
|
1230
|
+
};
|
|
1231
|
+
})();
|
|
1232
|
+
</script>
|
|
1233
|
+
<!-- JF_FRAME_PRELOAD_END -->
|
|
1234
|
+
|
|
1235
|
+
</body>
|
|
1236
|
+
</html>
|