Changelog af4a206
poster
9 files of code, design or docs +434 −18 lines
Code, design and documents 9 files
source-archive.json +13 −13
Not shown: a picture, a generated file, or past the length shown per change.
src/components/HomePage.astro +5 −1
@@ -212,5 +212,8 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", <h2>World map</h2> </div> - <a class="map-expand" id="mapExpand" href={viewHref(current)} title="Open this map on a page of its own">Expand <span aria-hidden="true">↗</span></a> + <div class="map-links"> + <a class="map-expand" href={`${base}map/poster/`}>Print or share →</a> + <a class="map-expand" id="mapExpand" href={viewHref(current)} title="Open this map on a page of its own">Expand <span aria-hidden="true">↗</span></a> + </div> </div> )} @@ -262,4 +265,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", {legend.map((t) => <span><i class="sw" style={`background:var(--t${t.i})`}></i>{t.name} · {t.min}–{t.max}</span>)} <span><i class="sw sw-nodata"></i>No data</span> + {view && <a class="legend-poster" href={`${base}map/poster/`}>Print or share this map →</a>} </div> {Object.entries(USE_SCALES).map(([key, scale]) => (
src/layouts/Base.astro +6 −4
@@ -11,5 +11,6 @@ import Leaf from "../components/Leaf.astro"; // object or a list of them; `type` is the Open Graph type of the page. // `noindex` keeps a page out of search engines (hidden utility pages) -const { title, seoTitle, description, jsonld, type = "website", noindex = false } = Astro.props; +// `image` replaces the drawn preview with another image of the site: [path after the base, width, height] +const { title, seoTitle, description, jsonld, type = "website", noindex = false, image } = Astro.props; const base = import.meta.env.BASE_URL; const repo = "https://github.com/burningtree/cannabis-freedom-index"; @@ -21,5 +22,6 @@ const canonical = new URL(Astro.url.pathname, Astro.site ?? "https://cannabisfre // /country/de/ → /og/country/de.png, the home page → /og/index.png const ogPath = Astro.url.pathname.slice(base.length).replace(/\/$/, "") || "index"; -const ogImage = new URL(`${base}og/${ogPath}.png`, Astro.site ?? "https://cannabisfreedom.fyi").href; +const ogImage = new URL(image ? `${base}${image[0]}` : `${base}og/${ogPath}.png`, Astro.site ?? "https://cannabisfreedom.fyi").href; +const [ogW, ogH] = image ? image.slice(1) : [1200, 630]; const schemas = jsonld ? [jsonld].flat() : []; --- @@ -40,6 +42,6 @@ const schemas = jsonld ? [jsonld].flat() : []; <meta property="og:locale" content="en"> <meta property="og:image" content={ogImage}> - <meta property="og:image:width" content="1200"> - <meta property="og:image:height" content="630"> + <meta property="og:image:width" content={ogW}> + <meta property="og:image:height" content={ogH}> <meta property="og:image:alt" content={seoTitle ?? title ?? fullTitle}> <meta name="twitter:card" content="summary_large_image">
src/lib/world-map.js +81 −0
@@ -0,0 +1,81 @@ +// The world map drawn at build time, for the printable poster (/map/poster/) and its images. +// The same shapes and projection as the map on the home page (HomePage.astro), coloured by each +// country's tier. Each shape's path is returned as a string, so a page can inline it and the +// image renderer can wrap it in an SVG of its own. +import { geoNaturalEarth1, geoPath } from "d3-geo"; +import { feature } from "topojson-client"; +import world from "world-atlas/countries-50m.json"; +import territories from "../data/territories.geo.json"; +import { places, countries, worldAvg, lastUpdated, formatDate } from "./countries.js"; +import { TIERS, tierIndex } from "./tiers.js"; + +// the tier colours as plain values, as in src/og/render.js (an image has no CSS variables) +export const TIER_HEX = ["#a3121f", "#dc4a2a", "#f08c3a", "#f0cf4a", "#9ccc55", "#2e9e4f"]; +export const PAPER = { bg: "#f4f2eb", ink: "#11110f", muted: "#4b4a43", nodata: "#d9d5c8", land: "#e6e2d6" }; + +const byId = new Map(places.map((c) => [c.id, c])); +const byNum = new Map(places.filter((c) => c.num != null).map((c) => [c.num, c])); +// atlas shapes that take another entry's score, as on the home page +const ALIASES = { Kosovo: "XK", Somaliland: "XS", "N. Cyprus": "XN", 304: "DK" }; +const ANTARCTICA = "010"; + +const shapes = feature(world, world.objects.countries).features.map((f) => { + const alias = ALIASES[f.properties.name] || ALIASES[+f.id]; + return { f, c: alias ? byId.get(alias) : byNum.get(+f.id), antarctica: f.id === ANTARCTICA }; +}); +// disputed territories the atlas has no shape for, drawn last, on top +shapes.push(...territories.features.map((f) => ({ f, c: byId.get(f.id) })).filter((s) => s.c)); + +const land = shapes.filter((s) => !s.antarctica); +// the box the map fits: its aspect ratio, so callers can size a frame for it +export const MAP_RATIO = (() => { + const [[x0, y0], [x1, y1]] = geoPath(geoNaturalEarth1()).bounds({ type: "FeatureCollection", features: land.map((s) => s.f) }); + return (x1 - x0) / (y1 - y0); +})(); + +// width × height: the box the land (without Antarctica) is fitted into; `digits`, the decimals +// kept in the paths (none is plenty for a page, and keeps it light) +export function worldMap(width, height, digits = 1) { + const projection = geoNaturalEarth1().fitSize([width, height], { type: "FeatureCollection", features: land.map((s) => s.f) }); + const path = geoPath(projection).digits(digits); + const fill = (c) => (c ? TIER_HEX[tierIndex(c.raw)] : PAPER.nodata); + // a scored place too small to see at this size (Singapore, Malta, the islands) also gets a dot + const tiny = Math.max(4, (width * height) / 60000); + const dots = land.filter((s) => s.c && path.area(s.f) < tiny).map((s) => { + const [x, y] = path.centroid(s.f); + return { x: +x.toFixed(1), y: +y.toFixed(1), fill: fill(s.c), c: s.c }; + }).filter((d) => Number.isFinite(d.x)); + return { + shapes: land.map((s) => ({ d: path(s.f), fill: fill(s.c), c: s.c })).filter((s) => s.d), + dots, + r: Math.max(2, width / 520), + }; +} + +// The map as a standalone SVG document, for the PNG images. +export function worldMapSvg(width, height) { + const m = worldMap(width, height); + return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` + + m.shapes.map((s) => `<path d="${s.d}" fill="${s.fill}" stroke="${PAPER.bg}" stroke-width="0.5" stroke-linejoin="round"/>`).join("") + + m.dots.map((d) => `<circle cx="${d.x}" cy="${d.y}" r="${m.r}" fill="${d.fill}" stroke="${PAPER.bg}" stroke-width="0.8"/>`).join("") + + "</svg>"; +} + +// What the poster says beside the map: the tiers with how many countries are in each (the best +// first), the `n` most and least free, the world average and the date of the data. +export function posterFacts(n = 5) { + const tiers = TIERS.map((t, i) => { + const k = TIERS.length - 1 - i; + const hi = i ? TIERS[i - 1].min - 1 : 100; + return { name: t.name, range: `${t.min}–${hi}`, color: TIER_HEX[k], count: countries.filter((c) => tierIndex(c.raw) === k).length }; + }); + const row = (c) => ({ c, rank: c.rank, name: c.name, score: c.score, color: TIER_HEX[tierIndex(c.raw)] }); + return { + tiers, + top: countries.slice(0, n).map(row), + bottom: countries.slice(-n).map(row), + avg: worldAvg.toFixed(1), + total: countries.length, + date: formatDate(lastUpdated), + }; +}
src/og/poster.astro +61 −0
@@ -0,0 +1,61 @@ +--- +// The world map as an image to share (/map/poster/<size>.png): title, the map coloured by tier, +// the tiers with how many countries are in each. A square or portrait image also lists the +// most and least free; the wide one has no room for them. +const { W, H, wide, eyebrow, title, sub, map, mapW, mapH, tiers, top, bottom, avg, site, note } = Astro.props; +const pad = wide ? 56 : 52; +const titleSize = wide ? 64 : 62; +--- +<div style={`display:flex;flex-direction:column;width:${W}px;height:${H}px;background:#f4f2eb;color:#11110f;padding:${pad - 10}px ${pad}px ${pad - 12}px;font-family:Inter`}> + <div style="display:flex;justify-content:space-between;align-items:center;font-family:'JetBrains Mono';font-size:17px;letter-spacing:2px;color:#4b4a43"> + <div style="display:flex">{eyebrow}</div> + <div style="display:flex">{site}</div> + </div> + <div style="display:flex;justify-content:space-between;align-items:flex-end;margin-top:22px"> + <div style="display:flex;flex-direction:column;flex:1;min-width:0"> + <div style={`display:flex;font-family:'Instrument Serif';font-size:${titleSize}px;line-height:1;letter-spacing:-1.5px`}>{title}</div> + <div style={`display:flex;margin-top:10px;font-size:21px;line-height:1.35;color:#4b4a43;max-width:${wide ? 1100 : 640}px`}>{sub}</div> + </div> + <div style="display:flex;flex-direction:column;align-items:flex-end;margin-left:32px"> + <div style="display:flex;font-family:'JetBrains Mono';font-size:54px;font-weight:500;letter-spacing:-3px;line-height:1">{avg}</div> + <div style="display:flex;margin-top:6px;font-family:'JetBrains Mono';font-size:14px;letter-spacing:2px;color:#4b4a43">WORLD AVERAGE OF 100</div> + </div> + </div> + <div style="display:flex;flex:1;align-items:center;justify-content:center"> + <img src={map} style={`width:${mapW}px;height:${mapH}px`} /> + </div> + <div style="display:flex;flex-wrap:wrap;border-top:1px solid #11110f;padding-top:16px"> + {tiers.map((t) => ( + <div style={`display:flex;align-items:flex-start;width:${wide ? "16.66%" : "33.33%"};margin-bottom:${wide ? 0 : 14}px`}> + <div style={`display:flex;width:16px;height:16px;margin-top:3px;margin-right:9px;background:${t.color}`}></div> + <div style="display:flex;flex-direction:column"> + <div style="display:flex;font-size:17px;font-weight:600">{t.name}</div> + <div style="display:flex;margin-top:3px;font-family:'JetBrains Mono';font-size:14px;color:#4b4a43">{t.range} · {t.count}</div> + </div> + </div> + ))} + </div> + {!wide && ( + <div style="display:flex;margin-top:12px;border-top:1px solid #d9d5c8;padding-top:16px"> + {[["MOST FREE", top], ["LEAST FREE", bottom]].map(([label, list]) => ( + <div style="display:flex;flex-direction:column;width:50%;padding-right:24px"> + <div style="display:flex;font-family:'JetBrains Mono';font-size:14px;letter-spacing:2px;color:#4b4a43;margin-bottom:8px">{label}</div> + {list.map((c) => ( + <div style="display:flex;align-items:center;justify-content:space-between;padding:5px 0"> + <div style="display:flex;align-items:center"> + <div style="display:flex;width:30px;font-family:'JetBrains Mono';font-size:14px;color:#4b4a43">{c.rank}</div> + {c.flag && <img src={c.flag} style="width:26px;height:19px;margin-right:10px" />} + <div style="display:flex;font-family:'Instrument Serif';font-size:27px;line-height:1">{c.name}</div> + </div> + <div style={`display:flex;font-family:'JetBrains Mono';font-size:20px;font-weight:500;border-bottom:3px solid ${c.color}`}>{c.score}</div> + </div> + ))} + </div> + ))} + </div> + )} + <div style="display:flex;justify-content:space-between;margin-top:14px;font-family:'JetBrains Mono';font-size:13px;letter-spacing:1px;color:#4b4a43"> + <div style="display:flex">{note}</div> + <div style="display:flex">CC BY 4.0</div> + </div> +</div>
src/og/render-poster.js +28 −0
@@ -0,0 +1,28 @@ +// Draws the map poster images (/map/poster/<size>.png) at build time, like render.js draws the +// social previews but at sizes of their own, from src/og/poster.astro. Kept apart from +// render.js because every image that file draws is cached under a hash of the file itself. +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { satoriAstro } from "x-satori/astro"; +import { Resvg } from "@resvg/resvg-js"; + +const ROOT = process.cwd(); +const font = (pkg, file) => readFileSync(path.join(ROOT, "node_modules", "@fontsource", pkg, "files", file)); +const fonts = [ + ["Instrument Serif", "instrument-serif", 400], ["Inter", "inter", 400], ["Inter", "inter", 600], ["JetBrains Mono", "jetbrains-mono", 400], ["JetBrains Mono", "jetbrains-mono", 500], +].flatMap(([name, pkg, weight]) => ["latin", "latin-ext"].map((set) => ({ name, data: font(pkg, `${pkg}-${set}-${weight}-normal.woff`), weight, style: "normal" }))); + +// The map goes through the template as this one-pixel stand-in and is put into the finished SVG +// afterwards: satori would take long to parse a megabyte of base64 in the template's HTML. +const STAND_IN = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +// The text reaches satori as HTML, where ' and & arrive as entities it draws letter for letter +// ("d'Ivoire"), so they go in as their typographic forms, as in render.js. +const plain = (v, key) => key === "flag" ? v : typeof v === "string" ? v.replace(/'/g, "\u2019").replace(/ & /g, " and ").replace(/&/g, "+") + : Array.isArray(v) ? v.map((x) => plain(x)) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x, k)])) : v; + +export async function renderPoster(props, map, width, height) { + const source = readFileSync(path.join(ROOT, "src", "og", "poster.astro"), "utf8"); + const svg = (await satoriAstro({ width, height, fonts, props: { ...plain(props), map: STAND_IN } }, source)).replaceAll(STAND_IN, map); + return new Resvg(svg, { fitTo: { mode: "width", value: width } }).render().asPng(); +}
src/pages/map/poster/[size].png.js +42 −0
@@ -0,0 +1,42 @@ +// The world map as an image to share, in three sizes: /map/poster/wide.png (1600 × 900, for +// X, LinkedIn and slides), square.png (1080 × 1080) and portrait.png (1080 × 1350, for +// Instagram). Drawn by src/og/render-poster.js from src/og/poster.astro. +import { DATA_SNAPSHOT } from "../../../lib/countries.js"; +import { worldMapSvg, posterFacts, MAP_RATIO } from "../../../lib/world-map.js"; +import { flagOf } from "../../../og/render.js"; +import { renderPoster } from "../../../og/render-poster.js"; + +export const SIZES = { + wide: { W: 1600, H: 900, wide: true, mapH: 560, rows: 0 }, + square: { W: 1080, H: 1080, wide: false, mapH: 385, rows: 5 }, + portrait: { W: 1080, H: 1350, wide: false, mapH: 500, rows: 8 }, +}; + +export function getStaticPaths() { + return Object.keys(SIZES).map((size) => ({ params: { size } })); +} + +export async function GET({ params, site }) { + const { W, H, wide, mapH: maxH, rows } = SIZES[params.size]; + const pad = wide ? 56 : 52; + // the map as large as fits the space left for it, in its own proportions + const mapW = Math.round(Math.min(W - 2 * pad, maxH * MAP_RATIO)); + const mapH = Math.round(mapW / MAP_RATIO); + // drawn at twice the size, so the borders stay fine when the image is scaled + const svg = worldMapSvg(mapW * 2, mapH * 2); + const f = posterFacts(rows || 5); + const host = new URL(site).host.toUpperCase(); + const png = await renderPoster({ + W, H, wide, mapW, mapH, + eyebrow: `CANNABIS FREEDOM INDEX · ${DATA_SNAPSHOT} EDITION`, + title: "How free is cannabis?", + sub: `${f.total} countries scored from 0 to 100 for how free you are to carry, grow, share and use it.`, + tiers: f.tiers.map((t) => ({ ...t, count: `${t.count} ${t.count === 1 ? "country" : "countries"}` })), + top: f.top.map(({ c, ...r }) => ({ ...r, flag: flagOf(c.id) })), + bottom: f.bottom.map(({ c, ...r }) => ({ ...r, flag: flagOf(c.id) })), + avg: f.avg, + site: host, + note: `DATA AS OF ${f.date.toUpperCase()} · ${host}/MAP/`, + }, `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`, W, H); + return new Response(png, { headers: { "Content-Type": "image/png" } }); +}
src/pages/map/poster/index.astro +111 −0
@@ -0,0 +1,111 @@ +--- +// The world map as a poster: one sheet that prints on A4 or A3 landscape, with the tiers, the +// most and least free and the date of the data. The same poster comes as images to share, drawn +// by ./[size].png.js. The sheet keeps the paper colours in the dark theme too, as printed. +import Base from "../../../layouts/Base.astro"; +import { DATA_SNAPSHOT } from "../../../lib/countries.js"; +import { worldMap, posterFacts, MAP_RATIO } from "../../../lib/world-map.js"; + +const base = import.meta.env.BASE_URL; +const MAP_W = 1000; +const MAP_H = Math.round(MAP_W / MAP_RATIO); +const map = worldMap(MAP_W, MAP_H, 0); +const f = posterFacts(); +const host = new URL(Astro.site ?? "https://cannabisfreedom.fyi").host; +// the image sizes: id, name, pixels, where they fit +const images = [ + ["wide", "Wide", "1600 × 900", "X, LinkedIn, slides"], + ["square", "Square", "1080 × 1080", "Instagram, Mastodon"], + ["portrait", "Portrait", "1080 × 1350", "Instagram"], +]; +--- +<Base title="World map poster" description={`The Cannabis Freedom Index world map as a poster to print or share: ${f.total} countries scored from 0 to 100, by tier.`} image={["map/poster/wide.png", 1600, 900]}> + <main class="poster-page"> + <div class="poster-bar"> + <a class="back" href={`${base}map/`}>← The interactive map</a> + <div class="poster-pick" role="group" aria-label="Format"> + <button type="button" aria-pressed="true" data-format="print">Print <small>A4 landscape</small></button> + {images.map(([id, label, size]) => ( + <button type="button" aria-pressed="false" data-format={id} data-href={`${base}map/poster/${id}.png`} + data-file={`cannabis-freedom-index-map-${DATA_SNAPSHOT}-${id}.png`} data-size={size}>{label} <small>{size}</small></button> + ))} + </div> + <a class="btn btn-primary poster-go" id="posterGo" href={`${base}map/poster/wide.png`}>Print</a> + </div> + <p class="poster-use" id="posterUse">Prints on one A4 landscape sheet. Choose an image size instead to share it.</p> + + {images.map(([id, label, size, use]) => ( + <figure class="poster-image" data-format={id} hidden> + <img src={`${base}map/poster/${id}.png`} alt={`The poster as a ${label.toLowerCase()} image, ${size} pixels`} loading="lazy" + width={size.split(" × ")[0]} height={size.split(" × ")[1]} /> + <figcaption>{size} pixels · for {use}</figcaption> + </figure> + ))} + + <article class="poster" data-format="print" aria-labelledby="posterTitle"> + <header class="poster-head"> + <p class="poster-eyebrow"><span>Cannabis Freedom Index · {DATA_SNAPSHOT} edition</span><span>{host}</span></p> + <div class="poster-title"> + <div> + <h1 id="posterTitle">How free is cannabis?</h1> + <p>{f.total} countries scored from 0 to 100 for how free you are to carry, grow, share and use it.</p> + </div> + <p class="poster-avg"><b>{f.avg}</b><span>World average of 100</span></p> + </div> + </header> + + <svg class="poster-map" viewBox={`0 0 ${MAP_W} ${MAP_H}`} role="img" aria-label="World map of the countries coloured by their tier on the index"> + {map.shapes.map((s) => <path d={s.d} fill={s.fill} />)} + {map.dots.map((d) => <circle cx={d.x} cy={d.y} r={map.r} fill={d.fill} />)} + </svg> + + <ul class="poster-tiers"> + {f.tiers.map((t) => ( + <li><i style={`background:${t.color}`}></i><span><b>{t.name}</b><small>{t.range} · {t.count} {t.count === 1 ? "country" : "countries"}</small></span></li> + ))} + </ul> + + <div class="poster-lists"> + {[["Most free", f.top], ["Least free", f.bottom]].map(([label, list]) => ( + <section> + <h2>{label}</h2> + <ol> + {list.map((r) => ( + <li><span class="poster-rank">{r.rank}</span><span class="poster-name">{r.c.flag} {r.c.name}</span><b style={`border-color:${r.color}`}>{r.score}</b></li> + ))} + </ol> + </section> + ))} + </div> + + <footer class="poster-foot"> + <span>Data as of {f.date} · {host}/map/ · each country's rules and sources are on its page</span> + <span>CC BY 4.0</span> + </footer> + </article> + </main> + <script> + // Pick a format, see it, then print or download it: the sheet is the print version, the + // images are the drawn PNGs. + const go = document.getElementById("posterGo"); + const use = document.getElementById("posterUse"); + const picks = [...document.querySelectorAll(".poster-pick button")]; + const views = [...document.querySelectorAll(".poster, .poster-image")]; + function choose(btn) { + picks.forEach((b) => b.setAttribute("aria-pressed", String(b === btn))); + views.forEach((v) => (v.hidden = v.dataset.format !== btn.dataset.format)); + const print = btn.dataset.format === "print"; + go.textContent = print ? "Print" : "Download image"; + if (print) go.removeAttribute("download"); + else { go.href = btn.dataset.href; go.download = btn.dataset.file; } + use.textContent = print ? "Prints on one A4 landscape sheet. Choose an image size instead to share it." + : `A ${btn.dataset.size} PNG, ready to post.`; + } + picks.forEach((b) => b.addEventListener("click", () => choose(b))); + go.addEventListener("click", (e) => { + if (document.querySelector('.poster-pick [aria-pressed="true"]')?.dataset.format !== "print") return; + e.preventDefault(); + window.print(); + }); + </script> +</Base>
src/styles/global.css +87 −0
@@ -762,4 +762,5 @@ table.cani tr[hidden] { display: none; } .map-sub { margin: 0 0 14px; } /* "Expand" beside the World map title opens the current view on a page of its own */ +.map-links { display: flex; gap: 22px; align-items: baseline; } .map-expand { font: 400 11.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-2); } .map-expand:hover { color: var(--text); text-decoration: underline; text-underline-offset: 5px; } @@ -1950,2 +1951,88 @@ button.sv-row .sv-chip { font: 400 10.5px var(--mono); text-transform: uppercase .avg-tier { font: 400 11.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; padding-left: 8px; border-left: 3px solid var(--c); color: var(--text-2); } @media (max-width: 600px) { .avg-band { font-size: 9.5px; } } + +/* ══ Map poster (/map/poster/) ═════════════════════════════════════════════════ + One sheet in paper colours in both themes, as it prints; the bar above it is for the screen. */ +.poster-page { max-width: 1240px; margin: 0 auto; padding: 24px clamp(16px, 4vw, 40px) 64px; } +.poster-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; } +.poster-bar .back { width: 100%; } +.poster-pick { display: flex; flex-wrap: wrap; border: 1px solid var(--border); } +.poster-pick button { padding: 9px 14px; border: 0; border-left: 1px solid var(--border); background: none; color: var(--text-2); font: inherit; font-size: 14px; cursor: pointer; } +.poster-pick button:first-child { border-left: 0; } +.poster-pick button:hover { color: var(--text); } +.poster-pick button[aria-pressed="true"] { background: var(--text); color: var(--bg); } +.poster-pick small { margin-left: 6px; font: 400 11px var(--mono); opacity: .7; } +.poster-go { min-width: 10em; text-align: center; justify-content: center; } +.poster-use { margin: 10px 0 20px; font-size: 13px; color: var(--muted); } +.poster-image { margin: 0; text-align: center; } +.poster-image img { display: block; max-width: 100%; max-height: 78vh; width: auto; height: auto; margin: 0 auto; border: 1px solid var(--border); box-shadow: 0 18px 50px rgba(0, 0, 0, .12); } +.poster-image figcaption { margin-top: 10px; font: 400 11.5px var(--mono); color: var(--muted); } +.poster[hidden], .poster-image[hidden] { display: none; } +.poster { + --ink: #11110f; --ink-2: #4b4a43; --rule: #d9d5c8; + display: flex; flex-direction: column; gap: clamp(14px, 2vw, 22px); + padding: clamp(18px, 3.4vw, 44px) clamp(18px, 3.6vw, 48px) clamp(16px, 2.6vw, 32px); + background: #f4f2eb; color: var(--ink); border: 1px solid var(--border); + box-shadow: 0 18px 50px rgba(0, 0, 0, .12); + -webkit-print-color-adjust: exact; print-color-adjust: exact; +} +.poster-eyebrow { display: flex; justify-content: space-between; gap: 16px; margin: 0; font: 400 clamp(10px, 1.1vw, 12.5px) var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--ink-2); } +.poster-title { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-top: clamp(10px, 1.6vw, 18px); } +.poster-title h1 { margin: 0; font: 400 clamp(40px, 6vw, 76px)/1 var(--display); letter-spacing: -.02em; } +.poster-title p { margin: 10px 0 0; max-width: 56ch; font-size: clamp(14px, 1.4vw, 17px); color: var(--ink-2); } +.poster-title .poster-avg { display: flex; flex-direction: column; align-items: flex-end; flex: none; margin: 0; } +.poster-avg b { font: 400 clamp(34px, 5vw, 64px)/1 var(--mono); letter-spacing: -.05em; } +.poster-avg span { margin-top: 6px; font: 400 11px var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--ink-2); } +.poster-map { display: block; width: 100%; height: auto; } +.poster-map path { stroke: #f4f2eb; stroke-width: .5; stroke-linejoin: round; } +.poster-map circle { stroke: #f4f2eb; stroke-width: .8; } +.poster-tiers { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px 16px; margin: 0; padding: 14px 0 0; list-style: none; border-top: 1px solid var(--ink); } +.poster-tiers li { display: flex; gap: 9px; align-items: flex-start; } +.poster-tiers i { flex: none; width: 14px; height: 14px; margin-top: 3px; } +.poster-tiers b { display: block; font-size: 14.5px; font-weight: 600; line-height: 1.25; } +.poster-tiers small { display: block; margin-top: 2px; font: 400 11.5px var(--mono); color: var(--ink-2); } +.poster-lists { display: grid; grid-template-columns: 1fr 1fr; gap: 0 clamp(24px, 4vw, 56px); padding-top: 14px; border-top: 1px solid var(--rule); } +.poster-lists h2 { margin: 0 0 6px; font: 400 11px var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--ink-2); } +.poster-lists ol { margin: 0; padding: 0; list-style: none; } +.poster-lists li { display: flex; align-items: baseline; gap: 10px; padding: 4px 0; } +.poster-rank { width: 2.4em; font: 400 12px var(--mono); color: var(--ink-2); } +.poster-name { flex: 1; min-width: 0; font: 400 clamp(17px, 1.8vw, 22px)/1.2 var(--display); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.poster-lists b { font: 500 15px var(--mono); border-bottom: 3px solid; } +.poster-foot { display: flex; justify-content: space-between; gap: 16px; font: 400 11px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); } +@media (max-width: 760px) { + .poster-pick, .poster-go { width: 100%; } + .poster-pick button { flex: 1 1 45%; border-top: 1px solid var(--border); } + .poster-tiers { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .poster-title { flex-direction: column; align-items: flex-start; } + .poster-title .poster-avg { align-items: flex-start; } + .poster-lists { grid-template-columns: 1fr; gap: 14px; } + .poster-foot { flex-direction: column; gap: 4px; } +} +@media print { + /* one A4 landscape sheet: the poster fills the page and keeps its paper colour to the edge */ + @page { size: A4 landscape; margin: 0; } + html:has(.poster), body:has(.poster) { background: #f4f2eb !important; } + body:has(.poster) .topbar, body:has(.poster) .footer, .poster-bar, .poster-use, .poster-image { display: none !important; } + .poster[hidden] { display: flex !important; } + .poster-page { max-width: none; margin: 0; padding: 0; } + .poster { box-sizing: border-box; width: 297mm; height: 210mm; padding: 9mm 11mm 7mm; gap: 3mm; border: 0; box-shadow: none; overflow: hidden; } + .poster-eyebrow { font-size: 8pt; } + .poster-title { flex-direction: row; align-items: flex-end; margin-top: 2mm; } + .poster-title h1 { font-size: 36pt; } + .poster-title p { font-size: 10.5pt; margin-top: 1.5mm; } + .poster-title .poster-avg { align-items: flex-end; } + .poster-avg b { font-size: 30pt; } + .poster-avg span { font-size: 7pt; } + .poster-map { width: auto; height: 100mm; margin: 0 auto; } + .poster-tiers { grid-template-columns: repeat(6, minmax(0, 1fr)); padding-top: 2.5mm; } + .poster-tiers b { font-size: 9.5pt; } + .poster-tiers small { font-size: 7.5pt; } + .poster-lists { grid-template-columns: 1fr 1fr; gap: 0 12mm; padding-top: 2.5mm; } + .poster-lists h2 { font-size: 7pt; margin-bottom: 1mm; } + .poster-lists li { padding: .5mm 0; } + .poster-name { font-size: 12pt; } + .poster-rank, .poster-lists b { font-size: 8.5pt; } + .poster-foot { flex-direction: row; font-size: 7pt; margin-top: auto; } +} +.legend-poster { margin-left: auto; color: var(--text-2); text-decoration: underline; text-decoration-color: var(--muted); text-underline-offset: 3px; } +.legend-poster:hover { color: var(--text); }