All files / web/src/components/toys/euclid/render renderGhostGeometry.ts

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

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 184 185 186 187 188 189 190 191                                                                                                                                                                                                                                                                                                                                                                                             
import type { EuclidViewportState, GhostLayer } from '../types'
import { worldToScreen2D } from '../../shared/coordinateConversions'

function toScreen(wx: number, wy: number, viewport: EuclidViewportState, w: number, h: number) {
  return worldToScreen2D(
    wx,
    wy,
    viewport.center.x,
    viewport.center.y,
    viewport.pixelsPerUnit,
    viewport.pixelsPerUnit,
    w,
    h
  )
}

/** Lerp factor per frame (~60fps). Gives ~150ms transition. */
const LERP_SPEED = 0.15

// ── Ghost opacity controls ──

// Base opacity at rest for depth 1. Hover brightens to ~4x this value.
let ghostBaseOpacity = 0.2
export function getGhostBaseOpacity() {
  return ghostBaseOpacity
}
export function setGhostBaseOpacity(v: number) {
  ghostBaseOpacity = Math.max(0, Math.min(1, v))
}
export function getGhostBaseOpacityRange() {
  return { min: 0, max: 0.5 }
}

// Depth falloff: 0 = no backoff, 1 = immediate backoff (depth > 1 invisible)
let ghostFalloff = 0.75
export function getGhostFalloff() {
  return ghostFalloff
}
export function setGhostFalloff(v: number) {
  ghostFalloff = Math.max(0, Math.min(1, v))
}
export function getGhostFalloffRange() {
  return { min: 0, max: 1 }
}

/**
 * Compute depth-based opacity multiplier.
 * falloff=0 → 1 for all depths (no backoff)
 * falloff=1 → 0 for depth > 1 (immediate cutoff)
 */
function depthFactor(depth: number): number {
  return Math.max(0, 1 - ghostFalloff * (depth - 1))
}

/**
 * Render ghost geometry layers with depth-based opacity and smooth hover transitions.
 *
 * Ghost geometry represents the internal construction elements that macro
 * invocations skip — e.g. the two circles whose intersection proves I.1's
 * equilateral triangle. They appear as faint dashed outlines at rest and
 * brighten when the user hovers the corresponding step in the proof panel.
 *
 * @param ceremonyRevealCounts  During the macro reveal ceremony, maps layer key
 *   (`${atStep}:${depth}`) → number of revealGroups revealed so far.
 *   When present, overrides the normal hover/watermark opacity.
 * @returns true if opacity is still animating (caller should keep drawing)
 */
export function renderGhostGeometry(
  ctx: CanvasRenderingContext2D,
  ghostLayers: GhostLayer[],
  viewport: EuclidViewportState,
  w: number,
  h: number,
  hoveredMacroStep: number | null,
  opacities: Map<string, number>,
  ceremonyRevealCounts?: Map<string, number> | null,
  elementAnims?: Map<string, { startMs: number; durationMs: number }> | null,
  now?: number
): boolean {
  if (ghostLayers.length === 0) return false

  const ppu = viewport.pixelsPerUnit
  let stillAnimating = false

  for (const layer of ghostLayers) {
    const df = depthFactor(layer.depth)
    if (df <= 0) continue // this depth is invisible at current falloff

    const key = `${layer.atStep}:${layer.depth}`
    // A layer is in the ceremony only if it appears in the ceremony map
    const inLayerCeremony = ceremonyRevealCounts != null && ceremonyRevealCounts.has(key)

    let targetAlpha: number
    if (inLayerCeremony) {
      // During ceremony for this layer: revealed groups at 0.75, unrevealed at 0
      const revealedGroups = ceremonyRevealCounts!.get(key) ?? 0
      targetAlpha = revealedGroups > 0 ? 0.75 * df : 0
    } else {
      // Normal mode: brightened if hovered, faint watermark otherwise
      const isHovered = hoveredMacroStep === layer.atStep
      const baseAlpha = isHovered ? Math.min(1, ghostBaseOpacity * 4) : ghostBaseOpacity
      targetAlpha = baseAlpha * df
    }

    const currentAlpha = opacities.get(key) ?? targetAlpha
    const newAlpha = currentAlpha + (targetAlpha - currentAlpha) * LERP_SPEED
    const finalAlpha = Math.abs(newAlpha - targetAlpha) < 0.005 ? targetAlpha : newAlpha
    opacities.set(key, finalAlpha)

    if (finalAlpha !== targetAlpha) stillAnimating = true
    if (finalAlpha < 0.005) continue

    // Build set of visible element indices for ceremony mode
    let visibleIndices: Set<number> | null = null
    if (inLayerCeremony && layer.revealGroups) {
      const revealedGroups = ceremonyRevealCounts!.get(key) ?? 0
      visibleIndices = new Set<number>()
      for (let g = 0; g < revealedGroups && g < layer.revealGroups.length; g++) {
        for (const idx of layer.revealGroups[g]) visibleIndices.add(idx)
      }
    }

    ctx.save()
    ctx.globalAlpha = finalAlpha

    layer.elements.forEach((el, idx) => {
      if (visibleIndices && !visibleIndices.has(idx)) return

      // Compute draw progress for animated ceremony reveals
      const animKey = `${key}:${idx}`
      const anim = elementAnims?.get(animKey)
      let drawProgress = 1
      if (anim && anim.durationMs > 0 && now !== undefined) {
        drawProgress = Math.min(1, (now - anim.startMs) / anim.durationMs)
        if (drawProgress < 1) stillAnimating = true
      }

      if (el.kind === 'circle') {
        const center = toScreen(el.cx, el.cy, viewport, w, h)
        const screenR = el.r * ppu
        ctx.beginPath()
        if (drawProgress >= 1) {
          ctx.arc(center.x, center.y, screenR, 0, Math.PI * 2)
        } else {
          // Sweep clockwise from the top (−π/2), like a compass
          const startAngle = -Math.PI / 2
          ctx.arc(center.x, center.y, screenR, startAngle, startAngle + drawProgress * Math.PI * 2)
        }
        ctx.strokeStyle = el.color
        ctx.lineWidth = 1.5
        ctx.setLineDash([6, 4])
        ctx.stroke()
        ctx.setLineDash([])
      } else if (el.kind === 'segment') {
        const from = toScreen(el.x1, el.y1, viewport, w, h)
        const toFull = toScreen(el.x2, el.y2, viewport, w, h)
        // Ease-out so the line slows to a stop, matching straightedge draw feel
        const eased = drawProgress < 1 ? 1 - (1 - drawProgress) * (1 - drawProgress) : 1
        const to = {
          x: from.x + eased * (toFull.x - from.x),
          y: from.y + eased * (toFull.y - from.y),
        }
        ctx.beginPath()
        ctx.moveTo(from.x, from.y)
        ctx.lineTo(to.x, to.y)
        ctx.strokeStyle = el.color
        if (el.isProduction) {
          ctx.lineWidth = 1
          ctx.globalAlpha = finalAlpha * 0.5
        } else {
          ctx.lineWidth = 1.5
        }
        ctx.setLineDash([6, 4])
        ctx.stroke()
        ctx.setLineDash([])
        if (el.isProduction) ctx.globalAlpha = finalAlpha
      } else if (el.kind === 'point') {
        const pos = toScreen(el.x, el.y, viewport, w, h)
        ctx.beginPath()
        ctx.arc(pos.x, pos.y, 3, 0, Math.PI * 2)
        ctx.fillStyle = el.color
        ctx.fill()
      }
    })

    ctx.restore()
  }

  return stillAnimating
}