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