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>