From fa3c32d057296ac43f71886ef0b8cf883a108c9f Mon Sep 17 00:00:00 2001 From: David Huser <4357648+davidhuser@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:42:37 +0100 Subject: [PATCH 1/7] ui fixes --- css/map.css | 8 ++++---- index.html | 21 ++++++++------------- js/map-shared.js | 2 +- tenant.html | 12 +++++++----- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/css/map.css b/css/map.css index 2acb41e9..58d88b76 100644 --- a/css/map.css +++ b/css/map.css @@ -24,7 +24,7 @@ body { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; .info-card p { font-size: 13px; color: #444; line-height: 1.5; margin: 0; } .info-card a { color: #2563eb; text-decoration: none; } .info-card a:hover { text-decoration: underline; } -#generated { font-size: 11px; color: #999; margin-top: 4px; } +#generated { font-size: 12px; color: #999; margin-top: 4px; } .info-title { display: none; font-size: 16px; font-weight: 600; color: #1a1a2e; margin-bottom: 12px; } .legend-toggle { @@ -42,8 +42,8 @@ body { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; .legend-group { margin-top: 8px; } .legend-group:first-child { margin-top: 4px; } .legend-group-label { - font-size: 11px; color: #999; text-transform: uppercase; - letter-spacing: 0.3px; font-weight: 600; display: block; margin-bottom: 2px; + font-size: 13px; color: #333; + letter-spacing: 0.3px; font-weight: 600; display: block; margin-bottom: 4px; } #map { flex: 1; position: relative; width: 100%; will-change: transform; } @@ -108,7 +108,7 @@ body { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; .cat-badge.unknown { background: #f3f4f6; color: #6b7280; } .color-toggle { - display: block; margin-top: 8px; padding: 3px 8px; border-radius: 3px; + display: block; margin: 8px auto 0; padding: 3px 8px; border-radius: 3px; font-size: 11px; font-weight: 500; cursor: pointer; background: #f3f4f6; border: 1px solid #d1d5db; color: #374151; } diff --git a/index.html b/index.html index 11404f77..05e33801 100644 --- a/index.html +++ b/index.html @@ -61,17 +61,19 @@

What is this?

Context

-

Digital sovereignty: US-based providers are subject to the US CLOUD Act, which allows US authorities to request stored data, regardless of where it is physically hosted. This map makes the current provider landscape visible.

+

US-based providers are subject to the US CLOUD Act, allowing US authorities to request stored data regardless of where it is hosted. This map makes the current provider landscape visible.

How does it work?

-

Each municipality's official domain is checked via 11 signals from DNS records, SMTP banners, ASN lookups, and a public Microsoft API endpoint, then classified by provider type with confidence scoring.

-

Disclaimer: DNS records indicate mail routing and authorized senders, not necessarily where data is stored.

+

Each municipality's email domain is checked against multiple public DNS and network signals, then classified by provider type.

+
+
+

Accuracy

+

Classifications may contain errors. DNS records indicate mail routing, not necessarily where data is stored.
If you notice an error, please submit an issue.

Open source & open data

-

The code and data are on GitHub.
- If you have noticed an error, please submit an issue.

+

The code and data are on GitHub.

@@ -398,7 +400,7 @@

Open source & open data

showGenerated(dnsData); // Count by jurisdiction and confidence level - const catCounts = { 'us-cloud': 0, 'swiss-based': 0, 'insufficient': 0 }; + const catCounts = { 'us-cloud': 0, 'swiss-based': 0 }; const levelCounts = { 'us-cloud': { high: 0, medium: 0, low: 0 }, 'swiss-based': { high: 0, medium: 0, low: 0 } @@ -410,14 +412,9 @@

Open source & open data

catCounts[cat]++; const level = confidenceLevel(m ? (m.classification_confidence || 0) : 0); if (level !== 'insufficient') levelCounts[cat][level]++; - } else { - catCounts['insufficient']++; } } - const hatchSvg = 'data:image/svg+xml,' + - encodeURIComponent(''); - // Legend with counts const isMobile = window.innerWidth <= 600; const legend = L.control({ position: isMobile ? 'topright' : 'bottomright' }); @@ -427,10 +424,8 @@

