All files / web/src/components/toys/number-line/constants/demos renderPhiExploreImage.ts

0% Statements 0/182
0% Branches 0/1
0% Functions 0/1
0% Lines 0/182

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()
}