├── .gitignore ├── docs ├── hashvatar.png └── hashvatar-tweet.jpg ├── package.json ├── api ├── _lib │ ├── createSvg.ts │ ├── dataURL.ts │ ├── useHashAvatar.ts │ └── defs.ts └── index.ts ├── vercel.json ├── yarn.lock └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | node_modules 3 | -------------------------------------------------------------------------------- /docs/hashvatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wzulfikar/hashvatar/HEAD/docs/hashvatar.png -------------------------------------------------------------------------------- /docs/hashvatar-tweet.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wzulfikar/hashvatar/HEAD/docs/hashvatar-tweet.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hashvatar", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "wzulfikar ", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "@types/node": "^14.14.41" 9 | }, 10 | "dependencies": { 11 | "@47ng/codec": "^1.0.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /api/_lib/createSvg.ts: -------------------------------------------------------------------------------- 1 | import { mapValueToColor } from "./defs"; 2 | import { useHashAvatar, UseHashAvatarArgs } from "./useHashAvatar"; 3 | 4 | export function createSvg({ 5 | radiusFactor = 0.42, 6 | hash = Array(64).fill("0").join(""), 7 | variant = "stagger", 8 | mapColor = mapValueToColor, 9 | }: UseHashAvatarArgs) { 10 | const sections = useHashAvatar({ 11 | radiusFactor, 12 | hash, 13 | variant, 14 | mapColor, 15 | }); 16 | 17 | const svg = `${sections 18 | .map( 19 | (section) => 20 | `` 21 | ) 22 | .join("")}`; 23 | 24 | return svg; 25 | } 26 | -------------------------------------------------------------------------------- /api/_lib/dataURL.ts: -------------------------------------------------------------------------------- 1 | import { mapValueToColor } from "./defs"; 2 | import { useHashAvatar, UseHashAvatarArgs } from "./useHashAvatar"; 3 | 4 | export function generateHashAvatarDataURL({ 5 | radiusFactor = 0.42, 6 | hash = Array(64).fill("0").join(""), 7 | variant = "stagger", 8 | mapColor = mapValueToColor, 9 | }: UseHashAvatarArgs) { 10 | const sections = useHashAvatar({ 11 | radiusFactor, 12 | hash, 13 | variant, 14 | mapColor, 15 | }); 16 | 17 | const svg = `${sections 18 | .map( 19 | (section) => 20 | `` 21 | ) 22 | .join("")}`; 23 | 24 | return `data:image/svg+xml;utf8,${svg}`; 25 | } 26 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.+)", "destination": "/api" }], 3 | "redirects": [ 4 | { "source": "/", "destination": "https://github.com/wzulfikar/hashvatar" } 5 | ], 6 | "headers": [ 7 | { 8 | "source": "/(.*)", 9 | "headers": [ 10 | { "key": "Access-Control-Allow-Credentials", "value": "true" }, 11 | { "key": "Access-Control-Allow-Origin", "value": "*" }, 12 | { 13 | "key": "Access-Control-Allow-Methods", 14 | "value": "GET,OPTIONS,PATCH,DELETE,POST,PUT" 15 | }, 16 | { 17 | "key": "Access-Control-Allow-Headers", 18 | "value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version" 19 | } 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@47ng/codec@^1.0.1": 6 | version "1.0.1" 7 | resolved "https://registry.yarnpkg.com/@47ng/codec/-/codec-1.0.1.tgz#8387b45d6a4859fcfd14809950bc6603925175e1" 8 | integrity sha512-+PJ7xjyvcvK9K3WL99/0UNUtA1lizE6EEy2MSEFRr2zXNdd/jHe+uIeKr+0g8b4DFtYezKiM4DF8mWy+WWSIHQ== 9 | dependencies: 10 | "@stablelib/base64" "^1.0.0" 11 | "@stablelib/hex" "^1.0.0" 12 | 13 | "@stablelib/base64@^1.0.0": 14 | version "1.0.0" 15 | resolved "https://registry.yarnpkg.com/@stablelib/base64/-/base64-1.0.0.tgz#e08ba78078c731cbbb244530b1750659c52ba7cb" 16 | integrity sha512-s/wTc/3+vYSalh4gfayJrupzhT7SDBqNtiYOeEMlkSDqL/8cExh5FAeTzLpmYq+7BLLv36EjBL5xrb0bUHWJWQ== 17 | 18 | "@stablelib/hex@^1.0.0": 19 | version "1.0.0" 20 | resolved "https://registry.yarnpkg.com/@stablelib/hex/-/hex-1.0.0.tgz#9f2d21d412803e72a3bbc0ab4690e9bda0ca91cf" 21 | integrity sha512-EJ9oGiuaFw/Y0cBATTxo73sgqOgdnSmZ9ftU9FND9SD51OM8wvAfS78uPy3oBNmLWc/sZK5twMbEFf/A4T2F8A== 22 | 23 | "@types/node@^14.14.41": 24 | version "14.14.41" 25 | resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.41.tgz#d0b939d94c1d7bd53d04824af45f1139b8c45615" 26 | integrity sha512-dueRKfaJL4RTtSa7bWeTK1M+VH+Gns73oCgzvYfHZywRCoPSd8EkXBL0mZ9unPTveBn+D9phZBaxuzpwjWkW0g== 27 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { IncomingMessage, ServerResponse } from "http"; 3 | import { b64, utf8, base64ToHex } from "@47ng/codec"; 4 | 5 | import { createSvg } from "./_lib/createSvg"; 6 | 7 | function sendFile(res: ServerResponse, svg: string) { 8 | res.statusCode = 200; 9 | 10 | res.setHeader("Content-Type", `image/svg+xml`); 11 | res.setHeader("Content-Length", Buffer.byteLength(svg, "utf8")); 12 | res.setHeader( 13 | "Cache-Control", 14 | `public, immutable, no-transform, s-maxage=31536000, max-age=31536000` 15 | ); 16 | res.end(svg); 17 | } 18 | 19 | function getHandlerHash(handler: string) { 20 | return crypto.createHash("sha256").update(handler).digest("hex"); 21 | } 22 | 23 | const variants = ["normal", "stagger", "spider", "flower", "gem"]; 24 | 25 | export default async function handler( 26 | req: IncomingMessage, 27 | res: ServerResponse 28 | ) { 29 | const pathname = req.url; 30 | 31 | let handler = pathname.substr(1).trim().replace(".svg", ""); 32 | 33 | // Type can be normal, stagger, spider, flower, gem 34 | let variant = "stagger"; 35 | 36 | if (handler.includes("/")) { 37 | const split = handler.split("/"); 38 | handler = split[0]; 39 | 40 | if (variants.includes(split[1])) { 41 | variant = split[1]; 42 | } 43 | } 44 | 45 | const handlerHash = getHandlerHash(handler); 46 | 47 | console.log("handler:", { handler, hash: handlerHash }); 48 | 49 | const svg = createSvg({ 50 | hash: handlerHash, 51 | variant: variant as any, 52 | }); 53 | 54 | sendFile(res, svg); 55 | try { 56 | } catch (e) { 57 | res.statusCode = 500; 58 | res.setHeader("Content-Type", "text/html"); 59 | res.end("

