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

0% Statements 0/121
0% Branches 0/1
0% Functions 0/1
0% Lines 0/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 122                                                                                                                                                                                                                                                   
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)
}