clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / renderers / edge / src / languages / trn-renderer.ts
trn-renderer.ts 27.2K
1import type { TrnDocument, TrnInput, TrnOutcome, TrnStage } from './trn'23const MIN_ROW_HEIGHT = 284const MIN_LABEL_WIDTH = 2105const MAX_LABEL_WIDTH = 3906const OPERATION_WIDTH = 347const OPERATION_FONT_SIZE = 108const OPERATION_TEXT_PADDING = 149const INSTRUCTION_FONT_SIZE = 1210const INSTRUCTION_LINE_HEIGHT = 1611const INSTRUCTION_HORIZONTAL_PADDING = 1812const INSTRUCTION_VERTICAL_PADDING = 1013const MARGIN = 1214const HEADER_HEIGHT = 2815const ROOT_GAP = 116const RECIPE_GAP = 2017const RECIPE_COLUMN_GAP = 6418const RECIPE_LINK_CLEARANCE = 419const FONT_FAMILY = 'Arial,Helvetica,sans-serif'20const GRID_COLOR = '#65a268'21const BRANCH_COLORS = ['#dbeafe', '#fef3c7', '#ede9fe', '#ccfbf1'] as const2223interface LeafLayout {24 row: number25 input: TrnInput26 label: string27}2829interface StageLayout {30 outcome: TrnOutcome31 stage: TrnStage32 column: number33 startRow: number34 endRow: number35 arms: Array<{ startColumn: number; endColumn: number; startRow: number; endRow: number }>36 branchColor: number37}3839interface Layout {40 leaves: LeafLayout[]41 stages: StageLayout[]42 rows: number43 columns: number44}4546interface Geometry {47 labelWidth: number48 columnWidths: number[]49 columnOffsets: number[]50 rowHeights: number[]51 rowOffsets: number[]52 bodyHeight: number53 tableWidth: number54}5556interface InstructionLayout {57 items: Array<{ lines: string[]; height: number; offset: number }>58 height: number59}6061function escapeXml(value: string): string {62 return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')63}6465function textWidth(value: string, fontSize: number, padding: number): number {66 return value.length * fontSize * 0.56 + padding67}6869function breakWord(value: string, maximumWidth: number, fontSize: number): string[] {70 const characters = Array.from(value)71 const lines: string[] = []72 let current = ''73 for (const character of characters) {74 const candidate = current + character75 if (current !== '' && textWidth(candidate, fontSize, 0) > maximumWidth) {76 lines.push(current)77 current = character78 } else {79 current = candidate80 }81 }82 if (current !== '') lines.push(current)83 return lines84}8586function wrapText(value: string, maximumWidth: number, fontSize: number): string[] {87 const words = value.trim().split(/\s+/).filter(Boolean)88 if (words.length === 0) return ['']89 const lines: string[] = []90 let current = ''9192 for (const word of words) {93 const candidate = current === '' ? word : `${current} ${word}`94 if (textWidth(candidate, fontSize, 0) <= maximumWidth) {95 current = candidate96 continue97 }98 if (current !== '') {99 lines.push(current)100 current = ''101 }102 if (textWidth(word, fontSize, 0) <= maximumWidth) {103 current = word104 continue105 }106 const parts = breakWord(word, maximumWidth, fontSize)107 lines.push(...parts.slice(0, -1))108 current = parts.at(-1) ?? ''109 }110111 if (current !== '') lines.push(current)112 return lines113}114115function layoutInstructions(document: TrnDocument, tableWidth: number): InstructionLayout {116 const maximumWidth = Math.max(1, tableWidth - INSTRUCTION_HORIZONTAL_PADDING)117 const items: InstructionLayout['items'] = []118 let offset = 0119 for (const instruction of document.instructions) {120 const lines = wrapText(instruction.text, maximumWidth, INSTRUCTION_FONT_SIZE)121 const height = Math.max(MIN_ROW_HEIGHT, lines.length * INSTRUCTION_LINE_HEIGHT + INSTRUCTION_VERTICAL_PADDING)122 items.push({ lines, height, offset })123 offset += height124 }125 return { items, height: offset }126}127128function clamp(value: number, minimum: number, maximum: number): number {129 return Math.max(minimum, Math.min(maximum, value))130}131132function rowY(row: number, bodyY: number, geometry: Geometry): number {133 return bodyY + geometry.rowOffsets[row]!134}135136function ingredientText(leaf: LeafLayout): string {137 return leaf.input.quantity === '' ? leaf.label : `${leaf.input.quantity} ${leaf.label}`138}139140function actionText(item: StageLayout): string {141 return item.stage.action.replaceAll('{portion}', item.outcome.portion)142}143144function fifoInputs(document: TrnDocument, inputs: TrnInput[]): TrnInput[] {145 const outcomes: TrnInput[] = []146 const ingredients: TrnInput[] = []147 for (const input of inputs) {148 (document.outcomes.has(input.id) ? outcomes : ingredients).push(input)149 }150 return [...outcomes, ...ingredients]151}152153function layoutTree(document: TrnDocument): Layout {154 const layout: Layout = { leaves: [], stages: [], rows: 0, columns: 0 }155 let nextRow = 0156157 function leaf(input: TrnInput): { startRow: number; endRow: number; column: number } {158 const ingredient = document.ingredients.get(input.id)!159 const row = nextRow160 nextRow += 1161 layout.leaves.push({ row, input, label: ingredient.label })162 return { startRow: row, endRow: row, column: 0 }163 }164165 function outcome(value: TrnOutcome): { startRow: number; endRow: number; column: number } {166 let startRow = -1167 let endRow = -1168 let previousColumn = 0169170 for (let stageIndex = 0; stageIndex < value.stages.length; stageIndex += 1) {171 const stage = value.stages[stageIndex]!172 let dependencyColumn = previousColumn173 const inputs: Array<{ startRow: number; endRow: number; column: number }> = []174175 for (const input of fifoInputs(document, stage.inputs)) {176 const child = document.outcomes.get(input.id)177 const placed = child === undefined ? leaf(input) : outcome(child)178 inputs.push(placed)179 if (startRow < 0) startRow = placed.startRow180 endRow = Math.max(endRow, placed.endRow)181 dependencyColumn = Math.max(dependencyColumn, placed.column)182 }183184 const column = dependencyColumn + 1185 const arms: StageLayout['arms'] = []186 for (const input of inputs) {187 if (input.column + 1 < column) {188 arms.push({189 startColumn: input.column + 1,190 endColumn: column - 1,191 startRow: input.startRow,192 endRow: input.endRow,193 })194 }195 }196 layout.stages.push({ outcome: value, stage, column, startRow, endRow, arms, branchColor: 0 })197 previousColumn = column198 layout.columns = Math.max(layout.columns, column)199 }200201 return { startRow, endRow, column: previousColumn }202 }203204 for (const root of document.roots) {205 outcome(root)206 nextRow += ROOT_GAP207 }208 layout.rows = Math.max(1, nextRow - ROOT_GAP)209 assignBranchColors(layout.stages)210 return layout211}212213function layoutOutcome(document: TrnDocument, outcome: TrnOutcome): Layout {214 const layout: Layout = { leaves: [], stages: [], rows: 0, columns: 0 }215 let nextRow = 0216 let startRow = -1217 let endRow = -1218 let previousColumn = 0219220 for (const stage of outcome.stages) {221 const inputs: Array<{ startRow: number; endRow: number; column: number }> = []222 for (const input of stage.inputs) {223 const value = document.ingredients.get(input.id) ?? document.outcomes.get(input.id)!224 const row = nextRow225 nextRow += 1226 layout.leaves.push({ row, input, label: value.label })227 inputs.push({ startRow: row, endRow: row, column: 0 })228 if (startRow < 0) startRow = row229 endRow = row230 }231232 const column = previousColumn + 1233 const arms: StageLayout['arms'] = inputs234 .filter(input => input.column + 1 < column)235 .map(input => ({236 startColumn: input.column + 1,237 endColumn: column - 1,238 startRow: input.startRow,239 endRow: input.endRow,240 }))241 layout.stages.push({ outcome, stage, column, startRow, endRow, arms, branchColor: 0 })242 previousColumn = column243 layout.columns = column244 }245246 layout.rows = Math.max(1, nextRow)247 assignBranchColors(layout.stages)248 return layout249}250251function outcomesInDependencyOrder(document: TrnDocument): TrnOutcome[] {252 const ordered: TrnOutcome[] = []253 const visited = new Set<string>()254 function visit(outcome: TrnOutcome): void {255 if (visited.has(outcome.id)) return256 visited.add(outcome.id)257 for (const stage of outcome.stages) {258 for (const input of stage.inputs) {259 const child = document.outcomes.get(input.id)260 if (child !== undefined) visit(child)261 }262 }263 ordered.push(outcome)264 }265 for (const root of document.roots) visit(root)266 return ordered267}268269function stageCells(item: StageLayout): Set<string> {270 const cells = new Set<string>()271 for (let row = item.startRow; row <= item.endRow; row += 1) cells.add(`${item.column}:${row}`)272 for (const arm of item.arms) {273 for (let column = arm.startColumn; column <= arm.endColumn; column += 1) {274 for (let row = arm.startRow; row <= arm.endRow; row += 1) cells.add(`${column}:${row}`)275 }276 }277 return cells278}279280function zonesTouch(left: Set<string>, right: Set<string>): boolean {281 for (const value of left) {282 const [column, row] = value.split(':').map(Number) as [number, number]283 if (right.has(`${column - 1}:${row}`) || right.has(`${column + 1}:${row}`)284 || right.has(`${column}:${row - 1}`) || right.has(`${column}:${row + 1}`)) return true285 }286 return false287}288289function assignBranchColors(stages: StageLayout[]): void {290 const cells = stages.map(stageCells)291 const neighbors = stages.map(() => new Set<number>())292 for (let left = 0; left < stages.length; left += 1) {293 for (let right = left + 1; right < stages.length; right += 1) {294 if (!zonesTouch(cells[left]!, cells[right]!)) continue295 neighbors[left]!.add(right)296 neighbors[right]!.add(left)297 }298 }299300 const colors = Array.from({ length: stages.length }, () => -1)301 function colorNext(): boolean {302 let selected = -1303 let selectedSaturation = -1304 let selectedDegree = -1305 for (let index = 0; index < stages.length; index += 1) {306 if (colors[index] !== -1) continue307 const saturation = new Set([...neighbors[index]!].map(neighbor => colors[neighbor]!).filter(color => color >= 0)).size308 const degree = neighbors[index]!.size309 if (saturation > selectedSaturation || (saturation === selectedSaturation && degree > selectedDegree)) {310 selected = index311 selectedSaturation = saturation312 selectedDegree = degree313 }314 }315 if (selected === -1) return true316 const unavailable = new Set([...neighbors[selected]!].map(neighbor => colors[neighbor]!).filter(color => color >= 0))317 for (let color = 0; color < BRANCH_COLORS.length; color += 1) {318 if (unavailable.has(color)) continue319 colors[selected] = color320 if (colorNext()) return true321 colors[selected] = -1322 }323 return false324 }325326 if (!colorNext()) throw new Error('TRN relationship zones could not be colored without adjacent matches.')327 stages.forEach((stage, index) => { stage.branchColor = colors[index]! })328}329330function geometryFor(layout: Layout): Geometry {331 const longestIngredient = Math.max(...layout.leaves.map(leaf => textWidth(ingredientText(leaf), 12, 18)))332 const labelWidth = Math.ceil(clamp(longestIngredient, MIN_LABEL_WIDTH, MAX_LABEL_WIDTH))333 const columnWidths = Array.from({ length: layout.columns }, () => OPERATION_WIDTH)334 const columnOffsets: number[] = []335 let offset = labelWidth336 for (const width of columnWidths) {337 columnOffsets.push(offset)338 offset += width339 }340 const rowHeights = Array.from({ length: layout.rows }, () => MIN_ROW_HEIGHT)341 const stagesBySpan = [...layout.stages].sort((left, right) =>342 (left.endRow - left.startRow) - (right.endRow - right.startRow))343 for (const item of stagesBySpan) {344 const requiredHeight = Math.ceil(textWidth(actionText(item), OPERATION_FONT_SIZE, OPERATION_TEXT_PADDING))345 const rows = item.endRow - item.startRow + 1346 const availableHeight = rowHeights.slice(item.startRow, item.endRow + 1)347 .reduce((total, height) => total + height, 0)348 let deficit = Math.max(0, requiredHeight - availableHeight)349 for (let row = item.startRow; row <= item.endRow && deficit > 0; row += 1) {350 const rowsLeft = item.endRow - row + 1351 const addition = Math.ceil(deficit / rowsLeft)352 rowHeights[row]! += addition353 deficit -= addition354 }355 }356 const rowOffsets: number[] = []357 let bodyHeight = 0358 for (const height of rowHeights) {359 rowOffsets.push(bodyHeight)360 bodyHeight += height361 }362 return { labelWidth, columnWidths, columnOffsets, rowHeights, rowOffsets, bodyHeight, tableWidth: offset }363}364365function leafSvg(366 leaf: LeafLayout,367 geometry: Geometry,368 bodyY: number,369): string {370 const text = ingredientText(leaf)371 const cellX = MARGIN372 const height = geometry.rowHeights[leaf.row]!373 const top = rowY(leaf.row, bodyY, geometry)374 const centerY = top + height / 2375 const x = cellX + 7376 const y = centerY + 4377 const fontSize = 12378 const available = geometry.labelWidth - 14379 const length = Math.max(1, Math.floor(available / (fontSize * 0.56)))380 const shown = text.length <= length ? text : `${text.slice(0, length - 1)}…`381 return `<g class="trn-ingredient" data-value-id="${escapeXml(leaf.input.id)}"><title>${escapeXml(text)}</title><rect class="trn-ingredient-cell" x="${cellX}" y="${top}" width="${geometry.labelWidth}" height="${height}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/><text class="trn-ingredient-label" x="${x}" y="${y}" text-anchor="start" dominant-baseline="auto" fill="#111827" font-size="${fontSize}" font-family="${FONT_FAMILY}">${escapeXml(shown)}</text></g>`382}383384function stageSvg(385 item: StageLayout,386 geometry: Geometry,387 bodyY: number,388): string {389 const width = geometry.columnWidths[item.column - 1]!390 const columnLeft = (column: number): number => {391 const offset = geometry.columnOffsets[column - 1]!392 return MARGIN + offset393 }394 const columnRight = (column: number): number => columnLeft(column) + geometry.columnWidths[column - 1]!395 const x = columnLeft(item.column)396 const y = rowY(item.startRow, bodyY, geometry)397 const height = geometry.rowOffsets[item.endRow]! + geometry.rowHeights[item.endRow]! - geometry.rowOffsets[item.startRow]!398 const result = item.outcome.portion === '' ? item.outcome.label : `${item.outcome.label} (${item.outcome.portion})`399 const action = actionText(item)400 const cells = stageCells(item)401 const edges: Array<{ start: [number, number]; end: [number, number] }> = []402 for (const value of cells) {403 const [column, row] = value.split(':').map(Number) as [number, number]404 const left = columnLeft(column)405 const right = columnRight(column)406 const top = rowY(row, bodyY, geometry)407 const bottom = top + geometry.rowHeights[row]!408 if (!cells.has(`${column}:${row - 1}`)) edges.push({ start: [left, top], end: [right, top] })409 const rightNeighbor = column + 1410 const leftNeighbor = column - 1411 if (!cells.has(`${rightNeighbor}:${row}`)) edges.push({ start: [right, top], end: [right, bottom] })412 if (!cells.has(`${column}:${row + 1}`)) edges.push({ start: [right, bottom], end: [left, bottom] })413 if (!cells.has(`${leftNeighbor}:${row}`)) edges.push({ start: [left, bottom], end: [left, top] })414 }415 const pointKey = (point: [number, number]): string => `${point[0]}:${point[1]}`416 const edgesByStart = new Map<string, number[]>()417 edges.forEach((edge, index) => {418 const key = pointKey(edge.start)419 const indexes = edgesByStart.get(key) ?? []420 indexes.push(index)421 edgesByStart.set(key, indexes)422 })423 const usedEdges = new Set<number>()424 const paths: string[] = []425 for (let index = 0; index < edges.length; index += 1) {426 if (usedEdges.has(index)) continue427 const first = edges[index]!428 usedEdges.add(index)429 const points: Array<[number, number]> = [first.start, first.end]430 let current = first.end431 while (pointKey(current) !== pointKey(first.start)) {432 const nextIndex = (edgesByStart.get(pointKey(current)) ?? []).find(candidate => !usedEdges.has(candidate))433 if (nextIndex === undefined) throw new Error('TRN relationship zone has an open boundary.')434 usedEdges.add(nextIndex)435 current = edges[nextIndex]!.end436 points.push(current)437 }438 paths.push(`M${points.map(point => `${point[0]} ${point[1]}`).join('L')}Z`)439 }440 const zonePath = paths.join('')441 const labelX = x + width / 2442 const labelY = y + height / 2443 const transform = ` transform="rotate(90 ${labelX} ${labelY})"`444 return [445 `<g class="trn-operation-zone" data-outcome-id="${escapeXml(item.outcome.id)}" data-column="${item.column}" data-cell-count="${cells.size}" data-arm-count="${item.arms.length}" data-branch-color="${item.branchColor + 1}">`,446 `<title>${escapeXml(action)} → ${escapeXml(result)}</title>`,447 `<path class="trn-zone-shape trn-branch-${item.branchColor + 1}" d="${zonePath}" fill="${BRANCH_COLORS[item.branchColor]}" stroke="${GRID_COLOR}" stroke-width="1"/>`,448 `<text class="trn-operation-label" x="${labelX}" y="${labelY}" text-anchor="middle" dominant-baseline="middle" fill="#111827" font-size="${OPERATION_FONT_SIZE}" font-family="${FONT_FAMILY}"${transform}>${escapeXml(action)}</text>`,449 '</g>',450 ].join('')451}452453function instructionSvg(454 document: TrnDocument,455 layout: InstructionLayout,456 tableY: number,457 tableWidth: number,458): string {459 return document.instructions.map((instruction, index) => {460 const item = layout.items[index]!461 const y = tableY + item.offset462 const firstBaseline = y + (item.height - item.lines.length * INSTRUCTION_LINE_HEIGHT) / 2 + INSTRUCTION_FONT_SIZE463 const x = MARGIN + tableWidth / 2464 const lines = item.lines.map((line, lineIndex) =>465 `<tspan class="trn-instruction-line" x="${x}" y="${firstBaseline + lineIndex * INSTRUCTION_LINE_HEIGHT}">${escapeXml(line)}</tspan>`).join('')466 return `<g class="trn-instruction"><title>${escapeXml(instruction.text)}</title><rect class="trn-instruction-cell" x="${MARGIN}" y="${y}" width="${tableWidth}" height="${item.height}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/><text class="trn-instruction-label" text-anchor="middle" fill="#111827" font-size="${INSTRUCTION_FONT_SIZE}" font-family="${FONT_FAMILY}">${lines}</text></g>`467 }).join('')468}469470function renderCombinedTrn(document: TrnDocument): string {471 const layout = layoutTree(document)472 const geometry = geometryFor(layout)473 const tableY = MARGIN + HEADER_HEIGHT474 const instructionLayout = layoutInstructions(document, geometry.tableWidth)475 const instructionHeight = instructionLayout.height476 const bodyY = tableY + instructionHeight477 const width = MARGIN * 2 + geometry.tableWidth478 const height = MARGIN * 2 + HEADER_HEIGHT + instructionHeight + geometry.bodyHeight479 const title = document.roots.map(root => root.portion === '' ? root.label : `${root.label} (yields ${root.portion})`).join(' + ')480 const instructions = instructionSvg(document, instructionLayout, tableY, geometry.tableWidth)481 const leaves = layout.leaves.map(item => leafSvg(item, geometry, bodyY)).join('')482 const stages = layout.stages.map(item => stageSvg(item, geometry, bodyY)).join('')483484 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" class="trn-diagram trn-layout-combined" data-layout="combined">485 <title>${escapeXml(title)} TRN</title>486 <desc>Tabular Recipe Notation with ingredients in rows and operations spanning the rows they combine.</desc>487 <rect class="trn-canvas" data-dz-role="canvas" x="0" y="0" width="${width}" height="${height}" fill="#fbf8dc"/>488 <text class="trn-title" x="${MARGIN + 7}" y="${MARGIN + 19}" fill="#111827" font-size="14" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(title)}</text>489 <rect class="trn-table-surface" x="${MARGIN}" y="${tableY}" width="${geometry.tableWidth}" height="${instructionHeight + geometry.bodyHeight}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/>490 ${instructions}491 ${leaves}492 ${stages}493</svg>`494}495496function renderIndividualTrn(document: TrnDocument): string {497 const title = document.roots.map(root => root.portion === '' ? root.label : `${root.label} (yields ${root.portion})`).join(' + ')498 const recipes = outcomesInDependencyOrder(document).map(outcome => {499 const layout = layoutOutcome(document, outcome)500 const geometry = geometryFor(layout)501 return { outcome, layout, geometry, height: HEADER_HEIGHT + geometry.bodyHeight }502 })503 const recipeById = new Map(recipes.map(recipe => [recipe.outcome.id, recipe]))504 const dependencies = new Map(recipes.map(recipe => [505 recipe.outcome.id,506 [...new Set(recipe.outcome.stages.flatMap(stage => stage.inputs.map(input => input.id)))]507 .map(id => document.outcomes.get(id))508 .filter((outcome): outcome is TrnOutcome => outcome !== undefined),509 ]))510 const depths = new Map<string, number>()511 function depthOf(outcome: TrnOutcome): number {512 const known = depths.get(outcome.id)513 if (known !== undefined) return known514 const children = dependencies.get(outcome.id) ?? []515 const depth = children.length === 0 ? 0 : Math.max(...children.map(depthOf)) + 1516 depths.set(outcome.id, depth)517 return depth518 }519 const maximumDepth = Math.max(...recipes.map(recipe => depthOf(recipe.outcome)))520 const columnWidths = Array.from({ length: maximumDepth + 1 }, (_, depth) =>521 Math.max(...recipes.filter(recipe => depthOf(recipe.outcome) === depth).map(recipe => recipe.geometry.tableWidth)))522 const columnOffsets: number[] = []523 let columnOffset = 0524 for (const columnWidth of columnWidths) {525 columnOffsets.push(columnOffset)526 columnOffset += columnWidth + RECIPE_COLUMN_GAP527 }528 const centers = new Map<string, number>()529 for (let depth = 0; depth <= maximumDepth; depth += 1) {530 const column = recipes.filter(recipe => depthOf(recipe.outcome) === depth)531 .map(recipe => {532 const children = dependencies.get(recipe.outcome.id) ?? []533 const target = children.length === 0534 ? Number.POSITIVE_INFINITY535 : children.reduce((total, child) => total + centers.get(child.id)!, 0) / children.length536 return { recipe, target }537 })538 if (depth === 0) {539 let cursor = 0540 for (const item of column) {541 centers.set(item.recipe.outcome.id, cursor + item.recipe.height / 2)542 cursor += item.recipe.height + RECIPE_GAP543 }544 continue545 }546 column.sort((left, right) => left.target - right.target)547 let bottom = 0548 for (const item of column) {549 const center = Math.max(item.target, bottom + item.recipe.height / 2)550 centers.set(item.recipe.outcome.id, center)551 bottom = center + item.recipe.height / 2 + RECIPE_GAP552 }553 }554 const treeHeight = Math.max(...recipes.map(recipe => centers.get(recipe.outcome.id)! + recipe.height / 2))555 const width = columnOffset - RECIPE_COLUMN_GAP + MARGIN * 2556 const instructionWidth = width - MARGIN * 2557 const instructionLayout = layoutInstructions(document, instructionWidth)558 const instructionHeight = instructionLayout.height559 const instructionY = MARGIN + HEADER_HEIGHT560 const treeBaseY = instructionY + instructionHeight + (instructionHeight > 0 ? RECIPE_GAP : 0)561 const dependencyLinks = recipes.flatMap(parent => (dependencies.get(parent.outcome.id) ?? []).map(child => ({ parent, child })))562 const treeY = treeBaseY563 const instructions = instructionSvg(document, instructionLayout, instructionY, instructionWidth)564 const recipeRects = recipes.map(recipe => {565 const depth = depthOf(recipe.outcome)566 const top = treeY + centers.get(recipe.outcome.id)! - recipe.height / 2567 return {568 id: recipe.outcome.id,569 left: columnOffsets[depth]! + MARGIN - RECIPE_LINK_CLEARANCE,570 right: columnOffsets[depth]! + MARGIN + recipe.geometry.tableWidth + RECIPE_LINK_CLEARANCE,571 top: top - RECIPE_LINK_CLEARANCE,572 bottom: top + recipe.height + RECIPE_LINK_CLEARANCE,573 }574 })575576 const recipeSvg = recipes.map(recipe => {577 const depth = depthOf(recipe.outcome)578 const recipeX = columnOffsets[depth]!579 const recipeY = Math.round(centers.get(recipe.outcome.id)! - recipe.height / 2)580 const tableY = HEADER_HEIGHT581 const bodyY = tableY582 const recipeTitle = recipe.outcome.portion === ''583 ? recipe.outcome.label584 : `${recipe.outcome.label} (${recipe.outcome.portion})`585 const leaves = recipe.layout.leaves.map(item => leafSvg(item, recipe.geometry, bodyY)).join('')586 const stages = recipe.layout.stages.map(item => stageSvg(item, recipe.geometry, bodyY)).join('')587 return `<g class="trn-individual-recipe" data-recipe-id="${escapeXml(recipe.outcome.id)}" data-depth="${depth}" transform="translate(${recipeX} ${treeY + recipeY})"><text class="trn-title trn-recipe-title" x="${MARGIN + 7}" y="19" fill="#111827" font-size="14" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(recipeTitle)}</text><rect class="trn-table-surface" x="${MARGIN}" y="${tableY}" width="${recipe.geometry.tableWidth}" height="${recipe.geometry.bodyHeight}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/>${leaves}${stages}</g>`588 }).join('')589 const links = dependencyLinks.map(({ parent, child }) => {590 const childRecipe = recipeById.get(child.id)!591 const childDepth = depthOf(child)592 const parentDepth = depthOf(parent.outcome)593 const startX = columnOffsets[childDepth]! + MARGIN + childRecipe.geometry.tableWidth594 const startY = treeY + centers.get(child.id)! + HEADER_HEIGHT / 2595 const endX = columnOffsets[parentDepth]! + MARGIN596 const endY = treeY + centers.get(parent.outcome.id)! + HEADER_HEIGHT / 2597 const startGutterX = columnOffsets[childDepth]! + MARGIN + columnWidths[childDepth]! + RECIPE_COLUMN_GAP / 2598 const endGutterX = columnOffsets[parentDepth]! + MARGIN - RECIPE_COLUMN_GAP / 2599 const relevantRects = recipeRects.filter(rect => rect.id !== child.id && rect.id !== parent.outcome.id600 && rect.left < endGutterX && rect.right > startGutterX)601 const candidates = [...new Set([602 startY,603 endY,604 ...relevantRects.flatMap(rect => [rect.top, rect.bottom]),605 treeY + treeHeight + RECIPE_LINK_CLEARANCE,606 ])]607 const hallwayY = candidates608 .filter(y => relevantRects.every(rect => y <= rect.top || y >= rect.bottom))609 .sort((left, right) => {610 const leftCost = Math.abs(startY - left) + Math.abs(endY - left)611 const rightCost = Math.abs(startY - right) + Math.abs(endY - right)612 return leftCost - rightCost || Math.abs(startY - left) - Math.abs(startY - right)613 })[0]!614 const adjacent = parentDepth - childDepth === 1615 const route = adjacent616 ? `M${startX} ${startY}H${startGutterX}V${endY}H${endX}`617 : `M${startX} ${startY}H${startGutterX}V${hallwayY}H${endGutterX}V${endY}H${endX}`618 const routeKind = adjacent ? 'gutter' : 'hallway'619 return `<path class="trn-recipe-link" data-from="${escapeXml(child.id)}" data-to="${escapeXml(parent.outcome.id)}" data-route="${routeKind}" d="${route}" fill="none" stroke="${GRID_COLOR}" stroke-width="1.5"/>`620 }).join('')621 const height = treeY + treeHeight + MARGIN622623 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" class="trn-diagram trn-layout-individual" data-layout="individual">624 <title>${escapeXml(title)} individual TRN recipes</title>625 <desc>Tabular Recipe Notation with one table per produced outcome, arranged left to right by dependency depth.</desc>626 <rect class="trn-canvas" data-dz-role="canvas" x="0" y="0" width="${width}" height="${height}" fill="#fbf8dc"/>627 <text class="trn-title" x="${MARGIN + 7}" y="${MARGIN + 19}" fill="#111827" font-size="14" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(title)} — individual recipes</text>628 ${instructions}629 <g class="trn-recipe-links">${links}</g>630 ${recipeSvg}631</svg>`632}633634export function renderTrn(document: TrnDocument): string {635 if (document.layout === 'individual') return renderIndividualTrn(document)636 return renderCombinedTrn(document)637}