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 | import type { ConstructionState, EuclidViewportState, AngleSpec } from '../types' import { BYRNE_CYCLE } from '../types' import { getPoint } from '../engine/constructionState' import type { FactStore } from '../engine/factStore' import { getEqualAngles } from '../engine/factStore' import { angleMeasureKey } from '../engine/facts' import type { AngleMeasure } from '../engine/facts' const ARC_RADIUS_PX = 18 const TICK_LENGTH_PX = 4 const ARC_LINE_WIDTH = 1.5 /** * Compute the screen-space angle (in radians) from a vertex to a ray endpoint. * Note: canvas Y is flipped (down = positive), so we negate dy for math coords. */ function angleToPoint(vx: number, vy: number, px: number, py: number): number { return Math.atan2(py - vy, px - vx) } /** Convert world coordinates to screen coordinates */ function toScreen( wx: number, wy: number, viewport: EuclidViewportState, w: number, h: number ): { sx: number; sy: number } { const sx = w / 2 + (wx - viewport.center.x) * viewport.pixelsPerUnit const sy = h / 2 - (wy - viewport.center.y) * viewport.pixelsPerUnit return { sx, sy } } /** * Normalize an angle to [0, 2π) */ function normalizeAngle(a: number): number { const TWO_PI = 2 * Math.PI return ((a % TWO_PI) + TWO_PI) % TWO_PI } /** * Draw a small arc between two rays emanating from a vertex, with optional tick marks. */ function drawArc( ctx: CanvasRenderingContext2D, cx: number, cy: number, startAngle: number, endAngle: number, color: string, tickCount: number, radius: number ) { // Ensure we draw the shorter arc let start = normalizeAngle(startAngle) let end = normalizeAngle(endAngle) let sweep = normalizeAngle(end - start) if (sweep > Math.PI) { // Swap to get the shorter arc const tmp = start start = end end = tmp sweep = 2 * Math.PI - sweep } ctx.beginPath() ctx.arc(cx, cy, radius, start, start + sweep) ctx.strokeStyle = color ctx.lineWidth = ARC_LINE_WIDTH ctx.stroke() // Draw tick marks if needed if (tickCount > 0) { const midAngle = start + sweep / 2 const tickSpacing = 3 const totalWidth = (tickCount - 1) * tickSpacing for (let t = 0; t < tickCount; t++) { const offset = -totalWidth / 2 + t * tickSpacing // Offset along the arc by converting pixel offset to angle offset const angleOffset = offset / radius const tickAngle = midAngle + angleOffset const innerR = radius - TICK_LENGTH_PX const outerR = radius + TICK_LENGTH_PX ctx.beginPath() ctx.moveTo(cx + innerR * Math.cos(tickAngle), cy + innerR * Math.sin(tickAngle)) ctx.lineTo(cx + outerR * Math.cos(tickAngle), cy + outerR * Math.sin(tickAngle)) ctx.strokeStyle = color ctx.lineWidth = 1.5 ctx.stroke() } } } /** Canonicalize an AngleSpec to a sorted key matching AngleMeasure format */ function canonicalAngleKey(spec: AngleSpec): string { const r1 = spec.ray1End const r2 = spec.ray2End const [sorted1, sorted2] = r1 < r2 ? [r1, r2] : [r2, r1] return `∠${spec.vertex}|${sorted1}|${sorted2}` } /** * Render angle arcs at vertices with optional equality tick marks. * Supports both static arcs (from PropositionDef) and dynamic arcs * derived from the fact store's angle equality facts. */ export function renderAngleArcs( ctx: CanvasRenderingContext2D, state: ConstructionState, viewport: EuclidViewportState, w: number, h: number, givenAngles?: Array<{ spec: AngleSpec; color: string; radiusPx?: number }>, equalAngles?: Array<[AngleSpec, AngleSpec]>, factStore?: FactStore ) { // Build a map from angle spec key to tick count (static pairs) const tickCounts = new Map<string, number>() if (equalAngles) { for (let pairIdx = 0; pairIdx < equalAngles.length; pairIdx++) { const [a1, a2] = equalAngles[pairIdx] const key1 = `${a1.vertex}|${a1.ray1End}|${a1.ray2End}` const key2 = `${a2.vertex}|${a2.ray1End}|${a2.ray2End}` tickCounts.set(key1, pairIdx + 1) tickCounts.set(key2, pairIdx + 1) } } // Track which angles have been rendered (canonical keys) to avoid duplicates const renderedAngles = new Set<string>() // ── Static angle arcs (from PropositionDef) ── if (givenAngles) { for (const { spec, color, radiusPx } of givenAngles) { const vertex = getPoint(state, spec.vertex) const ray1 = getPoint(state, spec.ray1End) const ray2 = getPoint(state, spec.ray2End) if (!vertex || !ray1 || !ray2) continue const vs = toScreen(vertex.x, vertex.y, viewport, w, h) const r1s = toScreen(ray1.x, ray1.y, viewport, w, h) const r2s = toScreen(ray2.x, ray2.y, viewport, w, h) const angle1 = angleToPoint(vs.sx, vs.sy, r1s.sx, r1s.sy) const angle2 = angleToPoint(vs.sx, vs.sy, r2s.sx, r2s.sy) const key = `${spec.vertex}|${spec.ray1End}|${spec.ray2End}` const ticks = tickCounts.get(key) ?? 0 drawArc(ctx, vs.sx, vs.sy, angle1, angle2, color, ticks, radiusPx ?? ARC_RADIUS_PX) renderedAngles.add(canonicalAngleKey(spec)) } } // ── Dynamic angle arcs (from fact store) ── if (!factStore || factStore.angleFacts.length === 0) return // Collect all unique angles mentioned in facts const allAngles = new Map<string, AngleMeasure>() for (const fact of factStore.angleFacts) { allAngles.set(angleMeasureKey(fact.left), fact.left) allAngles.set(angleMeasureKey(fact.right), fact.right) } // Group by equivalence class const visited = new Set<string>() const groups: AngleMeasure[][] = [] for (const [key, am] of allAngles) { if (visited.has(key)) continue const eqClass = getEqualAngles(factStore, am) const group: AngleMeasure[] = [] for (const eq of eqClass) { const k = angleMeasureKey(eq) if (allAngles.has(k) && !visited.has(k)) { visited.add(k) group.push(eq) } } if (group.length > 0) groups.push(group) } // Tick numbering starts after static equalAngles pairs const tickBase = equalAngles?.length ?? 0 let dynamicGroupIdx = 0 for (const group of groups) { const color = BYRNE_CYCLE[dynamicGroupIdx % BYRNE_CYCLE.length] const tickCount = group.length >= 2 ? tickBase + dynamicGroupIdx + 1 : 0 dynamicGroupIdx++ for (const am of group) { const canonKey = angleMeasureKey(am) if (renderedAngles.has(canonKey)) continue // already drawn by static path const vertex = getPoint(state, am.vertex) const ray1 = getPoint(state, am.ray1) const ray2 = getPoint(state, am.ray2) if (!vertex || !ray1 || !ray2) continue const vs = toScreen(vertex.x, vertex.y, viewport, w, h) const r1s = toScreen(ray1.x, ray1.y, viewport, w, h) const r2s = toScreen(ray2.x, ray2.y, viewport, w, h) const a1 = angleToPoint(vs.sx, vs.sy, r1s.sx, r1s.sy) const a2 = angleToPoint(vs.sx, vs.sy, r2s.sx, r2s.sy) drawArc(ctx, vs.sx, vs.sy, a1, a2, color, tickCount, ARC_RADIUS_PX) renderedAngles.add(canonKey) } } } |