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

38.83% Statements 40/103
100% Branches 0/0
0% Functions 0/2
38.83% Lines 40/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 1041x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                                                                                                       1x 1x 1x 1x 1x 1x 1x                          
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)
}