Skip to content

Commit 2726560

Browse files
committed
docs
1 parent 5880d5f commit 2726560

3 files changed

Lines changed: 51 additions & 43 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@
33
[![CI](https://github.com/davidhuser/mxmap/actions/workflows/ci.yml/badge.svg)](https://github.com/davidhuser/mxmap/actions/workflows/ci.yml)
44
[![Nightly](https://github.com/davidhuser/mxmap/actions/workflows/nightly.yml/badge.svg)](https://github.com/davidhuser/mxmap/actions/workflows/nightly.yml)
55

6-
An interactive map showing whether Swiss municipalities use hyperscaler email services (Microsoft 365, Google Workspace, AWS SES) or sovereign/self-hosted email infrastructure.
6+
An interactive map showing where Swiss municipalities host their email — whether with US hyperscalers (Microsoft, Google, AWS) or Swiss providers or other solutions.
77

88
**[View the live map](https://mxmap.ch)**
99

10-
Inspired by [belibre.be/map](https://belibre.be/map/nl.html).
10+
[![Screenshot of MXmap](og-image.png)](https://mxmap.ch)
1111

1212
## How it works
1313

1414
The data pipeline has three steps:
1515

1616
1. **Preprocess** -- Fetches all ~2100 Swiss municipalities from Wikidata, performs MX and SPF DNS lookups on their official domains, and classifies each municipality's email provider.
17-
2. **Postprocess** -- Scrapes websites of still-unclassified municipalities for email addresses, resolves those email domains via DNS, and applies manual overrides for edge cases.
17+
2. **Postprocess** -- Applies manual overrides for edge cases, retries DNS for unresolved domains, then scrapes websites of still-unclassified municipalities for email addresses.
1818
3. **Validate** -- Cross-validates MX and SPF records, assigns a confidence score (0-100) to each entry, and generates a validation report.
1919

2020
## Quick start
@@ -43,29 +43,6 @@ uv run ruff check src tests
4343
uv run ruff format src tests
4444
```
4545

46-
47-
## Methodology
48-
49-
Classification uses a **MX-first** approach:
50-
51-
1. **MX records** are checked first -- they show where mail is actually delivered
52-
2. **SPF records** are used as fallback only when no MX records exist, to avoid misclassifying self-hosted mail that merely authorizes third-party senders (e.g. for newsletters)
53-
3. For municipalities with no website or DNS records, domain guessing generates plausible `.ch` domains from the municipality name
54-
55-
## Data format
56-
57-
Each municipality entry in `data.json` contains:
58-
59-
| Field | Description |
60-
|------------|---------------------------------------------------------|
61-
| `bfs` | Swiss Federal Statistical Office municipality number |
62-
| `name` | Municipality name |
63-
| `canton` | Canton name |
64-
| `domain` | Email domain used by the municipality |
65-
| `mx` | List of MX record hostnames |
66-
| `spf` | SPF TXT record |
67-
| `provider` | Classified provider (`microsoft`, `google`, `aws`, `infomaniak`, `sovereign`, `merged`, `unknown`) |
68-
6946
## Contributing
7047

7148
If you spot a misclassification, please open an issue with the BFS number and the correct provider.

‎index.html‎

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
<meta property="og:url" content="https://mxmap.ch/">
2828
<meta property="og:locale" content="en_CH">
2929
<meta property="og:site_name" content="MXmap">
30-
<meta name="twitter:card" content="summary">
30+
<meta property="og:image" content="og-image.png">
31+
<meta name="twitter:card" content="summary_large_image">
3132
<meta name="twitter:title" content="MXmap — Email Sovereignty of Swiss Municipalities">
3233
<meta name="twitter:description" content="Interactive map showing where Swiss municipalities host their official email. DNS analysis of all ~2,100 municipalities.">
34+
<meta name="twitter:image" content="og-image.png">
3335
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='2' y='7' width='28' height='18' rx='2' fill='%231a1a2e'/%3E%3Cpath d='M2 9l14 9 14-9' fill='none' stroke='%2316a34a' stroke-width='2'/%3E%3C/svg%3E">
3436
<link rel="preload" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
3537
<noscript><link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"></noscript>
@@ -72,7 +74,6 @@
7274
display: none; background: none; border: none; font-size: 13px;
7375
font-weight: 600; cursor: pointer; padding: 0; color: #333;
7476
}
75-
.legend-content { }
7677
.info-title { display: none; font-size: 16px; font-weight: 600; color: #1a1a2e; margin-bottom: 12px; }
7778
@media (max-width: 600px) {
7879
.separator, .tagline { display: none; }
@@ -83,8 +84,7 @@
8384
.legend.legend-collapsed { padding: 8px 12px; }
8485

8586
}
86-
#map { flex: 1; width: 100%; }
87-
.leaflet-bottom.leaflet-right { }
87+
#map { flex: 1; position: relative; width: 100%; }
8888
.legend {
8989
background: white; padding: 10px 14px; border-radius: 6px;
9090
box-shadow: 0 2px 8px rgba(0,0,0,0.2); font-size: 13px; line-height: 1.8;
@@ -138,7 +138,7 @@
138138
<span class="tagline">Email Sovereignty of Swiss Municipalities</span>
139139
</div>
140140
<div class="header-right">
141-
<button class="toggle-info" id="toggle-info" onclick="toggleInfo()">About &#9652;</button>
141+
<button class="toggle-info" id="toggle-info" aria-expanded="true" aria-controls="info-bar">About &#9652;</button>
142142
</div>
143143
</div>
144144
<div id="info-bar">
@@ -158,7 +158,9 @@ <h3>How is it done?</h3>
158158
</div>
159159
</div>
160160
</div>
161-
<div id="map"></div>
161+
<div id="map" role="application" aria-label="Interactive map of Swiss municipality email providers">
162+
<div id="map-loading" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:1000;background:rgba(255,255,255,0.85);font-size:15px;color:#555;">Loading map data&hellip;</div>
163+
</div>
162164

163165
<script>
164166
document.addEventListener('DOMContentLoaded', function () {
@@ -184,6 +186,12 @@ <h3>How is it done?</h3>
184186
unknown: 'Unknown'
185187
};
186188

189+
function escapeHtml(str) {
190+
const el = document.createElement('span');
191+
el.textContent = str;
192+
return el.innerHTML;
193+
}
194+
187195
function classifyHost(host) {
188196
const h = host.toLowerCase();
189197
// Hyperscalers
@@ -270,7 +278,10 @@ <h3>How is it done?</h3>
270278
function renderTable(hosts) {
271279
const rows = hosts.map(host => {
272280
const cls = classifyHost(host);
273-
return `<tr><td class="dn">${cls.name}</td><td><span class="cat-badge ${cls.category}">${cls.category}</span></td><td class="host" title="${host}">${host}</td></tr>`;
281+
const eName = escapeHtml(cls.name);
282+
const eCat = escapeHtml(cls.category);
283+
const eHost = escapeHtml(host);
284+
return `<tr><td class="dn">${eName}</td><td><span class="cat-badge ${eCat}">${eCat}</span></td><td class="host" title="${eHost}">${eHost}</td></tr>`;
274285
}).join('');
275286
return `<table class="dns-table">${rows}</table>`;
276287
}
@@ -294,25 +305,31 @@ <h3>How is it done?</h3>
294305
pane: 'shadowPane'
295306
}).addTo(map);
296307

297-
window.toggleInfo = function () {
308+
function toggleInfo() {
298309
const bar = document.getElementById('info-bar');
299310
const btn = document.getElementById('toggle-info');
300311
bar.classList.toggle('collapsed');
301-
btn.innerHTML = bar.classList.contains('collapsed') ? 'About \u25be' : 'About \u25b4';
302-
};
312+
const collapsed = bar.classList.contains('collapsed');
313+
btn.innerHTML = collapsed ? 'About \u25be' : 'About \u25b4';
314+
btn.setAttribute('aria-expanded', String(!collapsed));
315+
}
316+
317+
document.getElementById('toggle-info').addEventListener('click', toggleInfo);
303318

304319
// Collapse info bar by default on mobile
305320
if (window.innerWidth <= 600) {
306321
document.getElementById('info-bar').classList.add('collapsed');
307-
document.getElementById('toggle-info').innerHTML = 'About \u25be';
322+
const btn = document.getElementById('toggle-info');
323+
btn.innerHTML = 'About \u25be';
324+
btn.setAttribute('aria-expanded', 'false');
308325
}
309326

310-
window.toggleLegend = function () {
327+
function toggleLegend() {
311328
const legend = document.querySelector('.legend');
312329
const btn = legend.querySelector('.legend-toggle');
313330
legend.classList.toggle('legend-collapsed');
314331
btn.textContent = legend.classList.contains('legend-collapsed') ? 'Legend \u25B8' : '\u2715';
315-
};
332+
}
316333

317334
document.getElementById('info-bar').addEventListener('transitionend', function (e) {
318335
if (e.propertyName === 'max-height') map.invalidateSize();
@@ -361,7 +378,7 @@ <h3>How is it done?</h3>
361378
const div = L.DomUtil.create('div', 'legend');
362379
if (isMobile) div.classList.add('legend-collapsed');
363380
div.innerHTML =
364-
'<button class="legend-toggle" onclick="toggleLegend()">' + (isMobile ? 'Legend \u25B8' : '') + '</button>' +
381+
'<button class="legend-toggle">' + (isMobile ? 'Legend \u25B8' : '') + '</button>' +
365382
'<div class="legend-content">' +
366383
'<strong>Email Provider</strong>' +
367384
legendGroupHtml('US Hyperscaler', ['microsoft', 'google', 'aws'], dnsData.counts) +
@@ -372,6 +389,7 @@ <h3>How is it done?</h3>
372389
return div;
373390
};
374391
legend.addTo(map);
392+
document.querySelector('.legend-toggle').addEventListener('click', toggleLegend);
375393

376394
// Render lakes as a distinct layer
377395
if (topo.objects.lakes) {
@@ -421,10 +439,13 @@ <h3>How is it done?</h3>
421439
spfSection = `<div class="popup-section"><div class="popup-section-title">Authorized senders (SPF)</div>${renderTable(spfDelegations)}</div>`;
422440
}
423441

442+
const eName = escapeHtml(m.name);
443+
const eDomain = escapeHtml(m.domain || 'unknown');
444+
const eLabel = escapeHtml(label);
424445
layer.bindPopup(
425446
`<div class="info-popup">` +
426-
`<strong>${m.name}</strong><br>` +
427-
`${m.domain || 'unknown'} <span class="provider-badge" style="background:${color};color:${textColor}">${label}</span>` +
447+
`<strong>${eName}</strong><br>` +
448+
`${eDomain} <span class="provider-badge" style="background:${color};color:${textColor}">${eLabel}</span>` +
428449
mxSection + spfSection +
429450
`</div>`,
430451
{ maxWidth: 450 }
@@ -433,9 +454,19 @@ <h3>How is it done?</h3>
433454
layer.on('mouseout', function () { this.setStyle({ weight: 0.5, color: '#666' }); });
434455
}
435456
}).addTo(map);
457+
458+
const loading = document.getElementById('map-loading');
459+
if (loading) loading.remove();
436460
}
437461

438-
loadData().catch(err => console.error('Failed to load data:', err));
462+
loadData().catch(err => {
463+
console.error('Failed to load data:', err);
464+
const loading = document.getElementById('map-loading');
465+
if (loading) {
466+
loading.textContent = 'Failed to load map data. Please try again later.';
467+
loading.style.color = '#dc2626';
468+
}
469+
});
439470
});
440471
</script>
441472
</body>

‎og-image.png‎

1.4 MB
Loading

0 commit comments

Comments
 (0)