diff --git a/public/color-rings.css b/public/color-rings.css index db04623..3f98d06 100644 --- a/public/color-rings.css +++ b/public/color-rings.css @@ -13,7 +13,7 @@ body { margin: 0; } .controls :focus-visible { outline: 2px solid #202020; outline-offset: 4px; } .range-ends { display: flex; justify-content: space-between; color: #666; font-size: 12px; } .controls select { display: block; width: 100%; min-height: 44px; margin: 8px 0 0; padding: 0 10px; font: inherit; color: inherit; background: white; border: 1px solid #bbb; border-radius: 4px; cursor: pointer; } -.theme-toggle { display: flex; gap: 10px; margin-top: 14px; } +.theme-toggle { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; } .palette { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 30px 0 20px; } .palette dt { font-size: 12px; color: #666; } .palette dd { margin: 6px 0 0; font-size: 14px; } diff --git a/public/color-rings.html b/public/color-rings.html index 0b7377a..e1a5211 100644 --- a/public/color-rings.html +++ b/public/color-rings.html @@ -14,7 +14,8 @@

Color rings

Three colors. One quiet experiment.

Three concentric color rings - Clay on the outside, ochre in the middle, dusty slate blue inside. The controls change their saturation, gap, scale, thickness, and rotation. + On a white background circle: clay on the outside, ochre in the middle, dusty slate blue inside. The controls change their saturation, gap, scale, thickness, and rotation. + @@ -56,6 +57,7 @@
+
@@ -63,7 +65,7 @@
Ochre · middle
#E2BB6D
Slate blue · inner
#8AA8BA
-

100% intensity is the original palette. Hue and HSL lightness stay fixed; saturation stops at 100%. Gap compresses or expands the space between rings; scale zooms the whole mark about its center so the band spacing stays even at any size; thickness changes stroke width; rotation spins the whole mark; order reassigns which color sits on each ring. Readouts are the exact sRGB hex colors shown. Export downloads a standalone SVG with a white background circle.

+

100% intensity is the original palette. Hue and HSL lightness stay fixed; saturation stops at 100%. Gap compresses or expands the space between rings; scale zooms the whole mark about its center so the band spacing stays even at any size; thickness changes stroke width; rotation spins the whole mark; order reassigns which color sits on each ring. Readouts are the exact sRGB hex colors shown. The white background circle in the preview is part of the mark. Export downloads it as a standalone SVG, or as a 2048px PNG that is transparent outside the circle.

diff --git a/public/color-rings.js b/public/color-rings.js index 667d731..60dc08a 100644 --- a/public/color-rings.js +++ b/public/color-rings.js @@ -12,6 +12,7 @@ const order = document.querySelector('#order'); const theme = document.querySelector('#theme'); const exportButton = document.querySelector('#export'); + const exportPngButton = document.querySelector('#export-png'); const ringsGroup = document.querySelector('#rings-group'); const ringsSvg = document.querySelector('.rings'); const bases = { clay: '#C98576', ochre: '#D6B779', slate: '#8FA7B5' }; @@ -24,6 +25,7 @@ const MIDDLE_R = 180; const INNER_R = 110; const BASE_GAP = 10; + const PNG_SIZE = 2048; function toHsl(hex) { const [r, g, b] = hex.match(/[a-f\d]{2}/gi).map(v => parseInt(v, 16) / 255); @@ -103,9 +105,10 @@ theme.textContent = dark ? 'Light mode' : 'Dark mode'; } - function exportSvg() { - // Build a standalone SVG: current geometry and colors on a white - // background circle, independent of the page's theme. + // Build a standalone SVG: current geometry and colors on a white + // background circle, independent of the page's theme. Size sets the + // intrinsic pixel dimensions; the viewBox stays 600 so geometry is shared. + function buildSvg(size = 600) { const names = orderedNames(); const pct = Number(intensity.value); const gapValue = Number(gap.value); @@ -120,8 +123,8 @@ return ` `; }).join('\n'); - const svg = ` - + return ` + Color rings @@ -129,18 +132,40 @@ ${circles} `; + } - const blob = new Blob([svg], { type: 'image/svg+xml' }); + function download(blob, filename) { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = 'color-rings.svg'; + link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } + function exportSvg() { + download(new Blob([buildSvg()], { type: 'image/svg+xml' }), 'color-rings.svg'); + } + + function exportPng() { + // Rasterize the same standalone SVG onto an untouched canvas, so + // everything outside the white background circle stays transparent. + const url = URL.createObjectURL(new Blob([buildSvg(PNG_SIZE)], { type: 'image/svg+xml' })); + const image = new Image(); + image.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = PNG_SIZE; + canvas.height = PNG_SIZE; + canvas.getContext('2d').drawImage(image, 0, 0, PNG_SIZE, PNG_SIZE); + URL.revokeObjectURL(url); + canvas.toBlob(blob => download(blob, 'color-rings.png'), 'image/png'); + }; + image.onerror = () => URL.revokeObjectURL(url); + image.src = url; + } + for (const input of [intensity, gap, scale, weight, rotation, order]) { input.addEventListener('input', render); } @@ -149,6 +174,7 @@ ${circles} applyTheme(); }); exportButton.addEventListener('click', exportSvg); + exportPngButton.addEventListener('click', exportPng); document.querySelector('.controls').addEventListener('reset', event => { event.preventDefault(); for (const input of [intensity, gap, scale, weight, rotation, order]) { diff --git a/scripts/color-rings.test.mjs b/scripts/color-rings.test.mjs index 4d9d270..69fec22 100644 --- a/scripts/color-rings.test.mjs +++ b/scripts/color-rings.test.mjs @@ -262,3 +262,68 @@ test('export produces a standalone SVG with a white background circle', () => { assert.ok(!svg.includes('rings-group')); assert.ok(!svg.includes('data-rings-theme')); }); + +test('PNG export rasterizes the standalone SVG onto a transparent 2048px canvas', () => { + const { element } = makeHarness(); + const blobs = []; + const revoked = []; + const drawn = []; + const downloads = []; + let image; + const canvas = { + getContext: kind => ({ drawImage: (...args) => drawn.push([kind, ...args]) }), + toBlob(callback, type) { callback({ png: true, type }); }, + }; + const link = { click() { downloads.push({ href: this.href, download: this.download }); }, remove() {} }; + vm.runInNewContext(readFileSync(new URL('../public/color-rings.js', import.meta.url), 'utf8'), { + document: { + querySelector: element, + createElement: tag => (tag === 'canvas' ? canvas : link), + body: { appendChild() {} }, + }, + Blob: class { constructor(parts, opts) { this.parts = parts; this.type = opts && opts.type; blobs.push(this); } }, + URL: { createObjectURL: blob => `blob:${blob.png ? 'png' : 'svg'}`, revokeObjectURL: url => revoked.push(url) }, + Image: class { constructor() { image = this; } }, + }); + element('#rotation').value = '45'; + element('#rotation').listeners.input(); + element('#export-png').listeners.click(); + + // The source is the same standalone SVG as the SVG export, sized up so the + // browser rasterizes it at full resolution. + assert.equal(blobs.length, 1); + assert.equal(blobs[0].type, 'image/svg+xml'); + const svg = blobs[0].parts[0]; + assert.match(svg, /viewBox="0 0 600 600" width="2048" height="2048"/); + assert.match(svg, //); + assert.match(svg, //); + assert.equal(image.src, 'blob:svg'); + assert.equal(downloads.length, 0, 'nothing downloads until the image loads'); + + image.onload(); + assert.equal(canvas.width, 2048); + assert.equal(canvas.height, 2048); + // Only the image is drawn: no fill behind it, so the corners stay clear. + assert.equal(drawn.length, 1); + assert.equal(drawn[0][0], '2d'); + assert.equal(drawn[0][1], image); + assert.deepEqual(drawn[0].slice(2), [0, 0, 2048, 2048]); + assert.deepEqual(downloads, [{ href: 'blob:png', download: 'color-rings.png' }]); + assert.deepEqual(revoked, ['blob:svg', 'blob:png']); +}); + +test('SVG export keeps its 600px intrinsic size', () => { + const { element } = makeHarness(); + let blob; + vm.runInNewContext(readFileSync(new URL('../public/color-rings.js', import.meta.url), 'utf8'), { + document: { + querySelector: element, + createElement: () => ({ click() {}, remove() {} }), + body: { appendChild() {} }, + }, + Blob: class { constructor(parts) { blob = parts[0]; } }, + URL: { createObjectURL: () => 'blob:mock', revokeObjectURL() {} }, + }); + element('#export').listeners.click(); + assert.match(blob, /viewBox="0 0 600 600" width="600" height="600"/); +});