Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | import type { NumberLineState } from '../../types' import { numberToScreenX } from '../../numberLineTicks' import { SUBDIVISIONS, RECT_RATIO, computeSweepTransform } from './goldenRatioDemo' export interface AlignmentConfig { scale: number rotation: number offsetX: number offsetY: number } // Spiral convergence point (innermost subdivision center) const CONV = { x: SUBDIVISIONS[SUBDIVISIONS.length - 1].arcCx, y: SUBDIVISIONS[SUBDIVISIONS.length - 1].arcCy, } // Max distance from convergence to any corner of the golden rectangle // (in sub-coords). This is where feathering begins — the entire golden // rectangle is fully opaque within this radius. // Golden rect corners: (0,0), (φ,0), (φ,-1), (0,-1). CONV.y is negative. const MAX_CORNER_DIST_SUB = Math.max( Math.hypot(CONV.x, CONV.y), // to (0, 0) Math.hypot(RECT_RATIO - CONV.x, CONV.y), // to (φ, 0) Math.hypot(RECT_RATIO - CONV.x, CONV.y + 1), // to (φ, -1) Math.hypot(CONV.x, CONV.y + 1) // to (0, -1) ) // Full circle radius: feather occupies the outer 25%, so the opaque // zone (inner 75%) must reach past every corner of the golden rectangle. const RADIUS_SUB = MAX_CORNER_DIST_SUB / 0.75 // Pre-compute the FINAL sweep transform (revealProgress = 1.0) and the // convergence point in number-line coordinates. At final position the // golden rectangle maps to [0, φ] × [-1, 0] in NL space with effScale ≈ 1. const FINAL = computeSweepTransform(1.0) const FINAL_COS = Math.cos(FINAL.effRotation) const FINAL_SIN = Math.sin(FINAL.effRotation) const CONV_NL: [number, number] = (() => { const dx = CONV.x - FINAL.tipX const dy = CONV.y - FINAL.tipY return [ (dx * FINAL_COS - dy * FINAL_SIN) * FINAL.effScale, (dx * FINAL_SIN + dy * FINAL_COS) * FINAL.effScale, ] as [number, number] })() // Cached offscreen canvas for feathered circle compositing let _featherCanvas: HTMLCanvasElement | null = null let _featherCtx: CanvasRenderingContext2D | null = null /** Max offscreen canvas dimension to avoid huge allocations */ const MAX_BUFFER_SIZE = 2048 /** * Render a phi-explore image behind the golden spiral overlay. * * The image is positioned at the spiral's FINAL canonical location (not the * current animated transform) and fades in as a static background. This * avoids the astronomical scale factors at mid-animation that would stretch * the image into a single-color wash. * * The image is drawn inside a feathered circle centered on the spiral * convergence point. The circle is sized so its opaque zone (inner 75%) * covers every corner of the golden rectangle. The outer 25% feathers * to transparent, starting just past the farthest rectangle corner. * * Admin editor: toCanvasY(y) = oy + (-y) * mapScale (Y-flipped) * Live demo: toY(nly) = centerY + nly * ppu (no flip; subs already flipped) * * To correct: negate offsetY and negate rotation from alignment data. */ export function renderPhiExploreImage( ctx: CanvasRenderingContext2D, state: NumberLineState, _cssWidth: number, cssHeight: number, _revealProgress: number, imageOpacity: number, image: HTMLImageElement, alignment: AlignmentConfig ): void { if (imageOpacity <= 0) return const centerY = cssHeight / 2 const ppu = state.pixelsPerUnit const toX = (nlx: number) => numberToScreenX(nlx, state.center, ppu, _cssWidth) const toY = (nly: number) => centerY + nly * ppu // Convergence point in screen coords using the FINAL transform const convSx = toX(CONV_NL[0]) const convSy = toY(CONV_NL[1]) // Golden rectangle height at final scale (effScale ≈ 1) const boxH = FINAL.effScale * ppu // Circle radius in CSS pixels — sized so the opaque zone covers // the entire golden rectangle and feathering starts beyond it const radius = RADIUS_SUB * boxH if (radius < 2) return // --- Offscreen canvas for feathered circle --- const dpr = window.devicePixelRatio || 1 const pixelDiameter = Math.min(MAX_BUFFER_SIZE, Math.ceil(radius * 2 * dpr)) if (pixelDiameter < 2) return // The effective scale from CSS coords to offscreen pixels const bufferScale = pixelDiameter / (radius * 2) if (!_featherCanvas) { _featherCanvas = document.createElement('canvas') _featherCtx = _featherCanvas.getContext('2d')! } // Resize only when needed; otherwise just clear if (_featherCanvas.width !== pixelDiameter || _featherCanvas.height !== pixelDiameter) { _featherCanvas.width = pixelDiameter _featherCanvas.height = pixelDiameter } else { _featherCtx!.setTransform(1, 0, 0, 1, 0, 0) _featherCtx!.globalCompositeOperation = 'source-over' _featherCtx!.clearRect(0, 0, pixelDiameter, pixelDiameter) } const tc = _featherCtx! const cx = pixelDiameter / 2 const cy = pixelDiameter / 2 const pixelRadius = pixelDiameter / 2 // 1) Clip to circle tc.save() tc.beginPath() tc.arc(cx, cy, pixelRadius, 0, Math.PI * 2) tc.clip() // 2) Apply alignment transform around center (in buffer pixel coords) // Y-axis correction: negate rotation and offsetY from admin data. const alignRotRad = (-alignment.rotation * Math.PI) / 180 tc.translate(cx, cy) tc.rotate(alignRotRad) tc.scale(alignment.scale, alignment.scale) tc.translate(-cx, -cy) // 3) Draw image in cover-fit mode, sized to fill the full circle. // The circle is larger than the golden rectangle, so the image must // cover the entire pixelDiameter × pixelDiameter buffer. const imgAspect = image.naturalWidth / image.naturalHeight let imgDrawW: number, imgDrawH: number if (imgAspect > 1) { imgDrawH = pixelDiameter imgDrawW = pixelDiameter * imgAspect } else { imgDrawW = pixelDiameter imgDrawH = pixelDiameter / imgAspect } // Image center offset from convergence (in buffer pixels) const imgCx = cx + alignment.offsetX * boxH * bufferScale const imgCy = cy + -alignment.offsetY * boxH * bufferScale tc.drawImage(image, imgCx - imgDrawW / 2, imgCy - imgDrawH / 2, imgDrawW, imgDrawH) tc.restore() // 4) Feather mask: outer 25% of circle fades to transparent tc.globalCompositeOperation = 'destination-in' const featherStart = pixelRadius * 0.75 const grad = tc.createRadialGradient(cx, cy, featherStart, cx, cy, pixelRadius) grad.addColorStop(0, 'rgba(0,0,0,1)') grad.addColorStop(1, 'rgba(0,0,0,0)') tc.fillStyle = grad tc.fillRect(0, 0, pixelDiameter, pixelDiameter) tc.globalCompositeOperation = 'source-over' // 5) Draw feathered circle onto main canvas ctx.save() ctx.globalAlpha = imageOpacity const drawSize = radius * 2 ctx.drawImage(_featherCanvas, convSx - radius, convSy - radius, drawSize, drawSize) ctx.restore() } |