Open source & open data

div.innerHTML = '' + '
' + - 'Email Jurisdiction' + legendCategoryHtml('US Cloud', CATEGORY_COLORS['us-cloud'], catCounts['us-cloud'], 'us-cloud', levelCounts['us-cloud']) + legendCategoryHtml('Swiss Based', CATEGORY_COLORS['swiss-based'], catCounts['swiss-based'], 'swiss-based', levelCounts['swiss-based']) + - '
Insufficient data (' + catCounts['insufficient'] + ')
' + '' + '
'; L.DomEvent.disableClickPropagation(div); diff --git a/js/map-shared.js b/js/map-shared.js index bc43e860..11ad45e7 100644 --- a/js/map-shared.js +++ b/js/map-shared.js @@ -79,7 +79,7 @@ function showGenerated(dnsData) { var date = new Date(dnsData.generated); var text = 'Updated ' + date.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' }); if (dnsData.commit) { - text += ' \u00b7 ' + dnsData.commit; + text += ' \u00b7 commit ' + dnsData.commit; } document.getElementById('generated').textContent = text; } diff --git a/tenant.html b/tenant.html index a85d2ad0..e2a3abe1 100644 --- a/tenant.html +++ b/tenant.html @@ -61,17 +61,19 @@

What is this?

What does a tenant mean?

-

A registered M365 tenant means the municipality's domain is in Microsoft Entra ID, implying (but not confirming) the use of M365 services such as Teams, SharePoint, or OneDrive — but not necessarily email. See the Email Map for email hosting.

+

A registered M365 tenant means the municipality's domain is in Microsoft Entra ID, implying (but not confirming) the use of some M365 services.

Methodology

-

Each domain is queried against Microsoft's public getuserrealm.srf endpoint (unauthenticated). It returns: Managed (fully cloud-managed identity), Federated (hybrid, on-premises AD synced to cloud), or no result.

-

Caveat: Tenant presence confirms domain registration, not specific service usage. Some tenants could be inactive.

+

Each domain is queried against Microsoft's public getuserrealm.srf endpoint, which returns Managed, Federated, or no result.

+
+
+

Accuracy

+

Tenant presence confirms domain registration, not specific service usage. Tenants could be inactive. If you notice an error, please submit an issue.

Open source & open data

-

The code and data are on GitHub.
- If you have noticed an error, please submit an issue.

+

The code and data are on GitHub.

From 90c297a018cc8b92299e36c9ba07b5db177fba36 Mon Sep 17 00:00:00 2001 From: David Huser <4357648+davidhuser@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:21:23 +0200 Subject: [PATCH 2/7] hyperscaler on dns map --- dns.html | 340 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ js/nav.js | 1 + 2 files changed, 341 insertions(+) create mode 100644 dns.html diff --git a/dns.html b/dns.html new file mode 100644 index 00000000..d9e6b448 --- /dev/null +++ b/dns.html @@ -0,0 +1,340 @@ + + + + + + + + + + + + + + + +MXmap — Hyperscaler DNS Exposure of Swiss Municipalities + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

Hyperscaler DNS Exposure of Swiss Municipalities

+
+
+

What is this?

+

A map of all ~2,100 Swiss municipalities colored red if any email-related DNS record references a US hyperscaler — Microsoft, Google, or AWS. Even a single include: in SPF is enough to trigger exposure.

+
+
+

How is this different from the Email Map?

+

The Email Map picks one winning provider per municipality. This map casts a wider net: a municipality whose email is hosted by a Swiss provider but whose SPF record still delegates to spf.protection.outlook.com shows green on the Email Map, but red here.

+
+
+

Which records are checked?

+

MX, SPF, DKIM, autodiscover, DMARC, CNAME chain, TXT verification, and ASN of MX/SPF IPs. The M365 tenant check (getuserrealm.srf) is not counted here because it's not a DNS record — see the Tenant Map for that signal.

+
+
+

Open source & open data

+

The code and data are on GitHub.
+ If you have noticed an error, please submit an issue.

+

