canvas-layout-service.ts
1 import { VaultService } from './vault-service'; 2 import { CanvasParserService, CanvasData, CanvasNode } from './canvas-parser-service'; 3 import { parseCanvasToBlocks } from './dreamsong/parser'; 4 5 /** 6 * Canvas Layout Service 7 * 8 * Auto-arranges canvas elements in a linear top-to-bottom flow: 9 * - Text cards have uniform width, height scales with content 10 * - Media nodes positioned in center column 11 * - Media-text pairs: media in center, text horizontally offset 12 */ 13 14 export interface LayoutConfig { 15 centerX: number; // X-coordinate for center column 16 textCardWidth: number; // Standard width for text cards 17 verticalSpacing: number; // Gap between elements 18 horizontalOffset: number; // Gap between media and text in pairs 19 startY: number; // Starting Y position 20 charHeightRatio: number; // Approximate height per character for text scaling 21 } 22 23 const DEFAULT_LAYOUT_CONFIG: LayoutConfig = { 24 centerX: 400, 25 textCardWidth: 360, // 600 * 0.6 = 360 (reduced by 40%) 26 verticalSpacing: 75, // 150 * 0.5 = 75 (reduced by 50%) 27 horizontalOffset: 50, 28 startY: 0, 29 charHeightRatio: 0.15 // Rough estimate: 150px per 1000 chars 30 }; 31 32 export class CanvasLayoutService { 33 constructor( 34 private vaultService: VaultService, 35 private canvasParser: CanvasParserService 36 ) {} 37 38 /** 39 * Auto-layout a canvas file with linear top-to-bottom flow 40 */ 41 async autoLayoutCanvas(canvasPath: string, config: Partial<LayoutConfig> = {}): Promise<void> { 42 const layoutConfig = { ...DEFAULT_LAYOUT_CONFIG, ...config }; 43 44 // Parse canvas 45 const canvasData = await this.canvasParser.parseCanvas(canvasPath); 46 47 // Get topologically sorted blocks 48 const blocks = parseCanvasToBlocks(canvasData); 49 50 // Build node lookup map 51 const nodesMap = new Map<string, CanvasNode>( 52 canvasData.nodes.map(node => [node.id, node]) 53 ); 54 55 // Track current Y position 56 let currentY = layoutConfig.startY; 57 58 // Process each block in order 59 for (const block of blocks) { 60 if (block.type === 'media-text') { 61 // Handle media-text pair 62 const mediaNodeId = block.id.split('-')[0]; 63 const textNodeId = block.id.split('-')[1]; 64 65 const mediaNode = nodesMap.get(mediaNodeId); 66 const textNode = nodesMap.get(textNodeId); 67 68 if (mediaNode && textNode) { 69 // Normalize media width and calculate height preserving aspect ratio 70 const originalAspectRatio = mediaNode.width / mediaNode.height; 71 mediaNode.width = layoutConfig.textCardWidth; 72 mediaNode.height = layoutConfig.textCardWidth / originalAspectRatio; 73 74 // Calculate text height based on content length 75 const textHeight = this.calculateTextHeight(textNode.text || '', layoutConfig); 76 textNode.width = layoutConfig.textCardWidth; 77 textNode.height = textHeight; 78 79 // Calculate vertical centering offset 80 const mediaHeight = mediaNode.height; 81 const heightDiff = Math.abs(mediaHeight - textHeight); 82 const verticalOffset = heightDiff / 2; 83 84 // Position media in center column 85 mediaNode.x = layoutConfig.centerX; 86 if (mediaHeight > textHeight) { 87 // Media is taller - media at currentY, text offset down 88 mediaNode.y = currentY; 89 textNode.y = currentY + verticalOffset; 90 } else { 91 // Text is taller - text at currentY, media offset down 92 textNode.y = currentY; 93 mediaNode.y = currentY + verticalOffset; 94 } 95 96 // Position text horizontally adjacent to media 97 textNode.x = layoutConfig.centerX + mediaNode.width + layoutConfig.horizontalOffset; 98 99 // Advance Y by the taller of the two elements 100 const maxHeight = Math.max(mediaNode.height, textNode.height); 101 currentY += maxHeight + layoutConfig.verticalSpacing; 102 } 103 } else if (block.type === 'media') { 104 // Standalone media node 105 const mediaNode = nodesMap.get(block.id); 106 if (mediaNode) { 107 // Normalize media width and calculate height preserving aspect ratio 108 const originalAspectRatio = mediaNode.width / mediaNode.height; 109 mediaNode.width = layoutConfig.textCardWidth; 110 mediaNode.height = layoutConfig.textCardWidth / originalAspectRatio; 111 112 mediaNode.x = layoutConfig.centerX; 113 mediaNode.y = currentY; 114 currentY += mediaNode.height + layoutConfig.verticalSpacing; 115 } 116 } else if (block.type === 'text') { 117 // Standalone text node 118 const textNode = nodesMap.get(block.id); 119 if (textNode) { 120 const textHeight = this.calculateTextHeight(textNode.text || '', layoutConfig); 121 textNode.x = layoutConfig.centerX; 122 textNode.y = currentY; 123 textNode.width = layoutConfig.textCardWidth; 124 textNode.height = textHeight; 125 currentY += textHeight + layoutConfig.verticalSpacing; 126 } 127 } 128 } 129 130 // Write updated canvas back to file 131 await this.writeCanvas(canvasPath, canvasData); 132 } 133 134 /** 135 * Calculate text card height based on content length and wrapping 136 * 137 * Uses realistic text metrics: 138 * - Average character width: ~8px (Obsidian's default font) 139 * - Line height: ~24px (typical 1.5x line spacing) 140 * - Card padding: ~40px (20px top + 20px bottom) 141 */ 142 private calculateTextHeight(text: string, config: LayoutConfig): number { 143 const minHeight = 100; // Minimum card height 144 const maxHeight = 2000; // Maximum card height 145 146 const avgCharWidth = 8; // Average character width in pixels 147 const lineHeight = 24; // Line height in pixels 148 const cardPadding = 40; // Vertical padding (top + bottom) 149 150 // Calculate how many characters fit per line 151 const availableWidth = config.textCardWidth - 40; // Subtract horizontal padding 152 const charsPerLine = Math.floor(availableWidth / avgCharWidth); 153 154 // Estimate number of lines (account for newlines in text) 155 const textLines = text.split('\n'); 156 let totalLines = 0; 157 158 for (const line of textLines) { 159 if (line.trim() === '') { 160 totalLines += 1; // Empty line still takes up space 161 } else { 162 // Calculate wrapped lines for this paragraph 163 const wrappedLines = Math.ceil(line.length / charsPerLine); 164 totalLines += Math.max(1, wrappedLines); 165 } 166 } 167 168 // Calculate total height: lines * lineHeight + padding 169 const estimatedHeight = (totalLines * lineHeight) + cardPadding; 170 171 return Math.max(minHeight, Math.min(estimatedHeight, maxHeight)); 172 } 173 174 /** 175 * Write canvas data back to file 176 */ 177 private async writeCanvas(canvasPath: string, canvasData: CanvasData): Promise<void> { 178 const canvasJson = JSON.stringify(canvasData, null, 2); 179 await this.vaultService.writeFile(canvasPath, canvasJson); 180 } 181 }