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