UNPKG

@mattereum/voltsig

Version:

High-entropy human-readable hashes

556 lines (516 loc) 18.6 kB
/** * Voltsig * * Copyright 2021 Alistair Turnbull * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to * deal in the Software without restriction, including without limitation the * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or * sell copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS * IN THE SOFTWARE. */ /** * voltsig(): replace the contents of an SVG element with a branded graphic * that represents a hash of some data. * * - element - an <svg> DOM element. * - s - the value to be hashed, which can be a string, or some other value * that can be converted losslessly to a string (not an Object). * - options - an Object with a subset of the following fields: * - logoURL - the URL of an image file (ideally SVG) to use as the logo. * An uninspiring default is supplied. */ // eslint-disable-next-line no-unused-vars const voltsig = (function () { // Stuff defined here won't pollute the top-level namespace. /** This is an inline encoding of the file "sheep-face.svg". */ const defaultLogoURL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTMgMjAgMjAiPgo8cGF0aCBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxIiBkPSJNIDEsMiBDIDIsMSA1LDEgNiwyIDgsMSAxMiwxIDE0LDIgMTUsMSAxOCwxIDE5LDIgMTgsNCAxNyw0IDE1LDUgMTQsOSAxMywxNCAxMCwxNCA3LDE0IDYsOSA1LDUgMyw0IDIsNCAxLDIgeiIvPgo8L3N2Zz4='; /** An array of colours around the colour wheel. */ const colours = [ { r: 192, g: 64, b: 64 }, // Red. { r: 192, g: 128, b: 64 }, // Orange. { r: 176, g: 192, b: 64 }, // Yellow. { r: 96, g: 192, b: 64 }, // Green. { r: 64, g: 192, b: 192 }, // Cyan. { r: 64, g: 128, b: 192 }, // Blue. { r: 80, g: 64, b: 192 }, // Indigo. { r: 192, g: 64, b: 192 }, // Violet. ]; /** * Possible transformations to apply to the whole image. * These are the only ones that leave the logo at the bottom left. */ const transforms = [ { rotation: 0, reflection: -1 }, { rotation: 90, reflection: 1 }, ]; /** Possible rotations to apply to the glyphs. */ const angles = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]; /** A selection of glyph stalks. */ const stalks = [ 'M -4,-6 L -4,6', 'M -4,-6 L -4,6 M 4,4 L 4,6 L 1,6 L 1,4 Z', 'M -4,-6 L -4,6 M 4,5 C 4,6.1 3.1,7 2,7 C 0.9,7 0,6.1 0,5 C 0,3.9 0.9,3 2,3 C 3.1,3 4,3.9 4,5 Z', 'M -4,-6 L -4,4 L -2,4', 'M -4,-6 L -4,2 M 4,4 L 0,7', 'M -4,-6 L -4,3 C -4,5.2 -2.2,7 0,7 C 2.2,7 4,5.2 4,3', 'M -4,-6 L -4,6 L 0,6', 'M -4,-6 L -4,6 M -4,1 L 4,6', 'M -4,-6 L -4,0 C -4,4 2,2 2,6 L -2,6', ]; /** A selection of glyph heads. */ const heads = [ 'M -4,-6 L 4,-6', 'M -4,-6 L 2,-6 C 3.1,-6 4,-5.1, 4,-4 M -7,0 L 0,0', 'M -4,0 L 4,0 L 4,-2', 'M -4,-6 L 0,-6 M -4,0 L 4,0', 'M -4,-6 L 1,-6 C 2.7 -6 4 -4.7 4 -3 C 4 -1.3 2.7 0 1 0 L -6 0', 'M -4,-6 L -4,-4 C -4,-1.8 -2.2,0 0,0 C 2.2,0 4,-1.8 4,-4', 'M 4,-6 C 4,-2.6 1.4,0 -2,0 L -6,0', 'M 4,-6 L 4,0', ]; /** A selection of triplets of cells for the stroke to occupy. */ const paths = [ [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }], [{ x: 1, y: 0 }, { x: 1, y: 1 }, { x: 0, y: 1 }], [{ x: 1, y: 1 }, { x: 0, y: 1 }, { x: 0, y: 0 }], [{ x: 0, y: 1 }, { x: 0, y: 0 }, { x: 1, y: 0 }], [{ x: 0, y: 1 }, { x: 1, y: 1 }, { x: 1, y: 2 }], [{ x: 1, y: 1 }, { x: 1, y: 2 }, { x: 0, y: 2 }], [{ x: 1, y: 2 }, { x: 0, y: 2 }, { x: 0, y: 1 }], [{ x: 0, y: 2 }, { x: 0, y: 1 }, { x: 1, y: 1 }], ]; /** A selection of brushes. Each is a list of {from, to}. */ const brushes = [ [{ from: -4, to: 4 }], [{ from: -1, to: 4 }], [{ from: -1, to: 1 }], [{ from: -1, to: 1 }, { from: 2, to: 4 }], [{ from: -4, to: 1 }], [{ from: -4, to: -2 }, { from: 2, to: 4 }], [{ from: -4, to: -2 }, { from: -1, to: 1 }], [{ from: -4, to: -2 }, { from: -1, to: 1 }, { from: 2, to: 4 }], ]; /** * A selection of join styles. Each is a pair of lists of intervals. * Each element of the pair specifies how one branch of the join is clipped. * The interval end points can be: * - "--" - the far edge of the cell. * - "-" - the far edge of the other branch. * - "0" - the mitre line. * - "+" - the near edge of the other branch. * - "++" - the near edge of the cell. */ const joins = [{ // Mitre join. a: [{ from: '0', to: '++' }], b: [{ from: '0', to: '++' }], }, { // Cross through mitre join. a: [{ from: '--', to: '0' }, { from: '+', to: '++' }], b: [{ from: '--', to: '0' }, { from: '+', to: '++' }], }, { // A blocks B. a: [{ from: '-', to: '++' }], b: [{ from: '+', to: '++' }], }, { // B blocks A. a: [{ from: '+', to: '++' }], b: [{ from: '-', to: '++' }], }, { // A caps B. a: [{ from: '--', to: '++' }], b: [{ from: '+', to: '++' }], }, { // B caps A. a: [{ from: '+', to: '++' }], b: [{ from: '--', to: '++' }], }, { // A over B. a: [{ from: '--', to: '++' }], b: [{ from: '--', to: '-' }, { from: '+', to: '++' }], }, { // B over A. a: [{ from: '--', to: '-' }, { from: '+', to: '++' }], b: [{ from: '--', to: '++' }], }]; /** 2D vector addition. */ function vadd(u, v) { return { x: u.x + v.x, y: u.y + v.y }; } /** 2D vector subtraction. */ function vsub(u, v) { return { x: u.x - v.x, y: u.y - v.y }; } /** 2D vector scale. */ function vmul(v, scale) { return { x: v.x * scale, y: v.y * scale }; } /** 2D vector dot product. */ function vdot(u, v) { return u.x * v.x + u.y * v.y; } /** Returns a unit vector in the direction of `v`. */ function vunit(v) { return vmul(v, 1.0 / Math.sqrt(vdot(v, v))); } /** 2D vector clockwise rotate 90°. */ function vrot(v) { return { x: -v.y, y: v.x }; } function vstr(v) { return `${v.x},${v.y}`; } /** * Pick a colour. The hue comes from `base`. * - `saturation` - from -1.0 to 1.0. * - `value` - from -1.0 to 1.0. */ function mixColour(base, saturation, value) { const v = 0.2126 * base.r + 0.7152 * base.g + 0.0722 * base.b; const grey = 128 + 64 * value; return `rgb(${ Math.floor(grey + saturation * (base.r - v))},${ Math.floor(grey + saturation * (base.g - v))},${ Math.floor(grey + saturation * (base.b - v)) })`; } function createSVGElement(tag, attributes) { const element = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const [name, value] of Object.entries(attributes)) { element.setAttribute(name, value); } return element; } /** See top of file for documentation. */ function doVoltsig(element_, s_, options) { // Parse `options`. const logoURL = options.logoURL || defaultLogoURL; // Remove all existing content. let old = element_; const element = old.cloneNode(false); old.parentElement.replaceChild(element, old); old = undefined; // Convert an arbitrary Javascript value into a string. // No effect if it is already a string. const s = String(s_); // The 16-bit hash accumulators. Lengthen the array if necessary. const hashes = [1, 1, 1, 1, 1, 1, 1, 1]; // One round of hashing. // The hash is not cryptographically secure. function step(data) { // Computing a hash is non-trivial using Javascript's arithmetic // operators, which tend to round off results, destroying // information. The subset we rely on is integer operations // (including bitwise operators) when the result fits in 32 bits. // The algorithm uses multiple independent 16-bit hashes. let newData = data & 0xFFFFFFFF; for (let i = 0; i < hashes.length; i += 1) { newData ^= hashes[i] * 31415; hashes[i] = (newData >>> 16) ^ (newData & 0xFFFF); } } // Incorporate some constants into the hash to make it look more random. step(2718281828); step(4590452353); // Incorporate `s.length` into the hash. step(s.length); // Incorporate each character of `s` in turn into the hash. for (let i = 0; i < s.length; i += 1) { step(s.charCodeAt(i)); } // Pick an element of the array `choices`. // Modifies `hashes` so that fresh information is extracted each time. function choose(choices) { let carry = 0; for (let i = 0; i < hashes.length; i += 1) { const product = carry + choices.length * hashes[i]; hashes[i] = product & 0xFFFF; carry = product >>> 16; } return choices[carry]; } // Generate a stand-alone "glyph" on a coloured plynth. function generateGlyph(element, baseColour) { const transform = `rotate(${choose(angles)}) scale(${choose([-1, 1])}, 1)`; element.appendChild(createSVGElement('rect', { x: -8, y: -8, width: 16, height: 16, rx: 5, transform, fill: mixColour(baseColour, choose([-0.5, 1.0]), -1.0), })); element.appendChild(createSVGElement('path', { d: `${choose(stalks)} ${choose(heads)}`, transform, stroke: mixColour(baseColour, choose([-0.5, 1.0]), 1.0), 'stroke-width': 1, 'stroke-linecap': 'round', fill: 'none', })); } // Draw the "a" half of a corner at (0, 0). // `a` and `b` are vectors roughly at right-angles. // `isFirstHalf` is a boolean indicating which half of the corner it is. // `aBrush` and `bBrush` are a list of {from, to} indicating the brush strokes. // `aJoin` is a list of {from, to} indicating the uncovered intervals. function generateHalfCorner(element, a_, b_, isFirstHalf, aBrush, bBrush, aJoin) { const a = vunit(a_); const b = vunit(b_); console.assert(Math.abs(vdot(a, b)) < 0.5, a, b); const ab = vadd(a, b); // Get the perpendicular vector. let arot = vrot(a); if (isFirstHalf) { arot = vmul(arot, -1); } // Compute the edges of `bBrush`. const bFirst = bBrush[0].from - 1; const bLast = bBrush[bBrush.length - 1].to + 1; let bNear; let bFar; if (vdot(arot, b) > 0) { bNear = bFirst; bFar = bLast; } else { bNear = bLast; bFar = bFirst; } // Compute all clip lines. const clips = { '--': { x: -8, v: arot }, '-': { x: bNear, v: b }, 0: { x: 0, v: ab }, '+': { x: bFar, v: b }, '++': { x: 10, v: arot }, }; // Computes the intersection of: // - The specified clip line. // - A line parallel to `a` offset by `y`. function intersect(clip, y) { const v = vunit(clip.v); return vadd( vmul(a, clip.x / vdot(v, arot)), vmul(v, y / vdot(v, arot)), ); } aJoin.forEach((interval) => { aBrush.forEach((stripe) => { const points = [ vstr(intersect(clips[interval.from], stripe.from)), vstr(intersect(clips[interval.from], stripe.to)), vstr(intersect(clips[interval.to], stripe.to)), vstr(intersect(clips[interval.to], stripe.from)), ]; element.appendChild(createSVGElement('polygon', { points: points.join(' '), fill: 'black', })); }); }); } // Picks a way of mapping `n` cells onto `n+1` spaces. // Returns an array of {index, from, to}. function chooseDistortion(n) { const choices = []; for (let i = 0; i < n; i += 1) { choices.push(i); } const stretched = choose(choices); const ret = []; for (let i = 0; i < stretched; i += 1) { ret.push({ index: i, from: i, to: i + 1 }); } const method = choose([0, 1, 2, 3]); if (method === 0) { ret.push({ index: stretched, from: stretched, to: stretched + 2 }); } else if (method === 1) { ret.push({ index: stretched, from: stretched, to: stretched + 1.3 }); } else if (method === 2) { ret.push({ index: stretched, from: stretched + 0.7, to: stretched + 2 }); } else if (method === 3) { ret.push({ index: stretched, from: stretched, to: stretched + 1 }); ret.push({ index: stretched, from: stretched + 1, to: stretched + 2 }); } for (let i = stretched + 1; i < n; i += 1) { ret.push({ index: i, from: i + 1, to: i + 2 }); } return ret; } // Choose a colour scheme. const baseColour = choose(colours); // Make a 2x3 lookup table of cells. const cells = [[null, null], [null, null], [null, null]]; for (let y = 0; y < 3; y += 1) { for (let x = 0; x < 2; x += 1) { cells[y][x] = createSVGElement('g', {}); } } // Populate three of the cells with bits of a path. // The path consists of three right-angle corners. // The middle line segments connect the cells in `path`. // The end line segments are at right-angles to the adjacent segments. const path = choose(paths); let aBrush = choose(brushes); for (let i = 0; i < path.length; i += 1) { let a; let b; if (i + 1 < path.length) { a = vsub(path[i + 1], path[i]); } else { a = vrot(vsub(path[i - 1], path[i])); if (choose([false, true])) { a = vmul(a, -1); } } if (i - 1 >= 0) { b = vsub(path[i - 1], path[i]); } else { b = vrot(vsub(path[i + 1], path[i])); if (choose([false, true])) { b = vmul(b, -1); } } const bBrush = aBrush; aBrush = choose(brushes); const join = choose(joins); generateHalfCorner(cells[path[i].y][path[i].x], a, b, false, aBrush, bBrush, join.a); generateHalfCorner(cells[path[i].y][path[i].x], b, a, true, bBrush, aBrush, join.b); } // Populate the remaining cells with glyphs. for (let y = 0; y < 3; y += 1) { for (let x = 0; x < 2; x += 1) { if (!cells[y][x].firstChild) { generateGlyph(cells[y][x], baseColour); } } } // Generate a grid with a background, a border stripe and a transformation. element.setAttribute('viewBox', '-2.1 -2.1 4.2 4.2'); const transform = choose(transforms); const grid = createSVGElement('g', { transform: `rotate(${transform.rotation})` + `scale(${transform.reflection}, 1)`, }); grid.appendChild(createSVGElement('rect', { x: -2.05, y: -2.05, width: 4.1, height: 4.1, rx: 0.4, fill: mixColour(baseColour, 1.0, 1.0), })); const logo = createSVGElement('g', { transform: `${'translate(1.5, 1.5)' + 'scale(0.3, 0.3)' + 'scale('}${transform.reflection}, 1)` + `rotate(${-transform.rotation})`, }); logo.appendChild(createSVGElement('image', { x: -1, y: -1, witdh: 2, height: 2, href: logoURL, })); grid.appendChild(logo); grid.appendChild(createSVGElement('line', { x1: 1.5, y1: -1.5, x2: 1.5, y2: 0.8, stroke: 'black', 'stroke-width': choose([0.1, 0.2, 0.4]), })); element.appendChild(grid); // Populate the grid with copies of cells. // Each cell has its own coordinate system. const xDistortion = chooseDistortion(2); const yDistortion = chooseDistortion(3); yDistortion.forEach((yd) => { xDistortion.forEach((xd) => { const cx = (xd.from + xd.to - 4) * 0.5; const cy = (yd.from + yd.to - 4) * 0.5; const sx = (xd.to - xd.from) * 0.05; const sy = (yd.to - yd.from) * 0.05; const cell = createSVGElement('g', { transform: `translate(${cx} ${cy})` + `scale(${sx} ${sy})`, }); cell.appendChild(cells[yd.index][xd.index].cloneNode(true)); grid.appendChild(cell); }); }); } // Uncomment this for debugging. // window.addEventListener('load', () => { // const svg = document.getElementById('glyphs'); // svg.setAttribute('viewBox', '0 0 10 10'); // for (let y = 0; y < stalks.length; y += 1) { // for (let x = 0; x < heads.length; x += 1) { // const cx = y + 0.5; // const cy = x + 0.5; // const g = createSVGElement('g', { // transform: // `translate(${cx}, ${cy})` // + 'scale(0.05, 0.05)', // }); // g.appendChild(createSVGElement('path', { // d: stalks[y], // stroke: 'black', // 'stroke-width': 1, // 'stroke-linecap': 'round', // fill: 'none', // })); // g.appendChild(createSVGElement('path', { // d: heads[x], // stroke: 'black', // 'stroke-width': 1, // 'stroke-linecap': 'round', // fill: 'none', // })); // svg.appendChild(g); // } // } // }); // Uncomment this for debugging. // window.addEventListener('load', () => { // const svg = document.getElementById('colours'); // svg.setAttribute('viewBox', '0 0 8 6'); // const shades = [ // { s: 1.0, v: 1.0 }, // { s: -0.5, v: 1.0 }, // { s: 1.0, v: -1.0 }, // { s: -0.5, v: -1.0 }, // ]; // for (let x = 0; x < colours.length; x += 1) { // for (let y = 0; y < shades.length; y += 1) { // svg.appendChild(createSVGElement('rect', { // x, // y, // width: 1, // height: 1, // fill: mixColour(colours[x], shades[y].s, shades[y].v), // })); // } // } // }); return doVoltsig; }());