Files
weltze.it/map-prototype.js
2025-10-14 08:25:32 -06:00

454 lines
12 KiB
JavaScript

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;
}
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;
}
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 classEntry = resolveEntryFromElement(event.target);
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');
}
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')}`
};
}