Something went wrong. Try again.
Central platform for European atproto.<cc> country community websites
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172// The localness device, as a renderer. window.LocalityMap.render(el, opts) draws one map into el.// opts: cc, theme, accent, dots ("lon:lat:n,..."), lit, active, style ("flat"), motion ("off"), ground ("terminal"), res, w/h (frame size, default 600x400).// Needs d3 and topojson-client loaded first (pinned tags in locality-map.html).window.LocalityMap = { render(el, o = {}) { // The localness device: one Europe geometry, one parameter (cc). Highlighted country in the // terminal tone, neighbours in surface, members as accent dots at city coordinates. // ?cc=be|nl|de|no|eu &theme=light|dark &accent=orange|yellow|red|purple|blue &dots=lon:lat:n,lon:lat:n const cc = (o.cc || 'nl').toLowerCase(), theme = o.theme || 'light', accentName = o.accent || 'orange'; const dark = theme === 'dark'; const T = dark ? { paper: '#282828', surface: '#32302f', terminal: '#1d2021', rule: '#665c54', ink: '#ebdbb2', inkstrong: '#fbf1c7', muted: '#a89984' } : { paper: '#fbf1c7', surface: '#f9f5d7', terminal: '#ebdbb2', rule: '#d5c4a1', ink: '#3c3836', inkstrong: '#282828', muted: '#7c6f64' }; // Accent packs (pack -> [light, dark]) are fetched from /map/accents.json, which is GENERATED // from src/config/accents.ts (the single source of truth). The map never hard-codes colours; // `accent` is resolved from that table once it loads (see the Promise.all below). const ISO = { nl: 528, be: 56, de: 276, fr: 250, lu: 442, at: 40, ch: 756, dk: 208, pl: 616, cz: 203, es: 724, pt: 620, it: 380, ie: 372, gb: 826, se: 752, fi: 246, no: 578, ee: 233, lv: 428, lt: 440, hu: 348, sk: 703, si: 705, hr: 191, ro: 642, bg: 100, gr: 300, cy: 196, mt: 470, is: 352, li: 438, ua: 804 }; // ISO numeric codes of the countries we treat as Europe (matches scripts/country-tlds.json). Only // these are ever drawn, so North Africa, the Middle East and the Caucasus never bleed into the frame. // EU/EEA + UK + Switzerland + the Balkans + micro-states + Ukraine (804, an "also in Europe" // member — western Ukraine falls inside the frame; the rest clips at the east edge like any other // country). Russia (643) and Belarus (112) stay OUT of the map. (Attribution/stats count on their // own lists; this set is map geometry only.) const EUROPE_ISO = new Set([20, 8, 40, 70, 56, 100, 756, 196, 203, 276, 208, 233, 246, 234, 250, 724, 826, 304, 300, 191, 348, 372, 352, 380, 438, 440, 442, 428, 492, 807, 470, 528, 578, 616, 620, 642, 688, 752, 705, 703, 674, 336, 804]); const EU27 = ['at','be','bg','hr','cy','cz','dk','ee','fi','fr','de','gr','hu','ie','it','lv','lt','lu','mt','nl','pl','pt','ro','sk','si','es','se']; const EURO = { type: 'MultiPoint', coordinates: [[-11, 35], [32, 35], [32, 71], [-11, 71]] }; const dots = (o.dots || '').split(',').filter(Boolean).map(s => s.split(':').map(Number)); // .eu aggregate, three tiers matching the /stats table: `active` = EU members -> bright #, // `lit` = the non-EU "also in Europe" set (gb/no/ch/is/li) -> dim #, any other land in the frame // -> dots. The site passes both lists (from data/europe-regions.ts); the defaults keep a bare // ?cc=eu (e.g. the OG card) EU-bright with no dim set. const active = o.active ? o.active.split(',').filter(Boolean) : (cc === 'eu' ? EU27 : []); const lit = o.lit ? o.lit.split(',').filter(Boolean) : []; // Optional geographic frame [west,south,east,north]: zoom the map to a REGION inside the home // country (e.g. Catalunya inside Spain for a city scope) instead of auto-fitting the whole country. const boxParam = o.box ? o.box.split(',').map(Number).filter(n => !isNaN(n)) : null; // Highlight point: `hi=lon,lat` tints the tiles around it in `hicolor` (an accents.json pack name, // default crimson) -- used to mark a city inside its region frame (Barcelona on Catalunya). const hi = o.hi ? o.hi.split(',').map(Number) : null; const hicolorName = o.hicolor || 'crimson'; const W = o.w || 600, H = o.h || 400; // a tall country gets a portrait frame (see Banner) const ascii = o.style !== 'flat'; // default: terminal rendering. # highlighted land, . other land, @ members const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || o.motion === 'off'; const ground = o.ground === 'terminal' ? T.terminal : T.paper; // sea colour: paper on pages, terminal tone inside the banner // fit=cover fills the frame (no letterbox) for the full-bleed background use; default 'meet' // (contain) keeps the whole map visible everywhere else. Cover avoids the transparent top/bottom // strips that some browsers paint as a grey bar around a stretched iframe. const par = o.fit === 'cover' ? 'xMidYMid slice' : 'xMidYMid meet';
const res = o.res || '50m'; // 50m for all: the default character grid (~0.1° cells) shows no benefit from 10m, and 10m is 3.6MB vs 756KB self-hosted // Natural Earth country polygons count large inland water as land. Coarse water cutouts, per country, on the // character grid only (cells ≈ 0.1°). The one place geometry is hand-set; add a polygon here for a new country if it needs one. const WATER = { nl: [[[5.03, 52.94], [5.33, 53.07], [5.42, 53.02], [5.70, 52.85], [5.62, 52.66], [5.75, 52.60], [5.43, 52.51], [5.20, 52.38], [5.07, 52.33], [5.10, 52.46], [5.06, 52.52], [5.06, 52.64], [5.28, 52.70], [5.10, 52.77], [5.03, 52.94]]] }; const accentsUrl = '/map/accents.json' + (o.v ? '?v=' + encodeURIComponent(o.v) : ''); return Promise.all([fetch(`/map/vendor/countries-${res}.json`).then(r => r.json()), document.fonts.load("700 10px 'JetBrains Mono'").catch(() => null), fetch(accentsUrl).then(r => r.json())]).then(([topo, , ACC]) => { const accent = (ACC[accentName] || ACC.orange)[dark ? 1 : 0]; const hiColor = (ACC[hicolorName] || ACC.crimson)[dark ? 1 : 0]; const land = topojson.feature(topo, topo.objects.countries); const proj = d3.geoMercator(); const target = cc === 'eu' ? null : land.features.find(f => +f.id === ISO[cc]); let box = null; // the country's fit box (geo corners); reused to clip neighbour sprawl below if (target) { // Fit to the country, clipped to Europe so overseas territory never drives the frame. // Bounds of the European polygons only: overseas territory never drives the frame. const polys = target.geometry.type === 'MultiPolygon' ? target.geometry.coordinates.map(c => ({ type: 'Polygon', coordinates: c })) : [target.geometry]; const home = polys.filter(p => { const c = d3.geoCentroid(p); return c[0] > -25 && c[0] < 45 && c[1] > 34 && c[1] < 72; }); // Frame fit only: drop polygons under 0.5% of the country's area, so a remote islet (Jan Mayen for .no) // cannot widen the frame. Rendering still draws every polygon, so small islands stay visible. const base = home.length ? home : polys; const areas = base.map(p => d3.geoArea(p)), total = areas.reduce((a, b) => a + b, 0); const fit = base.filter((p, i) => areas[i] / total > 0.005); const b = d3.geoBounds({ type: 'GeometryCollection', geometries: fit.length ? fit : base }); box = { type: 'MultiPoint', coordinates: [[b[0][0], b[0][1]], [b[1][0], b[0][1]], [b[1][0], b[1][1]], [b[0][0], b[1][1]]] }; // A ?box= frame overrides the country's auto-fit: zoom to the region (Catalunya) while the // home country (Spain) still colours the land and the clip below trims neighbour sprawl to it. if (boxParam && boxParam.length === 4) { const [w, s, e, n] = boxParam; box = { type: 'MultiPoint', coordinates: [[w, s], [e, s], [e, n], [w, n]] }; } proj.fitExtent([[W * 0.05, H * 0.05], [W * 0.95, H * 0.95]], box); // Right-align the country: shift it so its right edge sits ~2 columns in from the frame's // right. The neighbour fringe then forms a column on the right and the country fills leftward, // instead of floating centred with empty sea on both sides. The clip below follows the shift. const pbFit = d3.geoPath(proj).bounds(box); const rightMargin = 15; const dx = (W - rightMargin) - pbFit[1][0]; const trFit = proj.translate(); proj.translate([trFit[0] + dx, trFit[1]]); } else { proj.fitExtent([[16, 16], [W - 16, H - 16]], EURO); // The map is a background behind the left-hand text, so shift the European mass right: put the // EURO box's west edge ~28% in from the left. The far east is already dropped, so little crops. const tr = proj.translate(); proj.translate([tr[0] + W * 0.28 - 16, tr[1]]); } const path = d3.geoPath(proj); const litSet = new Set(lit.map(c => ISO[c])), activeSet = new Set(active.map(c => ISO[c])); const isLit = f => target ? +f.id === +target.id : (litSet.has(+f.id) || activeSet.has(+f.id)); const isActive = f => !target && activeSet.has(+f.id); if (ascii) { const cw = 7.5, ch = 11, cols = Math.floor(W / cw), rows = Math.floor(H / ch); const view = [proj.invert([0, H]), proj.invert([W, 0])]; const water = Object.values(WATER).map(c => { let p = { type: 'Polygon', coordinates: c }; if (d3.geoArea(p) > 2 * Math.PI) p = { type: 'Polygon', coordinates: c.map(r => r.slice().reverse()) }; return p; }); // Split multipolygons and keep the European pieces only: a polygon crossing the antimeridian (Russia's far east) would otherwise fill the frame. const pieces = f => (f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates.map(c => ({ type: 'Polygon', coordinates: c })) : [f.geometry]) .filter(p => { const c = d3.geoCentroid(p); return c[0] > -30 && c[0] < 60 && c[1] > 30 && c[1] < 82; }); // Turkey (ISO 792) is a large non-EU mass on the east edge of the .eu frame; drawn grey it // unbalances the map and pushes the lit EU to the left, so leave it out of the geometry. const euro = land.features.filter(f => EUROPE_ISO.has(+f.id)).flatMap(f => pieces(f).map(g => ({ type: 'Feature', id: f.id, geometry: g }))) .filter(f => { const b = d3.geoBounds(f); return b[1][0] > view[0][0] - 1 && b[0][0] < view[1][0] + 1 && b[1][1] > view[0][1] - 1 && b[0][1] < view[1][1] + 1; }); const svgA = d3.select(el).append('svg').attr('viewBox', `0 0 ${W} ${H}`).attr('width', W).attr('height', H).style('width', '100%').style('height', '100%').style('display', 'block').attr('preserveAspectRatio', par).attr('role', 'img') .style('font-family', "'JetBrains Mono', ui-monospace, monospace").style('font-size', '10px'); svgA.append('rect').attr('width', W).attr('height', H).attr('fill', ground); // Rasterise the projected geometry to the character grid via an offscreen canvas: a cell is land when // enough of its pixels are, so small islands survive that a centre-point test would drop. proj.clipExtent([[0, 0], [W, H]]); const cv = document.createElement('canvas'); cv.width = W; cv.height = H; const ctx = cv.getContext('2d'); const gp = d3.geoPath(proj, ctx); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H); for (const f of euro.filter(f => !isLit(f))) { ctx.beginPath(); gp(f); ctx.fillStyle = '#0f0'; ctx.fill('evenodd'); } for (const f of euro.filter(f => isLit(f) && !isActive(f))) { ctx.beginPath(); gp(f); ctx.fillStyle = '#f00'; ctx.fill('evenodd'); } for (const f of euro.filter(isActive)) { ctx.beginPath(); gp(f); ctx.fillStyle = '#00f'; ctx.fill('evenodd'); } for (const w of water) { ctx.beginPath(); gp(w); ctx.fillStyle = '#000'; ctx.fill(); } const px = ctx.getImageData(0, 0, W, H).data; const cells = []; // Highlight point (e.g. a city within its region): tiles within hiR of its projected pixel are // tinted `hiColor` and forced to a solid '#', marking the spot (Barcelona on the Catalunya frame). const hip = hi ? proj(hi) : null; const hiR = 2.6 * cw; for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) { let litN = 0, landN = 0, actN = 0, n = 0; for (let y = Math.floor(r * ch); y < Math.floor((r + 1) * ch); y++) for (let x = Math.floor(c * cw); x < Math.floor((c + 1) * cw); x++) { const i = (y * W + x) * 4; n++; if (px[i + 2] > 127) actN++; else if (px[i] > 127) litN++; else if (px[i + 1] > 127) landN++; } const t = target ? 0.12 : 0.3; const hiCell = hip ? Math.hypot(c * cw + cw / 2 - hip[0], r * ch + ch / 2 - hip[1]) < hiR : false; if (actN / n > t) cells.push({ x: c * cw, y: r * ch + ch - 2, ch: '#', lit: true, act: true, hi: hiCell }); else if (litN / n > t) cells.push({ x: c * cw, y: r * ch + ch - 2, ch: '#', lit: true, hi: hiCell }); else if (!target && (litN + landN) / n > t) cells.push({ x: c * cw, y: r * ch + ch - 2, ch: '.', lit: false, hi: hiCell }); } svgA.append('g').selectAll('text').data(cells).join('text').attr('x', d => d.x).attr('y', d => d.y) .attr('fill', d => d.hi ? hiColor : d.act ? T.inkstrong : d.lit ? (target ? T.ink : T.muted) : T.rule).attr('font-weight', d => (d.hi || d.lit) ? 700 : 400).text(d => d.hi ? '#' : d.ch) .attr('opacity', reduced ? 1 : 0).transition().delay(() => reduced ? 0 : Math.random() * rows * 28).duration(reduced ? 0 : 120).attr('opacity', 1); const marks = svgA.append('g').selectAll('text').data(dots).join('text') .attr('x', d => Math.floor(proj([d[0], d[1]])[0] / cw) * cw).attr('y', d => Math.floor(proj([d[0], d[1]])[1] / ch) * ch + ch - 2) .attr('fill', accent).attr('font-weight', 700).attr('font-size', d => (10 + Math.min(8, Math.sqrt(d[2] || 1) * 1.4)) + 'px').text('@') .attr('opacity', 1); // Afterwards: the terminal's only motion. One member at a time blinks as a block cursor, slowly. if (!reduced && dots.length) { const nodes = marks.nodes(); let i = 0; setTimeout(() => setInterval(() => { const n = nodes[i++ % nodes.length]; n.textContent = '\u2588'; setTimeout(() => { n.textContent = '@'; }, 520); }, 2400), rows * 28 + 800); } return; } const svg = d3.select(el).append('svg').attr('viewBox', `0 0 ${W} ${H}`).attr('width', W).attr('height', H).style('width', '100%').style('height', '100%').style('display', 'block').attr('preserveAspectRatio', par).attr('role', 'img'); svg.append('rect').attr('width', W).attr('height', H).attr('fill', ground); svg.append('g').selectAll('path').data(land.features).join('path') .attr('d', path) .attr('fill', f => isLit(f) ? T.terminal : T.surface) .attr('stroke', f => isActive(f) || (target && isLit(f)) ? T.ink : T.rule) .attr('stroke-width', f => isActive(f) || (target && isLit(f)) ? 1.25 : 0.75) .attr('stroke-linejoin', 'round'); const r = target ? 5 : 3.5; svg.append('g').selectAll('circle').data(dots).join('circle') .attr('cx', d => proj([d[0], d[1]])[0]).attr('cy', d => proj([d[0], d[1]])[1]) .attr('r', d => r + Math.min(9, Math.sqrt(d[2] || 1) * 1.6)).attr('fill', accent); });
} };