ISOTYPIFY
Build your own isotype visualization
md5 = {
function md5cycle(x, k) {
var a = x[0],
b = x[1],
c = x[2],
d = x[3];
a = ff(a, b, c, d, k[0], 7, -680876936);
d = ff(d, a, b, c, k[1], 12, -389564586);
c = ff(c, d, a, b, k[2], 17, 606105819);
b = ff(b, c, d, a, k[3], 22, -1044525330);
a = ff(a, b, c, d, k[4], 7, -176418897);
d = ff(d, a, b, c, k[5], 12, 1200080426);
c = ff(c, d, a, b, k[6], 17, -1473231341);
b = ff(b, c, d, a, k[7], 22, -45705983);
a = ff(a, b, c, d, k[8], 7, 1770035416);
d = ff(d, a, b, c, k[9], 12, -1958414417);
c = ff(c, d, a, b, k[10], 17, -42063);
b = ff(b, c, d, a, k[11], 22, -1990404162);
a = ff(a, b, c, d, k[12], 7, 1804603682);
d = ff(d, a, b, c, k[13], 12, -40341101);
c = ff(c, d, a, b, k[14], 17, -1502002290);
b = ff(b, c, d, a, k[15], 22, 1236535329);
a = gg(a, b, c, d, k[1], 5, -165796510);
d = gg(d, a, b, c, k[6], 9, -1069501632);
c = gg(c, d, a, b, k[11], 14, 643717713);
b = gg(b, c, d, a, k[0], 20, -373897302);
a = gg(a, b, c, d, k[5], 5, -701558691);
d = gg(d, a, b, c, k[10], 9, 38016083);
c = gg(c, d, a, b, k[15], 14, -660478335);
b = gg(b, c, d, a, k[4], 20, -405537848);
a = gg(a, b, c, d, k[9], 5, 568446438);
d = gg(d, a, b, c, k[14], 9, -1019803690);
c = gg(c, d, a, b, k[3], 14, -187363961);
b = gg(b, c, d, a, k[8], 20, 1163531501);
a = gg(a, b, c, d, k[13], 5, -1444681467);
d = gg(d, a, b, c, k[2], 9, -51403784);
c = gg(c, d, a, b, k[7], 14, 1735328473);
b = gg(b, c, d, a, k[12], 20, -1926607734);
a = hh(a, b, c, d, k[5], 4, -378558);
d = hh(d, a, b, c, k[8], 11, -2022574463);
c = hh(c, d, a, b, k[11], 16, 1839030562);
b = hh(b, c, d, a, k[14], 23, -35309556);
a = hh(a, b, c, d, k[1], 4, -1530992060);
d = hh(d, a, b, c, k[4], 11, 1272893353);
c = hh(c, d, a, b, k[7], 16, -155497632);
b = hh(b, c, d, a, k[10], 23, -1094730640);
a = hh(a, b, c, d, k[13], 4, 681279174);
d = hh(d, a, b, c, k[0], 11, -358537222);
c = hh(c, d, a, b, k[3], 16, -722521979);
b = hh(b, c, d, a, k[6], 23, 76029189);
a = hh(a, b, c, d, k[9], 4, -640364487);
d = hh(d, a, b, c, k[12], 11, -421815835);
c = hh(c, d, a, b, k[15], 16, 530742520);
b = hh(b, c, d, a, k[2], 23, -995338651);
a = ii(a, b, c, d, k[0], 6, -198630844);
d = ii(d, a, b, c, k[7], 10, 1126891415);
c = ii(c, d, a, b, k[14], 15, -1416354905);
b = ii(b, c, d, a, k[5], 21, -57434055);
a = ii(a, b, c, d, k[12], 6, 1700485571);
d = ii(d, a, b, c, k[3], 10, -1894986606);
c = ii(c, d, a, b, k[10], 15, -1051523);
b = ii(b, c, d, a, k[1], 21, -2054922799);
a = ii(a, b, c, d, k[8], 6, 1873313359);
d = ii(d, a, b, c, k[15], 10, -30611744);
c = ii(c, d, a, b, k[6], 15, -1560198380);
b = ii(b, c, d, a, k[13], 21, 1309151649);
a = ii(a, b, c, d, k[4], 6, -145523070);
d = ii(d, a, b, c, k[11], 10, -1120210379);
c = ii(c, d, a, b, k[2], 15, 718787259);
b = ii(b, c, d, a, k[9], 21, -343485551);
x[0] = add32(a, x[0]);
x[1] = add32(b, x[1]);
x[2] = add32(c, x[2]);
x[3] = add32(d, x[3]);
}
function cmn(q, a, b, x, s, t) {
a = add32(add32(a, q), add32(x, t));
return add32((a << s) | (a >>> (32 - s)), b);
}
function ff(a, b, c, d, x, s, t) {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a, b, c, d, x, s, t) {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a, b, c, d, x, s, t) {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a, b, c, d, x, s, t) {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function md51(s) {
let txt = '';
var n = s.length,
state = [1732584193, -271733879, -1732584194, 271733878],
i;
for (i = 64; i <= s.length; i += 64) {
md5cycle(state, md5blk(s.substring(i - 64, i)));
}
s = s.substring(i - 64);
var tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for (i = 0; i < s.length; i++)
tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3);
tail[i >> 2] |= 0x80 << (i % 4 << 3);
if (i > 55) {
md5cycle(state, tail);
for (i = 0; i < 16; i++) tail[i] = 0;
}
tail[14] = n * 8;
md5cycle(state, tail);
return state;
}
function md5blk(s) {
var md5blks = [],
i;
for (i = 0; i < 64; i += 4) {
md5blks[i >> 2] =
s.charCodeAt(i) +
(s.charCodeAt(i + 1) << 8) +
(s.charCodeAt(i + 2) << 16) +
(s.charCodeAt(i + 3) << 24);
}
return md5blks;
}
var hex_chr = '0123456789abcdef'.split('');
function rhex(n) {
var s = '',
j = 0;
for (; j < 4; j++)
s += hex_chr[(n >> (j * 8 + 4)) & 0x0F] + hex_chr[(n >> (j * 8)) & 0x0F];
return s;
}
function hex(x) {
for (var i = 0; i < x.length; i++) x[i] = rhex(x[i]);
return x.join('');
}
function md5(s) {
return hex(md51(s));
}
function add32(a, b) {
return (a + b) & 0xFFFFFFFF;
}
if (md5('hello') != '5d41402abc4b2a76b9719d911017c592') {
function add32(x, y) {
var lsw = (x & 0xFFFF) + (y & 0xFFFF),
msw = (x >> 16) + (y >> 16) + (lsw >> 16);
return (msw << 16) | (lsw & 0xFFFF);
}
}
return md5;
}// Grouped select input function with cross-browser styling
groupedSelect = (config = {}) => {
let {
value: formValue,
title,
description,
multiple,
size,
groups,
highlightColorFemale = "#D6230080",
highlightColorMale = "#19873780"
} = config;
if (Array.isArray(config)) groups = config;
groups = groups.map(g => {
if (!g.label) {
throw new Error('Undefined label for groupedSelect')
}
return Object.assign(g, {
options: g.options.map(
o => (typeof o === "object" ? o : { value: o, label: o })
)
});
});
// Build select element with wrapper for wavy underline
const selectWrapper = document.createElement('span');
selectWrapper.className = 'select-wrapper';
const select = document.createElement('select');
select.setAttribute('size', '1');
select.style.overflow = 'hidden';
for (const group of groups) {
const optgroup = document.createElement('optgroup');
optgroup.label = group.label;
if (group.disabled) optgroup.disabled = true;
for (const opt of group.options) {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
option.selected = Array.isArray(formValue)
? formValue.includes(opt.value)
: formValue === opt.value;
if (opt.highlight === "female") {
option.style.backgroundColor = highlightColorFemale;
} else if (opt.highlight === "male") {
option.style.backgroundColor = highlightColorMale;
} else {
option.style.backgroundColor = "white";
}
optgroup.appendChild(option);
}
select.appendChild(optgroup);
}
const form = html`<form style="font-family: Jost; font-weight: 350;"></form>`;
// Wrap select in wrapper for wavy underline styling
selectWrapper.appendChild(select);
// Add custom dropdown arrow
const arrow = document.createElement('span');
arrow.className = 'select-arrow';
arrow.innerHTML = '▾'; // Down triangle
selectWrapper.appendChild(arrow);
// Add title/label if provided
if (title) {
const label = document.createElement('label');
label.textContent = title + ' ';
label.style.cssText = 'margin-right: 8px; font-size: 18px;';
label.appendChild(selectWrapper);
form.appendChild(label);
} else {
form.appendChild(selectWrapper);
}
form.value = formValue || groups[0]?.options[0]?.value;
select.addEventListener("input", () => {
form.value = select.value;
form.dispatchEvent(new CustomEvent("input"));
});
return form;
}shapeLookupCategory = new Map([
// Essen und andere persönliche Tätigkeiten
["Essen und andere persönliche Tätigkeiten_women", "echadfba"],
["Essen und andere persönliche Tätigkeiten_men", "REGATNGH"],
// Erwerbstätigkeit
["Erwerbstätigkeit_women", "coilmnpq>"],
["Erwerbstätigkeit_men", "PTJFWY<"],
// Aus- und Weiterbildung
["Aus- und Weiterbildung_women", "Bhapb"],
["Aus- und Weiterbildung_men", "AHDG"],
// Sorgearbeit in Haushalt und Familie
["Sorgearbeit in Haushalt und Familie_women", "a27b1b14bba2a6"],
["Sorgearbeit in Haushalt und Familie_men", "G1J26PJ2G7P3G86"],
// Freiwilligentätigkeiten
["Freiwilligentätigkeiten_women", "jiab"],
["Freiwilligentätigkeiten_men", "LGPGUPL"],
// Soziale Kontakte und Freizeit
["Soziale Kontakte und Freizeit_women", "echadfbagB"],
["Soziale Kontakte und Freizeit_men", "REGZATINGH"],
// Nicht näher bestimmte Zeitverwendung
["Nicht näher bestimmte Zeitverwendung_women", "abcdefghilmnopqB"],
["Nicht näher bestimmte Zeitverwendung_men", "SDEGHIJKLNOPRSTVWZ"],
])
// Override shapes at activity level (optional - add specific activities here)
shapeLookupActivity = new Map([
// Example: specific activity overrides
// ["Nahrungsmittelzubereitung_women", "abc"],
// ["Nahrungsmittelzubereitung_men", "ABC"],
])
// Default fallback by gender
shapeLookupDefault = new Map([
["women", "abcdefghilmnopqB"],
["men", "SDEGHIJKLNOPRSTVWZ"],
])
// Helper function to get shapes - checks activity first, then category, then default
getShapes = (activity, category, gender) => {
const activityKey = `${activity}_${gender}`;
const categoryKey = `${category}_${gender}`;
return shapeLookupActivity.get(activityKey)
|| shapeLookupCategory.get(categoryKey)
|| shapeLookupDefault.get(gender)
|| "abcdefghilmnopqSBDEGHIJKLNOPRSTVWXYZ";
}choose = (arr, n = 1, replace = true) => {
// Simple random choice
const chooseOne = arr => {
if (arr.length == 0) throw "Can't choose from an empty array";
return arr[Math.floor(Math.random() * arr.length)];
};
if (replace == false && arr.length < n) {
throw "Can't choose more than arr.length items";
}
let indicesRemaining = new Set(arr.map((_, i) => i));
let output = [];
for (let i = 0; i < n; i++) {
const nextIndex = chooseOne(Array.from(indicesRemaining));
output.push(arr[nextIndex]);
if (replace === false) {
indicesRemaining.delete(nextIndex);
}
}
return output;
}makeIsoline = (params = {}) => {
const {
data = null, // array of row objects: { key, value (0-1), subkey?, shapes?, fillHighlight?, fillNormal? }
vizWidth = width, // total SVG width in px; defaults to Observable's reactive viewport width
cellWidth = 18, // horizontal space per figure in px — controls packing density
cellHeight = 100, // row height in px
personHeight = 100, // Isotype glyph font-size in px — effectively the figure height
persPerLineValue = Math.floor(vizWidth / cellWidth) - 1, // figures per row; determines the "N per 100" scale
defaultShapes = "abcdefghilmnopqSBDEGHIJKLNOPRSTVWXYZ", // fallback glyph pool from the Isotype font; per-row shapes override this
defaultFillHighlight = palette[0], // fallback color for highlighted (active) figures; per-row fillHighlight overrides this
defaultFillNormal = palette[5], // fallback color for non-highlighted figures; per-row fillNormal overrides this
randomOrder = true, // true: scatter highlighted figures via MD5 hash; false: pack them left-to-right
groupByKey = false // true: rows sharing the same key are grouped under one <h3>; false: each row gets its own <h3>
} = params;
// Group data by key if groupByKey is true
const dataGroups = groupByKey
? Array.from(d3.group(data, d => d.key), ([key, items]) => ({ key, items }))
: data.map(item => ({ key: item.key, items: [item] }));
// Create rows for each group
const rows = dataGroups.map((group, groupIndex) => {
const groupRows = group.items.map((item, rowIndex) => {
const highlightValue = item.value;
const shapes = item.shapes || defaultShapes;
const fillHighlight = item.fillHighlight || defaultFillHighlight;
const fillNormal = item.fillNormal || defaultFillNormal;
const subkey = item.subkey || "Personen";
const highlightCountValue = Math.round(persPerLineValue * highlightValue);
// Generate person with custom alphabet
const generatePersonCustom = seed => {
const avatarChoices = shapes;
const id = "person_" + d3.format("0>4")(seed) + "_row" + rowIndex;
const avatar = randomOrder
? avatarChoices[parseInt(md5(id).slice(0, 2), 16) % avatarChoices.length]
: avatarChoices[seed % avatarChoices.length];
return {
id,
avatar,
highlight: seed < highlightCountValue ? true : false
};
};
// Render person with custom colors
const renderPersonCustom = (p) => {
const fillColor = p.highlight ? fillHighlight : fillNormal;
const strokeColor = "#fff";
return svg`
<g class="person">
<text y="0"
style="font-size: ${personHeight}px; font-family: Isotype"
fill="${fillColor}"
stroke="${strokeColor}"
stroke-width="3">
${p.avatar}
</text>
<text y="0"
style="font-size: ${personHeight}px; font-family: Isotype;"
fill="${fillColor}">
${p.avatar}
</text>
</g>
`;
};
// Create the SVG visualization for this row
const people = d3.range(persPerLineValue).map(generatePersonCustom);
const height = cellHeight;
const vizSvg = html`
<svg width="${vizWidth}" height="${height}">
<g transform="translate(0, 80)">
${people.map((p, i) => ({ p, i })).reverse().map(
({ p, i }) => svg`
<g transform="translate(${i * cellWidth}, 0)">
${renderPersonCustom(p)}
</g>`
)}
</g>
</svg>
`;
// Create the percentage span
const percentageText = html`
<span style="color: ${fillHighlight}; font-family: Jost, system-ui, -apple-system, sans-serif; font-weight: 350;">
${Math.round(highlightValue * 100)} / 100 ${subkey}
</span>
`;
// Return row with label
return html`
<div style="margin-bottom: 6px;">
${groupByKey && rowIndex === 0 ? html`<h3 style="font-family: Jost, system-ui, -apple-system, sans-serif; font-weight: 600; margin: 0 0 12px 0;">${group.key}</h3>` : html``}
${!groupByKey ? html`<h3 style="font-family: Jost, system-ui, -apple-system, sans-serif; font-weight: 600; margin: 0 0 12px 0;">${item.key}</h3>` : html``}
${percentageText}
${vizSvg}
</div>
`;
});
// Return group container
return html`
<div style="margin-bottom: ${groupByKey ? '0px' : '0px'};">
${groupRows}
</div>
`;
});
// Return all rows in a container
return html`
<div style="margin: 0px 0 0 0">
${rows}
</div>
`;
}Layout
viewof layoutOpts = {
const defaultPersPerLine = width >= 1200 ? 50 : width >= 600 ? 25 : 10;
const f = Inputs.form({
numRows: Inputs.range([1, 5], { step: 1, value: 2, label: "Number of rows" }),
persPerLine: Inputs.range([5, 100], { step: 5, value: defaultPersPerLine, label: "Figures per row" }),
cellWidth: Inputs.range([10, 40], { step: 1, value: 22, label: "Cell width (px)" }),
cellHeight: Inputs.range([60, 160], { step: 10, value: 100, label: "Cell height (px)" }),
personHeight: Inputs.range([40, 160], { step: 5, value: 100, label: "Glyph size (px)" }),
randomOrder: Inputs.checkbox(["Random order"]),
groupByKey: Inputs.checkbox(["Collect heading"]),
});
f.classList.add("isotypify-layout-form");
return f;
}// All 5 row forms live here permanently so their values survive numRows changes.
// Visibility is managed by the cell below via DOM IDs.
viewof rowData = {
const defaults = [
{ key: "Group A", pct: 75, subkey: "persons", hi: palette[0], lo: palette[5], shapes: "" },
{ key: "Group B", pct: 45, subkey: "persons", hi: palette[1], lo: palette[5], shapes: "" },
{ key: "Group C", pct: 60, subkey: "persons", hi: palette[2], lo: palette[5], shapes: "" },
{ key: "Group D", pct: 80, subkey: "persons", hi: palette[3], lo: palette[5], shapes: "" },
{ key: "Group E", pct: 55, subkey: "persons", hi: palette[4], lo: palette[5], shapes: "" },
];
const container = html`<div></div>`;
const subForms = [];
for (let i = 0; i < 5; i++) {
const d = defaults[i];
const form = Inputs.form({
key: Inputs.text({ label: "Label", value: d.key, width: 200 }),
pct: Inputs.range([0, 100], { label: "% highlighted", value: d.pct, step: 1 }),
subkey: Inputs.text({ label: "Unit label", value: d.subkey, width: 140 }),
hi: Inputs.color({ label: "Highlight color", value: d.hi }),
lo: Inputs.color({ label: "Background color",value: d.lo }),
shapes: Inputs.text({
label: "Glyphs",
placeholder: "leave blank for default",
width: 260,
value: "aGbPcZNeWiH",
}),
});
form.classList.add("isotypify-rows-form");
subForms.push(form);
const section = document.createElement("div");
section.id = `isotypify-row-${i}`;
section.style.cssText = `margin: 12px auto 12px auto; display: ${i < 2 ? "" : "none"};`;
const heading = document.createElement("h3");
heading.textContent = `Row ${i + 1}`;
heading.style.cssText = "margin: 0 0 8px 0;";
section.appendChild(heading);
section.appendChild(form);
container.appendChild(section);
}
container.value = subForms.map(f => f.value);
subForms.forEach(f => f.addEventListener("input", () => {
container.value = subForms.map(f => f.value);
container.dispatchEvent(new CustomEvent("input"));
}));
return container;
}// Reactive visibility: show/hide row sections based on numRows.
// References rowData to guarantee this runs after the forms are rendered.
{
rowData;
for (let i = 0; i < 5; i++) {
const el = document.getElementById(`isotypify-row-${i}`);
if (el) el.style.display = i < layoutOpts.numRows ? "" : "none";
}
}{
const data = rowData.slice(0, layoutOpts.numRows).map(r => ({
key: r.key || "–",
value: r.pct / 100,
subkey: r.subkey || "persons",
fillHighlight: r.hi,
fillNormal: r.lo,
...(r.shapes ? { shapes: r.shapes } : {})
}));
const viz = makeIsoline({
data,
persPerLineValue: layoutOpts.persPerLine,
cellWidth: layoutOpts.cellWidth,
cellHeight: layoutOpts.cellHeight,
personHeight: layoutOpts.personHeight,
randomOrder: layoutOpts.randomOrder.length > 0,
groupByKey: layoutOpts.groupByKey.length > 0
});
const container = html`<div id="isotypify-preview"></div>`;
container.appendChild(viz);
return container;
}{
const btn = html`<button style="font-family: Jost, sans-serif; font-size: 14px; font-weight: 350; cursor: pointer; border: none; border-bottom: 1px solid #000; background: transparent; padding: 4px 0; margin-top: 4px;">⬇ Download PNG</button>`;
btn.onclick = async () => {
const preview = document.getElementById('isotypify-preview');
if (!preview) return;
btn.textContent = 'Generating…';
btn.disabled = true;
try {
const rowDivs = [...preview.querySelectorAll('div[style*="margin-bottom: 6px"]')];
if (!rowDivs.length) return;
const firstSvg = preview.querySelector('svg');
const W = firstSvg ? +firstSvg.getAttribute('width') : 800;
const pad = 16;
// Measure total height before creating canvas
let totalH = pad;
for (const div of rowDivs) {
if (div.querySelector('h3')) totalH += 32;
if (div.querySelector('span')) totalH += 24;
const svg = div.querySelector('svg');
if (svg) totalH += +svg.getAttribute('height') + 6;
}
totalH += pad;
// Ensure the Isotype font is loaded in the document before drawing
await document.fonts.load(`100px Isotype`);
const scale = 2;
const canvas = document.createElement('canvas');
canvas.width = W * scale;
canvas.height = totalH * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
// White background
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, W, totalH);
let y = pad;
for (const div of rowDivs) {
const h3 = div.querySelector('h3');
const span = div.querySelector('span');
const svg = div.querySelector('svg');
if (h3) {
// Use system sans-serif — canvas weight 600 is reliable cross-browser
ctx.font = '600 18px sans-serif';
ctx.fillStyle = '#000';
ctx.fillText(h3.textContent.trim(), pad, y + 20);
y += 32;
}
if (span) {
ctx.font = '14px sans-serif';
ctx.fillStyle = span.style.color || '#000';
ctx.fillText(span.textContent.trim(), pad, y + 16);
y += 24;
}
if (svg) {
const svgH = +svg.getAttribute('height');
// getCTM() returns the accumulated transform matrix from element coords
// to SVG viewport — more reliable than parsing transform strings.
// Skip the stroke-duplicate text elements (those have a stroke attribute).
const fillTexts = [...svg.querySelectorAll('text')].filter(t => !t.getAttribute('stroke'));
const fontSize = parseInt(fillTexts[0]?.style.fontSize) || 100;
ctx.font = `${fontSize}px Isotype`;
// Pass 1: white outline for all glyphs first
ctx.strokeStyle = 'white';
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
for (const t of fillTexts) {
const m = t.getCTM();
if (!m) continue;
const char = t.textContent.trim();
if (char) ctx.strokeText(char, m.e, y + m.f);
}
// Pass 2: colored fill
for (const t of fillTexts) {
const m = t.getCTM();
if (!m) continue;
const char = t.textContent.trim();
if (!char) continue;
ctx.fillStyle = t.getAttribute('fill') || '#000';
ctx.fillText(char, m.e, y + m.f);
}
y += svgH + 6;
}
}
const a = document.createElement('a');
a.download = 'isotypify.png';
a.href = canvas.toDataURL('image/png');
a.click();
} catch(e) {
console.error(e);
alert('Export failed: ' + e.message);
} finally {
btn.textContent = '⬇ Download PNG';
btn.disabled = false;
}
};
return btn;
}Acknowledgements
- Isotype font by Ric Stephens
- Based upon implementation by Basile Simon’s ISOTYPE sketch
- AK Wien for inspiring this visualization by issuing this call