All files / web/src/components/toys/number-line/findTheNumber computeProximity.ts

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

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                                                                                                                                                                                                               
import type { NumberLineState } from '../types'
import { numberToScreenX } from '../numberLineTicks'

export type ProximityZone = 'far' | 'warm' | 'hot' | 'found'

export interface ProximityResult {
  /** 0 = invisible, 1 = fully visible */
  opacity: number
  /** Target's screen X position (may be off-screen) */
  screenX: number
  /** Whether the target is within the visible viewport */
  isOnScreen: boolean
  /** Qualitative distance */
  zone: ProximityZone
  /** Whether the user needs to zoom in more for the target to be visible */
  needsMoreZoom: boolean
  /** Whether target is left, right, or on screen relative to viewport center */
  targetDirection: 'left' | 'right' | 'onscreen'
  /** Raw zoom ratio: pxPerPrecisionUnit / requiredPx. >1 means more than enough zoom. */
  zoomFactor: number
}

/**
 * Compute how close the user's viewport is to the target number.
 *
 * Visibility requires both:
 * 1. Being panned close enough (position proximity)
 * 2. Being zoomed in enough (zoom adequacy)
 *
 * The target's precision determines the required zoom level — e.g., 3.14
 * needs more zoom than 3 because the user needs to see hundredths.
 */
export function computeProximity(
  target: number,
  state: NumberLineState,
  canvasWidth: number
): ProximityResult {
  const { center, pixelsPerUnit } = state

  const screenX = numberToScreenX(target, center, pixelsPerUnit, canvasWidth)
  const isOnScreen = screenX >= 0 && screenX <= canvasWidth
  const viewportCenter = canvasWidth / 2

  // Target direction relative to viewport center
  const targetDirection: 'left' | 'right' | 'onscreen' =
    screenX < 0 ? 'left' : screenX > canvasWidth ? 'right' : 'onscreen'

  // --- Zoom adequacy ---
  // Determine precision of the target number to decide required zoom
  const precision = getTargetPrecision(target)
  // At minimum, one unit at the target's precision should be ~20px wide
  const unitAtPrecision = 10 ** -precision
  const pxPerPrecisionUnit = unitAtPrecision * pixelsPerUnit
  const requiredPx = 20
  // Raw zoom ratio (uncapped) — exposed for hint logic
  const zoomFactor = pxPerPrecisionUnit / requiredPx
  // Capped version for opacity calculation
  const zoomFactorCapped = Math.min(1, zoomFactor)
  const needsMoreZoom = zoomFactorCapped < 0.8

  // --- Position proximity ---
  // Distance from viewport center to target in pixels, normalized by canvas width
  const distPx = Math.abs(screenX - viewportCenter)
  // Position factor: 1 when target is at center, fading to 0 at 1.5x canvas width away
  const positionFactor = Math.max(0, 1 - distPx / (canvasWidth * 1.5))

  // --- Combined opacity ---
  const opacity = Math.min(zoomFactorCapped, positionFactor)

  // --- Zone classification ---
  let zone: ProximityZone
  const foundThresholdPx = 30
  if (opacity > 0.95 && distPx < foundThresholdPx) {
    zone = 'found'
  } else if (opacity > 0.6) {
    zone = 'hot'
  } else if (opacity > 0.2) {
    zone = 'warm'
  } else {
    zone = 'far'
  }

  return { opacity, screenX, isOnScreen, zone, needsMoreZoom, targetDirection, zoomFactor }
}

/**
 * Determine how many significant decimal places the target number has.
 * This controls how zoomed in the user needs to be to see the target.
 *
 * Examples: 42 -> 0, 3.1 -> 1, 3.14 -> 2, 0.005 -> 3, 1/3 -> 4 (repeating)
 */
function getTargetPrecision(target: number): number {
  if (Number.isInteger(target)) return 0

  // Convert to string and count decimal places
  const str = target.toString()
  const dotIndex = str.indexOf('.')
  if (dotIndex === -1) return 0

  // Count significant decimal digits (cap at 6 to avoid float artifacts)
  const decimals = str.slice(dotIndex + 1)
  return Math.min(decimals.length, 6)
}