Spaces:
Paused
Paused
| const sharp = require('sharp'); | |
| const fs = require('fs').promises; | |
| const path = require('path'); | |
| class VisualDesignAnalyzer { | |
| constructor() { | |
| this.colorThresholds = { | |
| lowContrast: 3.0, | |
| normalContrast: 4.5, | |
| highContrast: 7.0 | |
| }; | |
| this.layoutThresholds = { | |
| minWhitespace: 0.15, // 15% minimum whitespace | |
| maxContentDensity: 0.7, // 70% max content density | |
| minTouchTargetSize: 44 // 44px minimum touch target | |
| }; | |
| } | |
| async analyzeVisualDesign(screenshotPath, viewport = 'desktop') { | |
| try { | |
| const imageBuffer = await fs.readFile(screenshotPath); | |
| const image = sharp(imageBuffer); | |
| const metadata = await image.metadata(); | |
| const analysis = { | |
| viewport, | |
| dimensions: { | |
| width: metadata.width, | |
| height: metadata.height | |
| }, | |
| colorAnalysis: await this.analyzeColors(image, imageBuffer), | |
| layoutAnalysis: await this.analyzeLayout(image, metadata), | |
| typographyAnalysis: await this.analyzeTypography(imageBuffer), | |
| spacingAnalysis: await this.analyzeSpacing(image, metadata), | |
| visualHierarchy: await this.analyzeVisualHierarchy(imageBuffer), | |
| issues: [], | |
| score: 0 | |
| }; | |
| // Calculate overall visual design score | |
| analysis.score = this.calculateVisualScore(analysis); | |
| // Identify issues | |
| analysis.issues = this.identifyVisualIssues(analysis); | |
| return analysis; | |
| } catch (error) { | |
| console.error('Visual design analysis error:', error); | |
| throw new Error(`Failed to analyze visual design: ${error.message}`); | |
| } | |
| } | |
| async analyzeColors(image, imageBuffer) { | |
| try { | |
| // Extract dominant colors | |
| const { dominant } = await image.stats(); | |
| // Get color palette using histogram analysis | |
| const colorPalette = await this.extractColorPalette(imageBuffer); | |
| // Analyze contrast ratios (simplified approach) | |
| const contrastAnalysis = await this.analyzeContrast(imageBuffer); | |
| return { | |
| dominantColor: { | |
| r: Math.round(dominant.r), | |
| g: Math.round(dominant.g), | |
| b: Math.round(dominant.b) | |
| }, | |
| colorPalette, | |
| contrastRatios: contrastAnalysis, | |
| colorHarmony: this.assessColorHarmony(colorPalette), | |
| accessibility: this.assessColorAccessibility(contrastAnalysis) | |
| }; | |
| } catch (error) { | |
| console.error('Color analysis error:', error); | |
| return { | |
| dominantColor: { r: 0, g: 0, b: 0 }, | |
| colorPalette: [], | |
| contrastRatios: { average: 0, minimum: 0, maximum: 0 }, | |
| colorHarmony: 'unknown', | |
| accessibility: 'poor' | |
| }; | |
| } | |
| } | |
| async extractColorPalette(imageBuffer) { | |
| // Simplified color extraction - in production, you'd use more sophisticated algorithms | |
| try { | |
| const image = sharp(imageBuffer); | |
| // Resize for faster processing | |
| const smallImage = await image.resize(100, 100).raw().toBuffer(); | |
| const colors = new Map(); | |
| // Ensure we have a valid buffer | |
| if (!smallImage || smallImage.length === 0) { | |
| return []; | |
| } | |
| // Sample every 4th pixel for performance | |
| for (let i = 0; i < smallImage.length - 2; i += 12) { | |
| const r = smallImage[i] || 0; | |
| const g = smallImage[i + 1] || 0; | |
| const b = smallImage[i + 2] || 0; | |
| // Group similar colors | |
| const colorKey = `${Math.floor(r/32)*32},${Math.floor(g/32)*32},${Math.floor(b/32)*32}`; | |
| colors.set(colorKey, (colors.get(colorKey) || 0) + 1); | |
| } | |
| // Return top 5 colors | |
| const entries = Array.from(colors.entries() || []); | |
| if (entries.length === 0) { | |
| return []; | |
| } | |
| return entries | |
| .sort(([,a], [,b]) => b - a) | |
| .slice(0, 5) | |
| .map(([color, count]) => { | |
| const [r, g, b] = color.split(',').map(Number); | |
| return { | |
| color: { r, g, b }, | |
| frequency: count, | |
| hex: this.rgbToHex(r, g, b) | |
| }; | |
| }); | |
| } catch (error) { | |
| console.error('Color palette extraction error:', error); | |
| return []; | |
| } | |
| } | |
| async analyzeContrast(imageBuffer) { | |
| // Simplified contrast analysis | |
| try { | |
| const image = sharp(imageBuffer); | |
| const { channels } = await image.stats(); | |
| // Calculate luminance variance as a proxy for contrast | |
| const luminanceVariance = channels.reduce((sum, channel) => { | |
| return sum + (channel.max - channel.min); | |
| }, 0) / channels.length; | |
| const normalizedContrast = Math.min(luminanceVariance / 255 * 10, 10); | |
| return { | |
| average: normalizedContrast, | |
| minimum: normalizedContrast * 0.7, | |
| maximum: normalizedContrast * 1.3, | |
| assessment: this.assessContrastLevel(normalizedContrast) | |
| }; | |
| } catch (error) { | |
| console.error('Contrast analysis error:', error); | |
| return { | |
| average: 0, | |
| minimum: 0, | |
| maximum: 0, | |
| assessment: 'poor' | |
| }; | |
| } | |
| } | |
| async analyzeLayout(image, metadata) { | |
| try { | |
| // Analyze layout using edge detection and region analysis | |
| const edgeImage = await image | |
| .greyscale() | |
| .convolve({ | |
| width: 3, | |
| height: 3, | |
| kernel: [-1, -1, -1, -1, 8, -1, -1, -1, -1] | |
| }) | |
| .raw() | |
| .toBuffer(); | |
| const layoutMetrics = this.calculateLayoutMetrics(edgeImage, metadata); | |
| return { | |
| whitespaceRatio: layoutMetrics.whitespace, | |
| contentDensity: layoutMetrics.density, | |
| symmetry: layoutMetrics.symmetry, | |
| balance: layoutMetrics.balance, | |
| gridAlignment: layoutMetrics.gridAlignment, | |
| assessment: this.assessLayout(layoutMetrics) | |
| }; | |
| } catch (error) { | |
| console.error('Layout analysis error:', error); | |
| return { | |
| whitespaceRatio: 0, | |
| contentDensity: 0, | |
| symmetry: 0, | |
| balance: 0, | |
| gridAlignment: 0, | |
| assessment: 'poor' | |
| }; | |
| } | |
| } | |
| calculateLayoutMetrics(edgeBuffer, metadata) { | |
| const totalPixels = metadata.width * metadata.height; | |
| let edgePixels = 0; | |
| // Count edge pixels (simplified) | |
| for (let i = 0; i < edgeBuffer.length; i++) { | |
| if (edgeBuffer[i] > 50) edgePixels++; | |
| } | |
| const contentDensity = edgePixels / totalPixels; | |
| const whitespaceRatio = 1 - contentDensity; | |
| // Simplified symmetry calculation | |
| const symmetry = this.calculateSymmetry(edgeBuffer, metadata.width, metadata.height); | |
| return { | |
| whitespace: whitespaceRatio, | |
| density: contentDensity, | |
| symmetry: symmetry, | |
| balance: (symmetry + whitespaceRatio) / 2, | |
| gridAlignment: this.estimateGridAlignment(edgeBuffer, metadata.width, metadata.height) | |
| }; | |
| } | |
| calculateSymmetry(buffer, width, height) { | |
| // Calculate horizontal symmetry | |
| let symmetryScore = 0; | |
| const halfWidth = Math.floor(width / 2); | |
| for (let y = 0; y < height; y++) { | |
| for (let x = 0; x < halfWidth; x++) { | |
| const leftPixel = buffer[y * width + x]; | |
| const rightPixel = buffer[y * width + (width - 1 - x)]; | |
| const diff = Math.abs(leftPixel - rightPixel); | |
| symmetryScore += (255 - diff) / 255; | |
| } | |
| } | |
| return symmetryScore / (halfWidth * height); | |
| } | |
| estimateGridAlignment(buffer, width, height) { | |
| // Simplified grid alignment detection | |
| const gridSize = 20; // 20px grid | |
| let alignmentScore = 0; | |
| let totalChecks = 0; | |
| for (let y = gridSize; y < height; y += gridSize) { | |
| for (let x = gridSize; x < width; x += gridSize) { | |
| const pixelIndex = y * width + x; | |
| if (pixelIndex < buffer.length) { | |
| alignmentScore += buffer[pixelIndex] > 50 ? 1 : 0; | |
| totalChecks++; | |
| } | |
| } | |
| } | |
| return totalChecks > 0 ? alignmentScore / totalChecks : 0; | |
| } | |
| async analyzeTypography(imageBuffer) { | |
| // Simplified typography analysis | |
| try { | |
| const image = sharp(imageBuffer); | |
| // Convert to grayscale and apply text detection filters | |
| const textRegions = await this.detectTextRegions(image); | |
| return { | |
| textRegions: textRegions.length, | |
| estimatedFontSizes: this.estimateFontSizes(textRegions), | |
| readabilityScore: this.calculateReadabilityScore(textRegions), | |
| hierarchy: this.analyzeTextHierarchy(textRegions) | |
| }; | |
| } catch (error) { | |
| console.error('Typography analysis error:', error); | |
| return { | |
| textRegions: 0, | |
| estimatedFontSizes: [], | |
| readabilityScore: 0, | |
| hierarchy: 'poor' | |
| }; | |
| } | |
| } | |
| async detectTextRegions(image) { | |
| // Simplified text region detection using morphological operations | |
| try { | |
| const textImage = await image | |
| .greyscale() | |
| .threshold(128) | |
| .raw() | |
| .toBuffer(); | |
| // Find connected components that might be text | |
| const regions = this.findConnectedComponents(textImage); | |
| return regions.filter(region => this.isLikelyText(region)); | |
| } catch (error) { | |
| console.error('Text region detection error:', error); | |
| return []; | |
| } | |
| } | |
| findConnectedComponents(buffer) { | |
| // Simplified connected component analysis | |
| const regions = []; | |
| const visited = new Set(); | |
| const width = Math.sqrt(buffer.length); // Assuming square for simplicity | |
| for (let i = 0; i < buffer.length; i++) { | |
| if (buffer[i] > 0 && !visited.has(i)) { | |
| const region = this.floodFill(buffer, i, width, visited); | |
| if (region.size > 10) { // Minimum region size | |
| regions.push({ | |
| size: region.size, | |
| bounds: this.calculateBounds(region, width) | |
| }); | |
| } | |
| } | |
| } | |
| return regions; | |
| } | |
| floodFill(buffer, start, width, visited) { | |
| const region = new Set(); | |
| const stack = [start]; | |
| const MAX_REGION_SIZE = 50000; // Prevent Set overflow | |
| const MAX_VISITED_SIZE = 100000; // Prevent visited Set overflow | |
| while (stack.length > 0 && region.size < MAX_REGION_SIZE && visited.size < MAX_VISITED_SIZE) { | |
| const current = stack.pop(); | |
| if (visited.has(current) || buffer[current] === 0) continue; | |
| // Check visited size before adding | |
| if (visited.size < MAX_VISITED_SIZE) { | |
| visited.add(current); | |
| } | |
| if (region.size < MAX_REGION_SIZE) { | |
| region.add(current); | |
| } | |
| // Add neighbors only if we haven't hit limits | |
| if (visited.size < MAX_VISITED_SIZE && region.size < MAX_REGION_SIZE) { | |
| const neighbors = [ | |
| current - 1, current + 1, | |
| current - width, current + width | |
| ]; | |
| neighbors.forEach(neighbor => { | |
| if (neighbor >= 0 && neighbor < buffer.length && !visited.has(neighbor)) { | |
| stack.push(neighbor); | |
| } | |
| }); | |
| } | |
| } | |
| return region; | |
| } | |
| calculateBounds(region, width) { | |
| let minX = Infinity, maxX = -Infinity; | |
| let minY = Infinity, maxY = -Infinity; | |
| for (const pixel of region) { | |
| const x = pixel % width; | |
| const y = Math.floor(pixel / width); | |
| minX = Math.min(minX, x); | |
| maxX = Math.max(maxX, x); | |
| minY = Math.min(minY, y); | |
| maxY = Math.max(maxY, y); | |
| } | |
| return { minX, maxX, minY, maxY, width: maxX - minX, height: maxY - minY }; | |
| } | |
| isLikelyText(region) { | |
| const { width, height } = region.bounds; | |
| const aspectRatio = width / height; | |
| // Text regions typically have certain aspect ratios | |
| return aspectRatio > 0.1 && aspectRatio < 20 && width > 5 && height > 5; | |
| } | |
| estimateFontSizes(textRegions) { | |
| return textRegions.map(region => { | |
| // Estimate font size based on region height | |
| const estimatedSize = Math.round(region.bounds.height * 0.75); | |
| return { | |
| estimatedSize, | |
| region: region.bounds, | |
| category: this.categorizeFontSize(estimatedSize) | |
| }; | |
| }); | |
| } | |
| categorizeFontSize(size) { | |
| if (size < 12) return 'small'; | |
| if (size < 16) return 'normal'; | |
| if (size < 24) return 'large'; | |
| return 'heading'; | |
| } | |
| calculateReadabilityScore(textRegions) { | |
| if (textRegions.length === 0) return 0; | |
| let score = 0; | |
| textRegions.forEach(region => { | |
| const { width, height } = region.bounds; | |
| const aspectRatio = width / height; | |
| // Good readability indicators | |
| if (height >= 12) score += 0.3; // Minimum readable size | |
| if (aspectRatio > 2 && aspectRatio < 15) score += 0.4; // Good line aspect ratio | |
| if (region.size > 100) score += 0.3; // Sufficient text area | |
| }); | |
| return Math.min(score / textRegions.length, 1); | |
| } | |
| analyzeTextHierarchy(textRegions) { | |
| const sizes = textRegions.map(r => r.bounds.height).sort((a, b) => b - a); | |
| const uniqueSizes = [...new Set(sizes)]; | |
| if (uniqueSizes.length >= 3) return 'good'; | |
| if (uniqueSizes.length === 2) return 'fair'; | |
| return 'poor'; | |
| } | |
| async analyzeSpacing(image, metadata) { | |
| try { | |
| // Analyze whitespace distribution | |
| const spacingMetrics = await this.calculateSpacingMetrics(image, metadata); | |
| return { | |
| verticalSpacing: spacingMetrics.vertical, | |
| horizontalSpacing: spacingMetrics.horizontal, | |
| consistency: spacingMetrics.consistency, | |
| balance: spacingMetrics.balance | |
| }; | |
| } catch (error) { | |
| console.error('Spacing analysis error:', error); | |
| return { | |
| verticalSpacing: 0, | |
| horizontalSpacing: 0, | |
| consistency: 0, | |
| balance: 0 | |
| }; | |
| } | |
| } | |
| async calculateSpacingMetrics(image, metadata) { | |
| // Simplified spacing analysis | |
| const edgeImage = await image | |
| .greyscale() | |
| .blur(1) | |
| .threshold(200) | |
| .raw() | |
| .toBuffer(); | |
| const verticalSpaces = this.analyzeVerticalSpacing(edgeImage, metadata.width, metadata.height); | |
| const horizontalSpaces = this.analyzeHorizontalSpacing(edgeImage, metadata.width, metadata.height); | |
| return { | |
| vertical: verticalSpaces.average, | |
| horizontal: horizontalSpaces.average, | |
| consistency: (verticalSpaces.consistency + horizontalSpaces.consistency) / 2, | |
| balance: this.calculateSpacingBalance(verticalSpaces, horizontalSpaces) | |
| }; | |
| } | |
| analyzeVerticalSpacing(buffer, width, height) { | |
| const rowSums = []; | |
| for (let y = 0; y < height; y++) { | |
| let rowSum = 0; | |
| for (let x = 0; x < width; x++) { | |
| rowSum += buffer[y * width + x]; | |
| } | |
| rowSums.push(rowSum / width); | |
| } | |
| const spaces = this.findSpaces(rowSums); | |
| return this.calculateSpaceMetrics(spaces); | |
| } | |
| analyzeHorizontalSpacing(buffer, width, height) { | |
| const colSums = []; | |
| for (let x = 0; x < width; x++) { | |
| let colSum = 0; | |
| for (let y = 0; y < height; y++) { | |
| colSum += buffer[y * width + x]; | |
| } | |
| colSums.push(colSum / height); | |
| } | |
| const spaces = this.findSpaces(colSums); | |
| return this.calculateSpaceMetrics(spaces); | |
| } | |
| findSpaces(values) { | |
| const spaces = []; | |
| let spaceStart = -1; | |
| const threshold = 200; // Whitespace threshold | |
| for (let i = 0; i < values.length; i++) { | |
| if (values[i] > threshold && spaceStart === -1) { | |
| spaceStart = i; | |
| } else if (values[i] <= threshold && spaceStart !== -1) { | |
| spaces.push(i - spaceStart); | |
| spaceStart = -1; | |
| } | |
| } | |
| return spaces.filter(space => space > 5); // Minimum space size | |
| } | |
| calculateSpaceMetrics(spaces) { | |
| if (spaces.length === 0) { | |
| return { average: 0, consistency: 0 }; | |
| } | |
| const average = spaces.reduce((sum, space) => sum + space, 0) / spaces.length; | |
| const variance = spaces.reduce((sum, space) => sum + Math.pow(space - average, 2), 0) / spaces.length; | |
| const consistency = Math.max(0, 1 - (Math.sqrt(variance) / average)); | |
| return { average, consistency }; | |
| } | |
| calculateSpacingBalance(vertical, horizontal) { | |
| const ratio = Math.min(vertical.average, horizontal.average) / Math.max(vertical.average, horizontal.average); | |
| return ratio * ((vertical.consistency + horizontal.consistency) / 2); | |
| } | |
| async analyzeVisualHierarchy(imageBuffer) { | |
| try { | |
| // Analyze visual weight distribution | |
| const image = sharp(imageBuffer); | |
| const hierarchyMetrics = await this.calculateHierarchyMetrics(image); | |
| return { | |
| clarity: hierarchyMetrics.clarity, | |
| contrast: hierarchyMetrics.contrast, | |
| progression: hierarchyMetrics.progression, | |
| focusPoints: hierarchyMetrics.focusPoints | |
| }; | |
| } catch (error) { | |
| console.error('Visual hierarchy analysis error:', error); | |
| return { | |
| clarity: 0, | |
| contrast: 0, | |
| progression: 0, | |
| focusPoints: 0 | |
| }; | |
| } | |
| } | |
| async calculateHierarchyMetrics(image) { | |
| // Apply visual saliency detection | |
| const saliencyMap = await this.generateSaliencyMap(image); | |
| return { | |
| clarity: this.assessHierarchyClarity(saliencyMap), | |
| contrast: this.assessVisualContrast(saliencyMap), | |
| progression: this.assessVisualProgression(saliencyMap), | |
| focusPoints: this.countFocusPoints(saliencyMap) | |
| }; | |
| } | |
| async generateSaliencyMap(image) { | |
| // Simplified saliency detection using edge detection and contrast | |
| const saliencyBuffer = await image | |
| .greyscale() | |
| .convolve({ | |
| width: 3, | |
| height: 3, | |
| kernel: [-1, -2, -1, 0, 0, 0, 1, 2, 1] | |
| }) | |
| .raw() | |
| .toBuffer(); | |
| return saliencyBuffer; | |
| } | |
| assessHierarchyClarity(saliencyMap) { | |
| if (!saliencyMap || saliencyMap.length === 0) return 0; | |
| // Calculate distribution of visual attention | |
| const histogram = new Array(256).fill(0); | |
| for (let i = 0; i < saliencyMap.length; i++) { | |
| const value = Math.max(0, Math.min(255, Math.floor(saliencyMap[i]))); | |
| histogram[value]++; | |
| } | |
| // Good hierarchy has clear peaks and valleys | |
| const peaks = this.findPeaks(histogram); | |
| return Math.min(peaks.length / 5, 1); // Normalize to 0-1 | |
| } | |
| assessVisualContrast(saliencyMap) { | |
| if (!saliencyMap || saliencyMap.length === 0) return 0; | |
| let max = 0; | |
| let min = 255; | |
| for (let i = 0; i < saliencyMap.length; i++) { | |
| if (saliencyMap[i] > max) max = saliencyMap[i]; | |
| if (saliencyMap[i] < min) min = saliencyMap[i]; | |
| } | |
| return (max - min) / 255; | |
| } | |
| assessVisualProgression(saliencyMap) { | |
| // Analyze if visual elements create a logical flow | |
| const quadrants = this.divideIntoQuadrants(saliencyMap); | |
| const progression = this.calculateProgression(quadrants); | |
| return progression; | |
| } | |
| countFocusPoints(saliencyMap) { | |
| if (!saliencyMap || saliencyMap.length === 0) return 0; | |
| // Count distinct areas of high visual interest | |
| const threshold = 200; | |
| let focusCount = 0; | |
| for (let i = 0; i < saliencyMap.length; i++) { | |
| if (saliencyMap[i] > threshold) focusCount++; | |
| } | |
| return Math.min(focusCount / saliencyMap.length * 10, 1); | |
| } | |
| findPeaks(histogram) { | |
| const peaks = []; | |
| for (let i = 1; i < histogram.length - 1; i++) { | |
| if (histogram[i] > histogram[i-1] && histogram[i] > histogram[i+1] && histogram[i] > 100) { | |
| peaks.push(i); | |
| } | |
| } | |
| return peaks; | |
| } | |
| divideIntoQuadrants(buffer) { | |
| const size = Math.sqrt(buffer.length); | |
| const halfSize = Math.floor(size / 2); | |
| const quadrants = [[], [], [], []]; | |
| for (let y = 0; y < size; y++) { | |
| for (let x = 0; x < size; x++) { | |
| const index = y * size + x; | |
| const quadrant = (y < halfSize ? 0 : 2) + (x < halfSize ? 0 : 1); | |
| quadrants[quadrant].push(buffer[index]); | |
| } | |
| } | |
| return quadrants.map(q => q.reduce((sum, val) => sum + val, 0) / q.length); | |
| } | |
| calculateProgression(quadrants) { | |
| // Check for visual flow patterns (simplified) | |
| const patterns = [ | |
| [0, 1, 2, 3], // Z-pattern | |
| [0, 2, 1, 3], // F-pattern | |
| [1, 0, 3, 2] // Reverse Z | |
| ]; | |
| let bestScore = 0; | |
| for (const pattern of patterns) { | |
| let score = 0; | |
| for (let i = 0; i < pattern.length - 1; i++) { | |
| const current = quadrants[pattern[i]]; | |
| const next = quadrants[pattern[i + 1]]; | |
| if (current >= next) score += 0.25; | |
| } | |
| bestScore = Math.max(bestScore, score); | |
| } | |
| return bestScore; | |
| } | |
| calculateVisualScore(analysis) { | |
| const weights = { | |
| color: 0.25, | |
| layout: 0.25, | |
| typography: 0.20, | |
| spacing: 0.15, | |
| hierarchy: 0.15 | |
| }; | |
| const scores = { | |
| color: this.scoreColorAnalysis(analysis.colorAnalysis), | |
| layout: this.scoreLayoutAnalysis(analysis.layoutAnalysis), | |
| typography: this.scoreTypographyAnalysis(analysis.typographyAnalysis), | |
| spacing: this.scoreSpacingAnalysis(analysis.spacingAnalysis), | |
| hierarchy: this.scoreHierarchyAnalysis(analysis.visualHierarchy) | |
| }; | |
| return Object.entries(weights).reduce((total, [key, weight]) => { | |
| return total + (scores[key] * weight); | |
| }, 0) * 100; // Convert to 0-100 scale | |
| } | |
| scoreColorAnalysis(colorAnalysis) { | |
| let score = 0; | |
| // Color harmony | |
| if (colorAnalysis.colorHarmony === 'excellent') score += 0.4; | |
| else if (colorAnalysis.colorHarmony === 'good') score += 0.3; | |
| else if (colorAnalysis.colorHarmony === 'fair') score += 0.2; | |
| // Contrast | |
| if (colorAnalysis.contrastRatios.average > this.colorThresholds.highContrast) score += 0.4; | |
| else if (colorAnalysis.contrastRatios.average > this.colorThresholds.normalContrast) score += 0.3; | |
| else if (colorAnalysis.contrastRatios.average > this.colorThresholds.lowContrast) score += 0.1; | |
| // Accessibility | |
| if (colorAnalysis.accessibility === 'excellent') score += 0.2; | |
| else if (colorAnalysis.accessibility === 'good') score += 0.15; | |
| else if (colorAnalysis.accessibility === 'fair') score += 0.1; | |
| return Math.min(score, 1); | |
| } | |
| scoreLayoutAnalysis(layoutAnalysis) { | |
| let score = 0; | |
| // Whitespace | |
| if (layoutAnalysis.whitespaceRatio >= this.layoutThresholds.minWhitespace) score += 0.3; | |
| // Content density | |
| if (layoutAnalysis.contentDensity <= this.layoutThresholds.maxContentDensity) score += 0.2; | |
| // Balance and symmetry | |
| score += layoutAnalysis.balance * 0.25; | |
| score += layoutAnalysis.symmetry * 0.25; | |
| return Math.min(score, 1); | |
| } | |
| scoreTypographyAnalysis(typographyAnalysis) { | |
| let score = 0; | |
| // Readability | |
| score += typographyAnalysis.readabilityScore * 0.4; | |
| // Hierarchy | |
| if (typographyAnalysis.hierarchy === 'good') score += 0.4; | |
| else if (typographyAnalysis.hierarchy === 'fair') score += 0.2; | |
| // Text regions presence | |
| if (typographyAnalysis.textRegions > 0) score += 0.2; | |
| return Math.min(score, 1); | |
| } | |
| scoreSpacingAnalysis(spacingAnalysis) { | |
| let score = 0; | |
| score += spacingAnalysis.consistency * 0.4; | |
| score += spacingAnalysis.balance * 0.3; | |
| score += Math.min(spacingAnalysis.verticalSpacing / 50, 1) * 0.15; | |
| score += Math.min(spacingAnalysis.horizontalSpacing / 50, 1) * 0.15; | |
| return Math.min(score, 1); | |
| } | |
| scoreHierarchyAnalysis(hierarchyAnalysis) { | |
| let score = 0; | |
| score += hierarchyAnalysis.clarity * 0.3; | |
| score += hierarchyAnalysis.contrast * 0.3; | |
| score += hierarchyAnalysis.progression * 0.2; | |
| score += hierarchyAnalysis.focusPoints * 0.2; | |
| return Math.min(score, 1); | |
| } | |
| identifyVisualIssues(analysis) { | |
| const issues = []; | |
| // Color issues | |
| if (analysis.colorAnalysis.contrastRatios.average < this.colorThresholds.normalContrast) { | |
| issues.push({ | |
| category: 'Color', | |
| severity: 'High', | |
| description: 'Low color contrast detected', | |
| recommendation: 'Increase contrast between text and background colors to meet WCAG AA standards (4.5:1 ratio)', | |
| location: 'Global' | |
| }); | |
| } | |
| if (analysis.colorAnalysis.accessibility === 'poor') { | |
| issues.push({ | |
| category: 'Color', | |
| severity: 'High', | |
| description: 'Poor color accessibility', | |
| recommendation: 'Ensure color is not the only means of conveying information', | |
| location: 'Global' | |
| }); | |
| } | |
| // Layout issues | |
| if (analysis.layoutAnalysis.whitespaceRatio < this.layoutThresholds.minWhitespace) { | |
| issues.push({ | |
| category: 'Layout', | |
| severity: 'Medium', | |
| description: 'Insufficient whitespace', | |
| recommendation: 'Increase spacing between elements to improve visual breathing room', | |
| location: 'Global' | |
| }); | |
| } | |
| if (analysis.layoutAnalysis.contentDensity > this.layoutThresholds.maxContentDensity) { | |
| issues.push({ | |
| category: 'Layout', | |
| severity: 'Medium', | |
| description: 'Content is too dense', | |
| recommendation: 'Reduce content density or reorganize layout for better scanability', | |
| location: 'Global' | |
| }); | |
| } | |
| // Typography issues | |
| if (analysis.typographyAnalysis.readabilityScore < 0.5) { | |
| issues.push({ | |
| category: 'Typography', | |
| severity: 'High', | |
| description: 'Poor text readability', | |
| recommendation: 'Increase font size, improve line spacing, or enhance text contrast', | |
| location: 'Text elements' | |
| }); | |
| } | |
| if (analysis.typographyAnalysis.hierarchy === 'poor') { | |
| issues.push({ | |
| category: 'Typography', | |
| severity: 'Medium', | |
| description: 'Weak typographic hierarchy', | |
| recommendation: 'Create clearer distinction between heading levels and body text', | |
| location: 'Text elements' | |
| }); | |
| } | |
| // Spacing issues | |
| if (analysis.spacingAnalysis.consistency < 0.5) { | |
| issues.push({ | |
| category: 'Spacing', | |
| severity: 'Medium', | |
| description: 'Inconsistent spacing', | |
| recommendation: 'Establish and maintain consistent spacing patterns throughout the design', | |
| location: 'Global' | |
| }); | |
| } | |
| // Visual hierarchy issues | |
| if (analysis.visualHierarchy.clarity < 0.4) { | |
| issues.push({ | |
| category: 'Visual Hierarchy', | |
| severity: 'High', | |
| description: 'Unclear visual hierarchy', | |
| recommendation: 'Strengthen visual hierarchy through size, color, and positioning', | |
| location: 'Global' | |
| }); | |
| } | |
| return issues; | |
| } | |
| assessColorHarmony(colorPalette) { | |
| if (colorPalette.length < 2) return 'insufficient'; | |
| // Simplified harmony assessment based on color relationships | |
| const harmony = this.calculateColorHarmony(colorPalette); | |
| if (harmony > 0.8) return 'excellent'; | |
| if (harmony > 0.6) return 'good'; | |
| if (harmony > 0.4) return 'fair'; | |
| return 'poor'; | |
| } | |
| calculateColorHarmony(colorPalette) { | |
| // Simplified harmony calculation based on hue relationships | |
| const hues = colorPalette.map(c => this.rgbToHsl(c.color.r, c.color.g, c.color.b).h); | |
| let harmonyScore = 0; | |
| const relationships = [60, 120, 180]; // Complementary, triadic, etc. | |
| for (let i = 0; i < hues.length - 1; i++) { | |
| for (let j = i + 1; j < hues.length; j++) { | |
| const diff = Math.abs(hues[i] - hues[j]); | |
| const normalizedDiff = Math.min(diff, 360 - diff); | |
| for (const relationship of relationships) { | |
| if (Math.abs(normalizedDiff - relationship) < 30) { | |
| harmonyScore += 0.3; | |
| } | |
| } | |
| } | |
| } | |
| return Math.min(harmonyScore, 1); | |
| } | |
| assessColorAccessibility(contrastAnalysis) { | |
| if (contrastAnalysis.average >= this.colorThresholds.highContrast) return 'excellent'; | |
| if (contrastAnalysis.average >= this.colorThresholds.normalContrast) return 'good'; | |
| if (contrastAnalysis.average >= this.colorThresholds.lowContrast) return 'fair'; | |
| return 'poor'; | |
| } | |
| assessContrastLevel(contrast) { | |
| if (contrast >= 7) return 'high'; | |
| if (contrast >= 4.5) return 'normal'; | |
| if (contrast >= 3) return 'low'; | |
| return 'poor'; | |
| } | |
| assessLayout(layoutMetrics) { | |
| const score = (layoutMetrics.whitespace + layoutMetrics.balance + layoutMetrics.symmetry) / 3; | |
| if (score >= 0.8) return 'excellent'; | |
| if (score >= 0.6) return 'good'; | |
| if (score >= 0.4) return 'fair'; | |
| return 'poor'; | |
| } | |
| // Utility functions | |
| rgbToHex(r, g, b) { | |
| return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); | |
| } | |
| rgbToHsl(r, g, b) { | |
| r /= 255; | |
| g /= 255; | |
| b /= 255; | |
| const max = Math.max(r, g, b); | |
| const min = Math.min(r, g, b); | |
| let h, s, l = (max + min) / 2; | |
| if (max === min) { | |
| h = s = 0; | |
| } else { | |
| const d = max - min; | |
| s = l > 0.5 ? d / (2 - max - min) : d / (max + min); | |
| switch (max) { | |
| case r: h = (g - b) / d + (g < b ? 6 : 0); break; | |
| case g: h = (b - r) / d + 2; break; | |
| case b: h = (r - g) / d + 4; break; | |
| } | |
| h /= 6; | |
| } | |
| return { h: h * 360, s: s * 100, l: l * 100 }; | |
| } | |
| } | |
| module.exports = VisualDesignAnalyzer; |