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 = '

Die Zeitzonenkarte konnte nicht geladen werden.

'; }); } 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')}` }; }