vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Give every landscape a second and third hue
df894c4f529477af69f50f52662bb7517843c3c9 · Keenan Nemetz · 7d ago · parent 51381592
Give every landscape a second and third hue A mark was one palette hue in shades of itself, so a field of avatars was seven colours in seven degrees of darkness. That is not enough for an avatar to tell people apart. The sky is still the account's tone, but the hills now take other tones from the same seven, chosen from a name-seeded shuffle and never the sky's own hue. Each hill is shaded toward the palette's darkest ground rather than toward black, which darkens it and ties different hues into one landscape. Every mark therefore carries at least two hues, and most carry three plus the disc. The seven grounds moved to :root so the bundle reads the palette from vole.css instead of carrying a second copy of it. A tone class now just points at one. Colour sets go from 21 per theme to 7 skies x (30 two-hill orders + 120 three-hill orders) x 3 discs = 3,150, before ridge, radius and disc position vary them further. keenan, which was clay and four shades of clay, is now clay with an olive hill, a slate hill and a moon.
scripts/avatars-entry.js +45−9
| @@ -23,15 +23,24 @@ const moonColors = ["f0e6d2", "e7e8ea", "cdd6d5"]; | |||
| 23 | 23 | ||
| 24 | 24 | // Full daylight would wash out at 20px and read as a pastel dot; this is the | |
| 25 | 25 | // last hour of it. The mark is drawn in the 100-unit box DiceBear gives a style, | |
| 26 | // and the ground comes from the element so vole.css stays the only palette: the | ||
| 27 | // sky is the account's tone and the hills are steps darker than it, which is what | ||
| 28 | // makes a landscape read at 17px without an outline. | ||
| 29 | const depth = [0.34, 0.5, 0.62]; | ||
| 26 | // and the palette comes from vole.css: the sky is the account's tone, the hills | ||
| 27 | // take other tones from the same seven, and all of them are dark enough to read | ||
| 28 | // at 17px without an outline. | ||
| 29 | const depth = [0.4, 0.56, 0.68]; | ||
| 30 | 30 | ||
| 31 | // Hills are shaded toward the palette's darkest ground rather than toward black: | ||
| 32 | // it darkens them and ties different hues together, so a clay sky with river | ||
| 33 | // hills still reads as one landscape. | ||
| 34 | const ink = "272e33"; | ||
| 31 | 35 | const shade = (hex, step) => { | |
| 32 | const n = parseInt(hex, 16); | ||
| 33 | const channel = (v) => Math.round(v * (1 - step)).toString(16).padStart(2, "0"); | ||
| 34 | return channel((n >> 16) & 255) + channel((n >> 8) & 255) + channel(n & 255); | ||
| 36 | const a = parseInt(hex, 16); | ||
| 37 | const b = parseInt(ink, 16); | ||
| 38 | const channel = (shift) => { | ||
| 39 | const va = (a >> shift) & 255; | ||
| 40 | const vb = (b >> shift) & 255; | ||
| 41 | return Math.round(va + (vb - va) * step).toString(16).padStart(2, "0"); | ||
| 42 | }; | ||
| 43 | return channel(16) + channel(8) + channel(0); | ||
| 35 | 44 | }; | |
| 36 | 45 | ||
| 37 | 46 | // DiceBear ships no landscape style, so this one is Vole's. It uses only the | |
| @@ -49,6 +58,7 @@ const landscape = { | |||
| 49 | 58 | type: "object", | |
| 50 | 59 | properties: { | |
| 51 | 60 | skyColor: { type: "array", items: { type: "string" }, default: ["6b7561"] }, | |
| 61 | palette: { type: "array", items: { type: "string" }, default: [] }, | ||
| 52 | 62 | discColor: { type: "array", items: { type: "string" }, default: sunColors }, | |
| 53 | 63 | hillCount: { type: "array", items: { type: "integer", minimum: 2, maximum: 3 }, default: [2, 3] }, | |
| 54 | 64 | }, | |
| @@ -59,6 +69,15 @@ const landscape = { | |||
| 59 | 69 | const hills = prng.pick(options.hillCount ?? [2, 3], 2); | |
| 60 | 70 | const body = [`<rect width="100" height="100" fill="#${sky}"/>`]; | |
| 61 | 71 | ||
| 72 | // Hills take hues from the rest of the palette, never the sky's own, so every | ||
| 73 | // mark has at least two colours. Shuffling keeps the order stable per seed | ||
| 74 | // while making the pairing depend on the name rather than on the sky. | ||
| 75 | const ground = (options.palette ?? []).filter((c) => c && c !== sky); | ||
| 76 | for (let i = ground.length - 1; i > 0; i--) { | ||
| 77 | const j = prng.integer(0, i); | ||
| 78 | [ground[i], ground[j]] = [ground[j], ground[i]]; | ||
| 79 | } | ||
| 80 | |||
| 62 | 81 | // The sun or moon sits above the highest ridge: the tallest a hill may rise | |
| 63 | 82 | // is 66, so the highest a ridge can reach is y=34 and the disc never sets | |
| 64 | 83 | // behind it. | |
| @@ -67,12 +86,14 @@ const landscape = { | |||
| 67 | 86 | ); | |
| 68 | 87 | ||
| 69 | 88 | // Each hill is a circle whose centre is below the box, so only the ridge | |
| 70 | // shows. Drawing them back to front, each a step darker, gives the depth. | ||
| 89 | // shows. Back to front, each one is a palette hue shaded a step darker, so | ||
| 90 | // the depth reads without an outline. | ||
| 71 | 91 | for (let i = 0; i < hills; i++) { | |
| 72 | 92 | const r = prng.integer(70, 130); | |
| 73 | 93 | const rise = prng.integer(40, 66) - i * prng.integer(5, 10); | |
| 94 | const hue = ground.length ? ground[i % ground.length] : sky; | ||
| 74 | 95 | body.push( | |
| 75 | `<circle cx="${prng.integer(-20, 120)}" cy="${100 + r - rise}" r="${r}" fill="#${shade(sky, depth[i])}"/>`, | ||
| 96 | `<circle cx="${prng.integer(-20, 120)}" cy="${100 + r - rise}" r="${r}" fill="#${shade(hue, depth[i])}"/>`, | ||
| 76 | 97 | ); | |
| 77 | 98 | } | |
| 78 | 99 | return { attributes: { viewBox: "0 0 100 100" }, body: body.join("") }; | |
| @@ -91,6 +112,20 @@ const ground = (el) => { | |||
| 91 | 112 | .join(""); | |
| 92 | 113 | }; | |
| 93 | 114 | ||
| 115 | // The seven grounds, read once from the root where vole.css keeps them. Reading | ||
| 116 | // them keeps this file from carrying a second copy of the palette. | ||
| 117 | let palette; | ||
| 118 | const grounds = () => { | ||
| 119 | if (palette) return palette; | ||
| 120 | const css = getComputedStyle(document.documentElement); | ||
| 121 | palette = []; | ||
| 122 | for (let i = 1; i <= 7; i++) { | ||
| 123 | const value = css.getPropertyValue(`--av-c${i}`).trim(); | ||
| 124 | if (/^#[0-9a-f]{6}$/i.test(value)) palette.push(value.slice(1)); | ||
| 125 | } | ||
| 126 | return palette; | ||
| 127 | }; | ||
| 128 | |||
| 94 | 129 | // The theme is on <html> and one of the six Everforest variants, because | |
| 95 | 130 | // theme.js resolves "system" to a concrete one before the first paint. | |
| 96 | 131 | const moonlit = () => (document.documentElement.getAttribute("data-theme") || "").includes("dark"); | |
| @@ -108,6 +143,7 @@ const render = (el) => { | |||
| 108 | 143 | clip: false, | |
| 109 | 144 | backgroundColor: [], | |
| 110 | 145 | skyColor: sky ? [sky] : undefined, | |
| 146 | palette: grounds(), | ||
| 111 | 147 | discColor: moonlit() ? moonColors : sunColors, | |
| 112 | 148 | hillCount: [2, 3], | |
| 113 | 149 | }).toString(); | |