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
+```
+
+
+
+| 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
+```
+
+
+
+| 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
+```
+
+
+
+| 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 @@
+
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 @@
+
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 @@
-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