HostingMap.astro

  1---
  2// Europe, drawn at build time from Natural Earth (world-atlas 50m) with a
  3// conic projection, so the page ships plain SVG and no map runtime.
  4import { geoConicConformal, geoPath } from 'd3-geo';
  5import { feature } from 'topojson-client';
  6import type { FeatureCollection, Geometry } from 'geojson';
  7import world from 'world-atlas/countries-50m.json';
  8
  9// A wide canvas: the frame crops its edges on narrow cells, never the labels.
 10const W = 1000;
 11const H = 860;
 12
 13// ISO 3166 numeric codes for the countries with EU hosting.
 14const HOSTED = new Set(['276', '246']); // Germany, Finland
 15
 16const countries = feature(
 17	world as any,
 18	(world as any).objects.countries,
 19) as unknown as FeatureCollection<Geometry, { name: string }>;
 20
 21const projection = geoConicConformal()
 22	.parallels([40, 65])
 23	.rotate([-14, 0])
 24	.fitExtent(
 25		[
 26			[190, 40],
 27			[W - 190, H - 10],
 28		],
 29		{
 30			type: 'MultiPoint',
 31			coordinates: [
 32				[-11, 36],
 33				[34, 36],
 34				[-11, 70],
 35				[34, 70],
 36				[12, 71],
 37			],
 38		},
 39	)
 40	.clipExtent([
 41		[0, 0],
 42		[W, H],
 43	]);
 44
 45const path = geoPath(projection).digits(1);
 46
 47const shapes = countries.features
 48	.map((f) => ({ id: String(f.id), name: f.properties.name, d: path(f) }))
 49	.filter((s): s is { id: string; name: string; d: string } => Boolean(s.d));
 50
 51const at = (lon: number, lat: number) => {
 52	const [x, y] = projection([lon, lat])!;
 53	return { x: Math.round(x), y: Math.round(y) };
 54};
 55
 56const sites = [
 57	{ name: 'Germany', ...at(10.2, 50.6), side: 'left' },
 58	{ name: 'Finland', ...at(24.9, 61.2), side: 'right' },
 59];
 60
 61// "Other regions" sits low in the frame, south-east of Germany, where no other
 62// label lives. It is anchored by its right edge so it grows away from the crop.
 63const hub = at(10.2, 50.6);
 64const elsewhere = { x: hub.x + 150, y: H - 70 };
 65---
 66
 67<figure class="hosting-map">
 68	<svg
 69		viewBox={`0 0 ${W} ${H}`}
 70		preserveAspectRatio="xMidYMid slice"
 71		role="img"
 72		aria-labelledby="hosting-map-title"
 73	>
 74		<title id="hosting-map-title">
 75			Map of Europe marking hosting in Germany and Finland, inside the EU, and a note that other hosting
 76			regions are available worldwide.
 77		</title>
 78		<g class="land">
 79			{shapes.map((s) => <path class:list={{ hosted: HOSTED.has(s.id) }} d={s.d} />)}
 80		</g>
 81		{
 82			sites.map((s) => (
 83				<g class="site">
 84					<g transform={`translate(${s.x} ${s.y})`}>
 85						<g class="scaled">
 86							<rect class="pin" x="-9" y="-9" width="18" height="18" />
 87						</g>
 88					</g>
 89					<g transform={`translate(${s.side === 'left' ? s.x - 22 : s.x + 22} ${s.y})`}>
 90						<g class:list={['scaled', 'label', `from-${s.side}`]}>
 91							<rect class="tag" x={s.side === 'left' ? -150 : 0} y="-30" width="150" height="60" />
 92							<g class="tag-text">
 93								<text class="tag-name" x={s.side === 'left' ? -136 : 14} y="-4">
 94									{s.name}
 95								</text>
 96								<text class="tag-note" x={s.side === 'left' ? -136 : 14} y="18">
 97									EU ยท GDPR
 98								</text>
 99							</g>
100						</g>
101					</g>
102				</g>
103			))
104		}
105		<g transform={`translate(${elsewhere.x} ${elsewhere.y})`}>
106			<g class="scaled label from-left">
107				<rect class="tag" x="-280" y="-24" width="280" height="44" />
108				<g class="tag-text">
109					<text class="tag-name" x="-266" y="6">Other regions worldwide</text>
110				</g>
111			</g>
112		</g>
113	</svg>
114</figure>
115
116<script>
117	// Fit each tag to its text once the real fonts are in: equal padding left
118	// and right, text block centred vertically, and the edge nearest the pin
119	// held in place. Without JS the static boxes above still enclose the text.
120	const PAD_X = 16;
121
122	const fit = () => {
123		for (const label of document.querySelectorAll<SVGGElement>('.hosting-map .label')) {
124			const rect = label.querySelector<SVGRectElement>('.tag')!;
125			const text = label.querySelector<SVGGElement>('.tag-text')!;
126			text.removeAttribute('transform');
127			const box = text.getBBox();
128			const width = box.width + PAD_X * 2;
129			const x = label.classList.contains('from-left') ? -width : 0;
130			const y = rect.y.baseVal.value;
131			const height = rect.height.baseVal.value;
132			rect.setAttribute('x', String(x));
133			rect.setAttribute('width', String(width));
134			const dx = x + PAD_X - box.x;
135			const dy = y + height / 2 - (box.y + box.height / 2);
136			text.setAttribute('transform', `translate(${dx} ${dy})`);
137		}
138	};
139
140	document.fonts.ready.then(fit);
141
142	// Hold tag type at a fixed on-screen size whatever size the map renders.
143	for (const svg of document.querySelectorAll<SVGSVGElement>('.hosting-map svg')) {
144		const size = () => {
145			const { width, height } = svg.getBoundingClientRect();
146			const vb = svg.viewBox.baseVal;
147			const pxPerUnit = Math.max(width / vb.width, height / vb.height); // matches slice
148			if (!pxPerUnit) return;
149			const target = parseFloat(getComputedStyle(svg).getPropertyValue('--tag-px')) || 16;
150			svg.style.setProperty('--label-scale', (target / (22 * pxPerUnit)).toFixed(3));
151		};
152		new ResizeObserver(size).observe(svg);
153	}
154</script>
155
156<style>
157	.hosting-map {
158		margin: 0;
159		background: var(--ptt);
160		overflow: hidden;
161	}
162
163	svg {
164		display: block;
165		width: 100%;
166		height: 100%;
167	}
168
169	.land path {
170		fill: var(--gold);
171		stroke: var(--ptt);
172		stroke-width: 1;
173		stroke-linejoin: round;
174	}
175
176	.land path.hosted {
177		fill: var(--signal);
178	}
179
180	.pin {
181		fill: var(--paper);
182		stroke: var(--ptt-deep);
183		stroke-width: 3;
184	}
185
186	.tag {
187		fill: var(--paper);
188	}
189
190	.tag-name {
191		fill: var(--ptt);
192		font: 750 22px var(--sans);
193		letter-spacing: 0;
194	}
195
196	.tag-note {
197		fill: var(--ink-soft);
198		font: 650 16px var(--sans);
199		letter-spacing: 0.01em;
200	}
201
202	/* SVG type shrinks with the map. A script sets --label-scale from the
203	   map's rendered size so the country name lands at --tag-px on screen
204	   (the note follows at 16/22 of it); the fixed scales are the no-JS fallback.
205	   Pins and tags grow around their own anchors. */
206	svg {
207		--tag-px: 17;
208	}
209	.scaled {
210		transform-box: fill-box;
211		transform-origin: 50% 50%;
212		scale: var(--label-scale, 1.2);
213	}
214	.label.from-left {
215		transform-origin: 100% 50%;
216	}
217	.label.from-right {
218		transform-origin: 0% 50%;
219	}
220
221	@media (max-width: 1100px) {
222		svg {
223			--tag-px: 15;
224		}
225		.scaled {
226			scale: var(--label-scale, 1.35);
227		}
228	}
229
230	@media (max-width: 640px) {
231		svg {
232			--tag-px: 13.5;
233		}
234		.scaled {
235			scale: var(--label-scale, 1.6);
236		}
237	}
238</style>