diff --git a/public/color-rings.html b/public/color-rings.html index f991301..0b7377a 100644 --- a/public/color-rings.html +++ b/public/color-rings.html @@ -14,7 +14,7 @@

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, edge margin, thickness, and rotation. + Clay on the outside, ochre in the middle, dusty slate blue inside. The controls change their saturation, gap, scale, thickness, and rotation. @@ -30,9 +30,9 @@ -
38
- - +
100%
+ +
24
@@ -63,7 +63,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; margin sets the white space between the outer ring and the canvas edge, independent of thickness; 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. Export downloads a standalone SVG with a white background circle.

diff --git a/public/color-rings.js b/public/color-rings.js index 487e480..667d731 100644 --- a/public/color-rings.js +++ b/public/color-rings.js @@ -3,8 +3,8 @@ const intensityOut = document.querySelector('#intensity-value'); const gap = document.querySelector('#gap'); const gapOut = document.querySelector('#gap-value'); - const margin = document.querySelector('#margin'); - const marginOut = document.querySelector('#margin-value'); + const scale = document.querySelector('#scale'); + const scaleOut = document.querySelector('#scale-value'); const weight = document.querySelector('#weight'); const weightOut = document.querySelector('#weight-value'); const rotation = document.querySelector('#rotation'); @@ -16,11 +16,11 @@ const ringsSvg = document.querySelector('.rings'); const bases = { clay: '#C98576', ochre: '#D6B779', slate: '#8FA7B5' }; - // Deployed mark: gap=10, margin=38, weight=24 gives radii 250/180/110 with + // Deployed mark: gap=10, scale=100, weight=24 gives radii 250/180/110 with // open space between each color and the ring edges, like the ring sigil. // The DOM keeps clay/ochre/slate element ids stable; the order select // reassigns radii by position, outer to inner. - const CANVAS_R = 300; + const OUTER_R = 250; const MIDDLE_R = 180; const INNER_R = 110; const BASE_GAP = 10; @@ -49,31 +49,37 @@ return order.value.split(','); } + // Radial geometry, shared by the live render and the SVG export. The extra + // gap compresses the middle and inner rings inward so the mark never clips + // the viewBox. Scale zooms every ring about the shared center, so the band + // spacing stays even at any size; thickness is independent and only changes + // the stroke width. Radii are rounded to hundredths so slider steps never + // leak float noise (e.g. 180 * 0.8) into the SVG. + function ringRadii(gapValue, scaleValue) { + const extra = gapValue - BASE_GAP; + const k = scaleValue / 100; + const outer = OUTER_R * k; + const middle = Math.max(INNER_R / 2, MIDDLE_R - extra) * k; + const inner = Math.max(INNER_R / 4, INNER_R - 2 * extra) * k; + return [outer, middle, inner].map(v => Math.round(v * 100) / 100); + } + function render() { const pct = Number(intensity.value); const gapValue = Number(gap.value); - const marginValue = Number(margin.value); + const scaleValue = Number(scale.value); const weightValue = Number(weight.value); const rotationValue = Number(rotation.value); const names = orderedNames(); intensityOut.value = `${pct}%`; gapOut.value = String(gapValue); - marginOut.value = String(marginValue); + scaleOut.value = `${scaleValue}%`; weightOut.value = String(weightValue); rotationOut.value = `${rotationValue}°`; intensity.setAttribute('aria-valuetext', `${pct}% of original saturation`); - // Radial geometry: the extra gap compresses the middle and inner rings - // inward so the mark never clips the viewBox. The margin is the visible - // white space between the outer ring's edge and the canvas edge; it is - // independent of thickness, so weight changes move the radius instead. - // The floor keeps the outer ring from crossing the middle ring. - const extra = gapValue - BASE_GAP; - const middle = Math.max(INNER_R / 2, MIDDLE_R - extra); - const inner = Math.max(INNER_R / 4, INNER_R - 2 * extra); - const outer = Math.max(CANVAS_R - marginValue - weightValue / 2, middle + weightValue); - const radiusByPosition = [outer, middle, inner]; + const radiusByPosition = ringRadii(gapValue, scaleValue); ringsGroup.setAttribute('stroke-width', String(weightValue)); ringsGroup.setAttribute('transform', `rotate(${rotationValue} 300 300)`); @@ -103,14 +109,10 @@ const names = orderedNames(); const pct = Number(intensity.value); const gapValue = Number(gap.value); - const marginValue = Number(margin.value); + const scaleValue = Number(scale.value); const weightValue = Number(weight.value); const rotationValue = Number(rotation.value); - const extra = gapValue - BASE_GAP; - const middle = Math.max(INNER_R / 2, MIDDLE_R - extra); - const inner = Math.max(INNER_R / 4, INNER_R - 2 * extra); - const outer = Math.max(CANVAS_R - marginValue - weightValue / 2, middle + weightValue); - const radiusByPosition = [outer, middle, inner]; + const radiusByPosition = ringRadii(gapValue, scaleValue); const circles = names.map(name => { const [h, s, l] = toHsl(bases[name]); @@ -139,7 +141,7 @@ ${circles} URL.revokeObjectURL(url); } - for (const input of [intensity, gap, margin, weight, rotation, order]) { + for (const input of [intensity, gap, scale, weight, rotation, order]) { input.addEventListener('input', render); } theme.addEventListener('click', () => { @@ -149,7 +151,7 @@ ${circles} exportButton.addEventListener('click', exportSvg); document.querySelector('.controls').addEventListener('reset', event => { event.preventDefault(); - for (const input of [intensity, gap, margin, weight, rotation, order]) { + for (const input of [intensity, gap, scale, weight, rotation, order]) { input.value = input.defaultValue; } theme.value = theme.defaultValue; diff --git a/scripts/color-rings.test.mjs b/scripts/color-rings.test.mjs index 7d71a3a..4d9d270 100644 --- a/scripts/color-rings.test.mjs +++ b/scripts/color-rings.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; -const INPUTS = ['#intensity', '#gap', '#margin', '#weight', '#rotation']; +const INPUTS = ['#intensity', '#gap', '#scale', '#weight', '#rotation']; function makeHarness() { const elements = new Map(); @@ -17,7 +17,7 @@ function makeHarness() { return elements.get(selector); }; const defaults = { - '#intensity': '125', '#gap': '10', '#margin': '38', '#weight': '24', '#rotation': '0', + '#intensity': '125', '#gap': '10', '#scale': '100', '#weight': '24', '#rotation': '0', }; for (const [sel, val] of Object.entries(defaults)) { element(sel).value = val; @@ -85,43 +85,49 @@ test('gap slider moves inner rings while the outer ring stays fixed', () => { assert.equal(element('#ring-slate').attributes.r, '110'); }); -test('margin slider insets the outer ring independently of thickness', () => { +test('scale slider zooms the whole mark, keeping band spacing even', () => { const { element } = makeHarness(); - const margin = element('#margin'); - // Default margin 38, weight 24: outer radius 300 - 38 - 12 = 250. + const scale = element('#scale'); + // Default scale 100: deployed radii 250/180/110. assert.equal(element('#ring-clay').attributes.r, '250'); - - // More margin insets the outer ring; middle and inner rings stay put. - margin.value = '80'; - margin.listeners.input(); - assert.equal(element('#ring-clay').attributes.r, '208'); assert.equal(element('#ring-ochre').attributes.r, '180'); assert.equal(element('#ring-slate').attributes.r, '110'); - assert.equal(element('#margin-value').value, '80'); - // Margin is visible white space, independent of thickness. Back at the - // default margin 38, weight 40 moves the radius to 300 - 38 - 20 = 242 so - // the outer edge stays at 262, still 38 from the canvas edge. - margin.value = '38'; - margin.listeners.input(); + // Scale 80 zooms every ring about the shared center. The radius step + // scales with it (70 -> 56), so the band spacing stays even everywhere + // instead of the outer band collapsing onto the middle one. + scale.value = '80'; + scale.listeners.input(); + assert.equal(element('#ring-clay').attributes.r, '200'); + assert.equal(element('#ring-ochre').attributes.r, '144'); + assert.equal(element('#ring-slate').attributes.r, '88'); + assert.equal(element('#scale-value').value, '80%'); + + // Scale is independent of thickness: changing the weight does not move + // any radius, so both band gaps stay equal (56 - 40 = 16 each here). const weight = element('#weight'); weight.value = '40'; weight.listeners.input(); - assert.equal(element('#ring-clay').attributes.r, '242'); + assert.equal(element('#ring-clay').attributes.r, '200'); + assert.equal(element('#ring-ochre').attributes.r, '144'); + assert.equal(element('#ring-slate').attributes.r, '88'); - // Flush margin puts the outer edge at the canvas edge. - margin.value = '0'; - margin.listeners.input(); - assert.equal(element('#ring-clay').attributes.r, '280'); + // Scale past 100 grows the mark toward the canvas edge. + scale.value = '110'; + scale.listeners.input(); + assert.equal(element('#ring-clay').attributes.r, '275'); + assert.equal(element('#ring-ochre').attributes.r, '198'); + assert.equal(element('#ring-slate').attributes.r, '121'); - // At extreme margin + thickness the floor keeps the outer band from - // crossing the middle band instead of shrinking under it. - margin.value = '80'; - margin.listeners.input(); - assert.equal(element('#ring-clay').attributes.r, '220'); + // Gap adjustments compose with scale: the gap-compressed radii then zoom. + const gap = element('#gap'); + gap.value = '30'; + gap.listeners.input(); + assert.equal(element('#ring-ochre').attributes.r, '176'); + assert.equal(element('#ring-slate').attributes.r, '77'); element('.controls').listeners.reset({ preventDefault() {} }); - assert.equal(margin.value, '38'); + assert.equal(scale.value, '100'); assert.equal(element('#ring-clay').attributes.r, '250'); });