All files / src/edit marks.ts

1% Statements 2/200
100% Branches 0/0
0% Functions 0/11
1% Lines 2/200

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 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283                                                                                                        2x                               2x                                                                                                                                                                                                                                                                                                                                                                                                                                            
/**
 * The suggestions, drawn over the map (SPECS §27.3): what the method proposes,
 * on the picture the reader is judging it by, in the accent — a suggestion is
 * something a reader can act on (§26.5), an offer and never a caution, so
 * nothing here is drawn in a warning's colour.
 *
 * - A **diamond** on the diagonal where it takes a junction away: the map says
 *   the reference is discontinuous there.
 * - A **corner** — an L of two strokes opening into its quadrant — where it
 *   brings two ends together: the corner of the block of contacts that stops
 *   there, which is the evidence, drawn in both triangles.
 * - A **number** beside it, the one the rail and the list give it.
 * - And for the one lit — hovered on the map, on the rail or in the list — what
 *   it would do: a turning arrow along a stretch it turns, an arrow from a
 *   stretch it moves to the line where it goes in, a cut across the diagonal
 *   where it splits, an arrow between two ends it joins.
 *
 * Canvas calls only, against a context and the numbers to place things by.
 */
 
import type { StageInk } from './draw'
import type { Suggestion } from './suggest'
 
/** A suggestion as the map draws it. */
export interface MarkedSuggestion {
  suggestion: Suggestion
  /** Its number, as the rail and the list give it. */
  number: number
  lit: boolean
}
 
/** What the marks are drawn from on one frame. */
export interface MarksFrame {
  size: number
  toX: (unit: number) => number
  toY: (unit: number) => number
  ink: StageInk
  marks: readonly MarkedSuggestion[]
}
 
/** A mark's place on the map, for the pointer to find it by: in pixels, and in units of the axis. */
export interface MarkSpot {
  key: string
  x: number
  y: number
  col: number
  row: number
  /** A corner, rather than a point on the diagonal. */
  corner: boolean
}
 
/** Every place a suggestion is drawn at: its diamonds and its corners, both triangles. */
export function markSpots(frame: Omit<MarksFrame, 'ink'>): MarkSpot[] {
  const spots: MarkSpot[] = []
  const spot = (key: string, col: number, row: number, corner: boolean): void => {
    spots.push({ key, x: frame.toX(col), y: frame.toY(row), col, row, corner })
  }
  for (const { suggestion } of frame.marks) {
    for (const x of suggestion.breaks) spot(suggestion.key, x, x, false)
    for (const corner of suggestion.corners) {
      spot(suggestion.key, corner.col, corner.row, true)
      spot(suggestion.key, corner.row, corner.col, true)
    }
  }
  return spots
}
 
/** Draw every suggestion's marks, the lit one last and strongest. */
export function drawSuggestions(ctx: CanvasRenderingContext2D, frame: MarksFrame): void {
  const ordered = [...frame.marks].sort((a, b) => Number(a.lit) - Number(b.lit))
  for (const mark of ordered) {
    if (mark.lit) drawDoing(ctx, frame, mark.suggestion)
    drawMarks(ctx, frame, mark)
  }
}
 
function visible(frame: MarksFrame, x: number, y: number, room = 24): boolean {
  return x >= -room && x <= frame.size + room && y >= -room && y <= frame.size + room
}
 
/** A stroke with a halo of the background under it, so it reads over the darkest contacts. */
function haloed(ctx: CanvasRenderingContext2D, ink: StageInk, width: number, path: () => void, alpha = 1): void {
  ctx.lineCap = 'round'
  ctx.lineJoin = 'round'
  ctx.globalAlpha = 0.75 * alpha
  ctx.strokeStyle = ink.background
  ctx.lineWidth = width + 2.5
  ctx.beginPath()
  path()
  ctx.stroke()
  ctx.globalAlpha = alpha
  ctx.strokeStyle = ink.accent
  ctx.lineWidth = width
  ctx.beginPath()
  path()
  ctx.stroke()
  ctx.globalAlpha = 1
}
 