Internal Error

Sorry, there was a problem

"); 60 | console.error(e); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /api/_lib/useHashAvatar.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ColorMapper, 3 | generateSectionPath, 4 | getSouls, 5 | mapValueToColor, 6 | Variants, 7 | } from "./defs"; 8 | 9 | export interface UseHashAvatarArgs { 10 | hash: string; 11 | variant?: Variants; 12 | radiusFactor?: number; 13 | mapColor?: ColorMapper; 14 | } 15 | 16 | export function useHashAvatar({ 17 | radiusFactor = 0.42, 18 | hash = Array(64).fill("0").join(""), 19 | variant = "normal", 20 | mapColor = mapValueToColor, 21 | }: UseHashAvatarArgs) { 22 | const mix = (a: number, b: number) => 23 | a * radiusFactor + b * (1 - radiusFactor); 24 | 25 | const r1 = variant === "flower" ? 0.75 : 0.99; // leave space for stroke 26 | const r2 = mix((r1 * Math.sqrt(3)) / 2, r1 * 0.75); 27 | const r3 = mix((r1 * Math.sqrt(2)) / 2, r1 * 0.5); 28 | const r4 = mix(r1 * 0.5, r1 * 0.25); 29 | 30 | const bytesPerSection = (hash?.length ?? 0) / 64; // 32 sections = 64 hex characters 31 | const charsPerSection = bytesPerSection * 2; 32 | const cutInBlocks = new RegExp(`.{1,${charsPerSection}}`, "g"); 33 | const values = hash.match(cutInBlocks)?.map((block) => block) ?? []; 34 | const { hashSoul, circleSouls } = getSouls(hash); 35 | const outerRadii = [r1, r2, r3, r4]; 36 | const sections = values.map((value, index) => { 37 | const circleIndex = Math.floor(index / 8); 38 | const outerRadius = outerRadii[circleIndex]; 39 | const circleSoul = circleSouls[circleIndex]; 40 | return { 41 | path: generateSectionPath({ 42 | index, 43 | outerRadius, 44 | variant, 45 | circleSoul, 46 | }), 47 | color: mapColor({ 48 | value: parseInt(value, 16), 49 | bitCount: bytesPerSection * 8, 50 | hashSoul, 51 | circleSoul, 52 | }), 53 | }; 54 | }); 55 | return sections; 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Hashvatar 6 | 7 | Create hash avatar of your handler. Inspired from [this tweet](https://twitter.com/fortysevenfx/status/1383760179632566273?s=20): 8 | 9 |

