1import type { SquaringDocument } from './squaring'2import type { SquaringModel, SquaringNode, SquaringSquare } from './squaring-model'34const MARGIN = 165const PANEL_GAP = 446const PANEL_TITLE_HEIGHT = 267const CAPTION_HEIGHT = 448const BATTERY_GUTTER = 409const TARGET_SIZE = 48010const FONT_FAMILY = 'Arial,Helvetica,sans-serif'11const INK = '#111827'12const MUTED = '#666666'13const WIRE = '#1e293b'14const BLOCK = '#db2777'15export const BAND_COUNT = 816// Raw appearance uses the light palette; themed appearances swap these through the17// squaring-semantic normalization profile in shared/svg.18export const LIGHT_SQUARE_BANDS = ['#93c5fd', '#b3d1fb', '#cddaf2', '#e2e4e8', '#f1d6d2', '#fbbfba', '#fca5a5', '#f87171'] as const19export const LIGHT_NODE_BANDS = ['#2563eb', '#3b6fe0', '#4f74c8', '#6b7280', '#b45454', '#d13b3b', '#dc2626', '#b91c1c'] as const2021interface Panel {22 x: number23 y: number24 width: number25 height: number26}2728function escapeXml(value: string): string {29 return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')30}3132function round(value: number): string {33 return String(Math.round(value * 100) / 100)34}3536function band(t: number): number {37 const clamped = Math.min(1, Math.max(0, t))38 return Math.min(BAND_COUNT - 1, Math.floor(clamped * BAND_COUNT))39}4041function squareSvg(square: SquaringSquare, model: SquaringModel, panel: Panel, unit: number, labels: boolean, overlay: boolean): string {42 const x = panel.x + square.x * unit43 const y = panel.y + square.y * unit44 const size = square.side * unit45 const topVoltage = model.nodes[square.top]?.voltage ?? model.height - square.y46 const level = band((topVoltage - square.side / 2) / model.height)47 const fill = LIGHT_SQUARE_BANDS[level] ?? LIGHT_SQUARE_BANDS[0]48 const fontSize = Math.min(16, size * 0.42, (size * 1.6) / Math.max(1, square.label.length))49 // In the overlay the wire runs down the middle of the square, so the label moves beside it.50 const labelX = overlay ? x + size / 2 + 6 : x + size / 251 const label = labels && size >= 1052 ? `<text x="${round(labelX)}" y="${round(y + size / 2)}" text-anchor="${overlay ? 'start' : 'middle'}" dominant-baseline="central" class="squaring-square-label" fill="${INK}" font-size="${round(fontSize)}" font-family="${FONT_FAMILY}">${escapeXml(square.label)}</text>`53 : ''54 return `<g class="squaring-square" data-index="${square.index}" data-side="${square.side}" data-x="${square.x}" data-y="${square.y}"><title>Square ${square.side} at (${square.x}, ${square.y})</title><rect class="squaring-square-fill" data-band="${level}" x="${round(x)}" y="${round(y)}" width="${round(size)}" height="${round(size)}" fill="${fill}" stroke="${INK}" stroke-width="1"/>${label}</g>`55}5657function blocksSvg(model: SquaringModel, panel: Panel, unit: number): string {58 return model.blocks.map(block => {59 const inset = 260 return `<rect class="squaring-block" data-squares="${block.squares}" x="${round(panel.x + block.x * unit + inset)}" y="${round(panel.y + block.y * unit + inset)}" width="${round(block.width * unit - inset * 2)}" height="${round(block.height * unit - inset * 2)}" fill="none" stroke="${BLOCK}" stroke-width="2.5" stroke-dasharray="7 4"><title>Compound block ${block.width} × ${block.height} of ${block.squares} squares</title></rect>`61 }).join('')62}6364function nodeCenter(node: SquaringNode, panel: Panel, unit: number): { x: number; y: number } {65 return { x: panel.x + ((node.x0 + node.x1) / 2) * unit, y: panel.y + node.y * unit }66}6768function wireSvg(square: SquaringSquare, model: SquaringModel, panel: Panel, unit: number, maxSide: number, showCurrent: boolean): string {69 const stroke = WIRE70 const top = model.nodes[square.top]71 const bottom = model.nodes[square.bottom]72 if (top === undefined || bottom === undefined) return ''73 const a = nodeCenter(top, panel, unit)74 const b = nodeCenter(bottom, panel, unit)75 const cx = panel.x + (square.x + square.side / 2) * unit76 const size = square.side * unit77 const radius = Math.max(0, Math.min(8, size / 3, Math.abs(cx - a.x) / 2, Math.abs(cx - b.x) / 2))78 const width = 1 + 2.4 * (square.side / maxSide)79 const towardsA = a.x < cx ? -1 : 180 const towardsB = b.x < cx ? -1 : 181 const path = [82 `M${round(a.x)} ${round(a.y)}`,83 `L${round(cx + towardsA * radius)} ${round(a.y)}`,84 `Q${round(cx)} ${round(a.y)} ${round(cx)} ${round(a.y + radius)}`,85 `L${round(cx)} ${round(b.y - radius)}`,86 `Q${round(cx)} ${round(b.y)} ${round(cx + towardsB * radius)} ${round(b.y)}`,87 `L${round(b.x)} ${round(b.y)}`,88 ].join(' ')89 const midY = (a.y + b.y) / 290 const arrow = size >= 1491 ? `<path d="M${round(cx - 3.5)} ${round(midY - 3)} L${round(cx + 3.5)} ${round(midY - 3)} L${round(cx)} ${round(midY + 3.5)} Z" class="squaring-wire-arrow" fill="${stroke}"/>`92 : ''93 const label = showCurrent && size >= 1494 ? `<text x="${round(cx + 6)}" y="${round(midY - 7)}" text-anchor="start" dominant-baseline="central" class="squaring-wire-label" fill="${MUTED}" font-size="10" font-family="${FONT_FAMILY}">${square.side}</text>`95 : ''96 return `<g class="squaring-wire" data-from="${escapeXml(top.id)}" data-to="${escapeXml(bottom.id)}" data-current="${square.side}"><title>Wire ${escapeXml(top.id)} → ${escapeXml(bottom.id)} carries ${square.side}</title><path class="squaring-wire-path" d="${path}" fill="none" stroke="${stroke}" stroke-width="${round(width)}" stroke-linecap="round" stroke-linejoin="round"/>${arrow}${label}</g>`97}9899function nodeSvg(node: SquaringNode, model: SquaringModel, panel: Panel, unit: number, rung: boolean, showVoltage: boolean): string {100 const center = nodeCenter(node, panel, unit)101 const level = band(node.voltage / model.height)102 const color = LIGHT_NODE_BANDS[level] ?? LIGHT_NODE_BANDS[0]103 const rungLine = rung104 ? `<line class="squaring-node-rung" data-band="${level}" x1="${round(panel.x + node.x0 * unit)}" y1="${round(center.y)}" x2="${round(panel.x + node.x1 * unit)}" y2="${round(center.y)}" stroke="${color}" stroke-width="3" stroke-linecap="round"/>`105 : ''106 const pole = node.index === model.positive ? '+' : node.index === model.negative ? '−' : ''107 // Wires leave every node horizontally, so labels sit above or below the rung instead of on it.108 const labelX = pole === '' ? center.x + 7 : center.x109 const labelY = node.index === model.negative ? center.y + 13 : center.y - 10110 const anchor = pole === '' ? 'start' : 'middle'111 const label = showVoltage112 ? `<text x="${round(labelX)}" y="${round(labelY)}" text-anchor="${anchor}" dominant-baseline="central" class="squaring-node-label" fill="${INK}" font-size="11" font-weight="700" font-family="${FONT_FAMILY}">${pole === '' ? '' : `${pole} `}${node.voltage}</text>`113 : ''114 return `<g class="squaring-node" data-node-id="${escapeXml(node.id)}" data-voltage="${node.voltage}"><title>Node ${escapeXml(node.id)} at ${node.voltage}</title>${rungLine}<circle class="squaring-node-dot" data-band="${level}" cx="${round(center.x)}" cy="${round(center.y)}" r="5.5" fill="${color}"/>${label}</g>`115}116117function batterySvg(model: SquaringModel, panel: Panel, unit: number): string {118 const positive = model.nodes[model.positive]119 const negative = model.nodes[model.negative]120 if (positive === undefined || negative === undefined) return ''121 const a = nodeCenter(positive, panel, unit)122 const b = nodeCenter(negative, panel, unit)123 const gx = panel.x - BATTERY_GUTTER / 2 - 4124 const midY = (a.y + b.y) / 2125 return `<g class="squaring-battery"><title>Battery: ${escapeXml(positive.id)} is the positive pole at ${positive.voltage}, ${escapeXml(negative.id)} is the negative pole at 0</title>` +126 `<path d="M${round(a.x)} ${round(a.y)} L${round(gx)} ${round(a.y)} L${round(gx)} ${round(midY - 7)} M${round(gx)} ${round(midY + 7)} L${round(gx)} ${round(b.y)} L${round(b.x)} ${round(b.y)}" class="squaring-wire-path" fill="none" stroke="${WIRE}" stroke-width="1.5" stroke-dasharray="4 3"/>` +127 `<line class="squaring-wire-path" x1="${round(gx - 9)}" y1="${round(midY - 4)}" x2="${round(gx + 9)}" y2="${round(midY - 4)}" stroke="${WIRE}" stroke-width="2.5"/>` +128 `<line class="squaring-wire-path" x1="${round(gx - 4)}" y1="${round(midY + 4)}" x2="${round(gx + 4)}" y2="${round(midY + 4)}" stroke="${WIRE}" stroke-width="2.5"/>` +129 `<text class="squaring-node-label" x="${round(gx - 12)}" y="${round(midY - 8)}" text-anchor="end" dominant-baseline="central" fill="${INK}" font-size="12" font-weight="700" font-family="${FONT_FAMILY}">+</text>` +130 `<text class="squaring-node-label" x="${round(gx - 12)}" y="${round(midY + 9)}" text-anchor="end" dominant-baseline="central" fill="${INK}" font-size="12" font-weight="700" font-family="${FONT_FAMILY}">−</text>` +131 `</g>`132}133134function panelTitle(text: string, panel: Panel): string {135 return `<text class="squaring-panel-title" x="${round(panel.x)}" y="${round(panel.y - 9)}" fill="${MUTED}" font-size="12" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(text)}</text>`136}137138function frame(panel: Panel): string {139 return `<rect class="squaring-frame" x="${round(panel.x)}" y="${round(panel.y)}" width="${round(panel.width)}" height="${round(panel.height)}" fill="none" stroke="${INK}" stroke-width="1.5"/>`140}141142function rectanglePanel(model: SquaringModel, panel: Panel, unit: number, labels: boolean, overlay = false): string {143 const squares = model.squares.map(square => squareSvg(square, model, panel, unit, labels, overlay)).join('')144 const gaps = model.complete145 ? ''146 : `<rect class="squaring-gap" x="${round(panel.x)}" y="${round(panel.y)}" width="${round(panel.width)}" height="${round(panel.height)}" fill="url(#squaring-hatch)"/>`147 return `<g class="squaring-rectangle">${gaps}${squares}${blocksSvg(model, panel, unit)}${frame(panel)}</g>`148}149150function searchTableSvg(model: SquaringModel, x: number, y: number, width: number): { svg: string; height: number } {151 if (model.search === null) return { svg: '', height: 0 }152 const rowHeight = 15153 const maxRows = 24154 const rows = model.search.slice(0, maxRows)155 const columns = [0, 110, 200, 270, 340]156 const cell = (text: string, column: number, rowY: number, bold = false, muted = false): string =>157 `<text x="${round(x + (columns[column] ?? 0))}" y="${round(rowY)}" class="${muted ? 'squaring-summary' : 'squaring-node-label'}" fill="${muted ? MUTED : INK}" font-size="11"${bold ? ' font-weight="700"' : ''} font-family="${FONT_FAMILY}">${escapeXml(text)}</text>`158 const header = ['Battery', 'Size', 'Order', 'Simple', 'Perfect'].map((text, column) => cell(text, column, y + 12, true, true)).join('')159 const body = rows.map((candidate, index) => {160 const rowY = y + 12 + rowHeight * (index + 1)161 const chosen = index === model.searchIndex162 const name = `${candidate.positive} → ${candidate.negative}${chosen ? ' ◀' : ''}`163 if (candidate.error !== null) {164 return cell(name, 0, rowY, chosen) + `<text x="${round(x + 110)}" y="${round(rowY)}" class="squaring-summary" fill="${MUTED}" font-size="11" font-family="${FONT_FAMILY}">${escapeXml(candidate.error.length > 70 ? `${candidate.error.slice(0, 67)}…` : candidate.error)}</text>`165 }166 return cell(name, 0, rowY, chosen) + cell(`${candidate.width} × ${candidate.height}`, 1, rowY, chosen) + cell(String(candidate.order), 2, rowY, chosen) + cell(candidate.simple ? 'yes' : 'no', 3, rowY, chosen) + cell(candidate.perfect ? 'yes' : 'no', 4, rowY, chosen)167 }).join('')168 const more = model.search.length > maxRows ? cell(`+${model.search.length - maxRows} more`, 0, y + 12 + rowHeight * (rows.length + 1), false, true) : ''169 const height = 12 + rowHeight * (rows.length + 1 + (more === '' ? 0 : 1)) + 6170 return { svg: `<g class="squaring-search" data-candidates="${model.search.length}"><line x1="${round(x)}" y1="${round(y + 17)}" x2="${round(x + Math.min(width, 420))}" y2="${round(y + 17)}" stroke="${MUTED}" stroke-width="0.5" class="squaring-frame"/>${header}${body}${more}</g>`, height }171}172173function circuitPanel(model: SquaringModel, panel: Panel, unit: number, overlay: boolean, labels: boolean): string {174 const maxSide = Math.max(...model.squares.map(square => square.side))175 const wires = model.squares.map(square => wireSvg(square, model, panel, unit, maxSide, labels && !overlay)).join('')176 const nodes = model.nodes.map(node => nodeSvg(node, model, panel, unit, overlay, labels && !overlay)).join('')177 return `<g class="squaring-circuit" data-overlay="${overlay}">${batterySvg(model, panel, unit)}${wires}${nodes}</g>`178}179180function defaultTitle(document: SquaringDocument, model: SquaringModel): string {181 if (document.title !== '') return document.title182 return model.width === model.height ? `Squared square ${model.width} × ${model.height}` : `Squared rectangle ${model.width} × ${model.height}`183}184185export function describeSquaring(model: SquaringModel): string {186 const parts = [`Order ${model.order}`, `${model.width} × ${model.height}`]187 if (!model.complete) {188 parts.push(`incomplete: ${model.problem ?? ''}`)189 return parts.join(' | ')190 }191 parts.push(model.simple ? 'simple' : `compound (${model.blocks.length} ${model.blocks.length === 1 ? 'block' : 'blocks'} outlined)`)192 parts.push(model.perfect ? 'perfect' : `imperfect (${model.repeatedSides.length} repeated ${model.repeatedSides.length === 1 ? 'side' : 'sides'}: ${model.repeatedSides.join(', ')})`)193 if (model.form === 'network') {194 const positive = model.nodes[model.positive]?.id ?? ''195 const negative = model.nodes[model.negative]?.id ?? ''196 parts.push(model.search === null ? `battery ${positive} → ${negative}` : `battery ${positive} → ${negative} (best of ${model.search.length} choices)`)197 }198 return parts.join(' | ')199}200201export function renderSquaring(document: SquaringDocument, model: SquaringModel): string {202 const unit = Math.min(60, Math.max(0.5, TARGET_SIZE / Math.max(model.width, model.height)))203 const labels = document.labels === 'sides'204 const view = model.complete ? document.view : 'rectangle'205 const panelWidth = model.width * unit206 const panelHeight = model.height * unit207 const top = MARGIN + PANEL_TITLE_HEIGHT208 const panels: string[] = []209 let x = MARGIN210211 if (view === 'rectangle' || view === 'both') {212 const panel = { x, y: top, width: panelWidth, height: panelHeight }213 panels.push(panelTitle('Squared rectangle', panel), rectanglePanel(model, panel, unit, labels))214 x += panelWidth + PANEL_GAP215 }216 if (view === 'circuit' || view === 'both') {217 x += BATTERY_GUTTER218 const panel = { x, y: top, width: panelWidth, height: panelHeight }219 panels.push(panelTitle('Smith diagram', panel), circuitPanel(model, panel, unit, false, labels))220 x += panelWidth221 }222 if (view === 'overlay') {223 x += BATTERY_GUTTER224 const panel = { x, y: top, width: panelWidth, height: panelHeight }225 panels.push(panelTitle('Smith diagram over the squared rectangle', panel), rectanglePanel(model, panel, unit, labels, true), circuitPanel(model, panel, unit, true, labels))226 x += panelWidth227 }228229 const title = defaultTitle(document, model)230 const summary = describeSquaring(model)231 const captionY = top + panelHeight + 20232 const table = searchTableSvg(model, MARGIN, captionY + 28, Math.max(x - MARGIN, 320))233 const width = Math.max(x + MARGIN, 320, model.search === null ? 0 : 420 + MARGIN * 2, MARGIN * 2 + summary.length * 6.6)234 const height = top + panelHeight + CAPTION_HEIGHT + table.height + MARGIN235 const hatch = model.complete ? '' : `<defs><pattern id="squaring-hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line class="squaring-frame" x1="0" y1="0" x2="0" y2="8" stroke="${MUTED}" stroke-width="1.5"/></pattern></defs>`236237 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${round(width)} ${round(height)}" width="${round(width)}" height="${round(height)}" class="squaring-diagram" data-view="${view}" data-form="${model.form}" data-order="${model.order}" data-width="${model.width}" data-height="${model.height}" data-complete="${model.complete}" data-simple="${model.simple}" data-perfect="${model.perfect}">238 <title>${escapeXml(title)}</title>239 <desc>${escapeXml(summary)}. Each square is a wire carrying current equal to its side; each horizontal segment is a node whose voltage is its height above the bottom edge.</desc>240 ${hatch}<rect class="squaring-canvas" x="0" y="0" width="${round(width)}" height="${round(height)}" fill="#ffffff"/>241 ${panels.join('\n ')}242 <text class="squaring-title" x="${MARGIN}" y="${round(captionY)}" fill="${INK}" font-size="14" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(title)}</text>243 <text class="squaring-summary" x="${MARGIN}" y="${round(captionY + 17)}" fill="${MUTED}" font-size="12" font-family="${FONT_FAMILY}">${escapeXml(summary)}</text>244 ${table.svg}245</svg>`246}