function drawMarks(ctx: CanvasRenderingContext2D, frame: MarksFrame, mark: MarkedSuggestion): void {
  const { toX, toY, ink } = frame
  const { suggestion, lit } = mark
  const alpha = lit ? 1 : 0.88
 
  // Where it takes a junction away: a diamond on the diagonal.
  const r = lit ? 6.5 : 5
  for (const x of suggestion.breaks) {
    const px = toX(x)
    const py = toY(x)
    if (!visible(frame, px, py)) continue
    ctx.globalAlpha = alpha
    ctx.beginPath()
    ctx.moveTo(px, py - r)
    ctx.lineTo(px + r, py)
    ctx.lineTo(px, py + r)
    ctx.lineTo(px - r, py)
    ctx.closePath()
    ctx.fillStyle = ink.accent
    ctx.fill()
    ctx.lineWidth = 1.5
    ctx.strokeStyle = ink.background
    ctx.stroke()
    ctx.globalAlpha = 1
  }
 
  // Where it brings two ends together: a corner opening into its quadrant, in
  // both triangles.
  const length = lit ? 14 : 11
  const width = lit ? 3 : 2.25
  for (const corner of suggestion.corners) {
    const places: Array<[number, number, 1 | -1, 1 | -1]> = [
      [toX(corner.col), toY(corner.row), corner.colSide, corner.rowSide],
      [toX(corner.row), toY(corner.col), corner.rowSide, corner.colSide],
    ]
    for (const [px, py, across, down] of places) {
      if (!visible(frame, px, py)) continue
      haloed(
        ctx,
        ink,
        width,
        () => {
          ctx.moveTo(px + across * length, py)
          ctx.lineTo(px, py)
          ctx.lineTo(px, py + down * length)
        },
        alpha,
      )
    }
  }
 
  // Its number, beside its first mark in the upper triangle.
  const anchor = badgeAt(frame, suggestion)
  if (anchor !== null) {
    const [bx, by] = anchor
    ctx.globalAlpha = alpha
    ctx.beginPath()
    ctx.arc(bx, by, lit ? 9 : 8, 0, 2 * Math.PI)
    ctx.fillStyle = ink.accent
    ctx.fill()
    ctx.lineWidth = 1.5
    ctx.strokeStyle = ink.background
    ctx.stroke()
    ctx.fillStyle = ink.background
    ctx.font = `600 ${lit ? 11 : 10}px ${ink.font.replace(/^\d+px\s*/, '')}`
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    ctx.fillText(String(mark.number), bx, by + 0.5)
    ctx.globalAlpha = 1
  }
}
 
/** Where a suggestion's number goes: up and right of its first corner, else of its first diamond. */
function badgeAt(frame: MarksFrame, suggestion: Suggestion): [number, number] | null {
  const { toX, toY } = frame
  const corner = suggestion.corners[0]
  const [x, y] =
    corner !== undefined
      ? [toX(corner.col), toY(corner.row)]
      : suggestion.breaks[0] !== undefined
        ? [toX(suggestion.breaks[0]), toY(suggestion.breaks[0])]
        : [Number.NaN, Number.NaN]
  if (!visible(frame, x, y, 0)) return null
  return [Math.min(frame.size - 10, x + 13), Math.max(10, y - 13)]
}
 
/** A point on the diagonal, in pixels. */
function onDiagonal(frame: MarksFrame, unit: number): [number, number] {
  return [frame.toX(unit), frame.toY(unit)]
}
 
/**
 * An arrow along a curve bowed off the diagonal into the upper triangle, from
 * one point on it to another, with a head at `to` — and at `from` as well for
 * a turn, which goes both ways.
 */
