Skip to content

Repository files navigation

picotile

WebsiteDocumentation

npm version Build status Gitpod Ready-to-Code install size npm bundle size npm downloads Gitter chat Contributors Agent Friendly Open in GitHub Codespaces

picotile avatar for the name Gafelson

Deterministic dithered dot avatars. A name and a seed go in; a two-tone tile comes out — circles arranged by ordered dithering, washing from empty through a half-tone band into solid.

Same input, same tile. Forever, on every platform. No dependencies.

npm install picotile
import { avatar, avatarDataUri, generate } from 'picotile';

const svg = avatar('Gafelson', { seed: 42 });          // SVG string
const uri = avatarDataUri('Gafelson', { seed: 42 });   // <img src={uri} />
const pattern = generate('Gafelson', { seed: 42 });    // raw dot coordinates

How it works

Two independent halves, which is the idea worth keeping hold of: the field decides how dense each part of the tile should be, and the dither decides how to draw that density in circles. Change one and the other still behaves.

  1. Seed. name and seed are hashed (xmur3) into the state of an sfc32 PRNG. Every later decision draws from that one stream, which is what makes the result reproducible.
  2. Field. A smooth scalar landscape over the tile — a ramp, a radial falloff, a conic sweep or concentric rings, plus two or three gentle harmonics that bend it. Low frequencies only, so it reads as a warped gradient rather than as texture fighting the dither.
  3. Shaping. Field values are mapped onto a density profile by rank: the lowest cells become empty, the highest become solid, and the middle gets a linear ramp between them. See below for why this is by rank.
  4. Dither. A cell is lit when its density beats a Bayer threshold. Bayer spreads thresholds so evenly that a half-lit region resolves into a clean checkerboard, and the crossings either side of it into regular half-tone bands. Random thresholds would just give noise.
  5. Render. One circle per lit cell, all the same radius, two flat colours.

The four field types, same name and seed (avatar('Gafelson', { seed: 42, field })):

linear field radial field sweep field ripple field

linear   radial   sweep   ripple

Why rank, not a curve

The obvious approach is to fit a curve to the raw field — gamma, gain, an offset. It doesn't work, and the reason is worth stating: the result is then at the mercy of whatever histogram the field happens to have. A radial falloff piles most of its cells near one end, so any fixed curve collapses the tile into a solid blob with a hard edge and no half-tone at all.

Ranking sidesteps that. The field decides which cells are densest; the profile decides how dense that ends up being. So every pattern gets a real gradient whatever shape produced it, and coverage and contrast mean the same thing in all four field modes. A test asserts the half-tone band survives on hundreds of generated tiles, which is exactly the regression to guard against.

API

avatar(name, options?): string

avatarDataUri(name, options?): string

generate(name, options?): Pattern

renderSvg(pattern, options?): string

generate and renderSvg are split so you can draw the dots yourself — canvas, WebGL, a plotter, CSS boxes. A Pattern is plain data:

{
  name: string;
  seed: string;
  gridSize: number;
  field: 'linear' | 'radial' | 'sweep' | 'ripple';
  coverage: number;
  contrast: number;
  ditherSize: number;
  dots: { x: number; y: number; value: number }[];
  palette: { background: string; dot: string };
}

Generate options

option default meaning
seed '' string or number; changes the tile completely
gridSize 32 cells per side
field 'auto' linear, radial, sweep, ripple
coverage from seed fraction of cells lit, 0..1
contrast from seed 0 a slow wash across the whole tile, 1 a hard edge
ditherSize 4 Bayer matrix size: 2, 4 or 8. Larger is finer
palette from seed override background and/or dot

coverage is honest: ask for 0.35 and about 35% of the cells light up, in every field mode, to within a couple of cells.

Render options

option default
size 160
dotScale 0.86 (diameter per cell; 1 touches, >1 merges into solid)
background true
radius 0 (fraction of size, for rounded tiles)
sized true (emit width/height as well as viewBox)
title the name (null to omit)

dotScale is the main aesthetic dial. Below 1 the tile stays visibly dotted everywhere; at 1.2 the dense regions fuse into continuous shapes while the half-tone band stays granular.

avatar('Gafelson', { dotScale: 1.2, palette: { background: '#007139', dot: '#00e19f' } });

CLI

npx picotile Gafelson --seed 42 --size 512 --out gafelson.svg
npx picotile "Ada Lovelace" --field radial --contrast 0.2 --dot-scale 1.1

Notes

  • Pure ESM, zero runtime dependencies, Node 18+ and any modern browser.
  • Output is a plain string, so it works server-side, at build time, or in a service worker just as well as in the DOM.
  • A 32×32 tile is a few hundred <circle> elements. If you are rendering thousands on one page, generate once and cache the string.
  • npm run demo writes preview/index.html — a gallery plus a panel showing what each option does.

Publishing

npm run build && npm test
npm publish --access public

picotile is the reserved name on the registry.

License

GPL-3.0-or-later

About

Deterministic dithered dot avatars generated from a name and a seed.

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages