@mattereum/voltsig
Version:
High-entropy human-readable hashes
556 lines (516 loc) • 18.6 kB
JavaScript
/**
* 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;
}());