10 | 11 |

12 | 13 | ## Usage 14 | 15 | Use this format to get your hashvatar: 16 | 17 | ```sh 18 | # Format 19 | https://hashvatar.vercel.app/{yourhandler}/{optional_variants} 20 | 21 | # Example: 22 | https://hashvatar.vercel.app/wzulfikar 23 | https://hashvatar.vercel.app/wzulfikar/spider 24 | https://hashvatar.vercel.app/wzulfikar/stagger 25 | ``` 26 | 27 | ## Demo 28 | 29 | - [https://hashvatar.vercel.app/fortysevenfx](https://hashvatar.vercel.app/fortysevenfx) 30 | variant: `normal` 31 | variant: `stagger` 32 | variant: `spider` 33 | variant: `flower` 34 | variant: `gem` 35 | - [https://hashvatar.vercel.app/wzulfikar](https://hashvatar.vercel.app/wzulfikar) 36 | variant: `normal` 37 | variant: `stagger` 38 | variant: `spider` 39 | variant: `flower` 40 | variant: `gem` 41 | 42 | ## Credits 43 | 44 | Codes and maths to generate the avatar comes from [François Best (@fortysevenfx)](https://twitter.com/fortysevenfx). I only used the code to create a dynamic endpoint that serves the avatar on demand. 45 | 46 | See this blog post for implementation details: https://francoisbest.com/posts/2021/hashvatars?demo=Hello,%20Twitter%21 47 | 48 | _That's it!_ 49 | -------------------------------------------------------------------------------- /api/_lib/defs.ts: -------------------------------------------------------------------------------- 1 | export type Variants = "normal" | "stagger" | "spider" | "flower" | "gem"; 2 | 3 | export interface Point { 4 | x: number; 5 | y: number; 6 | } 7 | 8 | export function polarPoint(radius: number, angle: number): Point { 9 | // Angle is expressed as [0,1[ 10 | // Note: we subtract pi / 2 to start at noon and go clockwise 11 | // Trigonometric rotation + inverted Y axis = clockwise rotation, nifty! 12 | return { 13 | x: radius * Math.cos(2 * Math.PI * angle - Math.PI / 2), 14 | y: radius * Math.sin(2 * Math.PI * angle - Math.PI / 2), 15 | }; 16 | } 17 | 18 | function formatNumber(value: number, precision: number) { 19 | if (Number.isInteger(value)) { 20 | return value.toString(); 21 | } 22 | const format = Intl.NumberFormat("en-US", { 23 | minimumFractionDigits: 0, 24 | maximumFractionDigits: precision, 25 | }); 26 | return format.format(value).replace(/,/g, ""); 27 | } 28 | 29 | // SVG Path functions -- 30 | 31 | export function moveTo({ x, y }: Point) { 32 | return `M ${formatNumber(x, 6)} ${formatNumber(y, 6)}`; 33 | } 34 | 35 | export function lineTo({ x, y }: Point) { 36 | return `L ${formatNumber(x, 6)} ${formatNumber(y, 6)}`; 37 | } 38 | 39 | export function arcTo({ x, y }: Point, radius: number = 0, invert = false) { 40 | return [ 41 | "A", 42 | formatNumber(radius, 6), 43 | formatNumber(radius, 6), 44 | 0, 45 | 0, 46 | invert ? 0 : 1, 47 | formatNumber(x, 6), 48 | formatNumber(y, 6), 49 | ].join(" "); 50 | } 51 | 52 | // -- 53 | 54 | export function getSouls(hash: string) { 55 | const bytes = hash.match(/.{1,2}/g)?.map((byte) => byte) ?? []; 56 | const circleSize = Math.round(bytes.length / 4); 57 | const circles = [ 58 | bytes.slice(0, circleSize), 59 | bytes.slice(1 * circleSize, 2 * circleSize), 60 | bytes.slice(2 * circleSize, 3 * circleSize), 61 | bytes.slice(3 * circleSize, 4 * circleSize), 62 | ]; 63 | const xor = (xor: number, byte: string) => xor ^ parseInt(byte, 16); 64 | return { 65 | hashSoul: (bytes.reduce(xor, 0) / 0xff) * 2 - 1, 66 | circleSouls: circles.map( 67 | (circle) => (circle.reduce(xor, 0) / 0xff) * 2 - 1 68 | ), 69 | }; 70 | } 71 | 72 | // Space mapping -- 73 | 74 | export interface GenerateSectionArgs { 75 | index: number; 76 | outerRadius: number; 77 | circleSoul: number; 78 | variant?: Variants; 79 | } 80 | 81 | export function generateSectionPath({ 82 | index, 83 | outerRadius, 84 | circleSoul, 85 | variant = "normal", 86 | }: GenerateSectionArgs) { 87 | const circleIndex = Math.floor(index / 8); 88 | const staggering = 89 | variant === "gem" || variant === "flower" 90 | ? circleIndex % 2 === 0 91 | ? 0.5 92 | : 0 93 | : variant === "stagger" 94 | ? circleSoul 95 | : 0; 96 | 97 | const angleA = index / 8; 98 | const angleB = (index + 1) / 8; 99 | const angleOffset = staggering / 8; 100 | 101 | const arcRadius = 102 | variant === "gem" 103 | ? 0 104 | : variant === "flower" 105 | ? 0.25 * outerRadius 106 | : outerRadius; 107 | 108 | const path = [ 109 | moveTo({ x: 0, y: 0 }), 110 | lineTo(polarPoint(outerRadius, angleA + angleOffset)), 111 | arcTo( 112 | polarPoint(outerRadius, angleB + angleOffset), 113 | arcRadius, 114 | variant === "spider" 115 | ), 116 | "Z", // close the path 117 | ].join(" "); 118 | 119 | return path; 120 | } 121 | 122 | // Color mapping -- 123 | 124 | export type ColorMapper = (args: { 125 | value: number; // [0; 2 ^ bitCount - 1] 126 | bitCount: number; 127 | hashSoul: number; // [0-1] 128 | circleSoul: number; // [0-1] 129 | }) => string; 130 | 131 | export const mapValueToColor: ColorMapper = ({ 132 | value, 133 | hashSoul, 134 | bitCount, 135 | circleSoul, 136 | }) => { 137 | const halfMask = 2 ** (bitCount / 2) - 1; 138 | const quarterMask = 2 ** (bitCount / 4) - 1; 139 | const colorH = value >> (bitCount / 2); 140 | const colorS = (value >> (bitCount / 4)) & quarterMask; 141 | const colorL = value & quarterMask; 142 | const normH = colorH / halfMask; 143 | const normS = colorS / quarterMask; 144 | const normL = colorL / quarterMask; 145 | const h = 360 * hashSoul + 120 * circleSoul + 30 * normH; 146 | const s = 50 + 50 * normS; 147 | const l = 40 + 30 * normL; 148 | return `hsl(${formatNumber(h, 2)}, ${formatNumber(s, 4)}%, ${formatNumber( 149 | l, 150 | 4 151 | )}%)`; 152 | }; 153 | --------------------------------------------------------------------------------