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

19% Statements 23/121
100% Branches 0/0
0% Functions 0/3
19% Lines 23/121

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 1221x 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, TickMark, TickThresholds } from './types'
import { DEFAULT_TICK_THRESHOLDS } from './types'
import { computeProminence } from '../shared/tickMath'
import { worldToScreen, screenToWorld } from '../shared/coordinateConversions'
 
/**
 * Compute all visible tick marks for the current viewport.
 *
 * For each power of 10, we compute how many ticks of that spacing fit on screen
 * and assign a continuous prominence value based on configurable thresholds.
 * Prominence drives smooth interpolation of all visual properties (height,
 * line width, font, color) — no abrupt jumps at threshold boundaries.
 *
 * A tick at value N is only emitted at its coarsest applicable power so that,
 * for example, 100 is drawn as a power-2 tick and not also as power-1 and power-0.
 */
export function computeTickMarks(
  state: NumberLineState,
  canvasWidth: number,
  thresholds: TickThresholds = DEFAULT_TICK_THRESHOLDS
): TickMark[] {
  const { center, pixelsPerUnit } = state
  if (pixelsPerUnit <= 0 || canvasWidth <= 0) return []

  const { anchorMax, mediumMax } = thresholds

  const halfWidth = canvasWidth / 2
  const leftValue = center - halfWidth / pixelsPerUnit
  const rightValue = center + halfWidth / pixelsPerUnit

  // Determine the range of powers to consider.
  // Smallest power where tick spacing in px >= 2
  const minPower = Math.floor(Math.log10(2 / pixelsPerUnit))
  // Largest power where at least ~1 tick fits on screen
  const maxPower = Math.ceil(Math.log10((rightValue - leftValue) * 2))

  // First pass: determine prominence and opacity for each power
  const powerInfo: { power: number; prominence: number; opacity: number }[] = []

  for (let power = maxPower; power >= minPower; power--) {
    const spacing = 10 ** power
    const tickSpacingPx = spacing * pixelsPerUnit

    // Skip if too dense (more than ~130 ticks) or too small (< 2px apart)
    if (tickSpacingPx < 2) continue
    const numTicks = canvasWidth / tickSpacingPx
    if (numTicks > 130) continue

    const prominence = computeProminence(numTicks, anchorMax, mediumMax)

    // Opacity: fully visible until prominence < 0.15, then fades
    const opacity = Math.min(1, prominence / 0.15)

    // Skip fully invisible ticks
    if (opacity <= 0) continue

    powerInfo.push({ power, prominence, opacity })
  }

  // Second pass: generate ticks, deduplicating so each value appears only at
  // its coarsest power. We process from coarsest to finest.
  // Use string keys "power:index" for dedup to avoid floating-point collisions.
  const seen = new Set<string>()
  const ticks: TickMark[] = []

  // powerInfo is already ordered from coarsest (highest power) to finest
  for (const { power, prominence, opacity } of powerInfo) {
    const spacing = 10 ** power
    const firstIndex = Math.ceil(leftValue / spacing)
    const lastIndex = Math.floor(rightValue / spacing)

    for (let i = firstIndex; i <= lastIndex; i++) {
      // Compute value from index * spacing (avoids accumulated addition drift)
      const value = i * spacing

      // Dedup: a tick at value V was already emitted at a coarser power P'
      // if V is a multiple of 10^P'. Check all coarser powers.
      let dominated = false
      for (const coarser of powerInfo) {
        if (coarser.power <= power) break // only check strictly coarser
        const coarserSpacing = 10 ** coarser.power
        // V is a multiple of coarserSpacing if i is a multiple of 10^(P'-P)
        const ratio = Math.round(coarserSpacing / spacing)
        if (i % ratio === 0) {
          dominated = true
          break
        }
      }
      if (dominated) continue

      // Additional string-key dedup for safety
      const key = `${power}:${i}`
      if (seen.has(key)) continue
      seen.add(key)

      ticks.push({ value, power, prominence, opacity })
    }
  }

  return ticks
}
 
/** Convert a number-line value to a screen X coordinate */
export function numberToScreenX(
  value: number,
  center: number,
  pixelsPerUnit: number,
  canvasWidth: number
): number {
  return worldToScreen(value, center, pixelsPerUnit, canvasWidth)
}
 
/** Convert a screen X coordinate to a number-line value */
export function screenXToNumber(
  screenX: number,
  center: number,
  pixelsPerUnit: number,
  canvasWidth: number
): number {
  return screenToWorld(screenX, center, pixelsPerUnit, canvasWidth)
}