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.
Draw landscapes instead of DiceBear's Shapes style
2a8c7fbf4b2670a386ab2c203db20bb90489263a · Keenan Nemetz · 7d ago · parent 97f77001
Draw landscapes instead of DiceBear's Shapes style DiceBear ships no landscape style, so the bundle now carries one written against its core API. The engine that matters is still DiceBear's — the seeded PRNG, the option handling, the 100×100 box and the circular mask — while the style is part of Vole and carries this project's licence. That drops the Shapes licence file and leaves DiceBear's own MIT notice. The art is one idea: the account's tone is the sky, two or three hills are the same colour a step darker, and the sun is drawn from Everforest's cream, yellow or sunset orange. Each hill is a circle whose centre sits below the box, so only its ridge shows; drawing them back to front with one step of shade between them is what gives the depth at 17px without an outline. The sun is placed above the highest ridge, so it is never lost behind a hill, and a low ridge can swallow its foot like a sunset. The bundle is now 10KB (4KB gzipped) and an avatar is about 470 bytes of SVG, down from 18KB and 800 because Shapes was the bigger style. The server's half is unchanged: the initial on the account's ground is drawn first and stays if the script never runs.
scripts/avatars-entry.js +79−20
| @@ -1,54 +1,113 @@ | |||
| 1 | /*! Vole avatars: DiceBear core 9.3.1 + Shapes 9.4.2, bundled into one script. | ||
| 2 | * MIT (code) and CC0 1.0 (Shapes design). See server/web/static/dicebear.LICENSE | ||
| 3 | * and server/web/static/dicebear-shapes.LICENSE. | ||
| 1 | /*! Vole avatars: a landscape drawn by DiceBear core 9.3.1. | ||
| 2 | * | ||
| 3 | * DiceBear is MIT (see server/web/static/dicebear.LICENSE); the Landscape style | ||
| 4 | * below is part of Vole and carries the same licence as the rest of the program | ||
| 5 | * (Apache-2.0). | ||
| 4 | 6 | * | |
| 5 | 7 | * Built with esbuild. To rebuild after changing the palette or the versions: | |
| 6 | 8 | * | |
| 7 | 9 | * mkdir -p /tmp/dicebear && cd /tmp/dicebear && npm init -y | |
| 8 | * npm i @dicebear/core@9.3.1 @dicebear/shapes@9.4.2 esbuild@0.25.9 | ||
| 10 | * npm i @dicebear/core@9.3.1 esbuild@0.25.9 | ||
| 9 | 11 | * npx esbuild /path/to/scripts/avatars-entry.js \ | |
| 10 | 12 | * --bundle --minify --format=iife \ | |
| 11 | 13 | * --outfile=/path/to/server/web/static/dicebear-avatars.min.js | |
| 12 | 14 | * | |
| 13 | 15 | * Then run `vole workflow run verify` so the licence inventory is checked. */ | |
| 14 | 16 | import { createAvatar } from "@dicebear/core"; | |
| 15 | import * as shapes from "@dicebear/shapes"; | ||
| 17 | |||
| 18 | // The sun, from the Everforest palette: cream, yellow, and the warm orange of a | ||
| 19 | // low sunset. | ||
| 20 | const sunColors = ["f0e6d2", "dbbc7f", "e69875"]; | ||
| 21 | |||
| 22 | // Full daylight would wash out at 20px and read as a pastel dot; this is the | ||
| 23 | // last hour of it. The mark is drawn in the 100-unit box DiceBear gives a style, | ||
| 24 | // and the ground comes from the element so vole.css stays the only palette: the | ||
| 25 | // sky is the account's tone and the hills are steps darker than it, which is what | ||
| 26 | // makes a landscape read at 17px without an outline. | ||
| 27 | const depth = [0.34, 0.5, 0.62]; | ||
| 28 | |||
| 29 | const shade = (hex, step) => { | ||
| 30 | const n = parseInt(hex, 16); | ||
| 31 | const channel = (v) => Math.round(v * (1 - step)).toString(16).padStart(2, "0"); | ||
| 32 | return channel((n >> 16) & 255) + channel((n >> 8) & 255) + channel(n & 255); | ||
| 33 | }; | ||
| 34 | |||
| 35 | // DiceBear ships no landscape style, so this one is Vole's. It uses only the | ||
| 36 | // engine's contract — a seeded prng, the declared options, and a body for the | ||
| 37 | // 100×100 box — so seeding, the background, and the circular mask are still | ||
| 38 | // DiceBear's. | ||
| 39 | const landscape = { | ||
| 40 | meta: { | ||
| 41 | title: "Landscape", | ||
| 42 | source: "https://github.com/rodent-software/vole", | ||
| 43 | creator: "Vole", | ||
| 44 | license: { name: "Apache-2.0", url: "https://www.apache.org/licenses/LICENSE-2.0" }, | ||
| 45 | }, | ||
| 46 | schema: { | ||
| 47 | type: "object", | ||
| 48 | properties: { | ||
| 49 | skyColor: { type: "array", items: { type: "string" }, default: ["6b7561"] }, | ||
| 50 | sunColor: { type: "array", items: { type: "string" }, default: sunColors }, | ||
| 51 | hillCount: { type: "array", items: { type: "integer", minimum: 2, maximum: 3 }, default: [2, 3] }, | ||
| 52 | }, | ||
| 53 | }, | ||
| 54 | create({ prng, options }) { | ||
| 55 | const sky = prng.pick(options.skyColor ?? ["6b7561"], "6b7561"); | ||
| 56 | const suns = options.sunColor ?? sunColors; | ||
| 57 | const hills = prng.pick(options.hillCount ?? [2, 3], 2); | ||
| 58 | const body = [`<rect width="100" height="100" fill="#${sky}"/>`]; | ||
| 59 | |||
| 60 | // The sun sits above the highest ridge: the tallest a hill may rise is 66. | ||
| 61 | body.push( | ||
| 62 | `<circle cx="${prng.integer(16, 84)}" cy="${prng.integer(18, 32)}" r="${prng.integer(6, 10)}" fill="#${prng.pick(suns)}"/>`, | ||
| 63 | ); | ||
| 64 | |||
| 65 | // Each hill is a circle whose centre is below the box, so only the ridge | ||
| 66 | // shows. Drawing them back to front, each a step darker, gives the depth. | ||
| 67 | for (let i = 0; i < hills; i++) { | ||
| 68 | const r = prng.integer(70, 130); | ||
| 69 | const rise = prng.integer(40, 66) - i * prng.integer(5, 10); | ||
| 70 | body.push( | ||
| 71 | `<circle cx="${prng.integer(-20, 120)}" cy="${100 + r - rise}" r="${r}" fill="#${shade(sky, depth[i])}"/>`, | ||
| 72 | ); | ||
| 73 | } | ||
| 74 | return { attributes: { viewBox: "0 0 100 100" }, body: body.join("") }; | ||
| 75 | }, | ||
| 76 | }; | ||
| 16 | 77 | ||
| 17 | 78 | // The ground comes from the element, not from this file: vole.css owns the seven | |
| 18 | 79 | // Everforest grounds, and reading the computed colour keeps the initial the | |
| 19 | // server renders and the finished mark on the same ground. | ||
| 80 | // server renders and the finished landscape on the same sky. | ||
| 20 | 81 | const ground = (el) => { | |
| 21 | 82 | const parts = (getComputedStyle(el).backgroundColor.match(/\d+/g) || []).map(Number); | |
| 83 | if (parts.length < 3) return ""; | ||
| 22 | 84 | return parts | |
| 23 | 85 | .slice(0, 3) | |
| 24 | 86 | .map((n) => n.toString(16).padStart(2, "0")) | |
| 25 | 87 | .join(""); | |
| 26 | 88 | }; | |
| 27 | 89 | ||
| 28 | // Everforest's cream, dark ground, green, blue and yellow. The ground makes the | ||
| 29 | // sixth colour, so a mark is at most two of these plus its ground. | ||
| 30 | const marks = ["f0e6d2", "2d353b", "a7c080", "7fbbb3", "dbbc7f"]; | ||
| 31 | |||
| 32 | // SVG ids are document-wide, and every DiceBear mark names its clip mask the same | ||
| 33 | // way. A directory page holds fifty of them, so each mark gets its own id. | ||
| 90 | // SVG ids are document-wide, and DiceBear names its clip mask the same way for | ||
| 91 | // every mark. A directory page holds fifty of them, so each gets its own id. | ||
| 34 | 92 | let drawn = 0; | |
| 35 | 93 | ||
| 36 | 94 | const render = (el) => { | |
| 37 | 95 | if (el.querySelector("svg")) return; | |
| 38 | 96 | const seed = el.getAttribute("data-avatar"); | |
| 39 | 97 | if (!seed) return; | |
| 40 | const svg = createAvatar(shapes, { | ||
| 98 | const sky = ground(el); | ||
| 99 | const svg = createAvatar(landscape, { | ||
| 41 | 100 | seed, | |
| 42 | 101 | size: 40, | |
| 43 | 102 | radius: 50, | |
| 44 | backgroundColor: [ground(el)], | ||
| 45 | shape1Color: marks, | ||
| 46 | shape2Color: marks, | ||
| 47 | shape3Color: marks, | ||
| 103 | backgroundColor: [], | ||
| 104 | skyColor: sky ? [sky] : undefined, | ||
| 105 | sunColor: sunColors, | ||
| 106 | hillCount: [2, 3], | ||
| 48 | 107 | }).toString(); | |
| 49 | // The RDF metadata block names the style and its licence. It is identical for | ||
| 50 | // every avatar and the licence is in NOTICE, so repeating it fifty times in a | ||
| 51 | // directory page would be weight without information. | ||
| 108 | // The RDF block names the style and its licence. It is identical for every | ||
| 109 | // avatar, so repeating it fifty times on a directory page is weight without | ||
| 110 | // information; the licence is in NOTICE. | ||
| 52 | 111 | el.innerHTML = svg | |
| 53 | 112 | .replace(/<metadata[\s\S]*?<\/metadata>/g, "") | |
| 54 | 113 | .replace(/viewboxMask/g, `viewboxMask-${++drawn}`); | |