+
+
+
+
+
Loading map data…
+
+ + + + diff --git a/js/nav.js b/js/nav.js index 1ae07db4..6f852bdc 100644 --- a/js/nav.js +++ b/js/nav.js @@ -4,6 +4,7 @@ var primary = [ { href: '/', label: 'Email Map', match: ['/', '/index.html'] }, { href: '/tenant.html', label: 'Tenant Map' }, + { href: '/dns.html', label: 'DNS Exposure' }, ]; var secondary = [ { href: '/impressum.html', label: 'Impressum' }, From 4554e3255ceded601cab5386b34863a77a93f615 Mon Sep 17 00:00:00 2001 From: David Huser <4357648+davidhuser@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:35:34 +0200 Subject: [PATCH 3/7] colors --- dns.html | 53 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/dns.html b/dns.html index d9e6b448..3fbe1f4e 100644 --- a/dns.html +++ b/dns.html @@ -58,11 +58,11 @@

Hyperscaler DNS Exposure of Swiss Municipalities

What is this?

-

A map of all ~2,100 Swiss municipalities colored red if any email-related DNS record references a US hyperscaler — Microsoft, Google, or AWS. Even a single include: in SPF is enough to trigger exposure.

+

A map of all ~2,100 Swiss municipalities shaded by how deeply their email DNS touches a US hyperscaler — Microsoft, Google, or AWS. Orange means the MX record itself points to a hyperscaler (email lives there). Pale amber means the MX is elsewhere but SPF, DKIM, autodiscover, or another record still delegates to one. Green means no hyperscaler trace at all.

How is this different from the Email Map?

-

The Email Map picks one winning provider per municipality. This map casts a wider net: a municipality whose email is hosted by a Swiss provider but whose SPF record still delegates to spf.protection.outlook.com shows green on the Email Map, but red here.

+

The Email Map picks one winning provider per municipality. This map casts a wider net and grades exposure: a municipality whose email is hosted by a Swiss provider but whose SPF record still delegates to spf.protection.outlook.com shows as Swiss on the Email Map, but pale amber here.

Which records are checked?

@@ -84,10 +84,18 @@

Open source & open data

document.addEventListener('DOMContentLoaded', function () { var DNS_COLORS = { - hyperscaler: '#d32f2f', - clean: '#4caf50', - nodata: '#cfcfcf', - lake: '#afd7f5', + heavy: '#fc7417', + partial: '#fcae3c', + clean: '#8cee92', + nodata: '#d8d8d8', + lake: '#8fc1e8', +}; + +var DNS_TEXT_ON = { + heavy: '#fff', + partial: '#5a3a00', + clean: '#1f3a22', + nodata: '#333', }; // Mirror of src/mail_sovereignty/signatures.py — keep in sync if Cloudflare @@ -194,7 +202,12 @@

Open source & open data

if (!m) return 'nodata'; var hasData = (m.mx && m.mx.length) || m.spf; if (!hasData) return 'nodata'; - return getHyperscalerHits(m).length > 0 ? 'hyperscaler' : 'clean'; + var hits = getHyperscalerHits(m); + if (hits.length === 0) return 'clean'; + for (var i = 0; i < hits.length; i++) { + if (hits[i].kind === 'mx') return 'heavy'; + } + return 'partial'; } function getColor(m) { @@ -202,8 +215,9 @@

Open source & open data

} function getStatusLabel(status) { - if (status === 'hyperscaler') return 'Hyperscaler exposed'; - if (status === 'clean') return 'No hyperscaler'; + if (status === 'heavy') return 'MX on hyperscaler'; + if (status === 'partial') return 'Hyperscaler in SPF/DNS'; + if (status === 'clean') return 'No hyperscaler'; return 'No data'; } @@ -221,12 +235,12 @@

Open source & open data

muni = dnsData.municipalities; showGenerated(dnsData); - var counts = { hyperscaler: 0, clean: 0, nodata: 0 }; + var counts = { heavy: 0, partial: 0, clean: 0, nodata: 0 }; var byHyperscaler = { microsoft: 0, google: 0, aws: 0 }; for (var bfs of Object.keys(muni)) { var status = getDnsStatus(muni[bfs]); counts[status]++; - if (status === 'hyperscaler') { + if (status === 'heavy' || status === 'partial') { var hs = getHyperscalerHits(muni[bfs]); var seenH = {}; for (var i = 0; i < hs.length; i++) { @@ -248,14 +262,15 @@

Open source & open data

'
' + 'Hyperscaler DNS Exposure' + '
' + - 'Hyperscaler exposed (' + counts.hyperscaler + ')
' + + 'MX on hyperscaler (' + counts.heavy + ')
' + + 'Hyperscaler in SPF/DNS only (' + counts.partial + ')
' + 'No hyperscaler (' + counts.clean + ')
' + 'No data (' + counts.nodata + ')' + '
' + '
' + - 'Microsoft: ' + byHyperscaler.microsoft + '
' + - 'Google: ' + byHyperscaler.google + '
' + - 'AWS: ' + byHyperscaler.aws + + 'Any trace of Microsoft: ' + byHyperscaler.microsoft + '
' + + 'Any trace of Google: ' + byHyperscaler.google + '
' + + 'Any trace of AWS: ' + byHyperscaler.aws + '
' + '
'; L.DomEvent.disableClickPropagation(div); @@ -274,8 +289,8 @@

Open source & open data

var m = muni[bfs]; return { fillColor: getColor(m), - weight: 0.6, - color: '#afafaf', + weight: 0.5, + color: '#444444', fillOpacity: 1 }; }, @@ -289,7 +304,7 @@

Open source & open data

var status = getDnsStatus(m); var statusLabel = getStatusLabel(status); var color = DNS_COLORS[status]; - var textColor = status === 'nodata' ? '#333' : '#fff'; + var textColor = DNS_TEXT_ON[status]; var eName = escapeHtml(m.name); var cantonCode = CANTON_CODES[m.canton] || ''; @@ -303,7 +318,7 @@

Open source & open data

'' + nameDisplay + '
' + eDomain + ' ' + badge; - if (status === 'hyperscaler') { + if (status === 'heavy' || status === 'partial') { var hits = getHyperscalerHits(m); var hyperscalersFound = {}; for (var i = 0; i < hits.length; i++) hyperscalersFound[hits[i].hyperscaler] = true; From 1d058a7a2d266b84462e9c14cd510efbfbbf2911 Mon Sep 17 00:00:00 2001 From: David Huser <4357648+davidhuser@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:57:34 +0200 Subject: [PATCH 4/7] footprint map --- README.md | 6 +- css/map.css | 6 +- css/shared.css | 16 +-- datenschutz.html | 2 +- dns.html => footprint.html | 62 +++++----- impressum.html | 2 +- index.html | 5 +- js/nav.js | 5 +- tenant.html | 236 ------------------------------------- 9 files changed, 52 insertions(+), 288 deletions(-) rename dns.html => footprint.html (80%) delete mode 100644 tenant.html diff --git a/README.md b/README.md index 7129add8..0e6a7af7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# MXmap — Email Providers of Swiss Municipalities +# MXmap — municipal email infrastructure maps [![CI](https://github.com/davidhuser/mxmap/actions/workflows/ci.yml/badge.svg)](https://github.com/davidhuser/mxmap/actions/workflows/ci.yml) -An interactive map showing where Swiss municipalities host their email — whether with US hyperscalers (Microsoft, Google, AWS) or Swiss providers or other solutions. +Interactive maps showing where Swiss municipalities host their email and how deeply their DNS is tied to US hyperscalers (Microsoft, Google, AWS) versus Swiss providers and self-hosted solutions. -**[View the live map](https://mxmap.ch)** +**[View the live maps](https://mxmap.ch)** [![Screenshot of MXmap](og-image.jpg)](https://mxmap.ch) diff --git a/css/map.css b/css/map.css index 58d88b76..9c48aa65 100644 --- a/css/map.css +++ b/css/map.css @@ -11,14 +11,14 @@ body { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; #info-bar { background: #f0f1f5; border-bottom: 1px solid #d8dae0; overflow: clip; flex-shrink: 0; position: relative; z-index: 1; - max-height: 600px; padding: 16px 20px; + min-height: 120px; max-height: 600px; padding: 13px 20px; box-shadow: 0 2px 6px rgba(0,0,0,0.1); } -#info-bar.collapsed { max-height: 0; padding-top: 0; padding-bottom: 0; } +#info-bar.collapsed { min-height: 0; max-height: 0; padding-top: 0; padding-bottom: 0; } .info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 14px; + gap: 12px; } .info-card h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; color: #1a1a2e; } .info-card p { font-size: 13px; color: #444; line-height: 1.5; margin: 0; } diff --git a/css/shared.css b/css/shared.css index 043abc16..0ccedf57 100644 --- a/css/shared.css +++ b/css/shared.css @@ -8,13 +8,14 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans- position: relative; z-index: 2000; } .header-left { display: flex; align-items: center; gap: 10px; min-width: 0; } -.brand { font-size: 17px; font-weight: 700; white-space: nowrap; margin: 0; } +.brand { + font-size: 14px; font-weight: 700; white-space: nowrap; margin: 0; + line-height: 1.05; +} .brand a { color: #fff; text-decoration: none; } -.beta-badge { - font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; - background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3); - color: #f0c040; padding: 1px 6px; border-radius: 3px; vertical-align: middle; - cursor: default; +.brand-tag { + display: block; font-weight: 400; font-size: 11px; + opacity: 0.6; letter-spacing: 0.3px; margin-top: 1px; } .header-right { display: flex; align-items: center; gap: 12px; flex-shrink: 0; } .header-disclaimer { @@ -59,8 +60,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans- .nav-menu .nav-menu-mobile { display: none; } @media (max-width: 600px) { - .beta-badge { display: none; } - .header-disclaimer { font-size: 10px; padding: 0 6px; } + .header-disclaimer { display: none; } .nav-primary .header-link { font-size: 11px; padding: 2px 8px; } .nav-primary { display: none; } .nav-menu .nav-menu-mobile { display: block; } diff --git a/datenschutz.html b/datenschutz.html index 700ed60e..f98032a6 100644 --- a/datenschutz.html +++ b/datenschutz.html @@ -12,7 +12,7 @@ diff --git a/dns.html b/footprint.html similarity index 80% rename from dns.html rename to footprint.html index 3fbe1f4e..2280c20f 100644 --- a/dns.html +++ b/footprint.html @@ -13,22 +13,22 @@ -MXmap — Hyperscaler DNS Exposure of Swiss Municipalities - - +MXmap — Hyperscaler Footprint of Swiss Municipalities + + - + - - - + + + - - + + @@ -43,40 +43,42 @@
-

Hyperscaler DNS Exposure of Swiss Municipalities

+

Hyperscaler Footprint of Swiss Municipalities

What is this?

-

A map of all ~2,100 Swiss municipalities shaded by how deeply their email DNS touches a US hyperscaler — Microsoft, Google, or AWS. Orange means the MX record itself points to a hyperscaler (email lives there). Pale amber means the MX is elsewhere but SPF, DKIM, autodiscover, or another record still delegates to one. Green means no hyperscaler trace at all.

+

All ~2,100 Swiss municipalities, shaded by how much their email DNS touches a US hyperscaler (Microsoft, Google, AWS). See the legend for what each color means.

-

How is this different from the Email Map?

-

The Email Map picks one winning provider per municipality. This map casts a wider net and grades exposure: a municipality whose email is hosted by a Swiss provider but whose SPF record still delegates to spf.protection.outlook.com shows as Swiss on the Email Map, but pale amber here.

+

How is this different from the Providers map?

+

The Providers map names one email provider per municipality. This one is broader, it also catches Swiss-hosted municipalities that still touch Microsoft, Google, or AWS somewhere in their email setup.

-

Which records are checked?

-

MX, SPF, DKIM, autodiscover, DMARC, CNAME chain, TXT verification, and ASN of MX/SPF IPs. The M365 tenant check (getuserrealm.srf) is not counted here because it's not a DNS record — see the Tenant Map for that signal.

+

Which signals are checked?

+

DNS records: MX, SPF, DKIM, autodiscover, DMARC, CNAME chain, TXT verification, and ASN of MX/SPF IPs.

+
+
+

Accuracy

+

Classifications may contain errors. DNS records indicate mail routing, not necessarily where data is stored.
If you notice an error, please submit an issue.

Open source & open data

-

The code and data are on GitHub.
- If you have noticed an error, please submit an issue.

+

The code and data are on GitHub.

-
+
Loading map data…
@@ -84,18 +86,18 @@

Open source & open data

document.addEventListener('DOMContentLoaded', function () { var DNS_COLORS = { - heavy: '#fc7417', - partial: '#fcae3c', - clean: '#8cee92', - nodata: '#d8d8d8', - lake: '#8fc1e8', + heavy: '#fd968d', + partial: '#ffc79f', + clean: '#89ffae', + nodata: '#cbd5e1', + lake: '#9cdafd', }; var DNS_TEXT_ON = { heavy: '#fff', - partial: '#5a3a00', - clean: '#1f3a22', - nodata: '#333', + partial: '#4a2400', + clean: '#0f3a2c', + nodata: '#3f3f46', }; // Mirror of src/mail_sovereignty/signatures.py — keep in sync if Cloudflare @@ -260,7 +262,7 @@

Open source & open data

div.innerHTML = '' + '
' + - 'Hyperscaler DNS Exposure' + + 'Hyperscaler Footprint' + '
' + 'MX on hyperscaler (' + counts.heavy + ')
' + 'Hyperscaler in SPF/DNS only (' + counts.partial + ')
' + diff --git a/impressum.html b/impressum.html index 29eb5142..3b6cb5ff 100644 --- a/impressum.html +++ b/impressum.html @@ -12,7 +12,7 @@ diff --git a/index.html b/index.html index 4f82ced7..1f22ca13 100644 --- a/index.html +++ b/index.html @@ -43,11 +43,10 @@
- ⚠ Beta — data may be out of date or incorrect. A research project is ongoing to further develop these maps. + Beta: data may be out of date or incorrect. A research project is ongoing to further develop these maps.
@@ -57,7 +57,7 @@

Email Providers of Swiss Municipalities

What is this?

-

A map of all ~2,100 Swiss municipalities showing which provider handles their official email — grouped by jurisdiction — based on public DNS records and other public network signals.

+

A map of all ~2,100 Swiss municipalities, grouped by jurisdiction. Each is colored by the provider handling its official email, based on public DNS records and other public network signals.

Context

@@ -86,7 +86,7 @@

Open source & open data

document.addEventListener('DOMContentLoaded', function () { const COLOR_SCHEMES = { default: { - 'us-cloud': { high: '#ffa199', medium: '#ffccb6', low: '#cccccc' }, + 'us-cloud': { high: '#ff9e91', medium: '#ffccaa', low: '#cccccc' }, 'swiss-based': { high: '#88faaa', medium: '#daffc2', low: '#cccccc' }, lake: '#89B3D6', }, @@ -347,6 +347,26 @@

Open source & open data

} } +// Hyperscaler trace detection for swiss-based municipalities: flags cases where +// MX is Swiss but outbound/auth DNS still touches Microsoft/Google/AWS. +const HYPERSCALER_PROVIDERS = { microsoft: 'Microsoft', google: 'Google', aws: 'AWS' }; +const NON_MX_TRACE_KINDS = { spf: 1, dkim: 1, autodiscover: 1, cname_chain: 1, spf_ip: 1, asn: 1 }; + +function getHyperscalerNonMxTraces(m) { + if (!m || !m.classification_signals) return []; + const byHs = {}; + for (const s of m.classification_signals) { + const label = HYPERSCALER_PROVIDERS[s.provider]; + if (!label || !NON_MX_TRACE_KINDS[s.kind]) continue; + if (!byHs[label]) byHs[label] = new Set(); + byHs[label].add(SIGNAL_LABELS[s.kind] || s.kind); + } + return Object.keys(byHs).sort().map(name => ({ + name, + kinds: Array.from(byHs[name]).sort(), + })); +} + const map = initMap('map'); setupInfoBar(map); @@ -378,7 +398,11 @@

Open source & open data

} document.querySelectorAll('.legend-swatch').forEach(function (el) { - el.style.background = CATEGORY_COLORS[el.dataset.cat][el.dataset.level]; + if (el.dataset.lake) { + el.style.background = CATEGORY_COLORS.lake; + } else { + el.style.background = CATEGORY_COLORS[el.dataset.cat][el.dataset.level]; + } }); var btn = document.querySelector('.color-toggle'); @@ -426,6 +450,9 @@

Open source & open data

'
' + legendCategoryHtml('US Cloud', CATEGORY_COLORS['us-cloud'], catCounts['us-cloud'], 'us-cloud', levelCounts['us-cloud']) + legendCategoryHtml('Swiss Based', CATEGORY_COLORS['swiss-based'], catCounts['swiss-based'], 'swiss-based', levelCounts['swiss-based']) + + '
' + + 'Lake (not a municipality)' + + '
' + '' + '
'; L.DomEvent.disableClickPropagation(div); @@ -511,11 +538,21 @@

Open source & open data

if (m.gateway) metaParts.push(`Gateway: ${escapeHtml(m.gateway)}`); const metaLine = ``; + let traceLine = ''; + if (cat === 'swiss-based') { + const traces = getHyperscalerNonMxTraces(m); + if (traces.length) { + const parts = traces.map(t => `${t.name} (${t.kinds.join(', ')})`).join(', '); + traceLine = ``; + } + } + layer.bindPopup( `
` + `${nameDisplay}
` + `${eDomain} ${badge}` + metaLine + + traceLine + mxSection + spfSection + signalsSection + `
`, { maxWidth: isMobile ? 300 : 450 } diff --git a/js/map-shared.js b/js/map-shared.js index 11ad45e7..7e136ac4 100644 --- a/js/map-shared.js +++ b/js/map-shared.js @@ -1,4 +1,4 @@ -/* map-shared.js — shared utilities for map pages */ +/* map-shared.js - shared utilities for map pages */ function escapeHtml(str) { var el = document.createElement('span'); diff --git a/js/nav.js b/js/nav.js index 45f9ad29..17b9713f 100644 --- a/js/nav.js +++ b/js/nav.js @@ -1,11 +1,7 @@ -/* nav.js — auto-renders navigation with pill tabs + dropdown overflow menu */ +/* nav.js - auto-renders dropdown overflow menu */ (function () { var path = window.location.pathname; - var primary = [ - { href: '/', label: 'Providers', match: ['/', '/index.html'] }, - { href: '/footprint.html', label: 'Footprint' }, - ]; - var secondary = [ + var links = [ { href: '/impressum.html', label: 'Impressum' }, { href: '/datenschutz.html', label: 'Datenschutz' }, ]; @@ -13,44 +9,27 @@ var nav = document.getElementById('nav'); if (!nav) return; - function isActive(link) { - return link.match - ? link.match.indexOf(path) !== -1 - : path === link.href; - } - - function makeLink(link, extraClass) { + function makeLink(link) { var a = document.createElement('a'); a.href = link.href; - a.className = 'header-link' + (extraClass ? ' ' + extraClass : ''); + a.className = 'header-link'; a.textContent = link.label; - if (isActive(link)) a.classList.add('active'); + if (path === link.href) a.classList.add('active'); return a; } - /* inline primary links as pill tabs (hidden on mobile via CSS) */ - var inlineWrap = document.createElement('span'); - inlineWrap.className = 'nav-primary'; - primary.forEach(function (link) { - inlineWrap.appendChild(makeLink(link)); - }); - nav.appendChild(inlineWrap); - /* toggle button */ var toggle = document.createElement('button'); toggle.className = 'nav-menu-toggle'; toggle.setAttribute('aria-label', 'More links'); toggle.setAttribute('aria-expanded', 'false'); - toggle.textContent = '\u22EF'; + toggle.textContent = '⋯'; nav.appendChild(toggle); /* dropdown menu */ var menu = document.createElement('div'); menu.className = 'nav-menu'; - primary.forEach(function (link) { - menu.appendChild(makeLink(link, 'nav-menu-mobile')); - }); - secondary.forEach(function (link) { + links.forEach(function (link) { menu.appendChild(makeLink(link)); }); nav.appendChild(menu); From 9f900a530918f72221f61b772c08a86010f6ad60 Mon Sep 17 00:00:00 2001 From: David Huser <4357648+davidhuser@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:56:17 +0200 Subject: [PATCH 6/7] lake legend --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 53bfb63d..9edb318b 100644 --- a/index.html +++ b/index.html @@ -88,7 +88,7 @@

Open source & open data

default: { 'us-cloud': { high: '#ff9e91', medium: '#ffccaa', low: '#cccccc' }, 'swiss-based': { high: '#88faaa', medium: '#daffc2', low: '#cccccc' }, - lake: '#89B3D6', + lake: '#a3cef1', }, colorblind: { 'us-cloud': { high: '#f1be61', medium: '#fde7c4', low: '#cccccc' }, From 65a7eebbd7bd5db2ba0812a9d1504b3f9536deed Mon Sep 17 00:00:00 2001 From: David Huser <4357648+davidhuser@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:15:05 +0200 Subject: [PATCH 7/7] remove inline popup info --- css/map.css | 5 ----- index.html | 31 ------------------------------- 2 files changed, 36 deletions(-) diff --git a/css/map.css b/css/map.css index d7c3a8da..2eb75721 100644 --- a/css/map.css +++ b/css/map.css @@ -75,11 +75,6 @@ body { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; .popup-toggle:hover { color: #666; } .popup-section-body { padding-top: 4px; } .popup-meta { display: block; font-size: 11px; color: #888; margin-top: 2px; } -.popup-trace { - display: block; font-size: 11px; color: #92400e; - background: #fef3c7; padding: 3px 6px; border-radius: 3px; - margin-top: 4px; -} .popup-signal-count { display: inline-block; background: #f3f4f6; color: #6b7280; font-size: 10px; font-weight: 600; padding: 0 5px; border-radius: 8px; diff --git a/index.html b/index.html index 9edb318b..2c80281a 100644 --- a/index.html +++ b/index.html @@ -347,27 +347,6 @@

Open source & open data

} } -// Hyperscaler trace detection for swiss-based municipalities: flags cases where -// MX is Swiss but outbound/auth DNS still touches Microsoft/Google/AWS. -const HYPERSCALER_PROVIDERS = { microsoft: 'Microsoft', google: 'Google', aws: 'AWS' }; -const NON_MX_TRACE_KINDS = { spf: 1, dkim: 1, autodiscover: 1, cname_chain: 1, spf_ip: 1, asn: 1 }; - -function getHyperscalerNonMxTraces(m) { - if (!m || !m.classification_signals) return []; - const byHs = {}; - for (const s of m.classification_signals) { - const label = HYPERSCALER_PROVIDERS[s.provider]; - if (!label || !NON_MX_TRACE_KINDS[s.kind]) continue; - if (!byHs[label]) byHs[label] = new Set(); - byHs[label].add(SIGNAL_LABELS[s.kind] || s.kind); - } - return Object.keys(byHs).sort().map(name => ({ - name, - kinds: Array.from(byHs[name]).sort(), - })); -} - - const map = initMap('map'); setupInfoBar(map); const isMobile = window.innerWidth <= 600; @@ -538,21 +517,11 @@

Open source & open data

if (m.gateway) metaParts.push(`Gateway: ${escapeHtml(m.gateway)}`); const metaLine = ``; - let traceLine = ''; - if (cat === 'swiss-based') { - const traces = getHyperscalerNonMxTraces(m); - if (traces.length) { - const parts = traces.map(t => `${t.name} (${t.kinds.join(', ')})`).join(', '); - traceLine = ``; - } - } - layer.bindPopup( `
` + `${nameDisplay}
` + `${eDomain} ${badge}` + metaLine + - traceLine + mxSection + spfSection + signalsSection + `
`, { maxWidth: isMobile ? 300 : 450 }