Compare commits

...

7 Commits

Author SHA1 Message Date
Your Name
bb337b6641 chore: add mobile web app meta tag 2025-10-13 22:59:30 -06:00
Your Name
aa76e34d69 fix: use actual timezone offsets in map tooltip 2025-10-13 22:58:45 -06:00
Your Name
2769cb8bf1 feat: add interactive map prototype 2025-10-13 22:55:07 -06:00
Your Name
749ee79619 chore: tweak footer copy 2025-10-13 22:51:58 -06:00
Your Name
b7f3837b1e fix: localize date formatting 2025-10-13 22:49:50 -06:00
Your Name
d58c0723b1 feat: add pwa support 2025-10-13 22:48:34 -06:00
Your Name
e333121999 fix: restore footer attribution 2025-10-13 11:45:47 -06:00
9 changed files with 2287 additions and 4 deletions

13
app.js
View File

@@ -13,6 +13,8 @@ const POPULAR_NAMES = [
const timezoneByName = new Map(SORTED_TIMEZONE_DATA.map((entry) => [entry.name, entry]));
const LOCALE = 'de-DE';
function normalizeText(value) {
return value
.toLowerCase()
@@ -80,7 +82,7 @@ function resolveInitialTheme() {
}
function buildLocalLabel(timeZone) {
const formatter = Intl.DateTimeFormat(undefined, {
const formatter = Intl.DateTimeFormat(LOCALE, {
timeZone,
timeZoneName: 'longGeneric'
});
@@ -95,7 +97,7 @@ function buildLocalLabel(timeZone) {
}
function formatClockValue(date, timeZone) {
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(LOCALE, {
timeZone,
hour: '2-digit',
minute: '2-digit',
@@ -105,7 +107,7 @@ function formatClockValue(date, timeZone) {
}
function formatDateValue(date, timeZone) {
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(LOCALE, {
timeZone,
weekday: 'long',
year: 'numeric',
@@ -361,6 +363,11 @@ hasExplicitThemePreference = Boolean(window.localStorage.getItem(THEME_STORAGE_K
window.addEventListener('load', () => {
startClock();
renderSearchResults('');
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js').catch((error) => {
console.error('Service Worker Registrierung fehlgeschlagen:', error);
});
}
});
if (themeToggleButton) {

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

169
index-map.html Normal file
View File

@@ -0,0 +1,169 @@
<!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-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 OpenSourceProjekt</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>

View File

@@ -4,7 +4,15 @@
<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">
<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>
@@ -51,7 +59,7 @@
</main>
<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 OpenSourceProjekt</a> von <a href="https://andreas.due.ren">Andreas Düren</a></p>
</footer>
<script src="zones.js"></script>

25
manifest.webmanifest Normal file
View 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"
}
]
}

185
map-prototype.js Normal file
View File

@@ -0,0 +1,185 @@
const MAP_SVG_SOURCE = 'assets/world_time_zones.svg';
const OFFSET_PRECISION = 30; // Minuten-Schrittweite
const mapContainer = document.getElementById('map-container');
const overlayEl = document.getElementById('map-overlay');
const tooltipEl = document.getElementById('map-tooltip');
if (mapContainer) {
loadMap();
}
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 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);
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');
}
function handleClick(event) {
const { x } = getRelativePoint(event);
const lon = normalizeLongitude(x, svg.viewBox.baseVal.width);
const offsetMinutes = approximateOffsetMinutes(lon);
const candidate = findTimezoneByOffset(offsetMinutes);
let labelToShow;
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
View 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'));
})
);
});