Compare commits
9 Commits
80714a0b57
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
cfe891f6b0 | ||
|
9bb7ae7213 | ||
|
bb337b6641 | ||
|
aa76e34d69 | ||
|
2769cb8bf1 | ||
|
749ee79619 | ||
|
b7f3837b1e | ||
|
d58c0723b1 | ||
|
e333121999 |
13
app.js
13
app.js
@@ -13,6 +13,8 @@ const POPULAR_NAMES = [
|
|||||||
|
|
||||||
const timezoneByName = new Map(SORTED_TIMEZONE_DATA.map((entry) => [entry.name, entry]));
|
const timezoneByName = new Map(SORTED_TIMEZONE_DATA.map((entry) => [entry.name, entry]));
|
||||||
|
|
||||||
|
const LOCALE = 'de-DE';
|
||||||
|
|
||||||
function normalizeText(value) {
|
function normalizeText(value) {
|
||||||
return value
|
return value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -80,7 +82,7 @@ function resolveInitialTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildLocalLabel(timeZone) {
|
function buildLocalLabel(timeZone) {
|
||||||
const formatter = Intl.DateTimeFormat(undefined, {
|
const formatter = Intl.DateTimeFormat(LOCALE, {
|
||||||
timeZone,
|
timeZone,
|
||||||
timeZoneName: 'longGeneric'
|
timeZoneName: 'longGeneric'
|
||||||
});
|
});
|
||||||
@@ -95,7 +97,7 @@ function buildLocalLabel(timeZone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatClockValue(date, timeZone) {
|
function formatClockValue(date, timeZone) {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return new Intl.DateTimeFormat(LOCALE, {
|
||||||
timeZone,
|
timeZone,
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
@@ -105,7 +107,7 @@ function formatClockValue(date, timeZone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDateValue(date, timeZone) {
|
function formatDateValue(date, timeZone) {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return new Intl.DateTimeFormat(LOCALE, {
|
||||||
timeZone,
|
timeZone,
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -361,6 +363,11 @@ hasExplicitThemePreference = Boolean(window.localStorage.getItem(THEME_STORAGE_K
|
|||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
startClock();
|
startClock();
|
||||||
renderSearchResults('');
|
renderSearchResults('');
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('service-worker.js').catch((error) => {
|
||||||
|
console.error('Service Worker Registrierung fehlgeschlagen:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (themeToggleButton) {
|
if (themeToggleButton) {
|
||||||
|
1833
assets/world_time_zones.svg
Normal file
1833
assets/world_time_zones.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 939 KiB |
BIN
icons/icon-192.png
Normal file
BIN
icons/icon-192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
BIN
icons/icon-512.png
Normal file
BIN
icons/icon-512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.7 KiB |
172
index-map.html
Normal file
172
index-map.html
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Weltze.it — Alle Zeitzonen im Blick</title>
|
||||||
|
<meta name="theme-color" content="#0f172a">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<style>
|
||||||
|
.layout {
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
.map-panel {
|
||||||
|
background: var(--panel-background);
|
||||||
|
border: var(--border-thickness) solid var(--panel-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: clamp(1.2rem, 4vw, 1.8rem);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.map-panel header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.map-panel h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.map-panel p {
|
||||||
|
color: var(--text-color-alt);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.map-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 12 / 5;
|
||||||
|
border: var(--border-thickness) solid var(--panel-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--background-color-alt);
|
||||||
|
}
|
||||||
|
.map-container svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.map-container svg .hover-highlight {
|
||||||
|
filter: drop-shadow(0 0 6px rgba(14, 165, 233, 0.75));
|
||||||
|
}
|
||||||
|
.map-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
text-shadow: 0 3px 12px rgba(0,0,0,0.4);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.map-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.map-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
color: #f8fafc;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -120%);
|
||||||
|
white-space: pre;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.map-tooltip.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-rows: auto;
|
||||||
|
}
|
||||||
|
.map-panel {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
.comparisons {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="icons/icon-512.png">
|
||||||
|
<link rel="apple-touch-icon" href="icons/icon-192.png">
|
||||||
|
<script defer src="https://umami.due.ren/script.js" data-website-id="4692d3ad-c36a-4fb9-a7f0-182a8fe72a0b"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="brand">
|
||||||
|
<h1>weltze.it</h1>
|
||||||
|
<p>Alle Zeitzonen auf den ersten Blick.</p>
|
||||||
|
</div>
|
||||||
|
<button id="theme-toggle" class="theme-toggle" type="button" aria-label="Dunklen Modus umschalten">
|
||||||
|
<span class="theme-toggle-icon" aria-hidden="true">☾</span>
|
||||||
|
<span class="theme-toggle-label">Dunkler Modus</span>
|
||||||
|
</button>
|
||||||
|
<div class="local-time">
|
||||||
|
<h2>Deine aktuelle Zeit</h2>
|
||||||
|
<div id="local-time" class="time-display">--:--</div>
|
||||||
|
<div id="local-date" class="date-display"></div>
|
||||||
|
<div id="local-zone" class="zone-display"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="search-panel">
|
||||||
|
<h3>Finde eine Stadt oder Zeitzone</h3>
|
||||||
|
<label for="search-input" class="visually-hidden">Suche Stadt, Land oder Zeitzone</label>
|
||||||
|
<input id="search-input" class="search-input" type="search" placeholder="Suche nach Städten, Ländern oder Zeitzonen…" autocomplete="off">
|
||||||
|
<div id="search-results" class="search-results"></div>
|
||||||
|
<div class="tips">
|
||||||
|
<strong>Tipp:</strong> Füge mehrere Orte hinzu, um Abweichungen direkt zu vergleichen.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="map-panel">
|
||||||
|
<header>
|
||||||
|
<h3>Interaktive Zeitzonen-Karte (Prototyp)</h3>
|
||||||
|
<p>Tippe auf einen Punkt der Karte, um die nächstgelegene Zeitzone zu übernehmen. Die Karte verwendet das Wikimedia-Weltzeit-Zonen-SVG.</p>
|
||||||
|
</header>
|
||||||
|
<div id="map-container" class="map-container">
|
||||||
|
<div class="map-overlay" id="map-overlay">UTC±00:00</div>
|
||||||
|
<div class="map-tooltip" id="map-tooltip"></div>
|
||||||
|
</div>
|
||||||
|
<p class="map-disclaimer">Quelle: <a href="https://en.wikipedia.org/wiki/Time_zone">Wikipedia – Time zone</a> · Lizenz: CC-BY-SA 4.0</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="comparisons">
|
||||||
|
<header class="comparisons-header">
|
||||||
|
<h3>Ausgewählte Orte</h3>
|
||||||
|
<button id="reset-button" class="reset-button" type="button">Nur lokalen Ort behalten</button>
|
||||||
|
</header>
|
||||||
|
<div class="table-headings">
|
||||||
|
<div>Ort</div>
|
||||||
|
<div>Aktuelle Zeit</div>
|
||||||
|
<div>Abweichung zur lokalen Zeit</div>
|
||||||
|
</div>
|
||||||
|
<ul id="selection-list" class="selection-list"></ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<p><a href="https://git.due.ren/andreas/weltze.it">Ein Open‑Source‑Projekt</a> von <a href="https://andreas.due.ren">Andreas Düren</a></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="zones.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
<script src="map-prototype.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
10
index.html
10
index.html
@@ -4,7 +4,15 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Weltze.it — Alle Zeitzonen im Blick</title>
|
<title>Weltze.it — Alle Zeitzonen im Blick</title>
|
||||||
|
<meta name="theme-color" content="#0f172a">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="icons/icon-512.png">
|
||||||
|
<link rel="apple-touch-icon" href="icons/icon-192.png">
|
||||||
<script defer src="https://umami.due.ren/script.js" data-website-id="4692d3ad-c36a-4fb9-a7f0-182a8fe72a0b"></script>
|
<script defer src="https://umami.due.ren/script.js" data-website-id="4692d3ad-c36a-4fb9-a7f0-182a8fe72a0b"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -51,7 +59,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>von Andreas Dueren (andreas.due.ren) · Open Source unter der <a href="LICENSE">MIT-Lizenz</a></p>
|
<p><a href="https://git.due.ren/andreas/weltze.it">Ein Open‑Source‑Projekt</a> von <a href="https://andreas.due.ren">Andreas Düren</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="zones.js"></script>
|
<script src="zones.js"></script>
|
||||||
|
25
manifest.webmanifest
Normal file
25
manifest.webmanifest
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "Weltze.it — Alle Zeitzonen im Blick",
|
||||||
|
"short_name": "Weltze.it",
|
||||||
|
"description": "Alle Zeitzonen auf einen Blick vergleichen und Termine koordinieren.",
|
||||||
|
"lang": "de",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#0f172a",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
468
map-prototype.js
Normal file
468
map-prototype.js
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
const MAP_SVG_SOURCE = 'assets/world_time_zones.svg';
|
||||||
|
const OFFSET_PRECISION = 30; // Minuten-Schrittweite
|
||||||
|
|
||||||
|
const CLASS_TO_TIMEZONE = {
|
||||||
|
// Europe
|
||||||
|
ad: 'Europe/Madrid',
|
||||||
|
al: 'Europe/Athens',
|
||||||
|
at: 'Europe/Vienna',
|
||||||
|
ba: 'Europe/Bucharest',
|
||||||
|
be: 'Europe/Amsterdam',
|
||||||
|
bg: 'Europe/Sofia',
|
||||||
|
ch: 'Europe/Zurich',
|
||||||
|
cz: 'Europe/Berlin',
|
||||||
|
de: 'Europe/Berlin',
|
||||||
|
dk: 'Europe/Copenhagen',
|
||||||
|
ee: 'Europe/Helsinki',
|
||||||
|
es: 'Europe/Madrid',
|
||||||
|
fi: 'Europe/Helsinki',
|
||||||
|
fr: 'Europe/Paris',
|
||||||
|
gb: 'Europe/London',
|
||||||
|
gr: 'Europe/Athens',
|
||||||
|
hr: 'Europe/Rome',
|
||||||
|
hu: 'Europe/Vienna',
|
||||||
|
ie: 'Europe/Dublin',
|
||||||
|
is: 'Atlantic/Reykjavik',
|
||||||
|
it: 'Europe/Rome',
|
||||||
|
li: 'Europe/Zurich',
|
||||||
|
lt: 'Europe/Riga',
|
||||||
|
lu: 'Europe/Amsterdam',
|
||||||
|
lv: 'Europe/Riga',
|
||||||
|
mt: 'Europe/Rome',
|
||||||
|
nl: 'Europe/Amsterdam',
|
||||||
|
no: 'Europe/Oslo',
|
||||||
|
pl: 'Europe/Warsaw',
|
||||||
|
pt0: 'Europe/Lisbon',
|
||||||
|
pt: 'Europe/Lisbon',
|
||||||
|
'pt-1': 'Europe/Lisbon',
|
||||||
|
ro: 'Europe/Bucharest',
|
||||||
|
rs: 'Europe/Athens',
|
||||||
|
se: 'Europe/Stockholm',
|
||||||
|
si: 'Europe/Vienna',
|
||||||
|
sk: 'Europe/Vienna',
|
||||||
|
ua: 'Europe/Kyiv',
|
||||||
|
ua2: 'Europe/Kyiv',
|
||||||
|
ua3: 'Europe/Kyiv',
|
||||||
|
va: 'Europe/Rome',
|
||||||
|
|
||||||
|
// Americas
|
||||||
|
ar: 'America/Argentina/Buenos_Aires',
|
||||||
|
bb: 'America/Barbados',
|
||||||
|
bo: 'America/La_Paz',
|
||||||
|
'br-4': 'America/Manaus',
|
||||||
|
'br-3': 'America/Sao_Paulo',
|
||||||
|
'br-2': 'America/Sao_Paulo',
|
||||||
|
'ca-4': 'America/Halifax',
|
||||||
|
'ca-5': 'America/Toronto',
|
||||||
|
'ca-6': 'America/Winnipeg',
|
||||||
|
'ca-7': 'America/Edmonton',
|
||||||
|
'ca-8': 'America/Vancouver',
|
||||||
|
'ca-330': 'America/St_Johns',
|
||||||
|
'ca-4n': 'America/Halifax',
|
||||||
|
'ca-5n': 'America/Toronto',
|
||||||
|
'ca-6n': 'America/Winnipeg',
|
||||||
|
'ca-7n': 'America/Edmonton',
|
||||||
|
'ca-8n': 'America/Vancouver',
|
||||||
|
'cl-3': 'America/Santiago',
|
||||||
|
'cl-4': 'America/Santiago',
|
||||||
|
co: 'America/Bogota',
|
||||||
|
cu: 'America/Havana',
|
||||||
|
'ec-5': 'America/Guayaquil',
|
||||||
|
'ec-6': 'America/Guayaquil',
|
||||||
|
gt: 'America/Guatemala',
|
||||||
|
hn: 'America/Tegucigalpa',
|
||||||
|
jm: 'America/Jamaica',
|
||||||
|
'mx-5': 'America/Mexico_City',
|
||||||
|
'mx-6': 'America/Mexico_City',
|
||||||
|
'mx-7': 'America/Ciudad_Juarez',
|
||||||
|
'mx-8': 'America/Tijuana',
|
||||||
|
'mx-5b': 'America/Mexico_City',
|
||||||
|
'mx-6b': 'America/Mexico_City',
|
||||||
|
'mx-7b': 'America/Ciudad_Juarez',
|
||||||
|
'mx-8b': 'America/Tijuana',
|
||||||
|
ni: 'America/Managua',
|
||||||
|
pa: 'America/Panama',
|
||||||
|
pe: 'America/Lima',
|
||||||
|
pr: 'America/Puerto_Rico',
|
||||||
|
'us-5': 'America/New_York',
|
||||||
|
'us-6': 'America/Chicago',
|
||||||
|
'us-7': 'America/Denver',
|
||||||
|
'us-8': 'America/Los_Angeles',
|
||||||
|
'us-9': 'America/Anchorage',
|
||||||
|
'us-10': 'Pacific/Honolulu',
|
||||||
|
'us-10n': 'Pacific/Honolulu',
|
||||||
|
'us-9n': 'America/Anchorage',
|
||||||
|
'us-8n': 'America/Los_Angeles',
|
||||||
|
'us-7n': 'America/Denver',
|
||||||
|
'us-6n': 'America/Chicago',
|
||||||
|
'us-5n': 'America/New_York',
|
||||||
|
uy: 'America/Montevideo',
|
||||||
|
ve: 'America/Caracas',
|
||||||
|
|
||||||
|
// Africa
|
||||||
|
ao: 'Africa/Luanda',
|
||||||
|
bf: 'Africa/Abidjan',
|
||||||
|
bi: 'Africa/Maputo',
|
||||||
|
bj: 'Africa/Lagos',
|
||||||
|
bw: 'Africa/Johannesburg',
|
||||||
|
cd1: 'Africa/Kinshasa',
|
||||||
|
cd2: 'Africa/Lubumbashi',
|
||||||
|
cf: 'Africa/Bangui',
|
||||||
|
cg: 'Africa/Lagos',
|
||||||
|
ci: 'Africa/Abidjan',
|
||||||
|
cm: 'Africa/Douala',
|
||||||
|
dz: 'Africa/Algiers',
|
||||||
|
eg: 'Africa/Cairo',
|
||||||
|
et: 'Africa/Addis_Ababa',
|
||||||
|
gh: 'Africa/Accra',
|
||||||
|
gm: 'Africa/Abidjan',
|
||||||
|
gn: 'Africa/Conakry',
|
||||||
|
gq: 'Africa/Lagos',
|
||||||
|
ke: 'Africa/Nairobi',
|
||||||
|
lr: 'Africa/Monrovia',
|
||||||
|
ls: 'Africa/Johannesburg',
|
||||||
|
ly: 'Africa/Tripoli',
|
||||||
|
ma: 'Africa/Casablanca',
|
||||||
|
mg: 'Africa/Nairobi',
|
||||||
|
ml: 'Africa/Bamako',
|
||||||
|
mr: 'Africa/Nouakchott',
|
||||||
|
mu: 'Africa/Nairobi',
|
||||||
|
mw: 'Africa/Maputo',
|
||||||
|
mz: 'Africa/Maputo',
|
||||||
|
na: 'Africa/Windhoek',
|
||||||
|
ne: 'Africa/Lagos',
|
||||||
|
ng: 'Africa/Lagos',
|
||||||
|
rw: 'Africa/Maputo',
|
||||||
|
sd: 'Africa/Khartoum',
|
||||||
|
sl: 'Africa/Abidjan',
|
||||||
|
sn: 'Africa/Dakar',
|
||||||
|
tn: 'Africa/Tunis',
|
||||||
|
tz: 'Africa/Nairobi',
|
||||||
|
ug: 'Africa/Nairobi',
|
||||||
|
za2: 'Africa/Johannesburg',
|
||||||
|
za3: 'Africa/Johannesburg',
|
||||||
|
zm: 'Africa/Maputo',
|
||||||
|
zw: 'Africa/Maputo',
|
||||||
|
|
||||||
|
// Middle East & Asia
|
||||||
|
ae: 'Asia/Dubai',
|
||||||
|
af: 'Asia/Kabul',
|
||||||
|
am: 'Asia/Yerevan',
|
||||||
|
az: 'Asia/Baku',
|
||||||
|
bh: 'Asia/Bahrain',
|
||||||
|
cn: 'Asia/Shanghai',
|
||||||
|
ge: 'Asia/Tbilisi',
|
||||||
|
il: 'Asia/Jerusalem',
|
||||||
|
in: 'Asia/Kolkata',
|
||||||
|
iq: 'Asia/Baghdad',
|
||||||
|
ir: 'Asia/Tehran',
|
||||||
|
jo: 'Asia/Amman',
|
||||||
|
jp: 'Asia/Tokyo',
|
||||||
|
kg: 'Asia/Bishkek',
|
||||||
|
kz: 'Asia/Almaty',
|
||||||
|
kw: 'Asia/Kuwait',
|
||||||
|
lb: 'Asia/Beirut',
|
||||||
|
lk: 'Asia/Colombo',
|
||||||
|
mm: 'Asia/Yangon',
|
||||||
|
mn7: 'Asia/Irkutsk',
|
||||||
|
mn8: 'Asia/Irkutsk',
|
||||||
|
mv: 'Asia/Dubai',
|
||||||
|
my: 'Asia/Kuala_Lumpur',
|
||||||
|
np: 'Asia/Kathmandu',
|
||||||
|
om: 'Asia/Dubai',
|
||||||
|
ph: 'Asia/Manila',
|
||||||
|
pk: 'Asia/Karachi',
|
||||||
|
qa: 'Asia/Qatar',
|
||||||
|
ru2: 'Europe/Moscow',
|
||||||
|
ru3: 'Europe/Moscow',
|
||||||
|
ru4: 'Europe/Samara',
|
||||||
|
ru5: 'Asia/Yekaterinburg',
|
||||||
|
ru6: 'Asia/Omsk',
|
||||||
|
ru7: 'Asia/Krasnoyarsk',
|
||||||
|
ru8: 'Asia/Irkutsk',
|
||||||
|
ru9: 'Asia/Yakutsk',
|
||||||
|
ru10: 'Asia/Vladivostok',
|
||||||
|
ru11: 'Asia/Magadan',
|
||||||
|
ru12: 'Asia/Kamchatka',
|
||||||
|
sa: 'Asia/Riyadh',
|
||||||
|
sg: 'Asia/Singapore',
|
||||||
|
sy: 'Asia/Damascus',
|
||||||
|
th: 'Asia/Bangkok',
|
||||||
|
tj: 'Asia/Dushanbe',
|
||||||
|
tm: 'Asia/Ashgabat',
|
||||||
|
tr: 'Europe/Istanbul',
|
||||||
|
uz: 'Asia/Tashkent',
|
||||||
|
vn: 'Asia/Ho_Chi_Minh',
|
||||||
|
ye: 'Asia/Aden',
|
||||||
|
|
||||||
|
// Oceania
|
||||||
|
au8: 'Australia/Perth',
|
||||||
|
au10: 'Australia/Sydney',
|
||||||
|
au10n: 'Australia/Sydney',
|
||||||
|
au1030: 'Australia/Sydney',
|
||||||
|
au930: 'Australia/Perth',
|
||||||
|
au930n: 'Australia/Perth',
|
||||||
|
au845: 'Australia/Perth',
|
||||||
|
fj: 'Pacific/Fiji',
|
||||||
|
gu: 'Pacific/Port_Moresby',
|
||||||
|
nc: 'Pacific/Noumea',
|
||||||
|
nz12: 'Pacific/Auckland',
|
||||||
|
nz1245: 'Pacific/Auckland',
|
||||||
|
'pf-10': 'Pacific/Honolulu',
|
||||||
|
'pf-9': 'Pacific/Honolulu',
|
||||||
|
'pf-930': 'Pacific/Honolulu',
|
||||||
|
to: 'Pacific/Auckland',
|
||||||
|
ws: 'Pacific/Auckland',
|
||||||
|
tk: 'Pacific/Auckland'
|
||||||
|
};
|
||||||
|
|
||||||
|
const timezoneById = new Map(TIMEZONE_DATA.map((entry) => [entry.timeZone, entry]));
|
||||||
|
|
||||||
|
const mapContainer = document.getElementById('map-container');
|
||||||
|
const overlayEl = document.getElementById('map-overlay');
|
||||||
|
const tooltipEl = document.getElementById('map-tooltip');
|
||||||
|
|
||||||
|
if (mapContainer) {
|
||||||
|
loadMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntryForTimeZone(timeZoneId) {
|
||||||
|
if (!timeZoneId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (timezoneById.has(timeZoneId)) {
|
||||||
|
return timezoneById.get(timeZoneId);
|
||||||
|
}
|
||||||
|
return SORTED_TIMEZONE_DATA.find((entry) => entry.timeZone === timeZoneId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEntryFromElement(element) {
|
||||||
|
if (!element) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const candidate = element.closest('[class]');
|
||||||
|
if (!candidate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
lastHoveredElement = candidate;
|
||||||
|
const classAttr = candidate.getAttribute('class');
|
||||||
|
if (!classAttr) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const classes = classAttr.split(/\s+/);
|
||||||
|
for (const cls of classes) {
|
||||||
|
const timeZoneId = CLASS_TO_TIMEZONE[cls];
|
||||||
|
const entry = getEntryForTimeZone(timeZoneId);
|
||||||
|
if (entry) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastHoveredElement = null;
|
||||||
|
|
||||||
|
function loadMap() {
|
||||||
|
fetch(MAP_SVG_SOURCE)
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((svgText) => {
|
||||||
|
mapContainer.insertAdjacentHTML('afterbegin', svgText);
|
||||||
|
const svg = mapContainer.querySelector('svg');
|
||||||
|
if (!svg) {
|
||||||
|
throw new Error('SVG nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.setAttribute('viewBox', '0 0 3600 1500');
|
||||||
|
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||||
|
svg.style.width = '100%';
|
||||||
|
svg.style.height = '100%';
|
||||||
|
|
||||||
|
attachMapListeners(svg);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Karte konnte nicht geladen werden:', error);
|
||||||
|
mapContainer.innerHTML = '<p>Die Zeitzonenkarte konnte nicht geladen werden.</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachMapListeners(svg) {
|
||||||
|
const svgPoint = svg.createSVGPoint();
|
||||||
|
|
||||||
|
function clearHighlight() {
|
||||||
|
if (lastHoveredElement) {
|
||||||
|
lastHoveredElement.classList.remove('hover-highlight');
|
||||||
|
lastHoveredElement = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelativePoint(event) {
|
||||||
|
svgPoint.x = event.clientX;
|
||||||
|
svgPoint.y = event.clientY;
|
||||||
|
const ctm = svg.getScreenCTM();
|
||||||
|
if (!ctm) {
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
const inverse = ctm.inverse();
|
||||||
|
const transformed = svgPoint.matrixTransform(inverse);
|
||||||
|
return { x: transformed.x, y: transformed.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event) {
|
||||||
|
const { x, y } = getRelativePoint(event);
|
||||||
|
const lon = normalizeLongitude(x, svg.viewBox.baseVal.width);
|
||||||
|
const lat = normalizeLatitude(y, svg.viewBox.baseVal.height);
|
||||||
|
clearHighlight();
|
||||||
|
const classEntry = resolveEntryFromElement(event.target);
|
||||||
|
if (lastHoveredElement) {
|
||||||
|
lastHoveredElement.classList.add('hover-highlight');
|
||||||
|
}
|
||||||
|
if (classEntry) {
|
||||||
|
const suggestion = buildSuggestion(classEntry);
|
||||||
|
positionTooltip(event.clientX, event.clientY, `${suggestion.offsetLabel}\n${classEntry.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetMinutes = approximateOffsetMinutes(lon);
|
||||||
|
const candidate = findTimezoneByOffset(offsetMinutes);
|
||||||
|
|
||||||
|
if (candidate) {
|
||||||
|
const suggestion = buildSuggestion(candidate);
|
||||||
|
positionTooltip(event.clientX, event.clientY, `${suggestion.offsetLabel}\n${candidate.name}`);
|
||||||
|
} else {
|
||||||
|
const { offsetLabel } = describeOffset(offsetMinutes);
|
||||||
|
positionTooltip(event.clientX, event.clientY, `${offsetLabel}\n${formatCoordinate(lat, lon)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerLeave() {
|
||||||
|
tooltipEl?.classList.remove('visible');
|
||||||
|
clearHighlight();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event) {
|
||||||
|
const { x } = getRelativePoint(event);
|
||||||
|
const lon = normalizeLongitude(x, svg.viewBox.baseVal.width);
|
||||||
|
const classEntry = resolveEntryFromElement(event.target);
|
||||||
|
let labelToShow;
|
||||||
|
|
||||||
|
if (classEntry) {
|
||||||
|
const suggestion = buildSuggestion(classEntry);
|
||||||
|
labelToShow = suggestion.offsetLabel;
|
||||||
|
if (typeof addSelection === 'function') {
|
||||||
|
addSelection(classEntry);
|
||||||
|
renderSelections();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const offsetMinutes = approximateOffsetMinutes(lon);
|
||||||
|
const candidate = findTimezoneByOffset(offsetMinutes);
|
||||||
|
|
||||||
|
if (candidate) {
|
||||||
|
const suggestion = buildSuggestion(candidate);
|
||||||
|
labelToShow = suggestion.offsetLabel;
|
||||||
|
if (typeof addSelection === 'function') {
|
||||||
|
addSelection(candidate);
|
||||||
|
renderSelections();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { offsetLabel } = describeOffset(offsetMinutes);
|
||||||
|
labelToShow = offsetLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showOverlay(labelToShow);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.addEventListener('pointermove', handlePointerMove);
|
||||||
|
svg.addEventListener('pointerleave', handlePointerLeave);
|
||||||
|
svg.addEventListener('click', handleClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLongitude(x, width) {
|
||||||
|
const clampedWidth = width || 3600;
|
||||||
|
return (x / clampedWidth) * 360 - 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLatitude(y, height) {
|
||||||
|
const clampedHeight = height || 1500;
|
||||||
|
return 90 - (y / clampedHeight) * 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function approximateOffsetMinutes(longitude) {
|
||||||
|
const offsetHours = Math.round((longitude / 15) * (60 / OFFSET_PRECISION)) * (OFFSET_PRECISION / 60);
|
||||||
|
const clampedHours = Math.min(14, Math.max(-12, offsetHours));
|
||||||
|
return Math.round(clampedHours * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeOffset(offsetMinutes) {
|
||||||
|
const clamped = Math.round(offsetMinutes / OFFSET_PRECISION) * OFFSET_PRECISION;
|
||||||
|
const hours = Math.trunc(clamped / 60);
|
||||||
|
const minutes = Math.abs(clamped % 60);
|
||||||
|
const sign = clamped >= 0 ? '+' : '-';
|
||||||
|
return {
|
||||||
|
offsetMinutesRounded: clamped,
|
||||||
|
offsetLabel: `UTC${sign}${String(Math.abs(hours)).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionTooltip(clientX, clientY, text) {
|
||||||
|
if (!tooltipEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const containerRect = mapContainer.getBoundingClientRect();
|
||||||
|
tooltipEl.textContent = text;
|
||||||
|
tooltipEl.style.left = `${clientX - containerRect.left}px`;
|
||||||
|
tooltipEl.style.top = `${clientY - containerRect.top}px`;
|
||||||
|
tooltipEl.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlayTimeoutId = null;
|
||||||
|
function showOverlay(text) {
|
||||||
|
if (!overlayEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overlayEl.textContent = text;
|
||||||
|
overlayEl.classList.add('visible');
|
||||||
|
if (overlayTimeoutId) {
|
||||||
|
clearTimeout(overlayTimeoutId);
|
||||||
|
}
|
||||||
|
overlayTimeoutId = window.setTimeout(() => {
|
||||||
|
overlayEl?.classList.remove('visible');
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(lat, lon) {
|
||||||
|
const formatValue = (value, positiveSuffix, negativeSuffix) => {
|
||||||
|
const suffix = value >= 0 ? positiveSuffix : negativeSuffix;
|
||||||
|
return `${Math.abs(value).toFixed(1)}°${suffix}`;
|
||||||
|
};
|
||||||
|
return `${formatValue(lat, 'N', 'S')} · ${formatValue(lon, 'E', 'W')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTimezoneByOffset(targetOffsetMinutes) {
|
||||||
|
const now = new Date();
|
||||||
|
let bestCandidate = null;
|
||||||
|
let bestDelta = Infinity;
|
||||||
|
|
||||||
|
SORTED_TIMEZONE_DATA.forEach((entry) => {
|
||||||
|
const offset = getOffsetMinutes(now, entry.timeZone);
|
||||||
|
const delta = Math.abs(offset - targetOffsetMinutes);
|
||||||
|
if (delta < bestDelta) {
|
||||||
|
bestDelta = delta;
|
||||||
|
bestCandidate = entry;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return bestCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSuggestion(entry) {
|
||||||
|
const now = new Date();
|
||||||
|
const offsetMinutes = getOffsetMinutes(now, entry.timeZone);
|
||||||
|
const hours = Math.trunc(offsetMinutes / 60);
|
||||||
|
const minutes = Math.abs(offsetMinutes % 60);
|
||||||
|
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||||
|
return {
|
||||||
|
offsetMinutes,
|
||||||
|
offsetLabel: `UTC${sign}${String(Math.abs(hours)).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||||
|
};
|
||||||
|
}
|
56
service-worker.js
Normal file
56
service-worker.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
const CACHE_NAME = 'weltzeit-cache-v1';
|
||||||
|
const PRECACHE_RESOURCES = [
|
||||||
|
'./',
|
||||||
|
'./index.html',
|
||||||
|
'./styles.css',
|
||||||
|
'./app.js',
|
||||||
|
'./zones.js',
|
||||||
|
'./manifest.webmanifest',
|
||||||
|
'./icons/icon-192.png',
|
||||||
|
'./icons/icon-512.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_RESOURCES))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) =>
|
||||||
|
Promise.all(
|
||||||
|
cacheNames.map((cacheName) => {
|
||||||
|
if (cacheName !== CACHE_NAME) {
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
if (event.request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((cachedResponse) => {
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(event.request, responseClone);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match('./index.html'));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
Reference in New Issue
Block a user