Below is a cross-section of a rolled-up "scroll" — a spiral, like the papyrus seen edge-on inside a CT scan. Hidden ink strokes sit on its surface, but coiled up like this they're impossible to read.
<p class="hint">This spiral stands in for a scroll seen edge-on, like a slice through a CT scan. Somewhere on its coiled surface are faint ink strokes — but coiled up, they're unreadable. Trace the layers, then unroll them flat.</p>
<div class="stage">
<canvas id="spiral" width="220" height="220"></canvas>
<canvas id="strip" width="420" height="140"></canvas>
</div>
<div class="status" id="status">Press "Trace the layers" to begin.</div>
<div class="btns">
<button id="trace" type="button">Trace the layers</button>
<button id="unroll" type="button">Unroll</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.stage { display: flex; gap: .8rem; flex-wrap: wrap; align-items: flex-start; margin: .4rem 0; }
canvas { background: #f4efe4; border: 1px solid #d8cfba; border-radius: 8px; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.info { color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Virtual unwrapping in miniature: a spiral stands in for a scroll's cross-section.
// Ink is modeled as faint strokes at fixed positions along the coil, invisible until the coil is straightened.
const TURNS = 3.2; // how many times the spiral winds around its center
const POINTS_PER_TURN = 90; // sample points per full turn (resolution of the traced surface)
const TOTAL_POINTS = Math.round(TURNS * POINTS_PER_TURN);
const CX = 110, CY = 110; // canvas center, the axis the scroll is coiled around
const R0 = 10, DR = 12; // starting radius and how fast the radius grows per turn (sheet thickness)
// a handful of ink strokes, each pinned to one layer (turn index) and an angular range within that turn
const INK_STROKES = [
{ turnIndex: 0, angleStart: 0.55, angleEnd: 0.85 },
{ turnIndex: 0, angleStart: 0.10, angleEnd: 0.30 },
{ turnIndex: 1, angleStart: 0.35, angleEnd: 0.70 },
{ turnIndex: 1, angleStart: 0.05, angleEnd: 0.20 },
{ turnIndex: 2, angleStart: 0.20, angleEnd: 0.55 },
{ turnIndex: 2, angleStart: 0.65, angleEnd: 0.85 }
];
function inkAt(turnIndex, angleFrac){
// whether the given layer and angle position falls inside any ink stroke's range, 0 if not
for(const s of INK_STROKES){
if(s.turnIndex === turnIndex && angleFrac >= s.angleStart && angleFrac <= s.angleEnd) return 1;
}
return 0;
}
function spiralPoint(t){
// convert a 0..1 position along the whole coil into (x, y, radius, angle)
const angle = t * TURNS * Math.PI * 2;
const r = R0 + DR * (t * TURNS);
return { x: CX + r * Math.cos(angle), y: CY + r * Math.sin(angle), r, angle };
}
let traced = false, unrolled = false;
function drawSpiral(withInk){
const cv = document.getElementById('spiral');
const ctx = cv.getContext('2d');
ctx.clearRect(0,0,cv.width,cv.height);
ctx.lineWidth = 1;
ctx.strokeStyle = '#c9bfa0';
ctx.beginPath();
for(let i=0;i<=TOTAL_POINTS;i++){
const t = i / TOTAL_POINTS;
const p = spiralPoint(t);
if(i===0) ctx.moveTo(p.x,p.y); else ctx.lineTo(p.x,p.y);
}
ctx.stroke();
if(withInk){
for(let i=0;i<TOTAL_POINTS;i++){
const t = i / TOTAL_POINTS;
const turnPosContinuous = t * TURNS;
const turnIndex = Math.floor(turnPosContinuous);
const angleFrac = turnPosContinuous - turnIndex;
const ink = inkAt(turnIndex, angleFrac);
if(ink > 0){
const p = spiralPoint(t);
ctx.fillStyle = 'rgba(30,20,15,0.8)';
ctx.beginPath();
ctx.arc(p.x, p.y, 1.6, 0, Math.PI*2);
ctx.fill();
}
}
}
}
function drawStrip(revealInk){
const cv = document.getElementById('strip');
const ctx = cv.getContext('2d');
ctx.clearRect(0,0,cv.width,cv.height);
const w = cv.width, h = cv.height;
const margin = 10;
ctx.fillStyle = '#efe7d4';
ctx.fillRect(margin, margin, w - margin*2, h - margin*2);
ctx.strokeStyle = '#d8cfba';
ctx.strokeRect(margin, margin, w - margin*2, h - margin*2);
if(!revealInk) return;
// map each traced point's overall position along the coil to an x coordinate, and its angle within its own layer to a y coordinate
for(let i=0;i<TOTAL_POINTS;i++){
const t = i / TOTAL_POINTS;
const turnPosContinuous = t * TURNS;
const turnIndex = Math.floor(turnPosContinuous);
const angleFrac = turnPosContinuous - turnIndex;
const ink = inkAt(turnIndex, angleFrac);
if(ink > 0){
const x = margin + t * (w - margin*2);
const y = margin + 6 + angleFrac * (h - margin*2 - 12);
ctx.fillStyle = 'rgba(30,20,15,0.85)';
ctx.beginPath();
ctx.arc(x, y, 1.8, 0, Math.PI*2);
ctx.fill();
}
}
}
function setStatus(t, cls){
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + cls;
}
function trace(){
traced = true;
drawSpiral(true);
setStatus(i18nFmt('Traced %n% points along the spiral surface. Now unroll it.', { n: TOTAL_POINTS }), 'info');
document.getElementById('unroll').disabled = false;
}
function unroll(){
if(!traced) return;
unrolled = true;
drawStrip(true);
setStatus('Unrolled! The hidden ink strokes are now a readable strip.', 'ok');
console.log('Unrolled points:', TOTAL_POINTS, 'total samples flattened into the strip');
}
function reset(){
traced = false; unrolled = false;
drawSpiral(false);
drawStrip(false);
setStatus('Press "Trace the layers" to begin.', '');
document.getElementById('unroll').disabled = true;
}
document.getElementById('trace').addEventListener('click', trace);
document.getElementById('unroll').addEventListener('click', unroll);
document.getElementById('reset').addEventListener('click', reset);
document.getElementById('unroll').disabled = true;
drawSpiral(false);
drawStrip(false);
<p class="hint">This spiral stands in for a scroll seen edge-on, like a slice through a CT scan. Somewhere on its coiled surface are faint ink strokes — but coiled up, they're unreadable. Trace the layers, then unroll them flat.</p>
<div class="stage">
<canvas id="spiral" width="220" height="220"></canvas>
<canvas id="strip" width="420" height="140"></canvas>
</div>
<div class="status" id="status">Press "Trace the layers" to begin.</div>
<div class="btns">
<button id="trace" type="button">Trace the layers</button>
<button id="unroll" type="button">Unroll</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.stage { display: flex; gap: .8rem; flex-wrap: wrap; align-items: flex-start; margin: .4rem 0; }
canvas { background: #f4efe4; border: 1px solid #d8cfba; border-radius: 8px; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.info { color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Virtual unwrapping in miniature: a spiral stands in for a scroll's cross-section.
// Ink is modeled as faint strokes at fixed positions along the coil, invisible until the coil is straightened.
const TURNS = 3.2; // how many times the spiral winds around its center
const POINTS_PER_TURN = 90; // sample points per full turn (resolution of the traced surface)
const TOTAL_POINTS = Math.round(TURNS * POINTS_PER_TURN);
const CX = 110, CY = 110; // canvas center, the axis the scroll is coiled around
const R0 = 10, DR = 12; // starting radius and how fast the radius grows per turn (sheet thickness)
// a handful of ink strokes, each pinned to one layer (turn index) and an angular range within that turn
const INK_STROKES = [
{ turnIndex: 0, angleStart: 0.55, angleEnd: 0.85 },
{ turnIndex: 0, angleStart: 0.10, angleEnd: 0.30 },
{ turnIndex: 1, angleStart: 0.35, angleEnd: 0.70 },
{ turnIndex: 1, angleStart: 0.05, angleEnd: 0.20 },
{ turnIndex: 2, angleStart: 0.20, angleEnd: 0.55 },
{ turnIndex: 2, angleStart: 0.65, angleEnd: 0.85 }
];
function inkAt(turnIndex, angleFrac){
// whether the given layer and angle position falls inside any ink stroke's range, 0 if not
for(const s of INK_STROKES){
if(s.turnIndex === turnIndex && angleFrac >= s.angleStart && angleFrac <= s.angleEnd) return 1;
}
return 0;
}
function spiralPoint(t){
// convert a 0..1 position along the whole coil into (x, y, radius, angle)
const angle = t * TURNS * Math.PI * 2;
const r = R0 + DR * (t * TURNS);
return { x: CX + r * Math.cos(angle), y: CY + r * Math.sin(angle), r, angle };
}
let traced = false, unrolled = false;
function drawSpiral(withInk){
const cv = document.getElementById('spiral');
const ctx = cv.getContext('2d');
ctx.clearRect(0,0,cv.width,cv.height);
ctx.lineWidth = 1;
ctx.strokeStyle = '#c9bfa0';
ctx.beginPath();
for(let i=0;i<=TOTAL_POINTS;i++){
const t = i / TOTAL_POINTS;
const p = spiralPoint(t);
if(i===0) ctx.moveTo(p.x,p.y); else ctx.lineTo(p.x,p.y);
}
ctx.stroke();
if(withInk){
for(let i=0;i<TOTAL_POINTS;i++){
const t = i / TOTAL_POINTS;
const turnPosContinuous = t * TURNS;
const turnIndex = Math.floor(turnPosContinuous);
const angleFrac = turnPosContinuous - turnIndex;
const ink = inkAt(turnIndex, angleFrac);
if(ink > 0){
const p = spiralPoint(t);
ctx.fillStyle = 'rgba(30,20,15,0.8)';
ctx.beginPath();
ctx.arc(p.x, p.y, 1.6, 0, Math.PI*2);
ctx.fill();
}
}
}
}
function drawStrip(revealInk){
const cv = document.getElementById('strip');
const ctx = cv.getContext('2d');
ctx.clearRect(0,0,cv.width,cv.height);
const w = cv.width, h = cv.height;
const margin = 10;
ctx.fillStyle = '#efe7d4';
ctx.fillRect(margin, margin, w - margin*2, h - margin*2);
ctx.strokeStyle = '#d8cfba';
ctx.strokeRect(margin, margin, w - margin*2, h - margin*2);
if(!revealInk) return;
// map each traced point's overall position along the coil to an x coordinate, and its angle within its own layer to a y coordinate
for(let i=0;i<TOTAL_POINTS;i++){
const t = i / TOTAL_POINTS;
const turnPosContinuous = t * TURNS;
const turnIndex = Math.floor(turnPosContinuous);
const angleFrac = turnPosContinuous - turnIndex;
const ink = inkAt(turnIndex, angleFrac);
if(ink > 0){
const x = margin + t * (w - margin*2);
const y = margin + 6 + angleFrac * (h - margin*2 - 12);
ctx.fillStyle = 'rgba(30,20,15,0.85)';
ctx.beginPath();
ctx.arc(x, y, 1.8, 0, Math.PI*2);
ctx.fill();
}
}
}
function setStatus(t, cls){
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + cls;
}
function trace(){
traced = true;
drawSpiral(true);
setStatus(i18nFmt('Traced %n% points along the spiral surface. Now unroll it.', { n: TOTAL_POINTS }), 'info');
document.getElementById('unroll').disabled = false;
}
function unroll(){
if(!traced) return;
unrolled = true;
drawStrip(true);
setStatus('Unrolled! The hidden ink strokes are now a readable strip.', 'ok');
console.log('Unrolled points:', TOTAL_POINTS, 'total samples flattened into the strip');
}
function reset(){
traced = false; unrolled = false;
drawSpiral(false);
drawStrip(false);
setStatus('Press "Trace the layers" to begin.', '');
document.getElementById('unroll').disabled = true;
}
document.getElementById('trace').addEventListener('click', trace);
document.getElementById('unroll').addEventListener('click', unroll);
document.getElementById('reset').addEventListener('click', reset);
document.getElementById('unroll').disabled = true;
drawSpiral(false);
drawStrip(false);
Comments
Loading comments...