{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "geo-traceroute",
  "title": "Traceroute",
  "description": "A traceroute as a hop table and a dotted map; the packet waits at each hop for its share of the round-trip time, so slow hops are slow to watch.",
  "registryDependencies": [
    "https://elements.lumesec.ai/r/lumesec-theme.json",
    "https://elements.lumesec.ai/r/lumesec-canvas.json",
    "https://elements.lumesec.ai/r/lumesec-color.json",
    "https://elements.lumesec.ai/r/lumesec-geo.json",
    "https://elements.lumesec.ai/r/lumesec-geo-data.json",
    "https://elements.lumesec.ai/r/lumesec-math.json",
    "https://elements.lumesec.ai/r/lumesec-motion.json",
    "https://elements.lumesec.ai/r/lumesec-roll-text.json",
    "https://elements.lumesec.ai/r/lumesec-sparks.json",
    "https://elements.lumesec.ai/r/lumesec-use-controllable-state.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/components/lumesec/geo-traceroute.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { snapCenter, useCanvas, type CanvasSurface } from \"@/lib/lumesec/canvas\";\nimport { usePalette } from \"@/lib/lumesec/color\";\nimport {\n  boundsOf,\n  createDotBatch,\n  createProjection,\n  createScreenPoint,\n  cubicBezier,\n  distanceKm,\n  flatCurve,\n  flatPaths,\n  forEachDot,\n  GEO_TOKENS,\n  geoInk,\n  isValidLatLon,\n  MAP_LAT_RANGE,\n  mapLattice,\n  pointAtDistance,\n  quadPoint,\n  ringFront,\n  unwrapLongitude,\n  wrapLongitude,\n  type ArcLengthTable,\n  type DotBatch,\n  type DotLattice,\n  type GeoInk,\n  type GeoPalette,\n  type LatLon,\n  type MapProjection,\n} from \"@/lib/lumesec/geo\";\nimport { formatLatLon, formatMs, formatNumber, ipScope, median, parseIpAddress } from \"@/lib/lumesec/geo-data\";\nimport { clamp, easeOutCubic, hash, lerp, percent, smoothstep, TAU } from \"@/lib/lumesec/math\";\nimport {\n  prefersReducedMotion,\n  settleSpring,\n  useAnimationLoop,\n  useLazyRef,\n  useReducedMotion,\n  type Spring,\n} from \"@/lib/lumesec/motion\";\nimport { RollText } from \"@/lib/lumesec/roll-text\";\nimport { Sparks } from \"@/lib/lumesec/sparks\";\nimport { useControllableState } from \"@/lib/lumesec/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\n\n/** One hop of a traceroute: the router that answered (or did not), where it is, and the round-trip time of each probe. */\nexport interface TraceHop {\n  /** The address that answered, as traceroute prints it ('203.0.113.14', '2001:db8::1'). Use '*' for a hop that did not answer. */\n  readonly address: string;\n  /** Reverse DNS name of the address. */\n  readonly name?: string;\n  /** Place name shown in the table, for example 'Frankfurt'. Located hops without it show their coordinates. */\n  readonly location?: string;\n  /** Latitude in degrees. Hops with `lat` and `lon` are drawn on the map. */\n  readonly lat?: number;\n  /** Longitude in degrees. */\n  readonly lon?: number;\n  /**\n   * Round-trip time of each probe in ms. A non-finite entry is a probe that timed out; an empty array is a hop that\n   * did not answer at all.\n   */\n  readonly rtt: readonly number[];\n}\n\n/** Props of GeoTraceroute. The remaining `div` props go to the root. */\nexport interface GeoTracerouteProps extends Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> {\n  /**\n   * The hops in order, from the first router to the destination. Without it the component shows an 11-hop demo trace;\n   * an empty array shows an empty table and map.\n   */\n  hops?: readonly TraceHop[];\n  /**\n   * The destination shown in the header. Default: the name or address of the last hop that answered\n   * ('app.example.com' in the demo).\n   */\n  target?: string;\n  /** Index of the selected hop in `hops` (controlled), or null for none. Use with `onValueChange`. */\n  value?: number | null;\n  /** The hop selected at first when uncontrolled. */\n  defaultValue?: number | null;\n  /** Called with the hop index when a row or a map marker is selected, and with null when the selection is cleared. */\n  onValueChange?: (value: number | null) => void;\n  /** Whether the playback runs (controlled). Use with `onPlayingChange`. */\n  playing?: boolean;\n  /**\n   * Whether the playback starts by itself when uncontrolled. It begins once the map is first on screen. With false the\n   * path is drawn complete.\n   */\n  defaultPlaying?: boolean;\n  /** Called by Pause, Play and Replay, and with false when the playback reaches the end or `hops` changes. */\n  onPlayingChange?: (playing: boolean) => void;\n  /** Playback speed. At 1, each millisecond of round-trip time is 10 ms of animation. */\n  speed?: number;\n  /** Added latency in ms above which a segment is drawn as a dashed long-haul jump with its added time beside it. */\n  jumpMs?: number;\n  /** Probes sent per hop: the number of marks in each row. Rows with more `rtt` entries show one mark per entry. */\n  probes?: number;\n  /** Called when the playback reaches the end of the trace. */\n  onComplete?: () => void;\n}\n\n/**\n * A traceroute as a hop table and a dotted map. The path draws hop by hop: a packet travels each segment, then waits\n * at the next hop for that hop's share of the round-trip time while the dots around the hop fill clockwise like a\n * progress ring, so slow hops are slow to watch. Its row lights up and its round-trip time counts up from the hop\n * before. Segments that add more than `jumpMs` are dashed long-haul jumps with the added time on them; hops without a\n * location, with a private address or without a reply stay in the table only. Selecting a row highlights its segment.\n *\n * @example\n * <GeoTraceroute\n *   target=\"app.example.com\"\n *   hops={[\n *     { address: \"192.168.1.1\", name: \"router.home.arpa\", rtt: [0.6, 0.5, 0.7] },\n *     { address: \"203.0.113.14\", location: \"Frankfurt\", lat: 50.11, lon: 8.68, rtt: [8.4, 8.1, 9.0] },\n *     { address: \"203.0.113.200\", location: \"Singapore\", lat: 1.35, lon: 103.82, rtt: [162.4, 161.9, 163.0] },\n *   ]}\n *   onComplete={() => setTraced(true)}\n * />\n */\nexport function GeoTraceroute({\n  hops,\n  target,\n  value,\n  defaultValue = null,\n  onValueChange,\n  playing: playingProp,\n  defaultPlaying = true,\n  onPlayingChange,\n  speed = 1,\n  jumpMs = 40,\n  probes = 3,\n  onComplete,\n  className,\n  onKeyDown,\n  ...props\n}: GeoTracerouteProps) {\n  const demo = hops === undefined;\n  const hopList = hops ?? DEMO_HOPS;\n  const model = React.useMemo(() => resolveModel(hopList, jumpMs, probes), [hopList, jumpMs, probes]);\n  const layout = React.useMemo(() => markerLayout(model), [model]);\n  const rowCount = model.rows.length;\n  const lastLocated = model.located.length > 0 ? model.located[model.located.length - 1] : -1;\n  const targetText = target ?? (demo ? DEMO_TARGET : defaultTarget(model));\n\n  const [selection, setSelection] = useControllableState<number | null>({\n    value,\n    defaultValue,\n    onChange: onValueChange,\n  });\n  const selectedIndex =\n    selection !== null && Number.isInteger(selection) && selection >= 0 && selection < rowCount ? selection : -1;\n  const [playing, setPlaying] = useControllableState<boolean>({\n    value: playingProp,\n    defaultValue: defaultPlaying,\n    onChange: onPlayingChange,\n  });\n  const reducedMotion = useReducedMotion();\n\n  /**\n   * What the playback has reached, as reported by the animation loop: the active row (-1 before the start, rowCount\n   * when complete) and the last located row the packet arrived at. A new trace starts complete.\n   */\n  const [trace, setTrace] = React.useState(() => ({\n    key: model.key,\n    cursor: playing ? -1 : rowCount,\n    arrived: playing ? -1 : lastLocated,\n  }));\n  if (trace.key !== model.key) setTrace({ key: model.key, cursor: rowCount, arrived: lastLocated });\n  const cursor = Math.min(trace.cursor, rowCount);\n  const complete = cursor >= rowCount;\n\n  /** The row that holds the roving tab stop; -1 follows the selection. */\n  const [focusIndex, setFocusIndex] = React.useState(-1);\n  /** Width of the map box in CSS px; labels wait for the first measurement. */\n  const [boxWidth, setBoxWidth] = React.useState<number | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const cardRef = React.useRef<HTMLDivElement>(null);\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const baseRef = React.useRef<HTMLCanvasElement>(null);\n  const traceRef = React.useRef<HTMLCanvasElement>(null);\n  const fxRef = React.useRef<HTMLCanvasElement>(null);\n  const totalRef = React.useRef<HTMLSpanElement>(null);\n  const liveRef = React.useRef<HTMLSpanElement | null>(null);\n  const rowButtons = React.useRef<(HTMLButtonElement | null)[]>([]);\n  /**\n   * The playback engine, created on first use (effects, handlers and the loop), never during a render. It starts from\n   * the first render's trace and playback state.\n   */\n  const engineRef = useLazyRef(() => createEngine(model, playing));\n\n  /** The active row's round-trip time cell, which the animation loop writes to. */\n  const attachLive = React.useCallback((node: HTMLSpanElement | null) => {\n    liveRef.current = node;\n  }, []);\n\n  const loop = useAnimationLoop(cardRef, step);\n  const palette = usePalette(cardRef, GEO_TOKENS, () => {\n    engineRef.current.inkStale = true;\n    loop.kick();\n  });\n  const base = useCanvas(baseRef, () => loop.kick(), CANVAS_OPTIONS);\n  const traceLayer = useCanvas(traceRef, () => loop.kick(), CANVAS_OPTIONS);\n  const fx = useCanvas(fxRef, () => loop.kick(), CANVAS_OPTIONS);\n\n  // New data: a different trace stops the playback and shows the whole path; a new threshold only redraws.\n  React.useEffect(() => {\n    const engine = engineRef.current;\n    const changed = engine.model.key !== model.key;\n    engine.model = model;\n    engine.dirty = true;\n    if (changed) {\n      engine.clock = model.timing.total + TAIL;\n      engine.finished = true;\n      engine.restart = false;\n      engine.playing = false;\n      engine.cursor = model.rows.length;\n      engine.arrived = model.located.length > 0 ? model.located[model.located.length - 1] : -1;\n      engine.sparks.clear();\n      setPlaying(false);\n    }\n    loop.kick();\n  }, [model, engineRef, loop, setPlaying]);\n\n  // Play resumes where Pause left the clock; playing again after the end starts over.\n  React.useEffect(() => {\n    const engine = engineRef.current;\n    engine.playing = playing;\n    if (playing && engine.finished) engine.restart = true;\n    loop.kick();\n  }, [playing, engineRef, loop]);\n\n  React.useEffect(() => {\n    const engine = engineRef.current;\n    engine.selected = selectedIndex;\n    engine.dirty = true;\n    loop.kick();\n  }, [selectedIndex, engineRef, loop]);\n\n  React.useEffect(() => {\n    engineRef.current.speed = Number.isFinite(speed) && speed > 0 ? clamp(speed, 0.05, 20) : 1;\n  }, [speed, engineRef]);\n\n  // Reduced motion finishes the playback and every transition; restart the loop so it redraws once.\n  React.useEffect(() => {\n    engineRef.current.dirty = true;\n    loop.kick();\n  }, [reducedMotion, engineRef, loop]);\n\n  React.useEffect(() => {\n    const box = boxRef.current;\n    if (!box) return;\n    const observer = new ResizeObserver((entries) => {\n      const width = entries[entries.length - 1]?.contentRect.width ?? 0;\n      if (width > 0) setBoxWidth(Math.round(width));\n    });\n    observer.observe(box);\n    return () => observer.disconnect();\n  }, []);\n\n  function step(_time: number, delta: number) {\n    const engine = engineRef.current;\n    const baseSurface = base.current;\n    const traceSurface = traceLayer.current;\n    const fxSurface = fx.current;\n    const colors = palette.current;\n    if (!baseSurface || !traceSurface || !fxSurface || !colors) return false;\n    const reduced = prefersReducedMotion();\n    const current = engine.model;\n    const total = current.timing.total;\n    const end = total + TAIL;\n\n    if (engine.inkStale || !engine.ink) refreshInk(engine, colors);\n    if (\n      engine.layoutKey !== current.key ||\n      engine.width !== traceSurface.width ||\n      engine.height !== traceSurface.height\n    ) {\n      layoutEngine(engine, traceSurface.width, traceSurface.height);\n    } else if (engine.layoutModel !== current) {\n      // the same hops in a new array, or a new threshold: the geometry stands, the drawing follows the new model\n      engine.layoutModel = current;\n      engine.dirty = true;\n    }\n    const { batches, geometry } = engine;\n    if (!batches || !geometry) return false;\n\n    // on first view the land grows out from the first located hop; afterwards it is baked once per size and theme\n    let revealing = false;\n    if (engine.reveal < REVEAL_SECONDS) {\n      engine.reveal = reduced ? REVEAL_SECONDS : Math.min(REVEAL_SECONDS, engine.reveal + delta);\n      drawBase(baseSurface, geometry, batches.land, engine.reveal / REVEAL_SECONDS);\n      engine.baked = engine.reveal >= REVEAL_SECONDS;\n      revealing = !engine.baked;\n    } else if (!engine.baked) {\n      drawBase(baseSurface, geometry, batches.land, 1);\n      engine.baked = true;\n    }\n\n    if (engine.restart) {\n      engine.restart = false;\n      engine.clock = 0;\n      engine.finished = false;\n      engine.sparks.clear();\n    }\n    let advancing = false;\n    if (engine.playing && !engine.finished) {\n      engine.clock = reduced ? end : Math.min(total, engine.clock + delta * engine.speed);\n      advancing = true;\n      if (engine.clock >= total) finish(engine, fxSurface, reduced);\n    } else if (engine.finished && engine.clock < end) {\n      // the ping and the last ring play out after the end, also while Pause would hold the clock\n      engine.clock = reduced ? end : Math.min(end, engine.clock + delta);\n      advancing = true;\n    }\n\n    report(engine);\n    writeLiveCount(engine);\n    const moving = stepEmphasis(engine, delta, reduced);\n    if (advancing || moving || engine.dirty) {\n      drawTrace(engine, traceSurface, batches.path, reduced);\n      engine.dirty = false;\n    }\n    const sparking = drawSparks(engine, fxSurface, delta);\n    return advancing || moving || sparking || revealing;\n  }\n\n  /** The clock reached the end: stop, fire the arrival at the destination and tell the parent. */\n  function finish(engine: TraceEngine, fxSurface: CanvasSurface, reduced: boolean) {\n    engine.finished = true;\n    engine.playing = false;\n    const current = engine.model;\n    if (current.rows.length === 0) {\n      // nothing to play: stop quietly\n      setPlaying(false);\n      return;\n    }\n    const geometry = engine.geometry;\n    const box = boxRef.current;\n    if (!reduced && current.reached && current.pingRow >= 0 && geometry && box) {\n      const hop = geometry.hops[current.located.indexOf(current.pingRow)];\n      const offset = box.getBoundingClientRect();\n      const layer = fxSurface.canvas.getBoundingClientRect();\n      if (hop) {\n        const colors = engine.ink ? [engine.ink.accent, engine.ink.shine, engine.ink.glint] : [];\n        engine.sparks.add(hop.x + offset.left - layer.left, hop.y + offset.top - layer.top, 26, colors, SPARK_OPTIONS);\n      }\n      const total = totalRef.current;\n      if (total && typeof total.animate === \"function\")\n        total.animate(TOTAL_POP, { duration: 560, easing: \"cubic-bezier(.2,.8,.2,1)\" });\n    }\n    setPlaying(false);\n    onComplete?.();\n    const rtt = current.reached ? `, ${formatRtt(current.total)} ms` : \", no reply from the last hop\";\n    setAnnouncement(`Trace to ${targetText} complete: ${current.rows.length} hops${rtt}.`);\n  }\n\n  /** Tells React which row is active and where the packet is, when either changes. */\n  function report(engine: TraceEngine) {\n    const current = engine.model;\n    const nextCursor = rowAt(current, engine.clock);\n    const nextArrived = arrivedAt(current, engine.clock);\n    if (nextCursor === engine.cursor && nextArrived === engine.arrived) return;\n    engine.cursor = nextCursor;\n    engine.arrived = nextArrived;\n    setTrace((previous) =>\n      previous.key === current.key ? { key: previous.key, cursor: nextCursor, arrived: nextArrived } : previous,\n    );\n  }\n\n  /** The active row's round-trip time counts up mechanically; written straight to its cell, not through React. */\n  function writeLiveCount(engine: TraceEngine) {\n    const node = liveRef.current;\n    const current = engine.model;\n    const index = engine.cursor;\n    if (!node || index < 0 || index >= current.rows.length) return;\n    const row = current.rows[index];\n    if (row.kind === \"timeout\") return;\n    const timing = current.timing.rows[index];\n    const share = clamp((engine.clock - timing.countStart) / Math.max(1e-6, timing.countEnd - timing.countStart), 0, 1);\n    const text = formatRtt(lerp(row.previous, row.median, share));\n    if (node.textContent !== text) node.textContent = text;\n  }\n\n  function moveFocus(index: number) {\n    setFocusIndex(index);\n    rowButtons.current[index]?.focus();\n  }\n\n  function toggleRow(index: number) {\n    setFocusIndex(index);\n    setSelection(selection === index ? null : index);\n  }\n\n  function handleRowKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, index: number) {\n    let next: number;\n    if (event.key === \"ArrowDown\") next = index + 1;\n    else if (event.key === \"ArrowUp\") next = index - 1;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = rowCount - 1;\n    else return;\n    event.preventDefault();\n    next = clamp(next, 0, rowCount - 1);\n    if (next !== index) moveFocus(next);\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    onKeyDown?.(event);\n    if (event.defaultPrevented || event.key !== \"Escape\" || selection === null) return;\n    event.preventDefault();\n    setSelection(null);\n  }\n\n  function handleMapClick(event: React.MouseEvent<HTMLDivElement>) {\n    const marker = event.target instanceof Element ? event.target.closest<HTMLElement>(\"[data-hop]\") : null;\n    const index = marker ? Number(marker.dataset.hop) : Number.NaN;\n    if (Number.isInteger(index)) toggleRow(index);\n  }\n\n  function replay() {\n    const engine = engineRef.current;\n    engine.restart = true;\n    // the loop reports the first row again even when it is the row it last reported\n    engine.cursor = -1;\n    engine.arrived = -1;\n    setTrace((previous) => ({ ...previous, cursor: -1, arrived: -1 }));\n    setPlaying(true);\n    loop.kick();\n  }\n\n  function togglePlayback() {\n    if (playing) setPlaying(false);\n    else if (complete) replay();\n    else setPlaying(true);\n  }\n\n  const tabStop = focusIndex >= 0 && focusIndex < rowCount ? focusIndex : Math.max(0, selectedIndex);\n  const selectedRow = selectedIndex >= 0 ? model.rows[selectedIndex] : null;\n  const mapDimmed = selectedRow !== null && selectedRow.kind !== \"located\";\n  // the hop the packet is at or heading from, while the playback is under way (also while paused)\n  const packetRow = cursor >= 0 && !complete ? trace.arrived : -1;\n  const width = boxWidth ?? NOMINAL_BOX_WIDTH;\n  const labels = React.useMemo(\n    () => placeMapLabels(model, layout, width, selectedIndex, packetRow),\n    [model, layout, width, selectedIndex, packetRow],\n  );\n  const totalText = complete && model.reached ? formatRtt(model.total) : MISSING;\n  const totalReserve = React.useMemo(() => [...new Set([formatRtt(model.total), MISSING])], [model.total]);\n\n  return (\n    <div\n      data-slot=\"geo-traceroute\"\n      className={cn(\"@container grid w-full max-w-[660px] gap-3 text-[13px] leading-5 text-foreground\", className)}\n      onKeyDown={handleKeyDown}\n      {...props}\n    >\n      <div className=\"flex min-h-9 items-center gap-3\">\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"m-0 text-[12px] leading-4 text-muted-foreground\">\n            Traceroute\n            <span aria-hidden=\"true\"> · </span>\n            <span className=\"tabular-nums\">{rowCount === 1 ? \"1 hop\" : `${rowCount} hops`}</span>\n          </p>\n          <p className=\"m-0 truncate font-mono text-[13.5px] leading-5 font-medium\" title={targetText}>\n            {targetText}\n          </p>\n        </div>\n        <div className=\"shrink-0 text-right\">\n          <p className=\"m-0 text-[12px] leading-4 text-muted-foreground\">Round trip</p>\n          <p className=\"m-0 flex items-baseline justify-end gap-1 font-mono text-[13.5px] leading-5 font-medium tabular-nums\">\n            <span ref={totalRef} className=\"inline-block origin-right\">\n              <RollText\n                text={totalText}\n                direction={complete ? 1 : -1}\n                animate={!reducedMotion}\n                reserve={totalReserve}\n                className=\"justify-items-end\"\n              />\n            </span>\n            <span\n              className={cn(\n                \"font-sans text-[12px] font-normal text-muted-foreground transition-opacity duration-300\",\n                totalText === MISSING && \"opacity-0\",\n              )}\n            >\n              ms\n            </span>\n          </p>\n        </div>\n        <div className=\"flex shrink-0 gap-1.5\">\n          <ToolbarButton label=\"Replay\" onClick={replay} disabled={rowCount === 0}>\n            <svg\n              viewBox=\"0 0 16 16\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden=\"true\"\n              className=\"size-3.5 shrink-0\"\n            >\n              <path d=\"M3.2 8.6A4.9 4.9 0 1 0 4.6 4.4\" />\n              <path d=\"M4.4 1.9v2.7h2.7\" />\n            </svg>\n          </ToolbarButton>\n          <ToolbarButton\n            label={playing ? \"Pause\" : \"Play\"}\n            reserve={PLAY_LABELS}\n            onClick={togglePlayback}\n            disabled={rowCount === 0}\n          >\n            {playing ? (\n              <svg viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\" className=\"size-3.5 shrink-0\">\n                <rect x=\"4\" y=\"3.25\" width=\"2.6\" height=\"9.5\" rx=\"0.7\" />\n                <rect x=\"9.4\" y=\"3.25\" width=\"2.6\" height=\"9.5\" rx=\"0.7\" />\n              </svg>\n            ) : (\n              <svg viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\" className=\"size-3.5 shrink-0\">\n                <path d=\"M5 3.6c0-.6.6-.9 1.1-.6l6.3 4.4c.4.3.4.9 0 1.2l-6.3 4.4c-.5.3-1.1 0-1.1-.6z\" />\n              </svg>\n            )}\n          </ToolbarButton>\n        </div>\n      </div>\n\n      <div\n        ref={cardRef}\n        className=\"relative rounded-xl border border-border bg-card [-webkit-tap-highlight-color:transparent]\"\n      >\n        <div\n          ref={boxRef}\n          data-dimmed={mapDimmed ? \"\" : undefined}\n          onClick={handleMapClick}\n          className=\"group/map relative overflow-hidden rounded-[11px]\"\n          style={{ aspectRatio: MAP_ASPECT }}\n        >\n          <canvas ref={baseRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 block size-full\" />\n          <div role=\"img\" aria-label={mapLabel(model)} className=\"absolute inset-0\" />\n          {model.located.map((row, li) => {\n            const point = layout.points[li];\n            const reached = complete || row <= trace.arrived;\n            const here = row === packetRow;\n            const destination = complete && row === model.pingRow;\n            const state =\n              row === selectedIndex ? \"selected\" : here || destination ? \"current\" : reached ? \"reached\" : \"pending\";\n            return (\n              <span\n                key={model.rows[row].key}\n                aria-hidden=\"true\"\n                data-hop={row}\n                data-state={state}\n                className=\"group/hop absolute z-[1] -mt-2.5 -ml-2.5 grid size-5 cursor-pointer place-items-center\"\n                style={{ left: percent(point.x), top: percent(point.y) }}\n              >\n                <span className=\"block size-[7px] rounded-full border-[1.5px] border-muted-foreground/70 bg-card shadow-[0_0_0_2px_var(--card)] transition-[background-color,border-color,opacity,scale] duration-300 group-hover/hop:scale-[1.3] group-data-[dimmed]/map:opacity-35 group-data-[state=current]/hop:border-lumesec group-data-[state=current]/hop:bg-lumesec group-data-[state=reached]/hop:border-foreground group-data-[state=reached]/hop:bg-foreground group-data-[state=selected]/hop:scale-[1.15] group-data-[state=selected]/hop:border-lumesec group-data-[state=selected]/hop:bg-lumesec group-data-[state=selected]/hop:shadow-[0_0_0_2px_var(--card),0_0_0_3.5px_var(--lumesec)] motion-reduce:transition-none\" />\n              </span>\n            );\n          })}\n          <canvas\n            ref={traceRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0 z-[2] block size-full\"\n          />\n          <div\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute inset-0 z-[3] transition-opacity duration-300\",\n              boxWidth === null && \"opacity-0\",\n            )}\n          >\n            {model.segments.map((segment, k) => {\n              const placement = labels.chips[k];\n              if (!segment.jump || !placement) return null;\n              const drawn = complete || trace.arrived >= segment.to;\n              const selected = segment.to === selectedIndex;\n              return (\n                <span\n                  key={`${model.rows[segment.from].key}>${model.rows[segment.to].key}`}\n                  data-state={drawn ? \"drawn\" : \"pending\"}\n                  className={cn(\n                    \"absolute rounded-full border bg-card px-1.5 text-center font-mono text-[10px] leading-[14px] whitespace-nowrap tabular-nums transition-[opacity,scale,border-color,color] duration-300 data-[state=pending]:scale-75 data-[state=pending]:opacity-0 motion-reduce:transition-none\",\n                    selected ? \"border-lumesec text-foreground\" : \"border-border text-muted-foreground\",\n                    mapDimmed || (selectedIndex >= 0 && !selected) ? \"opacity-45\" : \"\",\n                  )}\n                  style={{ left: placement.x, top: placement.y, width: placement.width }}\n                >\n                  {jumpText(segment.delta)}\n                </span>\n              );\n            })}\n            {model.located.map((row, li) => {\n              const placement = labels.hops[li];\n              if (!placement?.visible) return null;\n              const hop = model.rows[row];\n              const strong = row === selectedIndex || row === packetRow;\n              return (\n                <span\n                  key={hop.key}\n                  className={cn(\n                    \"absolute font-mono text-[10.5px] leading-[14px] whitespace-nowrap transition-[opacity,color,left,top] duration-300 ease-[cubic-bezier(.2,.8,.2,1)] [text-shadow:0_0_2px_var(--card),0_0_4px_var(--card),0_0_6px_var(--card)] motion-reduce:transition-none\",\n                    strong ? \"text-foreground\" : \"text-muted-foreground\",\n                    mapDimmed && \"opacity-40\",\n                  )}\n                  style={{ left: placement.x, top: placement.y }}\n                >\n                  <span className={cn(strong ? \"text-lumesec\" : \"text-muted-foreground\")}>{hop.number}</span>\n                  {placement.named ? ` ${hop.place}` : null}\n                </span>\n              );\n            })}\n          </div>\n          {model.located.length < 2 ? (\n            <p className=\"absolute top-2.5 left-3 z-[3] m-0 rounded-md bg-card/85 px-1.5 text-[12px] leading-5 text-muted-foreground\">\n              No path to draw\n            </p>\n          ) : null}\n        </div>\n        <canvas\n          ref={fxRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute -inset-x-4 -inset-y-10 z-[4] block h-[calc(100%+80px)] w-[calc(100%+32px)]\"\n        />\n      </div>\n\n      <div className=\"overflow-hidden rounded-xl border border-border bg-card\">\n        <table className=\"w-full table-fixed border-collapse text-left text-[12px] leading-4\">\n          <caption className=\"sr-only\">Hops to {targetText}</caption>\n          <thead>\n            <tr className=\"border-b border-border text-muted-foreground\">\n              <th scope=\"col\" className=\"h-8 w-10 pl-3 font-normal @max-[480px]:w-8\">\n                #\n              </th>\n              <th scope=\"col\" className=\"h-8 w-[28%] px-2 font-normal @max-[480px]:w-auto\">\n                Address\n              </th>\n              <th scope=\"col\" className=\"h-8 px-2 font-normal @max-[480px]:hidden\">\n                Name\n              </th>\n              <th scope=\"col\" className=\"h-8 w-[20%] px-2 font-normal @max-[480px]:w-[23%]\">\n                Location\n              </th>\n              <th scope=\"col\" className=\"h-8 w-[76px] px-2 text-right font-normal @max-[480px]:w-[60px]\">\n                RTT<span className=\"hidden @max-[480px]:inline\"> ms</span>\n              </th>\n              <th scope=\"col\" className=\"h-8 w-[52px] pr-3 text-right font-normal @max-[480px]:w-[34px]\">\n                <span className=\"@max-[480px]:sr-only\">Probes</span>\n              </th>\n            </tr>\n          </thead>\n          <tbody>\n            {model.rows.map((row, i) => {\n              const state = complete || i < cursor ? \"done\" : i === cursor ? \"active\" : \"pending\";\n              const selected = i === selectedIndex;\n              return (\n                <tr\n                  key={row.key}\n                  data-state={state}\n                  data-selected={selected ? \"\" : undefined}\n                  onClick={() => toggleRow(i)}\n                  className=\"group/row cursor-pointer border-b border-border/70 transition-colors duration-200 last:border-b-0 hover:bg-muted/50 data-[selected]:bg-lumesec/[0.07] data-[state=active]:bg-muted motion-reduce:transition-none\"\n                >\n                  <td className=\"relative p-0\">\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-y-0 left-0 w-0.5 bg-lumesec opacity-0 transition-opacity duration-200 group-data-[selected]/row:opacity-100 group-data-[state=active]/row:opacity-100\"\n                    />\n                    <button\n                      ref={(node) => {\n                        rowButtons.current[i] = node;\n                      }}\n                      type=\"button\"\n                      tabIndex={i === tabStop ? 0 : -1}\n                      aria-pressed={selected}\n                      aria-label={rowLabel(row)}\n                      onFocus={() => setFocusIndex(i)}\n                      onKeyDown={(event) => handleRowKeyDown(event, i)}\n                      className=\"block h-[30px] w-full cursor-pointer border-0 bg-transparent p-0 pl-3 text-left font-mono text-[12px] text-muted-foreground tabular-nums outline-none group-data-[selected]/row:text-lumesec focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-lumesec focus-visible:outline-solid @max-[480px]:pl-2.5\"\n                    >\n                      {row.number}\n                    </button>\n                  </td>\n                  <td\n                    className={cn(\"truncate px-2 font-mono\", row.kind === \"timeout\" && \"text-muted-foreground\")}\n                    title={row.address}\n                  >\n                    {row.address}\n                  </td>\n                  <td className=\"truncate px-2 text-muted-foreground @max-[480px]:hidden\" title={row.hop.name}>\n                    {row.hop.name || (row.kind === \"timeout\" ? \"No reply\" : MISSING)}\n                  </td>\n                  <td className=\"truncate px-2\">\n                    <LocationCell row={row} selected={selected} />\n                  </td>\n                  <td className=\"px-2 text-right font-mono whitespace-nowrap tabular-nums\">\n                    <RttCell row={row} state={state} attachLive={attachLive} />\n                  </td>\n                  <td className=\"pr-3\">\n                    <ProbeMarks marks={row.marks} state={state} />\n                  </td>\n                </tr>\n              );\n            })}\n            {rowCount === 0 ? (\n              <tr>\n                <td colSpan={6} className=\"h-16 text-center text-muted-foreground\">\n                  No hops\n                </td>\n              </tr>\n            ) : null}\n          </tbody>\n        </table>\n      </div>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\n/* Header, table cells --------------------------------------------------------------------------------- */\n\n/** A header button: icon plus a label that rolls when it changes; the label hides on narrow containers. */\nfunction ToolbarButton({\n  label,\n  reserve,\n  onClick,\n  disabled,\n  children,\n}: {\n  label: string;\n  reserve?: readonly string[];\n  onClick: () => void;\n  disabled: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      aria-label={label}\n      className=\"inline-flex h-[30px] cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-[11px] font-[inherit] text-[12.5px] font-medium text-foreground transition-[border-color] duration-200 outline-none hover:border-[color-mix(in_srgb,var(--lumesec)_50%,var(--border))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lumesec focus-visible:outline-solid disabled:cursor-default disabled:opacity-50 @max-[480px]:px-2\"\n    >\n      {children}\n      <span aria-hidden=\"true\" className=\"@max-[480px]:hidden\">\n        {reserve ? <RollText text={label} reserve={reserve} /> : label}\n      </span>\n    </button>\n  );\n}\n\nfunction LocationCell({ row, selected }: { row: HopRow; selected: boolean }) {\n  if (row.kind === \"located\") return <span title={row.place}>{row.place}</span>;\n  if (row.kind === \"private\") {\n    return (\n      <span className=\"inline-flex h-[18px] items-center rounded-[5px] border border-border px-1.5 text-[11px] leading-none text-muted-foreground\">\n        Private\n      </span>\n    );\n  }\n  if (selected && row.kind === \"unlocated\") return <span className=\"text-muted-foreground\">No location</span>;\n  return <span className=\"text-muted-foreground\">{MISSING}</span>;\n}\n\n/**\n * The round-trip time: a dash until the playback reaches the row, a count written by the animation loop while it is\n * active, the median after. The live span has no React children, so the loop can own its text.\n */\nfunction RttCell({\n  row,\n  state,\n  attachLive,\n}: {\n  row: HopRow;\n  state: RowState;\n  attachLive: (node: HTMLSpanElement | null) => void;\n}) {\n  if (row.kind === \"timeout\") {\n    return <span className=\"text-muted-foreground\">{state === \"pending\" ? MISSING : \"*\"}</span>;\n  }\n  const unit = (\n    <span className={cn(\"font-sans text-muted-foreground @max-[480px]:hidden\", state === \"pending\" && \"invisible\")}>\n      {\" \"}\n      ms\n    </span>\n  );\n  if (state === \"pending\") {\n    return (\n      <>\n        <span className=\"text-muted-foreground\">{MISSING}</span>\n        {unit}\n      </>\n    );\n  }\n  if (state === \"active\") {\n    return (\n      <>\n        <span\n          key=\"live\"\n          ref={(node) => {\n            attachLive(node);\n            if (node && !node.textContent) node.textContent = formatRtt(row.previous);\n          }}\n          className=\"text-foreground\"\n        />\n        {unit}\n      </>\n    );\n  }\n  return (\n    <>\n      <span key=\"done\">{formatRtt(row.median)}</span>\n      {unit}\n    </>\n  );\n}\n\n/** One mark per probe: filled when it answered, hollow when it timed out. They fill in turn when the row's turn comes. */\nfunction ProbeMarks({ marks, state }: { marks: readonly boolean[]; state: RowState }) {\n  return (\n    <span className=\"flex items-center justify-end gap-[3px]\" aria-label={probeLabel(marks)} role=\"img\">\n      {marks.map((answered, j) => (\n        <span\n          key={j}\n          data-answered={answered && state !== \"pending\" ? \"\" : undefined}\n          className={cn(\n            \"block size-[5px] rounded-[1.5px] border border-foreground/30 transition-[background-color,border-color,scale] duration-200 data-[answered]:border-foreground/55 data-[answered]:bg-foreground/55 motion-reduce:transition-none\",\n            state === \"pending\" && \"border-foreground/15\",\n            state === \"active\" && answered && \"data-[answered]:border-lumesec data-[answered]:bg-lumesec\",\n          )}\n          style={{ transitionDelay: state === \"active\" ? `${j * 90}ms` : \"0ms\" }}\n        />\n      ))}\n    </span>\n  );\n}\n\n/* Constants ------------------------------------------------------------------------------------------ */\n\n/** Width / height of the map box. The map is a window onto a zoomed world, so any aspect works. */\nconst MAP_ASPECT = 2.1;\n/** The width the server markup and the projection for marker positions assume. */\nconst NOMINAL_WIDTH = 1000;\n/** The map box width label placement assumes before the first measurement (660 px minus the borders). */\nconst NOMINAL_BOX_WIDTH = 658;\n/** Below this map width the lattice gets finer. */\nconst NARROW_WIDTH = 480;\nconst CANVAS_OPTIONS = { maxDpr: 2 };\n\n/** The path: bend of each segment, dot spacing and size, its alpha, and the alpha of segments a selection dims. */\nconst BEND = 0.12;\nconst DOT_SPACING = 3.5;\nconst DOT_SIZE = 2;\n/** Path alpha on light and on dark cards: the accent loses contrast against a dark lattice, so it gets more. */\nconst PATH_ALPHA = { light: 0.72, dark: 0.86 };\nconst DIM = 0.25;\n/** Path dots stop this far from each hop centre, so they never run into the marker. */\nconst TRIM = 6;\n/** Dwell: the packet's orbit radius and the ring of lattice dots that fills around the hop. */\nconst ORBIT = 7;\nconst RING_INNER = 8.5;\nconst RING_OUTER = 16;\n/** Seconds of clock time for a dwell ring to fade after the packet leaves. */\nconst RING_FADE = 0.6;\n/** Edge fade of the land lattice in px. */\nconst EDGE_FADE = 22;\n/** Seconds for the land to grow out from the first located hop on first view. */\nconst REVEAL_SECONDS = 0.9;\n/** Width of the reveal front, as a share of the reach from the first hop to the farthest corner. */\nconst REVEAL_SOFTNESS = 0.07;\n\n/** Playback timing in seconds at speed 1: one ms of round-trip time is 10 ms of animation. */\nconst SECONDS_PER_MS = 0.01;\nconst MIN_STEP = 0.14;\nconst TIMEOUT_STEP = 0.36;\nconst MIN_DWELL = 0.18;\nconst TRAVEL_BASE = 0.35;\nconst TRAVEL_PER_10K_KM = 0.25;\n/** Seconds of effects after the end: the two-ring ping at the destination and the last ring's fade. */\nconst TAIL = 1.5;\nconst PING_RADIUS = 60;\nconst PING_SECONDS = 1.05;\nconst PING_DELAY = 0.22;\n\n/** Arc draws ease in and out, so the packet leaves and lands softly. */\nconst TRAVEL_EASE = cubicBezier(0.45, 0, 0.25, 1);\n\n/** Selection springs: a snap with a little overshoot. */\nconst EMPHASIS_SPRING = { stiffness: 260, damping: 0.8 };\n/** Seconds for a selected hop's ring to fill, and to fade when it is deselected. */\nconst SELECT_FILL = 0.45;\nconst SELECT_FADE = 0.3;\n\n/** Sparks at the destination, on the layer that reaches past the map. */\nconst SPARK_OPTIONS = { speed: [40, 175], life: [0.35, 0.85], size: [1.5, 2.5], drag: 3.2, jitter: 2 } as const;\n\nconst MISSING = \"–\";\nconst PLAY_LABELS = [\"Pause\", \"Play\"] as const;\nconst DEMO_TARGET = \"app.example.com\";\n\n/** Geist Mono advances 0.6 em per character; map labels are 10.5 px on a 14 px line, chips 10 px. */\nconst LABEL_CHAR_WIDTH = 6.3;\nconst LABEL_HEIGHT = 14;\nconst CHIP_CHAR_WIDTH = 6;\nconst CHIP_HEIGHT = 16;\nconst LABEL_GAP = 8;\n/** Space a chip keeps from every marker centre. */\nconst CHIP_CLEARANCE = 7;\n/** Space hop labels keep around the chips. */\nconst CHIP_MARGIN = 2;\n/** Space hop labels keep from marker centres, curve points and rings when they can. */\nconst POINT_MARGIN = 5;\n/** Half the box of a marker (7 px dot, 1.5 px border, 2 px card ring) that no label may cover. */\nconst MARKER_RADIUS = 5;\nconst LABEL_SIDES = [\"right\", \"left\", \"top\", \"bottom\"] as const;\n/** Points per segment curve that labels avoid. */\nconst CURVE_SAMPLES = 12;\n/** The label of a hop with a ring moves out past the ring. */\nconst RING_LABEL_GAP = RING_OUTER + 3;\n\nconst TOTAL_POP: Keyframe[] = [\n  { transform: \"scale(1)\", filter: \"brightness(1)\" },\n  { transform: \"scale(1.14)\", filter: \"brightness(1.4)\", offset: 0.4 },\n  { transform: \"scale(1)\", filter: \"brightness(1)\" },\n];\n\n/**\n * An invented trace from a home router through Frankfurt, Lyon and Marseille, under the sea to Cairo and Mumbai, and on\n * to Singapore. Addresses come from the private and documentation ranges and names use example.net.\n */\nconst DEMO_HOPS: readonly TraceHop[] = [\n  { address: \"192.168.1.1\", name: \"router.home.arpa\", rtt: [0.6, 0.5, 0.7] },\n  { address: \"100.64.0.1\", rtt: [4.2, 3.8, 4.0] },\n  {\n    address: \"203.0.113.14\",\n    name: \"ae2.br1.fra.example.net\",\n    location: \"Frankfurt\",\n    lat: 50.1109,\n    lon: 8.6821,\n    rtt: [8.4, 8.1, 9.0],\n  },\n  {\n    address: \"203.0.113.62\",\n    name: \"et-3.cr1.lys.example.net\",\n    location: \"Lyon\",\n    lat: 45.764,\n    lon: 4.8357,\n    rtt: [14.6, 14.2, 15.1],\n  },\n  { address: \"*\", rtt: [] },\n  {\n    address: \"198.51.100.21\",\n    name: \"ae5.cr2.mrs.example.net\",\n    location: \"Marseille\",\n    lat: 43.2965,\n    lon: 5.3698,\n    rtt: [19.2, 18.9, 19.6],\n  },\n  {\n    address: \"198.51.100.86\",\n    name: \"be-7.sub1.cai.example.net\",\n    location: \"Cairo\",\n    lat: 30.0444,\n    lon: 31.2357,\n    rtt: [61.3, 60.8, 62.0],\n  },\n  {\n    address: \"192.0.2.33\",\n    name: \"et-1.cr1.bom.example.net\",\n    location: \"Mumbai\",\n    lat: 19.076,\n    lon: 72.8777,\n    rtt: [110.4, 109.8, 111.2],\n  },\n  { address: \"192.0.2.70\", rtt: [118.9, Number.NaN, 119.5] },\n  {\n    address: \"192.0.2.118\",\n    name: \"ae-4.cr1.maa.example.net\",\n    location: \"Chennai\",\n    lat: 13.0827,\n    lon: 80.2707,\n    rtt: [126.0, 125.4, 126.8],\n  },\n  {\n    address: \"203.0.113.200\",\n    name: \"app.example.com\",\n    location: \"Singapore\",\n    lat: 1.3521,\n    lon: 103.8198,\n    rtt: [162.4, 161.9, 163.0],\n  },\n];\n\n/* Data model ----------------------------------------------------------------------------------------- */\n\n/** How a hop takes part: on the map, in the table only (private address, no location), or no reply. */\ntype HopKind = \"located\" | \"private\" | \"unlocated\" | \"timeout\";\ntype RowState = \"pending\" | \"active\" | \"done\";\n\ninterface HopRow {\n  hop: TraceHop;\n  key: string;\n  /** 1-based hop number. */\n  number: number;\n  /** Normalised address text, or '*' for a hop without one. */\n  address: string;\n  kind: HopKind;\n  /** Median of the answered probes in ms; NaN for a timeout. */\n  median: number;\n  /** Median of the closest answered hop above, where the count-up starts (0 for the first). */\n  previous: number;\n  point: LatLon | null;\n  /** Location text for located hops. */\n  place: string;\n  /** One entry per probe mark: whether that probe answered. */\n  marks: readonly boolean[];\n}\n\n/** A drawn segment between two consecutive located hops, by row index. */\ninterface TraceSegment {\n  from: number;\n  to: number;\n  /** Round-trip time the segment adds, in ms (never negative). */\n  delta: number;\n  jump: boolean;\n  km: number;\n}\n\ninterface Span {\n  start: number;\n  end: number;\n}\n\ninterface RowTiming extends Span {\n  /** When the round-trip time counts up: the dwell for located hops, the whole turn for the others. */\n  countStart: number;\n  countEnd: number;\n}\n\n/** The playback schedule in seconds at speed 1. */\ninterface TraceTiming {\n  rows: RowTiming[];\n  /** Travel of each segment. */\n  travel: Span[];\n  /** Dwell at each located hop, by located index. */\n  dwell: Span[];\n  total: number;\n}\n\n/** Where the map looks: the central meridian, and the zoom about a focus that frames every located hop. */\ninterface MapView {\n  lon0: number;\n  zoom: number;\n  focus: LatLon | undefined;\n}\n\ninterface TraceModel {\n  /** Identifies the trace data; a new key stops the playback. */\n  key: string;\n  rows: HopRow[];\n  /** Row indices of the located hops, in order. */\n  located: number[];\n  segments: TraceSegment[];\n  timing: TraceTiming;\n  view: MapView;\n  /** Median of the last hop in ms (NaN when it timed out). */\n  total: number;\n  /** Whether the last hop answered. */\n  reached: boolean;\n  /** The row that pings at the end: the last hop when it is located and answered, else -1. */\n  pingRow: number;\n}\n\nconst HIDDEN_SCOPES = new Set([\"private\", \"shared\", \"loopback\", \"link-local\"]);\n\nfunction resolveModel(hops: readonly TraceHop[], jumpMs: number, probes: number): TraceModel {\n  const markCount = clamp(Math.round(Number.isFinite(probes) ? probes : 3), 1, 8);\n  const threshold = Number.isFinite(jumpMs) ? jumpMs : 40;\n  const rows: HopRow[] = [];\n  const located: number[] = [];\n  const seen = new Map<string, number>();\n  let previous = 0;\n  hops.forEach((hop, index) => {\n    const rtt = Array.isArray(hop.rtt) ? hop.rtt : [];\n    const answered = rtt.filter((value) => Number.isFinite(value));\n    const raw = typeof hop.address === \"string\" ? hop.address.trim() : \"\";\n    const parsed = parseIpAddress(raw);\n    const scope = parsed ? ipScope(parsed) : null;\n    const hasPoint = isValidLatLon(hop);\n    const kind: HopKind =\n      answered.length === 0\n        ? \"timeout\"\n        : scope && HIDDEN_SCOPES.has(scope)\n          ? \"private\"\n          : hasPoint\n            ? \"located\"\n            : \"unlocated\";\n    const point = kind === \"located\" ? { lat: hop.lat ?? 0, lon: wrapLongitude(hop.lon ?? 0) } : null;\n    const value = answered.length ? median(answered) : Number.NaN;\n    const address = parsed?.text ?? (raw || \"*\");\n    const base = `${index + 1}:${address}`;\n    const repeat = seen.get(base) ?? 0;\n    seen.set(base, repeat + 1);\n    rows.push({\n      hop,\n      key: repeat ? `${base}#${repeat}` : base,\n      number: index + 1,\n      address,\n      kind,\n      median: value,\n      previous,\n      point,\n      place: point ? hop.location?.trim() || formatLatLon(point, \"decimal\", 1) : \"\",\n      marks: Array.from({ length: Math.min(8, Math.max(markCount, rtt.length)) }, (_, j) => Number.isFinite(rtt[j])),\n    });\n    if (kind === \"located\") located.push(index);\n    if (Number.isFinite(value)) previous = value;\n  });\n\n  const segments: TraceSegment[] = [];\n  for (let k = 1; k < located.length; k++) {\n    const a = rows[located[k - 1]];\n    const b = rows[located[k]];\n    const delta = Math.max(0, b.median - a.median);\n    segments.push({\n      from: located[k - 1],\n      to: located[k],\n      delta,\n      jump: delta > threshold,\n      km: a.point && b.point ? distanceKm(a.point, b.point) : 0,\n    });\n  }\n  const last = rows[rows.length - 1];\n  const reached = last !== undefined && last.kind !== \"timeout\";\n  return {\n    key: traceKey(hops),\n    rows,\n    located,\n    segments,\n    timing: scheduleTrace(rows, located, segments),\n    view: mapView(located.map((row) => rows[row].point ?? { lat: 0, lon: 0 })),\n    total: reached ? last.median : Number.NaN,\n    reached,\n    pingRow: reached && last.kind === \"located\" ? rows.length - 1 : -1,\n  };\n}\n\n/** A string that changes when the trace data changes, so inline arrays with the same hops do not restart anything. */\nfunction traceKey(hops: readonly TraceHop[]) {\n  return hops\n    .map((hop) => `${hop.address}|${hop.lat}|${hop.lon}|${Array.isArray(hop.rtt) ? hop.rtt.join(\" \") : \"\"}`)\n    .join(\"\\n\");\n}\n\n/**\n * The playback schedule. Rows before the first located hop (and after the last) take turns in the table only. The\n * packet appears at the first located hop, then for each segment it travels (0.35 s plus 0.25 s per 10,000 km) and\n * waits at the next hop for the segment's added time. Rows between two located hops take turns during the travel.\n */\nfunction scheduleTrace(\n  rows: readonly HopRow[],\n  located: readonly number[],\n  segments: readonly TraceSegment[],\n): TraceTiming {\n  const timing: RowTiming[] = rows.map(() => ({ start: 0, end: 0, countStart: 0, countEnd: 0 }));\n  const travel: Span[] = [];\n  const dwell: Span[] = [];\n  let t = 0;\n  const turn = (index: number) => {\n    const row = rows[index];\n    const duration =\n      row.kind === \"timeout\"\n        ? TIMEOUT_STEP\n        : Math.max(MIN_STEP, Math.max(0, row.median - row.previous) * SECONDS_PER_MS);\n    timing[index] = { start: t, end: t + duration, countStart: t, countEnd: t + duration };\n    t += duration;\n  };\n  if (located.length === 0) {\n    rows.forEach((_, index) => turn(index));\n    return { rows: timing, travel, dwell, total: t };\n  }\n  const first = located[0];\n  for (let i = 0; i < first; i++) turn(i);\n  const opening = Math.max(MIN_DWELL, Math.max(0, rows[first].median - rows[first].previous) * SECONDS_PER_MS);\n  timing[first] = { start: t, end: t + opening, countStart: t, countEnd: t + opening };\n  dwell.push({ start: t, end: t + opening });\n  t += opening;\n  for (const segment of segments) {\n    const duration = TRAVEL_BASE + (TRAVEL_PER_10K_KM * segment.km) / 10_000;\n    const between = segment.to - segment.from - 1;\n    const slice = duration / (between + 1);\n    for (let j = 1; j <= between; j++) {\n      const start = t + (j - 1) * slice;\n      timing[segment.from + j] = { start, end: start + slice, countStart: start, countEnd: start + slice };\n    }\n    const arrival = t + duration;\n    const wait = Math.max(MIN_DWELL, segment.delta * SECONDS_PER_MS);\n    timing[segment.to] = {\n      start: t + between * slice,\n      end: arrival + wait,\n      countStart: arrival,\n      countEnd: arrival + wait,\n    };\n    travel.push({ start: t, end: arrival });\n    dwell.push({ start: arrival, end: arrival + wait });\n    t = arrival + wait;\n  }\n  for (let i = located[located.length - 1] + 1; i < rows.length; i++) turn(i);\n  return { rows: timing, travel, dwell, total: t };\n}\n\n/** The active row at a clock time: the row whose turn holds it, or rows.length at the end. */\nfunction rowAt(model: TraceModel, clock: number) {\n  if (clock >= model.timing.total) return model.rows.length;\n  const rows = model.timing.rows;\n  for (let i = 0; i < rows.length; i++) if (clock < rows[i].end) return i;\n  return model.rows.length;\n}\n\n/** The last located row the packet has arrived at, or -1. */\nfunction arrivedAt(model: TraceModel, clock: number) {\n  let arrived = -1;\n  model.timing.dwell.forEach((span, li) => {\n    if (clock >= span.start) arrived = model.located[li];\n  });\n  return arrived;\n}\n\n/** The last hop that answered, by name or address; a trace whose last hops did not answer still names where it got to. */\nfunction defaultTarget(model: TraceModel) {\n  for (let i = model.rows.length - 1; i >= 0; i--) {\n    const row = model.rows[i];\n    if (row.kind !== \"timeout\") return row.hop.name?.trim() || row.address;\n  }\n  return MISSING;\n}\n\nconst formatRtt = (ms: number) => formatNumber(ms, { fractionDigits: 1 });\n\nconst jumpText = (delta: number) => `${formatNumber(delta, { sign: true })} ms`;\n\n/** \"7 located hops from Frankfurt to Singapore, 162 ms\". */\nfunction mapLabel(model: TraceModel) {\n  const count = model.located.length;\n  if (count === 0) return \"Map: no hop has a location.\";\n  const first = model.rows[model.located[0]];\n  const last = model.rows[model.located[count - 1]];\n  if (count === 1) return `1 located hop in ${first.place}. No path to draw.`;\n  const jumps = model.segments.filter((segment) => segment.jump).length;\n  const jumpNote = jumps === 1 ? \" 1 long-haul jump.\" : jumps > 1 ? ` ${jumps} long-haul jumps.` : \"\";\n  return `${count} located hops from ${first.place} to ${last.place}, ${formatMs(last.median)}.${jumpNote}`;\n}\n\n/** The row button's name: hop number, where it is and its time. */\nfunction rowLabel(row: HopRow) {\n  if (row.kind === \"timeout\") return `Hop ${row.number}, no reply`;\n  const where = row.kind === \"located\" ? row.place : row.kind === \"private\" ? \"private address\" : \"no location\";\n  return `Hop ${row.number}, ${row.hop.name || row.address}, ${where}, ${formatRtt(row.median)} ms`;\n}\n\nfunction probeLabel(marks: readonly boolean[]) {\n  const answered = marks.filter(Boolean).length;\n  return `${answered} of ${marks.length} probes answered`;\n}\n\n/* Map view, markers and labels ---------------------------------------------------------------------- */\n\n/**\n * Frames the located hops: their bounds (25 % margin, at least 60° on each axis) centred on the central meridian, and\n * the zoom of a whole-world Equal Earth map that fits those bounds into the box. The other axis gets more context, so\n * the map always fills its box edge to edge, without the curved sides a cropped Equal Earth map would have.\n */\nfunction mapView(points: readonly LatLon[]): MapView {\n  if (points.length === 0) return { lon0: 0, zoom: 1, focus: undefined };\n  const bounds = boundsOf(points, 0.25, 60);\n  const middle = (bounds.west + bounds.east) / 2;\n  const lon0 = wrapLongitude(middle);\n  const width = NOMINAL_WIDTH;\n  const height = width / MAP_ASPECT;\n  const projection = createProjection(\"equalEarth\", {\n    width,\n    height,\n    latRange: MAP_LAT_RANGE,\n    center: { lat: 0, lon: lon0 },\n  });\n  const south = clamp(bounds.south, MAP_LAT_RANGE[0], MAP_LAT_RANGE[1]);\n  const north = clamp(bounds.north, MAP_LAT_RANGE[0], MAP_LAT_RANGE[1]);\n  const span = bounds.east - bounds.west;\n  const point = createScreenPoint();\n  let x0 = Infinity;\n  let y0 = Infinity;\n  let x1 = -Infinity;\n  let y1 = -Infinity;\n  const SAMPLES = 16;\n  for (let i = 0; i <= SAMPLES; i++) {\n    const lambda = bounds.west - middle + (span * i) / SAMPLES;\n    const lat = south + ((north - south) * i) / SAMPLES;\n    for (const [la, lo] of [\n      [south, lambda],\n      [north, lambda],\n      [lat, bounds.west - middle],\n      [lat, bounds.east - middle],\n    ]) {\n      projection.projectRelative(la, lo, point);\n      x0 = Math.min(x0, point.x);\n      y0 = Math.min(y0, point.y);\n      x1 = Math.max(x1, point.x);\n      y1 = Math.max(y1, point.y);\n    }\n  }\n  const zoom = clamp(Math.min(width / Math.max(1, x1 - x0), height / Math.max(1, y1 - y0)), 1, 24);\n  const focus = projection.invert((x0 + x1) / 2, (y0 + y1) / 2) ?? { lat: (south + north) / 2, lon: lon0 };\n  return { lon0, zoom, focus };\n}\n\nfunction viewProjection(view: MapView, width: number, height: number): MapProjection {\n  return createProjection(\"equalEarth\", {\n    width,\n    height,\n    latRange: MAP_LAT_RANGE,\n    center: { lat: 0, lon: view.lon0 },\n    zoom: view.zoom,\n    focus: view.focus,\n  });\n}\n\ninterface MarkerLayout {\n  /** Located hop centres as fractions of the map box, by located index. */\n  points: readonly { x: number; y: number }[];\n  /** Midpoint of each segment's curve as fractions of the map box. */\n  midpoints: readonly { x: number; y: number }[];\n  /** Points along every curve as fractions of the map box, which labels keep clear of. */\n  samples: readonly { x: number; y: number }[];\n}\n\n/** Marker and chip positions from the nominal projection: pure, so the server renders them where the client draws. */\nfunction markerLayout(model: TraceModel): MarkerLayout {\n  const width = NOMINAL_WIDTH;\n  const height = width / MAP_ASPECT;\n  const projection = viewProjection(model.view, width, height);\n  const screen = createScreenPoint();\n  const points = model.located.map((row) => {\n    const point = model.rows[row].point ?? { lat: 0, lon: 0 };\n    projection.project(point.lat, point.lon, screen);\n    return { x: screen.x / width, y: screen.y / height };\n  });\n  const curve = { x0: 0, y0: 0, cx: 0, cy: 0, x1: 0, y1: 0 };\n  const middle = { x: 0, y: 0 };\n  const samples: { x: number; y: number }[] = [];\n  const midpoints = model.segments.map((segment) => {\n    const a = model.rows[segment.from].point ?? { lat: 0, lon: 0 };\n    const b = model.rows[segment.to].point ?? { lat: 0, lon: 0 };\n    // the same unwrapping as flatPaths: `to` follows `from` the short way round\n    const lambdaA = wrapLongitude(a.lon - projection.centerLon);\n    const lambdaB = unwrapLongitude(lambdaA, b.lon - projection.centerLon);\n    projection.projectRelative(a.lat, lambdaA, screen);\n    const ax = screen.x;\n    const ay = screen.y;\n    projection.projectRelative(b.lat, lambdaB, screen);\n    flatCurve(ax, ay, screen.x, screen.y, BEND, curve);\n    for (let i = 1; i < CURVE_SAMPLES; i++) {\n      quadPoint(curve, i / CURVE_SAMPLES, middle);\n      samples.push({ x: middle.x / width, y: middle.y / height });\n    }\n    quadPoint(curve, 0.5, middle);\n    return { x: middle.x / width, y: middle.y / height };\n  });\n  return { points, midpoints, samples };\n}\n\ninterface MapLabels {\n  /** By located index: the label's top-left in px, whether it fitted and whether it carries the place name. */\n  hops: { visible: boolean; x: number; y: number; named: boolean }[];\n  /** By segment index: the jump chip's top-left and width in px, or null for segments without a chip. */\n  chips: ({ x: number; y: number; width: number } | null)[];\n}\n\ninterface Point {\n  x: number;\n  y: number;\n}\n\ninterface Box extends Point {\n  width: number;\n  height: number;\n}\n\n/** A hop label to place: its marker centre, the widths to try (with the place name first), priority and gap. */\ninterface HopLabelAnchor extends Point {\n  widths: readonly number[];\n  priority: number;\n  gap: number;\n}\n\n/** Where a hop label went: its top-left, and which of its widths fitted. */\ninterface HopLabelPlacement extends Point {\n  visible: boolean;\n  variant: number;\n}\n\n/**\n * Jump chips sit on their segment's midpoint like a dimension on a drawing line, or just beside the line when a marker\n * is in the way. Hop numbers go beside their markers, never over a chip, and clear of the path and of the ring of the\n * selected hop and of the hop the packet is at when they can. The first and last located hops, the selected one and\n * the one the packet is at also carry their place name when there is room for it.\n */\nfunction placeMapLabels(\n  model: TraceModel,\n  layout: MarkerLayout,\n  boxWidth: number,\n  selectedIndex: number,\n  packetRow: number,\n): MapLabels {\n  const width = boxWidth;\n  const height = boxWidth / MAP_ASPECT;\n  const markers = layout.points.map((point) => ({ x: point.x * width, y: point.y * height }));\n  const chips = placeChips(model, layout, markers, width, height);\n  const lastLocated = model.located.length - 1;\n  const named: boolean[] = [];\n  const anchors = model.located.map((row, li): HopLabelAnchor => {\n    const hop = model.rows[row];\n    const isNamed = li === 0 || li === lastLocated || row === selectedIndex || row === packetRow;\n    named.push(isNamed);\n    const short = `${hop.number}`.length * LABEL_CHAR_WIDTH + 2;\n    const long = `${hop.number} ${hop.place}`.length * LABEL_CHAR_WIDTH + 2;\n    const ringed = row === selectedIndex || row === packetRow;\n    return {\n      x: markers[li].x,\n      y: markers[li].y,\n      widths: isNamed ? [long, short] : [short],\n      priority: row === selectedIndex ? 1000 : row === packetRow ? 900 : isNamed ? 600 : 100 - li,\n      gap: ringed ? RING_LABEL_GAP : LABEL_GAP,\n    };\n  });\n  // the chips are already placed, and no label covers a marker: hard obstacles. A label's own marker never collides,\n  // since every label keeps a gap larger than MARKER_RADIUS from its anchor.\n  const obstacles: Box[] = markers.map((marker) => ({\n    x: marker.x - MARKER_RADIUS,\n    y: marker.y - MARKER_RADIUS,\n    width: MARKER_RADIUS * 2,\n    height: MARKER_RADIUS * 2,\n  }));\n  for (const chip of chips) {\n    if (!chip) continue;\n    obstacles.push({\n      x: chip.x - CHIP_MARGIN,\n      y: chip.y - CHIP_MARGIN,\n      width: chip.width + CHIP_MARGIN * 2,\n      height: CHIP_HEIGHT + CHIP_MARGIN * 2,\n    });\n  }\n  // the path, and the rings of the selected hop and of the packet's hop, are points labels keep clear of when they can\n  const points: Point[] = [];\n  for (const sample of layout.samples) {\n    const x = sample.x * width;\n    const y = sample.y * height;\n    if (markers.some((marker) => Math.hypot(marker.x - x, marker.y - y) < 10)) continue;\n    points.push({ x, y });\n  }\n  model.located.forEach((row, li) => {\n    if (row !== selectedIndex && row !== packetRow) return;\n    for (let k = 0; k < 8; k++) {\n      const angle = (k / 8) * TAU;\n      points.push({ x: markers[li].x + Math.cos(angle) * 12, y: markers[li].y + Math.sin(angle) * 12 });\n    }\n  });\n  const placed = placeHopLabels(anchors, obstacles, points, width, height);\n  return {\n    hops: placed.map((placement, li) => ({\n      visible: placement.visible,\n      x: placement.x,\n      y: placement.y,\n      named: named[li] && placement.variant === 0,\n    })),\n    chips,\n  };\n}\n\n/**\n * Greedy label placement by priority, like placeLabels in the geo module, with fixed obstacles: each label tries\n * right, left, top and bottom of its marker and takes the first box inside the map that overlaps no chip and no placed\n * label, first keeping clear of the other markers and of `points`, then allowing them. A label that does not fit with\n * its place name tries again with the hop number only; one that fits nowhere stays hidden.\n */\nfunction placeHopLabels(\n  anchors: readonly HopLabelAnchor[],\n  obstacles: readonly Box[],\n  points: readonly Point[],\n  width: number,\n  height: number,\n): HopLabelPlacement[] {\n  const taken: Box[] = [...obstacles];\n  const placements: HopLabelPlacement[] = anchors.map((anchor) => ({\n    visible: false,\n    x: anchor.x,\n    y: anchor.y,\n    variant: 0,\n  }));\n  const order = anchors.map((_, index) => index).sort((a, b) => anchors[b].priority - anchors[a].priority || a - b);\n  const near = (point: Point, box: Box) =>\n    point.x > box.x - POINT_MARGIN &&\n    point.x < box.x + box.width + POINT_MARGIN &&\n    point.y > box.y - POINT_MARGIN &&\n    point.y < box.y + box.height + POINT_MARGIN;\n  const fits = (index: number, box: Box, avoidPoints: boolean) => {\n    if (box.x < 0 || box.y < 0 || box.x + box.width > width || box.y + box.height > height) return false;\n    for (const other of taken) {\n      const apart =\n        box.x >= other.x + other.width ||\n        box.x + box.width <= other.x ||\n        box.y >= other.y + other.height ||\n        box.y + box.height <= other.y;\n      if (!apart) return false;\n    }\n    if (!avoidPoints) return true;\n    for (let j = 0; j < anchors.length; j++) {\n      if (j !== index && near(anchors[j], box)) return false;\n    }\n    return !points.some((point) => near(point, box));\n  };\n  for (const index of order) {\n    const anchor = anchors[index];\n    const h = LABEL_HEIGHT;\n    placing: for (let variant = 0; variant < anchor.widths.length; variant++) {\n      const w = anchor.widths[variant];\n      for (const avoidPoints of [true, false]) {\n        for (const side of LABEL_SIDES) {\n          const box: Box = {\n            x:\n              side === \"right\" ? anchor.x + anchor.gap : side === \"left\" ? anchor.x - anchor.gap - w : anchor.x - w / 2,\n            y:\n              side === \"top\" ? anchor.y - anchor.gap - h : side === \"bottom\" ? anchor.y + anchor.gap : anchor.y - h / 2,\n            width: w,\n            height: h,\n          };\n          if (!fits(index, box, avoidPoints)) continue;\n          placements[index] = { visible: true, x: box.x, y: box.y, variant };\n          taken.push(box);\n          break placing;\n        }\n      }\n    }\n  }\n  return placements;\n}\n\n/**\n * Chip boxes for long-haul segments: centred on the curve's midpoint, else moved off the line along its normal, first\n * to the outside of the bend, whichever keeps clear of every marker, of the box edges and of earlier chips.\n */\nfunction placeChips(\n  model: TraceModel,\n  layout: MarkerLayout,\n  markers: readonly { x: number; y: number }[],\n  width: number,\n  height: number,\n) {\n  const placed: { x: number; y: number; width: number }[] = [];\n  return model.segments.map((segment, k) => {\n    if (!segment.jump) return null;\n    const chipWidth = jumpText(segment.delta).length * CHIP_CHAR_WIDTH + 14;\n    const middle = { x: layout.midpoints[k].x * width, y: layout.midpoints[k].y * height };\n    const from = markers[model.located.indexOf(segment.from)];\n    const to = markers[model.located.indexOf(segment.to)];\n    const chord = Math.max(1e-6, Math.hypot(to.x - from.x, to.y - from.y));\n    // the left normal of travel: the side the curve bends to\n    const nx = (to.y - from.y) / chord;\n    const ny = -(to.x - from.x) / chord;\n    let best: { x: number; y: number; width: number } | null = null;\n    let bestScore = -Infinity;\n    for (const offset of [0, 13, -13, 22, -22]) {\n      const box = {\n        x: clamp(middle.x + nx * offset - chipWidth / 2, 2, width - chipWidth - 2),\n        y: clamp(middle.y + ny * offset - CHIP_HEIGHT / 2, 2, height - CHIP_HEIGHT - 2),\n        width: chipWidth,\n      };\n      // clearance: the distance from the box to the closest marker, and no overlap with earlier chips\n      let clearance = Infinity;\n      for (const marker of markers) {\n        const dx = Math.max(box.x - marker.x, 0, marker.x - (box.x + chipWidth));\n        const dy = Math.max(box.y - marker.y, 0, marker.y - (box.y + CHIP_HEIGHT));\n        clearance = Math.min(clearance, Math.hypot(dx, dy));\n      }\n      const overlaps = placed.some(\n        (other) =>\n          box.x < other.x + other.width + 3 &&\n          box.x + chipWidth + 3 > other.x &&\n          box.y < other.y + CHIP_HEIGHT + 3 &&\n          box.y + CHIP_HEIGHT + 3 > other.y,\n      );\n      const score = (overlaps ? -100 : 0) + Math.min(clearance, CHIP_CLEARANCE) - Math.abs(offset) * 0.01;\n      if (score > bestScore) {\n        bestScore = score;\n        best = box;\n      }\n      if (!overlaps && clearance >= CHIP_CLEARANCE) break;\n    }\n    if (best) placed.push(best);\n    return best;\n  });\n}\n\n/* Engine --------------------------------------------------------------------------------------------- */\n\n/** Dots along one projected curve, every DOT_SPACING px, with their distance along it. */\ninterface Track {\n  table: ArcLengthTable;\n  count: number;\n  x: Float32Array;\n  y: Float32Array;\n  s: Float32Array;\n}\n\ninterface SegmentGeometry {\n  /** The main curve, then its copies across the map's seam. */\n  tracks: Track[];\n  /** Heading of the packet as it lands, in radians: the dwell ring starts there and turns clockwise. */\n  arrivalAngle: number;\n}\n\n/** Grid positions of the lattice around a point (land or not), for rings and pings that must read as full circles. */\ninterface DiscDots {\n  count: number;\n  x: Float32Array;\n  y: Float32Array;\n  distance: number[];\n  /** Clockwise position around the hop from the ring's start angle, 0 to 1. */\n  turn: Float32Array;\n  jitter: Float32Array;\n  land: Uint8Array;\n}\n\ninterface HopGeometry {\n  x: number;\n  y: number;\n  ring: DiscDots;\n}\n\ninterface TraceGeometry {\n  projection: MapProjection;\n  lattice: DotLattice;\n  dotSize: number;\n  segments: SegmentGeometry[];\n  hops: HopGeometry[];\n  /** Grid dots around the destination for the ping, or null. */\n  ping: DiscDots | null;\n  /** Where the land reveal starts (the first located hop, else the middle) and its distance to the farthest corner. */\n  revealX: number;\n  revealY: number;\n  revealReach: number;\n}\n\ninterface TraceBatches {\n  path: DotBatch;\n  land: DotBatch;\n}\n\n/** Everything the animation loop owns. Lives in a ref, never in React state. */\ninterface TraceEngine {\n  model: TraceModel;\n  /** Playback clock in seconds at speed 1, from 0 to total + TAIL. */\n  clock: number;\n  playing: boolean;\n  /** The end has been reached (the clock may still run the ping). */\n  finished: boolean;\n  restart: boolean;\n  speed: number;\n  /** The last row and arrival reported to React. */\n  cursor: number;\n  arrived: number;\n  selected: number;\n  /** Per segment: 1 normally, DIM when a selection leaves it out; and 1 for the selected hop's incoming segment. */\n  emphasis: Spring[];\n  hot: Spring[];\n  /** Per located hop: the selection ring, 0 to 1, and whether it is filling (clockwise) or fading. */\n  selectLight: number[];\n  selectRising: boolean[];\n  /** The model the geometry was built for (or a model with the same hops), and its key. */\n  layoutModel: TraceModel | null;\n  layoutKey: string | null;\n  /** Seconds of the land reveal so far, up to REVEAL_SECONDS. */\n  reveal: number;\n  width: number;\n  height: number;\n  geometry: TraceGeometry | null;\n  ink: GeoInk | null;\n  batches: TraceBatches | null;\n  inkStale: boolean;\n  baked: boolean;\n  dirty: boolean;\n  sparks: Sparks;\n  fxDirty: boolean;\n}\n\nfunction createEngine(model: TraceModel, playing: boolean): TraceEngine {\n  return {\n    model,\n    clock: playing ? 0 : model.timing.total + TAIL,\n    playing,\n    finished: !playing,\n    restart: false,\n    speed: 1,\n    cursor: playing ? -1 : model.rows.length,\n    arrived: playing || model.located.length === 0 ? -1 : model.located[model.located.length - 1],\n    selected: -1,\n    emphasis: [],\n    hot: [],\n    selectLight: [],\n    selectRising: [],\n    layoutModel: null,\n    layoutKey: null,\n    reveal: 0,\n    width: 0,\n    height: 0,\n    geometry: null,\n    ink: null,\n    batches: null,\n    inkStale: true,\n    baked: false,\n    dirty: true,\n    sparks: new Sparks(),\n    fxDirty: false,\n  };\n}\n\n/** New ink after a theme change: the path ramp runs from the accent to the shine (deep violet in light, white in dark). */\nfunction refreshInk(engine: TraceEngine, palette: GeoPalette) {\n  const ink = geoInk(palette);\n  engine.ink = ink;\n  const pathStops = [ink.accent, ink.shine];\n  if (engine.batches) {\n    engine.batches.path.setRamp(pathStops);\n    engine.batches.land.setRamp([ink.land]);\n  } else {\n    engine.batches = {\n      path: createDotBatch(2048, pathStops, { tones: 16, levels: 32 }),\n      land: createDotBatch(8192, [ink.land], { tones: 1, levels: 16 }),\n    };\n  }\n  engine.inkStale = false;\n  engine.baked = false;\n  engine.dirty = true;\n}\n\nconst pathPoint = { x: 0, y: 0, angle: 0 };\n\n/** A new size or trace: the projection, the lattice, every curve and every ring. */\nfunction layoutEngine(engine: TraceEngine, width: number, height: number) {\n  const model = engine.model;\n  const projection = viewProjection(model.view, width, height);\n  const pitch = width < NARROW_WIDTH ? 3 : 4;\n  const lattice = mapLattice(projection, { pitch });\n  const land = new Set<number>();\n  for (let i = 0; i < lattice.count; i++) land.add(lattice.row[i] * GRID_STRIDE + lattice.column[i]);\n\n  const segments = model.segments.map((segment): SegmentGeometry => {\n    const from = model.rows[segment.from].point ?? { lat: 0, lon: 0 };\n    const to = model.rows[segment.to].point ?? { lat: 0, lon: 0 };\n    const tables = flatPaths(projection, from, to, { bend: BEND, segments: 48 });\n    const tracks = tables.map((table) => sampleTrack(table));\n    const main = tables[0];\n    let arrivalAngle = -Math.PI / 2;\n    if (main && main.length > 0.5) {\n      pointAtDistance(main, main.length, pathPoint);\n      arrivalAngle = pathPoint.angle;\n    }\n    return { tracks, arrivalAngle };\n  });\n\n  const screen = createScreenPoint();\n  const hops = model.located.map((row, li): HopGeometry => {\n    const point = model.rows[row].point ?? { lat: 0, lon: 0 };\n    projection.project(point.lat, point.lon, screen);\n    // the ring starts where the packet lands heading; the first hop has no incoming segment and starts at the top\n    const startAngle = li > 0 ? (segments[li - 1]?.arrivalAngle ?? -Math.PI / 2) : -Math.PI / 2;\n    return {\n      x: screen.x,\n      y: screen.y,\n      ring: discDots(projection, pitch, land, screen.x, screen.y, RING_INNER, RING_OUTER, startAngle),\n    };\n  });\n\n  let ping: DiscDots | null = null;\n  if (model.pingRow >= 0) {\n    const hop = hops[model.located.indexOf(model.pingRow)];\n    if (hop) ping = discDots(projection, pitch, land, hop.x, hop.y, 0, PING_RADIUS + 12, 0);\n  }\n\n  const origin = hops[0] ?? { x: width / 2, y: height / 2 };\n  const reach = Math.max(\n    Math.hypot(origin.x, origin.y),\n    Math.hypot(width - origin.x, origin.y),\n    Math.hypot(origin.x, height - origin.y),\n    Math.hypot(width - origin.x, height - origin.y),\n    1,\n  );\n\n  engine.geometry = {\n    projection,\n    lattice,\n    dotSize: pitch >= 4 ? 1.6 : 1.3,\n    segments,\n    hops,\n    ping,\n    revealX: origin.x,\n    revealY: origin.y,\n    revealReach: reach,\n  };\n  engine.layoutModel = model;\n  engine.layoutKey = model.key;\n  engine.width = width;\n  engine.height = height;\n  engine.baked = false;\n  engine.dirty = true;\n  // springs follow the segment and hop count; a new trace starts with every segment at full strength\n  engine.emphasis = model.segments.map((_, k) => engine.emphasis[k] ?? { position: 1, velocity: 0 });\n  engine.hot = model.segments.map((_, k) => engine.hot[k] ?? { position: 0, velocity: 0 });\n  engine.selectLight = model.located.map((_, li) => engine.selectLight[li] ?? 0);\n  engine.selectRising = model.located.map((_, li) => engine.selectRising[li] ?? false);\n}\n\n/** Row stride for grid keys: wider than any lattice can get. */\nconst GRID_STRIDE = 1 << 16;\n\nfunction sampleTrack(table: ArcLengthTable): Track {\n  const capacity = Math.floor(table.length / DOT_SPACING) + 2;\n  const track: Track = {\n    table,\n    count: 0,\n    x: new Float32Array(capacity),\n    y: new Float32Array(capacity),\n    s: new Float32Array(capacity),\n  };\n  forEachDot(table, DOT_SPACING, 0, table.length, (x, y, s) => {\n    if (track.count >= capacity) return;\n    track.x[track.count] = x;\n    track.y[track.count] = y;\n    track.s[track.count] = s;\n    track.count++;\n  });\n  return track;\n}\n\n/**\n * The lattice's grid positions within [inner, outer] px of a point, whether land or water, so a dwell ring or a ping\n * reads as a whole circle even at a coastal hop. They sit exactly on the lattice grid, so land dots coincide.\n */\nfunction discDots(\n  projection: MapProjection,\n  pitch: number,\n  land: ReadonlySet<number>,\n  cx: number,\n  cy: number,\n  inner: number,\n  outer: number,\n  startAngle: number,\n): DiscDots {\n  const { x0, y0 } = projection.frame;\n  const c0 = Math.ceil((cx - outer - x0) / pitch - 0.5);\n  const c1 = Math.floor((cx + outer - x0) / pitch - 0.5);\n  const r0 = Math.ceil((cy - outer - y0) / pitch - 0.5);\n  const r1 = Math.floor((cy + outer - y0) / pitch - 0.5);\n  const xs: number[] = [];\n  const ys: number[] = [];\n  const distance: number[] = [];\n  const turn: number[] = [];\n  const jitter: number[] = [];\n  const isLand: number[] = [];\n  for (let r = r0; r <= r1; r++) {\n    for (let c = c0; c <= c1; c++) {\n      const x = x0 + (c + 0.5) * pitch;\n      const y = y0 + (r + 0.5) * pitch;\n      const d = Math.hypot(x - cx, y - cy);\n      if (d < inner || d > outer) continue;\n      xs.push(x);\n      ys.push(y);\n      distance.push(d);\n      // screen y points down, so a growing atan2 angle turns clockwise\n      const angle = Math.atan2(y - cy, x - cx) - startAngle;\n      turn.push((((angle % TAU) + TAU) % TAU) / TAU);\n      jitter.push(hash(r * 31 + c * 7 + 0.5));\n      isLand.push(land.has(r * GRID_STRIDE + c) ? 1 : 0);\n    }\n  }\n  return {\n    count: xs.length,\n    x: Float32Array.from(xs),\n    y: Float32Array.from(ys),\n    distance,\n    turn: Float32Array.from(turn),\n    jitter: Float32Array.from(jitter),\n    land: Uint8Array.from(isLand),\n  };\n}\n\n/**\n * The land lattice in the quiet land ink, fading out toward the box edges so the map has no hard cut. While `progress`\n * is below 1 the land grows out from the first located hop behind an uneven front (threshold = n·0.82 + jitter·0.18),\n * the dots at the front a little brighter than the land behind it.\n */\nfunction drawBase(surface: CanvasSurface, geometry: TraceGeometry, batch: DotBatch, progress: number) {\n  const { context, width, height } = surface;\n  context.clearRect(0, 0, width, height);\n  const { lattice, dotSize, revealX, revealY, revealReach } = geometry;\n  const growing = progress < 1;\n  const front = easeOutCubic(progress) * (1 + REVEAL_SOFTNESS * 2);\n  for (let i = 0; i < lattice.count; i++) {\n    const x = lattice.x[i];\n    const y = lattice.y[i];\n    const edge = smoothstep(0, EDGE_FADE, Math.min(x, y, width - x, height - y));\n    let shown = 1;\n    let crest = 0;\n    if (growing) {\n      const threshold = (Math.hypot(x - revealX, y - revealY) / revealReach) * 0.82 + lattice.jitter[i] * 0.18;\n      shown = clamp((front - threshold) / REVEAL_SOFTNESS, 0, 1);\n      crest = shown * (1 - shown) * 4;\n    }\n    batch.add(x, y, dotSize, 0, edge * (0.5 * shown + 0.4 * crest));\n  }\n  batch.flush(context);\n}\n\n/** Selection springs and rings. Returns whether anything moved. */\nfunction stepEmphasis(engine: TraceEngine, delta: number, reduced: boolean) {\n  const model = engine.layoutModel;\n  if (!model) return false;\n  const selected = engine.selected;\n  const anySelected = selected >= 0 && selected < model.rows.length;\n  let moving = false;\n  model.segments.forEach((segment, k) => {\n    const emphasis = engine.emphasis[k];\n    const hot = engine.hot[k];\n    if (!emphasis || !hot) return;\n    const isSelected = anySelected && segment.to === selected;\n    moving = settleSpring(emphasis, !anySelected || isSelected ? 1 : DIM, delta, reduced, EMPHASIS_SPRING) || moving;\n    moving = settleSpring(hot, isSelected ? 1 : 0, delta, reduced, EMPHASIS_SPRING) || moving;\n  });\n  model.located.forEach((row, li) => {\n    const target = row === selected ? 1 : 0;\n    const light = engine.selectLight[li] ?? 0;\n    if (light === target) return;\n    engine.selectRising[li] = target > light;\n    engine.selectLight[li] = reduced\n      ? target\n      : target > light\n        ? Math.min(target, light + delta / SELECT_FILL)\n        : Math.max(target, light - delta / SELECT_FADE);\n    moving = true;\n  });\n  return moving;\n}\n\n/**\n * The path drawn so far, the dwell and selection rings, the packet and the ping, as a function of the clock. Dot\n * squares start on the device pixel grid, so the path reads as crisp pixel rows, like the lattice under it.\n */\nfunction drawTrace(engine: TraceEngine, surface: CanvasSurface, batch: DotBatch, reduced: boolean) {\n  const { context, width, height, dpr } = surface;\n  context.clearRect(0, 0, width, height);\n  const geometry = engine.geometry;\n  const model = engine.layoutModel;\n  if (!geometry || !model) return;\n  const t = engine.clock;\n  const { timing } = model;\n  const selected = engine.selected;\n  const dimAll = selected >= 0 && selected < model.rows.length && model.rows[selected].kind !== \"located\";\n  const dimMap = dimAll ? DIM : 1;\n  const base = engine.ink?.dark ? PATH_ALPHA.dark : PATH_ALPHA.light;\n\n  // segments: dotted, dashed for long-haul jumps, drawn up to the packet with a hot wake behind it\n  model.segments.forEach((segment, k) => {\n    const span = timing.travel[k];\n    const shape = geometry.segments[k];\n    if (!span || !shape || t <= span.start) return;\n    const progress = t >= span.end ? 1 : TRAVEL_EASE((t - span.start) / (span.end - span.start));\n    const traveling = progress < 1;\n    const emphasis = clamp(engine.emphasis[k]?.position ?? 1, 0, 1.2) * dimMap;\n    const hot = clamp(engine.hot[k]?.position ?? 0, 0, 1);\n    const size = DOT_SIZE * (1 + 0.5 * hot);\n    for (const track of shape.tracks) {\n      const length = track.table.length;\n      const head = progress * length;\n      for (let j = 0; j < track.count; j++) {\n        const s = track.s[j];\n        if (s > head) break;\n        if (s < TRIM || s > length - TRIM) continue;\n        if (segment.jump && Math.round(s / DOT_SPACING) % 6 >= 3) continue;\n        const heat = traveling ? Math.exp(-(head - s) / 22) : 0;\n        const alpha = (base + (1 - base) * hot) * emphasis + heat * 0.45;\n        const tone = Math.max(hot * 0.55, heat);\n        batch.add(snapCenter(track.x[j], size, dpr), snapCenter(track.y[j], size, dpr), size, tone, Math.min(1, alpha));\n      }\n    }\n  });\n\n  // rings: the dwell fills clockwise from the landing heading; a selected hop's ring fills the same way and holds\n  model.located.forEach((_, li) => {\n    const hop = geometry.hops[li];\n    const span = timing.dwell[li];\n    if (!hop || !span) return;\n    let share = 0;\n    let fade = 0;\n    if (t >= span.start && !reduced) {\n      share = clamp((t - span.start) / Math.max(1e-6, span.end - span.start), 0, 1);\n      fade = t <= span.end ? 1 : 1 - smoothstep(0, RING_FADE, t - span.end);\n    }\n    const select = engine.selectLight[li] ?? 0;\n    const rising = engine.selectRising[li] ?? false;\n    if (fade <= 0 && select <= 0) return;\n    const ring = hop.ring;\n    // the whole ring shows faintly as a track while it fills, so it reads as a progress ring\n    const track = Math.max(share < 1 ? fade : 0, rising && select < 1 ? 1 : 0) * 0.16;\n    for (let i = 0; i < ring.count; i++) {\n      const threshold = ring.turn[i] * 0.9 + ring.jitter[i] * 0.1;\n      let lit = 0;\n      let lead = 0;\n      if (fade > 0) {\n        const ahead = share - threshold;\n        lit = clamp(ahead / 0.05, 0, 1) * fade;\n        if (ahead > 0 && share < 1) lead = Math.exp(-ahead / 0.05) * fade;\n      }\n      const selectLit = rising ? clamp((select - threshold) / 0.05, 0, 1) : select;\n      const shown = Math.max(lit, selectLit * 0.75);\n      const depth = (ring.distance[i] - RING_INNER) / (RING_OUTER - RING_INNER);\n      const alpha = (Math.max(track, shown * (0.95 - 0.4 * depth)) + lead * 0.35) * dimMap;\n      if (alpha < 0.02) continue;\n      batch.add(\n        snapCenter(ring.x[i], geometry.dotSize, dpr),\n        snapCenter(ring.y[i], geometry.dotSize, dpr),\n        geometry.dotSize,\n        Math.max(lead, 0.08),\n        Math.min(1, alpha),\n      );\n    }\n  });\n\n  if (!reduced) {\n    drawPacket(engine, geometry, model, batch, dpr);\n    drawPing(engine, geometry, model, batch);\n  }\n  batch.flush(context);\n}\n\nconst TAIL_GAP = 2.6;\nconst TAIL_ALPHA = [1, 0.7, 0.42, 0.2];\n\n/** The packet: a hot head with a short tail, on its segment while travelling and orbiting the hop while it waits. */\nfunction drawPacket(engine: TraceEngine, geometry: TraceGeometry, model: TraceModel, batch: DotBatch, dpr: number) {\n  const t = engine.clock;\n  const { timing } = model;\n  if (t >= timing.total || model.located.length === 0) return;\n  const put = (x: number, y: number, k: number) => {\n    const size = k === 0 ? 3 : 1.75;\n    batch.add(snapCenter(x, size, dpr), snapCenter(y, size, dpr), size, k === 0 ? 1 : 0.65 - k * 0.2, TAIL_ALPHA[k]);\n  };\n  for (let k = 0; k < model.segments.length; k++) {\n    const span = timing.travel[k];\n    const shape = geometry.segments[k];\n    if (!span || !shape || t < span.start || t >= span.end) continue;\n    const progress = TRAVEL_EASE((t - span.start) / (span.end - span.start));\n    for (const track of shape.tracks) {\n      const head = progress * track.table.length;\n      for (let j = 0; j < TAIL_ALPHA.length; j++) {\n        const along = head - j * TAIL_GAP;\n        if (along < 0) break;\n        pointAtDistance(track.table, along, pathPoint);\n        put(pathPoint.x, pathPoint.y, j);\n      }\n    }\n    return;\n  }\n  for (let li = 0; li < model.located.length; li++) {\n    const span = timing.dwell[li];\n    const hop = geometry.hops[li];\n    if (!span || !hop || t < span.start || t >= span.end) continue;\n    const share = (t - span.start) / Math.max(1e-6, span.end - span.start);\n    const startAngle = li > 0 ? (geometry.segments[li - 1]?.arrivalAngle ?? -Math.PI / 2) : -Math.PI / 2;\n    // out of the centre into the orbit, once round, and back in to leave along the next segment\n    const radius = ORBIT * smoothstep(0, 0.16, share) * (1 - smoothstep(0.86, 1, share));\n    const angle = startAngle + share * TAU;\n    const step = TAIL_GAP / Math.max(1.5, radius);\n    for (let j = 0; j < TAIL_ALPHA.length; j++) {\n      const a = angle - Math.min(0.9, step) * j;\n      put(hop.x + Math.cos(a) * radius, hop.y + Math.sin(a) * radius, j);\n    }\n    return;\n  }\n}\n\n/** Two rings of land dots run out from the destination behind an uneven front, the second one fainter. */\nfunction drawPing(engine: TraceEngine, geometry: TraceGeometry, model: TraceModel, batch: DotBatch) {\n  const ping = geometry.ping;\n  const since = engine.clock - model.timing.total;\n  if (!ping || !engine.finished || since <= 0 || since >= PING_SECONDS + PING_DELAY) return;\n  for (let i = 0; i < ping.count; i++) {\n    if (!ping.land[i]) continue;\n    let value = 0;\n    for (let ring = 0; ring < 2; ring++) {\n      const p = (since - ring * PING_DELAY) / PING_SECONDS;\n      if (p <= 0 || p >= 1) continue;\n      const radius = easeOutCubic(p) * PING_RADIUS;\n      value = Math.max(\n        value,\n        ringFront(ping.distance[i], radius, 4.5, ping.jitter[i], 5) * Math.pow(1 - p, 1.2) * (ring === 0 ? 1 : 0.65),\n      );\n    }\n    if (value < 0.04) continue;\n    batch.add(ping.x[i], ping.y[i], geometry.dotSize, value, value);\n  }\n}\n\n/** Sparks from the destination on the layer that reaches past the map. Returns whether any are alive. */\nfunction drawSparks(engine: TraceEngine, surface: CanvasSurface, delta: number) {\n  const { context, width, height } = surface;\n  if (!engine.sparks.active) {\n    if (engine.fxDirty) {\n      context.clearRect(0, 0, width, height);\n      engine.fxDirty = false;\n    }\n    return false;\n  }\n  engine.fxDirty = true;\n  context.clearRect(0, 0, width, height);\n  return engine.sparks.step(context, delta);\n}\n",
      "type": "registry:component",
      "target": "@components/lumesec/geo-traceroute.tsx"
    }
  ],
  "meta": {
    "tag": "geo-traceroute",
    "collection": "location",
    "react": "GeoTraceroute",
    "kind": "react",
    "data": "input"
  },
  "docs": "Traceroute docs: https://elements.lumesec.ai/components/location/traceroute",
  "categories": [
    "location",
    "network-latency"
  ],
  "type": "registry:component"
}