Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,9 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v6
- name: Inject CARTO basemap key
env:
CARTO_KEY: ${{ secrets.CARTO_KEY }}
run: |
test -n "$CARTO_KEY" || { echo "CARTO_KEY secret is not set"; exit 1; }
sed -i "s|__CARTO_KEY__|$CARTO_KEY|" js/map-shared.js
grep -q "$CARTO_KEY" js/map-shared.js
# No basemap key injection anymore: the basemap is keyless (OSM primary
# + Esri reserve, see js/map-shared.js). Forks and local checkouts render
# exactly what production renders, and no CARTO_KEY secret is needed.
- uses: actions/upload-pages-artifact@v4
with:
path: .
Expand Down
21 changes: 21 additions & 0 deletions css/map.css
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,24 @@ body { display: flex; flex-direction: column; height: 100dvh; overflow: hidden;
.signal-text { max-width: 140px; }
.signal-item { gap: 4px; padding-left: 4px; }
}

/* ── Keyless basemap (OSM primary, Esri reserve) ─────────────────────────────
.basemap-muted recreates the light-gray "positron" canvas from the raw
OSM tiles (Leaflet puts a tileLayer's className on the LAYER CONTAINER,
not on the tile <img>s — target both for robustness).
.leaflet-basemap-labels-pane re-draws the SAME tiles above the data
polygons and keeps only their dark pixels (place names, admin borders)
via mix-blend-mode: darken + a midtone-crushing filter. Hidden entirely
where blending is unsupported, so it can never cover the data. */
.leaflet-layer.basemap-muted,
.leaflet-tile.basemap-muted {
filter: saturate(0) brightness(1.06) contrast(0.9);
}
.leaflet-basemap-labels-pane { display: none; pointer-events: none; }
@supports (mix-blend-mode: darken) {
.leaflet-basemap-labels-pane {
display: block;
mix-blend-mode: darken;
filter: saturate(0) brightness(1.35) contrast(1.6);
}
}
2 changes: 1 addition & 1 deletion datenschutz.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ <h2>3. Externe Ressourcen</h2>
<p>Zur Darstellung der Karten werden externe Ressourcen von Drittanbietern geladen. Dabei wird Ihre IP-Adresse an die jeweiligen Server übermittelt:</p>
<ul>
<li><code>unpkg.com</code> — Leaflet-Kartenbibliothek und Gemeindegrenzen (TopoJSON)</li>
<li><code>basemaps.cartocdn.com</code> — Kartenkacheln (CARTO)</li>
<li><code>tile.openstreetmap.org</code> — Kartenkacheln (OpenStreetMap)</li>
</ul>

<h2>4. Cookies</h2>
Expand Down
138 changes: 122 additions & 16 deletions js/map-shared.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,52 @@
/* map-shared.js — shared utilities for map pages */

var CARTO_KEY = '__CARTO_KEY__'; // injected from the CARTO_KEY GitHub secret at deploy time
/* Basemap: open & keyless providers only — no API keys, no accounts, no
* deploy-time secret injection. Rationale: on a static site a "secret"
* basemap key ends up world-readable in the served JS anyway (anyone can
* copy it and burn the quota), forks and local checkouts render broken
* tiles, and the deploy gains a billing dependency. CARTO put its raster
* basemaps behind API keys (watermark rollout 2026-08-28, enforcement
* 2026-09-23), so the keyless CARTO URLs this file used are dead.
*
* Primary: OpenStreetMap standard raster (OSMF community infrastructure,
* native up to z19). The familiar light-gray "positron" look is recreated
* with a CSS grayscale filter (.basemap-muted, css/map.css); the labels
* that OSM bakes into the raster are lifted ABOVE the data polygons by
* re-drawing the same tiles on a dedicated pane blended with
* mix-blend-mode: darken (same URLs -> served from the browser HTTP
* cache, zero extra tile requests; see css/map.css).
*
* Fallback (runtime failover): Esri World Light Gray, also keyless. Two
* Esri traps encoded here: the axis order is /tile/{z}/{y}/{x} — INVERTED
* vs slippy — and native tiles stop at z16 (maxNativeZoom upscales
* beyond). A wrong axis order renders the wrong place on Earth with zero
* errors, so tests/test_basemap.py pins the rule per host.
*/
var BASEMAP = {
base: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
exportAttribution: '© OpenStreetMap contributors',
maxZoom: 19,
fallbackBase: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
fallbackLabels: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}',
fallbackAttribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &middot; Tiles &copy; <a href="https://www.esri.com/">Esri</a>',
fallbackExportAttribution: '© OpenStreetMap · Tiles © Esri',
fallbackMaxNativeZoom: 16,
failoverThreshold: 8
};

