This is page 3 of 6. Use http://codebase.md/1yhy/figma-context-mcp?page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .env.example
├── .github
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ ├── PULL_REQUEST_TEMPLATE.md
│ └── workflows
│ └── ci.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .lintstagedrc.json
├── .nvmrc
├── .prettierrc
├── CHANGELOG.md
├── commitlint.config.js
├── CONTRIBUTING.md
├── Dockerfile
├── docs
│ ├── en
│ │ ├── absolute-to-relative-research.md
│ │ ├── architecture.md
│ │ ├── cache-architecture.md
│ │ ├── grid-layout-research.md
│ │ ├── icon-detection.md
│ │ ├── layout-detection-research.md
│ │ └── layout-detection.md
│ └── zh-CN
│ ├── absolute-to-relative-research.md
│ ├── architecture.md
│ ├── cache-architecture.md
│ ├── grid-layout-research.md
│ ├── icon-detection.md
│ ├── layout-detection-research.md
│ ├── layout-detection.md
│ └── TODO-feature-enhancements.md
├── eslint.config.js
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── README.zh-CN.md
├── scripts
│ ├── fetch-test-data.ts
│ └── optimize-figma-json.ts
├── smithery.yaml
├── src
│ ├── algorithms
│ │ ├── icon
│ │ │ ├── detector.ts
│ │ │ └── index.ts
│ │ └── layout
│ │ ├── detector.ts
│ │ ├── index.ts
│ │ ├── optimizer.ts
│ │ └── spatial.ts
│ ├── config.ts
│ ├── core
│ │ ├── effects.ts
│ │ ├── layout.ts
│ │ ├── parser.ts
│ │ └── style.ts
│ ├── index.ts
│ ├── prompts
│ │ ├── design-to-code.ts
│ │ └── index.ts
│ ├── resources
│ │ ├── figma-resources.ts
│ │ └── index.ts
│ ├── server.ts
│ ├── services
│ │ ├── cache
│ │ │ ├── cache-manager.ts
│ │ │ ├── disk-cache.ts
│ │ │ ├── index.ts
│ │ │ ├── lru-cache.ts
│ │ │ └── types.ts
│ │ ├── cache.ts
│ │ ├── figma.ts
│ │ └── simplify-node-response.ts
│ ├── types
│ │ ├── figma.ts
│ │ ├── index.ts
│ │ └── simplified.ts
│ └── utils
│ ├── color.ts
│ ├── css.ts
│ ├── file.ts
│ └── validation.ts
├── tests
│ ├── fixtures
│ │ ├── expected
│ │ │ ├── node-240-32163-optimized.json
│ │ │ ├── node-402-34955-optimized.json
│ │ │ └── real-node-data-optimized.json
│ │ └── figma-data
│ │ ├── node-240-32163.json
│ │ ├── node-402-34955.json
│ │ └── real-node-data.json
│ ├── integration
│ │ ├── __snapshots__
│ │ │ ├── layout-optimization.test.ts.snap
│ │ │ └── output-quality.test.ts.snap
│ │ ├── layout-optimization.test.ts
│ │ ├── output-quality.test.ts
│ │ └── parser.test.ts
│ ├── unit
│ │ ├── algorithms
│ │ │ ├── icon-optimization.test.ts
│ │ │ ├── icon.test.ts
│ │ │ └── layout.test.ts
│ │ ├── resources
│ │ │ └── figma-resources.test.ts
│ │ └── services
│ │ └── cache.test.ts
│ └── utils
│ ├── preview-generator.ts
│ ├── preview.ts
│ ├── run-simplification.ts
│ └── viewer.html
├── tsconfig.json
├── tsup.config.ts
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/docs/en/layout-detection.md:
--------------------------------------------------------------------------------
```markdown
# Design-to-Code: Flex Layout Detection Algorithm
## Table of Contents
1. [Background](#1-background)
2. [Industry Solutions Analysis](#2-industry-solutions-analysis)
3. [Core Algorithm Principles](#3-core-algorithm-principles)
4. [Implementation Details](#4-implementation-details)
5. [Testing & Verification](#5-testing--verification)
6. [Best Practices](#6-best-practices)
---
## 1. Background
### 1.1 Problem Statement
Design files (like Figma) typically use **absolute positioning** (x, y, width, height), while frontend code requires **relative layouts** (Flexbox, Grid) for responsive design.
**Core Challenge**: How to accurately infer Flex layout structure from a flat list of absolutely positioned elements?
### 1.2 Key Difficulties
| Challenge | Description |
| -------------------- | -------------------------------------------------------------------------- |
| Row/Column Detection | How to determine if elements are arranged horizontally or vertically? |
| Nested Structure | How to convert a flat list into a nested DOM tree? |
| Gap Calculation | How to determine if gaps are consistent and should use the `gap` property? |
| Alignment Detection | How to detect `justify-content` and `align-items`? |
| Overlap Handling | How to handle overlapping elements that need absolute positioning? |
| Tolerance Handling | How to handle small offsets in design files? |
---
## 2. Industry Solutions Analysis
### 2.1 Major Tools Comparison
| Tool | Developer | Layout Detection | Open Source |
| --------------- | ------------ | -------------------------------- | ----------- |
| **FigmaToCode** | bernaferrari | Relies on Figma Auto Layout data | ✓ |
| **Grida** | gridaco | Rules + ML hybrid | ✓ |
| **imgcook** | Alibaba | Rule system + Machine Learning | ✗ |
| **Anima** | Anima | Constraint inference | ✗ |
### 2.2 FigmaToCode Analysis
**GitHub**: https://github.com/bernaferrari/FigmaToCode
**Features**:
- No layout inference, directly maps Figma Auto Layout properties
- Uses AltNodes as intermediate representation
- Uses absolute positioning for non-Auto Layout designs
**Limitations**:
- Depends on designers correctly using Auto Layout
- Cannot handle legacy designs or manually positioned layouts
### 2.3 imgcook Layout Algorithm (Alibaba)
**Core Flow**:
```
Flattened JSON → Row/Column Grouping → Layout Inference → Semantics → Code Generation
```
**Key Technologies**:
1. **Page Segmentation**: Split page into different sub-modules
2. **Grouping Algorithm**: Determine element containment relationships
3. **Loop Detection**: Identify lists/repeated elements
4. **Multi-state Recognition**: Identify different states of the same component
---
## 3. Core Algorithm Principles
### 3.1 Y-Axis Overlap Detection (Row Grouping)
**Principle**: If two elements overlap on the Y-axis, they belong to the same row.
```
Element A: y=10, height=30 → Y range [10, 40]
Element B: y=20, height=30 → Y range [20, 50]
[10, 40] and [20, 50] intersect → Same row
```
**Implementation**:
```typescript
function isOverlappingY(a: ElementRect, b: ElementRect, tolerance = 0): boolean {
return !(a.bottom + tolerance < b.y || b.bottom + tolerance < a.y);
}
```
### 3.2 X-Axis Overlap Detection (Column Grouping)
**Principle**: If two elements overlap on the X-axis, they belong to the same column.
```typescript
function isOverlappingX(a: ElementRect, b: ElementRect, tolerance = 0): boolean {
return !(a.right + tolerance < b.x || b.right + tolerance < a.x);
}
```
### 3.3 Gap Consistency Analysis
**Principle**: Calculate the standard deviation of all gaps to determine consistency.
```typescript
function analyzeGaps(gaps: number[], tolerancePercent = 20) {
const average = gaps.reduce((a, b) => a + b, 0) / gaps.length;
const variance = gaps.reduce((sum, g) => sum + Math.pow(g - average, 2), 0) / gaps.length;
const stdDev = Math.sqrt(variance);
return {
isConsistent: stdDev <= average * (tolerancePercent / 100),
average,
rounded: Math.round(average / 4) * 4, // Round to 4px grid
stdDev,
};
}
```
### 3.4 Alignment Detection
**Horizontal Alignment**:
- Left aligned: All elements have the same `left` value
- Center aligned: All elements have the same center X coordinate
- Right aligned: All elements have the same `right` value
**Vertical Alignment**:
- Top aligned: All elements have the same `top` value
- Center aligned: All elements have the same center Y coordinate
- Bottom aligned: All elements have the same `bottom` value
---
## 4. Implementation Details
### 4.1 Layout Detection Flow
```
1. Extract bounding boxes from child elements
2. Group elements into rows (Y-axis overlap)
3. If single row → check column grouping (X-axis overlap)
4. Determine layout direction (row vs column)
5. Calculate gaps and check consistency
6. Detect alignment (justify-content, align-items)
7. Handle overlapping elements (mark for absolute positioning)
```
### 4.2 Confidence Scoring
The algorithm calculates a confidence score based on:
- Number of elements that fit the detected pattern
- Gap consistency
- Alignment accuracy
---
## 5. Testing & Verification
### 5.1 Test Results
| Test Case | Status | Result |
| -------------------------- | ------ | ---------------------------------- |
| Bounding box extraction | ✓ | Extracted 2 child elements |
| Row grouping algorithm | ✓ | Grouped into 1 row |
| Column grouping algorithm | ✓ | Grouped into 1 column |
| Consistent gap detection | ✓ | Avg: 16px, StdDev: 0.63 |
| Inconsistent gap detection | ✓ | Avg: 25px, StdDev: 14.14 |
| Left alignment detection | ✓ | Detected: left |
| Center alignment detection | ✓ | Detected: center |
| Layout tree building | ✓ | Type: container, Direction: column |
**Overall: 8/9 tests passed**
---
## 6. Best Practices
### 6.1 Recommended Parameters
| Parameter | Recommended Value | Description |
| ------------------- | ----------------- | ---------------------------- |
| Y-axis tolerance | 2px | For row grouping |
| Gap tolerance | 20% | Standard deviation threshold |
| Alignment tolerance | 2px | For alignment detection |
| Max recursion depth | 5 | For nested layouts |
### 6.2 When to Use Absolute Positioning
- Elements with significant overlap (IoU > 0.1)
- Complex icon compositions
- Decorative elements with specific positioning
---
## 7. Complete Implementation Analysis
This section provides an in-depth analysis of the layout detection algorithm implementation, including the complete call chain and core functions.
### 7.1 System Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Layout Optimization System │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ optimizer.ts (Orchestration Layer) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ optimizeDesign() → optimizeNodeTree() → optimizeContainer() │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ detector.ts │ │ detector.ts │ │ optimizer.ts │ │
│ │ (Overlap Detection)│ │ (Layout Detect)│ │ (Style Generation) │ │
│ │ │ │ │ │ │ │
│ │ detectOverlapping() │ │ analyzeLayout() │ │ generateGridCSS() │ │
│ │ detectBackground() │ │ detectGrid() │ │ convertToRelative() │ │
│ └─────────────────────┘ └─────────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 7.2 Core Entry Function Call Chain
```
src/algorithms/layout/optimizer.ts
═══════════════════════════════════════════════════════════════════════════
┌─────────────────────────┐
│ LayoutOptimizer. │
│ optimizeDesign(design) │
│ [optimizer.ts:36] │
└───────────┬─────────────┘
│
Reset containerIdCounter
│
▼
┌─────────────────────────────────┐
│ design.nodes.map((node) => │
│ optimizeNodeTree(node) │
│ ) │
│ [optimizer.ts:46] │
└───────────────┬─────────────────┘
│
▼ (Recursively process each node)
┌─────────────────────────────────┐
│ optimizeNodeTree(node) │
│ [optimizer.ts:61] │
│ │
│ Recursively call itself for │
│ each child, then call │
│ optimizeContainer() │
└───────────────┬─────────────────┘
│
▼
┌─────────────────────────────────┐
│ optimizeContainer(node) │
│ [optimizer.ts:89] │
│ │
│ Core optimization logic (7.3) │
└─────────────────────────────────┘
```
### 7.3 optimizeContainer Four-Step Algorithm
```
optimizeContainer(node) - [optimizer.ts:89-356]
═══════════════════════════════════════════════════════════════════════════
Input: SimplifiedNode (container node with children)
Output: Optimized SimplifiedNode
┌─────────────────────────────────────────────────────────────────────────┐
│ Pre-checks │
│ • children.length <= 1 → return immediately │
│ • Check if FRAME/GROUP container │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ STEP 1: Overlap Detection │
│ [optimizer.ts:98-109] │
│ │
│ elementRects = nodesToElementRects(children) │
│ │ │
│ ▼ │
│ overlapResult = detectOverlappingElements(elementRects, 0.1) │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ flowElements[] stackedElements[] │
│ (participate in layout) (keep absolute) │
│ │
│ If flowElements.length < 2 → skip layout optimization │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ STEP 1.5: Background Element Detection │
│ [optimizer.ts:111-151] │
│ │
│ detectBackgroundElement(elementRects, parentWidth, parentHeight) │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ │ hasBackground: true │ │
│ │ backgroundIndex >= 0 │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ isBackgroundElement() ──► extractBackgroundStyles() │
│ │ │ │
│ │ ▼ │
│ │ mergedBackgroundStyles = { │
│ │ backgroundColor, borderRadius, │
│ │ border, boxShadow... │
│ │ } │
│ │ │
│ ▼ │
│ filteredChildren = children.filter(non-background) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ STEP 2: Grid Detection (Priority over Flex) │
│ [optimizer.ts:165-206] │
│ │
│ if (isContainer) { │
│ gridDetection = detectGridIfApplicable(filteredChildren) │
│ │ │
│ if (gridDetection) { │
│ ┌─────────────┴─────────────┐ │
│ │ gridResult │ │
│ │ gridIndices (Grid elements)│ │
│ └─────────────┬─────────────┘ │
│ │ │
│ gridStyles = generateGridCSS(gridResult) │
│ │ │
│ convertAbsoluteToRelative(..., gridIndices) │
│ │ │
│ return Grid layout node ──────────────────────► [END] │
│ } │
│ } │
└─────────────────────────────────────────────────────────────────────────┘
│
▼ (Grid not detected)
┌─────────────────────────────────────────────────────────────────────────┐
│ STEP 3: Flex Detection (Fallback) │
│ [optimizer.ts:208-356] │
│ │
│ { isRow, isColumn, rowGap, columnGap, │
│ isGapConsistent, justifyContent, alignItems } │
│ = analyzeLayoutDirection(filteredChildren) │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ │ │ │
│ isRow = true isColumn = true │
│ direction: 'row' direction: 'column' │
│ │ │ │
│ └───────────────┬───────────────┘ │
│ │ │
│ flexStyles = { │
│ display: 'flex', │
│ flexDirection: direction, // only set for column │
│ gap: `${gap}px`, // only when consistent │
│ justifyContent, │
│ alignItems │
│ } │
│ │ │
│ convertAbsoluteToRelative() → padding + clean child styles │
│ │ │
│ return Flex layout node │
└─────────────────────────────────────────────────────────────────────────┘
```
### 7.4 Flex Layout Detection Flow
```
analyzeLayoutDirection(nodes) - [optimizer.ts:361-438]
═══════════════════════════════════════════════════════════════════════════
Input: SimplifiedNode[] (child nodes array)
Output: { isRow, isColumn, rowGap, columnGap, isGapConsistent,
justifyContent, alignItems }
┌─────────────────────────────────┐
│ Extract position info │
│ rects = nodes.map(node => { │
│ left, top, width, height │
│ }) │
└───────────────┬─────────────────┘
│
▼
┌─────────────────────────────────┐
│ analyzeAlignment(rects) │
│ [optimizer.ts:443-489] │
│ │
│ Analyze horizontal/vertical │
│ alignment: │
│ • leftAligned │
│ • rightAligned │
│ • centerHAligned │
│ • topAligned │
│ • bottomAligned │
│ • centerVAligned │
└───────────────┬─────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ calculateRowScore() │ │ calculateColumnScore() │
│ [optimizer.ts:551-579] │ │ [optimizer.ts:584-612] │
│ │ │ │
│ Row layout scoring: │ │ Column layout scoring: │
│ │ │ │
│ 1. Sort by left │ │ 1. Sort by top │
│ │ │ │
│ 2. Calculate h-gaps │ │ 2. Calculate v-gaps │
│ gap = next.left - │ │ gap = next.top - │
│ current.right │ │ current.bottom │
│ │ │ │
│ 3. Count consecutive │ │ 3. Count consecutive │
│ positive gaps │ │ positive gaps │
│ (0 <= gap <= 50px) │ │ (0 <= gap <= 50px) │
│ │ │ │
│ 4. Score calculation: │ │ 4. Score calculation: │
│ distribution * 0.7 │ │ distribution * 0.7 │
│ + alignment * 0.3 │ │ + alignment * 0.3 │
└───────────────┬─────────┘ └───────────────┬──────────┘
│ │
└───────────────┬───────────────┘
│
▼
┌─────────────────────────────────┐
│ Layout direction decision │
│ │
│ if (rowScore > columnScore │
│ && rowScore > 0.4) │
│ → isRow = true │
│ │
│ if (columnScore > rowScore │
│ && columnScore > 0.4) │
│ → isColumn = true │
└───────────────┬─────────────────┘
│
▼
┌─────────────────────────────────┐
│ CSS property mapping │
│ │
│ Row layout: │
│ justify = horizontal align │
│ align = vertical align │
│ │
│ Column layout: │
│ justify = vertical align │
│ align = horizontal align │
└─────────────────────────────────┘
```
### 7.5 Overlap Detection Algorithm (IoU)
```
detectOverlappingElements(rects, iouThreshold=0.1) - [detector.ts:246-281]
═══════════════════════════════════════════════════════════════════════════
IoU (Intersection over Union) Calculation:
┌──────────────────────────────┐
│ Element A │
│ ┌──────────────────────┐ │
│ │ Intersection │ │
└────┼──────────────────────┼──┘
│ Element B │
└──────────────────────┘
IoU = Intersection Area / Union Area
where:
Intersection = overlap_width × overlap_height
Union = Area_A + Area_B - Intersection
Algorithm Flow:
┌────────────────────────────────────────────────────────────────┐
│ for each pair (i, j) where i < j: │
│ │
│ iou = calculateIoU(rects[i], rects[j]) │
│ │
│ if (iou > 0.1): │
│ stackedIndices.add(rects[i].index) │
│ stackedIndices.add(rects[j].index) │
│ │
│ Result: │
│ flowElements = elements NOT in stackedIndices │
│ stackedElements = elements in stackedIndices │
└────────────────────────────────────────────────────────────────┘
IoU Threshold Classification:
┌────────────┬───────────────────────────────────────────────────┐
│ IoU Range │ Classification │
├────────────┼───────────────────────────────────────────────────┤
│ = 0 │ none/adjacent (no overlap or adjacent) │
│ 0 < x < 0.1│ partial (slight overlap - can participate) │
│ 0.1 ≤ x < 0.5│ significant (needs absolute positioning) │
│ ≥ 0.5 │ contained (severe overlap/containment) │
└────────────┴───────────────────────────────────────────────────┘
```
### 7.6 Gap Analysis Algorithm
```
calculateGaps() + analyzeGaps() - [detector.ts:447-509]
═══════════════════════════════════════════════════════════════════════════
Gap Calculation Example (horizontal direction):
┌────┐ gap1 ┌────┐ gap2 ┌────┐
│ A │ ──────── │ B │ ──────── │ C │
└────┘ └────┘ └────┘
right left right left
gap1 = B.x - A.right
gap2 = C.x - B.right
Gap Consistency Analysis:
┌────────────────────────────────────────────────────────────────┐
│ analyzeGaps(gaps, tolerancePercent=20) │
│ │
│ 1. Calculate average: │
│ average = sum(gaps) / gaps.length │
│ │
│ 2. Calculate standard deviation: │
│ variance = Σ(gap - average)² / n │
│ stdDev = √variance │
│ │
│ 3. Consistency check: │
│ tolerance = average × 20% │
│ isConsistent = (stdDev <= tolerance) │
│ │
│ 4. Round to common values: │
│ COMMON_VALUES = [0,2,4,6,8,10,12,16,20,24,32,40,48,64...] │
│ rounded = findClosest(average, COMMON_VALUES) │
└────────────────────────────────────────────────────────────────┘
Example:
┌──────────────────────────────────────────────────────────────┐
│ gaps = [16, 15, 17, 16] │
│ average = 16 │
│ stdDev = 0.71 │
│ tolerance = 16 × 0.2 = 3.2 │
│ isConsistent = (0.71 <= 3.2) = true ✓ │
│ rounded = 16px │
└──────────────────────────────────────────────────────────────┘
```
### 7.7 Absolute to Relative Position Conversion
```
convertAbsoluteToRelative() - [optimizer.ts:1591-1685]
═══════════════════════════════════════════════════════════════════════════
Original Layout (Absolute Positioning):
┌────────────────────────────────────────────────┐
│ Parent Container │
│ ┌──────────────────────────────────────────┐ │
│ │ ← paddingLeft │ │
│ │ ┌────────┐ gap ┌────────┐ │ │
│ │ │ Child1 │ ──────── │ Child2 │ │ │
│ │ │left:20 │ │left:140│ │ │
│ │ │top:10 │ │top:10 │ │ │
│ │ └────────┘ └────────┘ │ │
│ │ paddingRight→│
│ └──────────────────────────────────────────┘ │
│ ↓ paddingBottom │
└────────────────────────────────────────────────┘
After Conversion (Flex Layout):
┌────────────────────────────────────────────────┐
│ Parent Container │
│ display: flex; │
│ padding: 10px 30px 20px 20px; │
│ gap: 20px; │
│ ┌──────────────────────────────────────────┐ │
│ │ ┌────────┐ ←gap→ ┌────────┐ │ │
│ │ │ Child1 │ │ Child2 │ │ │
│ │ │(no left)│ │(no left)│ │ │
│ │ │(no top) │ │(no top) │ │ │
│ │ └────────┘ └────────┘ │ │
│ └──────────────────────────────────────────┘ │
└────────────────────────────────────────────────┘
Conversion Steps:
┌────────────────────────────────────────────────────────────────┐
│ 1. collectFlowChildOffsets() │
│ Collect position info for all flow children │
│ │
│ 2. inferContainerPadding() │
│ Infer container padding from child positions: │
│ • paddingLeft = min(children.left) │
│ • paddingTop = min(children.top) │
│ • paddingRight = parentWidth - max(children.right) │
│ • paddingBottom = parentHeight - max(children.bottom) │
│ │
│ 3. calculateChildMargins() │
│ Calculate individual child margin adjustments │
│ (cross-axis offset) │
│ │
│ 4. generatePaddingCSS() │
│ Generate CSS padding shorthand: │
│ • All same: "10px" │
│ • Top/bottom same, left/right same: "10px 20px" │
│ • All different: "10px 20px 30px 40px" │
│ │
│ 5. cleanChildStylesForLayout() │
│ Clean child element styles: │
│ • Remove position: absolute │
│ • Remove left, top │
│ • Keep width, height │
└────────────────────────────────────────────────────────────────┘
```
### 7.8 Core Data Structures
```typescript
// Core data structures in detector.ts
═══════════════════════════════════════════════════════════════════════════
// Basic bounding box
interface BoundingBox {
x: number; // left offset
y: number; // top offset
width: number; // width
height: number; // height
}
// Extended element rect (with computed properties)
interface ElementRect extends BoundingBox {
index: number; // index in original array
right: number; // x + width
bottom: number; // y + height
centerX: number; // x + width/2
centerY: number; // y + height/2
}
// Layout analysis result
interface LayoutAnalysisResult {
direction: 'row' | 'column' | 'none';
confidence: number; // 0-1 confidence score
gap: number; // gap value (px)
isGapConsistent: boolean; // is gap consistent
justifyContent: string; // CSS justify-content
alignItems: string; // CSS align-items
rows: ElementRect[][]; // row grouping result
columns: ElementRect[][]; // column grouping result
overlappingElements: ElementRect[]; // overlapping elements
}
// Overlap detection result
interface OverlapDetectionResult {
flowElements: ElementRect[]; // elements participating in layout
stackedElements: ElementRect[]; // elements needing absolute
stackedIndices: Set<number>; // overlapping element indices
}
// Background detection result
interface BackgroundDetectionResult {
backgroundIndex: number; // background element index (-1 if none)
contentIndices: number[]; // content element indices
hasBackground: boolean; // whether background detected
}
```
### 7.9 File Path Mapping
| Module | File Path | Line Range |
| -------------------------- | ------------------------------------ | ---------- |
| Bounding box extraction | `src/algorithms/layout/detector.ts` | 56-109 |
| Overlap detection (IoU) | `src/algorithms/layout/detector.ts` | 111-281 |
| Background detection | `src/algorithms/layout/detector.ts` | 283-344 |
| Row/column grouping | `src/algorithms/layout/detector.ts` | 346-422 |
| Gap analysis | `src/algorithms/layout/detector.ts` | 442-535 |
| Alignment detection | `src/algorithms/layout/detector.ts` | 537-642 |
| Layout direction detection | `src/algorithms/layout/detector.ts` | 644-755 |
| Layout tree building | `src/algorithms/layout/detector.ts` | 847-982 |
| Optimization entry | `src/algorithms/layout/optimizer.ts` | 36-53 |
| Container optimization | `src/algorithms/layout/optimizer.ts` | 89-356 |
| CSS generation | `src/algorithms/layout/optimizer.ts` | 875-913 |
| Position conversion | `src/algorithms/layout/optimizer.ts` | 1591-1685 |
---
_Document version: 2.0_
_Last updated: 2025-12-06_
_For the Chinese version of this document, see [docs/zh-CN/layout-detection.md](../zh-CN/layout-detection.md)_
```
--------------------------------------------------------------------------------
/docs/en/icon-detection.md:
--------------------------------------------------------------------------------
```markdown
# Icon Layer Merge Algorithm
## Overview
This document describes the algorithm for detecting and merging icon layers in Figma designs. The goal is to identify groups of vector elements that should be exported as a single icon image rather than individual elements.
## Problem Statement
In Figma designs, icons are often composed of multiple vector layers:
- A magnifying glass icon might have a circle and a line
- A settings icon might have multiple gear shapes
- Complex icons might have dozens of individual elements
Exporting each layer separately would result in fragmented assets that are difficult to use.
## Algorithm Design
### 1. Detection Criteria
An element group is considered an icon if:
| Criterion | Threshold | Rationale |
| ----------------- | ---------- | ------------------------------------ |
| Maximum size | 300×300 px | Icons are typically small |
| Minimum size | 8×8 px | Avoid detecting tiny decorations |
| Mergeable ratio | 80% | Most children should be vector types |
| Max nesting depth | 5 levels | Avoid complex UI components |
### 2. Mergeable Node Types
The following node types are considered mergeable:
- `VECTOR`
- `ELLIPSE`
- `RECTANGLE`
- `STAR`
- `POLYGON`
- `LINE`
- `BOOLEAN_OPERATION`
### 3. Export Format Selection
| Condition | Format | Reason |
| ------------------------------- | ------------ | ---------------------------- |
| All vector elements | SVG | Best quality, smallest size |
| Contains effects (blur, shadow) | PNG | Effects not supported in SVG |
| Designer specified format | As specified | Respect design intent |
### 4. Algorithm Flow
```
1. Traverse node tree depth-first
2. For each GROUP/FRAME node:
a. Check size constraints
b. Count mergeable vs non-mergeable children
c. Calculate mergeable ratio
d. If ratio >= threshold, mark as icon
3. Determine export format
4. Skip processing children of icon nodes
```
## Implementation
### Core Detection Function
```typescript
function shouldMergeAsIcon(node: FigmaNode, config: IconConfig): IconResult {
// Size check
if (node.width > config.maxSize || node.height > config.maxSize) {
return { shouldMerge: false, reason: "Size too large" };
}
// Check for text nodes (exclude UI components)
if (containsTextNode(node)) {
return { shouldMerge: false, reason: "Contains TEXT elements" };
}
// Calculate mergeable ratio
const ratio = countMergeableChildren(node) / node.children.length;
if (ratio < config.mergeableRatio) {
return { shouldMerge: false, reason: "Low mergeable ratio" };
}
return {
shouldMerge: true,
format: hasComplexEffects(node) ? "PNG" : "SVG",
reason: "All criteria met",
};
}
```
## Test Results
### Test Cases
| Test Case | Expected | Result |
| -------------------------------- | ---------------------------- | ------ |
| Icon container (designer marked) | ✓ Export as PNG | ✓ PASS |
| Core icon group | ✓ Export as whole | ✓ PASS |
| Magnifying glass icon | ✓ Small icon group | ✓ PASS |
| Exclamation icon | ✓ Export as icon | ✓ PASS |
| Star icon in AI button | ✓ Export as icon | ✓ PASS |
| Text node | ✗ Should not merge | ✓ PASS |
| Root node (too large) | ✗ Should not export | ✓ PASS |
| Group with TEXT | ✗ Should not export as image | ✓ PASS |
| Background rectangle | ✗ Too large | ✓ PASS |
**Result: 9/9 tests passed**
### Optimization Results
- **Before optimization**: ~45 potential exports (fragmented)
- **After optimization**: 2 exports (merged icons)
- **Reduction**: 96%
## Configuration
### Default Configuration
```typescript
const DEFAULT_CONFIG: IconDetectionConfig = {
maxIconSize: 300,
minIconSize: 8,
mergeableRatio: 0.8,
maxDepth: 5,
maxChildren: 50,
respectExportSettingsMaxSize: 500,
};
```
### Tuning Guidelines
| Scenario | Adjustment |
| ---------------- | ------------------------------------- |
| Large icons | Increase `maxIconSize` |
| Strict detection | Increase `mergeableRatio` |
| Complex icons | Increase `maxDepth` and `maxChildren` |
---
## Complete Implementation Analysis
### Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Icon Detection Algorithm │
│ src/algorithms/icon/detector.ts │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ Figma Node Tree │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ analyzeNodeTree() [Entry Point] │ │
│ │ Orchestrates the detection flow, returns results and summary │ │
│ └────────────────────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┴───────────────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌────────────────────┐ │
│ │ processNodeTree() │ │collectExportable │ │
│ │ Bottom-up recursive│─────── After processing ────▶│ Icons() │ │
│ │ Mark _iconDetection│ │ Collect exportable │ │
│ └──────────┬──────────┘ │ icon list │ │
│ │ └────────────────────┘ │
│ │ For each node │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ detectIcon() [Core Detection] │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ │
│ │ │ 9-Step Rules │───▶│collectNode │───▶│ IconDetectionResult │ │ │
│ │ │ (see below) │ │ Stats() │ │ {shouldMerge, format} │ │ │
│ │ └─────────────┘ │ O(n) single │ └─────────────────────────┘ │ │
│ │ │ pass │ │ │
│ │ └─────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### detectIcon() 9-Step Detection Flow
```
┌─────────────────────┐
│ detectIcon() │
│ Input: FigmaNode │
└──────────┬──────────┘
│
┌────────────────────┴────────────────────┐
▼ │
┌───────────────────────┐ │
Step 1 │ exportSettings Check │ │
│ Designer marked export?│ │
└───────────┬───────────┘ │
│ │
┌────────YES────────┐ │
▼ │ │
┌───────────────────┐ │ │
│ Size ≤ 400px? │ │ │
│ No TEXT? │ │ │
└─────────┬─────────┘ │ │
│ │ │
YES────┴────NO │ │
│ │ │ │
▼ │ │ │
┌────────┐ │ │ │
│✅ Merge │ └────────────┼───────────────┐ │
│Use │ │ │ │
│designer │ │ │ │
│settings │ │ │ │
└────────┘ │ │ │
▼ ▼ │
┌───────────────────────────────┐ │
Step 2 │ Container or mergeable type? │ │
│ CONTAINER: GROUP/FRAME/... │ │
│ MERGEABLE: VECTOR/ELLIPSE/... │ │
└───────────────┬───────────────┘ │
│ │
┌─────────NO─────────┐ │
▼ │ │
┌─────────┐ │ │
│❌ Skip │ │ │
│Non-target│ │ │
│type │ │ │
└─────────┘ │ │
▼ │
┌─────────────────────────┐ │
│ Single element (VECTOR)?│ │
└───────────┬─────────────┘ │
│ │
YES────────┴────────NO │
│ │ │
┌───────────────┘ │ │
▼ │ │
┌─────────────────────┐ │ │
│ Exclude RECTANGLE │ │ │
│ (usually background)│ │ │
│ Check size ≤ 300px │ │ │
└──────────┬──────────┘ │ │
│ │ │
PASS───┴───FAIL │ │
│ │ │ │
▼ ▼ │ │
┌────────┐ ┌─────────┐ │ │
│✅ Merge │ │❌ Skip │ │ │
│Single │ │Background│ │ │
│vector │ │/too large│ │ │
└────────┘ └─────────┘ │ │
│ │
┌─────────────────────────┘ │
▼ │
┌───────────────────────────┐ │
Step 3 │ Size Check (containers) │ │
│ 8px ≤ size ≤ 300px │ │
└─────────────┬─────────────┘ │
│ │
PASS──────┴──────FAIL │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ ❌ Skip │ │
│ │ Too large/ │ │
│ │ small │ │
│ └─────────────┘ │
▼ │
╔═══════════════════════════════════════════════════════╗ │
║ collectNodeStats() Single-Pass ║ │
║ ┌────────────────────────────────────────────┐ ║ │
║ │ O(n) complexity collects 7 statistics: │ ║ │
║ │ • depth - Tree depth │ ║ │
║ │ • totalChildren - Total descendants │ ║ │
║ │ • hasExcludeType - Contains TEXT etc. │ ║ │
║ │ • hasImageFill - Contains image fills │ ║ │
║ │ • hasComplexEffects - Contains effects │ ║ │
║ │ • allLeavesMergeable - All leaves OK │ ║ │
║ │ • mergeableRatio - Mergeable type ratio │ ║ │
║ └────────────────────────────────────────────┘ ║ │
╚═══════════════════════════════╤═══════════════════════╝ │
│ │
▼ │
┌───────────────────────────────────┐ │
Step 4 │ Exclude Type Check │ │
│ hasExcludeType? (TEXT/COMPONENT) │ │
└─────────────────┬─────────────────┘ │
│ │
YES───────┴───────NO │
│ │ │
▼ │ │
┌─────────┐ │ │
│❌ Skip │ │ │
│Has text │ │ │
└─────────┘ │ │
▼ │
┌───────────────────────────────────┐ │
Step 5 │ Depth Check │ │
│ depth ≤ 5? │ │
└─────────────────┬─────────────────┘ │
│ │
NO────────┴────────YES │
│ │ │
▼ │ │
┌─────────┐ │ │
│❌ Skip │ │ │
│Too deep │ │ │
└─────────┘ │ │
▼ │
┌───────────────────────────────────┐ │
Step 6 │ Child Count Check │ │
│ totalChildren ≤ 100? │ │
└─────────────────┬─────────────────┘ │
│ │
NO────────┴────────YES │
│ │ │
▼ │ │
┌─────────┐ │ │
│❌ Skip │ │ │
│Too many │ │ │
│children │ │ │
└─────────┘ │ │
▼ │
┌───────────────────────────────────┐ │
Step 7 │ Mergeable Ratio Check │ │
│ mergeableRatio ≥ 60%? │ │
└─────────────────┬─────────────────┘ │
│ │
NO────────┴────────YES │
│ │ │
▼ │ │
┌─────────┐ │ │
│❌ Skip │ │ │
│Ratio low│ │ │
└─────────┘ │ │
▼ │
┌───────────────────────────────────┐ │
Step 8 │ Leaf Mergeability Check │ │
│ allLeavesMergeable? │ │
└─────────────────┬─────────────────┘ │
│ │
NO────────┴────────YES │
│ │ │
▼ │ │
┌─────────┐ │ │
│❌ Skip │ │ │
│Non- │ │ │
│mergeable│ │ │
│leaves │ │ │
└─────────┘ │ │
▼ │
┌───────────────────────────────────┐ │
Step 9 │ Export Format Decision │ │
│ Based on hasImageFill/hasComplex │ │
└─────────────────┬─────────────────┘ │
│ │
┌────────────────────┼────────────────────┐ │
▼ ▼ ▼ │
┌───────────────┐ ┌───────────────┐ ┌───────────────┐│
│ hasImageFill │ │hasComplex │ │ Pure vector ││
│ = true │ │Effects = true │ │ ││
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘│
│ │ │ │
▼ ▼ ▼ │
┌───────────────┐ ┌───────────────┐ ┌───────────────┐│
│ ✅ shouldMerge │ │ ✅ shouldMerge │ │ ✅ shouldMerge ││
│ format: PNG │ │ format: PNG │ │ format: SVG ││
└───────────────┘ └───────────────┘ └───────────────┘│
│
└─────────────────────────────────────────────────────────────┘
```
### Bottom-Up Processing Flow
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ processNodeTree() Bottom-Up Processing │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Example Input: │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Frame "Card" │ │
│ │ ├── Frame "Header" │ │
│ │ │ ├── Group "Logo" ◄─── Potential icon │ │
│ │ │ │ ├── Vector (path1) │ │
│ │ │ │ └── Ellipse (circle) │ │
│ │ │ └── Text "Title" │ │
│ │ └── Frame "Content" │ │
│ │ └── Group "Icon-Set" │ │
│ │ ├── Group "Search" ◄─── Potential icon │ │
│ │ │ ├── Ellipse │ │
│ │ │ └── Line │ │
│ │ └── Group "Menu" ◄─── Potential icon │ │
│ │ ├── Line │ │
│ │ ├── Line │ │
│ │ └── Line │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ Processing Order (bottom-up): │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Round 1: Process deepest leaf nodes │ │
│ │ Vector, Ellipse, Text, Line... → All leaves, no _iconDetection │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Round 2: Process first-level containers │ │
│ │ │ │
│ │ Group "Logo" → detectIcon() → ✅ shouldMerge (pure vector) │ │
│ │ Group "Search" → detectIcon() → ✅ shouldMerge (pure vector) │ │
│ │ Group "Menu" → detectIcon() → ✅ shouldMerge (pure vector) │ │
│ │ │ │
│ │ ⚠️ After marking, child _iconDetection is cleared (will be merged) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Round 3: Process upper containers │ │
│ │ │ │
│ │ Frame "Header" │ │
│ │ → Children include Logo(icon) + Text │ │
│ │ → Not all children are icons │ │
│ │ → detectIcon() → ❌ Contains TEXT │ │
│ │ → Logo keeps _iconDetection marker │ │
│ │ │ │
│ │ Group "Icon-Set" │ │
│ │ → All children (Search, Menu) already marked as icons │ │
│ │ → allChildrenAreIcons = true │ │
│ │ → detectIcon() → Check if can promote merge │ │
│ │ → If size/depth allows → ✅ Merge as single icon │ │
│ │ → Clear Search, Menu _iconDetection │ │
│ │ OR │ │
│ │ → If criteria not met → Keep individual _iconDetection │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Final: Collect exportable icons │ │
│ │ │ │
│ │ collectExportableIcons() traverses processed tree: │ │
│ │ │ │
│ │ - Node with _iconDetection → Add to export list │ │
│ │ - Don't recurse into marked nodes (children will be merged) │ │
│ │ - Continue traversing unmarked nodes' children │ │
│ │ │ │
│ │ Output: [Logo, Search, Menu] or [Logo, Icon-Set] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### collectNodeStats() Single-Pass Optimization
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ collectNodeStats() Performance Optimization │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Before: 6 separate recursive functions, O(6n) complexity │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ calculateDepth() ─────▶ Traverse tree ────▶ depth │ │
│ │ countTotalChildren() ─────▶ Traverse tree ────▶ count │ │
│ │ hasExcludeTypeInTree() ─────▶ Traverse tree ────▶ boolean │ │
│ │ hasImageFillInTree() ─────▶ Traverse tree ────▶ boolean │ │
│ │ hasComplexEffectsInTree() ─────▶ Traverse tree ────▶ boolean │ │
│ │ areAllLeavesMergeable() ─────▶ Traverse tree ────▶ boolean │ │
│ │ │ │
│ │ Total traversals: 6n │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ │ │
│ ▼ │
│ │
│ After: Single-pass collection, O(n) complexity │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ collectNodeStats(node) │ │
│ │ │ │ │
│ │ ┌───────────────┴───────────────┐ │ │
│ │ ▼ ▼ │ │
│ │ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ Leaf node │ │ Container node │ │ │
│ │ │ No children │ │ Has children │ │ │
│ │ └──────┬──────┘ └────────┬────────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ Compute directly: Recurse then aggregate: │ │
│ │ • depth = 0 • depth = max(child.depth) + 1 │ │
│ │ • totalChildren = 0 • totalChildren = Σ(1 + child.total) │ │
│ │ • hasExcludeType • hasExcludeType = any(children) │ │
│ │ • hasImageFill • hasImageFill = any(children) │ │
│ │ • hasComplexEffects • hasComplexEffects = any(children) │ │
│ │ • allLeavesMergeable • allLeavesMergeable = all(children) │ │
│ │ • mergeableRatio • mergeableRatio = count/total │ │
│ │ │ │
│ │ Total traversals: n │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ Performance improvement: ~28% (64 → 82 nodes/ms) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Type Constants and Configuration
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Type Classification Constants │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CONTAINER_TYPES (Can contain icon children) │ │
│ │ ├── GROUP Figma group │ │
│ │ ├── FRAME Figma frame │ │
│ │ ├── COMPONENT Component definition │ │
│ │ └── INSTANCE Component instance │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ MERGEABLE_TYPES (Can be part of an icon) │ │
│ │ ├── VECTOR Vector path │ │
│ │ ├── RECTANGLE Rectangle │ │
│ │ ├── ELLIPSE Ellipse/Circle │ │
│ │ ├── LINE Line │ │
│ │ ├── POLYGON Polygon │ │
│ │ ├── STAR Star │ │
│ │ ├── BOOLEAN_OPERATION Boolean operation result │ │
│ │ └── REGULAR_POLYGON Regular polygon │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ EXCLUDE_TYPES (Presence prevents merging) │ │
│ │ ├── TEXT Text element (needs separate rendering) │ │
│ │ ├── COMPONENT Component definition (has logic) │ │
│ │ └── INSTANCE Component instance (may have interactions) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PNG_REQUIRED_EFFECTS (Require PNG format) │ │
│ │ ├── DROP_SHADOW Drop shadow │ │
│ │ ├── INNER_SHADOW Inner shadow │ │
│ │ ├── LAYER_BLUR Layer blur │ │
│ │ └── BACKGROUND_BLUR Background blur │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ Default Configuration │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ DEFAULT_CONFIG = { │
│ maxIconSize: 300, // Maximum icon size (px) │
│ minIconSize: 8, // Minimum icon size (px) │
│ mergeableRatio: 0.6, // Minimum mergeable ratio (60%) │
│ maxDepth: 5, // Maximum nesting depth │
│ maxChildren: 100, // Maximum child count │
│ respectExportSettingsMaxSize: 400 // Max size for designer settings │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Export Format Decision Tree
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Export Format Decision Logic │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────┐ │
│ │ Confirmed export │ │
│ │ shouldMerge=true │ │
│ └─────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ Designer set export format │ │
│ │ in Figma? │ │
│ │ node.exportSettings[0] │ │
│ └────────────────┬───────────────┘ │
│ │ │
│ YES───────────┴───────────NO │
│ │ │ │
│ ▼ │ │
│ ┌─────────────────────┐ │ │
│ │ Use designer format │ │ │
│ │ format = settings │ │ │
│ └─────────────────────┘ │ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ Tree contains image │ │
│ │ fills? │ │
│ │ hasImageFill = true │ │
│ └───────────┬────────────┘ │
│ │ │
│ YES──────────┴──────────NO │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────┐ ┌─────────────────────────┐ │
│ │ format = "PNG" │ │ Tree contains complex │ │
│ │ │ │ effects? │ │
│ │ Reason: │ │ hasComplexEffects=true │ │
│ │ Images can't be │ └───────────┬─────────────┘ │
│ │ vectorized │ │ │
│ └───────────────────┘ YES────────┴────────NO │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ format = "PNG" │ │ format = "SVG" │ │
│ │ │ │ │ │
│ │ Reason: │ │ Reason: │ │
│ │ Shadow/blur │ │ Pure vector │ │
│ │ effects can't │ │ Lossless scaling │ │
│ │ render in SVG │ │ │ │
│ └───────────────────┘ └───────────────────┘ │
│ │
│ ════════════════════════════════════════════════════════════════════════ │
│ │
│ PNG Priority Scenarios: SVG Priority Scenarios: │
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ • Contains bitmap fills │ │ • Pure vector paths │ │
│ │ • DROP_SHADOW │ │ • Simple shape combos │ │
│ │ • INNER_SHADOW │ │ • No complex effects │ │
│ │ • LAYER_BLUR │ │ • Needs lossless scaling │ │
│ │ • BACKGROUND_BLUR │ │ • Needs CSS coloring │ │
│ │ • Complex gradient+effects │ │ • Small file size needed │ │
│ └────────────────────────────┘ └────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Complete Call Chain
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Complete Call Chain Diagram │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ External Entry (parser.ts / server.ts) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ analyzeNodeTree(node) ││
│ │ ││
│ │ Params: node: FigmaNode, config?: DetectionConfig ││
│ │ Returns: { processedTree, exportableIcons, summary } ││
│ └────────────────────────────────┬────────────────────────────────────────┘│
│ │ │
│ ┌───────────────────────┴───────────────────────┐ │
│ ▼ ▼ │
│ ┌────────────────────────┐ ┌─────────────────────────────┐│
│ │ processNodeTree() │ │ collectExportableIcons() ││
│ │ │ │ ││
│ │ Recursive processing: │ After complete │ Traverse processed tree: ││
│ │ 1. Process children │ ────────────────▶│ 1. Has marker → add to list││
│ │ first │ │ 2. No marker → recurse ││
│ │ 2. Check if all │ │ 3. Return all exportable ││
│ │ children are icons │ │ icons ││
│ │ 3. Try parent promote │ │ ││
│ │ 4. Mark _iconDetection│ │ ││
│ └───────────┬────────────┘ └─────────────────────────────┘│
│ │ │
│ │ For each node │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ detectIcon(node) ││
│ │ ││
│ │ Core detection, executes 9 steps: ││
│ │ Step 1: exportSettings check ││
│ │ Step 2: Type check (container/mergeable) ││
│ │ Step 3: Size check (8-300px) ││
│ │ Step 4-8: Use collectNodeStats() for tree properties ││
│ │ Step 9: Determine export format (SVG/PNG) ││
│ └────────────────────────────────┬────────────────────────────────────────┘│
│ │ │
│ ┌────────────────────┴────────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────────────┐ ┌────────────────────────────────┐ │
│ │ collectNodeStats(node) │ │ Helper Functions: │ │
│ │ │ │ │ │
│ │ Single-pass collection │ │ • isContainerType() │ │
│ │ of 7 statistics: │ │ • isMergeableType() │ │
│ │ O(n) complexity │ │ • isExcludeType() │ │
│ │ │ │ • hasImageFill() │ │
│ │ Replaces 6 original │ │ • hasComplexEffects() │ │
│ │ recursive functions │ │ • getNodeSize() │ │
│ │ ~28% performance boost │ │ │ │
│ └─────────────────────────────┘ └────────────────────────────────┘ │
│ │
│ ════════════════════════════════════════════════════════════════════════ │
│ │
│ Return Structure: │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ { ││
│ │ processedTree: FigmaNode & { _iconDetection?: IconDetectionResult }, ││
│ │ exportableIcons: IconDetectionResult[], ││
│ │ summary: { ││
│ │ totalIcons: number, // Total exportable icons ││
│ │ svgCount: number, // SVG format count ││
│ │ pngCount: number // PNG format count ││
│ │ } ││
│ │ } ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
_Last updated: 2025-12-06 (Added complete implementation analysis)_
_For the Chinese version of this document, see [docs/zh-CN/icon-detection.md](../zh-CN/icon-detection.md)_
```
--------------------------------------------------------------------------------
/src/algorithms/layout/detector.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Layout Detection Algorithm
*
* Infers Flex layout from absolutely positioned design elements.
*
* Core algorithm flow:
* 1. Extract element bounding boxes
* 2. Group by Y-axis overlap into "rows"
* 3. Group by X-axis overlap into "columns"
* 4. Analyze gap consistency
* 5. Detect alignment
* 6. Recursively build layout tree
*
* @module algorithms/layout/detector
*/
// ==================== Type Definitions ====================
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
export interface ElementRect extends BoundingBox {
index: number;
right: number;
bottom: number;
centerX: number;
centerY: number;
}
export interface LayoutGroup {
elements: ElementRect[];
direction: "row" | "column" | "none";
gap: number;
isGapConsistent: boolean;
justifyContent: string;
alignItems: string;
bounds: BoundingBox;
}
export interface LayoutAnalysisResult {
direction: "row" | "column" | "none";
confidence: number;
gap: number;
isGapConsistent: boolean;
justifyContent: string;
alignItems: string;
rows: ElementRect[][];
columns: ElementRect[][];
overlappingElements: ElementRect[];
}
// ==================== Bounding Box Utilities ====================
/**
* Extract bounding box from CSS styles object
*/
export function extractBoundingBox(cssStyles: Record<string, unknown>): BoundingBox | null {
if (!cssStyles) return null;
const x = parseFloat(String(cssStyles.left || "0").replace("px", ""));
const y = parseFloat(String(cssStyles.top || "0").replace("px", ""));
const width = parseFloat(String(cssStyles.width || "0").replace("px", ""));
const height = parseFloat(String(cssStyles.height || "0").replace("px", ""));
if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) {
return null;
}
return { x, y, width, height };
}
/**
* Convert bounding box to element rect (with computed properties)
*/
export function toElementRect(box: BoundingBox, index: number): ElementRect {
return {
...box,
index,
right: box.x + box.width,
bottom: box.y + box.height,
centerX: box.x + box.width / 2,
centerY: box.y + box.height / 2,
};
}
/**
* Calculate bounding rect of a group of elements
*/
export function calculateBounds(rects: ElementRect[]): BoundingBox {
if (rects.length === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
const minX = Math.min(...rects.map((r) => r.x));
const minY = Math.min(...rects.map((r) => r.y));
const maxX = Math.max(...rects.map((r) => r.right));
const maxY = Math.max(...rects.map((r) => r.bottom));
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
// ==================== Overlap Detection ====================
/**
* Check if two elements overlap on Y-axis (for row detection)
* If two elements have overlapping vertical ranges, they are in the same row
*/
export function isOverlappingY(a: ElementRect, b: ElementRect, tolerance: number = 0): boolean {
return !(a.bottom + tolerance < b.y || b.bottom + tolerance < a.y);
}
/**
* Check if two elements overlap on X-axis (for column detection)
* If two elements have overlapping horizontal ranges, they are in the same column
*/
export function isOverlappingX(a: ElementRect, b: ElementRect, tolerance: number = 0): boolean {
return !(a.right + tolerance < b.x || b.right + tolerance < a.x);
}
/**
* Check if two elements fully overlap (requires absolute positioning)
*/
export function isFullyOverlapping(
a: ElementRect,
b: ElementRect,
threshold: number = 0.5,
): boolean {
const overlapX = Math.max(0, Math.min(a.right, b.right) - Math.max(a.x, b.x));
const overlapY = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.y, b.y));
const overlapArea = overlapX * overlapY;
const areaA = a.width * a.height;
const areaB = b.width * b.height;
const minArea = Math.min(areaA, areaB);
return minArea > 0 && overlapArea / minArea > threshold;
}
/**
* Calculate IoU (Intersection over Union) between two elements
*
* IoU is a standard metric for measuring overlap:
* - IoU = 0: No overlap
* - IoU = 1: Perfect overlap (same box)
*
* Industry standard thresholds:
* - IoU > 0.1: Partial overlap (consider absolute positioning)
* - IoU > 0.5: Significant overlap (definitely needs absolute)
*/
export function calculateIoU(a: ElementRect, b: ElementRect): number {
// Calculate intersection
const xOverlap = Math.max(0, Math.min(a.right, b.right) - Math.max(a.x, b.x));
const yOverlap = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.y, b.y));
const intersection = xOverlap * yOverlap;
if (intersection === 0) return 0;
// Calculate union
const areaA = a.width * a.height;
const areaB = b.width * b.height;
const union = areaA + areaB - intersection;
return union > 0 ? intersection / union : 0;
}
/**
* Overlap type classification based on IoU
*/
export type OverlapType = "none" | "adjacent" | "partial" | "significant" | "contained";
/**
* Classify overlap type between two elements
*
* @param a First element
* @param b Second element
* @returns Overlap classification
*/
export function classifyOverlap(a: ElementRect, b: ElementRect): OverlapType {
const iou = calculateIoU(a, b);
if (iou === 0) {
// Check if adjacent (touching but not overlapping)
// Calculate gap on each axis
const gapX = Math.max(a.x, b.x) - Math.min(a.right, b.right);
const gapY = Math.max(a.y, b.y) - Math.min(a.bottom, b.bottom);
// Elements are adjacent only if gap on the separating axis is small
// If they overlap on one axis (gap < 0), check gap on the other axis
let effectiveGap: number;
if (gapX > 0 && gapY > 0) {
// Don't overlap on either axis - use the maximum gap (corner distance)
effectiveGap = Math.max(gapX, gapY);
} else if (gapX > 0) {
// Don't overlap on X, but overlap on Y
effectiveGap = gapX;
} else if (gapY > 0) {
// Don't overlap on Y, but overlap on X
effectiveGap = gapY;
} else {
// This shouldn't happen if IoU is 0, but handle it
effectiveGap = 0;
}
return effectiveGap <= 2 ? "adjacent" : "none";
}
if (iou < 0.1) return "partial";
if (iou < 0.5) return "significant";
return "contained";
}
/**
* Overlap detection result
*/
export interface OverlapDetectionResult {
/** Elements that can participate in flow layout (flex/grid) */
flowElements: ElementRect[];
/** Elements that need absolute positioning due to overlap */
stackedElements: ElementRect[];
/** Indices of stacked elements */
stackedIndices: Set<number>;
}
/**
* Detect overlapping elements and separate them from flow elements
*
* Uses IoU (Intersection over Union) to detect overlaps:
* - IoU > 0.1: Element is considered overlapping and needs absolute positioning
*
* This follows the imgcook algorithm approach where overlapping elements
* are marked for absolute positioning while the rest participate in flex/grid layout.
*
* @param rects Element rectangles to analyze
* @param iouThreshold IoU threshold for overlap detection (default: 0.1)
* @returns Separated flow and stacked elements
*/
export function detectOverlappingElements(
rects: ElementRect[],
iouThreshold: number = 0.1,
): OverlapDetectionResult {
const stackedIndices = new Set<number>();
// Check each pair of elements for overlap
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const iou = calculateIoU(rects[i], rects[j]);
if (iou > iouThreshold) {
// Both overlapping elements need absolute positioning
stackedIndices.add(rects[i].index);
stackedIndices.add(rects[j].index);
}
}
}
// Separate elements into flow and stacked groups
const flowElements: ElementRect[] = [];
const stackedElements: ElementRect[] = [];
for (const rect of rects) {
if (stackedIndices.has(rect.index)) {
stackedElements.push(rect);
} else {
flowElements.push(rect);
}
}
return {
flowElements,
stackedElements,
stackedIndices,
};
}
/**
* Background element detection result
*/
export interface BackgroundDetectionResult {
/** Index of background element (or -1 if none) */
backgroundIndex: number;
/** Indices of content elements */
contentIndices: number[];
/** Whether a valid background was detected */
hasBackground: boolean;
}
/**
* Detect if a container has a background element pattern
*
* Background element pattern:
* - Element at position 0,0 (or very close)
* - Same size as parent container (or very close)
* - Typically a RECTANGLE type
* - Other elements are positioned on top of it
*
* @param rects All child element rectangles
* @param parentWidth Parent container width
* @param parentHeight Parent container height
* @returns Background detection result
*/
export function detectBackgroundElement(
rects: ElementRect[],
parentWidth: number,
parentHeight: number,
): BackgroundDetectionResult {
const emptyResult: BackgroundDetectionResult = {
backgroundIndex: -1,
contentIndices: rects.map((r) => r.index),
hasBackground: false,
};
if (rects.length < 2) return emptyResult;
// Find element at origin that matches parent size
for (const rect of rects) {
// Must be at origin (within 2px tolerance)
if (rect.x > 2 || rect.y > 2) continue;
// Must match parent size (within 5% tolerance)
const widthMatch = Math.abs(rect.width - parentWidth) / parentWidth < 0.05;
const heightMatch = Math.abs(rect.height - parentHeight) / parentHeight < 0.05;
if (widthMatch && heightMatch) {
// Found background element
const contentIndices = rects.filter((r) => r.index !== rect.index).map((r) => r.index);
return {
backgroundIndex: rect.index,
contentIndices,
hasBackground: true,
};
}
}
return emptyResult;
}
// ==================== Row/Column Grouping Algorithm ====================
/**
* Group elements by Y-axis overlap into "rows"
* Core algorithm: if two elements overlap on Y-axis, they belong to the same row
*/
export function groupIntoRows(rects: ElementRect[], tolerance: number = 2): ElementRect[][] {
if (rects.length === 0) return [];
if (rects.length === 1) return [[rects[0]]];
// Sort by Y coordinate
const sorted = [...rects].sort((a, b) => a.y - b.y);
const rows: ElementRect[][] = [];
let currentRow: ElementRect[] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const elem = sorted[i];
// Check if overlaps with any element in current row on Y-axis
const overlapsWithRow = currentRow.some((rowElem) => isOverlappingY(rowElem, elem, tolerance));
if (overlapsWithRow) {
currentRow.push(elem);
} else {
// Current row complete, sort by X and save
rows.push(currentRow.sort((a, b) => a.x - b.x));
currentRow = [elem];
}
}
// Save last row
if (currentRow.length > 0) {
rows.push(currentRow.sort((a, b) => a.x - b.x));
}
return rows;
}
/**
* Group elements by X-axis overlap into "columns"
* Core algorithm: if two elements overlap on X-axis, they belong to the same column
*/
export function groupIntoColumns(rects: ElementRect[], tolerance: number = 2): ElementRect[][] {
if (rects.length === 0) return [];
if (rects.length === 1) return [[rects[0]]];
// Sort by X coordinate
const sorted = [...rects].sort((a, b) => a.x - b.x);
const columns: ElementRect[][] = [];
let currentColumn: ElementRect[] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const elem = sorted[i];
// Check if overlaps with any element in current column on X-axis
const overlapsWithColumn = currentColumn.some((colElem) =>
isOverlappingX(colElem, elem, tolerance),
);
if (overlapsWithColumn) {
currentColumn.push(elem);
} else {
// Current column complete, sort by Y and save
columns.push(currentColumn.sort((a, b) => a.y - b.y));
currentColumn = [elem];
}
}
// Save last column
if (currentColumn.length > 0) {
columns.push(currentColumn.sort((a, b) => a.y - b.y));
}
return columns;
}
/**
* Find fully overlapping elements (requires absolute positioning)
*/
export function findOverlappingElements(rects: ElementRect[]): ElementRect[] {
const overlapping: Set<number> = new Set();
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
if (isFullyOverlapping(rects[i], rects[j])) {
overlapping.add(rects[i].index);
overlapping.add(rects[j].index);
}
}
}
return rects.filter((r) => overlapping.has(r.index));
}
// ==================== Gap Analysis ====================
/**
* Calculate gaps between a group of elements
*/
export function calculateGaps(
rects: ElementRect[],
direction: "horizontal" | "vertical",
): number[] {
if (rects.length < 2) return [];
const sorted =
direction === "horizontal"
? [...rects].sort((a, b) => a.x - b.x)
: [...rects].sort((a, b) => a.y - b.y);
const gaps: number[] = [];
for (let i = 0; i < sorted.length - 1; i++) {
const current = sorted[i];
const next = sorted[i + 1];
const gap = direction === "horizontal" ? next.x - current.right : next.y - current.bottom;
// Only record positive gaps
if (gap >= 0) {
gaps.push(gap);
}
}
return gaps;
}
/**
* Analyze gap consistency
*/
export function analyzeGaps(
gaps: number[],
tolerancePercent: number = 20,
): {
isConsistent: boolean;
average: number;
rounded: number;
stdDev: number;
} {
if (gaps.length === 0) {
return { isConsistent: true, average: 0, rounded: 0, stdDev: 0 };
}
if (gaps.length === 1) {
const rounded = roundToCommonValue(gaps[0]);
return { isConsistent: true, average: gaps[0], rounded, stdDev: 0 };
}
const sum = gaps.reduce((a, b) => a + b, 0);
const average = sum / gaps.length;
const variance = gaps.reduce((acc, gap) => acc + Math.pow(gap - average, 2), 0) / gaps.length;
const stdDev = Math.sqrt(variance);
// Consistency check: standard deviation less than specified percentage of average
const tolerance = average * (tolerancePercent / 100);
const isConsistent = average === 0 || stdDev <= tolerance;
const rounded = roundToCommonValue(average);
return { isConsistent, average, rounded, stdDev };
}
/**
* Round gap to common design values
*/
export function roundToCommonValue(value: number): number {
const COMMON_VALUES = [0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96, 128];
// Find closest common value
let closest = COMMON_VALUES[0];
let minDiff = Math.abs(value - closest);
for (const common of COMMON_VALUES) {
const diff = Math.abs(value - common);
if (diff < minDiff) {
minDiff = diff;
closest = common;
}
}
// If difference is too large (> 4px), use rounded value
if (minDiff > 4) {
return Math.round(value);
}
return closest;
}
// ==================== Alignment Detection ====================
/**
* Check if a group of values are aligned
*/
export function areValuesAligned(values: number[], tolerance: number = 3): boolean {
if (values.length < 2) return true;
const first = values[0];
return values.every((v) => Math.abs(v - first) <= tolerance);
}
/**
* Analyze alignment
*/
export function analyzeAlignment(
rects: ElementRect[],
bounds: BoundingBox,
): {
horizontal: "left" | "center" | "right" | "stretch" | "none";
vertical: "top" | "center" | "bottom" | "stretch" | "none";
} {
if (rects.length === 0) {
return { horizontal: "none", vertical: "none" };
}
const tolerance = Math.max(3, Math.min(bounds.width, bounds.height) * 0.02);
// Horizontal alignment analysis
const lefts = rects.map((r) => r.x);
const rights = rects.map((r) => r.right);
const centerXs = rects.map((r) => r.centerX);
const widths = rects.map((r) => r.width);
let horizontal: "left" | "center" | "right" | "stretch" | "none" = "none";
if (areValuesAligned(lefts, tolerance)) {
horizontal = "left";
} else if (areValuesAligned(rights, tolerance)) {
horizontal = "right";
} else if (areValuesAligned(centerXs, tolerance)) {
horizontal = "center";
} else if (areValuesAligned(widths, tolerance) && widths[0] >= bounds.width * 0.9) {
horizontal = "stretch";
}
// Vertical alignment analysis
const tops = rects.map((r) => r.y);
const bottoms = rects.map((r) => r.bottom);
const centerYs = rects.map((r) => r.centerY);
const heights = rects.map((r) => r.height);
let vertical: "top" | "center" | "bottom" | "stretch" | "none" = "none";
if (areValuesAligned(tops, tolerance)) {
vertical = "top";
} else if (areValuesAligned(bottoms, tolerance)) {
vertical = "bottom";
} else if (areValuesAligned(centerYs, tolerance)) {
vertical = "center";
} else if (areValuesAligned(heights, tolerance) && heights[0] >= bounds.height * 0.9) {
vertical = "stretch";
}
return { horizontal, vertical };
}
/**
* Convert alignment to CSS justify-content value
*/
export function toJustifyContent(alignment: string, hasGaps: boolean): string {
switch (alignment) {
case "left":
case "top":
return "flex-start";
case "right":
case "bottom":
return "flex-end";
case "center":
return "center";
case "stretch":
return hasGaps ? "space-between" : "flex-start";
default:
return "flex-start";
}
}
/**
* Convert alignment to CSS align-items value
*/
export function toAlignItems(alignment: string): string {
switch (alignment) {
case "left":
case "top":
return "flex-start";
case "right":
case "bottom":
return "flex-end";
case "center":
return "center";
case "stretch":
return "stretch";
default:
return "stretch";
}
}
// ==================== Layout Direction Detection ====================
/**
* Detect optimal layout direction
* Core logic: compare quality of row grouping vs column grouping
*/
export function detectLayoutDirection(rects: ElementRect[]): {
direction: "row" | "column" | "none";
confidence: number;
reason: string;
} {
if (rects.length < 2) {
return { direction: "none", confidence: 0, reason: "Insufficient elements" };
}
const rows = groupIntoRows(rects);
const columns = groupIntoColumns(rects);
// Calculate row layout score
const rowScore = calculateLayoutScore(rows, "row", rects.length);
// Calculate column layout score
const columnScore = calculateLayoutScore(columns, "column", rects.length);
// Select layout with higher score
if (rowScore.score > columnScore.score && rowScore.score > 0.3) {
return {
direction: "row",
confidence: rowScore.score,
reason: rowScore.reason,
};
} else if (columnScore.score > rowScore.score && columnScore.score > 0.3) {
return {
direction: "column",
confidence: columnScore.score,
reason: columnScore.reason,
};
}
return { direction: "none", confidence: 0, reason: "No clear layout pattern" };
}
/**
* Calculate layout score
*/
function calculateLayoutScore(
groups: ElementRect[][],
direction: "row" | "column",
totalElements: number,
): { score: number; reason: string } {
if (groups.length === 0) {
return { score: 0, reason: "No groups" };
}
// Score factors:
// 1. Group count rationality (ideal: one or few groups per row/column)
// 2. Gap consistency within each group
// 3. Element coverage
let score = 0;
const reasons: string[] = [];
// 1. For row layout, ideal is single row (all elements horizontal)
// For column layout, ideal is single column (all elements vertical)
if (groups.length === 1 && groups[0].length === totalElements) {
score += 0.4;
reasons.push("Perfect grouping");
} else if (groups.length <= 3) {
score += 0.2;
reasons.push("Reasonable grouping");
}
// 2. Analyze gap consistency
for (const group of groups) {
if (group.length >= 2) {
const gapDirection = direction === "row" ? "horizontal" : "vertical";
const gaps = calculateGaps(group, gapDirection);
const gapAnalysis = analyzeGaps(gaps);
if (gapAnalysis.isConsistent && gaps.length > 0) {
score += 0.3 / groups.length;
reasons.push(`Consistent gap (${Math.round(gapAnalysis.average)}px)`);
}
}
}
// 3. Check cross-axis alignment
for (const group of groups) {
if (group.length >= 2) {
const bounds = calculateBounds(group);
const alignment = analyzeAlignment(group, bounds);
const crossAlignment = direction === "row" ? alignment.vertical : alignment.horizontal;
if (crossAlignment !== "none") {
score += 0.2 / groups.length;
reasons.push(`Good alignment (${crossAlignment})`);
}
}
}
// 4. Check main axis element distribution
const largestGroup = groups.reduce((a, b) => (a.length > b.length ? a : b));
if (largestGroup.length >= totalElements * 0.7) {
score += 0.1;
reasons.push("Concentrated distribution");
}
return {
score: Math.min(1, score),
reason: reasons.join(", ") || "No obvious features",
};
}
// ==================== Complete Layout Analysis ====================
/**
* Complete layout analysis
* Returns layout direction, gap, alignment, and all other information
*/
export function analyzeLayout(rects: ElementRect[]): LayoutAnalysisResult {
if (rects.length < 2) {
return {
direction: "none",
confidence: 0,
gap: 0,
isGapConsistent: true,
justifyContent: "flex-start",
alignItems: "stretch",
rows: [rects],
columns: [rects],
overlappingElements: [],
};
}
// Detect overlapping elements
const overlappingElements = findOverlappingElements(rects);
// Analyze layout after filtering out overlapping elements
const nonOverlapping = rects.filter((r) => !overlappingElements.some((o) => o.index === r.index));
if (nonOverlapping.length < 2) {
return {
direction: "none",
confidence: 0,
gap: 0,
isGapConsistent: true,
justifyContent: "flex-start",
alignItems: "stretch",
rows: [rects],
columns: [rects],
overlappingElements,
};
}
// Detect layout direction
const { direction, confidence } = detectLayoutDirection(nonOverlapping);
// Grouping
const rows = groupIntoRows(nonOverlapping);
const columns = groupIntoColumns(nonOverlapping);
// Calculate gaps
const gapDirection = direction === "row" ? "horizontal" : "vertical";
const gaps = calculateGaps(
direction === "row"
? nonOverlapping.sort((a, b) => a.x - b.x)
: nonOverlapping.sort((a, b) => a.y - b.y),
gapDirection,
);
const gapAnalysis = analyzeGaps(gaps);
// Analyze alignment
const bounds = calculateBounds(nonOverlapping);
const alignment = analyzeAlignment(nonOverlapping, bounds);
// Determine CSS properties
let justifyContent: string;
let alignItems: string;
if (direction === "row") {
justifyContent = toJustifyContent(alignment.horizontal, gaps.length > 0);
alignItems = toAlignItems(alignment.vertical);
} else if (direction === "column") {
justifyContent = toJustifyContent(alignment.vertical, gaps.length > 0);
alignItems = toAlignItems(alignment.horizontal);
} else {
justifyContent = "flex-start";
alignItems = "stretch";
}
return {
direction,
confidence,
gap: gapAnalysis.rounded,
isGapConsistent: gapAnalysis.isConsistent,
justifyContent,
alignItems,
rows,
columns,
overlappingElements,
};
}
// ==================== Recursive Layout Tree Building ====================
export interface LayoutNode {
type: "container" | "element";
direction?: "row" | "column";
gap?: number;
justifyContent?: string;
alignItems?: string;
children?: LayoutNode[];
elementIndex?: number;
bounds: BoundingBox;
needsAbsolute?: boolean;
}
/**
* Recursively build layout tree
* Convert flat element list to nested layout structure
*/
export function buildLayoutTree(
rects: ElementRect[],
depth: number = 0,
maxDepth: number = 5,
): LayoutNode {
const bounds = calculateBounds(rects);
// Single element returns directly
if (rects.length === 1) {
return {
type: "element",
elementIndex: rects[0].index,
bounds,
};
}
// Max depth reached, return simple container
if (depth >= maxDepth) {
return {
type: "container",
direction: "column",
children: rects.map((r) => ({
type: "element" as const,
elementIndex: r.index,
bounds: { x: r.x, y: r.y, width: r.width, height: r.height },
})),
bounds,
};
}
// Analyze layout
const analysis = analyzeLayout(rects);
// Handle overlapping elements
const overlappingNodes: LayoutNode[] = analysis.overlappingElements.map((r) => ({
type: "element" as const,
elementIndex: r.index,
bounds: { x: r.x, y: r.y, width: r.width, height: r.height },
needsAbsolute: true,
}));
// Filter out overlapping elements
const nonOverlapping = rects.filter(
(r) => !analysis.overlappingElements.some((o) => o.index === r.index),
);
if (nonOverlapping.length === 0) {
// All elements overlap
return {
type: "container",
children: overlappingNodes,
bounds,
};
}
if (analysis.direction === "none" || analysis.confidence < 0.3) {
// No clear layout, use default vertical layout
return {
type: "container",
direction: "column",
children: [
...nonOverlapping.map((r) => ({
type: "element" as const,
elementIndex: r.index,
bounds: { x: r.x, y: r.y, width: r.width, height: r.height },
})),
...overlappingNodes,
],
bounds,
};
}
// Group by layout direction
const groups = analysis.direction === "row" ? analysis.rows : analysis.columns;
// Recursively process each group
const children: LayoutNode[] = groups.map((group) => {
if (group.length === 1) {
return {
type: "element" as const,
elementIndex: group[0].index,
bounds: { x: group[0].x, y: group[0].y, width: group[0].width, height: group[0].height },
};
}
// For multi-element groups, check if further analysis needed (cross direction)
const crossDirection = analysis.direction === "row" ? "column" : "row";
const crossGroups = crossDirection === "row" ? groupIntoRows(group) : groupIntoColumns(group);
if (crossGroups.length > 1) {
// Nested layout needed
return buildLayoutTree(group, depth + 1, maxDepth);
}
// Simple group, no nesting needed
const groupBounds = calculateBounds(group);
return {
type: "container" as const,
direction: crossDirection,
children: group.map((r) => ({
type: "element" as const,
elementIndex: r.index,
bounds: { x: r.x, y: r.y, width: r.width, height: r.height },
})),
bounds: groupBounds,
};
});
return {
type: "container",
direction: analysis.direction,
gap: analysis.isGapConsistent && analysis.gap > 0 ? analysis.gap : undefined,
justifyContent: analysis.justifyContent !== "flex-start" ? analysis.justifyContent : undefined,
alignItems: analysis.alignItems !== "stretch" ? analysis.alignItems : undefined,
children: [...children, ...overlappingNodes],
bounds,
};
}
// ==================== Grid Layout Detection ====================
/**
* Result of grid layout analysis
*/
export interface GridAnalysisResult {
/** Whether elements form a valid grid layout */
isGrid: boolean;
/** Confidence score (0-1) for grid detection */
confidence: number;
/** Number of rows in the grid */
rowCount: number;
/** Number of columns in the grid */
columnCount: number;
/** Gap between rows in pixels */
rowGap: number;
/** Gap between columns in pixels */
columnGap: number;
/** Whether row gap is consistent */
isRowGapConsistent: boolean;
/** Whether column gap is consistent */
isColumnGapConsistent: boolean;
/** Width of each column track */
trackWidths: number[];
/** Height of each row track */
trackHeights: number[];
/** X positions where columns are aligned */
alignedColumnPositions: number[];
/** Grouped rows of elements */
rows: ElementRect[][];
/** Map of element indices to grid cells [row][col] */
cellMap: (number | null)[][];
}
// ==================== Homogeneity Detection ====================
/**
* Result of homogeneity analysis for a group of elements
*/
export interface HomogeneityResult {
/** Whether the group is homogeneous (similar size/type) */
isHomogeneous: boolean;
/** Coefficient of variation for widths (lower = more similar) */
widthCV: number;
/** Coefficient of variation for heights (lower = more similar) */
heightCV: number;
/** Unique element types in the group */
types: string[];
/** Elements that belong to the homogeneous group */
homogeneousElements: ElementRect[];
/** Elements that don't fit the homogeneous pattern */
outlierElements: ElementRect[];
}
/**
* Size cluster for grouping elements by similar dimensions
*/
export interface SizeCluster {
/** Representative width for this cluster */
width: number;
/** Representative height for this cluster */
height: number;
/** Elements belonging to this cluster */
elements: ElementRect[];
/** Original node types (if available) */
types?: string[];
}
/**
* Calculate coefficient of variation (CV) for a set of values
* CV = stddev / mean, lower values indicate more consistency
* Returns 0 for single values or empty arrays
*/
export function coefficientOfVariation(values: number[]): number {
if (values.length <= 1) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
if (mean === 0) return 0;
const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length;
const stddev = Math.sqrt(variance);
return stddev / mean;
}
/**
* Cluster elements by similar size (width × height)
* Groups elements whose dimensions are within the tolerance percentage
*
* @param rects - Elements to cluster
* @param tolerancePercent - Size tolerance as decimal (0.2 = 20%)
* @returns Array of size clusters, sorted by element count (largest first)
*/
export function clusterBySimilarSize(
rects: ElementRect[],
tolerancePercent: number = 0.2,
): SizeCluster[] {
if (rects.length === 0) return [];
const clusters: SizeCluster[] = [];
for (const rect of rects) {
let foundCluster = false;
for (const cluster of clusters) {
// Check if rect dimensions are within tolerance of cluster
const widthDiff = Math.abs(rect.width - cluster.width) / Math.max(cluster.width, 1);
const heightDiff = Math.abs(rect.height - cluster.height) / Math.max(cluster.height, 1);
if (widthDiff <= tolerancePercent && heightDiff <= tolerancePercent) {
cluster.elements.push(rect);
// Update cluster center to average
const allWidths = cluster.elements.map((e) => e.width);
const allHeights = cluster.elements.map((e) => e.height);
cluster.width = allWidths.reduce((a, b) => a + b, 0) / allWidths.length;
cluster.height = allHeights.reduce((a, b) => a + b, 0) / allHeights.length;
foundCluster = true;
break;
}
}
if (!foundCluster) {
clusters.push({
width: rect.width,
height: rect.height,
elements: [rect],
});
}
}
// Sort by element count (largest cluster first)
return clusters.sort((a, b) => b.elements.length - a.elements.length);
}
/**
* Check if a group of elements is homogeneous
* Homogeneous = similar sizes, compatible types
*
* @param rects - Elements to check
* @param nodeTypes - Optional array of node types corresponding to rects
* @param sizeToleranceCV - Max coefficient of variation for size (default 0.2 = 20%)
* @returns Homogeneity analysis result
*/
export function analyzeHomogeneity(
rects: ElementRect[],
nodeTypes?: string[],
sizeToleranceCV: number = 0.2,
): HomogeneityResult {
const emptyResult: HomogeneityResult = {
isHomogeneous: false,
widthCV: 1,
heightCV: 1,
types: [],
homogeneousElements: [],
outlierElements: rects,
};
if (rects.length < 4) {
return emptyResult;
}
// 1. Cluster by size first
const sizeClusters = clusterBySimilarSize(rects, sizeToleranceCV);
// If no cluster has 4+ elements, not homogeneous enough for grid
const largestCluster = sizeClusters[0];
if (!largestCluster || largestCluster.elements.length < 4) {
return emptyResult;
}
// 2. Calculate CV for the largest cluster
const widths = largestCluster.elements.map((e) => e.width);
const heights = largestCluster.elements.map((e) => e.height);
const widthCV = coefficientOfVariation(widths);
const heightCV = coefficientOfVariation(heights);
// 3. Check type consistency if provided
let types: string[] = [];
if (nodeTypes) {
const clusterIndices = new Set(largestCluster.elements.map((e) => e.index));
types = [...new Set(nodeTypes.filter((_, i) => clusterIndices.has(i)))];
// Allow compatible container types
const allowedTypes = new Set(["FRAME", "INSTANCE", "COMPONENT", "GROUP", "RECTANGLE"]);
const hasIncompatibleType = types.some((t) => !allowedTypes.has(t));
if (hasIncompatibleType) {
return {
...emptyResult,
widthCV,
heightCV,
types,
};
}
}
// 4. Determine if homogeneous
const isHomogeneous = widthCV <= sizeToleranceCV && heightCV <= sizeToleranceCV;
// 5. Separate homogeneous elements from outliers
const homogeneousSet = new Set(largestCluster.elements.map((e) => e.index));
const homogeneousElements = rects.filter((r) => homogeneousSet.has(r.index));
const outlierElements = rects.filter((r) => !homogeneousSet.has(r.index));
return {
isHomogeneous,
widthCV,
heightCV,
types,
homogeneousElements,
outlierElements,
};
}
/**
* Result of filtering homogeneous elements for grid detection
*/
export interface HomogeneousFilterResult {
/** Elements suitable for grid detection */
elements: ElementRect[];
/** Indices of grid elements in the original array */
gridIndices: Set<number>;
}
/**
* Filter elements for grid detection by keeping only homogeneous groups
* This prevents mixed layouts from being incorrectly detected as grids
*
* @param rects - All child elements
* @param nodeTypes - Optional node types for additional filtering
* @returns Elements suitable for grid detection with their indices, or empty if not homogeneous
*/
export function filterHomogeneousForGrid(
rects: ElementRect[],
nodeTypes?: string[],
): HomogeneousFilterResult {
const analysis = analyzeHomogeneity(rects, nodeTypes);
if (analysis.isHomogeneous && analysis.homogeneousElements.length >= 4) {
// Extract indices from homogeneous elements
const gridIndices = new Set(analysis.homogeneousElements.map((el) => el.index));
return {
elements: analysis.homogeneousElements,
gridIndices,
};
}
return {
elements: [],
gridIndices: new Set(),
};
}
/**
* A cluster of similar values
*/
interface ValueCluster {
center: number;
values: number[];
count: number;
}
/**
* Column alignment analysis result
*/
interface ColumnAlignmentResult {
isAligned: boolean;
alignedPositions: number[];
columnCount: number;
}
/**
* Cluster similar values together
* Used to find aligned column positions across rows
*/
export function clusterValues(values: number[], tolerance: number = 3): ValueCluster[] {
if (values.length === 0) return [];
const sorted = [...values].sort((a, b) => a - b);
const clusters: ValueCluster[] = [];
let currentCluster: ValueCluster = {
center: sorted[0],
values: [sorted[0]],
count: 1,
};
for (let i = 1; i < sorted.length; i++) {
const value = sorted[i];
// Check if value is within tolerance of cluster center
if (Math.abs(value - currentCluster.center) <= tolerance) {
currentCluster.values.push(value);
currentCluster.count++;
// Update center to be the average
currentCluster.center =
currentCluster.values.reduce((a, b) => a + b, 0) / currentCluster.values.length;
} else {
// Start new cluster
clusters.push(currentCluster);
currentCluster = {
center: value,
values: [value],
count: 1,
};
}
}
clusters.push(currentCluster);
return clusters;
}
/**
* Extract X positions of elements in each row
*/
function extractColumnPositions(rows: ElementRect[][]): number[][] {
return rows.map((row) => row.map((el) => el.x).sort((a, b) => a - b));
}
/**
* Check if columns are aligned across rows
* Returns aligned column positions if alignment is detected
*/
function checkColumnAlignment(rows: ElementRect[][], tolerance: number = 3): ColumnAlignmentResult {
if (rows.length < 2) {
return { isAligned: false, alignedPositions: [], columnCount: 0 };
}
// Get column positions for each row
const columnPositions = extractColumnPositions(rows);
// Collect all X positions
const allPositions = columnPositions.flat();
// Cluster the positions
const clusters = clusterValues(allPositions, tolerance);
// Get aligned positions (cluster centers)
const alignedPositions = clusters.map((c) => Math.round(c.center)).sort((a, b) => a - b);
// Verify that most rows have elements at the aligned positions
let alignedRows = 0;
for (const row of rows) {
const rowPositions = row.map((el) => el.x);
const rowAligned = rowPositions.every((x) =>
alignedPositions.some((ap) => Math.abs(x - ap) <= tolerance),
);
if (rowAligned) alignedRows++;
}
const alignmentRatio = alignedRows / rows.length;
return {
isAligned: alignmentRatio >= 0.8,
alignedPositions,
columnCount: alignedPositions.length,
};
}
/**
* Calculate row gaps (vertical spacing between rows)
*/
function calculateRowGaps(rows: ElementRect[][]): number[] {
if (rows.length < 2) return [];
const gaps: number[] = [];
for (let i = 0; i < rows.length - 1; i++) {
const currentRowBottom = Math.max(...rows[i].map((el) => el.bottom));
const nextRowTop = Math.min(...rows[i + 1].map((el) => el.y));
const gap = nextRowTop - currentRowBottom;
if (gap >= 0) gaps.push(gap);
}
return gaps;
}
/**
* Calculate column gaps (horizontal spacing between columns)
*/
function calculateColumnGaps(alignedPositions: number[], rows: ElementRect[][]): number[] {
if (alignedPositions.length < 2) return [];
const gaps: number[] = [];
// For each row, calculate gaps between adjacent elements
for (const row of rows) {
const sortedByX = [...row].sort((a, b) => a.x - b.x);
for (let i = 0; i < sortedByX.length - 1; i++) {
const gap = sortedByX[i + 1].x - sortedByX[i].right;
if (gap >= 0) gaps.push(gap);
}
}
return gaps;
}
/**
* Calculate track widths (column widths)
*/
function calculateTrackWidths(
rows: ElementRect[][],
alignedPositions: number[],
tolerance: number = 3,
): number[] {
// Group elements by column
const columns: ElementRect[][] = alignedPositions.map(() => []);
for (const row of rows) {
for (const el of row) {
const colIndex = alignedPositions.findIndex((pos) => Math.abs(el.x - pos) <= tolerance);
if (colIndex >= 0) {
columns[colIndex].push(el);
}
}
}
// Calculate width for each column (use max width in column)
return columns.map((col) => {
if (col.length === 0) return 0;
const widths = col.map((el) => el.width);
return roundToCommonValue(Math.max(...widths));
});
}
/**
* Calculate track heights (row heights)
*/
function calculateTrackHeights(rows: ElementRect[][]): number[] {
return rows.map((row) => {
if (row.length === 0) return 0;
const heights = row.map((el) => el.height);
return roundToCommonValue(Math.max(...heights));
});
}
/**
* Build cell map - maps grid positions to element indices
*/
function buildCellMap(
rows: ElementRect[][],
alignedPositions: number[],
tolerance: number = 3,
): (number | null)[][] {
const cellMap: (number | null)[][] = [];
for (const row of rows) {
const rowCells: (number | null)[] = new Array(alignedPositions.length).fill(null);
for (const el of row) {
const colIndex = alignedPositions.findIndex((pos) => Math.abs(el.x - pos) <= tolerance);
if (colIndex >= 0) {
rowCells[colIndex] = el.index;
}
}
cellMap.push(rowCells);
}
return cellMap;
}
/**
* Calculate grid confidence score
*/
function calculateGridConfidence(
rows: ElementRect[][],
columnAlignment: ColumnAlignmentResult,
rowGapAnalysis: { isConsistent: boolean; rounded: number },
columnGapAnalysis: { isConsistent: boolean; rounded: number },
): number {
let score = 0;
// 1. Multiple rows required for grid (0.3 max)
if (rows.length >= 2) score += 0.2;
if (rows.length >= 3) score += 0.1;
// 2. Consistent column count across rows (0.2)
const columnCounts = rows.map((r) => r.length);
const allSameCount = columnCounts.every((c) => c === columnCounts[0]);
if (allSameCount && columnCounts[0] >= 2) score += 0.2;
// 3. Columns aligned across rows (0.25)
if (columnAlignment.isAligned) score += 0.25;
// 4. Consistent row gap (0.1)
if (rowGapAnalysis.isConsistent) score += 0.1;
// 5. Consistent column gap (0.1)
if (columnGapAnalysis.isConsistent) score += 0.1;
// 6. Grid fill ratio - elements fill most of expected cells (0.05)
const expectedCells = rows.length * Math.max(...columnCounts);
const actualCells = rows.reduce((sum, r) => sum + r.length, 0);
const fillRatio = actualCells / expectedCells;
if (fillRatio >= 0.75) score += 0.05;
return Math.min(1, score);
}
/**
* Detect grid layout from element rectangles
*
* Grid detection criteria:
* - Multiple rows (2+)
* - Columns aligned across rows
* - Consistent column count per row
* - Consistent gaps
*/
export function detectGridLayout(rects: ElementRect[]): GridAnalysisResult {
const emptyResult: GridAnalysisResult = {
isGrid: false,
confidence: 0,
rowCount: 0,
columnCount: 0,
rowGap: 0,
columnGap: 0,
isRowGapConsistent: false,
isColumnGapConsistent: false,
trackWidths: [],
trackHeights: [],
alignedColumnPositions: [],
rows: [],
cellMap: [],
};
// Need at least 4 elements for a meaningful grid (2x2)
if (rects.length < 4) {
return emptyResult;
}
// Step 1: Group into rows
const rows = groupIntoRows(rects, 2);
// Need at least 2 rows for grid
if (rows.length < 2) {
return emptyResult;
}
// Step 2: Check column alignment
const columnAlignment = checkColumnAlignment(rows, 3);
// Step 3: Analyze row gaps
const rowGaps = calculateRowGaps(rows);
const rowGapAnalysis = analyzeGaps(rowGaps);
// Step 4: Analyze column gaps
const columnGaps = calculateColumnGaps(columnAlignment.alignedPositions, rows);
const columnGapAnalysis = analyzeGaps(columnGaps);
// Step 5: Calculate confidence
const confidence = calculateGridConfidence(
rows,
columnAlignment,
rowGapAnalysis,
columnGapAnalysis,
);
// Grid threshold: confidence >= 0.6
const isGrid = confidence >= 0.6;
if (!isGrid) {
return {
...emptyResult,
confidence,
rows,
rowCount: rows.length,
};
}
// Step 6: Calculate track sizes
const trackWidths = calculateTrackWidths(rows, columnAlignment.alignedPositions);
const trackHeights = calculateTrackHeights(rows);
// Step 7: Build cell map
const cellMap = buildCellMap(rows, columnAlignment.alignedPositions);
return {
isGrid: true,
confidence,
rowCount: rows.length,
columnCount: columnAlignment.columnCount,
rowGap: rowGapAnalysis.rounded,
columnGap: columnGapAnalysis.rounded,
isRowGapConsistent: rowGapAnalysis.isConsistent,
isColumnGapConsistent: columnGapAnalysis.isConsistent,
trackWidths,
trackHeights,
alignedColumnPositions: columnAlignment.alignedPositions,
rows,
cellMap,
};
}
// ==================== Debug and Visualization ====================
/**
* Generate layout analysis report (for debugging)
*/
export function generateLayoutReport(rects: ElementRect[]): string {
const analysis = analyzeLayout(rects);
const tree = buildLayoutTree(rects);
const lines: string[] = [
"=== Layout Analysis Report ===",
"",
`Element count: ${rects.length}`,
`Detected direction: ${analysis.direction} (confidence: ${(analysis.confidence * 100).toFixed(1)}%)`,
`Gap: ${analysis.gap}px (consistent: ${analysis.isGapConsistent ? "yes" : "no"})`,
`justifyContent: ${analysis.justifyContent}`,
`alignItems: ${analysis.alignItems}`,
"",
`Row groups: ${analysis.rows.length} rows`,
...analysis.rows.map(
(row, i) => ` Row ${i + 1}: ${row.length} elements [${row.map((r) => r.index).join(", ")}]`,
),
"",
`Column groups: ${analysis.columns.length} columns`,
...analysis.columns.map(
(col, i) =>
` Column ${i + 1}: ${col.length} elements [${col.map((r) => r.index).join(", ")}]`,
),
"",
`Overlapping elements: ${analysis.overlappingElements.length}`,
"",
"=== Layout Tree ===",
JSON.stringify(tree, null, 2),
];
return lines.join("\n");
}
```
--------------------------------------------------------------------------------
/docs/en/grid-layout-research.md:
--------------------------------------------------------------------------------
```markdown
# Grid Layout Detection Algorithm Research
## Research Summary
This document synthesizes research findings from multiple sources to design a Grid layout detection algorithm for converting Figma designs to CSS Grid.
## Sources Analyzed
### 1. Academic Research
**"A Layout Inference Algorithm for GUIs" (ScienceDirect)**
- Uses **Allen's Interval Algebra** for spatial relationship modeling
- Two-phase algorithm:
1. Convert absolute coordinates to relative positioning using directed graphs
2. Apply pattern matching and graph rewriting for layout inference
- Achieves 97% accuracy in maintaining original layout faithfulness
- 84% of views maintain proportions when resized
**Allen's 13 Interval Relations** (applicable to 1D spatial analysis):
- `before/after`: Element A completely before/after B
- `meets/met-by`: Element A ends exactly where B starts
- `overlaps/overlapped-by`: Element A partially overlaps B
- `starts/started-by`: Element A starts at same position as B
- `finishes/finished-by`: Element A ends at same position as B
- `during/contains`: Element A completely inside/outside B
- `equals`: Element A same position as B
### 2. Open Source Implementations
**FigmaToCode (bernaferrari/FigmaToCode)**
- Uses intermediate "AltNodes" representation
- Analyzes auto-layouts, responsive constraints, color variables
- Handles mixed positioning (absolute + auto-layout)
- Makes intelligent decisions about code structure
**imgcook (Alibaba)**
- Layout restoration is "core of entire D2C process"
- Uses flat JSON with absolute position, size, style
- Expert rule system + machine learning hybrid
- Components: Page splitting, Grouping, Loop detection, Multi-status handling
- Rule system preferred for controllable, near 100% availability
**GRIDS (Aalto University)**
- MILP (Mixed Integer Linear Programming) based approach
- Mathematical optimization for grid generation
- Python implementation with Gurobi optimizer
### 3. Industry Implementations
**Figma's Native Grid Auto-Layout (May 2025)**
- New Grid flow option alongside horizontal/vertical
- Supports span, row/column count
- Limited compared to full CSS Grid specification
- Missing: `fr` units, named grid areas, subgrid
**Screen Parsing (CMU - UIST 2021)**
- ML-based UI structure inference
- Detects 7 container types including grids
- Trained on 130K iOS + 80K Android screens
## Current Implementation Analysis
### Existing Flex Layout Detection
The current codebase has robust Flex detection in `src/algorithms/layout/detector.ts`:
```
Flow: Elements → Bounding Boxes → Row/Column Grouping → Gap Analysis → Alignment Detection → CSS Generation
```
**Key Algorithms:**
1. **Y-axis overlap** → Row grouping (elements in same row)
2. **X-axis overlap** → Column grouping (elements in same column)
3. **Gap analysis** → Consistent spacing detection (20% std dev tolerance)
4. **Alignment detection** → left/center/right/stretch
5. **Layout scoring** → Confidence-based direction selection
### Gap in Current Implementation
The `LayoutInfo` interface already defines `type: "flex" | "absolute" | "grid"` but Grid detection is **NOT IMPLEMENTED**.
## Grid Layout Detection Algorithm Design
### Core Concept: When to Use Grid vs Flex
| Criterion | Flexbox | CSS Grid |
| --------------- | --------------------------- | ---------------------------------- |
| Dimension | 1D (row OR column) | 2D (rows AND columns) |
| Rows consistent | Single row spans full width | Multiple rows with aligned columns |
| Column count | Variable per row | Consistent across rows |
| Cell alignment | Only within row | Both row AND column aligned |
| Gaps | Single gap value | row-gap and column-gap |
### Algorithm: Grid Detection
```typescript
interface GridAnalysisResult {
isGrid: boolean;
confidence: number;
rows: number;
columns: number;
rowGap: number;
columnGap: number;
trackWidths: number[]; // For grid-template-columns
trackHeights: number[]; // For grid-template-rows
cellMap: (number | null)[][]; // Element indices in grid positions
}
function detectGridLayout(rects: ElementRect[]): GridAnalysisResult {
// Step 1: Group into rows (Y-axis overlap)
const rows = groupIntoRows(rects);
// Step 2: Check if column count is consistent across rows
const columnCounts = rows.map((row) => row.length);
const isConsistentColumns = areValuesAligned(columnCounts, 0);
// Step 3: Check column alignment across rows
const columnPositions = extractColumnPositions(rows);
const areColumnsAligned = checkColumnAlignment(columnPositions);
// Step 4: Calculate confidence
// Grid confidence higher when:
// - Multiple rows exist (> 1)
// - Columns are aligned across rows
// - Both row and column gaps are consistent
// Step 5: Extract track sizes
const trackWidths = calculateTrackWidths(rows, columnPositions);
const trackHeights = calculateTrackHeights(rows);
return result;
}
```
### Step-by-Step Algorithm
#### Step 1: Row Detection (existing)
```typescript
// Already implemented: groupIntoRows()
const rows = groupIntoRows(rects, tolerance);
```
#### Step 2: Column Alignment Detection (NEW)
```typescript
function extractColumnPositions(rows: ElementRect[][]): number[][] {
// For each row, extract the X positions of elements
return rows.map((row) => row.map((el) => el.x).sort((a, b) => a - b));
}
function checkColumnAlignment(columnPositions: number[][]): {
isAligned: boolean;
alignedPositions: number[];
tolerance: number;
} {
if (columnPositions.length < 2) {
return { isAligned: false, alignedPositions: [], tolerance: 0 };
}
// Merge all X positions and cluster them
const allPositions = columnPositions.flat();
const clusters = clusterValues(allPositions, tolerance);
// Check if each row has elements at cluster positions
const alignedPositions = clusters.map((c) => c.center);
// Verify alignment across rows
let alignedRows = 0;
for (const row of columnPositions) {
const rowAligned = row.every((x) =>
alignedPositions.some((ap) => Math.abs(x - ap) <= tolerance),
);
if (rowAligned) alignedRows++;
}
return {
isAligned: alignedRows / columnPositions.length >= 0.8,
alignedPositions,
tolerance,
};
}
```
#### Step 3: Grid Confidence Scoring (NEW)
```typescript
function calculateGridConfidence(
rows: ElementRect[][],
columnAlignment: ColumnAlignmentResult,
rowGapAnalysis: GapAnalysis,
columnGapAnalysis: GapAnalysis,
): number {
let score = 0;
// 1. Multiple rows (required for grid)
if (rows.length >= 2) score += 0.2;
if (rows.length >= 3) score += 0.1;
// 2. Consistent column count
const columnCounts = rows.map((r) => r.length);
if (areValuesEqual(columnCounts)) score += 0.2;
// 3. Column alignment across rows
if (columnAlignment.isAligned) score += 0.25;
// 4. Consistent row gap
if (rowGapAnalysis.isConsistent) score += 0.1;
// 5. Consistent column gap
if (columnGapAnalysis.isConsistent) score += 0.1;
// 6. 2D regularity (elements form a regular matrix)
const expectedCells = rows.length * Math.max(...columnCounts);
const actualCells = rows.reduce((sum, r) => sum + r.length, 0);
const fillRatio = actualCells / expectedCells;
if (fillRatio >= 0.75) score += 0.05;
return Math.min(1, score);
}
```
#### Step 4: Track Size Extraction (NEW)
```typescript
function calculateTrackWidths(
rows: ElementRect[][],
alignedPositions: number[],
): (number | "auto" | "fr")[] {
// Group elements by column position
const columns: ElementRect[][] = [];
for (let i = 0; i < alignedPositions.length; i++) {
columns[i] = [];
}
for (const row of rows) {
for (const el of row) {
const colIndex = alignedPositions.findIndex((pos) => Math.abs(el.x - pos) <= tolerance);
if (colIndex >= 0) {
columns[colIndex].push(el);
}
}
}
// Calculate width for each column
return columns.map((col) => {
const widths = col.map((el) => el.width);
const avgWidth = widths.reduce((a, b) => a + b, 0) / widths.length;
return roundToCommonValue(avgWidth);
});
}
function calculateTrackHeights(rows: ElementRect[][]): number[] {
return rows.map((row) => {
const heights = row.map((el) => el.height);
const maxHeight = Math.max(...heights);
return roundToCommonValue(maxHeight);
});
}
```
#### Step 5: CSS Grid Generation (NEW)
```typescript
function generateGridCSS(analysis: GridAnalysisResult): CSSStyle {
const css: CSSStyle = {
display: "grid",
};
// grid-template-columns
const columns = analysis.trackWidths.map((w) => (typeof w === "number" ? `${w}px` : w)).join(" ");
css["gridTemplateColumns"] = columns;
// grid-template-rows (optional, often auto)
const rows = analysis.trackHeights
.map((h) => (typeof h === "number" ? `${h}px` : "auto"))
.join(" ");
if (!rows.split(" ").every((r) => r === "auto")) {
css["gridTemplateRows"] = rows;
}
// Gap
if (analysis.rowGap > 0 || analysis.columnGap > 0) {
if (analysis.rowGap === analysis.columnGap) {
css.gap = `${analysis.rowGap}px`;
} else {
css.gap = `${analysis.rowGap}px ${analysis.columnGap}px`;
}
}
return css;
}
```
### Decision Tree: Grid vs Flex vs Absolute
```
Start
│
▼
┌─────────────────┐
│ Elements ≥ 2? │──No──▶ position: absolute
└────────┬────────┘
│Yes
▼
┌─────────────────┐
│ Overlapping │──Yes─▶ position: absolute (for overlaps)
│ elements? │
└────────┬────────┘
│No
▼
┌─────────────────┐
│ Multiple rows │──No──▶ display: flex (row)
│ detected? │
└────────┬────────┘
│Yes
▼
┌─────────────────┐
│ Columns aligned │──No──▶ display: flex (column)
│ across rows? │ with nested flex rows
└────────┬────────┘
│Yes
▼
┌─────────────────┐
│ Grid confidence │──No──▶ display: flex (column)
│ ≥ 0.6? │
└────────┬────────┘
│Yes
▼
display: grid
```
## Additional CSS Properties to Support
### Grid-Specific Properties
| Property | Priority | Description |
| -------------------------------- | -------- | --------------------------------- |
| `display: grid` | P0 | Enable grid layout |
| `grid-template-columns` | P0 | Define column tracks |
| `grid-template-rows` | P1 | Define row tracks |
| `gap` / `row-gap` / `column-gap` | P0 | Spacing between tracks |
| `grid-auto-flow` | P2 | Auto-placement algorithm |
| `justify-items` | P1 | Align items in cells horizontally |
| `align-items` | P1 | Align items in cells vertically |
| `place-items` | P2 | Shorthand for align + justify |
### Child Element Properties
| Property | Priority | Description |
| -------------- | -------- | ------------------------- |
| `grid-column` | P1 | Column span/position |
| `grid-row` | P1 | Row span/position |
| `grid-area` | P2 | Named grid area |
| `justify-self` | P2 | Self horizontal alignment |
| `align-self` | P2 | Self vertical alignment |
### Enhanced Flex Properties (Missing)
| Property | Priority | Description |
| ------------- | -------- | ------------------------------- |
| `flex-grow` | P1 | Element growth factor |
| `flex-shrink` | P2 | Element shrink factor |
| `flex-basis` | P2 | Initial size before grow/shrink |
| `flex` | P1 | Shorthand (grow shrink basis) |
| `order` | P2 | Element ordering |
## Implementation Plan
### Phase 1: Fix Existing TODO (layout.ts)
Split `convertAlign` into two functions:
- `convertJustifyContent()` - for main axis alignment
- `convertAlignItems()` - for cross axis alignment
### Phase 2: Add Grid Detection (detector.ts)
1. Add `GridAnalysisResult` interface
2. Implement `detectGridLayout()` function
3. Add column alignment detection
4. Implement grid confidence scoring
5. Add track size extraction
### Phase 3: CSS Generation (optimizer.ts)
1. Update `LayoutInfo` usage to include grid type
2. Add `generateGridCSS()` function
3. Integrate into `optimizeDesign()` pipeline
4. Add decision tree for grid vs flex
### Phase 4: Type Updates (simplified.ts)
1. Add Grid CSS properties to `CSSStyle`
2. Extend `LayoutInfo` for grid-specific data
### Phase 5: Testing
1. Add unit tests for grid detection
2. Add integration tests with real Figma data
3. Test edge cases (irregular grids, mixed layouts)
## Current Implementation Issues (2024-12 Analysis)
### Problem: Mixed Layout False Positives
Testing with real Figma data revealed that the Grid detection algorithm incorrectly identifies mixed layouts as grids.
**Example Case: Keywords Management Panel (node-402-34955)**
```
Container: 1580px × 340px
Children:
1. Tabs (320×41) left: 630px top: 0px ← centered tabs
2. Divider (1580×1) left: 0px top: 41px ← full-width line
3. Info Bar (1528×88) left: 26px top: 62px ← nearly full-width
4. Card 1 (500×78) left: 26px top: 170px ┐
5. Card 2 (500×78) left: 540px top: 170px ├─ actual grid candidates
6. Card 3 (500×78) left: 1054px top: 170px ┘
7. Card 4 (500×78) left: 26px top: 262px ← second row
```
**Detected Result (WRONG):**
```css
display: grid;
grid-template-columns: 1580px 1528px 500px 320px 500px; /* ❌ Sum = 4428px > 1580px */
```
**Expected Result:**
- Overall container: `flex-direction: column` or `position: absolute`
- Cards only (items 4-7): `display: grid; grid-template-columns: repeat(3, 500px);`
### Root Cause Analysis
| Issue | Code Location | Description |
| ------------------------------- | --------------------- | ------------------------------------------------------ |
| **No element type filtering** | `detector.ts:1073` | All children analyzed together regardless of size/type |
| **Y-overlap only row grouping** | `detector.ts:154-185` | 2px tolerance ignores visual/functional differences |
| **No homogeneity check** | N/A | Missing validation that elements "look similar" |
| **Strict column alignment** | `detector.ts:908-911` | 80% threshold fails on intentionally mixed layouts |
### Research: Industry Approaches
#### 1. UI Semantic Group Detection (2024)
> Source: [arxiv.org/html/2403.04984v1](https://arxiv.org/html/2403.04984v1)
- **Key Insight**: "Group adjacent elements with similar semantics before layout detection"
- **Method**: Transformer-based detector using Gestalt principles
- **Relevance**: Pre-clustering homogeneous elements
#### 2. UIHASH - Grid-Based UI Similarity
> Source: [jun-zeng.github.io](https://jun-zeng.github.io/file/uihash_paper.pdf)
- **Key Insight**: "Proximity principle - users group adjacent elements as unified entity"
- **Method**: Partition screen into regions, encode by constituent controls
- **Relevance**: Size-based clustering before grid analysis
#### 3. GUI Layout Inference Algorithm
> Source: [ScienceDirect](https://www.sciencedirect.com/science/article/abs/pii/S0950584915001718)
- **Key Insight**: "Two-phase: relative positioning → pattern matching"
- **Method**: Allen relations + exploratory algorithm for layout composition
- **Relevance**: Hierarchical layout detection
#### 4. Multilevel Homogeneity Structure
> Source: [ScienceDirect](https://www.sciencedirect.com/science/article/abs/pii/S0957417417303469)
- **Key Insight**: "Bottom-up aggregation into homogeneous regions"
- **Method**: Connected components → words → text lines → regions
- **Relevance**: Progressive homogeneous grouping
## Optimization Plan
### Solution: Homogeneous Element Pre-filtering
Add a pre-filtering step before Grid detection to identify elements that "look similar" and should be considered together.
#### Homogeneity Criteria
```typescript
interface HomogeneityCheck {
sizeVariance: number; // Width/height variance (threshold: 20%)
typeConsistency: boolean; // Same node types
stylesSimilar: boolean; // Similar CSS properties
}
function isHomogeneousGroup(nodes: SimplifiedNode[]): boolean {
if (nodes.length < 4) return false;
// 1. Size clustering - width/height within 20% variance
const widths = nodes.map((n) => parseFloat(n.cssStyles?.width || "0"));
const heights = nodes.map((n) => parseFloat(n.cssStyles?.height || "0"));
const widthCV = coefficientOfVariation(widths);
const heightCV = coefficientOfVariation(heights);
if (widthCV > 0.2 || heightCV > 0.2) return false;
// 2. Type consistency - allow FRAME + INSTANCE + COMPONENT
const types = new Set(nodes.map((n) => n.type));
const allowedTypes = new Set(["FRAME", "INSTANCE", "COMPONENT", "GROUP"]);
const hasDisallowedType = [...types].some((t) => !allowedTypes.has(t));
if (hasDisallowedType || types.size > 3) return false;
// 3. Style similarity (optional) - background, border, etc.
// ...
return true;
}
```
#### Updated Detection Flow
```
Before:
detectGridLayout(allChildren) → wrong grid
After:
1. clusterBySimilarSize(allChildren) → size groups
2. For each group with 4+ elements:
a. isHomogeneousGroup(group) → true/false
b. If true: detectGridLayout(group)
3. Remaining elements: use flex/absolute
```
#### Implementation Steps
1. **Add `isHomogeneousGroup()` function** - `detector.ts`
2. **Add `clusterBySimilarSize()` function** - `detector.ts`
3. **Update `LayoutOptimizer.optimizeContainer()`** - `optimizer.ts`
- Call homogeneity check before grid detection
- Only pass homogeneous elements to `detectGridLayout()`
4. **Add tests** - `layout.test.ts`
### Expected Results
| Scenario | Before | After |
| ----------------------------- | ------------------ | -------------------- |
| Mixed layout (tabs + cards) | Wrong grid for all | Cards only → grid |
| Pure card grid (4+ same size) | Correct grid | Correct grid |
| Single row items | Flex row | Flex row (no change) |
| Irregular sizes | Wrong grid | Flex/absolute |
## References
- [Allen's Interval Algebra - Wikipedia](https://en.wikipedia.org/wiki/Allen's_interval_algebra)
- [FigmaToCode - GitHub](https://github.com/bernaferrari/FigmaToCode)
- [GRIDS Layout Engine - GitHub](https://github.com/aalto-ui/GRIDS)
- [CSS Grid Layout Module Level 1 - W3C](https://www.w3.org/TR/css-grid-1/)
- [Figma Grid Auto-Layout Help](https://help.figma.com/hc/en-us/articles/31289469907863-Use-the-grid-auto-layout-flow)
- [Screen Parsing - CMU ML Blog](https://blog.ml.cmu.edu/2021/12/10/understanding-user-interfaces-with-screen-parsing/)
---
## Complete Implementation Analysis
This section provides an in-depth analysis of the Grid layout detection algorithm implementation, including the complete call chain and core functions.
### System Architecture: Grid vs Flex Decision
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Layout Detection Decision Tree │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ │
│ │ optimizeContainer() │ │
│ │ [optimizer.ts:89] │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ┌───────────────────┼───────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ STEP 1: Overlap │ │ STEP 1.5: │ │ STEP 2: Grid │ │
│ │ Detection │ │ Background │ │ Detection │ │
│ │ IoU > 0.1 ? │ │ Detection │ │ (Priority) │ │
│ └────────┬────────┘ └────────────────┘ └────────┬────────┘ │
│ │ │ │
│ │ ┌────────────────────┤ │
│ │ │ │ │
│ │ Grid detection OK? Grid detection failed │
│ │ confidence >= 0.6 │ │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │
│ │ │ display: grid │ │ STEP 3: Flex │ │
│ │ │ grid-template- │ │ Detection │ │
│ │ │ columns/rows │ │ (Fallback) │ │
│ │ └─────────────────┘ └────────┬────────┘ │
│ │ │ │ │
│ │ │ Flex detection OK? │
│ │ │ score > 0.4 │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │
│ Overlapping keeps │ Generate Grid │ │ display: flex │ │
│ position: absolute │ CSS + padding │ │ + gap + align │ │
│ │ └─────────────────┘ └─────────────────┘ │
│ │ │
└───────────┴─────────────────────────────────────────────────────────────┘
```
### Grid Detection Entry: detectGridIfApplicable
```
detectGridIfApplicable(nodes) - [optimizer.ts:918-961]
═══════════════════════════════════════════════════════════════════════════
┌─────────────────────────────────┐
│ detectGridIfApplicable(nodes) │
│ [optimizer.ts:918] │
└───────────────┬─────────────────┘
│
▼
┌─────────────────────────────────┐
│ Pre-checks │
│ • nodes.length < 4 → return null│
│ • Need at least 2×2 grid │
└───────────────┬─────────────────┘
│
▼
┌─────────────────────────────────┐
│ nodesToElementRects(nodes) │
│ Convert to ElementRect[] │
│ [optimizer.ts:856-870] │
└───────────────┬─────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ filterHomogeneousForGrid(elementRects, nodeTypes) │
│ [detector.ts:1216-1235] │
│ │
│ ★ Key step: Homogeneity filtering │
│ • Filter elements with similar sizes │
│ • Prevent mixed layouts from being detected as Grid │
└───────────────────────────┬─────────────────────────┘
│
┌─────────────────┴─────────────────┐
│ │
homogeneousElements.length < 4 homogeneousElements >= 4
│ │
▼ ▼
return null ┌─────────────────────────┐
│ detectGridLayout() │
│ [detector.ts:1490] │
│ Run Grid detection on │
│ homogeneous elements │
└───────────────┬─────────┘
│
┌───────────────────────────────────────┤
│ │
isGrid && confidence >= 0.6 ? confidence < 0.6
rowCount >= 2 && columnCount >= 2 │
│ │
▼ ▼
┌─────────────────────────┐ return null
│ return { │
│ gridResult, │
│ gridIndices │
│ } │
└─────────────────────────┘
```
### Homogeneity Analysis: analyzeHomogeneity
```
analyzeHomogeneity(rects, nodeTypes) - [detector.ts:1127-1196]
═══════════════════════════════════════════════════════════════════════════
Purpose: Ensure only "similar" elements participate in Grid detection,
avoiding misdetection of mixed layouts
Mixed Layout Example (should be filtered):
┌──────────────────────────────────────────────────────────────────────────┐
│ Container 1580px × 340px │
│ │
│ ┌─────────────────────┐ ← Tabs 320×41 (heterogeneous) │
│ │ Tabs │ │
│ └─────────────────────┘ │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ← Divider 1580×1 (heterogeneous) │
│ ┌──────────────────────────────────────────────────────────────────────┐│
│ │ Info Bar 1528×88 ││
│ └──────────────────────────────────────────────────────────────────────┘│
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ← Homogeneous (Grid)│
│ │ Card 500×78│ │ Card 500×78│ │ Card 500×78│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Card 500×78│ │
│ └─────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
Algorithm Flow:
┌────────────────────────────────────────────────────────────────┐
│ 1. Size Clustering (clusterBySimilarSize) │
│ │
│ Input: All child ElementRect[] │
│ Tolerance: 20% (widthDiff <= 0.2 && heightDiff <= 0.2) │
│ │
│ Process: │
│ for each rect: │
│ Find existing cluster with similar size │
│ If found → add to that cluster │
│ If not found → create new cluster │
│ │
│ Output: SizeCluster[] (sorted by element count descending) │
│ │
│ Example: │
│ Cluster 1: [Card1, Card2, Card3, Card4] ← 500×78 │
│ Cluster 2: [InfoBar] ← 1528×88 │
│ Cluster 3: [Tabs] ← 320×41 │
│ Cluster 4: [Divider] ← 1580×1 │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ 2. Get Largest Cluster (largestCluster) │
│ │
│ If largestCluster.length < 4 → not homogeneous │
│ │
│ Example: [Card1, Card2, Card3, Card4] = 4 elements ✓ │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ 3. Calculate Coefficient of Variation │
│ │
│ widthCV = stddev(widths) / mean(widths) │
│ heightCV = stddev(heights) / mean(heights) │
│ │
│ If widthCV > 0.2 || heightCV > 0.2 → not homogeneous │
│ │
│ Example: │
│ widths = [500, 500, 500, 500] → CV = 0 ✓ │
│ heights = [78, 78, 78, 78] → CV = 0 ✓ │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ 4. Type Consistency Check (optional) │
│ │
│ Allowed types: FRAME, INSTANCE, COMPONENT, GROUP, RECTANGLE│
│ If other types exist → not homogeneous │
│ │
│ Example: All Cards are FRAME → pass ✓ │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Output: HomogeneityResult │
│ { │
│ isHomogeneous: true, │
│ widthCV: 0, │
│ heightCV: 0, │
│ types: ['FRAME'], │
│ homogeneousElements: [Card1, Card2, Card3, Card4], │
│ outlierElements: [Tabs, Divider, InfoBar] │
│ } │
└────────────────────────────────────────────────────────────────┘
```
### Grid Detection Core: detectGridLayout
```
detectGridLayout(rects) - [detector.ts:1490-1573]
═══════════════════════════════════════════════════════════════════════════
┌─────────────────────────────────┐
│ detectGridLayout(rects) │
│ Input: Filtered homogeneous │
│ elements │
└───────────────┬─────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ STEP 1: Row Grouping (groupIntoRows) │
│ [detector.ts:1513] │
│ │
│ Use Y-axis overlap detection to group elements into "rows" │
│ │
│ Input (4 cards): │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │ 1 │ │ 2 │ │ 3 │ y: 170, height: 78 │
│ └────┘ └────┘ └────┘ │
│ ┌────┐ │
│ │ 4 │ y: 262, height: 78 │
│ └────┘ │
│ │
│ Output: rows = [[1, 2, 3], [4]] │
│ rowCount = 2 │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ STEP 2: Column Alignment Detection (checkColumnAlignment) │
│ [detector.ts:1305-1339] │
│ │
│ Check if X positions across rows are aligned │
│ │
│ 1. Extract all X positions: │
│ Row 1: [26, 540, 1054] │
│ Row 2: [26] │
│ │
│ 2. Cluster all X positions (tolerance=3px): │
│ Cluster 1: center=26 │
│ Cluster 2: center=540 │
│ Cluster 3: center=1054 │
│ │
│ 3. Verify row elements are at cluster positions: │
│ Row 1: [26 ≈ 26 ✓, 540 ≈ 540 ✓, 1054 ≈ 1054 ✓] │
│ Row 2: [26 ≈ 26 ✓] │
│ │
│ Output: { isAligned: true, alignedPositions: [26, 540, 1054] } │
│ columnCount = 3 │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ STEP 3: Gap Analysis │
│ [detector.ts:1524-1529] │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Row Gap: │ │
│ │ │ │
│ │ Row 1 bottom = max(170+78) = 248 │ │
│ │ Row 2 top = min(262) = 262 │ │
│ │ rowGap = 262 - 248 = 14px │ │
│ │ │ │
│ │ analyzeGaps([14]) → { isConsistent: true, rounded: 16 }│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Column Gap: │ │
│ │ │ │
│ │ Row 1: gap1 = 540 - (26+500) = 14px │ │
│ │ gap2 = 1054 - (540+500) = 14px │ │
│ │ │ │
│ │ analyzeGaps([14,14]) → { isConsistent: true, rounded: 16 }│ │
│ └─────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ STEP 4: Grid Confidence Calculation (calculateGridConfidence) │
│ [detector.ts:1446-1479] │
│ │
│ 6 Scoring Factors: │
│ │
│ ┌────────────────────────────────────────────────────────────┐│
│ │ Factor │ Condition │ Score ││
│ ├────────────────────────────────────────────────────────────┤│
│ │ 1. Multiple rows (>= 2) │ 2 rows │ +0.2 ││
│ │ 2. Multiple rows (>= 3) │ 2 rows < 3 │ +0.0 ││
│ │ 3. Consistent column count │ [3, 1] inconsistent│ +0.0 ││
│ │ 4. Column alignment │ isAligned = true │ +0.25 ││
│ │ 5. Row gap consistency │ isConsistent = true│ +0.1 ││
│ │ 6. Column gap consistency │ isConsistent = true│ +0.1 ││
│ │ 7. Fill rate >= 75% │ 4/6 = 67% < 75% │ +0.0 ││
│ └────────────────────────────────────────────────────────────┘│
│ │
│ Total: 0.2 + 0.25 + 0.1 + 0.1 = 0.65 │
│ Threshold: 0.6 │
│ Result: 0.65 >= 0.6 → isGrid = true ✓ │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ STEP 5-6: Track Size Calculation │
│ [detector.ts:1552-1557] │
│ │
│ trackWidths = calculateTrackWidths(rows, alignedPositions) │
│ = [500, 500, 500] │
│ │
│ trackHeights = calculateTrackHeights(rows) │
│ = [78, 78] │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ STEP 7: Cell Map Building (buildCellMap) │
│ [detector.ts:1556] │
│ │
│ cellMap: │
│ ┌─────────────────────────────────────┐ │
│ │ Col 0 │ Col 1 │ Col 2 │ │
│ ├───────────┼───────────┼─────────────┤ │
│ │ Card 0 │ Card 1 │ Card 2 │ Row 0 │
│ │ Card 3 │ null │ null │ Row 1 │
│ └───────────┴───────────┴─────────────┘ │
└────────────────────────────────────────────────────────────────┘
```
### CSS Grid Generation
```
generateGridCSS(gridResult) - [optimizer.ts:875-913]
═══════════════════════════════════════════════════════════════════════════
Input: GridAnalysisResult
Output: CSS Grid style object
┌─────────────────────────────────┐
│ GridAnalysisResult │
│ { │
│ isGrid: true, │
│ rowCount: 2, │
│ columnCount: 3, │
│ rowGap: 16, │
│ columnGap: 16, │
│ trackWidths: [500, 500, 500],│
│ trackHeights: [78, 78] │
│ } │
└───────────────┬─────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Generate grid-template-columns │
│ │
│ trackWidths = [500, 500, 500] │
│ → "500px 500px 500px" │
│ │
│ (Can be optimized to repeat(3, 500px) if all same width) │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Generate grid-template-rows (only if heights differ) │
│ │
│ trackHeights = [78, 78] │
│ All row heights same → don't set (use auto) │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Generate gap │
│ │
│ rowGap = 16, columnGap = 16 │
│ rowGap === columnGap → gap: "16px" │
│ │
│ If different → gap: "${rowGap}px ${columnGap}px" │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Output CSS: │
│ { │
│ display: "grid", │
│ gridTemplateColumns: "500px 500px 500px", │
│ gap: "16px" │
│ } │
└────────────────────────────────────────────────────────────────┘
```
### Core Data Structures
```typescript
// Grid-related data structures in detector.ts
═══════════════════════════════════════════════════════════════════════════
// Grid analysis result
interface GridAnalysisResult {
isGrid: boolean; // whether valid Grid detected
confidence: number; // confidence (0-1)
rowCount: number; // row count
columnCount: number; // column count
rowGap: number; // row gap (px)
columnGap: number; // column gap (px)
isRowGapConsistent: boolean; // is row gap consistent
isColumnGapConsistent: boolean; // is column gap consistent
trackWidths: number[]; // column widths [col0Width, col1Width, ...]
trackHeights: number[]; // row heights [row0Height, row1Height, ...]
alignedColumnPositions: number[]; // aligned column X coordinates
rows: ElementRect[][]; // grouped row data
cellMap: (number | null)[][]; // cell to element index mapping
}
// Homogeneity analysis result
interface HomogeneityResult {
isHomogeneous: boolean; // is homogeneous
widthCV: number; // width coefficient of variation
heightCV: number; // height coefficient of variation
types: string[]; // element type list
homogeneousElements: ElementRect[]; // homogeneous elements
outlierElements: ElementRect[]; // outlier elements (not in Grid)
}
// Size cluster
interface SizeCluster {
width: number; // cluster representative width
height: number; // cluster representative height
elements: ElementRect[]; // elements in this cluster
types?: string[]; // element types
}
```
### File Path Mapping
| Module | File Path | Line Range |
| -------------------------- | ------------------------------------ | ---------- |
| Grid entry | `src/algorithms/layout/optimizer.ts` | 918-961 |
| Grid CSS generation | `src/algorithms/layout/optimizer.ts` | 875-913 |
| Homogeneity filtering | `src/algorithms/layout/detector.ts` | 1216-1235 |
| Homogeneity analysis | `src/algorithms/layout/detector.ts` | 1127-1196 |
| Size clustering | `src/algorithms/layout/detector.ts` | 1077-1116 |
| CV calculation | `src/algorithms/layout/detector.ts` | 1057-1067 |
| Grid detection core | `src/algorithms/layout/detector.ts` | 1490-1573 |
| Column alignment detection | `src/algorithms/layout/detector.ts` | 1305-1339 |
| Row gap calculation | `src/algorithms/layout/detector.ts` | 1344-1356 |
| Column gap calculation | `src/algorithms/layout/detector.ts` | 1361-1376 |
| Confidence calculation | `src/algorithms/layout/detector.ts` | 1446-1479 |
| Track width calculation | `src/algorithms/layout/detector.ts` | 1381-1404 |
| Track height calculation | `src/algorithms/layout/detector.ts` | 1409-1415 |
| Cell map building | `src/algorithms/layout/detector.ts` | 1420-1441 |
---
_Last updated: 2025-12-06_
```