function bowedArrow(ctx: CanvasRenderingContext2D, frame: MarksFrame, from: [number, number], to: [number, number], both: boolean): void {
  const [x0, y0] = from
  const [x1, y1] = to
  const length = Math.hypot(x1 - x0, y1 - y0)
  if (length < 6) return
  // Off the diagonal, up and to the right, by a third of the span.
  const bow = Math.max(16, Math.min(90, length * 0.33))
  const mx = (x0 + x1) / 2 + bow / Math.SQRT2
  const my = (y0 + y1) / 2 - bow / Math.SQRT2
  haloed(ctx, frame.ink, 2, () => {
    ctx.moveTo(x0, y0)
    ctx.quadraticCurveTo(mx, my, x1, y1)
  })
  const head = (tipX: number, tipY: number, fromX: number, fromY: number): void => {
    const angle = Math.atan2(tipY - fromY, tipX - fromX)
    const size = 8
    ctx.beginPath()
    ctx.moveTo(tipX, tipY)
    ctx.lineTo(tipX - size * Math.cos(angle - 0.45), tipY - size * Math.sin(angle - 0.45))
    ctx.lineTo(tipX - size * Math.cos(angle + 0.45), tipY - size * Math.sin(angle + 0.45))
    ctx.closePath()
    ctx.fillStyle = frame.ink.accent
    ctx.fill()
  }
  head(x1, y1, mx, my)
  if (both) head(x0, y0, mx, my)
}
 
/** A dashed outline of a stretch's square on the diagonal: what would move or turn. */
function stretchOutline(ctx: CanvasRenderingContext2D, frame: MarksFrame, stretch: readonly [number, number]): void {
  const [a, b] = stretch
  const x0 = frame.toX(a)
  const y0 = frame.toY(a)
  const x1 = frame.toX(b)
  const y1 = frame.toY(b)
  ctx.setLineDash([5, 4])
  ctx.strokeStyle = frame.ink.background
  ctx.lineWidth = 3
  ctx.globalAlpha = 0.6
  ctx.strokeRect(x0, y0, x1 - x0, y1 - y0)
  ctx.globalAlpha = 1
  ctx.strokeStyle = frame.ink.accent
  ctx.lineWidth = 1.5
  ctx.strokeRect(x0, y0, x1 - x0, y1 - y0)
  ctx.setLineDash([])
}
 
/** A short line across the diagonal: where a stretch goes in, or where a sequence is cut. */
function across(ctx: CanvasRenderingContext2D, frame: MarksFrame, unit: number, half: number): void {
  const [x, y] = onDiagonal(frame, unit)
  haloed(ctx, frame.ink, 2.5, () => {
    ctx.moveTo(x - half, y + half)
    ctx.lineTo(x + half, y - half)
  })
}
 
/** What the lit suggestion would do. */
function drawDoing(ctx: CanvasRenderingContext2D, frame: MarksFrame, suggestion: Suggestion): void {
  switch (suggestion.kind) {
    case 'turn': {
      const stretch = suggestion.stretch
      if (stretch === null) return
      stretchOutline(ctx, frame, stretch)
      bowedArrow(ctx, frame, onDiagonal(frame, stretch[0]), onDiagonal(frame, stretch[1]), true)
      return
    }
    case 'move': {
      const stretch = suggestion.stretch
      const target = suggestion.target
      if (stretch === null || target === null) return
      stretchOutline(ctx, frame, stretch)
      across(ctx, frame, target, 11)
      bowedArrow(ctx, frame, onDiagonal(frame, (stretch[0] + stretch[1]) / 2), onDiagonal(frame, target), false)
      return
    }
    case 'split': {
      const at = suggestion.breaks[0]
      if (at !== undefined) across(ctx, frame, at, 14)
      return
    }
    case 'join': {
      const [a, b] = suggestion.extent
      bowedArrow(ctx, frame, onDiagonal(frame, a), onDiagonal(frame, b), true)
      return
    }
  }
}