diff --git a/assets/scss/common/_custom.scss b/assets/scss/common/_custom.scss index e151333..fa59c3b 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 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 @@ -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,273 @@ $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 square the old svg halo's circle is drawn + // in — see `::before` below, which is the round part. + aspect-ratio: 1; + // 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; + + // 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 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. +.worldmap-hit:focus-visible { + outline: 2px solid $map-mark; + outline-offset: 2px; +} + +// 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. // -// 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 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: 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 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 + // 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; + // 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: 100%; +} + +.worldmap-hit:hover .worldmap-city, +.worldmap-hit:focus-within .worldmap-city { + opacity: 1; + visibility: visible; + transition: + opacity 0.15s ease, + visibility 0s; } -// 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-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 { + 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; + // 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; + } } } +.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 — +// 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 { + 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 aa53533..d116bba 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": [ @@ -14,7 +14,34 @@ "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" + }, + { + "login": "caozhuozi", + "avatar": "https://avatars.githubusercontent.com/u/68834160?v=4&s=64" + } + ], + "more": 0 }, { "label": "San Francisco, USA", @@ -27,7 +54,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 +83,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 +107,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 +131,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 +155,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 +179,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 +199,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 +219,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 +239,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 +259,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 +279,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 +299,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 +320,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 +340,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 +360,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 +380,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 +400,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 +420,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 +440,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 +460,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 +480,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 +500,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, diff --git a/hugo_stats.json b/hugo_stats.json index 0090be9..93df9d8 100644 --- a/hugo_stats.json +++ b/hugo_stats.json @@ -19,6 +19,7 @@ "head", "header", "html", + "img", "input", "kbd", "label", @@ -177,8 +178,15 @@ "title-submitted", "visually-hidden", "worldmap", + "worldmap-avatar", + "worldmap-city", + "worldmap-city-head", + "worldmap-city-login", + "worldmap-city-people", "worldmap-dot", - "worldmap-halo", + "worldmap-hit", + "worldmap-hit-left", + "worldmap-hits", "worldmap-label", "worldmap-land", "worldmap-layout", diff --git a/layouts/_default/home.html b/layouts/_default/home.html index 64a7654..d2dd674 100644 --- a/layouts/_default/home.html +++ b/layouts/_default/home.html @@ -46,19 +46,19 @@ <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, 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 its `<title>` tooltip, 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 @@ -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, 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 + 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,103 @@ <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, + 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 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" .) -}} + {{/* 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"> + {{/* 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> + {{/* 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> + {{/* 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 -}} + </div> </figure> {{/* The legend the dots need: a dot's size is comparable but not readable, 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 12d1854..cfe90da 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` + @@ -148,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) => @@ -157,8 +175,32 @@ const places = new Map(); const unmatched = new Map(); let located = 0; +// 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 } = await github(`users/${login}`); + const { location, avatar_url: avatar } = await github(`users/${login}`); + + // `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, + // 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, + place: null, + }; + people.push(person); + if (!location || !location.trim()) continue; const key = normalise(location); @@ -173,6 +215,11 @@ for (const login of [...logins].sort()) { // An explicit null: a known non-place, already decided about. if (place === null) continue; + // 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) { existing.count += 1; @@ -237,6 +284,68 @@ for (const place of matched) { regionCounts.set(region, (regionCounts.get(region) ?? 0) + place.count); } +// 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. +// +// 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 count for nothing at all. They are a defensible way to decide +// 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; + if (!placePeople.has(person.place)) placePeople.set(person.place, []); + placePeople.get(person.place).push(person); +} +for (const list of placePeople.values()) { + list.sort( + (a, b) => + b.commits - a.commits || + b.repos - a.repos || + a.login.localeCompare(b.login), + ); +} + +// 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( + `${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, @@ -306,7 +415,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 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()] + .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. @@ -319,6 +446,10 @@ 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. + // + // 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],