UX-agent / backend /services /visualDesignAnalyzer.js
AUXteam's picture
Fix crash in visual design analyzer when no colors are found
f77b8ef verified
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;