From f972324f02ff69e609ac7e19adbc0364122090d2 Mon Sep 17 00:00:00 2001 From: hahwul Date: Sat, 1 Aug 2026 19:32:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20three=20new=20styles=20=E2=80=94=20cons?= =?UTF-8?q?tellation,=20skyline,=20and=20metro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit constellation draws a night sky: rank sets each star's size and glow, near neighbours join into constellations over a pruned spanning tree, and two depths of hash-scattered dust fill the dark between them. skyline draws a city: weight sets each building's height through a dense rank over distinct weights, the avatar rides the roofline, and each building draws its roof silhouette, wall width, and glazing from a deterministic lottery. metro draws a transit map: stations on a route that snakes across the wall, one line per section — or one per role with `role_lines`, and `weave` interleaves those lines' rows so the routes cross one another like a real network, with every rail landing midway between station columns so no crossing touches a ring. constellation and skyline honour per-user `scale` (skyline in height); metro is a fixed lattice and ignores it. The shared hash-noise mixers now live once in the base renderer instead of per style. Review fixes folded in: a sparse constellation no longer scatters stars past its own document, metro sizes its document for role titles and the weave legend, the turn margin funds the stroke it draws, the station pitch funds a terminus ring on both sides, woven rails clear station labels, and the weave stagger no longer repeats verbatim every third line. --- CHANGELOG.md | 15 + CONTRIBUTING.md | 2 +- README.md | 134 +++++- examples/constellation.svg | 129 +++++ examples/grid.svg | 2 +- examples/honeycomb.svg | 2 +- examples/metro.svg | 77 +++ examples/mosaic.svg | 2 +- examples/orbit.svg | 30 +- examples/showcase.yml | 19 + examples/skyline.svg | 361 ++++++++++++++ examples/spiral.svg | 26 +- examples/stencil.svg | 2 +- examples/voronoi.svg | 2 +- spec/examples_spec.cr | 2 +- spec/fixtures/golden/constellation.svg | 133 ++++++ spec/fixtures/golden/metro.svg | 53 +++ spec/fixtures/golden/skyline.svg | 384 +++++++++++++++ spec/renderers/constellation_spec.cr | 188 ++++++++ spec/renderers/metro_spec.cr | 249 ++++++++++ spec/renderers/radial_spec.cr | 2 +- spec/renderers/skyline_spec.cr | 174 +++++++ spec/svg_spec.cr | 2 +- src/contributor_mural.cr | 3 + src/contributor_mural/config.cr | 119 ++++- .../renderers/constellation.cr | 299 ++++++++++++ src/contributor_mural/renderers/metro.cr | 448 ++++++++++++++++++ src/contributor_mural/renderers/renderer.cr | 50 +- src/contributor_mural/renderers/skyline.cr | 351 ++++++++++++++ src/contributor_mural/renderers/voronoi.cr | 12 - src/contributor_mural/runner.cr | 2 +- 31 files changed, 3204 insertions(+), 70 deletions(-) create mode 100644 examples/constellation.svg create mode 100644 examples/metro.svg create mode 100644 examples/skyline.svg create mode 100644 spec/fixtures/golden/constellation.svg create mode 100644 spec/fixtures/golden/metro.svg create mode 100644 spec/fixtures/golden/skyline.svg create mode 100644 spec/renderers/constellation_spec.cr create mode 100644 spec/renderers/metro_spec.cr create mode 100644 spec/renderers/skyline_spec.cr create mode 100644 src/contributor_mural/renderers/constellation.cr create mode 100644 src/contributor_mural/renderers/metro.cr create mode 100644 src/contributor_mural/renderers/skyline.cr diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e022a..518ec4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and the generated files. ## Unreleased +### Added + +- Three new styles. `constellation` draws a night sky: rank sets each star's + size and glow, near neighbours join up into constellations, and two depths + of dust fill the dark between them. `skyline` draws a city: weight sets + each building's height, the avatar sits up top like a rooftop billboard, + and each building draws one of six roof silhouettes, a wall width, and a + glazing for its lit windows from a deterministic lottery. `metro` draws a + transit map: stations on a coloured route that snakes across the wall, one + line per section — or one per role with `role_lines`, and `weave` makes + those lines interleave and cross one another like a real network — with + the heavier terminus rings at both ends. `constellation` and `skyline` + honour per-user `scale` (skyline in height); `metro` is a fixed lattice + and ignores it. + ### Fixed - The `svg_path` output is emitted again. It is declared in `action.yml` and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1440d14..21c5cf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ just render # run a config through the action image, committing nothing Every image in the README is generated from a committed config, and the YAML snippet next to an image is expected to match that config verbatim — so a snippet cannot -describe something the renderer does not do. `examples/showcase.yml` produces the seven +describe something the renderer does not do. `examples/showcase.yml` produces the ten style heroes; `examples/variants/*.yml` produce the per-option comparisons, one file per variant (the per-style blocks are global, so `shape: circle` and `shape: square` cannot share a run). diff --git a/README.md b/README.md index 3a7f8c6..d42c326 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,10 @@ A GitHub Action that renders your users, your contributors, or both into embeddable SVG art and commits it to your repository. -- **Seven styles** — a classic grid, honeycomb hexagons, a weight-tiered mosaic, a +- **Ten styles** — a classic grid, honeycomb hexagons, a weight-tiered mosaic, a golden-angle spiral, an orbit with your lead contributor at its centre, a - stained-glass voronoi, and a stencil that spells a word out of faces. + stained-glass voronoi, a stencil that spells a word out of faces, a night-sky + constellation, a city skyline, and a transit-map metro. - **Many sources, one mural** — your curated `users` list, repository contributors, org members, stargazers, and GitHub Sponsors (tier amounts become weights). Write a source to enable it; everything merges, and your YAML entries always win. @@ -37,7 +38,7 @@ SVG art and commits it to your repository. ## Contents - [Quick start](#quick-start) -- [Styles](#styles) — [grid](#grid) · [honeycomb](#honeycomb) · [mosaic](#mosaic) · [spiral](#spiral) · [orbit](#orbit) · [voronoi](#voronoi) · [stencil](#stencil) +- [Styles](#styles) — [grid](#grid) · [honeycomb](#honeycomb) · [mosaic](#mosaic) · [spiral](#spiral) · [orbit](#orbit) · [voronoi](#voronoi) · [stencil](#stencil) · [constellation](#constellation) · [skyline](#skyline) · [metro](#metro) - [Theme](#theme) - [Sections and roles](#sections-and-roles) - [Emphasising a person](#emphasising-a-person) @@ -314,6 +315,88 @@ only what your crowd covers. Size the word to your crowd: the block is the same size whether one person or six hundred show up, so a long word with few contributors is mostly ghosts. +### Constellation + +A night sky: every contributor is a star whose size and glow follow their rank, near +neighbours join up into constellations, and tiny dust stars fill the dark between them. +Best over a dark background — try `preset: midnight`. + +```yaml +style: constellation +constellation: + width: 480 + max_size: 72 + min_size: 28 +``` + +![constellation](https://raw.githubusercontent.com/crystal-actions/contributor-mural/main/examples/constellation.svg) + +| Option | Default | Accepts | +| ------ | ------- | ------- | +| `width` | `720` | 64–8000, and at least `max_size` + `gap` | +| `max_size` | `64` | 8–512 (the brightest star) | +| `min_size` | `20` | 8–512, must be ≤ `max_size` (the faintest) | +| `gap` | `12` | 0–200 — the least clearance between any two stars | +| `jitter` | `0.8` | 0–1, how far a star wanders inside its own cell | +| `lines` | `true` | the thin lines joining near neighbours into constellations | +| `dust` | `4` | 0–32 background dust stars per contributor; `0` clears the sky | + +### Skyline + +A city skyline: each contributor is a building whose height follows their weight — the +people carrying the project are the towers — with the avatar up top like a rooftop +billboard and a grid of lit windows below it. Daytime silhouette on light walls, dusk +city on dark ones. + +```yaml +style: skyline +skyline: + width: 520 + avatar_size: 48 +``` + +![skyline](https://raw.githubusercontent.com/crystal-actions/contributor-mural/main/examples/skyline.svg) + +| Option | Default | Accepts | +| ------ | ------- | ------- | +| `width` | `800` | 64–8000; buildings wrap into further rows past it | +| `avatar_size` | `48` | 8–512 | +| `min_height` | `96` | 28–1024, and at least `avatar_size` + 20 | +| `max_height` | `220` | 28–1024, must be ≥ `min_height` (the top contributor's tower) | +| `gap` | `6` | 0–200 | +| `shape` | `rounded` | `circle`, `rounded`, `square` | +| `windows` | `true` | the lit-and-dark window panes | +| `show_names` | `false` | draws the name under each building | +| `truncate` | `10` | max name length; `0` disables truncation | + +### Metro + +A transit map: contributors are stations on a coloured route that snakes across the +wall, with the heavier terminus rings at both ends of the line. Each section is its own +line in its own colour — the section title reads as the line's name — and `role_lines` +splits a section further into one line per role, named after it. Add `weave` and those +lines interleave and cross one another, the way a real network does. + +```yaml +style: metro +metro: + columns: 4 + station_size: 52 +``` + +![metro](https://raw.githubusercontent.com/crystal-actions/contributor-mural/main/examples/metro.svg) + +| Option | Default | Accepts | +| ------ | ------- | ------- | +| `columns` | `6` | 1–100 (stations per row before the line turns) | +| `station_size` | `56` | 8–512 | +| `line_width` | `8` | 2–64, at most half of `station_size` | +| `gap` | `24` | 0–200 (clearance between station rings) | +| `role_lines` | `false` | one line per role, named after it; the unroled ride an unnamed line | +| `weave` | `false` | interleave the role lines so their routes cross; needs `gap` ≥ 2.5 × `line_width` | +| `show_names` | `true` | draws the name under each station | +| `truncate` | `10` | max name length; `0` disables truncation | + ## Theme Four presets, each a light/dark palette pair: @@ -425,7 +508,9 @@ and a huge commit count still only means "first". `scale` names the person inste | `mosaic` | multiplies the tier span and rounds, ties up — `1.5` turns a 1-cell tier into a 2×2 cell, without moving a boundary everyone in that tier shares | | `spiral` | multiplies the avatar size; the bloom re-packs so the larger avatar keeps a full `gap` from its neighbours | | `orbit` | multiplies the avatar size; its ring holds fewer people and sits further out to make room | -| `grid`, `honeycomb`, `stencil` | ignored — in a fixed lattice a larger avatar either overlaps its neighbours or leaves a hole | +| `constellation` | multiplies the star size; the sky keeps everyone a full `gap` apart around it | +| `skyline` | multiplies the building's height — the emphasised tower rises above the wall; the avatar keeps its size | +| `grid`, `honeycomb`, `stencil`, `metro` | ignored — in a fixed lattice a larger avatar either overlaps its neighbours or leaves a hole | | `voronoi` | ignored — cells are cut out of the block rather than placed, so there is no per-user size to multiply | A `scale` the chosen style cannot honour is reported as a workflow warning rather than @@ -441,7 +526,8 @@ users: # your curated list — always wins on conflicts - login: hahwul name: HAHWUL # optional display name (default: login) weight: 10 # optional, drives mosaic/voronoi sizing and weight sort - scale: 1.6 # optional 1–2 size multiplier (mosaic, spiral, orbit) + scale: 1.6 # optional 1–2 size multiplier (mosaic, spiral, orbit, + # constellation, skyline) role: Creator # optional label under the name group: Contributors # optional section link: https://hahwul.com # optional (default: the GitHub profile) @@ -608,7 +694,8 @@ on a fractional one. Every key on one page ```yaml -style: grid # grid | honeycomb | mosaic | spiral | orbit | voronoi | stencil +style: grid # grid | honeycomb | mosaic | spiral | orbit | voronoi | + # stencil | constellation | skyline | metro output: CONTRIBUTOR_MURAL.svg # path relative to the repository root # --- Sources: write a block to enable it; results are merged --- @@ -618,7 +705,8 @@ users: # your curated list name: HAHWUL # optional display name (default: login) weight: 10 # optional, drives mosaic sizing + weight sort scale: 1.6 # optional 1–2 size multiplier for this person alone; - # honoured by mosaic, spiral, and orbit + # honoured by mosaic, spiral, orbit, constellation, + # and skyline role: Creator # optional label under the name (grid) / in tooltips group: Contributors # optional section this user renders in link: https://hahwul.com # optional (default: the GitHub profile) @@ -724,6 +812,36 @@ stencil: # avatars fill the pixels of a word shape: circle # circle | rounded | square ghosts: true # faint dots on the pixels nobody has filled yet +constellation: # a night sky; rank sets each star's size and glow + width: 720 + max_size: 64 # the brightest star + min_size: 20 # the faintest + gap: 12 # the least clearance between any two stars + jitter: 0.8 # 0..1, how far a star wanders inside its cell + lines: true # join near neighbours into constellations + dust: 4 # background dust stars per contributor (0 = none) + +skyline: # a city; weight sets each building's height + width: 800 # buildings wrap into further rows past this + avatar_size: 48 + min_height: 96 # at least avatar_size + 20 + max_height: 220 # the top contributor's tower + gap: 6 + shape: rounded # circle | rounded | square + windows: true # the lit-and-dark window panes + show_names: false + truncate: 10 + +metro: # a transit map; each section is its own line + columns: 6 # stations per row before the line turns + station_size: 56 + line_width: 8 # at most half of station_size + gap: 24 # clearance between station rings + role_lines: false # split each section into one line per role + weave: false # interleave the role lines so their routes cross + show_names: true + truncate: 10 + theme: preset: github # github | midnight | paper | mono mode: auto # auto (follows the viewer's dark mode) | light | dark @@ -856,7 +974,7 @@ cannot drift from what the renderer does. To regenerate them (needs network — come from github.com): ```bash -bin/contributor-mural -c examples/showcase.yml # the seven style heroes +bin/contributor-mural -c examples/showcase.yml # the ten style heroes for f in examples/variants/*.yml; do bin/contributor-mural -c "$f"; done ``` diff --git a/examples/constellation.svg b/examples/constellation.svg new file mode 100644 index 0000000..e8f9292 --- /dev/null +++ b/examples/constellation.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HAHWUL (@hahwul) + + + + Linus Torvalds (@torvalds) + + + + Matz (@matz) + + + + The Octocat (@octocat) + + + + sindresorhus + + + + tj + + + + antirez + + + + dhh + + + + mitchellh + + + + kentcdodds + + + + rauchg + + + + defunkt + + + diff --git a/examples/grid.svg b/examples/grid.svg index 701dbce..e9d4e19 100644 --- a/examples/grid.svg +++ b/examples/grid.svg @@ -43,7 +43,7 @@ mitchellh - + mitchellh diff --git a/examples/honeycomb.svg b/examples/honeycomb.svg index 1640042..71febf9 100644 --- a/examples/honeycomb.svg +++ b/examples/honeycomb.svg @@ -35,7 +35,7 @@ mitchellh - + kentcdodds diff --git a/examples/metro.svg b/examples/metro.svg new file mode 100644 index 0000000..4cf9621 --- /dev/null +++ b/examples/metro.svg @@ -0,0 +1,77 @@ + + + + + + HAHWUL (@hahwul) + + + HAHWUL + + + Linus Torvalds (@torvalds) + + + Linus Tor… + + + Matz (@matz) + + + Matz + + + The Octocat (@octocat) + + + The Octoc… + + + sindresorhus + + + sindresor… + + + tj + + + tj + + + antirez + + + antirez + + + dhh + + + dhh + + + mitchellh + + + mitchellh + + + kentcdodds + + + kentcdodds + + + rauchg + + + rauchg + + + defunkt + + + defunkt + + diff --git a/examples/mosaic.svg b/examples/mosaic.svg index 19157c8..abe7033 100644 --- a/examples/mosaic.svg +++ b/examples/mosaic.svg @@ -35,7 +35,7 @@ mitchellh - + kentcdodds diff --git a/examples/orbit.svg b/examples/orbit.svg index 1780141..f82e155 100644 --- a/examples/orbit.svg +++ b/examples/orbit.svg @@ -1,54 +1,54 @@ - + - - + + HAHWUL (@hahwul) - + Linus Torvalds (@torvalds) - + Matz (@matz) - + The Octocat (@octocat) - + sindresorhus - + tj - + antirez - + dhh - + mitchellh - + kentcdodds - + rauchg - + defunkt - + diff --git a/examples/showcase.yml b/examples/showcase.yml index 43dd7f6..0ae7df4 100644 --- a/examples/showcase.yml +++ b/examples/showcase.yml @@ -47,6 +47,12 @@ outputs: style: voronoi - path: examples/stencil.svg style: stencil + - path: examples/constellation.svg + style: constellation + - path: examples/skyline.svg + style: skyline + - path: examples/metro.svg + style: metro grid: columns: 6 @@ -82,3 +88,16 @@ stencil: text: THANKS pixel_size: 14 gap: 2 + +constellation: + width: 480 + max_size: 72 + min_size: 28 + +skyline: + width: 520 + avatar_size: 48 + +metro: + columns: 4 + station_size: 52 diff --git a/examples/skyline.svg b/examples/skyline.svg new file mode 100644 index 0000000..2b1a404 --- /dev/null +++ b/examples/skyline.svg @@ -0,0 +1,361 @@ + + + + + + HAHWUL (@hahwul) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Linus Torvalds (@torvalds) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Matz (@matz) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The Octocat (@octocat) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sindresorhus + + + + + + + + + + + + + + + + + + + + + + + + + + + + tj + + + + + + + + + + + + + + + + + + + + + + + + + + + + + antirez + + + + + + + + + + + + + + + + + + + + + + + + + dhh + + + + + + + + + + + + + + + + + + + + + + + + + mitchellh + + + + + + + + + + + + + + + + + + + kentcdodds + + + + + + + + + + + + + + + + + + + + + rauchg + + + + + + + + + + + + + + + + + + + + + defunkt + + + + + + + + + + + + + + + + diff --git a/examples/spiral.svg b/examples/spiral.svg index 2dd45db..fbc8833 100644 --- a/examples/spiral.svg +++ b/examples/spiral.svg @@ -1,52 +1,52 @@ - + HAHWUL (@hahwul) - + Linus Torvalds (@torvalds) - + Matz (@matz) - + The Octocat (@octocat) - + sindresorhus - + tj - + antirez - + dhh - + mitchellh - + kentcdodds - + rauchg - + defunkt - + diff --git a/examples/stencil.svg b/examples/stencil.svg index 988f434..69d79a0 100644 --- a/examples/stencil.svg +++ b/examples/stencil.svg @@ -118,7 +118,7 @@ mitchellh - + kentcdodds diff --git a/examples/voronoi.svg b/examples/voronoi.svg index 52189d5..c599370 100644 --- a/examples/voronoi.svg +++ b/examples/voronoi.svg @@ -48,7 +48,7 @@ mitchellh - + kentcdodds diff --git a/spec/examples_spec.cr b/spec/examples_spec.cr index 95d6ff0..01be44c 100644 --- a/spec/examples_spec.cr +++ b/spec/examples_spec.cr @@ -33,7 +33,7 @@ private def readme_configs(readme : String) : Array({Int32, String}) end workflow = /^\s*(uses|runs-on|steps|jobs|permissions):/m - mural = /^(style|output|outputs|users|groups|contributors|members|stargazers|sponsors|exclude|sort|limit|fail_on_missing|grid|honeycomb|mosaic|spiral|orbit|voronoi|stencil|theme|png):/m + mural = /^(style|output|outputs|users|groups|contributors|members|stargazers|sponsors|exclude|sort|limit|fail_on_missing|grid|honeycomb|mosaic|spiral|orbit|voronoi|stencil|constellation|skyline|metro|theme|png):/m blocks.select { |(_line, text)| text.matches?(mural) && !text.matches?(workflow) } end diff --git a/spec/fixtures/golden/constellation.svg b/spec/fixtures/golden/constellation.svg new file mode 100644 index 0000000..54b4e42 --- /dev/null +++ b/spec/fixtures/golden/constellation.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + user000 + + + + user001 + + + + user002 + + + + user003 + + + + user004 + + + + user005 + + + + user006 + + + + user007 + + + + user008 + + + + user009 + + + + user010 + + + + user011 + + + diff --git a/spec/fixtures/golden/metro.svg b/spec/fixtures/golden/metro.svg new file mode 100644 index 0000000..f206666 --- /dev/null +++ b/spec/fixtures/golden/metro.svg @@ -0,0 +1,53 @@ + + + + + + user000 + + + user000 + + + user001 + + + user001 + + + user002 + + + user002 + + + user003 + + + user003 + + + user004 + + + user004 + + + user005 + + + user005 + + + user006 + + + user006 + + + user007 + + + user007 + + diff --git a/spec/fixtures/golden/skyline.svg b/spec/fixtures/golden/skyline.svg new file mode 100644 index 0000000..2ef97f3 --- /dev/null +++ b/spec/fixtures/golden/skyline.svg @@ -0,0 +1,384 @@ + + + + + + user000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + user001 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + user002 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + user003 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + user004 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + user005 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + user006 + + + + + + + + + + + + + + + + + + + + + + + + + + + + user007 + + + + + + + + + + + + + + + + + + + + + + + + + user008 + + + + + + + + + + + + + + + + + + + + + + + user009 + + + + + + + + + + + + + + + + + + + + + user010 + + + + + + + + + + + + + + + + + + + + + user011 + + + + + + + + + + + + + + + + diff --git a/spec/renderers/constellation_spec.cr b/spec/renderers/constellation_spec.cr new file mode 100644 index 0000000..aee73be --- /dev/null +++ b/spec/renderers/constellation_spec.cr @@ -0,0 +1,188 @@ +require "../spec_helper" +require "../support/fake_avatar_source" +require "../support/golden" + +private def render_constellation(yaml : String) : String + config = ContributorMural::Config.parse(yaml) + config.validate! + users = ContributorMural::Resolver.resolve(config) + renderer = ContributorMural::Renderer.for(config.style, config) + renderer.prepare(users) + embedded, _ = ContributorMural::Embedder.new(FakeAvatarSource.new) + .embed(users, renderer, fail_on_missing: false) + renderer.render(ContributorMural::Resolver.grouped(embedded, config)) +end + +private def constellation_users(count : Int32, scales = {} of Int32 => Float64) : String + String.build do |io| + io << "users:\n" + count.times do |index| + io << " - login: user#{index.to_s.rjust(3, '0')}\n" + io << " weight: #{count - index}\n" + if scale = scales[index]? + io << " scale: #{scale}\n" + end + end + end +end + +# {center x, center y, size} for every avatar. +private def stars(svg : String) : Array({Float64, Float64, Float64}) + svg.scan(/]*x="([-0-9.]+)" y="([-0-9.]+)" width="([0-9.]+)"/).map do |match| + size = match[3].to_f + {match[1].to_f + size / 2, match[2].to_f + size / 2, size} + end +end + +private def lines(svg : String) : Array({Float64, Float64, Float64, Float64}) + svg.scan(/= (a[2] + b[2]) / 2 + gap - 0.02 + end +end + +describe ContributorMural::Renderers::Constellation do + it "renders the constellation golden file" do + svg = render_constellation("style: constellation\n#{constellation_users(12)}") + svg.should contain(%(clip-path="url(#star-clip)")) + svg.should contain(%()) + Golden.assert("constellation.svg", svg) + end + + # The lattice is the guarantee: each star keeps to its own cell inset by + # half the gap, so any two stay a full `gap` apart — whatever the config, + # the count, or the emphasis. + it "keeps stars a full gap apart, whatever the config" do + { + {"", 1, 12.0}, + {"", 2, 12.0}, + {"", 5, 12.0}, + {"", 40, 12.0}, + {"", 120, 12.0}, + {"constellation:\n max_size: 200\n min_size: 190\n gap: 0\n", 20, 0.0}, + {"constellation:\n max_size: 512\n width: 600\n gap: 20\n", 6, 20.0}, + {"constellation:\n jitter: 1.0\n", 30, 12.0}, + }.each do |extra, count, gap| + svg = render_constellation("style: constellation\n#{extra}#{constellation_users(count)}") + assert_gap(stars(svg), gap) + end + end + + it "keeps an emphasised star clear too, at its scaled size" do + plain = stars(render_constellation("style: constellation\n#{constellation_users(30)}")) + scaled = stars(render_constellation("style: constellation\n#{constellation_users(30, {3 => 2.0})}")) + # `scale` doubles the size the taper arrived at; the taper still owns + # everyone else. + scaled[3][2].should be_close(plain[3][2] * 2, 0.02) + scaled[4][2].should be_close(plain[4][2], 0.02) + assert_gap(scaled, 12.0) + end + + it "stays inside the configured width" do + svg = render_constellation("style: constellation\n#{constellation_users(40)}") + stars(svg).each do |(x, _y, size)| + (x + size / 2).should be <= 720.02 + end + end + + # The cell shuffle deals over the whole lattice, so a sky with fewer people + # than the width has columns used to scatter its stars past the edge of the + # document it had sized to the people. + it "keeps a sparse sky inside its own document" do + [1, 2, 3, 5].each do |count| + svg = render_constellation("style: constellation\n#{constellation_users(count)}") + width = svg.match!(/width="([0-9.]+)"/)[1].to_f + height = svg.match!(/height="([0-9.]+)"/)[1].to_f + placed = stars(svg) + placed.size.should eq(count) + placed.each do |(x, y, size)| + (x - size / 2).should be >= -0.02 + (x + size / 2).should be <= width + 0.02 + (y - size / 2).should be >= -0.02 + (y + size / 2).should be <= height + 0.02 + end + end + end + + it "draws constellation lines between placed stars, and only then" do + svg = render_constellation("style: constellation\n#{constellation_users(20)}") + placed = stars(svg) + drawn = lines(svg) + drawn.size.should be >= 1 + drawn.size.should be <= placed.size - 1 + svg.should contain(%(class="mural-line")) + svg.should contain(".mural-line{stroke:#57606a}") + + # Every trimmed endpoint points back at some star's rim. + drawn.each do |(x1, y1, x2, y2)| + { {x1, y1}, {x2, y2} }.each do |(x, y)| + nearest = placed.min_of do |(cx, cy, size)| + Math.sqrt((cx - x) ** 2 + (cy - y) ** 2) - size / 2 + end + nearest.should be_close(4.0, 0.02) # LINE_INSET + end + end + + off = render_constellation("style: constellation\nconstellation:\n lines: false\n#{constellation_users(20)}") + off.should_not contain("/) + dust.size.should be > 0 + dust.size.should be <= 40 # 10 users * dust 4 + dust.each do |match| + x = match[1].to_f + y = match[2].to_f + placed.each do |(cx, cy, size)| + Math.sqrt((cx - x) ** 2 + (cy - y) ** 2).should be > size / 2 + end + end + + off = render_constellation("style: constellation\nconstellation:\n dust: 0\n#{constellation_users(10)}") + off.should_not contain("mural-dust") + end + + it "renders the same sky twice" do + yaml = "style: constellation\n#{constellation_users(15)}" + render_constellation(yaml).should eq(render_constellation(yaml)) + end + + it "emits its defs once and stacks sections downward" do + svg = render_constellation(<<-YAML) + style: constellation + sort: none + users: + - login: a + group: One + - login: b + group: Two + YAML + + svg.scan(//).size.should eq(1) + svg.scan(/ placed[0][1] + end + + it "sizes fetches for the taper, the scale included" do + config = ContributorMural::Config.parse("style: constellation\n#{constellation_users(4, {0 => 1.5})}") + users = ContributorMural::Resolver.resolve(config) + renderer = ContributorMural::Renderer.for(ContributorMural::Style::Constellation, config) + renderer.prepare(users) + + renderer.fetch_size(users.first).should eq(192) # max_size 64 * 1.5 * 2 + renderer.fetch_size(users.last).should eq(40) # min_size 20 * 2 + end +end diff --git a/spec/renderers/metro_spec.cr b/spec/renderers/metro_spec.cr new file mode 100644 index 0000000..3bbd511 --- /dev/null +++ b/spec/renderers/metro_spec.cr @@ -0,0 +1,249 @@ +require "../spec_helper" +require "../support/fake_avatar_source" +require "../support/golden" + +private def render_metro(yaml : String) : String + config = ContributorMural::Config.parse(yaml) + config.validate! + users = ContributorMural::Resolver.resolve(config) + renderer = ContributorMural::Renderer.for(config.style, config) + renderer.prepare(users) + embedded, _ = ContributorMural::Embedder.new(FakeAvatarSource.new) + .embed(users, renderer, fail_on_missing: false) + renderer.render(ContributorMural::Resolver.grouped(embedded, config)) +end + +private def metro_users(count : Int32) : String + String.build do |io| + io << "users:\n" + count.times do |index| + io << " - login: user#{index.to_s.rjust(3, '0')}\n" + io << " weight: #{count - index}\n" + end + end +end + +# {center x, center y} per station, in drawing order. +private def stations(svg : String) : Array({Float64, Float64}) + svg.scan(/]*x="([-0-9.]+)" y="([-0-9.]+)" width="([0-9.]+)"/).map do |match| + size = match[3].to_f + {match[1].to_f + size / 2, match[2].to_f + size / 2} + end +end + +describe ContributorMural::Renderers::Metro do + it "renders the metro golden file" do + svg = render_metro("style: metro\nmetro:\n columns: 3\n#{metro_users(8)}") + svg.should contain(%(clip-path="url(#metro-clip)")) + svg.scan(//).size.should eq(1) + Golden.assert("metro.svg", svg) + end + + # Defaults with six-character logins: outer = 28 + 1.6*8 = 40.8, the corner + # radius is min(123.6 / 2, 56) = 56, and the margin (56 + 8/2 + 2) clears + # the loop with its stroke, the rings, and the labels alike. + it "lays stations on a serpentine: right, turn, and back again" do + svg = render_metro("style: metro\nmetro:\n columns: 3\n#{metro_users(8)}") + placed = stations(svg) + placed.size.should eq(8) + placed.first[0].should be_close(62.0, 0.02) # margin + placed.first[1].should be_close(42.8, 0.02) # top = outer + 2 + + rows = placed.group_by(&.[](1)).values + rows.size.should eq(3) + rows[0].map(&.[](0)).should eq(rows[0].map(&.[](0)).sort!) # rightward + rows[1].map(&.[](0)).should eq(rows[1].map(&.[](0)).sort!.reverse!) # and back + # Stations sit one pitch apart, a terminus ring funded on both sides: + # 56 + 2 * 1.6 * 8 + 24. + (rows[0][1][0] - rows[0][0][0]).should be_close(105.6, 0.02) + end + + it "runs the route through the station rows, turning with rounded arcs" do + svg = render_metro("style: metro\nmetro:\n columns: 3\n#{metro_users(8)}") + path = svg.match!(/]*stroke="(#\w+)"/).map(&.[](1)) + strokes.size.should eq(2) + strokes.uniq.size.should eq(2) + + yaml = String.build do |io| + io << "style: metro\nsort: none\nusers:\n" + 9.times do |section| + io << " - login: rider#{section}a\n group: Line#{section}\n" + io << " - login: rider#{section}b\n group: Line#{section}\n" + end + end + nine = render_metro(yaml) + wrapped = nine.scan(/]*stroke="(#\w+)"/).map(&.[](1)) + wrapped.size.should eq(9) + wrapped.last.should eq(wrapped.first) + end + + it "marks both ends of the line as termini" do + svg = render_metro("style: metro\nmetro:\n columns: 3\n#{metro_users(8)}") + widths = svg.scan(/]*stroke-width="([0-9.]+)"/).map(&.[](1)) + widths.first.should eq("12.8") # 1.6 * line_width + widths.last.should eq("12.8") + widths[1..-2].each(&.should(eq("8"))) + end + + it "renders a lone contributor as a single badge, no track" do + svg = render_metro("style: metro\n#{metro_users(1)}") + svg.should_not contain("]*stroke-width="([0-9.]+)"/)[1].should eq("12.8") + end + + it "can keep the names off, and truncates the ones it keeps" do + quiet = render_metro("style: metro\nmetro:\n show_names: false\n#{metro_users(4)}") + quiet.should_not contain(%(font-size="11")) + + named = render_metro(<<-YAML) + style: metro + users: + - login: somebody + name: Somebody Quite Long-Named + YAML + named.should contain("…") + end + + it "splits a wall into one line per role when asked" do + svg = render_metro(<<-YAML) + style: metro + metro: + role_lines: true + sort: none + users: + - login: ada + role: Maintainer + - login: bee + role: Maintainer + - login: cal + role: Contributor + - login: dot + role: Contributor + - login: eve + YAML + + # Two lines of two get track; the lone unroled rider is a badge. + strokes = svg.scan(/]*stroke="(#\w+)"/).map(&.[](1)) + strokes.should eq(["#e5484d", "#3b82f6"]) + svg.should contain(%(fill="#e5484d">Maintainer)) + svg.should contain(%(fill="#3b82f6">Contributor)) + # The unnamed line carries no title, but its terminus ring is there. + svg.scan(/font-weight="600"/).size.should eq(2) + svg.scan(/]*stroke-width/).size.should eq(5) + + # Off by default: the same wall is one line. + plain = render_metro(<<-YAML) + style: metro + sort: none + users: + - login: ada + role: Maintainer + - login: bee + YAML + plain.scan(/Maintainer") + end + + it "weaves role lines through one another" do + svg = render_metro(<<-YAML) + style: metro + metro: + columns: 3 + role_lines: true + weave: true + sort: none + users: + - {login: a1, role: Core} + - {login: a2, role: Core} + - {login: a3, role: Core} + - {login: a4, role: Core} + - {login: a5, role: Core} + - {login: a6, role: Core} + - {login: b1, role: Docs} + - {login: b2, role: Docs} + - {login: b3, role: Docs} + - {login: b4, role: Docs} + YAML + + strokes = svg.scan(/]*stroke="(#\w+)"/).map(&.[](1)) + strokes.should eq(["#e5484d", "#3b82f6"]) + + # Rows interleave: Core rides rows 0 and 2 of the shared lattice, Docs + # rows 1 and 3 — which is what makes the turns cross the other line. + placed = stations(svg) + rows = placed.map(&.[](1)).uniq!.sort! + rows.size.should eq(4) + placed[0...6].map(&.[](1)).uniq!.sort!.should eq([rows[0], rows[2]]) + placed[6...10].map(&.[](1)).uniq!.sort!.should eq([rows[1], rows[3]]) + + # The second line is right-aligned and one column narrower, and its + # first row runs right to left. + placed[6...8].map(&.[](0)).should eq(placed[6...8].map(&.[](0)).sort!.reverse!) + + # Woven corners are half a station pitch — the radius that lands every + # rail midway between two station columns: (56 + 2 × 1.6 × 8 + 24) / 2. + # The narrow line's first turn drops on the interior side, straight + # through the wide line's rows: its rail runs at margin 62 + pitch 105.6 + # − 52.8. + svg.should contain("A52.8 52.8 0 0") + paths = svg.scan(/Core)) + svg.should contain(%(fill="#3b82f6">Docs)) + legend_rows = svg.scan(/font-weight="600"[^>]*>[^<]+<\/text>/) + legend_rows.size.should eq(2) + ys = svg.scan(/]*y="([0-9.]+)"[^>]*font-weight="600"/).map(&.[](1)) + ys.uniq.size.should eq(1) + end + + it "refuses a weave whose gap cannot clear the rings" do + config = ContributorMural::Config.parse(<<-YAML) + style: metro + metro: + role_lines: true + weave: true + gap: 10 + users: + - login: a + YAML + expect_raises(ContributorMural::ConfigError, /at least 2.5/) { config.validate! } + end + + it "fetches at double the station size" do + config = ContributorMural::Config.parse("style: metro\nmetro:\n station_size: 60\n#{metro_users(2)}") + renderer = ContributorMural::Renderer.for(ContributorMural::Style::Metro, config) + renderer.fetch_size(ContributorMural::ResolvedUser.new("x")).should eq(120) + end +end diff --git a/spec/renderers/radial_spec.cr b/spec/renderers/radial_spec.cr index c700b33..eb601df 100644 --- a/spec/renderers/radial_spec.cr +++ b/spec/renderers/radial_spec.cr @@ -57,7 +57,7 @@ describe "list order across styles" do it "places users in the order `sort` asked for" do # Listed against their weights, so any style that re-ranks shows it. users = "users:\n - login: zoe\n weight: 1\n - login: yan\n weight: 2\n - login: xu\n weight: 3\n" - %w[grid honeycomb mosaic voronoi stencil spiral orbit].each do |style| + %w[grid honeycomb mosaic voronoi stencil spiral orbit constellation skyline metro].each do |style| { "none" => ["zoe", "yan", "xu"], "login" => ["xu", "yan", "zoe"], diff --git a/spec/renderers/skyline_spec.cr b/spec/renderers/skyline_spec.cr new file mode 100644 index 0000000..26ee368 --- /dev/null +++ b/spec/renderers/skyline_spec.cr @@ -0,0 +1,174 @@ +require "../spec_helper" +require "../support/fake_avatar_source" +require "../support/golden" + +private def render_skyline(yaml : String) : String + config = ContributorMural::Config.parse(yaml) + config.validate! + users = ContributorMural::Resolver.resolve(config) + renderer = ContributorMural::Renderer.for(config.style, config) + renderer.prepare(users) + embedded, _ = ContributorMural::Embedder.new(FakeAvatarSource.new) + .embed(users, renderer, fail_on_missing: false) + renderer.render(ContributorMural::Resolver.grouped(embedded, config)) +end + +private def skyline_users(count : Int32, scales = {} of Int32 => Float64) : String + String.build do |io| + io << "users:\n" + count.times do |index| + io << " - login: user#{index.to_s.rjust(3, '0')}\n" + io << " weight: #{count - index}\n" + if scale = scales[index]? + io << " scale: #{scale}\n" + end + end + end +end + +private alias Rect = {Float64, Float64, Float64, Float64} + +private def rects(fragment : String) : Array(Rect) + fragment.scan(/\n(.*?)}m).map do |match| + body = rects(match[1]).select { |(_x, _y, w, _h)| w > 2.0 } + x = body.min_of(&.[](0)) + top = body.min_of(&.[](1)) + width = body.max_of { |(bx, _y, w, _h)| bx + w } - x + baseline = body.max_of { |(_x, by, _w, h)| by + h } + {x, top, width, baseline - top, baseline} + end +end + +describe ContributorMural::Renderers::Skyline do + it "renders the skyline golden file" do + svg = render_skyline("style: skyline\n#{skyline_users(12)}") + svg.should contain(%(clip-path="url(#skyline-clip)")) + svg.should contain(%(class="mural-building")) + svg.should contain(%(class="mural-ground")) + Golden.assert("skyline.svg", svg) + end + + it "inlines its inks in a static mode" do + svg = render_skyline("style: skyline\ntheme:\n mode: dark\n#{skyline_users(4)}") + svg.should contain(%(fill="#21262d")) + svg.should_not contain("mural-building") + end + + it "pins the heaviest contributor's tower to max_height exactly" do + towers = buildings(render_skyline("style: skyline\n#{skyline_users(8)}")) + towers.first[3].should be_close(220.0, 0.02) # skyline.max_height + + scaled = buildings(render_skyline("style: skyline\n#{skyline_users(8, {0 => 2.0})}")) + scaled.first[3].should be_close(440.0, 0.02) + # Everyone else keeps the height their rank set. + scaled[1][3].should be_close(towers[1][3], 0.02) + end + + it "keeps the roofline in the order the weights decided" do + heights = buildings(render_skyline("style: skyline\n#{skyline_users(6)}")).map(&.[](3)) + heights.each_cons_pair do |taller, shorter| + taller.should be > shorter + end + end + + it "breaks up a wall of equals instead of drawing a flat roof" do + heights = buildings(render_skyline(<<-YAML)).map(&.[](3)) + style: skyline + users: + #{String.build { |io| 8.times { |i| io << " - login: peer#{i}\n weight: 1\n" } }} + YAML + + heights.uniq.size.should be > 1 + heights.each do |height| + height.should be >= 96.0 # skyline.min_height + height.should be <= 220.0 # skyline.max_height + end + end + + it "leaves at least the gap between buildings in a row" do + towers = buildings(render_skyline("style: skyline\n#{skyline_users(9)}")) + towers.group_by(&.[](4)).each_value do |row| + row.sort_by(&.[](0)).each_cons_pair do |left, right| + (right[0] - (left[0] + left[2])).should be >= 6.0 - 0.02 # skyline.gap + end + end + end + + it "keeps every window inside its building's walls, below the avatar" do + svg = render_skyline("style: skyline\nskyline:\n min_height: 180\n max_height: 320\n#{skyline_users(6)}") + svg.scan(%r{]*>\n(.*?)}m).each do |anchor| + body = rects(anchor[1].scan(%r{\n(.*?)}m).first[1]) + .select { |(_x, _y, w, _h)| w > 2.0 } + left = body.min_of(&.[](0)) + right = body.max_of { |(x, _y, w, _h)| x + w } + bottom = body.max_of { |(_x, y, _w, h)| y + h } + image = anchor[1].match!(/]*x="([-0-9.]+)" y="([-0-9.]+)" width="([0-9]+)"/) + floor = image[2].to_f + image[3].to_f + + anchor[1].scan(%r{\n(.*?)}m).each do |panes| + rects(panes[1]).each do |(x, y, w, h)| + x.should be >= left + (x + w).should be <= right + y.should be >= floor + (y + h).should be <= bottom + end + end + end + end + + it "wraps into further rows, each with its own street" do + svg = render_skyline("style: skyline\n#{skyline_users(25)}") # 12 columns at the defaults + svg.scan(/class="mural-ground"/).size.should eq(3) + + single = render_skyline("style: skyline\n#{skyline_users(1)}") + single.scan(/class="mural-ground"/).size.should eq(1) + buildings(single).size.should eq(1) + end + + it "can turn the windows off and the names on" do + dark = render_skyline("style: skyline\nskyline:\n windows: false\n#{skyline_users(6)}") + dark.should_not contain("mural-window") + + named = render_skyline(<<-YAML) + style: skyline + skyline: + show_names: true + users: + - login: somebody + name: Somebody Quite Long-Named + YAML + named.should contain(%(font-size="11")) + named.should contain("…") + end + + it "emits its defs once across sections" do + svg = render_skyline(<<-YAML) + style: skyline + sort: none + users: + - login: a + group: One + - login: b + group: Two + YAML + svg.scan(//).size.should eq(1) + end + + it "fetches at double the avatar size, scale or no scale" do + config = ContributorMural::Config.parse("style: skyline\n#{skyline_users(4, {0 => 2.0})}") + users = ContributorMural::Resolver.resolve(config) + renderer = ContributorMural::Renderer.for(ContributorMural::Style::Skyline, config) + renderer.prepare(users) + + renderer.fetch_size(users.first).should eq(96) # avatar_size 48 * 2 + renderer.fetch_size(users.last).should eq(96) + end +end diff --git a/spec/svg_spec.cr b/spec/svg_spec.cr index 908f588..f47894e 100644 --- a/spec/svg_spec.cr +++ b/spec/svg_spec.cr @@ -68,7 +68,7 @@ describe ContributorMural::SVG do end end - {"grid", "honeycomb", "mosaic", "spiral", "orbit", "voronoi", "stencil"}.each do |style| + {"grid", "honeycomb", "mosaic", "spiral", "orbit", "voronoi", "stencil", "constellation", "skyline", "metro"}.each do |style| it "writes well-formed XML for #{style} however a user is named" do svg = render_hostile(style) diff --git a/src/contributor_mural.cr b/src/contributor_mural.cr index ec1d989..da24745 100644 --- a/src/contributor_mural.cr +++ b/src/contributor_mural.cr @@ -17,6 +17,9 @@ require "./contributor_mural/renderers/spiral" require "./contributor_mural/renderers/orbit" require "./contributor_mural/renderers/voronoi" require "./contributor_mural/renderers/stencil" +require "./contributor_mural/renderers/constellation" +require "./contributor_mural/renderers/skyline" +require "./contributor_mural/renderers/metro" require "./contributor_mural/embedder" require "./contributor_mural/rasterizer" require "./contributor_mural/inputs" diff --git a/src/contributor_mural/config.cr b/src/contributor_mural/config.cr index a857ae0..774a7ad 100644 --- a/src/contributor_mural/config.cr +++ b/src/contributor_mural/config.cr @@ -29,6 +29,9 @@ module ContributorMural Orbit Voronoi Stencil + Constellation + Skyline + Metro end enum Shape @@ -71,6 +74,9 @@ module ContributorMural property orbit : OrbitConfig = OrbitConfig.new property voronoi : VoronoiConfig = VoronoiConfig.new property stencil : StencilConfig = StencilConfig.new + property constellation : ConstellationConfig = ConstellationConfig.new + property skyline : SkylineConfig = SkylineConfig.new + property metro : MetroConfig = MetroConfig.new property theme : ThemeConfig = ThemeConfig.new property png : PngConfig = PngConfig.new @@ -242,6 +248,9 @@ module ContributorMural errors.concat(orbit.validate) errors.concat(voronoi.validate) errors.concat(stencil.validate) + errors.concat(constellation.validate) + errors.concat(skyline.validate) + errors.concat(metro.validate) errors.concat(theme.validate) raise ConfigError.new(errors.join("; ")) unless errors.empty? @@ -428,7 +437,8 @@ module ContributorMural # does for itself. `weight` says where someone stands in the list, which # is not the same question as how large to draw them: a rank is relative # to everyone else and moves whenever the list does. Honoured by the - # styles that derive a size per user (mosaic, spiral, orbit). + # styles that derive a size per user (mosaic, spiral, orbit, + # constellation, and skyline — the latter in height). @[YAML::Field(converter: ContributorMural::NumberConverter)] property scale : Float64? = nil property role : String? = nil @@ -719,6 +729,113 @@ module ContributorMural end end + class ConstellationConfig + include YAML::Serializable + include YAML::Serializable::Strict + + property width : Int32 = 720 + property max_size : Int32 = 64 + property min_size : Int32 = 20 + property gap : Int32 = 12 + @[YAML::Field(converter: ContributorMural::NumberConverter)] + property jitter : Float64 = 0.8 + property? lines : Bool = true + property dust : Int32 = 4 + + def initialize + end + + def validate : Array(String) + errors = [] of String + errors << "constellation `width` must be between 64 and 8000" unless (64..8000).includes?(width) + errors << "constellation `max_size` must be between 8 and 512" unless (8..512).includes?(max_size) + errors << "constellation `min_size` must be between 8 and 512" unless (8..512).includes?(min_size) + errors << "constellation `min_size` must not exceed `max_size`" if min_size > max_size + errors << "constellation `width` must be >= `max_size` plus `gap`" if width < max_size + gap + errors << "constellation `gap` must be between 0 and 200" unless (0..200).includes?(gap) + errors << "constellation `jitter` must be between 0 and 1" unless (0.0..1.0).includes?(jitter) + errors << "constellation `dust` must be between 0 and 32" unless (0..32).includes?(dust) + errors + end + end + + class SkylineConfig + include YAML::Serializable + include YAML::Serializable::Strict + + # Room a building needs above its avatar: the roof band plus the inset that + # keeps the picture off the parapet. + HEADROOM = 20 + + property width : Int32 = 800 + property avatar_size : Int32 = 48 + property min_height : Int32 = 96 + property max_height : Int32 = 220 + property gap : Int32 = 6 + property shape : Shape = Shape::Rounded + property? windows : Bool = true + property? show_names : Bool = false + property truncate : Int32 = 10 + + def initialize + end + + def validate : Array(String) + errors = [] of String + errors << "skyline `width` must be between 64 and 8000" unless (64..8000).includes?(width) + errors << "skyline `avatar_size` must be between 8 and 512" unless (8..512).includes?(avatar_size) + errors << "skyline `min_height` must be between 28 and 1024" unless (28..1024).includes?(min_height) + errors << "skyline `max_height` must be between 28 and 1024" unless (28..1024).includes?(max_height) + errors << "skyline `min_height` must not exceed `max_height`" if min_height > max_height + # The shortest tower still has to hold its avatar under the roof band. + if min_height < avatar_size + HEADROOM + errors << "skyline `min_height` must be at least `avatar_size` plus #{HEADROOM}" + end + errors << "skyline `gap` must be between 0 and 200" unless (0..200).includes?(gap) + errors << "skyline `truncate` must be >= 0" if truncate < 0 + errors + end + end + + class MetroConfig + include YAML::Serializable + include YAML::Serializable::Strict + + property columns : Int32 = 6 + property station_size : Int32 = 56 + property line_width : Int32 = 8 + property gap : Int32 = 24 + # Split each section into one line per role, named after it — the people + # who carry no role ride together on an unnamed line. + property? role_lines : Bool = false + # Interleave a section's lines so their routes cross one another, the way + # a real network does. Only does anything where `role_lines` yields more + # than one line. + property? weave : Bool = false + property? show_names : Bool = true + property truncate : Int32 = 10 + + def initialize + end + + def validate : Array(String) + errors = [] of String + errors << "metro `columns` must be between 1 and 100" unless (1..100).includes?(columns) + errors << "metro `station_size` must be between 8 and 512" unless (8..512).includes?(station_size) + errors << "metro `line_width` must be between 2 and 64" unless (2..64).includes?(line_width) + # A ring thicker than the avatar's radius swallows the face it frames. + errors << "metro `line_width` must not exceed half of `station_size`" if line_width > station_size // 2 + errors << "metro `gap` must be between 0 and 200" unless (0..200).includes?(gap) + # A woven rail passes midway between two station columns; the midpoint + # clears the rings only when the gap funds it. + if weave? && gap * 2 < line_width * 5 + errors << "metro `gap` must be at least 2.5 × `line_width` when `weave` is on" + end + errors << "metro `truncate` must be >= 0" if truncate < 0 + errors + end + end + class ThemeConfig include YAML::Serializable include YAML::Serializable::Strict diff --git a/src/contributor_mural/renderers/constellation.cr b/src/contributor_mural/renderers/constellation.cr new file mode 100644 index 0000000..2ad79ff --- /dev/null +++ b/src/contributor_mural/renderers/constellation.cr @@ -0,0 +1,299 @@ +module ContributorMural::Renderers + # A night sky: every contributor is a star whose size and glow follow their + # rank, near neighbours join up into constellations, and a scatter of tiny + # dust stars fills the dark between them. Placement is a hash-jittered + # lattice — the cells are disjoint, so however the jitter lands no star can + # touch another — with the occupied cells themselves picked by hash, which + # is what makes the sky read as scattered rather than ruled. + class Constellation < Renderer + CLIP_ID = "star-clip" + GLOW_ID = "star-glow" + + # Halo radius as a multiple of the star's own: the tail glows a little + # past its rim, the top of the ranking half again as far. The canvas + # padding is derived from HALO_MAX, so the brightest halo just fits. + HALO_MIN = 1.3 + HALO_MAX = 1.8 + + # Longest constellation edge kept, in cell pitches. The spanning tree + # connects everything; pruning it back to its short edges is what breaks + # the sky into separate constellations instead of one long snake. + LINE_REACH = 2.4 + # How far a line stops short of the rim it points at. + LINE_INSET = 4.0 + # A trimmed stub shorter than this reads as a speck, not a line. + LINE_MIN = 8.0 + + # Hard ceiling on dust per section, so a wall of hundreds cannot swell + # the file with thousands of circles. + DUST_CAP = 1500 + # Dust never comes closer to an avatar's rim than this. + DUST_CLEARANCE = 3.0 + + private alias Star = NamedTuple(user: EmbeddedUser, x: Float64, y: Float64, size: Float64) + + @sizes = {} of String => Float64 + @glows = {} of String => Float64 + @section = 0 + + # Rank drives size and glow exactly like the spiral's taper: a power curve + # below 1 drops quickly among the leaders and then runs nearly flat, so + # the long tail stays a uniform field of faint stars. + def prepare(users : Array(ResolvedUser)) : Nil + constellation = @config.constellation + ranked = users.sort_by { |user| {-user.weight, user.login.downcase} } + last = Math.max(ranked.size - 1, 1) + ranked.each_with_index do |user, index| + t = (index / last.to_f) ** 0.45 + size = constellation.max_size - (constellation.max_size - constellation.min_size) * t + @sizes[user.login] = size * user.scale + @glows[user.login] = 1.0 - t + end + end + + def fetch_size(user : ResolvedUser) : Int32 + (size_for(user.login) * 2).ceil.to_i + end + + # Cell shuffles and dust are salted by section, so a renderer reused for a + # second document has to start counting over. + def render(groups : Array({String?, Array(EmbeddedUser)})) : String + @section = 0 + super + end + + protected def defs(io : String::Builder) : Nil + io << " \n" + io << %( \n) + io << %( \n) + io << %( \n) + io << %( \n) + io << %( \n) + io << " \n" + io << " \n" + end + + protected def style_rules(palette : Palette) : String + constellation = @config.constellation + dark = palette == theme.dark_palette + String.build do |rules| + rules << ".mural-glow{stop-color:#{palette.title_color}}" + rules << ".mural-halo{opacity:#{dark ? "0.9" : "0.4"}}" + rules << ".mural-dust{fill:#{palette.label_color}}" if constellation.dust > 0 + rules << ".mural-line{stroke:#{palette.label_color}}" if constellation.lines? + end + end + + protected def block_size(users : Array(EmbeddedUser)) : {Float64, Float64} + return {16.0, 16.0} if users.empty? + width, height, _cols, _pitch, _pad = frame(users) + {width, height} + end + + protected def draw_block(io : String::Builder, users : Array(EmbeddedUser), y_offset : Float64) : Nil + return if users.empty? + + constellation = @config.constellation + width, height, cols, pitch, pad = frame(users) + stars = place(users, cols, pitch, pad) + + # Back to front: dust, then lines, then every halo, then every avatar — + # a halo bleeding past its cell may tint the background, never a + # neighbour's face. + draw_dust(io, stars, width, height, pad, y_offset) if constellation.dust > 0 + draw_lines(io, stars, pitch, y_offset) if constellation.lines? + + stars.each do |star| + halo = star[:size] / 2 * (HALO_MIN + (HALO_MAX - HALO_MIN) * glow_for(star[:user].login)) + io << %( \n) + end + + stars.each do |star| + user = star[:user] + size = star[:size] + io << %( \n) + io << %( #{SVG.escape(title_for(user))}\n) + io << %( \n) + io << " \n" + end + + @section += 1 + end + + # {width, height, cols, pitch, pad}, closed form. The pitch is at least + # the widest star plus the gap — by the division when a column fits, by + # the clamp when even one does not — so every cell can hold its star with + # `gap` of clearance to spare. The lattice never has more columns than + # people: the shuffle deals cells over the whole lattice, and a column + # the width formula did not pay for would put its star past the edge of + # the document. Pure: the base class sizes every section before drawing + # any of them. + private def frame(users : Array(EmbeddedUser)) : {Float64, Float64, Int32, Float64, Float64} + constellation = @config.constellation + count = users.size + widest = users.max_of { |user| size_for(user.login) } + pad = (HALO_MAX - 1.0) / 2 * widest + inner = Math.max(constellation.width - 2 * pad, 1.0) + raw = Math.max((inner / (widest + constellation.gap)).to_i, 1) + pitch = raw == 1 ? Math.max(inner, widest + constellation.gap) : inner / raw + cols = Math.min(raw, count) + rows = (count + cols - 1) // cols + {cols * pitch + 2 * pad, rows * pitch + 2 * pad, cols, pitch, pad} + end + + # Every star keeps to its own cell, inset by half the gap on each side: + # the jitter spends only the room the cell has left over once the star and + # the gap are paid for, so two stars are always a full `gap` apart — for + # any config, any count, and any `scale`. Which cells are occupied is a + # hashed shuffle of all of them, so the holes land anywhere rather than + # always at the bottom edge. List order is kept: only positions permute. + private def place(users : Array(EmbeddedUser), cols : Int32, pitch : Float64, pad : Float64) : Array(Star) + constellation = @config.constellation + rows = (users.size + cols - 1) // cols + shuffle_salt = @section.to_u64 &* 7919_u64 + jitter_salt = @section.to_u64 &* 104729_u64 &+ 1 + cells = (0...rows * cols).to_a.sort_by! { |cell| {noise(cell, shuffle_salt), cell} } + + users.map_with_index do |user, index| + size = size_for(user.login) + row, col = cells[index].divmod(cols) + free = Math.max(pitch - size - constellation.gap, 0.0) + x = pad + (col + 0.5) * pitch + (noise(index * 2, jitter_salt) - 0.5) * constellation.jitter * free + y = pad + (row + 0.5) * pitch + (noise(index * 2 + 1, jitter_salt) - 0.5) * constellation.jitter * free + {user: user, x: x, y: y, size: size} + end + end + + private def draw_lines(io : String::Builder, stars : Array(Star), pitch : Float64, y_offset : Float64) : Nil + segments = [] of {Float64, Float64, Float64, Float64} + constellation_edges(stars, pitch).each do |(a, b)| + from = stars[a] + to = stars[b] + dx = to[:x] - from[:x] + dy = to[:y] - from[:y] + span = Math.sqrt(dx * dx + dy * dy) + next if span < 1e-9 + # Stop short of both rims, so a line points at a star without ever + # running underneath its glow. + head = from[:size] / 2 + LINE_INSET + tail = to[:size] / 2 + LINE_INSET + next if span - head - tail < LINE_MIN + ux = dx / span + uy = dy / span + segments << {from[:x] + ux * head, from[:y] + uy * head, to[:x] - ux * tail, to[:y] - uy * tail} + end + return if segments.empty? + + io << %( \n) + segments.each do |(x1, y1, x2, y2)| + io << %( \n) + end + io << " \n" + end + + # Prim's spanning tree over the star centres, pruned back to short edges: + # near neighbours connect, distant ones stay apart, and what remains reads + # as separate constellations. Ties in the float distances are settled by + # visiting order, so the tree is the same tree every render. + private def constellation_edges(stars : Array(Star), pitch : Float64) : Array({Int32, Int32}) + count = stars.size + return [] of {Int32, Int32} if count < 2 + + reach = (LINE_REACH * pitch) ** 2 + in_tree = Array.new(count, false) + best = Array.new(count) { |index| index.zero? ? 0.0 : distance2(stars[0], stars[index]) } + from = Array.new(count, 0) + in_tree[0] = true + + edges = [] of {Int32, Int32} + (count - 1).times do + pick = -1 + count.times do |index| + next if in_tree[index] + pick = index if pick < 0 || best[index] < best[pick] + end + break if pick < 0 + + in_tree[pick] = true + edges << {from[pick], pick} if best[pick] <= reach + count.times do |index| + next if in_tree[index] + squared = distance2(stars[pick], stars[index]) + if squared < best[index] + best[index] = squared + from[index] = pick + end + end + end + edges + end + + # Slots that land under a star are simply skipped — the count coming out a + # little under `dust` per person is deterministic too. Two depths of dust: + # the smaller specks sit fainter, which is what gives the sky its distance. + private def draw_dust(io : String::Builder, stars : Array(Star), width : Float64, height : Float64, + pad : Float64, y_offset : Float64) : Nil + salt = @section.to_u64 &* 15485863_u64 &+ 2 + near = [] of {Float64, Float64, Float64} + far = [] of {Float64, Float64, Float64} + Math.min(stars.size * @config.constellation.dust, DUST_CAP).times do |slot| + x = pad / 2 + noise(slot * 3, salt) * (width - pad) + y = pad / 2 + noise(slot * 3 + 1, salt) * (height - pad) + covered = stars.any? do |star| + dx = star[:x] - x + dy = star[:y] - y + limit = star[:size] / 2 + DUST_CLEARANCE + dx * dx + dy * dy < limit * limit + end + next if covered + radius = 0.5 + noise(slot * 3 + 2, salt) * 0.7 + (radius < 0.85 ? far : near) << {x, y, radius} + end + + draw_speck_layer(io, far, 0.25, y_offset) + draw_speck_layer(io, near, 0.5, y_offset) + end + + private def draw_speck_layer(io : String::Builder, specks : Array({Float64, Float64, Float64}), + opacity : Float64, y_offset : Float64) : Nil + return if specks.empty? + io << %( \n) + specks.each do |(x, y, radius)| + io << %( \n) + end + io << " \n" + end + + private def distance2(a : Star, b : Star) : Float64 + dx = a[:x] - b[:x] + dy = a[:y] - b[:y] + dx * dx + dy * dy + end + + private def size_for(login : String) : Float64 + @sizes[login]? || @config.constellation.max_size.to_f + end + + private def glow_for(login : String) : Float64 + @glows[login]? || 1.0 + end + + private def glow_paint : String + mode.auto? ? %(class="mural-glow") : %(stop-color="#{SVG.escape(palette.title_color)}") + end + + # The halo carries the glow in both themes, but a dark sky takes much more + # of it than a white page does. + private def halo_paint : String + mode.auto? ? %(class="mural-halo") : %(opacity="#{mode.dark? ? "0.9" : "0.4"}") + end + + private def dust_paint : String + mode.auto? ? %(class="mural-dust") : %(fill="#{SVG.escape(palette.label_color)}") + end + + private def line_paint : String + mode.auto? ? %(class="mural-line") : %(stroke="#{SVG.escape(palette.label_color)}") + end + end +end diff --git a/src/contributor_mural/renderers/metro.cr b/src/contributor_mural/renderers/metro.cr new file mode 100644 index 0000000..da50394 --- /dev/null +++ b/src/contributor_mural/renderers/metro.cr @@ -0,0 +1,448 @@ +module ContributorMural::Renderers + # A transit map: contributors are stations on a coloured route that snakes + # across the wall — left to right, a rounded 180° turn, back again — in the + # flat-colour idiom of the classic network diagrams. Each section is its own + # line in its own colour, its title reading as the line's name, and the two + # ends of a line carry the heavier ring of a terminus. With `role_lines`, + # a section splits further: one line per role, named after it — and `weave` + # interleaves those lines' rows so the routes genuinely cross one another, + # the way a real network does. + class Metro < Renderer + CLIP_ID = "metro-clip" + + # Terminus ring stroke, as a multiple of the line's width. The ring's + # inner edge stays flush with the avatar either way; a terminus is only + # heavier on the outside. + TERMINUS = 1.6 + # Vertical room reserved under each row for station names. + LABEL_BLOCK = 18.0 + # The band a role-named line's title takes, and the air between lines. + LINE_TITLE = 20.0 + LINE_GAP = 18.0 + + # Mid-lightness route colours that hold up on white and on GitHub's dark + # background alike, so one palette serves every theme and every stroke is + # a plain attribute — nothing for CSS or the rasterizer to miss. A ninth + # line wraps around to the first colour. + LINE_COLORS = %w[#e5484d #3b82f6 #30a46c #f76b15 #8e4ec6 #12a594 #d6409f #ad7f58] + + # Anyone whose label reaches the document, whether or not their avatar + # has been fetched yet. + private alias Labelled = ResolvedUser | EmbeddedUser + private alias Line = {String?, Array(EmbeddedUser)} + + private record Metrics, + outer : Float64, + pitch_x : Float64, + pitch_y : Float64, + corner : Float64, + margin : Float64, + top : Float64, + bottom : Float64 + + @line = 0 + @half_label = 0.0 + + # Half the widest station name in the whole document, measured once so + # every section shares one margin and the columns stay in true. + def prepare(users : Array(ResolvedUser)) : Nil + @half_label = half_label(users) + end + + def fetch_size(user : ResolvedUser) : Int32 + @config.metro.station_size * 2 + end + + # Line colours cycle across the document, so a renderer reused for a + # second document has to start the cycle over. + def render(groups : Array({String?, Array(EmbeddedUser)})) : String + @line = 0 + super + end + + # The line's name starts over the first station, not over the corner + # loop's margin. + protected def title_inset : Float64 + m = base_metrics(@half_label) + m.margin - m.outer + end + + protected def defs(io : String::Builder) : Nil + shape_clip(io, CLIP_ID, Shape::Circle) + end + + protected def block_size(users : Array(EmbeddedUser)) : {Float64, Float64} + return {16.0, 16.0} if users.empty? + m = metrics(users) + lines = buckets(users) + return weave_size(lines, m) if weave?(lines) + + width = 0.0 + height = 0.0 + lines.each_with_index do |(role, members), index| + height += LINE_GAP if index.positive? + height += LINE_TITLE if role + line_w, line_h = line_size(members.size, m) + # A line is as wide as its serpentine or its name, whichever runs + # further — the base class only measures section titles, not these. + line_w = Math.max(line_w, m.margin - m.outer + text_width(role, 12.0) + 4) if role + width = Math.max(width, line_w) + height += line_h + end + {width, height} + end + + protected def draw_block(io : String::Builder, users : Array(EmbeddedUser), y_offset : Float64) : Nil + return if users.empty? + + m = metrics(users) + lines = buckets(users) + if weave?(lines) + draw_weave(io, lines, m, y_offset) + @line += lines.size + return + end + + y = y_offset + lines.each_with_index do |(role, members), index| + y += LINE_GAP if index.positive? + color = LINE_COLORS[@line % LINE_COLORS.size] + if role + io << %( #{SVG.escape(role)}\n) + y += LINE_TITLE + end + draw_line(io, members, m, y, color) + y += line_size(members.size, m)[1] + @line += 1 + end + end + + # One stacked line: its route, its stations, its name labels. + private def draw_line(io : String::Builder, users : Array(EmbeddedUser), m : Metrics, + y_offset : Float64, color : String) : Nil + # A lone ringed station reads as a badge; a stub of track would read as + # a mistake. + draw_route(io, users.size, m, y_offset, color) if users.size > 1 + + line = @config.metro.line_width.to_f + spots = centers(users.size, m) + last = users.size - 1 + users.each_with_index do |user, index| + cx, cy = spots[index] + ring = index.zero? || index == last ? TERMINUS * line : line + draw_station(io, user, cx, cy + y_offset, ring, color, m) + end + end + + private def draw_station(io : String::Builder, user : EmbeddedUser, cx : Float64, cy : Float64, + ring : Float64, color : String, m : Metrics) : Nil + metro = @config.metro + size = metro.station_size.to_f + io << %( \n) + io << %( #{SVG.escape(title_for(user))}\n) + io << %( \n) + io << %( \n) + if metro.show_names? + label(io, truncate(user.name, metro.truncate), cx, cy + m.outer + 12) + end + io << " \n" + end + + # --- Weave mode: the lines share one lattice and cross one another --- + + private def weave?(lines : Array(Line)) : Bool + @config.metro.weave? && lines.size > 1 + end + + private def legend?(lines : Array(Line)) : Bool + lines.any? { |(role, _members)| role } + end + + private def weave_size(lines : Array(Line), m : Metrics) : {Float64, Float64} + cols = lattice_columns(lines) + slots = weave_slots(lines) + height = (legend?(lines) ? LINE_TITLE : 0.0) + m.top + (slots.size - 1) * m.pitch_y + m.bottom + width = 2 * m.margin + (cols - 1) * m.pitch_x + # The legend runs left to right in one band; the document has to reach + # its last entry. + if legend?(lines) + edge = m.margin - m.outer + lines.each { |(role, _members)| edge += text_width(role, 12.0) + 18 if role } + width = Math.max(width, edge - 18 + 4) + end + {width, height} + end + + private def lattice_columns(lines : Array(Line)) : Int32 + lines.max_of { |(_role, members)| Math.min(@config.metro.columns, members.size) } + end + + # How many lattice columns line `index` may ride. Every line past the + # first gives up one column — a full-width line's turns swing outside the + # map where there is nothing to cross, and the ceded column is what puts + # the narrower lines' rails *inside* the others' rows. Each round of the + # three-way stagger cedes one more, so line 3 does not land its rails on + # line 0's exact x and read as one route changing colour. + private def weave_cols(lines : Array(Line), index : Int32) : Int32 + lattice = lattice_columns(lines) + wanted = Math.min(@config.metro.columns, lines[index][1].size) + shrink = (index % 3 == 0 ? 0 : 1) + index // 3 + Math.max(Math.min(wanted, lattice - shrink), 1) + end + + # Where line `index`'s span starts on the lattice: first line left, the + # next right, the next centred, and around again — staggered ends are + # what the crossings are made of. + private def weave_start(lines : Array(Line), index : Int32) : Int32 + lattice = lattice_columns(lines) + cols = weave_cols(lines, index) + case index % 3 + when 1 then lattice - cols + when 2 then (lattice - cols) // 2 + else 0 + end + end + + # Right-aligned lines travel their first row right-to-left, so their + # first turn drops on the interior side. + private def mirrored?(index : Int32) : Bool + index % 3 == 1 + end + + # The lines' rows dealt out in rounds — every line places its row 0, then + # every line still running places its row 1, and so on. Interleaving is + # the whole trick: a line reaching its next row has to travel down past + # the rows the other lines put in between, and that is where it crosses + # them. + private def weave_slots(lines : Array(Line)) : Array({Int32, Int32}) + slots = [] of {Int32, Int32} + row = 0 + loop do + placed = slots.size + lines.each_with_index do |(_role, members), index| + cols = weave_cols(lines, index) + slots << {index, row} if row < (members.size + cols - 1) // cols + end + break if slots.size == placed + row += 1 + end + slots + end + + private def draw_weave(io : String::Builder, lines : Array(Line), m : Metrics, y_offset : Float64) : Nil + metro = @config.metro + slot_of = {} of {Int32, Int32} => Int32 + weave_slots(lines).each_with_index { |slot, index| slot_of[slot] = index } + + y = y_offset + if legend?(lines) + x = m.margin - m.outer + lines.each_with_index do |(role, _members), index| + next unless role + io << %( #{SVG.escape(role)}\n) + x += text_width(role, 12.0) + 18 + end + y += LINE_TITLE + end + + # A corner of half the station pitch drops every vertical rail exactly + # midway between two station columns, so a rail can cross another + # line's row without ever touching a ring — the validator's floor on + # `gap` is what pays for that clearance. + rail = m.pitch_x / 2 + line_width = metro.line_width.to_f + + lines.each_with_index do |(_role, members), index| + cols = weave_cols(lines, index) + rows = (members.size + cols - 1) // cols + start = weave_start(lines, index) + mirror = mirrored?(index) + color = weave_color(index) + row_y = ->(row : Int32) { y + m.top + slot_of[{index, row}] * m.pitch_y } + col_x = ->(col : Int32) { m.margin + col * m.pitch_x } + + draw_weave_route(io, members.size, cols, rows, start, mirror, m, rail, row_y, col_x, color) if members.size > 1 + + last = members.size - 1 + members.each_with_index do |user, position| + row, spot = position.divmod(cols) + cx = col_x.call(weave_col(start, cols, mirror, row, spot)) + ring = position.zero? || position == last ? TERMINUS * line_width : line_width + draw_station(io, user, cx, row_y.call(row), ring, color, m) + end + end + end + + # Lattice column of a line's `spot`th station in `row`, serpentine within + # the line's own span. + private def weave_col(start : Int32, cols : Int32, mirror : Bool, row : Int32, spot : Int32) : Int32 + leftward = mirror ? row.even? : row.odd? + start + (leftward ? cols - 1 - spot : spot) + end + + private def draw_weave_route(io : String::Builder, count : Int32, cols : Int32, rows : Int32, + start : Int32, mirror : Bool, m : Metrics, rail : Float64, + row_y : Proc(Int32, Float64), col_x : Proc(Int32, Float64), + color : String) : Nil + path = String.build do |track| + track << "M#{SVG.num(col_x.call(weave_col(start, cols, mirror, 0, 0)))},#{SVG.num(row_y.call(0))}" + current = weave_col(start, cols, mirror, 0, 0) + rows.times do |row| + in_row = Math.min(count - row * cols, cols) + stop = weave_col(start, cols, mirror, row, in_row - 1) + row_line = row_y.call(row) + unless stop == current + track << "L#{SVG.num(col_x.call(stop))},#{SVG.num(row_line)}" + current = stop + end + next if row == rows - 1 + + drop = row_y.call(row + 1) + # The side this row's travel ends on — a single-station row still + # turns on its travelling side. + rightward = mirror ? row.odd? : row.even? + radius = Math.min(rail, (drop - row_line) / 2) + side = rightward ? radius : -radius + sweep = rightward ? 1 : 0 + edge = col_x.call(stop) + track << "A#{SVG.num(radius)} #{SVG.num(radius)} 0 0 #{sweep} #{SVG.num(edge + side)},#{SVG.num(row_line + radius)}" + track << "L#{SVG.num(edge + side)},#{SVG.num(drop - radius)}" if drop - row_line - 2 * radius > 0 + track << "A#{SVG.num(radius)} #{SVG.num(radius)} 0 0 #{sweep} #{SVG.num(edge)},#{SVG.num(drop)}" + end + end + io << %( \n) + end + + private def weave_color(index : Int32) : String + LINE_COLORS[(@line + index) % LINE_COLORS.size] + end + + # --- Shared plumbing --- + + # The lines a section carries. Without `role_lines` a section is one + # line; with it, one per role in order of first appearance, and the + # people who carry no role ride together on an unnamed one. List order + # is kept within every line. + private def buckets(users : Array(EmbeddedUser)) : Array(Line) + return [{nil.as(String?), users}] unless @config.metro.role_lines? + + order = [] of String? + grouped = {} of String? => Array(EmbeddedUser) + users.each do |user| + key = user.role + unless grouped.has_key?(key) + order << key + grouped[key] = [] of EmbeddedUser + end + grouped[key] << user + end + order.map { |key| {key, grouped[key]} } + end + + # {width, height} of one stacked line's serpentine, labels included. + private def line_size(count : Int32, m : Metrics) : {Float64, Float64} + cols = Math.min(@config.metro.columns, count) + rows = (count + cols - 1) // cols + {2 * m.margin + (cols - 1) * m.pitch_x, m.top + (rows - 1) * m.pitch_y + m.bottom} + end + + private def metrics(users : Array(EmbeddedUser)) : Metrics + base_metrics(Math.max(@half_label, half_label(users))) + end + + private def base_metrics(inset : Float64) : Metrics + metro = @config.metro + size = metro.station_size.to_f + line = metro.line_width.to_f + outer = size / 2 + TERMINUS * line + # The pitch funds a terminus ring on both sides, so `gap` stays the + # clearance between rings it is documented to be even next to one. + pitch_x = size + 2 * TERMINUS * line + metro.gap + # A woven rail rides midway between two columns; where the names are on, + # the midpoint also has to clear the label text either side of it. + pitch_x = Math.max(pitch_x, 2 * inset + line + 4) if metro.weave? + pitch_y = 2 * outer + metro.gap + (metro.show_names? ? LABEL_BLOCK : 0.0) + # A full half-pitch corner degenerates the turn into a clean semicircle. + corner = Math.min(pitch_y / 2, size) + # Woven rails swing half a station pitch wide, and the margin has to + # hold whichever loop is wider — stroke included. + reach = metro.weave? ? Math.max(corner, pitch_x / 2) : corner + margin = {reach + line / 2, outer, inset}.max + 2 + Metrics.new( + outer: outer, + pitch_x: pitch_x, + pitch_y: pitch_y, + corner: corner, + margin: margin, + top: outer + 2, + bottom: outer + 2 + (metro.show_names? ? 16.0 : 0.0), + ) + end + + # Station centres in list order: even rows run left to right, odd rows + # come back — the serpentine the route itself follows. + private def centers(count : Int32, m : Metrics) : Array({Float64, Float64}) + cols = Math.min(@config.metro.columns, count) + Array.new(count) do |index| + row, spot = index.divmod(cols) + col = row.even? ? spot : cols - 1 - spot + {m.margin + col * m.pitch_x, m.top + row * m.pitch_y} + end + end + + # The route as one path: a straight run through each row's centres, rows + # joined by two quarter-circle arcs with a short vertical straight between + # them when the rows sit further apart than the corner reaches. Even rows + # turn on the right (clockwise, sweep 1), odd rows on the left. + private def draw_route(io : String::Builder, count : Int32, m : Metrics, y_offset : Float64, color : String) : Nil + cols = Math.min(@config.metro.columns, count) + rows = (count + cols - 1) // cols + near = m.margin + far = m.margin + (cols - 1) * m.pitch_x + straight = m.pitch_y - 2 * m.corner + + path = String.build do |track| + track << "M#{SVG.num(near)},#{SVG.num(m.top + y_offset)}" + current = near + rows.times do |row| + y = m.top + row * m.pitch_y + y_offset + stop = stop_of(row, rows, count, cols, m) + unless stop == current + track << "L#{SVG.num(stop)},#{SVG.num(y)}" + current = stop + end + next if row == rows - 1 + + drop = y + m.pitch_y + edge = row.even? ? far : near + side = row.even? ? m.corner : -m.corner + sweep = row.even? ? 1 : 0 + track << "A#{SVG.num(m.corner)} #{SVG.num(m.corner)} 0 0 #{sweep} #{SVG.num(edge + side)},#{SVG.num(y + m.corner)}" + track << "L#{SVG.num(edge + side)},#{SVG.num(drop - m.corner)}" if straight > 0 + track << "A#{SVG.num(m.corner)} #{SVG.num(m.corner)} 0 0 #{sweep} #{SVG.num(edge)},#{SVG.num(drop)}" + current = edge + end + end + io << %( \n) + end + + # Where the track stops on this row: full rows run wall to wall, the last + # row ends at its final station, whose round cap is the line's tail. + private def stop_of(row : Int32, rows : Int32, count : Int32, cols : Int32, m : Metrics) : Float64 + if row < rows - 1 + row.even? ? m.margin + (cols - 1) * m.pitch_x : m.margin + else + short = count - (rows - 1) * cols + steps = row.even? ? short - 1 : cols - short + m.margin + steps * m.pitch_x + end + end + + private def half_label(users : Array(Labelled)) : Float64 + metro = @config.metro + return 0.0 unless metro.show_names? + widest = users.max_of? { |user| text_width(truncate(user.name, metro.truncate), 11.0) } + (widest || 0.0) / 2 + end + end +end diff --git a/src/contributor_mural/renderers/renderer.cr b/src/contributor_mural/renderers/renderer.cr index 6b02a2b..c8b3725 100644 --- a/src/contributor_mural/renderers/renderer.cr +++ b/src/contributor_mural/renderers/renderer.cr @@ -74,24 +74,28 @@ module ContributorMural def self.for(style : Style, config : Config, mode : ThemeMode? = nil) : Renderer case style - in .grid? then Renderers::Grid.new(config, mode) - in .honeycomb? then Renderers::Honeycomb.new(config, mode) - in .mosaic? then Renderers::Mosaic.new(config, mode) - in .spiral? then Renderers::Spiral.new(config, mode) - in .orbit? then Renderers::Orbit.new(config, mode) - in .voronoi? then Renderers::Voronoi.new(config, mode) - in .stencil? then Renderers::Stencil.new(config, mode) + in .grid? then Renderers::Grid.new(config, mode) + in .honeycomb? then Renderers::Honeycomb.new(config, mode) + in .mosaic? then Renderers::Mosaic.new(config, mode) + in .spiral? then Renderers::Spiral.new(config, mode) + in .orbit? then Renderers::Orbit.new(config, mode) + in .voronoi? then Renderers::Voronoi.new(config, mode) + in .stencil? then Renderers::Stencil.new(config, mode) + in .constellation? then Renderers::Constellation.new(config, mode) + in .skyline? then Renderers::Skyline.new(config, mode) + in .metro? then Renderers::Metro.new(config, mode) end end # Styles that can honour a per-user `scale`: the ones that already derive # a size per user, where an override is exact. The fixed lattices (grid, - # honeycomb, stencil) have nowhere to put an avatar larger than its cell - # without overlapping a neighbour or leaving a hole, and voronoi sizes + # honeycomb, stencil, metro) have nowhere to put an avatar larger than its + # cell without overlapping a neighbour or leaving a hole, and voronoi sizes # cells by cutting the block up rather than by placing a shape — those - # ignore `scale` rather than approximate it. + # ignore `scale` rather than approximate it. Skyline honours it in the one + # dimension it is free in: the emphasised tower grows taller, not wider. def self.honors_scale?(style : Style) : Bool - style.mosaic? || style.spiral? || style.orbit? + style.mosaic? || style.spiral? || style.orbit? || style.constellation? || style.skyline? end # Style-wide , emitted once per document. @@ -127,6 +131,30 @@ module ContributorMural 8.0 end + # Deterministic noise in [0, 1), addressed by index rather than drawn from + # a running generator: value k depends only on k and the salt, so layouts + # survive being computed once for sizing and again for drawing. One copy + # for every renderer, because the exact bits are golden-file contract. + protected def noise(index : Int32, salt : UInt64) : Float64 + mix((index.to_u64 &+ salt &* 0x9e3779b97f4a7c15_u64) &* 6364136223846793005_u64 &+ 1442695040888963407_u64) + end + + # The same noise keyed by a string — for per-person variation that has to + # survive the list around it changing. + protected def hash01(text : String, salt : UInt64) : Float64 + state = 0xcbf29ce484222325_u64 + text.each_byte { |byte| state = (state ^ byte) &* 0x100000001b3_u64 } + mix(state &+ salt &* 0x9e3779b97f4a7c15_u64) + end + + # The divisor is a power of two, so the result is exact in binary floating + # point — which is what keeps the golden files stable. + private def mix(state : UInt64) : Float64 + state = (state ^ (state >> 33)) &* 0xff51afd7ed558ccd_u64 + state ^= state >> 29 + ((state >> 43) & 0x1fffff).to_f64 / 0x200000.to_f64 + end + protected def theme : ThemeConfig @config.theme end diff --git a/src/contributor_mural/renderers/skyline.cr b/src/contributor_mural/renderers/skyline.cr new file mode 100644 index 0000000..dba1a35 --- /dev/null +++ b/src/contributor_mural/renderers/skyline.cr @@ -0,0 +1,351 @@ +module ContributorMural::Renderers + # A city skyline: each contributor is a building on a shared street, its + # height set by their weight — the people carrying the project are the + # towers — with the avatar up top like a rooftop billboard and a hash-lit + # grid of windows below it. Buildings wrap into further rows past the + # configured width, each row bottom-aligned on its own ground strip. + class Skyline < Renderer + CLIP_ID = "skyline-clip" + + # Side padding between a building's wall and its avatar; a building is + # `avatar_size + 2 * PAD` wide. + PAD = 6.0 + # The roof zone every silhouette variation stays inside, so the avatar + # always sits against the full-width body underneath it. + ROOF_BAND = 8.0 + ANTENNA_W = 2.0 + ANTENNA_H = 12.0 + GROUND_H = 2.0 + # Headroom over each row's tallest extent. + TOP_PAD = 4.0 + ROW_GAP = 24.0 + LABEL_H = 16.0 + # Share of windows lit. + LIT_RATIO = 0.5 + # Height jitter as a share of the smallest step between weight ranks — + # under half, so jitter can never reorder what the weights decided. + JITTER_SHARE = 0.35 + + SALT_HEIGHT = 1_u64 + + # How many roof silhouettes `draw_roof` knows. + ROOF_TYPES = 6 + + # A clean daytime silhouette on light walls, a dusk city on dark ones. + # Hardcoded per mode like orbit's ring: the four-colour palette record has + # no slot for architecture, and a per-style palette is not worth its keys. + LIGHT_INKS = {building: "#d0d7de", lit: "#fff8c5", unlit: "#afb8c1", ground: "#afb8c1"} + DARK_INKS = {building: "#21262d", lit: "#f2cc60", unlit: "#30363d", ground: "#30363d"} + + # Anyone whose label reaches the document, whether or not their avatar has + # been fetched yet. + private alias Labelled = ResolvedUser | EmbeddedUser + + @heights = {} of String => Float64 + @antenna = Set(String).new + @gutter = 0.0 + @section = 0 + + # Heights rank the *distinct* weights across the whole document — a + # thousandfold outlier lands at the top of the band exactly like a + # twofold one, and a section of equals renders low rather than faking a + # skyline of towers. Keyed by login, so sectioning cannot move anyone. + def prepare(users : Array(ResolvedUser)) : Nil + skyline = @config.skyline + range = (skyline.max_height - skyline.min_height).to_f + distinct = users.map(&.weight).uniq!.sort! + + if distinct.size <= 1 + # Everyone on one rung: spread over the whole band by hash, or the + # wall is a single flat roofline. + users.each do |user| + @heights[user.login] = (skyline.min_height + range * hash01(user.login, SALT_HEIGHT)) * user.scale + end + else + span = (distinct.size - 1).to_f + base = {} of Int32 => Float64 + distinct.each_with_index do |weight, index| + # The spiral's power taper, measured down from the top: the leaders + # drop fast and the long tail flattens into an even skyline. + base[weight] = skyline.max_height - range * ((span - index) / span) ** 0.45 + end + # Jitter breaks up the plateau a shared weight makes. Bounded by a + # third of the smallest step between ranks, so the jittered bands + # stay disjoint and the roofline keeps the order the weights set. + min_gap = Float64::MAX + distinct.each_cons(2, reuse: true) do |pair| + step = base[pair[1]] - base[pair[0]] + min_gap = step if step < min_gap + end + amp = JITTER_SHARE * min_gap + top = distinct.last + users.each do |user| + height = base[user.weight] + # The top rank is the anchor: pinned to `max_height` exactly. + height += amp * (2 * hash01(user.login, SALT_HEIGHT) - 1) unless user.weight == top + height = height.clamp(skyline.min_height.to_f, skyline.max_height.to_f) + # `scale` grows the tower, not the avatar: horizontally this is a + # fixed lattice, but the sky is free. + @heights[user.login] = height * user.scale + end + end + + # One mast per section's skyline: its tallest tower, first on ties. + users.group_by(&.group).each_value do |members| + mast = members.first + members.each do |member| + mast = member if height_for(member.login) > height_for(mast.login) + end + @antenna << mast.login + end + @gutter = overhang(users) + end + + def fetch_size(user : ResolvedUser) : Int32 + @config.skyline.avatar_size * 2 + end + + # Building cosmetics are salted per section, so a renderer reused for a + # second document has to start counting over. + def render(groups : Array({String?, Array(EmbeddedUser)})) : String + @section = 0 + super + end + + protected def title_inset : Float64 + @config.skyline.gap + @gutter + end + + protected def defs(io : String::Builder) : Nil + shape_clip(io, CLIP_ID, @config.skyline.shape) + end + + protected def style_rules(palette : Palette) : String + inks = palette == theme.dark_palette ? DARK_INKS : LIGHT_INKS + rules = ".mural-building{fill:#{inks[:building]}}.mural-ground{fill:#{inks[:ground]}}" + if @config.skyline.windows? + rules += ".mural-window-lit{fill:#{inks[:lit]}}.mural-window-dark{fill:#{inks[:unlit]}}" + end + rules + end + + protected def block_size(users : Array(EmbeddedUser)) : {Float64, Float64} + return {16.0, 16.0} if users.empty? + _rows, width, height = layout(users) + {width, height} + end + + protected def draw_block(io : String::Builder, users : Array(EmbeddedUser), y_offset : Float64) : Nil + return if users.empty? + + skyline = @config.skyline + rows, width, _height = layout(users) + wall = skyline.avatar_size + 2 * PAD + left = skyline.gap + gutter_for(users) + clipped = !skyline.shape.square? + + y = y_offset + building = 0 + rows.each_with_index do |row, index| + y += ROW_GAP if index.positive? + baseline = y + TOP_PAD + extent(row) + io << %( \n) + row.each_with_index do |user, column| + draw_building(io, user, building, left + column * (wall + skyline.gap), wall, baseline, clipped) + building += 1 + end + y = baseline + GROUND_H + y += LABEL_H if skyline.show_names? + end + + @section += 1 + end + + # {rows, block width, block height}; pure, so sizing and drawing agree. + private def layout(users : Array(EmbeddedUser)) : {Array(Array(EmbeddedUser)), Float64, Float64} + skyline = @config.skyline + rows = users.each_slice(columns).to_a + used = Math.min(columns, users.size) + inset = skyline.gap + gutter_for(users) + width = 2 * inset + used * (skyline.avatar_size + 2 * PAD) + (used - 1) * skyline.gap + + height = 0.0 + rows.each_with_index do |row, index| + height += ROW_GAP if index.positive? + height += TOP_PAD + extent(row) + GROUND_H + height += LABEL_H if skyline.show_names? + end + {rows, width, height} + end + + private def columns : Int32 + skyline = @config.skyline + pitch = skyline.avatar_size + (2 * PAD).to_i + skyline.gap + Math.max((skyline.width + skyline.gap) // pitch, 1) + end + + # A row is as tall as its tallest building, mast included. + private def extent(row : Array(EmbeddedUser)) : Float64 + row.max_of do |user| + height_for(user.login) + (@antenna.includes?(user.login) ? ANTENNA_H : 0.0) + end + end + + private def draw_building(io : String::Builder, user : EmbeddedUser, index : Int32, x : Float64, + wall : Float64, baseline : Float64, clipped : Bool) : Nil + skyline = @config.skyline + height = height_for(user.login) + top = baseline - height + salt = @section.to_u64 &* 104729_u64 &+ 3 + + # A per-building wall inset (0–2px a side) breaks the block out of one + # uniform width without leaving the lattice: the inset always stays + # under PAD, so the avatar and the windows keep their footing. + inset = (noise(index * 3, salt) * 3).to_i.to_f + body_x = x + inset + body_w = wall - 2 * inset + + io << %( \n) + io << %( #{SVG.escape(title_for(user))}\n) + io << %( \n) + draw_roof(io, (noise(index * 3 + 1, salt) * ROOF_TYPES).to_i, body_x, body_w, top, height) + if @antenna.includes?(user.login) + io << %( \n) + end + io << " \n" + draw_windows(io, index, salt, x, top, height) if skyline.windows? + io << %( \n" + if skyline.show_names? + label(io, truncate(user.name, skyline.truncate), x + wall / 2, baseline + GROUND_H + 12) + end + io << " \n" + end + + # Six roof silhouettes, all confined to the top ROOF_BAND so the avatar + # always sits against the full-width body underneath, and every one of + # them reaches `top` so a building measures its full height. + private def draw_roof(io : String::Builder, pick : Int32, x : Float64, wall : Float64, + top : Float64, height : Float64) : Nil + body = ->(from : Float64) do + io << %( \n) + end + crown = ->(cx : Float64, cw : Float64, cy : Float64, ch : Float64) do + io << %( \n) + end + + case pick + when 1 + # Set-back parapet: a slightly narrower crown over the full body. + body.call(ROOF_BAND) + crown.call(x + 3, wall - 6, 0.0, ROOF_BAND) + when 2 + # Penthouse: a half-width cabin on the roof. + body.call(ROOF_BAND) + crown.call(x + wall / 4, wall / 2, 0.0, ROOF_BAND) + when 3 + # Ziggurat: two steps up to a narrow top. + body.call(ROOF_BAND) + crown.call(x + 2, wall - 4, ROOF_BAND / 2, ROOF_BAND / 2) + crown.call(x + wall / 4, wall / 2, 0.0, ROOF_BAND / 2) + when 4 + # Crenellated parapet: a block on each shoulder. + body.call(ROOF_BAND) + crown.call(x, wall * 0.28, 0.0, ROOF_BAND) + crown.call(x + wall * 0.72, wall * 0.28, 0.0, ROOF_BAND) + when 5 + # Twin pylons, the cooling-stack look. + body.call(ROOF_BAND) + crown.call(x + wall * 0.18, 5.0, 0.0, ROOF_BAND) + crown.call(x + wall * 0.82 - 5.0, 5.0, 0.0, ROOF_BAND) + else + # Flat. + body.call(0.0) + end + end + + # Panes on a grid derived from the avatar's own column count, in the zone + # between the avatar's floor and the building's, so avoiding the face is + # a property of the geometry rather than a check. Each building draws one + # glazing — square offices or long ribbon windows — from the same lottery + # as its roof. A short tower whose zone holds no full row stays solid. + private def draw_windows(io : String::Builder, index : Int32, salt : UInt64, + x : Float64, top : Float64, height : Float64) : Nil + size = @config.skyline.avatar_size + cols = (size // 12).clamp(2, 6) + pitch = size / cols + zone_top = top + ROOF_BAND + PAD + size + PAD + zone = top + height - PAD - zone_top + floors = (zone / pitch).floor.to_i + return if floors < 1 + + ribbon = noise(index * 3 + 2, salt) < 0.5 + pane_w = ribbon ? pitch * 0.62 : pitch / 2 + pane_h = ribbon ? pitch * 0.34 : pitch / 2 + off_x = ribbon ? pitch * 0.19 : pitch / 4 + off_y = ribbon ? pitch * 0.33 : pitch / 4 + + pane_salt = salt &+ index.to_u64 &* 7919_u64 + lit = [] of {Float64, Float64} + unlit = [] of {Float64, Float64} + floors.times do |floor| + cols.times do |column| + pane_x = x + PAD + column * pitch + off_x + pane_y = zone_top + floor * pitch + off_y + state = noise(floor * cols + column, pane_salt) + (state < LIT_RATIO ? lit : unlit) << {pane_x, pane_y} + end + end + + draw_panes(io, lit, pane_w, pane_h, "lit") + draw_panes(io, unlit, pane_w, pane_h, "dark") + end + + # One group per state, so the fill is inherited rather than repeated. + private def draw_panes(io : String::Builder, panes : Array({Float64, Float64}), + pane_w : Float64, pane_h : Float64, state : String) : Nil + return if panes.empty? + io << %( \n) + panes.each do |(x, y)| + io << %( \n) + end + io << " \n" + end + + private def height_for(login : String) : Float64 + @heights[login]? || @config.skyline.min_height.to_f + end + + # The gutter this section is drawn with — grid's document-wide measure, so + # every row of every section starts its buildings on the same left edge. + private def gutter_for(users : Array(EmbeddedUser)) : Float64 + Math.max(@gutter, overhang(users)) + end + + private def overhang(users : Array(Labelled)) : Float64 + skyline = @config.skyline + return 0.0 unless skyline.show_names? + widest = users.max_of? { |user| text_width(truncate(user.name, skyline.truncate), 11.0) } + return 0.0 unless widest + Math.max(widest - (skyline.avatar_size + 2 * PAD), 0.0) / 2 + end + + private def inks + mode.dark? ? DARK_INKS : LIGHT_INKS + end + + private def building_paint : String + mode.auto? ? %(class="mural-building") : %(fill="#{inks[:building]}") + end + + private def ground_paint : String + mode.auto? ? %(class="mural-ground") : %(fill="#{inks[:ground]}") + end + + private def window_paint(state : String) : String + return %(class="mural-window-#{state}") if mode.auto? + %(fill="#{state == "lit" ? inks[:lit] : inks[:unlit]}") + end + end +end diff --git a/src/contributor_mural/renderers/voronoi.cr b/src/contributor_mural/renderers/voronoi.cr index fab1179..05d8cff 100644 --- a/src/contributor_mural/renderers/voronoi.cr +++ b/src/contributor_mural/renderers/voronoi.cr @@ -156,18 +156,6 @@ module ContributorMural::Renderers Array.new(rows) { |row| base + (row < extra ? 1 : 0) } end - # Jitter is addressed by index rather than drawn from a running generator: - # seed k depends only on k, so placement is pure and survives being called - # once for sizing and again for drawing. - private def noise(index : Int32, salt : UInt64) : Float64 - state = (index.to_u64 &+ salt &* 0x9e3779b97f4a7c15_u64) &* 6364136223846793005_u64 &+ 1442695040888963407_u64 - state = (state ^ (state >> 33)) &* 0xff51afd7ed558ccd_u64 - state ^= state >> 29 - # The divisor is a power of two, so this is exact in binary floating - # point — which is what keeps the golden files stable. - ((state >> 43) & 0x1fffff).to_f64 / 0x200000.to_f64 - end - private def place(users : Array(EmbeddedUser), width : Float64, height : Float64, rows : Int32) : Array(Seed) return [] of Seed if rows.zero? diff --git a/src/contributor_mural/runner.cr b/src/contributor_mural/runner.cr index a6cdef2..2f0b982 100644 --- a/src/contributor_mural/runner.cr +++ b/src/contributor_mural/runner.cr @@ -175,7 +175,7 @@ module ContributorMural return if ignored.empty? Annotations.warning("per-user `scale` is ignored by #{ignored.map(&.to_s.downcase).join(", ")} — " \ - "mosaic, spiral, and orbit are the styles that size each avatar") + "mosaic, spiral, orbit, constellation, and skyline are the styles that honour it") end # Checked up front so a missing rasterizer fails before any file is