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