/* Active basemap state — the PNG export reads this too, so a failover
* changes both the on-screen map and exported images coherently. */
var activeBasemap = {
base: BASEMAP.base,
labels: BASEMAP.base, // OSM: labels are the same tiles, blended
exportAttribution: BASEMAP.exportAttribution,
exportMaxZoom: 18
};

function tileUrlFromTemplate(template, z, x, y) {
return template.replace('{z}', z).replace('{x}', x).replace('{y}', y);
}

function escapeHtml(str) {
var el = document.createElement('span');
Expand All @@ -21,20 +67,68 @@ function initMap(elementId, options) {
renderer: L.canvas()
});

L.tileLayer('https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png?key=' + CARTO_KEY, {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
// Dedicated pane for the label overlay: above the data polygons in the
// overlayPane (z 400), below markers (z 600). CSS in css/map.css gives it
// mix-blend-mode: darken + a midtone-crushing filter so only the dark
// pixels (place names, admin borders) emerge above the colored polygons,
// and hides it entirely where blending is unsupported (@supports guard).
map.createPane('basemap-labels');
map.getPane('basemap-labels').style.zIndex = 450;
map.getPane('basemap-labels').style.pointerEvents = 'none';

var baseLayer = L.tileLayer(activeBasemap.base, {
attribution: BASEMAP.attribution,
maxZoom: 19,
className: 'basemap-muted',
crossOrigin: ''
}).addTo(map);

L.tileLayer('https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png?key=' + CARTO_KEY, {
subdomains: 'abcd',
var labelLayer = L.tileLayer(activeBasemap.labels, {
maxZoom: 19,
pane: 'shadowPane',
pane: 'basemap-labels',
crossOrigin: ''
}).addTo(map);

// Runtime failover: OSMF is community infrastructure without an SLA. If
// the base layer accumulates tileerror events (outage, throttling), swap
// both layers to the keyless Esri reserve. The reserve is exercised daily
// by tests/test_basemap.py so it cannot rot unnoticed.
var osmTileErrors = 0;
var failoverDone = false;
function activateBasemapFailover(reason) {
if (failoverDone) return;
failoverDone = true;
console.warn('[basemap] primary basemap failing (' + reason + '): switching to Esri World Light Gray reserve');
map.removeLayer(baseLayer);
map.removeLayer(labelLayer);
activeBasemap.base = BASEMAP.fallbackBase;
activeBasemap.labels = BASEMAP.fallbackLabels;
activeBasemap.exportAttribution = BASEMAP.fallbackExportAttribution;
activeBasemap.exportMaxZoom = BASEMAP.fallbackMaxNativeZoom;
baseLayer = L.tileLayer(BASEMAP.fallbackBase, {
attribution: BASEMAP.fallbackAttribution,
maxNativeZoom: BASEMAP.fallbackMaxNativeZoom,
maxZoom: 19,
className: 'basemap-muted',
crossOrigin: ''
}).addTo(map);
// Esri Reference is a transparent label-only layer: on the blend pane
// the darken blend keeps the labels above the polygons, like OSM.
labelLayer = L.tileLayer(BASEMAP.fallbackLabels, {
maxNativeZoom: BASEMAP.fallbackMaxNativeZoom,
maxZoom: 19,
pane: 'basemap-labels',
crossOrigin: ''
}).addTo(map);
}
baseLayer.on('tileerror', function () {
osmTileErrors += 1;
if (osmTileErrors >= BASEMAP.failoverThreshold) {
activateBasemapFailover(osmTileErrors + ' tileerror');
}
});
window.__forceBasemapFailover = function () { activateBasemapFailover('manual'); };

var resizeTimer;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
Expand Down Expand Up @@ -167,19 +261,27 @@ function handleLoadError(err) {
}
}

function fetchTileLayer(ctx, layer, zoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY) {
function fetchTileLayer(ctx, template, zoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY, opts) {
opts = opts || {};
var promises = [];
for (var tx = minTX; tx <= maxTX; tx++) {
for (var ty = minTY; ty <= maxTY; ty++) {
(function (tx, ty) {
var sub = 'abcd'.charAt(Math.abs(tx + ty) % 4);
var url = 'https://' + sub + '.basemaps.cartocdn.com/' + layer + '/' + zoom + '/' + tx + '/' + ty + '.png?key=' + CARTO_KEY;
var url = tileUrlFromTemplate(template, zoom, tx, ty);
promises.push(
fetch(url, { mode: 'cors' })
.then(function (r) { return r.blob(); })
.then(function (b) { return createImageBitmap(b); })
.then(function (bmp) {
// Replicate the on-screen look: the base pass is muted like
// .basemap-muted; the label pass uses the same darken blend
// as the .leaflet-basemap-labels-pane CSS, so exports match
// the map (labels above polygons, gray canvas below).
ctx.save();
if (opts.composite) ctx.globalCompositeOperation = opts.composite;
if (opts.filter && 'filter' in ctx) ctx.filter = opts.filter;
ctx.drawImage(bmp, tx * tileSize - originX, ty * tileSize - originY, tileSize, tileSize);
ctx.restore();
bmp.close();
})
.catch(function () {})
Expand Down Expand Up @@ -301,9 +403,11 @@ function renderLegendToCanvas(legendEl, scale) {
function exportMapImage(map, filename, onDone) {
map.closePopup();

// Render at current zoom + 2 for 4× tile detail in each dimension
// Render at current zoom + 2 for 4× tile detail in each dimension.
// Cap at the active basemap's export ceiling (18 for OSM; the Esri
// reserve has no native tiles beyond 16).
var viewZoom = map.getZoom();
var exportZoom = Math.min(Math.round(viewZoom) + 2, 18);
var exportZoom = Math.min(Math.round(viewZoom) + 2, activeBasemap.exportMaxZoom);
var bounds = map.getBounds();

// Project bounds to pixel coordinates at export zoom
Expand All @@ -316,7 +420,7 @@ function exportMapImage(map, filename, onDone) {

// Cap at 8192 — fall back to zoom+1 if too large
if (w > 8192 || h > 8192) {
exportZoom = Math.min(Math.round(viewZoom) + 1, 18);
exportZoom = Math.min(Math.round(viewZoom) + 1, activeBasemap.exportMaxZoom);
nw = map.project(bounds.getNorthWest(), exportZoom);
se = map.project(bounds.getSouthEast(), exportZoom);
originX = Math.floor(nw.x);
Expand Down Expand Up @@ -346,10 +450,12 @@ function exportMapImage(map, filename, onDone) {
var legendPromise = renderLegendToCanvas(legendEl, legendScale);

// 1. Base tiles → 2. GeoJSON features → 3. Label tiles → 4. Legend + attribution
fetchTileLayer(ctx, 'light_nolabels', exportZoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY)
fetchTileLayer(ctx, activeBasemap.base, exportZoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY,
{ filter: 'saturate(0) brightness(1.06) contrast(0.9)' })
.then(function () {
drawMapFeatures(map, ctx, exportZoom, originX, originY);
return fetchTileLayer(ctx, 'light_only_labels', exportZoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY);
return fetchTileLayer(ctx, activeBasemap.labels, exportZoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY,
{ filter: 'saturate(0) brightness(1.35) contrast(1.6)', composite: 'darken' });
})
.then(function () { return legendPromise; })
.then(function (legendImg) {
Expand All @@ -360,7 +466,7 @@ function exportMapImage(map, filename, onDone) {
// Attribution
var fontSize = Math.max(13, Math.round(w / 300));
ctx.font = fontSize + 'px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
var attrText = '\u00a9 OpenStreetMap \u00a9 CARTO';
var attrText = activeBasemap.exportAttribution;
var tw = ctx.measureText(attrText).width;
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fillRect(0, h - fontSize * 2.2, tw + fontSize * 2, fontSize * 2.2);
Expand Down
5 changes: 1 addition & 4 deletions providers.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="preconnect" href="https://unpkg.com" crossorigin>
<link rel="preconnect" href="https://a.basemaps.cartocdn.com" crossorigin>
<link rel="preconnect" href="https://b.basemaps.cartocdn.com" crossorigin>
<link rel="preconnect" href="https://c.basemaps.cartocdn.com" crossorigin>
<link rel="preconnect" href="https://d.basemaps.cartocdn.com" crossorigin>
<link rel="preconnect" href="https://tile.openstreetmap.org" crossorigin>
<title>Email Providers of DACH Municipalities</title>
<meta name="description" content="Interactive map showing the email providers of ~15,300 municipalities in Germany, Austria, and Switzerland, based on DNS analysis.">
<link rel="apple-touch-icon" sizes="180x180" href="favicon/apple-touch-icon.png">
Expand Down
5 changes: 1 addition & 4 deletions security.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="preconnect" href="https://unpkg.com" crossorigin>
<link rel="preconnect" href="https://a.basemaps.cartocdn.com" crossorigin>
<link rel="preconnect" href="https://b.basemaps.cartocdn.com" crossorigin>
<link rel="preconnect" href="https://c.basemaps.cartocdn.com" crossorigin>
<link rel="preconnect" href="https://d.basemaps.cartocdn.com" crossorigin>
<link rel="preconnect" href="https://tile.openstreetmap.org" crossorigin>
<title>Email Security of DACH Municipalities</title>
<meta name="description" content="Interactive map showing email security (SPF, DMARC) of ~15,300 municipalities in Germany, Austria, and Switzerland.">
<link rel="apple-touch-icon" sizes="180x180" href="favicon/apple-touch-icon.png">
Expand Down
Loading