/ src / services / canvas-layout-service.ts
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  }