All files / src/state origins.ts

94.44% Statements 68/72
72.97% Branches 27/37
88.88% Functions 8/9
94.44% Lines 68/72

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 123 124 125 126                                              2x     2x                   34x 34x 34x 2x 2x 1x 1x 1x 2x 1x 2x 2x 2x 2x 2x     2x 2x 2x   2x 33x 33x   2x           2x 3x 3x     2x 31x 31x 2x 2x 31x 31x     2x 1x   1x 1x 1x 1x 1x 1x 1x 1x             2x 5x 5x 6x 6x 6x 6x 4x 4x 4x 5x 5x 5x     2x 1x 1x 1x   2x 6x 6x 6x 6x 6x 2x  
/**
 * Where each sequence's replication origin is.
 *
 * The same kind of fact as a circular declaration (`state/circular.ts`) and kept
 * the same way: per file, remembered for the session, carried in an exported
 * workspace, and **not** in the view state — it is about the molecule rather
 * than the window. What reads it is everything that sees a bacterial chromosome
 * in the frame of its two arms (§8.15): the arm-frame expectation, on the map
 * and in the compartment call; the inter-arm alignment; and the one-step framing
 * of a circle with its origin in the middle.
 *
 * Two hands write it, and the difference is kept. The *Origin and terminus*
 * task fills in every sequence it places; a reader's own arrives in a workspace
 * they exported, or on a `chromosomix` line's `--origin`, and the Chromosomes
 * table shows whichever is held. A reader's origin is **never** overwritten by
 * a task — a reader who has corrected a call knows something the skew did not —
 * while a task's is replaced by the next run of the task, which is what
 * re-running it with different sites is for.
 *
 * Base pairs, 0-based — the machine form every coordinate is kept in (§26.1);
 * the table and the CLI print it 1-based.
 */
 
import { recall, remember } from './UiMemory'
 
/** The `UiMemory` scope this is kept under. Also in `SessionConfig`'s table. */
export const ORIGIN_SCOPE = 'map:origins'
 
/** One declared origin: where, and whose word it is. */
export interface DeclaredOrigin {
  /** Base pairs, 0-based. */
  at: number
  by: 'reader' | 'task'
}
 
/** The stored shape, validated on the way in because storage outlives code. */
function parse(stored: unknown): Map<string, DeclaredOrigin> {
  const out = new Map<string, DeclaredOrigin>()
  if (stored === null || typeof stored !== 'object' || Array.isArray(stored)) return out
  for (const [name, value] of Object.entries(stored as Record<string, unknown>)) {
    if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
      out.set(name, { at: Math.floor(value), by: 'reader' })
      continue
    }
    if (value === null || typeof value !== 'object') continue
    const { at, by } = value as { at?: unknown; by?: unknown }
    if (typeof at !== 'number' || !Number.isFinite(at) || at < 0) continue
    out.set(name, { at: Math.floor(at), by: by === 'task' ? 'task' : 'reader' })
  }
  return out
}
 
/** The declared origins of one open file. */
export class OriginStore {
  private origins: Map<string, DeclaredOrigin>
  private readonly listeners = new Set<() => void>()
 
  constructor(private readonly key: string) {
    this.origins = parse(recall<unknown>(key, ORIGIN_SCOPE))
  }
 
  subscribe(listener: () => void): () => void {
    this.listeners.add(listener)
    return () => this.listeners.delete(listener)
  }
 
  /** The declared origin of a sequence, or `undefined`. */
  get(name: string): DeclaredOrigin | undefined {
    return this.origins.get(name)
  }
 
  /** Every declared origin by name, in base pairs — what a framing carries. */
  record(): Record<string, number> {
    const out: Record<string, number> = {}
    for (const [name, origin] of [...this.origins.entries()].sort(([a], [b]) => a.localeCompare(b))) {
      out[name] = origin.at
    }
    return out
  }
 
  /** Set by the reader, or cleared with `null`. */
  set(name: string, at: number | null): void {
    if (at === null) {
      if (!this.origins.delete(name)) return
    } else {
      const next = Math.max(0, Math.floor(at))
      const held = this.origins.get(name)
      if (held?.at === next && held.by === 'reader') return
      this.origins.set(name, { at: next, by: 'reader' })
    }
    this.persist()
  }
 
  /**
   * Filled in by a task, for every sequence the reader has not set one on.
   *
   * @returns The names that took the task's origin, so the caller can say so.
   */
  fill(found: Readonly<Record<string, number>>): string[] {
    const taken: string[] = []
    for (const [name, at] of Object.entries(found)) {
      if (!Number.isFinite(at) || at < 0) continue
      const held = this.origins.get(name)
      if (held?.by === 'reader') continue
      if (held?.at === Math.floor(at)) continue
      this.origins.set(name, { at: Math.floor(at), by: 'task' })
      taken.push(name)
    }
    if (taken.length > 0) this.persist()
    return taken
  }
 
  /** Replace the lot — how an imported workspace arrives. Every one is the reader's. */
  replace(found: Readonly<Record<string, number>>): void {
    this.origins = parse(found)
    this.persist()
  }
 
  private persist(): void {
    const stored: Record<string, DeclaredOrigin> = {}
    for (const [name, origin] of this.origins) stored[name] = origin
    remember(this.key, ORIGIN_SCOPE, stored)
    for (const listener of this.listeners) listener()
  }
}