<!-- Color Quantization (Median Cut) — interactive demo -->
<div class="controls">
<label for="nColors">Palette colors: <strong id="nVal">8</strong></label>
<input type="range" id="nColors" min="2" max="16" value="8" step="1" aria-label="Palette colors:">
<button id="btnStep" type="button">Step</button>
<button id="btnRun" type="button">Run all</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div class="panels">
<div class="panel">
<div class="panel-title">Color cloud (RGB)</div>
<canvas id="cubeCanvas" width="280" height="180" title="Color cloud (RGB)"></canvas>
</div>
<div class="panel">
<div class="panel-title">Current palette</div>
<div id="palette"></div>
</div>
</div>
<div id="status" class="status">Press Step or Run all to start Median Cut.</div>
/* Styles for the Median Cut demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-bottom: .6rem; }
label { font-size: .9rem; color: #444; }
input[type=range] { width: 110px; cursor: pointer; }
button { font: 600 13px system-ui; padding: .38rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.panels { display: flex; flex-wrap: wrap; gap: .8rem; }
.panel { flex: 1 1 200px; }
.panel-title { font-size: .8rem; font-weight: 600; color: #555; text-transform: uppercase;
letter-spacing: .04em; margin-bottom: .35rem; }
canvas { border: 1px solid #d0d7df; border-radius: 6px; display: block;
width: 100%; max-width: 280px; }
#palette { display: flex; flex-wrap: wrap; gap: 4px; min-height: 40px; }
.swatch { width: 28px; height: 28px; border-radius: 5px; border: 1px solid rgba(0,0,0,.15);
transition: transform .15s; cursor: default; }
.swatch:hover { transform: scale(1.25); }
.status { font-size: .88rem; font-weight: 600; color: #444; margin-top: .5rem; min-height: 1.3em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Median Cut color quantization demo — vanilla JS, no external dependencies
// Generate a fixed synthetic color cloud (7 Gaussian clusters in RGB space)
const PIXELS = (function () {
const pts = [];
const rng = (function () {
let s = 0x9e3779b9;
return () => { s ^= s << 13; s ^= s >> 17; s ^= s << 5; return (s >>> 0) / 0xffffffff; };
})();
// 7 color clusters: red, green, blue, yellow, cyan, magenta, orange
const clusters = [
[200, 60, 60], [60, 160, 80], [60, 80, 200],
[210, 190, 50], [50, 190, 190], [190, 60, 180], [240, 140, 30]
];
for (const [cr, cg, cb] of clusters) {
const n = 35 + Math.floor(rng() * 20);
for (let i = 0; i < n; i++) {
const r = Math.min(255, Math.max(0, cr + Math.round((rng() - 0.5) * 70)));
const g = Math.min(255, Math.max(0, cg + Math.round((rng() - 0.5) * 70)));
const b = Math.min(255, Math.max(0, cb + Math.round((rng() - 0.5) * 70)));
pts.push([r, g, b]);
}
}
return pts;
})();
// A bucket holds a set of pixels and their bounding box in RGB space
function makeBucket(pixels) {
const r = pixels.map(p => p[0]), g = pixels.map(p => p[1]), b = pixels.map(p => p[2]);
return {
pixels,
rMin: Math.min(...r), rMax: Math.max(...r),
gMin: Math.min(...g), gMax: Math.max(...g),
bMin: Math.min(...b), bMax: Math.max(...b)
};
}
// Split a bucket along its widest axis at the median
function splitBucket(bucket) {
const rRange = bucket.rMax - bucket.rMin;
const gRange = bucket.gMax - bucket.gMin;
const bRange = bucket.bMax - bucket.bMin;
let axis = 0;
if (gRange >= rRange && gRange >= bRange) axis = 1;
else if (bRange >= rRange && bRange >= gRange) axis = 2;
const sorted = bucket.pixels.slice().sort((a, b) => a[axis] - b[axis]);
const mid = Math.floor(sorted.length / 2);
return [makeBucket(sorted.slice(0, mid)), makeBucket(sorted.slice(mid))];
}
// Compute the representative color of a bucket (average of all pixels)
function bucketColor(bucket) {
const n = bucket.pixels.length;
const r = Math.round(bucket.pixels.reduce((s, p) => s + p[0], 0) / n);
const g = Math.round(bucket.pixels.reduce((s, p) => s + p[1], 0) / n);
const b = Math.round(bucket.pixels.reduce((s, p) => s + p[2], 0) / n);
return [r, g, b];
}
function rgb(r, g, b) { return `rgb(${r},${g},${b})`; }
// Algorithm state: current list of buckets and target palette size
let buckets = [makeBucket(PIXELS)];
let target = 8;
// Canvas drawing — project the 3D RGB cube to 2D using an oblique projection
const canvas = document.getElementById('cubeCanvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Oblique projection: x influenced by R and B, y influenced by G and B
function project(r, g, b) {
const x = Math.round(0.1 * W + (r / 255) * 0.55 * W + (b / 255) * 0.25 * W);
const y = Math.round(0.85 * H - (g / 255) * 0.65 * H - (b / 255) * 0.18 * H);
return [x, y];
}
function draw() {
ctx.clearRect(0, 0, W, H);
// Draw every pixel as a small dot in its own color
for (const [r, g, b] of PIXELS) {
const [x, y] = project(r, g, b);
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, 2 * Math.PI);
ctx.fillStyle = rgb(r, g, b);
ctx.fill();
}
// Draw the centroid of each current bucket as a larger outlined circle
for (const bkt of buckets) {
const [cr, cg, cb] = bucketColor(bkt);
const [x, y] = project(cr, cg, cb);
ctx.beginPath();
ctx.arc(x, y, 7, 0, 2 * Math.PI);
ctx.fillStyle = rgb(cr, cg, cb);
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, 7, 0, 2 * Math.PI);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, 7, 0, 2 * Math.PI);
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.stroke();
}
}
function drawPalette() {
const el = document.getElementById('palette');
el.innerHTML = '';
for (const bkt of buckets) {
const [r, g, b] = bucketColor(bkt);
const sw = document.createElement('div');
sw.className = 'swatch';
sw.style.background = rgb(r, g, b);
sw.title = `rgb(${r},${g},${b})`;
el.appendChild(sw);
}
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
// Pick the bucket with the greatest spread (widest axis) to split next
function pickWidest() {
let best = -1, idx = 0;
for (let i = 0; i < buckets.length; i++) {
const bkt = buckets[i];
const spread = Math.max(bkt.rMax - bkt.rMin, bkt.gMax - bkt.gMin, bkt.bMax - bkt.bMin);
if (spread > best) { best = spread; idx = i; }
}
return idx;
}
function canStep() { return buckets.length < target && buckets.length < PIXELS.length; }
function step() {
if (!canStep()) return;
const idx = pickWidest();
const [a, b] = splitBucket(buckets[idx]);
buckets.splice(idx, 1, a, b);
draw();
drawPalette();
if (!canStep()) {
setStatus(i18nFmt('Done! Palette has %n% colors.', { n: buckets.length }));
document.getElementById('btnStep').disabled = true;
document.getElementById('btnRun').disabled = true;
} else {
setStatus(i18nFmt('Split %cur% of %total% buckets — keep going!', { cur: buckets.length, total: target }));
}
}
function reset() {
buckets = [makeBucket(PIXELS)];
draw();
drawPalette();
setStatus('Press Step or Run all to start Median Cut.');
document.getElementById('btnStep').disabled = false;
document.getElementById('btnRun').disabled = false;
}
document.getElementById('nColors').addEventListener('input', function () {
target = +this.value;
document.getElementById('nVal').textContent = target;
reset();
});
document.getElementById('btnStep').addEventListener('click', step);
document.getElementById('btnRun').addEventListener('click', function () {
while (canStep()) step();
});
document.getElementById('btnReset').addEventListener('click', reset);
// Initial render
draw();
drawPalette();
setStatus('Press Step or Run all to start Median Cut.');
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Median Cut color quantization demo — vanilla JS, no external dependencies
// Generate a fixed synthetic color cloud (7 Gaussian clusters in RGB space)
const PIXELS = (function () {
const pts = [];
const rng = (function () {
let s = 0x9e3779b9;
return () => { s ^= s << 13; s ^= s >> 17; s ^= s << 5; return (s >>> 0) / 0xffffffff; };
})();
// 7 color clusters: red, green, blue, yellow, cyan, magenta, orange
const clusters = [
[200, 60, 60], [60, 160, 80], [60, 80, 200],
[210, 190, 50], [50, 190, 190], [190, 60, 180], [240, 140, 30]
];
for (const [cr, cg, cb] of clusters) {
const n = 35 + Math.floor(rng() * 20);
for (let i = 0; i < n; i++) {
const r = Math.min(255, Math.max(0, cr + Math.round((rng() - 0.5) * 70)));
const g = Math.min(255, Math.max(0, cg + Math.round((rng() - 0.5) * 70)));
const b = Math.min(255, Math.max(0, cb + Math.round((rng() - 0.5) * 70)));
pts.push([r, g, b]);
}
}
return pts;
})();
// A bucket holds a set of pixels and their bounding box in RGB space
function makeBucket(pixels) {
const r = pixels.map(p => p[0]), g = pixels.map(p => p[1]), b = pixels.map(p => p[2]);
return {
pixels,
rMin: Math.min(...r), rMax: Math.max(...r),
gMin: Math.min(...g), gMax: Math.max(...g),
bMin: Math.min(...b), bMax: Math.max(...b)
};
}
// Split a bucket along its widest axis at the median
function splitBucket(bucket) {
const rRange = bucket.rMax - bucket.rMin;
const gRange = bucket.gMax - bucket.gMin;
const bRange = bucket.bMax - bucket.bMin;
let axis = 0;
if (gRange >= rRange && gRange >= bRange) axis = 1;
else if (bRange >= rRange && bRange >= gRange) axis = 2;
const sorted = bucket.pixels.slice().sort((a, b) => a[axis] - b[axis]);
const mid = Math.floor(sorted.length / 2);
return [makeBucket(sorted.slice(0, mid)), makeBucket(sorted.slice(mid))];
}
// Compute the representative color of a bucket (average of all pixels)
function bucketColor(bucket) {
const n = bucket.pixels.length;
const r = Math.round(bucket.pixels.reduce((s, p) => s + p[0], 0) / n);
const g = Math.round(bucket.pixels.reduce((s, p) => s + p[1], 0) / n);
const b = Math.round(bucket.pixels.reduce((s, p) => s + p[2], 0) / n);
return [r, g, b];
}
function rgb(r, g, b) { return `rgb(${r},${g},${b})`; }
// Algorithm state: current list of buckets and target palette size
let buckets = [makeBucket(PIXELS)];
let target = 8;
// Canvas drawing — project the 3D RGB cube to 2D using an oblique projection
const canvas = document.getElementById('cubeCanvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Oblique projection: x influenced by R and B, y influenced by G and B
function project(r, g, b) {
const x = Math.round(0.1 * W + (r / 255) * 0.55 * W + (b / 255) * 0.25 * W);
const y = Math.round(0.85 * H - (g / 255) * 0.65 * H - (b / 255) * 0.18 * H);
return [x, y];
}
function draw() {
ctx.clearRect(0, 0, W, H);
// Draw every pixel as a small dot in its own color
for (const [r, g, b] of PIXELS) {
const [x, y] = project(r, g, b);
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, 2 * Math.PI);
ctx.fillStyle = rgb(r, g, b);
ctx.fill();
}
// Draw the centroid of each current bucket as a larger outlined circle
for (const bkt of buckets) {
const [cr, cg, cb] = bucketColor(bkt);
const [x, y] = project(cr, cg, cb);
ctx.beginPath();
ctx.arc(x, y, 7, 0, 2 * Math.PI);
ctx.fillStyle = rgb(cr, cg, cb);
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, 7, 0, 2 * Math.PI);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, 7, 0, 2 * Math.PI);
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.stroke();
}
}
function drawPalette() {
const el = document.getElementById('palette');
el.innerHTML = '';
for (const bkt of buckets) {
const [r, g, b] = bucketColor(bkt);
const sw = document.createElement('div');
sw.className = 'swatch';
sw.style.background = rgb(r, g, b);
sw.title = `rgb(${r},${g},${b})`;
el.appendChild(sw);
}
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
// Pick the bucket with the greatest spread (widest axis) to split next
function pickWidest() {
let best = -1, idx = 0;
for (let i = 0; i < buckets.length; i++) {
const bkt = buckets[i];
const spread = Math.max(bkt.rMax - bkt.rMin, bkt.gMax - bkt.gMin, bkt.bMax - bkt.bMin);
if (spread > best) { best = spread; idx = i; }
}
return idx;
}
function canStep() { return buckets.length < target && buckets.length < PIXELS.length; }
function step() {
if (!canStep()) return;
const idx = pickWidest();
const [a, b] = splitBucket(buckets[idx]);
buckets.splice(idx, 1, a, b);
draw();
drawPalette();
if (!canStep()) {
setStatus(i18nFmt('Done! Palette has %n% colors.', { n: buckets.length }));
document.getElementById('btnStep').disabled = true;
document.getElementById('btnRun').disabled = true;
} else {
setStatus(i18nFmt('Split %cur% of %total% buckets — keep going!', { cur: buckets.length, total: target }));
}
}
function reset() {
buckets = [makeBucket(PIXELS)];
draw();
drawPalette();
setStatus('Press Step or Run all to start Median Cut.');
document.getElementById('btnStep').disabled = false;
document.getElementById('btnRun').disabled = false;
}
document.getElementById('nColors').addEventListener('input', function () {
target = +this.value;
document.getElementById('nVal').textContent = target;
reset();
});
document.getElementById('btnStep').addEventListener('click', step);
document.getElementById('btnRun').addEventListener('click', function () {
while (canStep()) step();
});
document.getElementById('btnReset').addEventListener('click', reset);
// Initial render
draw();
drawPalette();
setStatus('Press Step or Run all to start Median Cut.');
Comments
Loading comments...