From c1b8e8ecce86ab69746e9f6d5f81b9673de2353c Mon Sep 17 00:00:00 2001 From: kerthcet Date: Sun, 6 Sep 2026 09:45:49 +0100 Subject: [PATCH 1/3] feat: open a contributor panel from each legend row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering (or focusing) a region in the map's legend now shows who its percentage is made of: the five most active contributors, their commit counts and how many of the seven projects each has touched. Ranked by commits, which is a rough measure and worth being honest about — a one-line addition counts the same as a feature, and reviewing or filing counts for nothing. `repos` sits beside every count for that reason: it separates one typo from work across projects. Both numbers come out of the commit pages tools/contributors.mjs already reads for the headcount, so the ranking costs no extra API requests. The panel and the percentage beside it are counted two different ways — the share from the places on the map, the panel from the people behind them — so the generator now cross-checks them per region and fails rather than shipping a card of four people next to a row reading 29%. CSS only. Every card is already in the HTML, so this is a selector rather than a listener and it works with JavaScript off. What that costs is repositioning: the card opens to a fixed side, left over the map, chosen because the legend sits at the container's right edge and nothing can measure the viewport at runtime to turn it around. Stacked, where the legend is full width, it drops out of the row instead. The rows carry `tabindex` so the same panel opens on a keyboard, and `:focus-within` is what a tap gets on a touchscreen. The "Others" note moves off the row's `title` and into its card — left where it was, a native tooltip would have opened on top of the card a moment later. Co-Authored-By: Claude Opus 5 --- assets/scss/common/_custom.scss | 186 +++++++++++++++++++++++++++++-- data/contributors.json | 188 ++++++++++++++++++++++++++++++-- hugo_stats.json | 8 ++ layouts/_default/home.html | 51 +++++++-- tools/contributors.mjs | 103 ++++++++++++++++- 5 files changed, 509 insertions(+), 27 deletions(-) diff --git a/assets/scss/common/_custom.scss b/assets/scss/common/_custom.scss index e151333..499c5c3 100644 --- a/assets/scss/common/_custom.scss +++ b/assets/scss/common/_custom.scss @@ -658,13 +658,12 @@ $map-pulse-offsets: list-style: none; font-size: 0.9375rem; - li { - display: flex; - align-items: baseline; - // Holds the share against the right edge, so the figures line up into a - // column that can be read down without the eye tracking across each row. - justify-content: space-between; - gap: 1rem; + // `> li` throughout, not `li`: each row now contains a second list — the people + // in the card — and a descendant selector gave every one of those a rule under it + // and the row's own padding. + > li { + // What the card is positioned against. + position: relative; padding: 0.3125rem 0; // The map's own land colour, so the legend's rules and the continents beside // them are the same grey rather than two different near-whites. @@ -673,9 +672,28 @@ $map-pulse-offsets: // The list is the whole block now; a rule under the last row would read as a // divider from whatever follows the section. - li:last-child { + > li:last-child { border-bottom: 0; } + + // The rows are focusable (see the `tabindex` in layouts/_default/home.html), so + // they need to show it — but only for the keyboard: `:focus-visible` and not + // `:focus` keeps a ring off the row a mouse just clicked on, which is not a + // click that does anything. + > li:focus-visible { + outline: 2px solid $map-mark; + outline-offset: 2px; + border-radius: 2px; + } +} + +.worldmap-region-row { + display: flex; + align-items: baseline; + // Holds the share against the right edge, so the figures line up into a + // column that can be read down without the eye tracking across each row. + justify-content: space-between; + gap: 1rem; } .worldmap-region-share { @@ -692,6 +710,158 @@ $map-pulse-offsets: color: $map-text; } +// The panel a legend row opens: who the region's percentage is made of, most active +// first. Content and ranking come from tools/contributors.mjs, which builds `top` +// out of the commit pages it already reads for the headcount. +// +// CSS only, on `:hover` and `:focus-within`. There is no script on this page and +// this was not worth adding one for: every card is already in the HTML, so the +// interaction is a selector rather than a listener, and it works with JavaScript +// off. What that costs is the thing a listener would have bought — no flipping the +// card to the other side when it would leave the viewport — which is why the +// position below is a fixed side chosen from where the legend actually sits. +.worldmap-card { + position: absolute; + // Over the map and over the rows below it. + z-index: 3; + padding: 0.75rem 0.875rem; + text-align: left; + // Opaque, because it sits on top of the dots — a tint would leave the map showing + // through the names. + background: var(--bs-body-bg, #fff); + border: 1px solid $map-land; + border-radius: 0.5rem; + // The one thing that says this is above the page rather than part of it. The + // border alone read as a box drawn on the map. + box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.08); + // Hidden in both senses: `opacity` is what fades, `visibility` is what keeps a + // closed card out of the accessibility tree and out of the way of the cursor. + // `display: none` would do the second but cannot be transitioned. + opacity: 0; + visibility: hidden; + // `visibility` is a discrete property: given a duration it flips at the halfway + // point, which showed the card at half a fade rather than at the start of one. So + // it is switched with a zero-length transition and a delay instead — held back to + // the end of the fade out here, and taken off entirely in the rule that shows the + // card, so opening is immediate and closing waits for the fade. + transition: + opacity 0.15s ease, + visibility 0s linear 0.15s; + + // Stacked, the legend is full width under the map: there is no side to open to, so + // the card drops out of the row and takes the legend's own width. Flush against the + // row rather than offset — a gap here is a strip where a cursor on its way down to + // the card is over neither, and the fade would start over. + top: 100%; + left: 0; + right: 0; +} + +// Hover is what this is for; focus is how it works on a keyboard, and — since the +// row is focusable and a tap moves focus to it — on a touchscreen. `:focus-within` +// rather than `:focus` so the card stays open while focus is inside it. +// +// One rule for both. Hover was scoped to `@media (hover: hover)` first, to keep a +// tap on a touchscreen from leaving a card stuck open, and that turns out to buy +// nothing: the same tap opens the card through `:focus-within` anyway, and both +// states clear the same way, on the next tap elsewhere. +.worldmap-regions > li:hover .worldmap-card, +.worldmap-regions > li:focus-within .worldmap-card { + opacity: 1; + visibility: visible; + // Visible from the first frame of the fade rather than halfway through it — see + // the transition above. + transition: + opacity 0.15s ease, + visibility 0s; +} + +@include media-breakpoint-up(lg) { + .worldmap-card { + // Beside the row, opening left over the map. Left because the legend is at the + // container's right edge — see `justify-content: space-between` on + // `.worldmap-layout` — so a card opening right would go off the viewport, and + // no-JS means nothing can measure that at runtime and turn it around. + top: 50%; + left: auto; + right: calc(100% + 0.75rem); + width: 17rem; + // Centred on its row, so the card is next to the name it belongs to rather than + // hanging off it. + transform: translateY(-50%); + + // Bridges the 0.75rem between the row and the card. Without it the gap is a + // strip belonging to neither, and a cursor crossing it to reach the card starts + // the card fading out first. Transparent, and a child of the card, which is what + // keeps `:hover` on the row true while the pointer is inside it. + &::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 100%; + width: 0.75rem; + } + } +} + +// Which region, how many people, how much they have done. +.worldmap-card-head { + margin: 0 0 0.5rem; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.35; +} + +// "Others" only: the one row whose name does not say what it counts. It used to be a +// `title` on the row — see layouts/_default/home.html. +.worldmap-card-note { + margin: -0.25rem 0 0.5rem; + font-size: 0.75rem; + line-height: 1.4; + color: $map-text; +} + +.worldmap-card-people { + margin: 0; + padding-left: 0; + list-style: none; + + li { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + padding: 0.1875rem 0; + font-size: 0.8125rem; + } +} + +.worldmap-card-name { + // Truncated rather than wrapped: a long display name would otherwise push the + // commit count onto a second line and break the column the counts read down. + // `min-width: 0` because a flex item will not shrink below its content otherwise. + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.worldmap-card-meta { + flex: 0 0 auto; + font-size: 0.75rem; + // Same column treatment as the shares in the legend. + font-variant-numeric: tabular-nums; + color: $map-text; +} + +// What the cap leaves out. Lighter than the names, because it is not one of them. +.worldmap-card-more { + margin: 0.5rem 0 0; + font-size: 0.75rem; + color: $map-text; +} + // The donation call to action. Deliberately unboxed: a panel here competed with // the project cards for attention and left a large empty band across the page. // A plain centred line separates it from the grid on whitespace alone. diff --git a/data/contributors.json b/data/contributors.json index aa53533..9ab1f25 100644 --- a/data/contributors.json +++ b/data/contributors.json @@ -1,5 +1,5 @@ { - "generated": "2026-09-03", + "generated": "2026-09-06", "total": 53, "located": 36, "places": [ @@ -311,33 +311,207 @@ { "name": "Asia", "count": 15, - "percent": 28 + "percent": 28, + "commits": 100, + "top": [ + { + "login": "cr7258", + "name": "Se7en", + "commits": 28, + "repos": 2 + }, + { + "login": "googs1025", + "name": "CYJiang", + "commits": 24, + "repos": 2 + }, + { + "login": "carlory", + "name": "杨朱 · Kiki", + "commits": 10, + "repos": 1 + }, + { + "login": "X1aoZEOuO", + "name": "Zefeng Ni", + "commits": 9, + "repos": 1 + }, + { + "login": "nayihz", + "name": "nayihz", + "commits": 8, + "repos": 1 + } + ], + "more": 10 }, { "name": "North America", "count": 9, - "percent": 17 + "percent": 17, + "commits": 19, + "top": [ + { + "login": "samzong", + "name": "samzong", + "commits": 10, + "repos": 1 + }, + { + "login": "paralleliq", + "name": "ParallelIQ", + "commits": 2, + "repos": 1 + }, + { + "login": "awdemos", + "name": "Drew the AI Guy", + "commits": 1, + "repos": 1 + }, + { + "login": "davidahmann", + "name": "David Ahmann", + "commits": 1, + "repos": 1 + }, + { + "login": "neerazz", + "name": "Neeraj Kumar Singh B", + "commits": 1, + "repos": 1 + } + ], + "more": 4 }, { "name": "Europe", "count": 7, - "percent": 13 + "percent": 13, + "commits": 797, + "top": [ + { + "login": "kerthcet", + "name": "Kante Yin", + "commits": 790, + "repos": 7 + }, + { + "login": "vivekchand", + "name": "Vivek Chand", + "commits": 2, + "repos": 1 + }, + { + "login": "evandempsey", + "name": "Evan Dempsey", + "commits": 1, + "repos": 1 + }, + { + "login": "mbakgun", + "name": "Burak Akgün", + "commits": 1, + "repos": 1 + }, + { + "login": "svalench", + "name": "alexander valenchits", + "commits": 1, + "repos": 1 + } + ], + "more": 2 }, { "name": "Oceania", "count": 3, - "percent": 6 + "percent": 6, + "commits": 3, + "top": [ + { + "login": "lee101", + "name": "Lee Penkman", + "commits": 1, + "repos": 1 + }, + { + "login": "matthewdcage", + "name": "Matthew Cage", + "commits": 1, + "repos": 1 + }, + { + "login": "robert-cronin", + "name": "Robbie Cronin", + "commits": 1, + "repos": 1 + } + ], + "more": 0 }, { "name": "South America", "count": 2, - "percent": 4 + "percent": 4, + "commits": 2, + "top": [ + { + "login": "alegauss", + "name": "Alê Oliveira", + "commits": 1, + "repos": 1 + }, + { + "login": "amedinat", + "name": "John Medina", + "commits": 1, + "repos": 1 + } + ], + "more": 0 }, { "name": "Others", "count": 17, "note": "Contributors whose GitHub profile does not say where they are.", - "percent": 32 + "percent": 32, + "commits": 43, + "top": [ + { + "login": "qinguoyi", + "name": "evan", + "commits": 9, + "repos": 2 + }, + { + "login": "wanyingz-dis", + "name": "sherry Zhao", + "commits": 6, + "repos": 1 + }, + { + "login": "hojkol", + "name": "hoj", + "commits": 5, + "repos": 1 + }, + { + "login": "Jerry-Kon", + "name": "Junyang Kong", + "commits": 4, + "repos": 2 + }, + { + "login": "kerry-he", + "name": "kerry-he", + "commits": 3, + "repos": 1 + } + ], + "more": 12 } ] } diff --git a/hugo_stats.json b/hugo_stats.json index 0090be9..bddb7e8 100644 --- a/hugo_stats.json +++ b/hugo_stats.json @@ -177,6 +177,13 @@ "title-submitted", "visually-hidden", "worldmap", + "worldmap-card", + "worldmap-card-head", + "worldmap-card-meta", + "worldmap-card-more", + "worldmap-card-name", + "worldmap-card-note", + "worldmap-card-people", "worldmap-dot", "worldmap-halo", "worldmap-label", @@ -185,6 +192,7 @@ "worldmap-place", "worldmap-pulse", "worldmap-region-name", + "worldmap-region-row", "worldmap-region-share", "worldmap-regions", "worldmap-summary", diff --git a/layouts/_default/home.html b/layouts/_default/home.html index 64a7654..18a1976 100644 --- a/layouts/_default/home.html +++ b/layouts/_default/home.html @@ -138,13 +138,50 @@

Contributors

    {{ range .regions -}} - {{/* Only "Others" carries a note — the one row whose name does not - say what it counts. */}} - - {{ .name }} - {{/* Percentages, not counts, and they add to 100 — see the - apportionment in tools/contributors.mjs. */}} - {{ .percent }}% + {{/* Focusable so the card below opens on the keyboard as well as + under a cursor: the stylesheet shows it on `:focus-within`, and + without a tab stop there is no way to get focus into the row. + `tabindex` and not a `
diff --git a/tools/contributors.mjs b/tools/contributors.mjs index 12d1854..36b6e5a 100644 --- a/tools/contributors.mjs +++ b/tools/contributors.mjs @@ -124,6 +124,12 @@ async function githubAll(path) { // seven — which is nothing against the hourly limit, and the per-contributor profile // lookups below already dwarf it. const logins = new Set(); +// How much each person has done, for the panel the legend opens on hover. Both come +// out of the commit pages that are already being read for the headcount, so the +// ranking costs no extra requests — which is the only reason it is commit-based +// rather than something the API would have to be asked for separately. +const commitsBy = new Map(); +const reposBy = new Map(); for (const repo of repos) { const commits = await githubAll(`repos/${repo}/commits?per_page=100`); const seen = new Set(); @@ -141,6 +147,9 @@ for (const repo of repos) { if (EXCLUDE.has(author.login.toLowerCase())) continue; seen.add(author.login); logins.add(author.login); + commitsBy.set(author.login, (commitsBy.get(author.login) ?? 0) + 1); + if (!reposBy.has(author.login)) reposBy.set(author.login, new Set()); + reposBy.get(author.login).add(repo); } console.log( `${repo}: ${seen.size} contributors across ${commits.length} commits` + @@ -157,8 +166,30 @@ const places = new Map(); const unmatched = new Map(); let located = 0; +// Everyone, whether or not they could be placed. The region panels are built from +// this rather than from `places`, because the "Others" row has to list people too +// and they are precisely the ones no place could be found for. +const people = []; + for (const login of [...logins].sort()) { - const { location } = await github(`users/${login}`); + const { location, name } = await github(`users/${login}`); + + // `country` stays null until a place is matched, and null is what puts someone in + // the "Others" panel: no location, a location the table has not learned, and a + // known non-place like "Remote" all end up here, which is exactly the set that + // `total - located` counts. + const person = { + login, + // The display name when the profile has one — "Se7en" reads as a person where + // "cr7258" reads as a handle — and the login when it does not. Both are kept: + // the name is for reading, the login is what identifies the account. + name: name?.trim() || login, + commits: commitsBy.get(login) ?? 0, + repos: reposBy.get(login)?.size ?? 0, + country: null, + }; + people.push(person); + if (!location || !location.trim()) continue; const key = normalise(location); @@ -173,6 +204,11 @@ for (const login of [...logins].sort()) { // An explicit null: a known non-place, already decided about. if (place === null) continue; + // The region a panel will list them under. Taken from the place rather than from + // the label, so somebody on a country-level fallback still counts under their + // region after that mark has yielded to a city. + person.country = place.country; + const existing = places.get(place.label); if (existing) { existing.count += 1; @@ -237,6 +273,47 @@ for (const place of matched) { regionCounts.set(region, (regionCounts.get(region) ?? 0) + place.count); } +// Who is in each region, for the panel its legend row opens. Ranked by commits, then +// by how many of the seven projects they have touched, then by login so that two +// people with one commit each on one project do not swap places between runs. +// +// Commits are a rough measure of activity and worth being honest about: a one-line +// addition to Awesome-LLMOps counts the same as a feature in llmaz, and reviewing, +// filing and answering are worth nothing here at all. `repos` is carried beside the +// count for exactly that reason — it separates one typo from work across projects, +// and it is the second thing the panel shows. +const TOP = 5; +const regionPeople = new Map(); +for (const person of people) { + const region = person.country + ? (regions[person.country] ?? "Unknown") + : "Others"; + if (!regionPeople.has(region)) regionPeople.set(region, []); + regionPeople.get(region).push(person); +} +for (const list of regionPeople.values()) { + list.sort( + (a, b) => + b.commits - a.commits || + b.repos - a.repos || + a.login.localeCompare(b.login), + ); +} + +// The panel and the percentage beside it are counted two different ways — the +// percentage from the places on the map, the panel from the people behind them — so +// they can disagree, and a panel listing four people beside a row reading 29% would +// be a bug nobody would notice by looking. Checked rather than assumed. +for (const [region, count] of regionCounts) { + const listed = regionPeople.get(region)?.length ?? 0; + if (listed !== count) { + exit( + `region ${region} has ${count} contributor(s) by place but ${listed} by person` + + ` — the panel and the share are counting different things`, + ); + } +} + // Shares of every contributor, as whole numbers that still add to 100. // // Rounding each share on its own does not: 15, 2, 1, 1 and 16 of 35 round to 43, @@ -319,10 +396,26 @@ const output = { // Regions plus the "Others" remainder: one row each in the summary. `count` is // not rendered — `percent` is — but it stays so the file can be checked against // itself: the counts sum to `total`, and the percents to 100. - regions: ranked.map((region, index) => ({ - ...region, - percent: shares[index], - })), + // + // `top` and `more` are the panel each row opens on hover. Capped at five, because + // the panel is a card floating over the map rather than a page of its own, and a + // region of seventeen would cover the continent it belongs to; `more` is what stops + // the cap from quietly hiding the rest. + regions: ranked.map((region, index) => { + const list = regionPeople.get(region.name) ?? []; + return { + ...region, + percent: shares[index], + commits: list.reduce((sum, p) => sum + p.commits, 0), + top: list.slice(0, TOP).map(({ login, name, commits, repos }) => ({ + login, + name, + commits, + repos, + })), + more: Math.max(0, list.length - TOP), + }; + }), }; // Same data as last time, only a newer date: the file is left exactly as it is. From 4e77924b6ac705b5eda65bba7864cff50d18801f Mon Sep 17 00:00:00 2001 From: kerthcet Date: Sun, 6 Sep 2026 10:46:30 +0100 Subject: [PATCH 2/3] feat: open a card of faces on each city on the map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering or focusing a city's dot now opens a card naming the city, counting its contributors and showing up to five of them by avatar and GitHub handle. Replaces the panel that opened from the legend's region rows: the map already draws cities, so the detail belongs where the eye is, and a region of "Asia — 10 contributors" told nobody who they were. The legend goes back to plain percentage rows. Nothing measures the viewport at runtime — this is CSS on :hover and :focus-within, so it works with JavaScript off. Which side a card opens on is decided in the template from the city's own longitude, and how wide it can grow comes from --map-room, set per mark in the generated stylesheet: the room between that dot and the edge it opens towards, read as container query units. Without that cap the cards ran up to 34px past the map's edge at 320px wide and one of them gave the page a horizontal scrollbar. The dots' hover targets are html on top of the svg, because a card cannot be html inside an svg. Both the dot and its target are placed from the new partials/worldmap-place.html, the one place the projection is computed — worst disagreement across the 23 marks is 0.06px at 1440. The coordinates ride in a generated stylesheet rather than style attributes, which the site's own style-src 'self' refuses. Two contributors list only "United States" and so appear on no card: their country mark was pruned once US cities existed. tools/contributors.mjs now reports them instead of dropping them quietly. Verified at 320, 390, 1024 and 1440: 23 targets over 23 dots, every card opens, 33 of 33 avatars load, no card crosses a viewport edge, all closed at rest. Co-Authored-By: Claude Opus 5 --- assets/scss/common/_custom.scss | 412 ++++++++++---------- data/contributors.json | 433 ++++++++++++---------- hugo_stats.json | 19 +- layouts/_default/home.html | 147 +++++--- layouts/partials/head/resource-hints.html | 58 +++ layouts/partials/worldmap-place.html | 25 ++ tools/contributors.mjs | 150 +++++--- 7 files changed, 707 insertions(+), 537 deletions(-) create mode 100644 layouts/partials/head/resource-hints.html create mode 100644 layouts/partials/worldmap-place.html diff --git a/assets/scss/common/_custom.scss b/assets/scss/common/_custom.scss index 499c5c3..0d9ba67 100644 --- a/assets/scss/common/_custom.scss +++ b/assets/scss/common/_custom.scss @@ -344,8 +344,9 @@ $feature-gap: 1.25rem; // keyline that rings each dot, 1.9:1 against the land. WCAG 2.1 asks 3:1 of a // graphic needed to understand the content, and this no longer meets it. What makes // that a considered trade rather than an oversight is that nothing here is only on -// the map: every city is in the `` tooltip, and the region shares are the -// text list beside it. Going back to 72% (#E47351) restores 3.1:1 on the keyline. +// the map: every city is named, counted and staffed in the card that opens on its +// dot, and the region shares are the text list beside it. Going back to 72% +// (#E47351) restores 3.1:1 on the keyline. $map-land: #e9edf2; $map-mark: mix($primary, #fff, 52%); // City labels and the legend's figures. Lighter than the body ink, which made both @@ -354,10 +355,10 @@ $map-mark: mix($primary, #fff, 52%); // background behind their own glyphs (see `paint-order` below), and the legend sits // off the map entirely. 4.7:1 on white, so AA at their size. $map-text: $gray-600; -// The halo is invisible at rest and appears on hover, so its alpha lives in -// `fill-opacity` rather than in the colour: `fill-opacity` clamps at 1 and can only -// ever scale a fill's own alpha down, so a translucent `fill` would leave the hover -// state with no way to strengthen the ring. +// The ring around a hovered dot, which is html rather than svg now — it belongs to the +// hover target in `.worldmap-hits`, so this is what its pseudo-element fades up to. +// The alpha is here and not in the colour because the ring is the mark's own hue: one +// variable holds how strong the ring gets, `$map-mark` holds what colour it is. $map-halo-alpha-hover: 0.22; // The pulse ring at its brightest, which is the moment it leaves the dot. Under the // hover alpha on purpose: hovering has to stay the strongest ring on the map, or @@ -408,6 +409,10 @@ $map-pulse-alpha: 0.18; max-width: 56rem; width: 100%; margin-inline: auto; + // What `.worldmap-hits` is positioned against. The figure holds nothing but the + // svg, so its content box is the svg's box — which is what lets the percentages in + // the generated stylesheet land the hover targets on the dots. + position: relative; } // In the row, the map takes exactly what the legend and the gap leave. Everything @@ -508,7 +513,9 @@ $map-pulse-alpha: 0.18; // lozenge without it — this is what makes them countable. stroke: var(--bs-body-bg, #fff); stroke-width: 2.5; - transition: fill-opacity 0.2s ease; + // Nothing transitions here. A `fill-opacity` transition used to, from when hovering + // a dot dimmed it; the hover state is the ring and the card now, and the dot itself + // never changes. } // A slow ring leaving each dot, so the section reads as somewhere people are still @@ -529,7 +536,7 @@ $map-pulse-alpha: 0.18; // any browser that does not run the animation, and under reduced motion below, // this is exactly the map it was before, rather than ten static rings. fill-opacity: 0; - // The cursor belongs to the halo underneath. + // The cursor belongs to the hover target in the html layer above. pointer-events: none; // Without `fill-box` the origin is the svg's, and every ring grows out of the // top-left corner of the map instead of out of its own dot. @@ -554,7 +561,7 @@ $map-pulse-alpha: 0.18; // Gone well before the cycle ends, and it holds there for the rest of it. A ring // still visible at full size is a second halo, and a halo painted at rest is what - // merged Guangzhou and Shenzhen into one lozenge — see `.worldmap-halo` below. + // merged Guangzhou and Shenzhen into one lozenge — see `.worldmap-hit::before`. // The pause is also what keeps this from reading as a strobe. 60%, 100% { @@ -599,36 +606,201 @@ $map-pulse-offsets: } } -// Motion is not what any of this is for: every city is in a `<title>` tooltip and -// the shares are the text list beside the map, so there is nothing here to lose. +// Motion is not what any of this is for: every city's name, count and contributors are +// in the card that opens on its dot and the shares are the text list beside the map, so +// there is nothing here to lose. @media (prefers-reduced-motion: reduce) { .worldmap-pulse { animation: none; } } -// A soft ring around each dot, on hover only. +// The hover targets, one per mark, on top of the svg. Positions and sizes come from +// the stylesheet generated in layouts/partials/head/resource-hints.html; everything +// is the same for all of them is here. +.worldmap-hits { + position: absolute; + inset: 0; + // The dots and labels underneath are what this layer is a target for; it paints + // nothing itself except the halo on hover. + pointer-events: none; + // So a card can be measured against the map rather than the page: this makes the + // layer — which is the svg's own box — a query container, and `cqw` in the card + // below then means one percent of the map's width. Inline size only, so nothing + // here depends on the layer's height. It clips nothing: cards still overflow the + // figure, which is what `paint` containment would have stopped and this does not + // ask for. + container-type: inline-size; +} + +.worldmap-hit { + position: absolute; + // `left` and `top` from the generated stylesheet are the dot's centre. + transform: translate(-50%, -50%); + // With `width` set per mark, this is the circle the old svg halo was. + aspect-ratio: 1; + border-radius: 50%; + // Back on, against the layer's `none` above. + pointer-events: auto; + cursor: default; + + // The soft ring around a hovered dot. Painted at rest it was the thing that merged + // the east-Asian cluster into one lozenge, so it appears only on hover — and it is + // the mark's own colour at 22%, over a dot of the same colour at full strength, so + // it rings the dot without touching how the dot itself reads. + // + // A pseudo-element and not this div's own background, which is where this started + // and which rendered the card at 22% as well: `opacity` applies to an element's + // whole subtree and makes it one composited layer, so a 0.22 halo took its card + // down with it — under the map, faded, unreadable. The halo needed to fade; the + // thing it belongs to did not. + &::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background: $map-mark; + opacity: 0; + transition: opacity 0.2s ease; + } +} + +// Hovering lifts a dot clear of its neighbours while its card is open — the only way +// to tell two overlapping cities apart. +.worldmap-hit:hover::before, +.worldmap-hit:focus-visible::before { + opacity: $map-halo-alpha-hover; +} + +// The ring is the focus indicator's job here too, but on its own it is a 22% wash +// that would not pass for one, so focus also gets an outline. On the card's side +// rather than the dot's, because the dot is 5px across at its smallest. +.worldmap-hit:focus-visible { + outline: 2px solid $map-mark; + outline-offset: 2px; +} + +// Who is at this mark: up to five handles with their avatars, and the city's own name +// and count as the heading — which is what the dots' native `<title>` tooltip used to +// carry, before this layer took the pointer. // -// Painted at rest it was the thing that merged the east-Asian cluster back -// together, so at rest it does no more than widen the hit target to 2.1x the dot's -// radius — worth having when the smallest dot is 6 units in a 1000-unit box. -// `pointer-events: all` because the default `visiblePainted` will not hit-test a -// fill this transparent. -.worldmap-halo { - fill: $map-mark; - fill-opacity: 0; - pointer-events: all; - transition: fill-opacity 0.2s ease; +// CSS only, on `:hover` and `:focus-within`. Every card is already in the page, so +// this is a selector rather than a listener and it works with JavaScript off. The +// price is that nothing can measure the viewport and flip a card that would fall off +// the edge, which is why the side each one opens on is decided in the template from +// the city's own longitude. +.worldmap-city { + position: absolute; + z-index: 3; + // Beside the dot, and clear of the halo: the target is a circle of 2.1 radii, so + // `100%` here is already past the ring rather than past the dot. + top: 50%; + left: calc(100% + 0.25rem); + transform: translateY(-50%); + // 13rem where the map has room for it, and no wider than the room the mark actually + // has where it does not. `--map-room` is set per mark by the generated stylesheet: + // the percentage of the map's width between this dot and the edge the card opens + // towards, and `1cqw` is one percent of that width (see `.worldmap-hits`). The + // 0.25rem is the gap to the dot, matched to `left` below. + // + // The fixed width is declared first and deliberately kept: it is what a browser + // without container query units falls back to, which is where every card was before + // this line. Only narrow screens are affected either way — at 1440 a card in the + // middle of the map has some 460px of room, so the `min()` picks 13rem and nothing + // changes. It is 320px where it earns its place: cards there ran up to 34px past + // the map's edge, and one of them gave the page a horizontal scrollbar. + // + // Interpolated because Sass reads a bare `min()` as its own function and rejects a + // `calc()` of units it cannot resolve; wrapped like this it is passed through as the + // CSS it is. + width: 13rem; + width: #{"min(13rem, calc(var(--map-room, 100) * 1cqw - 0.25rem))"}; + padding: 0.625rem 0.75rem; + text-align: left; + // Opaque: it sits on the map, and a tint would leave coastlines running through + // the faces. + background: var(--bs-body-bg, #fff); + border: 1px solid $map-land; + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.08); + // Text sized against the page, not the map: this is html, so unlike the city + // labels in the svg it does not shrink with the viewBox. + font-size: 0.8125rem; + line-height: 1.4; + opacity: 0; + visibility: hidden; + // `visibility` is discrete — given a duration it flips at the halfway point, which + // showed the card at half a fade. Switched with a delay instead, held to the end of + // the fade out here and dropped to zero in the rule that opens the card. + transition: + opacity 0.15s ease, + visibility 0s linear 0.15s; + // A card is a panel about the dot, not a second hover target: without this, a card + // covering a neighbouring dot would take that dot's hover as well. + pointer-events: none; +} + +// East of the middle, the card opens the other way — see the template. +.worldmap-hit-left .worldmap-city { + left: auto; + right: calc(100% + 0.25rem); } -// Hovering lifts a dot clear of its neighbours while the browser shows the -// `<title>` as a tooltip — the only way to tell two overlapping cities apart. -.worldmap-place:hover { - .worldmap-halo { - fill-opacity: $map-halo-alpha-hover; +.worldmap-hit:hover .worldmap-city, +.worldmap-hit:focus-within .worldmap-city { + opacity: 1; + visibility: visible; + transition: + opacity 0.15s ease, + visibility 0s; +} + +.worldmap-city-head { + margin: 0 0 0.5rem; + font-weight: 600; + line-height: 1.35; +} + +.worldmap-city-people { + margin: 0; + padding-left: 0; + list-style: none; + + li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.125rem 0; } } +.worldmap-avatar { + flex: 0 0 auto; + width: 24px; + height: 24px; + border-radius: 50%; + // Hotlinked from avatars.githubusercontent.com, which the site's own CSP allows + // (`img-src`). A missing or slow avatar leaves a grey circle rather than a gap + // that moves the handle beside it. + background: $map-land; +} + +.worldmap-city-login { + // Truncated rather than wrapped: a long handle would otherwise push the row's + // height out and leave the column of faces uneven. + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +// What the cap leaves out. Lighter than the handles, because it is not one of them. +.worldmap-city-more { + margin: 0.375rem 0 0; + font-size: 0.75rem; + color: $map-text; +} + // The legend beside the map: each region and its share of all contributors. .worldmap-summary { // Full width while stacked, so the rules under each row start and end where the @@ -658,12 +830,13 @@ $map-pulse-offsets: list-style: none; font-size: 0.9375rem; - // `> li` throughout, not `li`: each row now contains a second list — the people - // in the card — and a descendant selector gave every one of those a rule under it - // and the row's own padding. - > li { - // What the card is positioned against. - position: relative; + li { + display: flex; + align-items: baseline; + // Holds the share against the right edge, so the figures line up into a + // column that can be read down without the eye tracking across each row. + justify-content: space-between; + gap: 1rem; padding: 0.3125rem 0; // The map's own land colour, so the legend's rules and the continents beside // them are the same grey rather than two different near-whites. @@ -672,28 +845,9 @@ $map-pulse-offsets: // The list is the whole block now; a rule under the last row would read as a // divider from whatever follows the section. - > li:last-child { + li:last-child { border-bottom: 0; } - - // The rows are focusable (see the `tabindex` in layouts/_default/home.html), so - // they need to show it — but only for the keyboard: `:focus-visible` and not - // `:focus` keeps a ring off the row a mouse just clicked on, which is not a - // click that does anything. - > li:focus-visible { - outline: 2px solid $map-mark; - outline-offset: 2px; - border-radius: 2px; - } -} - -.worldmap-region-row { - display: flex; - align-items: baseline; - // Holds the share against the right edge, so the figures line up into a - // column that can be read down without the eye tracking across each row. - justify-content: space-between; - gap: 1rem; } .worldmap-region-share { @@ -710,158 +864,6 @@ $map-pulse-offsets: color: $map-text; } -// The panel a legend row opens: who the region's percentage is made of, most active -// first. Content and ranking come from tools/contributors.mjs, which builds `top` -// out of the commit pages it already reads for the headcount. -// -// CSS only, on `:hover` and `:focus-within`. There is no script on this page and -// this was not worth adding one for: every card is already in the HTML, so the -// interaction is a selector rather than a listener, and it works with JavaScript -// off. What that costs is the thing a listener would have bought — no flipping the -// card to the other side when it would leave the viewport — which is why the -// position below is a fixed side chosen from where the legend actually sits. -.worldmap-card { - position: absolute; - // Over the map and over the rows below it. - z-index: 3; - padding: 0.75rem 0.875rem; - text-align: left; - // Opaque, because it sits on top of the dots — a tint would leave the map showing - // through the names. - background: var(--bs-body-bg, #fff); - border: 1px solid $map-land; - border-radius: 0.5rem; - // The one thing that says this is above the page rather than part of it. The - // border alone read as a box drawn on the map. - box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.08); - // Hidden in both senses: `opacity` is what fades, `visibility` is what keeps a - // closed card out of the accessibility tree and out of the way of the cursor. - // `display: none` would do the second but cannot be transitioned. - opacity: 0; - visibility: hidden; - // `visibility` is a discrete property: given a duration it flips at the halfway - // point, which showed the card at half a fade rather than at the start of one. So - // it is switched with a zero-length transition and a delay instead — held back to - // the end of the fade out here, and taken off entirely in the rule that shows the - // card, so opening is immediate and closing waits for the fade. - transition: - opacity 0.15s ease, - visibility 0s linear 0.15s; - - // Stacked, the legend is full width under the map: there is no side to open to, so - // the card drops out of the row and takes the legend's own width. Flush against the - // row rather than offset — a gap here is a strip where a cursor on its way down to - // the card is over neither, and the fade would start over. - top: 100%; - left: 0; - right: 0; -} - -// Hover is what this is for; focus is how it works on a keyboard, and — since the -// row is focusable and a tap moves focus to it — on a touchscreen. `:focus-within` -// rather than `:focus` so the card stays open while focus is inside it. -// -// One rule for both. Hover was scoped to `@media (hover: hover)` first, to keep a -// tap on a touchscreen from leaving a card stuck open, and that turns out to buy -// nothing: the same tap opens the card through `:focus-within` anyway, and both -// states clear the same way, on the next tap elsewhere. -.worldmap-regions > li:hover .worldmap-card, -.worldmap-regions > li:focus-within .worldmap-card { - opacity: 1; - visibility: visible; - // Visible from the first frame of the fade rather than halfway through it — see - // the transition above. - transition: - opacity 0.15s ease, - visibility 0s; -} - -@include media-breakpoint-up(lg) { - .worldmap-card { - // Beside the row, opening left over the map. Left because the legend is at the - // container's right edge — see `justify-content: space-between` on - // `.worldmap-layout` — so a card opening right would go off the viewport, and - // no-JS means nothing can measure that at runtime and turn it around. - top: 50%; - left: auto; - right: calc(100% + 0.75rem); - width: 17rem; - // Centred on its row, so the card is next to the name it belongs to rather than - // hanging off it. - transform: translateY(-50%); - - // Bridges the 0.75rem between the row and the card. Without it the gap is a - // strip belonging to neither, and a cursor crossing it to reach the card starts - // the card fading out first. Transparent, and a child of the card, which is what - // keeps `:hover` on the row true while the pointer is inside it. - &::after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 100%; - width: 0.75rem; - } - } -} - -// Which region, how many people, how much they have done. -.worldmap-card-head { - margin: 0 0 0.5rem; - font-size: 0.8125rem; - font-weight: 600; - line-height: 1.35; -} - -// "Others" only: the one row whose name does not say what it counts. It used to be a -// `title` on the row — see layouts/_default/home.html. -.worldmap-card-note { - margin: -0.25rem 0 0.5rem; - font-size: 0.75rem; - line-height: 1.4; - color: $map-text; -} - -.worldmap-card-people { - margin: 0; - padding-left: 0; - list-style: none; - - li { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.75rem; - padding: 0.1875rem 0; - font-size: 0.8125rem; - } -} - -.worldmap-card-name { - // Truncated rather than wrapped: a long display name would otherwise push the - // commit count onto a second line and break the column the counts read down. - // `min-width: 0` because a flex item will not shrink below its content otherwise. - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.worldmap-card-meta { - flex: 0 0 auto; - font-size: 0.75rem; - // Same column treatment as the shares in the legend. - font-variant-numeric: tabular-nums; - color: $map-text; -} - -// What the cap leaves out. Lighter than the names, because it is not one of them. -.worldmap-card-more { - margin: 0.5rem 0 0; - font-size: 0.75rem; - color: $map-text; -} - // The donation call to action. Deliberately unboxed: a panel here competed with // the project cards for attention and left a large empty band across the page. // A plain centred line separates it from the grid on whitespace alone. diff --git a/data/contributors.json b/data/contributors.json index 9ab1f25..55d0490 100644 --- a/data/contributors.json +++ b/data/contributors.json @@ -14,7 +14,30 @@ "dy": 5, "anchor": "start" }, - "count": 6 + "count": 6, + "people": [ + { + "login": "cr7258", + "avatar": "https://avatars.githubusercontent.com/u/40051120?v=4&s=64" + }, + { + "login": "carlory", + "avatar": "https://avatars.githubusercontent.com/u/28390961?v=4&s=64" + }, + { + "login": "X1aoZEOuO", + "avatar": "https://avatars.githubusercontent.com/u/97826429?v=4&s=64" + }, + { + "login": "pacoxu", + "avatar": "https://avatars.githubusercontent.com/u/2010320?v=4&s=64" + }, + { + "login": "rudeigerc", + "avatar": "https://avatars.githubusercontent.com/u/18243819?v=4&s=64" + } + ], + "more": 1 }, { "label": "San Francisco, USA", @@ -27,7 +50,22 @@ "dy": -14, "anchor": "start" }, - "count": 3 + "count": 3, + "people": [ + { + "login": "paralleliq", + "avatar": "https://avatars.githubusercontent.com/u/241533185?v=4&s=64" + }, + { + "login": "neerazz", + "avatar": "https://avatars.githubusercontent.com/u/43318996?v=4&s=64" + }, + { + "login": "rohilvagarwal", + "avatar": "https://avatars.githubusercontent.com/u/88454899?v=4&s=64" + } + ], + "more": 0 }, { "label": "India", @@ -41,7 +79,18 @@ "dy": 23, "anchor": "middle" }, - "count": 2 + "count": 2, + "people": [ + { + "login": "SanjanShiv", + "avatar": "https://avatars.githubusercontent.com/u/90202905?v=4&s=64" + }, + { + "login": "VedantMadane", + "avatar": "https://avatars.githubusercontent.com/u/6527493?v=4&s=64" + } + ], + "more": 0 }, { "label": "Guangzhou, China", @@ -54,7 +103,18 @@ "dy": 20, "anchor": "end" }, - "count": 2 + "count": 2, + "people": [ + { + "login": "kenwoodjw", + "avatar": "https://avatars.githubusercontent.com/u/10386710?v=4&s=64" + }, + { + "login": "Whitea029", + "avatar": "https://avatars.githubusercontent.com/u/145814986?v=4&s=64" + } + ], + "more": 0 }, { "label": "Beijing, China", @@ -67,7 +127,18 @@ "dy": 4, "anchor": "start" }, - "count": 2 + "count": 2, + "people": [ + { + "login": "jiahuipaung", + "avatar": "https://avatars.githubusercontent.com/u/70688286?v=4&s=64" + }, + { + "login": "yafengio", + "avatar": "https://avatars.githubusercontent.com/u/45666616?v=4&s=64" + } + ], + "more": 0 }, { "label": "Shenzhen, China", @@ -80,7 +151,18 @@ "dy": 11, "anchor": "start" }, - "count": 2 + "count": 2, + "people": [ + { + "login": "nayihz", + "avatar": "https://avatars.githubusercontent.com/u/65757763?v=4&s=64" + }, + { + "login": "liangyuanpeng", + "avatar": "https://avatars.githubusercontent.com/u/28711504?v=4&s=64" + } + ], + "more": 0 }, { "label": "Paris, France", @@ -93,7 +175,14 @@ "dy": 28, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "ThibautMelen", + "avatar": "https://avatars.githubusercontent.com/u/20891897?v=4&s=64" + } + ], + "more": 0 }, { "label": "Sao Paulo, Brazil", @@ -106,7 +195,14 @@ "dy": 4, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "alegauss", + "avatar": "https://avatars.githubusercontent.com/u/331174?v=4&s=64" + } + ], + "more": 0 }, { "label": "Bogota, Colombia", @@ -119,7 +215,14 @@ "dy": 4, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "amedinat", + "avatar": "https://avatars.githubusercontent.com/u/25287764?v=4&s=64" + } + ], + "more": 0 }, { "label": "Toronto, Canada", @@ -132,7 +235,14 @@ "dy": 4, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "davidahmann", + "avatar": "https://avatars.githubusercontent.com/u/46606159?v=4&s=64" + } + ], + "more": 0 }, { "label": "Dublin, Ireland", @@ -145,7 +255,14 @@ "dy": -11, "anchor": "middle" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "evandempsey", + "avatar": "https://avatars.githubusercontent.com/u/5140530?v=4&s=64" + } + ], + "more": 0 }, { "label": "Chengdu, China", @@ -158,7 +275,14 @@ "dy": 4, "anchor": "end" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "googs1025", + "avatar": "https://avatars.githubusercontent.com/u/86391540?v=4&s=64" + } + ], + "more": 0 }, { "label": "Cambridge, UK", @@ -171,7 +295,14 @@ "dy": 19, "anchor": "end" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "kerthcet", + "avatar": "https://avatars.githubusercontent.com/u/18364341?v=4&s=64" + } + ], + "more": 0 }, { "label": "New Zealand", @@ -185,7 +316,14 @@ "dy": 4, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "lee101", + "avatar": "https://avatars.githubusercontent.com/u/2122616?v=4&s=64" + } + ], + "more": 0 }, { "label": "Brisbane, Australia", @@ -198,7 +336,14 @@ "dy": 4, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "matthewdcage", + "avatar": "https://avatars.githubusercontent.com/u/132959513?v=4&s=64" + } + ], + "more": 0 }, { "label": "Berlin, Germany", @@ -211,7 +356,14 @@ "dy": -11, "anchor": "middle" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "mbakgun", + "avatar": "https://avatars.githubusercontent.com/u/8726393?v=4&s=64" + } + ], + "more": 0 }, { "label": "Melbourne, Australia", @@ -224,7 +376,14 @@ "dy": 6, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "robert-cronin", + "avatar": "https://avatars.githubusercontent.com/u/8540764?v=4&s=64" + } + ], + "more": 0 }, { "label": "Chicago, USA", @@ -237,7 +396,14 @@ "dy": 19, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "samvallad33", + "avatar": "https://avatars.githubusercontent.com/u/143034159?v=4&s=64" + } + ], + "more": 0 }, { "label": "Los Angeles, USA", @@ -250,7 +416,14 @@ "dy": 4, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "samzong", + "avatar": "https://avatars.githubusercontent.com/u/13782141?v=4&s=64" + } + ], + "more": 0 }, { "label": "Minsk, Belarus", @@ -263,7 +436,14 @@ "dy": 4, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "svalench", + "avatar": "https://avatars.githubusercontent.com/u/20963370?v=4&s=64" + } + ], + "more": 0 }, { "label": "Palo Alto, USA", @@ -276,7 +456,14 @@ "dy": 23, "anchor": "end" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "tonydzi", + "avatar": "https://avatars.githubusercontent.com/u/194927794?v=4&s=64" + } + ], + "more": 0 }, { "label": "Barcelona, Spain", @@ -289,7 +476,14 @@ "dy": 22, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "vgmartinez", + "avatar": "https://avatars.githubusercontent.com/u/10614247?v=4&s=64" + } + ], + "more": 0 }, { "label": "Amsterdam, Netherlands", @@ -302,7 +496,14 @@ "dy": 23, "anchor": "start" }, - "count": 1 + "count": 1, + "people": [ + { + "login": "vivekchand", + "avatar": "https://avatars.githubusercontent.com/u/987905?v=4&s=64" + } + ], + "more": 0 } ], "cities": 21, @@ -311,207 +512,33 @@ { "name": "Asia", "count": 15, - "percent": 28, - "commits": 100, - "top": [ - { - "login": "cr7258", - "name": "Se7en", - "commits": 28, - "repos": 2 - }, - { - "login": "googs1025", - "name": "CYJiang", - "commits": 24, - "repos": 2 - }, - { - "login": "carlory", - "name": "杨朱 · Kiki", - "commits": 10, - "repos": 1 - }, - { - "login": "X1aoZEOuO", - "name": "Zefeng Ni", - "commits": 9, - "repos": 1 - }, - { - "login": "nayihz", - "name": "nayihz", - "commits": 8, - "repos": 1 - } - ], - "more": 10 + "percent": 28 }, { "name": "North America", "count": 9, - "percent": 17, - "commits": 19, - "top": [ - { - "login": "samzong", - "name": "samzong", - "commits": 10, - "repos": 1 - }, - { - "login": "paralleliq", - "name": "ParallelIQ", - "commits": 2, - "repos": 1 - }, - { - "login": "awdemos", - "name": "Drew the AI Guy", - "commits": 1, - "repos": 1 - }, - { - "login": "davidahmann", - "name": "David Ahmann", - "commits": 1, - "repos": 1 - }, - { - "login": "neerazz", - "name": "Neeraj Kumar Singh B", - "commits": 1, - "repos": 1 - } - ], - "more": 4 + "percent": 17 }, { "name": "Europe", "count": 7, - "percent": 13, - "commits": 797, - "top": [ - { - "login": "kerthcet", - "name": "Kante Yin", - "commits": 790, - "repos": 7 - }, - { - "login": "vivekchand", - "name": "Vivek Chand", - "commits": 2, - "repos": 1 - }, - { - "login": "evandempsey", - "name": "Evan Dempsey", - "commits": 1, - "repos": 1 - }, - { - "login": "mbakgun", - "name": "Burak Akgün", - "commits": 1, - "repos": 1 - }, - { - "login": "svalench", - "name": "alexander valenchits", - "commits": 1, - "repos": 1 - } - ], - "more": 2 + "percent": 13 }, { "name": "Oceania", "count": 3, - "percent": 6, - "commits": 3, - "top": [ - { - "login": "lee101", - "name": "Lee Penkman", - "commits": 1, - "repos": 1 - }, - { - "login": "matthewdcage", - "name": "Matthew Cage", - "commits": 1, - "repos": 1 - }, - { - "login": "robert-cronin", - "name": "Robbie Cronin", - "commits": 1, - "repos": 1 - } - ], - "more": 0 + "percent": 6 }, { "name": "South America", "count": 2, - "percent": 4, - "commits": 2, - "top": [ - { - "login": "alegauss", - "name": "Alê Oliveira", - "commits": 1, - "repos": 1 - }, - { - "login": "amedinat", - "name": "John Medina", - "commits": 1, - "repos": 1 - } - ], - "more": 0 + "percent": 4 }, { "name": "Others", "count": 17, "note": "Contributors whose GitHub profile does not say where they are.", - "percent": 32, - "commits": 43, - "top": [ - { - "login": "qinguoyi", - "name": "evan", - "commits": 9, - "repos": 2 - }, - { - "login": "wanyingz-dis", - "name": "sherry Zhao", - "commits": 6, - "repos": 1 - }, - { - "login": "hojkol", - "name": "hoj", - "commits": 5, - "repos": 1 - }, - { - "login": "Jerry-Kon", - "name": "Junyang Kong", - "commits": 4, - "repos": 2 - }, - { - "login": "kerry-he", - "name": "kerry-he", - "commits": 3, - "repos": 1 - } - ], - "more": 12 + "percent": 32 } ] } diff --git a/hugo_stats.json b/hugo_stats.json index bddb7e8..0704f8c 100644 --- a/hugo_stats.json +++ b/hugo_stats.json @@ -19,6 +19,7 @@ "head", "header", "html", + "img", "input", "kbd", "label", @@ -177,22 +178,22 @@ "title-submitted", "visually-hidden", "worldmap", - "worldmap-card", - "worldmap-card-head", - "worldmap-card-meta", - "worldmap-card-more", - "worldmap-card-name", - "worldmap-card-note", - "worldmap-card-people", + "worldmap-avatar", + "worldmap-city", + "worldmap-city-head", + "worldmap-city-login", + "worldmap-city-more", + "worldmap-city-people", "worldmap-dot", - "worldmap-halo", + "worldmap-hit", + "worldmap-hit-left", + "worldmap-hits", "worldmap-label", "worldmap-land", "worldmap-layout", "worldmap-place", "worldmap-pulse", "worldmap-region-name", - "worldmap-region-row", "worldmap-region-share", "worldmap-regions", "worldmap-summary", diff --git a/layouts/_default/home.html b/layouts/_default/home.html index 18a1976..7a2bb35 100644 --- a/layouts/_default/home.html +++ b/layouts/_default/home.html @@ -46,12 +46,12 @@ <h2 class="feature-grid-title">Contributors</h2> aria-label="{{ $summary }}." xmlns="http://www.w3.org/2000/svg"> <path class="worldmap-land" d="{{ $map.land }}" /> {{ range .places -}} - {{/* The projection tools/worldmap.mjs drew the land with, - equirectangular so that it is these two lines rather than a - JavaScript map library. */}} - {{ $x := mul $map.k (add .lng 180.0) -}} - {{ $y := mul $map.k (sub $map.latTop .lat) -}} - {{/* Area, not radius, carries the count: doubling the radius + {{/* Position and size, from the one place that computes them: the + stylesheet generated in head/resource-hints.html has to put each + card's hover target exactly on top of the dot drawn here, and + two copies of this arithmetic would eventually disagree. + + Area, not radius, carries the count: doubling the radius would draw a city of two as four times the city of one. Strictly proportional area — 6·√count — is where this started, @@ -67,8 +67,8 @@ <h2 class="feature-grid-title">Contributors</h2> That is a compressed scale and it should be said plainly: these dots are comparable, not measurable. Nothing here rests on reading a count out of a radius — every city's exact number - is in its `<title>` tooltip, and the shares are the text list - beside the map. + is in the card that opens on it, and the shares are the text + list beside the map. The compression is at the top rather than in the base, because the base is set from the narrowest case. These are viewBox @@ -77,11 +77,19 @@ <h2 class="feature-grid-title">Contributors</h2> of 4 into 1.4 real pixels and lost the single-contributor cities altogether. Scaling everything down to shrink Shanghai would have spent them again. */}} - {{ $r := mul 6.0 (math.Sqrt (add 1.0 (mul 0.5 (sub .count 1)))) -}} + {{ $pos := partial "worldmap-place.html" (dict "map" $map "place" .) -}} + {{ $x := $pos.x -}} + {{ $y := $pos.y -}} + {{ $r := $pos.r -}} <g class="worldmap-place"> - {{/* Native tooltip — no JS, and it works on the keyboard in - the browsers that surface it. */}} - <title>{{ .label }} — {{ .count }} contributor{{ if ne (int .count) 1 }}s{{ end }} + {{/* No `` here any more. It was a native tooltip naming the + city and its count, and the card in the layer below now sits + over the dot and says the same thing plus who is there — so + the tooltip could no longer be triggered by a pointer that + never reaches the svg, and a title nothing can open is worse + than none. Nothing is lost to a screen reader either: this + svg is a single `role="img"`, so child titles were never + announced. */}} {{/* The ring that pulses out of the dot. Grown by the stylesheet, so one animation covers every size; first in the group, so it passes under the dot rather than over the top of it. @@ -99,14 +107,12 @@ <h2 class="feature-grid-title">Contributors</h2> the dot, and the ring only says the map is live — so the rings can be closer in size than the counts are. - Not the halo below, which is the hover target — scaling that - would have made the tooltip's hit area breathe under the - cursor. Decoration only, and it is nowhere in the + Not the hover target, which is in the html layer below the + svg — scaling that would have made the hit area breathe + under the cursor. Decoration only, and it is nowhere in the accessibility tree: the svg is a single `role="img"`. */}} <circle class="worldmap-pulse" cx="{{ printf "%.1f" $x }}" cy="{{ printf "%.1f" $y }}" r="{{ printf "%.1f" (div (add $r 13.0) 2.0) }}" /> - <circle class="worldmap-halo" cx="{{ printf "%.1f" $x }}" cy="{{ printf "%.1f" $y }}" - r="{{ printf "%.1f" (mul $r 2.1) }}" /> <circle class="worldmap-dot" cx="{{ printf "%.1f" $x }}" cy="{{ printf "%.1f" $y }}" r="{{ printf "%.1f" $r }}" /> {{/* Offsets come from tools/locations.json, hand-placed per @@ -122,6 +128,62 @@ <h2 class="feature-grid-title">Contributors</h2> </g> {{ end -}} </svg> + + {{/* One hover target per mark, in html on top of the svg, each holding the + card for its city. Nothing is drawn here that the svg does not + already draw — the layer exists because a card cannot be html inside + an svg, and an html card outside it cannot be positioned against a + dot inside it. So the dot and its target are two elements over the + same point, both placed from partials/worldmap-place.html. + + The coordinates are in a stylesheet generated in + head/resource-hints.html, not in a `style` attribute here, which the + site's own `style-src 'self'` refuses. That is also why the order of + these divs matters: the generated rules address them by + `nth-child`, so this must stay a flat list in the order of + `.places`. + + This layer takes the pointer for the whole map, so it is also what + replaced the dots' `<title>` tooltips — the card names the city and + counts it, which is what the tooltip did. + + `tabindex` so the cards can be opened without a pointer. It is the + only route to these names for a keyboard or a screen reader, since + the svg is one `role="img"` graphic — at the cost of one tab stop + per city between the map and the projects below it. */}} + <div class="worldmap-hits"> + {{ range .places -}} + {{ $pos := partial "worldmap-place.html" (dict "map" $map "place" .) -}} + {{/* Which side the card opens on, decided here because no script + measures it at runtime: everything east of the middle opens to + the left, everything west of it to the right, so a card never + runs off the near edge of the map. */}} + <div class="worldmap-hit{{ if gt $pos.x (mul 0.5 $map.width) }} worldmap-hit-left{{ end }}" + tabindex="0"> + <div class="worldmap-city"> + <p class="worldmap-city-head">{{ .label }} — {{ .count }} + contributor{{ if ne (int .count) 1 }}s{{ end }}</p> + <ul class="worldmap-city-people"> + {{ range .people -}} + <li> + {{/* Empty `alt`: the handle is right beside it, so a + screen reader announcing the avatar as well would + say the name twice. `lazy` and the `s=64` in the + url are what keep 33 faces off the critical path + — see tools/contributors.mjs. */}} + <img class="worldmap-avatar" src="{{ .avatar }}" alt="" width="24" height="24" + loading="lazy" decoding="async"> + <span class="worldmap-city-login">{{ .login }}</span> + </li> + {{ end -}} + </ul> + {{/* Five faces is what the card has room for; this is what says + the mark stands for more than that. */}} + {{ with .more }}<p class="worldmap-city-more">+{{ . }} more</p>{{ end }} + </div> + </div> + {{ end -}} + </div> </figure> {{/* The legend the dots need: a dot's size is comparable but not readable, @@ -138,50 +200,13 @@ <h2 class="feature-grid-title">Contributors</h2> <div class="worldmap-summary"> <ul class="worldmap-regions"> {{ range .regions -}} - {{/* Focusable so the card below opens on the keyboard as well as - under a cursor: the stylesheet shows it on `:focus-within`, and - without a tab stop there is no way to get focus into the row. - `tabindex` and not a `<button>` — nothing here is clickable, and - announcing seven buttons that do nothing would be worse than - announcing seven rows. */}} - <li tabindex="0"> - <div class="worldmap-region-row"> - <span class="worldmap-region-name">{{ .name }}</span> - {{/* Percentages, not counts, and they add to 100 — see the - apportionment in tools/contributors.mjs. */}} - <span class="worldmap-region-share">{{ .percent }}%</span> - </div> - {{/* Who is behind the percentage. Hidden until the row is hovered - or focused, but real text in the page either way — no script - builds it, and no request fetches it. - - This is where the "Others" note now lives. It was a `title` - on the row, which would have opened a second, native tooltip - on top of this card a moment after this one appeared. */}} - <div class="worldmap-card"> - <p class="worldmap-card-head"> - {{ .name }} — {{ .count }} contributor{{ if ne (int .count) 1 }}s{{ end }}, - {{ .commits }} commit{{ if ne (int .commits) 1 }}s{{ end }} - </p> - {{ with .note }}<p class="worldmap-card-note">{{ . }}</p>{{ end }} - <ul class="worldmap-card-people"> - {{ range .top -}} - <li> - {{/* The display name reads as a person; the login is - what identifies the account, so it is the title - rather than being dropped. */}} - <span class="worldmap-card-name" title="@{{ .login }}">{{ .name }}</span> - <span class="worldmap-card-meta"> - {{ .commits }} commit{{ if ne (int .commits) 1 }}s{{ end }} - · {{ .repos }} repo{{ if ne (int .repos) 1 }}s{{ end }} - </span> - </li> - {{ end -}} - </ul> - {{/* The cap is five, so this is what says the list is not the - whole region. */}} - {{ with .more }}<p class="worldmap-card-more">+{{ . }} more</p>{{ end }} - </div> + {{/* Only "Others" carries a note — the one row whose name does not + say what it counts. */}} + <li{{ with .note }} title="{{ . }}"{{ end }}> + <span class="worldmap-region-name">{{ .name }}</span> + {{/* Percentages, not counts, and they add to 100 — see the + apportionment in tools/contributors.mjs. */}} + <span class="worldmap-region-share">{{ .percent }}%</span> </li> {{ end -}} </ul> diff --git a/layouts/partials/head/resource-hints.html b/layouts/partials/head/resource-hints.html new file mode 100644 index 0000000..5e97571 --- /dev/null +++ b/layouts/partials/head/resource-hints.html @@ -0,0 +1,58 @@ +{{/* A hook the theme's head calls before its own stylesheet + (@thulite/core/layouts/_partials/head/head.html). Two things for the contributor + map, both homepage-only so no other page pays for them. + + Note for anyone looking for the more obvious hook: layouts/partials/head/ + custom-head.html is dead. It belongs to @thulite/doks-core's head partial, which + @thulite/core's `_partials/head/head.html` now takes precedence over, and that + one calls resource-hints and script-header and nothing else. */}} +{{ if and .IsHome site.Data.contributors site.Data.worldmap -}} +{{/* The cards on the map show contributors' avatars, hotlinked from GitHub — the one + third party the page talks to, and the only host beyond 'self' in the site's own + `img-src` (netlify.toml). Opening the connection here saves the DNS, TCP and TLS + round trips from the moment the first card is hovered. */}} +<link rel="preconnect" href="https://avatars.githubusercontent.com" crossorigin> +{{/* Where each city's hover target sits on the map, as a real stylesheet. + + The homepage draws one target per mark on top of its dot (see the + `.worldmap-hits` layer in _default/home.html), and each one needs its own + position and size — data only data/contributors.json has. A `style` attribute per + div is the obvious way to carry that and is not available: the site sends + `style-src 'self'`, which refuses inline styles and inline `<style>` blocks + alike. So the per-city values are built into a file served from this origin like + any other stylesheet, and the design itself stays in + assets/scss/common/_custom.scss. + + Positions and sizes come out of partials/worldmap-place.html, the same partial + the svg's dots are drawn from, because these two have to agree to the pixel. */}} +{{ $map := site.Data.worldmap -}} +{{ $rules := slice -}} +{{ range $i, $place := site.Data.contributors.places -}} +{{ $pos := partial "worldmap-place.html" (dict "map" $map "place" $place) -}} +{{/* Percentages of the layer, which is the svg's own box — so the targets track the + map through every width it renders at, exactly as the dots do. + + The diameter is 2.1 radii, the same generous target the svg's halo circle used to + be: the smallest dot is 6 units in a 1000-unit box, which is around 5px on screen + and not something to ask anyone to hit. */}} +{{ $left := div (mul 100.0 $pos.x) $map.width -}} +{{ $top := div (mul 100.0 $pos.y) $map.height -}} +{{ $size := div (mul 100.0 (mul 4.2 $pos.r)) $map.width -}} +{{/* How much room this mark's card has, as a percentage of the map's width: the + distance from the edge of the hover target to the edge of the map on whichever + side the card opens on (the same longitude test as the template's + `worldmap-hit-left`). The stylesheet reads it as container query units and caps + the card there, so a card can no longer cross the map's edge on a narrow screen. + + Without it the cards were sized only in rem and the worst case was a city near + the middle of the map: at 320px Cambridge's card reached 34px past the left edge, + and Dublin's past the right by enough to give the whole page a horizontal + scrollbar. The floor this puts under a card is half the map's width, which is the + room a mark at the centre has — around 130px at 320px wide, still a readable + column of faces. */}} +{{ $room := cond (gt $pos.x (mul 0.5 $map.width)) (sub $left (div $size 2.0)) (sub (sub 100.0 $left) (div $size 2.0)) -}} +{{ $rules = $rules | append (printf ".worldmap-hit:nth-child(%d){left:%.3f%%;top:%.3f%%;width:%.3f%%;--map-room:%.3f}" (add $i 1) $left $top $size $room) -}} +{{ end -}} +{{ $css := resources.FromString "css/worldmap-hits.css" (delimit $rules "\n") | resources.Minify | fingerprint -}} +<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}"> +{{ end -}} diff --git a/layouts/partials/worldmap-place.html b/layouts/partials/worldmap-place.html new file mode 100644 index 0000000..8782b7a --- /dev/null +++ b/layouts/partials/worldmap-place.html @@ -0,0 +1,25 @@ +{{/* Where one mark goes on the map, and how big it is. Returns `x`, `y` and `r` in + viewBox units for a place from data/contributors.json. + + A partial because two callers need the same three numbers and must agree on them + to the pixel: layouts/_default/home.html draws the dot in the svg, and + layouts/partials/head/resource-hints.html generates the CSS that puts each + card's hover target on top of it. Computed twice, a change to either would silently + separate the cards from the dots they belong to. + + Call as: + {{ $pos := partial "worldmap-place.html" (dict "map" $map "place" $place) }} */}} +{{- $map := .map -}} +{{- $place := .place -}} +{{/* The projection tools/worldmap.mjs drew the land with, equirectangular so that it + is these two lines rather than a JavaScript map library. */}} +{{- $x := mul $map.k (add $place.lng 180.0) -}} +{{- $y := mul $map.k (sub $map.latTop $place.lat) -}} +{{/* Area, not radius, carries the count: doubling the radius would draw a city of two + as four times the city of one. Offset rather than strictly proportional — the + first contributor buys a mark of a fixed size and each one after adds half that + mark's area again, which keeps Shanghai the largest dot without making it the + subject of the map. The long version of this reasoning is in home.html, beside + the dot it draws. */}} +{{- $r := mul 6.0 (math.Sqrt (add 1.0 (mul 0.5 (sub $place.count 1)))) -}} +{{- return dict "x" $x "y" $y "r" $r -}} diff --git a/tools/contributors.mjs b/tools/contributors.mjs index 36b6e5a..090210a 100644 --- a/tools/contributors.mjs +++ b/tools/contributors.mjs @@ -157,6 +157,15 @@ for (const repo of repos) { ); } +// `?s=` on an avatar URL asks GitHub for that many pixels square. Set through the +// URL rather than appended, so this cannot depend on the `?v=4` that happens to be +// on every avatar_url today. +function sized(url, size) { + const parsed = new URL(url); + parsed.searchParams.set("s", String(size)); + return parsed.toString(); +} + // Lowercased, trimmed, and with one space after each comma, so "Shenzhen,China", // "shenzhen, china" and "Shenzhen, China " are one key rather than three. const normalise = (value) => @@ -166,27 +175,29 @@ const places = new Map(); const unmatched = new Map(); let located = 0; -// Everyone, whether or not they could be placed. The region panels are built from -// this rather than from `places`, because the "Others" row has to list people too -// and they are precisely the ones no place could be found for. +// Everyone, whether or not they could be placed — the card on each mark is built +// from this, matched back to its place below. const people = []; for (const login of [...logins].sort()) { - const { location, name } = await github(`users/${login}`); + const { location, avatar_url: avatar } = await github(`users/${login}`); - // `country` stays null until a place is matched, and null is what puts someone in - // the "Others" panel: no location, a location the table has not learned, and a - // known non-place like "Remote" all end up here, which is exactly the set that + // `place` stays null until one is matched, and a null is somebody no card will + // ever list: no location, a location the table has not learned, and a known + // non-place like "Remote" all end up here, which is exactly the set that // `total - located` counts. const person = { login, - // The display name when the profile has one — "Se7en" reads as a person where - // "cr7258" reads as a handle — and the login when it does not. Both are kept: - // the name is for reading, the login is what identifies the account. - name: name?.trim() || login, + // Requested at a size rather than at whatever GitHub's default is (460px): the + // card draws these at 24px, and 23 cards' worth of full-size avatars is most of + // a megabyte to show a few dozen faces the size of a full stop. + avatar: sized(avatar, 64), + // Only the ranking, and only until the sort below — neither number reaches + // data/contributors.json. The card shows a face and a handle; a commit count + // beside them invited a reading of the map as a scoreboard. commits: commitsBy.get(login) ?? 0, repos: reposBy.get(login)?.size ?? 0, - country: null, + place: null, }; people.push(person); @@ -204,10 +215,10 @@ for (const login of [...logins].sort()) { // An explicit null: a known non-place, already decided about. if (place === null) continue; - // The region a panel will list them under. Taken from the place rather than from - // the label, so somebody on a country-level fallback still counts under their - // region after that mark has yielded to a city. - person.country = place.country; + // The mark whose card will list them. The label, because that is what identifies + // one mark on the map — and note that it can be a country-level fallback, which + // may not survive the pruning below. + person.place = place.label; const existing = places.get(place.label); if (existing) { @@ -273,25 +284,23 @@ for (const place of matched) { regionCounts.set(region, (regionCounts.get(region) ?? 0) + place.count); } -// Who is in each region, for the panel its legend row opens. Ranked by commits, then -// by how many of the seven projects they have touched, then by login so that two -// people with one commit each on one project do not swap places between runs. +// Who is at each mark, for the card it opens. Ordered by commits, then by how many of +// the seven projects they have touched, then by login so that two people with one +// commit each do not swap places between runs. // -// Commits are a rough measure of activity and worth being honest about: a one-line +// The order is all that survives: neither number is emitted. Commits are a rough +// measure of activity and a poor thing to publish beside someone's face — a one-line // addition to Awesome-LLMOps counts the same as a feature in llmaz, and reviewing, -// filing and answering are worth nothing here at all. `repos` is carried beside the -// count for exactly that reason — it separates one typo from work across projects, -// and it is the second thing the panel shows. +// filing and answering count for nothing at all. They are a defensible way to decide +// which five faces a card has room for, and not much more than that. const TOP = 5; -const regionPeople = new Map(); +const placePeople = new Map(); for (const person of people) { - const region = person.country - ? (regions[person.country] ?? "Unknown") - : "Others"; - if (!regionPeople.has(region)) regionPeople.set(region, []); - regionPeople.get(region).push(person); + if (!person.place) continue; + if (!placePeople.has(person.place)) placePeople.set(person.place, []); + placePeople.get(person.place).push(person); } -for (const list of regionPeople.values()) { +for (const list of placePeople.values()) { list.sort( (a, b) => b.commits - a.commits || @@ -300,20 +309,37 @@ for (const list of regionPeople.values()) { ); } -// The panel and the percentage beside it are counted two different ways — the -// percentage from the places on the map, the panel from the people behind them — so -// they can disagree, and a panel listing four people beside a row reading 29% would -// be a bug nobody would notice by looking. Checked rather than assumed. -for (const [region, count] of regionCounts) { - const listed = regionPeople.get(region)?.length ?? 0; - if (listed !== count) { +// A mark's count and the people its card lists are arrived at separately — the count +// by tallying places as they are matched, the card by grouping the people afterwards +// — so they can disagree, and a card showing four faces on a dot drawn for six would +// be a bug nobody would catch by looking at the map. Checked rather than assumed. +for (const place of places.values()) { + const listed = placePeople.get(place.label)?.length ?? 0; + if (listed !== place.count) { exit( - `region ${region} has ${count} contributor(s) by place but ${listed} by person` + - ` — the panel and the share are counting different things`, + `${place.label} is drawn for ${place.count} contributor(s) but ${listed} are listed` + + ` — the dot and its card are counting different things`, ); } } +// Everyone the table placed in a country whose fallback mark was then pruned: located, +// counted in their region's share, and on no dot — so no card lists them. Reported +// because it is otherwise invisible, and because the fix is a line in +// tools/locations.json for the city they are actually in. +const orphaned = people.filter( + (person) => person.place && !places.has(person.place), +); +if (orphaned.length) { + console.log( + `\n${orphaned.length} contributor(s) matched a country-level mark that a city has` + + ` since replaced, so no card lists them:`, + ); + for (const person of orphaned) { + console.log(` ${person.login} (${person.place})`); + } +} + // Shares of every contributor, as whole numbers that still add to 100. // // Rounding each share on its own does not: 15, 2, 1, 1 and 16 of 35 round to 43, @@ -383,7 +409,25 @@ const output = { // One mark each on the map. Largest first, so the template draws the big dots // before the small ones and a city of one is never hidden underneath a city of // six. Country-level fallbacks that have yielded to a city are already gone. - places: [...places.values()].sort((a, b) => b.count - a.count), + // + // `people` is the card the mark opens: a handle and an avatar each, capped at five + // because the card is a panel floating over the map and a mark of thirteen would + // cover the continent it sits on. `more` is what stops the cap from quietly hiding + // the rest. Most marks are one person, so most cards are one face — that is the + // shape of the contributor list, not a limitation of the card. + places: [...places.values()] + .sort((a, b) => b.count - a.count) + .map((place) => { + const list = placePeople.get(place.label) ?? []; + return { + ...place, + people: list.slice(0, TOP).map(({ login, avatar }) => ({ + login, + avatar, + })), + more: Math.max(0, list.length - TOP), + }; + }), // Marks that are actually cities, which is not `len places`: a country-level // fallback is a mark too. Only used by the map's accessible name, which called // India a city before this existed. @@ -397,25 +441,13 @@ const output = { // not rendered — `percent` is — but it stays so the file can be checked against // itself: the counts sum to `total`, and the percents to 100. // - // `top` and `more` are the panel each row opens on hover. Capped at five, because - // the panel is a card floating over the map rather than a page of its own, and a - // region of seventeen would cover the continent it belongs to; `more` is what stops - // the cap from quietly hiding the rest. - regions: ranked.map((region, index) => { - const list = regionPeople.get(region.name) ?? []; - return { - ...region, - percent: shares[index], - commits: list.reduce((sum, p) => sum + p.commits, 0), - top: list.slice(0, TOP).map(({ login, name, commits, repos }) => ({ - login, - name, - commits, - repos, - })), - more: Math.max(0, list.length - TOP), - }; - }), + // Shares only. The people are on the marks now, where the question "who is in + // Shanghai" is asked by pointing at Shanghai; a second list of them under a region + // heading was the same names twice, and the coarser of the two. + regions: ranked.map((region, index) => ({ + ...region, + percent: shares[index], + })), }; // Same data as last time, only a newer date: the file is left exactly as it is. From d2a708248a283652901edf91616343a5681b7ef6 Mon Sep 17 00:00:00 2001 From: kerthcet <kerthcet@gmail.com> Date: Sun, 6 Sep 2026 11:00:12 +0100 Subject: [PATCH 3/3] feat: link each face on the map to its GitHub profile, and list ten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every handle in a city's card is now a link to github.com/<login>, avatar and name together as one row-wide target. The url is built in the template from the handle the data already carries, so data/contributors.json holds no second copy of it. Making the card clickable meant giving it the pointer, which it did not have: - The card was `pointer-events: none`, so it could not be reached at all. That is now `auto`, and the cost is stated where it is paid — a card lying over a neighbouring dot shadows that dot while it is open. - The 4px gap between a dot and its card was 4px of dead space that closed the card halfway to a link. The card now starts flush against the target. - The target was a circle, and hit testing honours border-radius, so the walk from dot to card crossed transparent corners. It is a square now; the visible ring is the pseudo-element and has not changed. - Neighbouring targets stole the pointer mid-walk: Chengdu sits directly between Shanghai's dot and Shanghai's card, and being later in the document it won the hover. The mark being read now takes a z-index while hovered. The cap on a card goes from five faces to ten. Five cut the largest city on the map short by one, which is a cap earning nothing; ten clears every city today, so all 34 placed contributors are now on a card. The overflow row is an ellipsis in the avatar column with the count beside it — headroom, not something anyone sees at present, since the largest city has six. Verified at 1440: the pointer walk from dot into card holds the card open and lands on the link; 34 of 34 avatars load; every one of the 23 marks opens from the keyboard and every one of the 34 links holds its card open while focused. No card crosses a viewport edge at 320, 390, 1024 or 1440. Not fixed, and worth knowing: five marks — San Francisco, Guangzhou, Paris, Cambridge, Los Angeles — cannot be opened by aiming at their own dot, because a nearer, later-drawn city's target covers their centre. San Francisco and Palo Alto are 1.2 units apart on a 1000-unit map, so no target size separates them. The keyboard reaches all 23 regardless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- assets/scss/common/_custom.scss | 110 ++++++++++++++++++++++++++------ data/contributors.json | 6 +- hugo_stats.json | 1 - layouts/_default/home.html | 87 ++++++++++++++++++------- tools/contributors.mjs | 16 +++-- 5 files changed, 171 insertions(+), 49 deletions(-) diff --git a/assets/scss/common/_custom.scss b/assets/scss/common/_custom.scss index 0d9ba67..fa59c3b 100644 --- a/assets/scss/common/_custom.scss +++ b/assets/scss/common/_custom.scss @@ -344,9 +344,9 @@ $feature-gap: 1.25rem; // keyline that rings each dot, 1.9:1 against the land. WCAG 2.1 asks 3:1 of a // graphic needed to understand the content, and this no longer meets it. What makes // that a considered trade rather than an oversight is that nothing here is only on -// the map: every city is named, counted and staffed in the card that opens on its -// dot, and the region shares are the text list beside it. Going back to 72% -// (#E47351) restores 3.1:1 on the keyline. +// the map: every city is named in the card that opens on its dot, along with the +// people who are there, and the region shares are the text list beside it. Going +// back to 72% (#E47351) restores 3.1:1 on the keyline. $map-land: #e9edf2; $map-mark: mix($primary, #fff, 52%); // City labels and the legend's figures. Lighter than the body ink, which made both @@ -637,9 +637,16 @@ $map-pulse-offsets: position: absolute; // `left` and `top` from the generated stylesheet are the dot's centre. transform: translate(-50%, -50%); - // With `width` set per mark, this is the circle the old svg halo was. + // With `width` set per mark, this is the square the old svg halo's circle is drawn + // in — see `::before` below, which is the round part. aspect-ratio: 1; - border-radius: 50%; + // Square rather than round on purpose, now that the card holds links that have to be + // reached with a pointer. Hit testing honours `border-radius`, so a circle here left + // the corners transparent to the pointer, and the walk from the dot to its card + // crossed them: hover was lost halfway and the card closed under the cursor. A + // square box shares its whole near edge with the card, which starts at `100%` with + // no gap, so the path in is continuous at every height. It costs the corners of the + // target — invisible either way, since the ring below is still a circle. // Back on, against the layer's `none` above. pointer-events: auto; cursor: default; @@ -672,6 +679,18 @@ $map-pulse-offsets: opacity: $map-halo-alpha-hover; } +// The mark being read owns the pointer, over every other mark's target. These are +// absolutely positioned siblings, so the later one in the document was on top: walking +// from Shanghai's dot to Shanghai's card crossed Chengdu's target — one square of some +// 15px, at nearly the same latitude and directly under the path — and Chengdu, being +// later, took the hover. Shanghai's card closed and Chengdu's opened, halfway to a link +// that was about to be clicked. Only while hovered or held: at rest the marks are in +// document order, so nothing about which dot is on top of which has changed. +.worldmap-hit:hover, +.worldmap-hit:focus-within { + z-index: 4; +} + // The ring is the focus indicator's job here too, but on its own it is a 22% wash // that would not pass for one, so focus also gets an outline. On the card's side // rather than the dot's, because the dot is 5px across at its smallest. @@ -680,9 +699,9 @@ $map-pulse-offsets: outline-offset: 2px; } -// Who is at this mark: up to five handles with their avatars, and the city's own name -// and count as the heading — which is what the dots' native `<title>` tooltip used to -// carry, before this layer took the pointer. +// Who is at this mark: up to ten handles with their avatars, each a link to the +// profile, under the city's name — which is what the dots' native `<title>` tooltip +// used to carry, before this layer took the pointer. // // CSS only, on `:hover` and `:focus-within`. Every card is already in the page, so // this is a selector rather than a listener and it works with JavaScript off. The @@ -692,16 +711,24 @@ $map-pulse-offsets: .worldmap-city { position: absolute; z-index: 3; - // Beside the dot, and clear of the halo: the target is a circle of 2.1 radii, so - // `100%` here is already past the ring rather than past the dot. + // Beside the dot, and clear of the halo: the target is 2.1 radii wide, so `100%` here + // is already past the ring rather than past the dot. + // + // Flush against it, with no gap. There was a 0.25rem one, and it was 4px of dead + // space between the dot and its own card: the pointer crossing it lost the hover and + // the card began to close, which was survivable while nothing in the card could be + // clicked and is not now. The ring is a circle inside a square box, so it still meets + // the card's edge only at the centre line — the gap is gone from the hit testing, not + // from the picture. top: 50%; - left: calc(100% + 0.25rem); + left: 100%; transform: translateY(-50%); // 13rem where the map has room for it, and no wider than the room the mark actually // has where it does not. `--map-room` is set per mark by the generated stylesheet: // the percentage of the map's width between this dot and the edge the card opens // towards, and `1cqw` is one percent of that width (see `.worldmap-hits`). The - // 0.25rem is the gap to the dot, matched to `left` below. + // 0.25rem is slack, so the widest cards stop just short of the map's edge rather + // than exactly on it. // // The fixed width is declared first and deliberately kept: it is what a browser // without container query units falls back to, which is where every card was before @@ -735,15 +762,20 @@ $map-pulse-offsets: transition: opacity 0.15s ease, visibility 0s linear 0.15s; - // A card is a panel about the dot, not a second hover target: without this, a card - // covering a neighbouring dot would take that dot's hover as well. - pointer-events: none; + // The card takes the pointer, because the handles in it are links and a panel you + // cannot reach the links in is not one. This was `none` — a card was a panel about + // the dot and nothing more, so that a card lying over a neighbouring dot could not + // take that dot's hover. That is the cost now paid: a card covering a neighbour + // shadows it for as long as it is open, and since it closes when the pointer leaves + // it, the neighbour needs one more move of the mouse to answer. Only ever a + // neighbour of the city you are already reading, and only while you are reading it. + pointer-events: auto; } // East of the middle, the card opens the other way — see the template. .worldmap-hit-left .worldmap-city { left: auto; - right: calc(100% + 0.25rem); + right: 100%; } .worldmap-hit:hover .worldmap-city, @@ -767,10 +799,36 @@ $map-pulse-offsets: list-style: none; li { + padding: 0.125rem 0; + } + + // The link is the row, not a word in it: the face and the handle are one target, and + // the flexbox that used to be the `li` lives here so the target is the whole line + // rather than 13px of text. + a { display: flex; align-items: center; gap: 0.5rem; - padding: 0.125rem 0; + // Ink, not link colour. Ten orange handles inside a small white card over a map of + // orange dots would have been the loudest thing in the section, and every row here + // is a link — so nothing is distinguished by colouring them all. What marks them is + // the underline on hover and the cursor, which is what a row this size can carry. + color: inherit; + text-decoration: none; + + &:hover, + &:focus-visible { + color: inherit; + text-decoration: underline; + } + + // Keyboard reaches these: the card opens on `:focus-within`, so tabbing into it is + // how it is read without a pointer, and each row has to say which one it is on. + &:focus-visible { + outline: 2px solid $map-mark; + outline-offset: 2px; + border-radius: 0.25rem; + } } } @@ -794,13 +852,27 @@ $map-pulse-offsets: text-overflow: ellipsis; } -// What the cap leaves out. Lighter than the handles, because it is not one of them. +// What the cap leaves out. Lighter than the handles, because it is not one of them — +// and laid out like them, so the ellipsis sits in the column the faces are in and the +// row continues the list instead of annotating it. .worldmap-city-more { - margin: 0.375rem 0 0; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.125rem 0 0; + padding: 0.125rem 0; font-size: 0.75rem; color: $map-text; } +// Standing in for the avatar that would have been here: same 24px circle, the map's own +// land as its fill, so the column of faces continues to the end of the card. +.worldmap-city-more-icon { + flex: 0 0 auto; + border-radius: 50%; + background: $map-land; +} + // The legend beside the map: each region and its share of all contributors. .worldmap-summary { // Full width while stacked, so the rules under each row start and end where the diff --git a/data/contributors.json b/data/contributors.json index 55d0490..d116bba 100644 --- a/data/contributors.json +++ b/data/contributors.json @@ -35,9 +35,13 @@ { "login": "rudeigerc", "avatar": "https://avatars.githubusercontent.com/u/18243819?v=4&s=64" + }, + { + "login": "caozhuozi", + "avatar": "https://avatars.githubusercontent.com/u/68834160?v=4&s=64" } ], - "more": 1 + "more": 0 }, { "label": "San Francisco, USA", diff --git a/hugo_stats.json b/hugo_stats.json index 0704f8c..93df9d8 100644 --- a/hugo_stats.json +++ b/hugo_stats.json @@ -182,7 +182,6 @@ "worldmap-city", "worldmap-city-head", "worldmap-city-login", - "worldmap-city-more", "worldmap-city-people", "worldmap-dot", "worldmap-hit", diff --git a/layouts/_default/home.html b/layouts/_default/home.html index 7a2bb35..d2dd674 100644 --- a/layouts/_default/home.html +++ b/layouts/_default/home.html @@ -57,8 +57,8 @@ <h2 class="feature-grid-title">Contributors</h2> Strictly proportional area — 6·√count — is where this started, and it drew Shanghai at a radius of 14.7 against 6 for a city of one. Rendered, that is not a bigger dot, it is the subject - of the map: six of thirty-five contributors took the eye and - the other nine cities read as its outliers. So the area is + of the map: six of the thirty-six contributors it can place took + the eye and the other cities read as its outliers. So the area is offset rather than proportional. The first contributor buys a mark of a fixed size, and each one after adds half that mark's area again — Shanghai lands at 11.2, still unmistakably the @@ -66,9 +66,9 @@ <h2 class="feature-grid-title">Contributors</h2> That is a compressed scale and it should be said plainly: these dots are comparable, not measurable. Nothing here rests - on reading a count out of a radius — every city's exact number - is in the card that opens on it, and the shares are the text - list beside the map. + on reading a count out of a radius — the card that opens on a + city lists who is there, so its exact number can be counted off + the faces, and the shares are the text list beside the map. The compression is at the top rather than in the base, because the base is set from the narrowest case. These are viewBox @@ -84,7 +84,7 @@ <h2 class="feature-grid-title">Contributors</h2> <g class="worldmap-place"> {{/* No `<title>` here any more. It was a native tooltip naming the city and its count, and the card in the layer below now sits - over the dot and says the same thing plus who is there — so + over the dot, names the city and lists who is there — so the tooltip could no longer be triggered by a pointer that never reaches the svg, and a title nothing can open is worse than none. Nothing is lost to a screen reader either: this @@ -144,13 +144,18 @@ <h2 class="feature-grid-title">Contributors</h2> `.places`. This layer takes the pointer for the whole map, so it is also what - replaced the dots' `<title>` tooltips — the card names the city and - counts it, which is what the tooltip did. + replaced the dots' `<title>` tooltips — the card names the city, + which is what the tooltip did. `tabindex` so the cards can be opened without a pointer. It is the only route to these names for a keyboard or a screen reader, since - the svg is one `role="img"` graphic — at the cost of one tab stop - per city between the map and the projects below it. */}} + the svg is one `role="img"` graphic — at the cost of tab stops between + the map and the projects below it: one per city to open its card, and + then one per handle in the card that opens, since those are links to + the profiles. Fifty-seven of them today. The city's own stop cannot be + dropped in favour of the links inside it: the card is `visibility: + hidden` until something in it has focus, and a hidden link is not + focusable, so without a stop on the mark itself there is no way in. */}} <div class="worldmap-hits"> {{ range .places -}} {{ $pos := partial "worldmap-place.html" (dict "map" $map "place" .) -}} @@ -161,25 +166,61 @@ <h2 class="feature-grid-title">Contributors</h2> <div class="worldmap-hit{{ if gt $pos.x (mul 0.5 $map.width) }} worldmap-hit-left{{ end }}" tabindex="0"> <div class="worldmap-city"> - <p class="worldmap-city-head">{{ .label }} — {{ .count }} - contributor{{ if ne (int .count) 1 }}s{{ end }}</p> + {{/* The place, and only the place. The count was here — "Shanghai, + China — 6 contributors" — and it said twice what the card + already shows: the faces below are the count, and the dot's + size is what carries it on the map. */}} + <p class="worldmap-city-head">{{ .label }}</p> <ul class="worldmap-city-people"> {{ range .people -}} <li> - {{/* Empty `alt`: the handle is right beside it, so a - screen reader announcing the avatar as well would - say the name twice. `lazy` and the `s=64` in the - url are what keep 33 faces off the critical path - — see tools/contributors.mjs. */}} - <img class="worldmap-avatar" src="{{ .avatar }}" alt="" width="24" height="24" - loading="lazy" decoding="async"> - <span class="worldmap-city-login">{{ .login }}</span> + {{/* Each row links to the profile. The url is built here + rather than carried in data/contributors.json: it is + github.com plus the handle, which the data already + has, so storing it would be a second copy of the + same fact to keep in step. + + The whole row is the link — avatar and handle both — + so the target is the 24px face and its name rather + than a line of 13px text. Same tab, like every other + link on this page. */}} + <a href="https://github.com/{{ .login }}"> + {{/* Empty `alt`: the handle is right beside it, so a + screen reader announcing the avatar as well would + say the name twice — and the link is not left + nameless by that, it is named by the handle it + wraps. `lazy` and the `s=64` in the url are what + keep 34 faces off the critical path — see + tools/contributors.mjs. */}} + <img class="worldmap-avatar" src="{{ .avatar }}" alt="" width="24" height="24" + loading="lazy" decoding="async"> + <span class="worldmap-city-login">{{ .login }}</span> + </a> </li> {{ end -}} </ul> - {{/* Five faces is what the card has room for; this is what says - the mark stands for more than that. */}} - {{ with .more }}<p class="worldmap-city-more">+{{ . }} more</p>{{ end }} + {{/* Ten faces is what the card has room for; this is what says the + mark stands for more than that. Nothing renders it today — + the largest city on the map has six — so it is the cap's + overflow behaviour rather than a row anyone currently sees. + + An ellipsis where the eleventh avatar would be, sized and + aligned to the faces above it so the row reads as the + continuation of the list rather than a note under it. The + count is text beside the icon: an icon alone would say there + are more without saying how many, and how many is the only + thing this row knows. */}} + {{ with .more }} + <p class="worldmap-city-more"> + <svg class="worldmap-city-more-icon" width="24" height="24" viewBox="0 0 24 24" + fill="currentColor" stroke="none" aria-hidden="true"> + <circle cx="6" cy="12" r="1.75" /> + <circle cx="12" cy="12" r="1.75" /> + <circle cx="18" cy="12" r="1.75" /> + </svg> + <span>{{ . }} more</span> + </p> + {{ end }} </div> </div> {{ end -}} diff --git a/tools/contributors.mjs b/tools/contributors.mjs index 090210a..cfe90da 100644 --- a/tools/contributors.mjs +++ b/tools/contributors.mjs @@ -292,8 +292,14 @@ for (const place of matched) { // measure of activity and a poor thing to publish beside someone's face — a one-line // addition to Awesome-LLMOps counts the same as a feature in llmaz, and reviewing, // filing and answering count for nothing at all. They are a defensible way to decide -// which five faces a card has room for, and not much more than that. -const TOP = 5; +// which ten faces a card has room for, and not much more than that. +// +// Ten, not the five this started at: five cut the largest city on the map short by one +// face, which is a cap earning nothing. Ten clears every city today — the largest is +// six — so the ordering above decides nothing at present and the cap is headroom. It is +// still a cap because a card is a card: ten rows is already 300px of it, and a city that +// ever passes ten is a city whose card has to say "and more" rather than grow. +const TOP = 10; const placePeople = new Map(); for (const person of people) { if (!person.place) continue; @@ -410,9 +416,9 @@ const output = { // before the small ones and a city of one is never hidden underneath a city of // six. Country-level fallbacks that have yielded to a city are already gone. // - // `people` is the card the mark opens: a handle and an avatar each, capped at five - // because the card is a panel floating over the map and a mark of thirteen would - // cover the continent it sits on. `more` is what stops the cap from quietly hiding + // `people` is the card the mark opens: a handle and an avatar each, capped at ten + // because the card is a panel floating over the map and a mark of thirty would cover + // the continent it sits on. `more` is what stops the cap from quietly hiding // the rest. Most marks are one person, so most cards are one face — that is the // shape of the contributor list, not a limitation of the card. places: [...places.values()]