From 78b017668b84a196884a9d3020aa1d69548f4c3c Mon Sep 17 00:00:00 2001 From: Vergissin <627974740@163.com> Date: Mon, 16 Mar 2026 18:05:55 +0800 Subject: [PATCH] kimi res --- .history/src/app/test/page_20260316165237.tsx | 335 +++++++++++ .history/src/app/test/page_20260316165403.tsx | 335 +++++++++++ .../components/ColorWheel_20260316164819.tsx | 326 +++++++++++ .../components/ColorWheel_20260316165406.tsx | 326 +++++++++++ .../components/ColorWheel_20260316165420.tsx | 324 +++++++++++ .../components/ColorWheel_20260316170349.tsx | 448 +++++++++++++++ .../components/ColorWheel_20260316170923.tsx | 447 +++++++++++++++ .../components/ColorWheel_20260316170933.tsx | 447 +++++++++++++++ .../components/ColorWheel_20260316171347.tsx | 495 ++++++++++++++++ .../components/ColorWheel_20260316171406.tsx | 542 ++++++++++++++++++ .../components/ColorWheel_20260316171437.tsx | 542 ++++++++++++++++++ .../components/ColorWheel_20260316172140.tsx | 541 +++++++++++++++++ .../colorUtils.test_20260316165122.ts | 229 ++++++++ .../colorUtils.test_20260316165406.ts | 230 ++++++++ .../colorUtils.test_20260316165442.ts | 229 ++++++++ README.md | 104 +++- next.config.ts | 9 +- src/app/page.tsx | 119 +++- src/app/test/page.tsx | 335 +++++++++++ src/components/ColorWheel.tsx | 541 +++++++++++++++++ src/lib/__tests__/colorUtils.test.ts | 229 ++++++++ src/lib/colorUtils.ts | 230 ++++++++ src/lib/utils.ts | 43 ++ 23 files changed, 7389 insertions(+), 17 deletions(-) create mode 100644 .history/src/app/test/page_20260316165237.tsx create mode 100644 .history/src/app/test/page_20260316165403.tsx create mode 100644 .history/src/components/ColorWheel_20260316164819.tsx create mode 100644 .history/src/components/ColorWheel_20260316165406.tsx create mode 100644 .history/src/components/ColorWheel_20260316165420.tsx create mode 100644 .history/src/components/ColorWheel_20260316170349.tsx create mode 100644 .history/src/components/ColorWheel_20260316170923.tsx create mode 100644 .history/src/components/ColorWheel_20260316170933.tsx create mode 100644 .history/src/components/ColorWheel_20260316171347.tsx create mode 100644 .history/src/components/ColorWheel_20260316171406.tsx create mode 100644 .history/src/components/ColorWheel_20260316171437.tsx create mode 100644 .history/src/components/ColorWheel_20260316172140.tsx create mode 100644 .history/src/lib/__tests__/colorUtils.test_20260316165122.ts create mode 100644 .history/src/lib/__tests__/colorUtils.test_20260316165406.ts create mode 100644 .history/src/lib/__tests__/colorUtils.test_20260316165442.ts create mode 100644 src/app/test/page.tsx create mode 100644 src/components/ColorWheel.tsx create mode 100644 src/lib/__tests__/colorUtils.test.ts create mode 100644 src/lib/colorUtils.ts diff --git a/.history/src/app/test/page_20260316165237.tsx b/.history/src/app/test/page_20260316165237.tsx new file mode 100644 index 0000000..0e54747 --- /dev/null +++ b/.history/src/app/test/page_20260316165237.tsx @@ -0,0 +1,335 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + hexToHsl, + hslToHex, + generateColorHarmonies, + generateRecommendedPair, + getColorWheelPosition, + getHslFromPosition +} from '@/lib/colorUtils'; +import { generateGradientColors } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { CheckCircle, XCircle, AlertCircle } from 'lucide-react'; + +interface TestResult { + name: string; + passed: boolean; + message: string; +} + +export default function TestPage() { + const [results, setResults] = useState([]); + const [isRunning, setIsRunning] = useState(false); + + const runTests = () => { + setIsRunning(true); + const testResults: TestResult[] = []; + + // 测试 1: HEX 转 HSL + try { + const redHsl = hexToHsl('#FF0000'); + const passed = redHsl.h === 0 && redHsl.s === 100 && redHsl.l === 50; + testResults.push({ + name: 'HEX 转 HSL', + passed, + message: passed + ? '#FF0000 正确转换为 HSL(0, 100%, 50%)' + : `转换结果: HSL(${redHsl.h}, ${redHsl.s}%, ${redHsl.l}%)` + }); + } catch (error) { + testResults.push({ name: 'HEX 转 HSL', passed: false, message: '测试执行出错' }); + } + + // 测试 2: HSL 转 HEX + try { + const redHex = hslToHex({ h: 0, s: 100, l: 50 }); + const passed = redHex === '#FF0000'; + testResults.push({ + name: 'HSL 转 HEX', + passed, + message: passed + ? 'HSL(0, 100%, 50%) 正确转换为 #FF0000' + : `转换结果: ${redHex}` + }); + } catch (error) { + testResults.push({ name: 'HSL 转 HEX', passed: false, message: '测试执行出错' }); + } + + // 测试 3: 双向转换 + try { + const originalHex = '#5135FF'; + const hsl = hexToHsl(originalHex); + const convertedHex = hslToHex(hsl); + const passed = convertedHex === originalHex; + testResults.push({ + name: 'HEX ↔ HSL 双向转换', + passed, + message: passed + ? `${originalHex} 双向转换成功` + : `转换结果: ${convertedHex}` + }); + } catch (error) { + testResults.push({ name: 'HEX ↔ HSL 双向转换', passed: false, message: '测试执行出错' }); + } + + // 测试 4: 色彩和谐生成 + try { + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + const passed = harmonies.length === 6; + const names = harmonies.map(h => h.name).join(', '); + testResults.push({ + name: '色彩和谐生成', + passed, + message: passed + ? `成功生成 6 种和谐配色: ${names}` + : `生成了 ${harmonies.length} 种配色` + }); + } catch (error) { + testResults.push({ name: '色彩和谐生成', passed: false, message: '测试执行出错' }); + } + + // 测试 5: 互补色计算 + try { + const harmonies = generateColorHarmonies({ h: 0, s: 100, l: 50 }); + const complementary = harmonies.find(h => h.name === '互补色'); + const passed = complementary?.colors.includes('#00FFFF') ?? false; + testResults.push({ + name: '互补色计算', + passed, + message: passed + ? '红色的互补色正确计算为青色 (#00FFFF)' + : `互补色结果: ${complementary?.colors.join(', ')}` + }); + } catch (error) { + testResults.push({ name: '互补色计算', passed: false, message: '测试执行出错' }); + } + + // 测试 6: 三分色计算 + try { + const harmonies = generateColorHarmonies({ h: 0, s: 100, l: 50 }); + const triadic = harmonies.find(h => h.name === '三分色'); + const hasRed = triadic?.colors.includes('#FF0000'); + const hasGreen = triadic?.colors.includes('#00FF00'); + const hasBlue = triadic?.colors.includes('#0000FF'); + const passed = hasRed && hasGreen && hasBlue; + testResults.push({ + name: '三分色计算', + passed, + message: passed + ? '正确生成红、绿、蓝三分色' + : `三分色结果: ${triadic?.colors.join(', ')}` + }); + } catch (error) { + testResults.push({ name: '三分色计算', passed: false, message: '测试执行出错' }); + } + + // 测试 7: 推荐配色 + try { + const recommendation = generateRecommendedPair('#FF0000'); + const passed = recommendation.secondary === '#00FFFF'; + testResults.push({ + name: '推荐配色算法', + passed, + message: passed + ? '红色正确推荐互补色青色' + : `推荐结果: ${recommendation.secondary}` + }); + } catch (error) { + testResults.push({ name: '推荐配色算法', passed: false, message: '测试执行出错' }); + } + + // 测试 8: 色轮位置计算 + try { + const pos = getColorWheelPosition(0, 100, 100); + const passed = pos.x === 100 && pos.y < 1; + testResults.push({ + name: '色轮位置计算', + passed, + message: passed + ? '色相0°正确位于色轮顶部' + : `位置: (${pos.x}, ${pos.y})` + }); + } catch (error) { + testResults.push({ name: '色轮位置计算', passed: false, message: '测试执行出错' }); + } + + // 测试 9: 位置转HSL + try { + const result = getHslFromPosition(100, 0, 100); + const passed = result.h === 0 && result.s === 100; + testResults.push({ + name: '位置转HSL', + passed, + message: passed + ? '色轮顶部正确转换为 HSL(0°, 100%)' + : `结果: HSL(${result.h}°, ${result.s}%)` + }); + } catch (error) { + testResults.push({ name: '位置转HSL', passed: false, message: '测试执行出错' }); + } + + // 测试 10: 渐变色彩生成 + try { + const gradientColors = generateGradientColors('#FF0000', '#0000FF'); + const passed = gradientColors.length === 4 && + gradientColors[0] === '#FF0000' && + gradientColors[3] === '#0000FF'; + testResults.push({ + name: '渐变色彩生成', + passed, + message: passed + ? '正确生成4色渐变数组 (红 -> 蓝)' + : `生成了 ${gradientColors.length} 个颜色: ${gradientColors.join(', ')}` + }); + } catch (error) { + testResults.push({ name: '渐变色彩生成', passed: false, message: '测试执行出错' }); + } + + setResults(testResults); + setIsRunning(false); + }; + + useEffect(() => { + runTests(); + }, []); + + const passedCount = results.filter(r => r.passed).length; + const totalCount = results.length; + + return ( +
+
+ + {/* Header */} +
+

+ 色彩模块测试 +

+

+ 验证色彩工具函数和色彩和谐算法的正确性 +

+
+ + {/* Summary Card */} + +
+
+

测试结果摘要

+

+ 通过: {passedCount} / {totalCount} +

+
+
+
+
+ {totalCount > 0 ? Math.round((passedCount / totalCount) * 100) : 0}% +
+
通过率
+
+ +
+
+ + {/* Progress Bar */} +
+
0 ? (passedCount / totalCount) * 100 : 0}%` }} + /> +
+ + + {/* Test Results */} +
+

详细结果

+ {results.map((result, index) => ( + +
+
+ {result.passed ? ( + + ) : ( + + )} +
+
+
+ {result.name} + + {result.passed ? '通过' : '失败'} + +
+

+ {result.message} +

+
+
+
+ ))} + + {results.length === 0 && ( + + +

点击"重新测试"开始测试

+
+ )} +
+ + {/* Color Harmony Preview */} + {results.length > 0 && ( + +

色彩和谐预览

+
+ {(() => { + const harmonies = generateColorHarmonies({ h: 200, s: 80, l: 50 }); + return harmonies.map((harmony) => ( +
+
+ {harmony.name} + + {harmony.colors.length} 色 + +
+
+ {harmony.colors.map((color, idx) => ( +
+ ))} +
+
+ )); + })()} +
+ + )} + + {/* Footer */} +
+

测试基于色彩和谐理论和 HSL 色彩空间

+
+
+
+ ); +} diff --git a/.history/src/app/test/page_20260316165403.tsx b/.history/src/app/test/page_20260316165403.tsx new file mode 100644 index 0000000..e428b50 --- /dev/null +++ b/.history/src/app/test/page_20260316165403.tsx @@ -0,0 +1,335 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + hexToHsl, + hslToHex, + generateColorHarmonies, + generateRecommendedPair, + getColorWheelPosition, + getHslFromPosition +} from '@/lib/colorUtils'; +import { generateGradientColors } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { CheckCircle, XCircle, AlertCircle } from 'lucide-react'; + +interface TestResult { + name: string; + passed: boolean; + message: string; +} + +export default function TestPage() { + const [results, setResults] = useState([]); + const [isRunning, setIsRunning] = useState(false); + + const runTests = () => { + setIsRunning(true); + const testResults: TestResult[] = []; + + // 测试 1: HEX 转 HSL + try { + const redHsl = hexToHsl('#FF0000'); + const passed = redHsl.h === 0 && redHsl.s === 100 && redHsl.l === 50; + testResults.push({ + name: 'HEX 转 HSL', + passed, + message: passed + ? '#FF0000 正确转换为 HSL(0, 100%, 50%)' + : `转换结果: HSL(${redHsl.h}, ${redHsl.s}%, ${redHsl.l}%)` + }); + } catch (error) { + testResults.push({ name: 'HEX 转 HSL', passed: false, message: '测试执行出错' }); + } + + // 测试 2: HSL 转 HEX + try { + const redHex = hslToHex({ h: 0, s: 100, l: 50 }); + const passed = redHex === '#FF0000'; + testResults.push({ + name: 'HSL 转 HEX', + passed, + message: passed + ? 'HSL(0, 100%, 50%) 正确转换为 #FF0000' + : `转换结果: ${redHex}` + }); + } catch (error) { + testResults.push({ name: 'HSL 转 HEX', passed: false, message: '测试执行出错' }); + } + + // 测试 3: 双向转换 + try { + const originalHex = '#5135FF'; + const hsl = hexToHsl(originalHex); + const convertedHex = hslToHex(hsl); + const passed = convertedHex === originalHex; + testResults.push({ + name: 'HEX ↔ HSL 双向转换', + passed, + message: passed + ? `${originalHex} 双向转换成功` + : `转换结果: ${convertedHex}` + }); + } catch (error) { + testResults.push({ name: 'HEX ↔ HSL 双向转换', passed: false, message: '测试执行出错' }); + } + + // 测试 4: 色彩和谐生成 + try { + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + const passed = harmonies.length === 6; + const names = harmonies.map(h => h.name).join(', '); + testResults.push({ + name: '色彩和谐生成', + passed, + message: passed + ? `成功生成 6 种和谐配色: ${names}` + : `生成了 ${harmonies.length} 种配色` + }); + } catch (error) { + testResults.push({ name: '色彩和谐生成', passed: false, message: '测试执行出错' }); + } + + // 测试 5: 互补色计算 + try { + const harmonies = generateColorHarmonies({ h: 0, s: 100, l: 50 }); + const complementary = harmonies.find(h => h.name === '互补色'); + const passed = complementary?.colors.includes('#00FFFF') ?? false; + testResults.push({ + name: '互补色计算', + passed, + message: passed + ? '红色的互补色正确计算为青色 (#00FFFF)' + : `互补色结果: ${complementary?.colors.join(', ')}` + }); + } catch (error) { + testResults.push({ name: '互补色计算', passed: false, message: '测试执行出错' }); + } + + // 测试 6: 三分色计算 + try { + const harmonies = generateColorHarmonies({ h: 0, s: 100, l: 50 }); + const triadic = harmonies.find(h => h.name === '三分色'); + const hasRed = triadic?.colors.includes('#FF0000'); + const hasGreen = triadic?.colors.includes('#00FF00'); + const hasBlue = triadic?.colors.includes('#0000FF'); + const passed = hasRed && hasGreen && hasBlue; + testResults.push({ + name: '三分色计算', + passed, + message: passed + ? '正确生成红、绿、蓝三分色' + : `三分色结果: ${triadic?.colors.join(', ')}` + }); + } catch (error) { + testResults.push({ name: '三分色计算', passed: false, message: '测试执行出错' }); + } + + // 测试 7: 推荐配色 + try { + const recommendation = generateRecommendedPair('#FF0000'); + const passed = recommendation.secondary === '#00FFFF'; + testResults.push({ + name: '推荐配色算法', + passed, + message: passed + ? '红色正确推荐互补色青色' + : `推荐结果: ${recommendation.secondary}` + }); + } catch (error) { + testResults.push({ name: '推荐配色算法', passed: false, message: '测试执行出错' }); + } + + // 测试 8: 色轮位置计算 + try { + const pos = getColorWheelPosition(0, 100, 100); + const passed = pos.x === 100 && pos.y < 1; + testResults.push({ + name: '色轮位置计算', + passed, + message: passed + ? '色相0°正确位于色轮顶部' + : `位置: (${pos.x}, ${pos.y})` + }); + } catch (error) { + testResults.push({ name: '色轮位置计算', passed: false, message: '测试执行出错' }); + } + + // 测试 9: 位置转HSL + try { + const result = getHslFromPosition(100, 0, 100); + const passed = result.h === 0 && result.s === 100; + testResults.push({ + name: '位置转HSL', + passed, + message: passed + ? '色轮顶部正确转换为 HSL(0°, 100%)' + : `结果: HSL(${result.h}°, ${result.s}%)` + }); + } catch (error) { + testResults.push({ name: '位置转HSL', passed: false, message: '测试执行出错' }); + } + + // 测试 10: 渐变色彩生成 + try { + const gradientColors = generateGradientColors('#FF0000', '#0000FF'); + const passed = gradientColors.length === 4 && + gradientColors[0] === '#FF0000' && + gradientColors[3] === '#0000FF'; + testResults.push({ + name: '渐变色彩生成', + passed, + message: passed + ? '正确生成4色渐变数组 (红 -> 蓝)' + : `生成了 ${gradientColors.length} 个颜色: ${gradientColors.join(', ')}` + }); + } catch (error) { + testResults.push({ name: '渐变色彩生成', passed: false, message: '测试执行出错' }); + } + + setResults(testResults); + setIsRunning(false); + }; + + useEffect(() => { + runTests(); + }, []); + + const passedCount = results.filter(r => r.passed).length; + const totalCount = results.length; + + return ( +
+
+ + {/* Header */} +
+

+ 色彩模块测试 +

+

+ 验证色彩工具函数和色彩和谐算法的正确性 +

+
+ + {/* Summary Card */} + +
+
+

测试结果摘要

+

+ 通过: {passedCount} / {totalCount} +

+
+
+
+
+ {totalCount > 0 ? Math.round((passedCount / totalCount) * 100) : 0}% +
+
通过率
+
+ +
+
+ + {/* Progress Bar */} +
+
0 ? (passedCount / totalCount) * 100 : 0}%` }} + /> +
+ + + {/* Test Results */} +
+

详细结果

+ {results.map((result, index) => ( + +
+
+ {result.passed ? ( + + ) : ( + + )} +
+
+
+ {result.name} + + {result.passed ? '通过' : '失败'} + +
+

+ {result.message} +

+
+
+
+ ))} + + {results.length === 0 && ( + + +

点击"重新测试"开始测试

+
+ )} +
+ + {/* Color Harmony Preview */} + {results.length > 0 && ( + +

色彩和谐预览

+
+ {(() => { + const harmonies = generateColorHarmonies({ h: 200, s: 80, l: 50 }); + return harmonies.map((harmony) => ( +
+
+ {harmony.name} + + {harmony.colors.length} 色 + +
+
+ {harmony.colors.map((color, idx) => ( +
+ ))} +
+
+ )); + })()} +
+ + )} + + {/* Footer */} +
+

测试基于色彩和谐理论和 HSL 色彩空间

+
+
+
+ ); +} diff --git a/.history/src/components/ColorWheel_20260316164819.tsx b/.history/src/components/ColorWheel_20260316164819.tsx new file mode 100644 index 0000000..2369807 --- /dev/null +++ b/.history/src/components/ColorWheel_20260316164819.tsx @@ -0,0 +1,326 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies, generateRecommendedPair } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + const primaryHsl = hexToHsl(primaryColor); + + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color, idx) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 处理颜色选择 + const handleColorSelection = (x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 如果点击靠近某个颜色点,开始拖动该点 + if (distToPrimary < 20) { + setIsDragging('primary'); + } else if (mode === 'free' && distToSecondary < 20) { + setIsDragging('secondary'); + } else { + // 否则根据模式选择要移动的点 + setIsDragging(mode === 'recommended' ? 'primary' : distToPrimary <= distToSecondary ? 'primary' : 'secondary'); + handleColorSelection(pos.x, pos.y, mode === 'recommended' ? 'primary' : distToPrimary <= distToSecondary ? 'primary' : 'secondary'); + } + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + if (!isDragging) return; + e.preventDefault(); + + const pos = getPositionFromEvent(e); + if (!pos) return; + + handleColorSelection(pos.x, pos.y, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
+ + {/* 次色选择点 */} +
+ + {/* 中心点 */} +
+
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/.history/src/components/ColorWheel_20260316165406.tsx b/.history/src/components/ColorWheel_20260316165406.tsx new file mode 100644 index 0000000..27dfbd0 --- /dev/null +++ b/.history/src/components/ColorWheel_20260316165406.tsx @@ -0,0 +1,326 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + const primaryHsl = hexToHsl(primaryColor); + + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color, idx) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 处理颜色选择 + const handleColorSelection = (x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 如果点击靠近某个颜色点,开始拖动该点 + if (distToPrimary < 20) { + setIsDragging('primary'); + } else if (mode === 'free' && distToSecondary < 20) { + setIsDragging('secondary'); + } else { + // 否则根据模式选择要移动的点 + setIsDragging(mode === 'recommended' ? 'primary' : distToPrimary <= distToSecondary ? 'primary' : 'secondary'); + handleColorSelection(pos.x, pos.y, mode === 'recommended' ? 'primary' : distToPrimary <= distToSecondary ? 'primary' : 'secondary'); + } + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + if (!isDragging) return; + e.preventDefault(); + + const pos = getPositionFromEvent(e); + if (!pos) return; + + handleColorSelection(pos.x, pos.y, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
+ + {/* 次色选择点 */} +
+ + {/* 中心点 */} +
+
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/.history/src/components/ColorWheel_20260316165420.tsx b/.history/src/components/ColorWheel_20260316165420.tsx new file mode 100644 index 0000000..f15d321 --- /dev/null +++ b/.history/src/components/ColorWheel_20260316165420.tsx @@ -0,0 +1,324 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 处理颜色选择 + const handleColorSelection = (x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 如果点击靠近某个颜色点,开始拖动该点 + if (distToPrimary < 20) { + setIsDragging('primary'); + } else if (mode === 'free' && distToSecondary < 20) { + setIsDragging('secondary'); + } else { + // 否则根据模式选择要移动的点 + setIsDragging(mode === 'recommended' ? 'primary' : distToPrimary <= distToSecondary ? 'primary' : 'secondary'); + handleColorSelection(pos.x, pos.y, mode === 'recommended' ? 'primary' : distToPrimary <= distToSecondary ? 'primary' : 'secondary'); + } + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + if (!isDragging) return; + e.preventDefault(); + + const pos = getPositionFromEvent(e); + if (!pos) return; + + handleColorSelection(pos.x, pos.y, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
+ + {/* 次色选择点 */} +
+ + {/* 中心点 */} +
+
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/.history/src/components/ColorWheel_20260316170349.tsx b/.history/src/components/ColorWheel_20260316170349.tsx new file mode 100644 index 0000000..eb09a1d --- /dev/null +++ b/.history/src/components/ColorWheel_20260316170349.tsx @@ -0,0 +1,448 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); + const [hoveredColor, setHoveredColor] = useState(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + const [showDragPreview, setShowDragPreview] = useState(false); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 在自由选择模式下绘制拖拽预览效果 + if (mode === 'free' && isDragging && dragPosition) { + const { h, s } = getHslFromPosition(dragPosition.x, dragPosition.y, radius); + const previewColor = hslToHex({ h, s, l: 50 }); + + // 绘制预览圆环 + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = previewColor; + ctx.lineWidth = 1; + ctx.stroke(); + + // 绘制从中心到拖拽位置的连线 + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.lineTo(dragPosition.x, dragPosition.y); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + } + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor, isDragging, dragPosition]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 处理颜色选择 + const handleColorSelection = (x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }; + + // 计算鼠标位置对应的颜色 + const getColorAtPosition = (x: number, y: number): string => { + const { h, s } = getHslFromPosition(x, y, radius); + return hslToHex({ h, s, l: 50 }); + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 如果点击靠近某个颜色点,开始拖动该点 + if (distToPrimary < 25) { + setIsDragging('primary'); + } else if (mode === 'free' && distToSecondary < 25) { + setIsDragging('secondary'); + } else { + // 否则根据模式选择要移动的点 + const targetType = mode === 'recommended' ? 'primary' : distToPrimary <= distToSecondary ? 'primary' : 'secondary'; + setIsDragging(targetType); + setDragPosition(pos); + setShowDragPreview(true); + handleColorSelection(pos.x, pos.y, targetType); + } + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 更新悬停颜色显示 + const color = getColorAtPosition(pos.x, pos.y); + setHoveredColor(color); + + if (!isDragging) { + // 检查是否悬停在颜色点上,改变光标样式 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + const canvas = canvasRef.current; + if (canvas) { + if (distToPrimary < 25 || (mode === 'free' && distToSecondary < 25)) { + canvas.style.cursor = 'grab'; + } else { + canvas.style.cursor = 'crosshair'; + } + } + return; + } + + e.preventDefault(); + setDragPosition(pos); + handleColorSelection(pos.x, pos.y, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + const handleMouseLeave = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + setHoveredColor(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
+ {/* 主色标签 */} +
+ 主色 +
+
+ + {/* 次色选择点 */} +
+ {/* 次色标签 */} + {mode === 'free' && ( +
+ 搭配 +
+ )} +
+ + {/* 拖拽时的实时预览点(自由选择模式) */} + {mode === 'free' && isDragging && dragPosition && ( +
+ )} + + {/* 中心点 */} +
+ + {/* 悬停颜色提示 */} + {mode === 'free' && hoveredColor && !isDragging && ( +
+ {hoveredColor.toUpperCase()} +
+ )} +
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/.history/src/components/ColorWheel_20260316170923.tsx b/.history/src/components/ColorWheel_20260316170923.tsx new file mode 100644 index 0000000..d521699 --- /dev/null +++ b/.history/src/components/ColorWheel_20260316170923.tsx @@ -0,0 +1,447 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); + const [hoveredColor, setHoveredColor] = useState(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + const [showDragPreview, setShowDragPreview] = useState(false); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 在自由选择模式下绘制拖拽预览效果 + if (mode === 'free' && isDragging && dragPosition) { + const { h, s } = getHslFromPosition(dragPosition.x, dragPosition.y, radius); + const previewColor = hslToHex({ h, s, l: 50 }); + + // 绘制预览圆环 + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = previewColor; + ctx.lineWidth = 1; + ctx.stroke(); + + // 绘制从中心到拖拽位置的连线 + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.lineTo(dragPosition.x, dragPosition.y); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + } + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor, isDragging, dragPosition]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 处理颜色选择 + const handleColorSelection = (x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }; + + // 计算鼠标位置对应的颜色 + const getColorAtPosition = (x: number, y: number): string => { + const { h, s } = getHslFromPosition(x, y, radius); + return hslToHex({ h, s, l: 50 }); + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 只有点击靠近颜色点时才允许拖拽 + if (distToPrimary < 25) { + setIsDragging('primary'); + setDragPosition(pos); + setShowDragPreview(true); + } else if (mode === 'free' && distToSecondary < 25) { + setIsDragging('secondary'); + setDragPosition(pos); + setShowDragPreview(true); + } + // 点击色轮其他位置不执行任何操作 + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 更新悬停颜色显示 + const color = getColorAtPosition(pos.x, pos.y); + setHoveredColor(color); + + if (!isDragging) { + // 检查是否悬停在颜色点上,改变光标样式 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + const canvas = canvasRef.current; + if (canvas) { + // 只有悬停在颜色点上时才显示 grab 光标,否则显示默认光标 + if (distToPrimary < 25 || (mode === 'free' && distToSecondary < 25)) { + canvas.style.cursor = 'grab'; + } else { + canvas.style.cursor = 'default'; + } + } + return; + } + + e.preventDefault(); + setDragPosition(pos); + handleColorSelection(pos.x, pos.y, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + const handleMouseLeave = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + setHoveredColor(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
+ {/* 主色标签 */} +
+ 主色 +
+
+ + {/* 次色选择点 */} +
+ {/* 次色标签 */} + {mode === 'free' && ( +
+ 搭配 +
+ )} +
+ + {/* 拖拽时的实时预览点(自由选择模式) */} + {mode === 'free' && isDragging && dragPosition && ( +
+ )} + + {/* 中心点 */} +
+ + {/* 悬停颜色提示 */} + {mode === 'free' && hoveredColor && !isDragging && ( +
+ {hoveredColor.toUpperCase()} +
+ )} +
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/.history/src/components/ColorWheel_20260316170933.tsx b/.history/src/components/ColorWheel_20260316170933.tsx new file mode 100644 index 0000000..3d730d9 --- /dev/null +++ b/.history/src/components/ColorWheel_20260316170933.tsx @@ -0,0 +1,447 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); + const [hoveredColor, setHoveredColor] = useState(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + const [showDragPreview, setShowDragPreview] = useState(false); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 在自由选择模式下绘制拖拽预览效果 + if (mode === 'free' && isDragging && dragPosition) { + const { h, s } = getHslFromPosition(dragPosition.x, dragPosition.y, radius); + const previewColor = hslToHex({ h, s, l: 50 }); + + // 绘制预览圆环 + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = previewColor; + ctx.lineWidth = 1; + ctx.stroke(); + + // 绘制从中心到拖拽位置的连线 + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.lineTo(dragPosition.x, dragPosition.y); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + } + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor, isDragging, dragPosition]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 处理颜色选择 + const handleColorSelection = (x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }; + + // 计算鼠标位置对应的颜色 + const getColorAtPosition = (x: number, y: number): string => { + const { h, s } = getHslFromPosition(x, y, radius); + return hslToHex({ h, s, l: 50 }); + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 只有点击靠近颜色点时才允许拖拽 + if (distToPrimary < 25) { + setIsDragging('primary'); + setDragPosition(pos); + setShowDragPreview(true); + } else if (mode === 'free' && distToSecondary < 25) { + setIsDragging('secondary'); + setDragPosition(pos); + setShowDragPreview(true); + } + // 点击色轮其他位置不执行任何操作 + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 更新悬停颜色显示 + const color = getColorAtPosition(pos.x, pos.y); + setHoveredColor(color); + + if (!isDragging) { + // 检查是否悬停在颜色点上,改变光标样式 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + const canvas = canvasRef.current; + if (canvas) { + // 只有悬停在颜色点上时才显示 grab 光标,否则显示默认光标 + if (distToPrimary < 25 || (mode === 'free' && distToSecondary < 25)) { + canvas.style.cursor = 'grab'; + } else { + canvas.style.cursor = 'default'; + } + } + return; + } + + e.preventDefault(); + setDragPosition(pos); + handleColorSelection(pos.x, pos.y, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + const handleMouseLeave = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + setHoveredColor(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
+ {/* 主色标签 */} +
+ 主色 +
+
+ + {/* 次色选择点 */} +
+ {/* 次色标签 */} + {mode === 'free' && ( +
+ 搭配 +
+ )} +
+ + {/* 拖拽时的实时预览点(自由选择模式) */} + {mode === 'free' && isDragging && dragPosition && ( +
+ )} + + {/* 中心点 */} +
+ + {/* 拖拽时的颜色值提示 */} + {isDragging && dragPosition && ( +
+ {(isDragging === 'primary' ? primaryColor : secondaryColor).toUpperCase()} +
+ )} +
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/.history/src/components/ColorWheel_20260316171347.tsx b/.history/src/components/ColorWheel_20260316171347.tsx new file mode 100644 index 0000000..51ef960 --- /dev/null +++ b/.history/src/components/ColorWheel_20260316171347.tsx @@ -0,0 +1,495 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); + const [hoveredColor, setHoveredColor] = useState(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + const [showDragPreview, setShowDragPreview] = useState(false); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 在自由选择模式下绘制拖拽预览效果 + if (mode === 'free' && isDragging && dragPosition) { + const { h, s } = getHslFromPosition(dragPosition.x, dragPosition.y, radius); + const previewColor = hslToHex({ h, s, l: 50 }); + + // 绘制预览圆环 + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = previewColor; + ctx.lineWidth = 1; + ctx.stroke(); + + // 绘制从中心到拖拽位置的连线 + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.lineTo(dragPosition.x, dragPosition.y); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + } + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor, isDragging, dragPosition]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 处理颜色选择 + const handleColorSelection = (x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }; + + // 计算鼠标位置对应的颜色 + const getColorAtPosition = (x: number, y: number): string => { + const { h, s } = getHslFromPosition(x, y, radius); + return hslToHex({ h, s, l: 50 }); + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 只有点击靠近颜色点时才允许拖拽 + if (distToPrimary < 25) { + setIsDragging('primary'); + setDragPosition(pos); + setShowDragPreview(true); + } else if (mode === 'free' && distToSecondary < 25) { + setIsDragging('secondary'); + setDragPosition(pos); + setShowDragPreview(true); + } + // 点击色轮其他位置不执行任何操作 + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 更新悬停颜色显示 + const color = getColorAtPosition(pos.x, pos.y); + setHoveredColor(color); + + if (!isDragging) { + // 检查是否悬停在颜色点上,改变光标样式 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + const canvas = canvasRef.current; + if (canvas) { + // 只有悬停在颜色点上时才显示 grab 光标,否则显示默认光标 + if (distToPrimary < 25 || (mode === 'free' && distToSecondary < 25)) { + canvas.style.cursor = 'grab'; + } else { + canvas.style.cursor = 'default'; + } + } + return; + } + + // 正在拖拽时,阻止默认行为并更新位置 + e.preventDefault(); + + // 计算相对于色轮中心的位置,限制在色轮范围内 + const dx = pos.x - center; + const dy = pos.y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 限制在色轮范围内(留出一点边距) + const maxDistance = radius - 15; + let finalX = pos.x; + let finalY = pos.y; + + if (distance > maxDistance) { + const ratio = maxDistance / distance; + finalX = center + dx * ratio; + finalY = center + dy * ratio; + } + + setDragPosition({ x: finalX, y: finalY }); + handleColorSelection(finalX, finalY, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + const handleMouseLeave = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + setHoveredColor(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
{ + e.preventDefault(); + e.stopPropagation(); + setIsDragging('primary'); + setDragPosition({ x: primaryPos.x, y: primaryPos.y }); + setShowDragPreview(true); + }} + onTouchStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging('primary'); + setDragPosition({ x: primaryPos.x, y: primaryPos.y }); + setShowDragPreview(true); + }} + > + {/* 主色标签 */} +
+ 主色 +
+
+ + {/* 次色选择点 */} +
{ + if (mode === 'recommended') return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging('secondary'); + setDragPosition({ x: secondaryPos.x, y: secondaryPos.y }); + setShowDragPreview(true); + }} + onTouchStart={(e) => { + if (mode === 'recommended') return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging('secondary'); + setDragPosition({ x: secondaryPos.x, y: secondaryPos.y }); + setShowDragPreview(true); + }} + > + {/* 次色标签 */} + {mode === 'free' && ( +
+ 搭配 +
+ )} +
+ + {/* 拖拽时的实时预览点(自由选择模式) */} + {mode === 'free' && isDragging && dragPosition && ( +
+ )} + + {/* 中心点 */} +
+ + {/* 拖拽时的颜色值提示 */} + {isDragging && dragPosition && ( +
+ {(isDragging === 'primary' ? primaryColor : secondaryColor).toUpperCase()} +
+ )} +
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/.history/src/components/ColorWheel_20260316171406.tsx b/.history/src/components/ColorWheel_20260316171406.tsx new file mode 100644 index 0000000..b560b94 --- /dev/null +++ b/.history/src/components/ColorWheel_20260316171406.tsx @@ -0,0 +1,542 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); + const [hoveredColor, setHoveredColor] = useState(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + const [showDragPreview, setShowDragPreview] = useState(false); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 在自由选择模式下绘制拖拽预览效果 + if (mode === 'free' && isDragging && dragPosition) { + const { h, s } = getHslFromPosition(dragPosition.x, dragPosition.y, radius); + const previewColor = hslToHex({ h, s, l: 50 }); + + // 绘制预览圆环 + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = previewColor; + ctx.lineWidth = 1; + ctx.stroke(); + + // 绘制从中心到拖拽位置的连线 + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.lineTo(dragPosition.x, dragPosition.y); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + } + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor, isDragging, dragPosition]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 添加全局鼠标事件监听,处理从颜色点拖拽到外部的情况 + useEffect(() => { + if (!isDragging) return; + + const handleGlobalMouseMove = (e: MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 计算相对于色轮中心的位置,限制在色轮范围内 + const dx = x - center; + const dy = y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 限制在色轮范围内(留出一点边距) + const maxDistance = radius - 15; + let finalX = x; + let finalY = y; + + if (distance > maxDistance) { + const ratio = maxDistance / distance; + finalX = center + dx * ratio; + finalY = center + dy * ratio; + } + + setDragPosition({ x: finalX, y: finalY }); + handleColorSelection(finalX, finalY, isDragging); + }; + + const handleGlobalMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + document.addEventListener('mousemove', handleGlobalMouseMove); + document.addEventListener('mouseup', handleGlobalMouseUp); + + return () => { + document.removeEventListener('mousemove', handleGlobalMouseMove); + document.removeEventListener('mouseup', handleGlobalMouseUp); + }; + }, [isDragging, center, radius, handleColorSelection]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 处理颜色选择 + const handleColorSelection = (x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }; + + // 计算鼠标位置对应的颜色 + const getColorAtPosition = (x: number, y: number): string => { + const { h, s } = getHslFromPosition(x, y, radius); + return hslToHex({ h, s, l: 50 }); + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 只有点击靠近颜色点时才允许拖拽 + if (distToPrimary < 25) { + setIsDragging('primary'); + setDragPosition(pos); + setShowDragPreview(true); + } else if (mode === 'free' && distToSecondary < 25) { + setIsDragging('secondary'); + setDragPosition(pos); + setShowDragPreview(true); + } + // 点击色轮其他位置不执行任何操作 + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 更新悬停颜色显示 + const color = getColorAtPosition(pos.x, pos.y); + setHoveredColor(color); + + if (!isDragging) { + // 检查是否悬停在颜色点上,改变光标样式 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + const canvas = canvasRef.current; + if (canvas) { + // 只有悬停在颜色点上时才显示 grab 光标,否则显示默认光标 + if (distToPrimary < 25 || (mode === 'free' && distToSecondary < 25)) { + canvas.style.cursor = 'grab'; + } else { + canvas.style.cursor = 'default'; + } + } + return; + } + + // 正在拖拽时,阻止默认行为并更新位置 + e.preventDefault(); + + // 计算相对于色轮中心的位置,限制在色轮范围内 + const dx = pos.x - center; + const dy = pos.y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 限制在色轮范围内(留出一点边距) + const maxDistance = radius - 15; + let finalX = pos.x; + let finalY = pos.y; + + if (distance > maxDistance) { + const ratio = maxDistance / distance; + finalX = center + dx * ratio; + finalY = center + dy * ratio; + } + + setDragPosition({ x: finalX, y: finalY }); + handleColorSelection(finalX, finalY, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + const handleMouseLeave = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + setHoveredColor(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
{ + e.preventDefault(); + e.stopPropagation(); + setIsDragging('primary'); + setDragPosition({ x: primaryPos.x, y: primaryPos.y }); + setShowDragPreview(true); + }} + onTouchStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging('primary'); + setDragPosition({ x: primaryPos.x, y: primaryPos.y }); + setShowDragPreview(true); + }} + > + {/* 主色标签 */} +
+ 主色 +
+
+ + {/* 次色选择点 */} +
{ + if (mode === 'recommended') return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging('secondary'); + setDragPosition({ x: secondaryPos.x, y: secondaryPos.y }); + setShowDragPreview(true); + }} + onTouchStart={(e) => { + if (mode === 'recommended') return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging('secondary'); + setDragPosition({ x: secondaryPos.x, y: secondaryPos.y }); + setShowDragPreview(true); + }} + > + {/* 次色标签 */} + {mode === 'free' && ( +
+ 搭配 +
+ )} +
+ + {/* 拖拽时的实时预览点(自由选择模式) */} + {mode === 'free' && isDragging && dragPosition && ( +
+ )} + + {/* 中心点 */} +
+ + {/* 拖拽时的颜色值提示 */} + {isDragging && dragPosition && ( +
+ {(isDragging === 'primary' ? primaryColor : secondaryColor).toUpperCase()} +
+ )} +
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/.history/src/components/ColorWheel_20260316171437.tsx b/.history/src/components/ColorWheel_20260316171437.tsx new file mode 100644 index 0000000..8e8bcbe --- /dev/null +++ b/.history/src/components/ColorWheel_20260316171437.tsx @@ -0,0 +1,542 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); + const [hoveredColor, setHoveredColor] = useState(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + const [showDragPreview, setShowDragPreview] = useState(false); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 在自由选择模式下绘制拖拽预览效果 + if (mode === 'free' && isDragging && dragPosition) { + const { h, s } = getHslFromPosition(dragPosition.x, dragPosition.y, radius); + const previewColor = hslToHex({ h, s, l: 50 }); + + // 绘制预览圆环 + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = previewColor; + ctx.lineWidth = 1; + ctx.stroke(); + + // 绘制从中心到拖拽位置的连线 + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.lineTo(dragPosition.x, dragPosition.y); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + } + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor, isDragging, dragPosition]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 添加全局鼠标事件监听,处理从颜色点拖拽到外部的情况 + useEffect(() => { + if (!isDragging) return; + + const handleGlobalMouseMove = (e: MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 计算相对于色轮中心的位置,限制在色轮范围内 + const dx = x - center; + const dy = y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 限制在色轮范围内(留出一点边距) + const maxDistance = radius - 15; + let finalX = x; + let finalY = y; + + if (distance > maxDistance) { + const ratio = maxDistance / distance; + finalX = center + dx * ratio; + finalY = center + dy * ratio; + } + + setDragPosition({ x: finalX, y: finalY }); + handleColorSelection(finalX, finalY, isDragging); + }; + + const handleGlobalMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + document.addEventListener('mousemove', handleGlobalMouseMove); + document.addEventListener('mouseup', handleGlobalMouseUp); + + return () => { + document.removeEventListener('mousemove', handleGlobalMouseMove); + document.removeEventListener('mouseup', handleGlobalMouseUp); + }; + }, [isDragging, center, radius, handleColorSelection]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 处理颜色选择 + const handleColorSelection = useCallback((x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }, [radius, onPrimaryChange, onSecondaryChange]); + + // 计算鼠标位置对应的颜色 + const getColorAtPosition = (x: number, y: number): string => { + const { h, s } = getHslFromPosition(x, y, radius); + return hslToHex({ h, s, l: 50 }); + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 只有点击靠近颜色点时才允许拖拽 + if (distToPrimary < 25) { + setIsDragging('primary'); + setDragPosition(pos); + setShowDragPreview(true); + } else if (mode === 'free' && distToSecondary < 25) { + setIsDragging('secondary'); + setDragPosition(pos); + setShowDragPreview(true); + } + // 点击色轮其他位置不执行任何操作 + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 更新悬停颜色显示 + const color = getColorAtPosition(pos.x, pos.y); + setHoveredColor(color); + + if (!isDragging) { + // 检查是否悬停在颜色点上,改变光标样式 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + const canvas = canvasRef.current; + if (canvas) { + // 只有悬停在颜色点上时才显示 grab 光标,否则显示默认光标 + if (distToPrimary < 25 || (mode === 'free' && distToSecondary < 25)) { + canvas.style.cursor = 'grab'; + } else { + canvas.style.cursor = 'default'; + } + } + return; + } + + // 正在拖拽时,阻止默认行为并更新位置 + e.preventDefault(); + + // 计算相对于色轮中心的位置,限制在色轮范围内 + const dx = pos.x - center; + const dy = pos.y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 限制在色轮范围内(留出一点边距) + const maxDistance = radius - 15; + let finalX = pos.x; + let finalY = pos.y; + + if (distance > maxDistance) { + const ratio = maxDistance / distance; + finalX = center + dx * ratio; + finalY = center + dy * ratio; + } + + setDragPosition({ x: finalX, y: finalY }); + handleColorSelection(finalX, finalY, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + const handleMouseLeave = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + setHoveredColor(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
{ + e.preventDefault(); + e.stopPropagation(); + setIsDragging('primary'); + setDragPosition({ x: primaryPos.x, y: primaryPos.y }); + setShowDragPreview(true); + }} + onTouchStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging('primary'); + setDragPosition({ x: primaryPos.x, y: primaryPos.y }); + setShowDragPreview(true); + }} + > + {/* 主色标签 */} +
+ 主色 +
+
+ + {/* 次色选择点 */} +
{ + if (mode === 'recommended') return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging('secondary'); + setDragPosition({ x: secondaryPos.x, y: secondaryPos.y }); + setShowDragPreview(true); + }} + onTouchStart={(e) => { + if (mode === 'recommended') return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging('secondary'); + setDragPosition({ x: secondaryPos.x, y: secondaryPos.y }); + setShowDragPreview(true); + }} + > + {/* 次色标签 */} + {mode === 'free' && ( +
+ 搭配 +
+ )} +
+ + {/* 拖拽时的实时预览点(自由选择模式) */} + {mode === 'free' && isDragging && dragPosition && ( +
+ )} + + {/* 中心点 */} +
+ + {/* 拖拽时的颜色值提示 */} + {isDragging && dragPosition && ( +
+ {(isDragging === 'primary' ? primaryColor : secondaryColor).toUpperCase()} +
+ )} +
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/.history/src/components/ColorWheel_20260316172140.tsx b/.history/src/components/ColorWheel_20260316172140.tsx new file mode 100644 index 0000000..099ceca --- /dev/null +++ b/.history/src/components/ColorWheel_20260316172140.tsx @@ -0,0 +1,541 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); + const [hoveredColor, setHoveredColor] = useState(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + const [showDragPreview, setShowDragPreview] = useState(false); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 在自由选择模式下绘制拖拽预览效果 + if (mode === 'free' && isDragging && dragPosition) { + const { h, s } = getHslFromPosition(dragPosition.x, dragPosition.y, radius); + const previewColor = hslToHex({ h, s, l: 50 }); + + // 绘制预览圆环 + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = previewColor; + ctx.lineWidth = 1; + ctx.stroke(); + + // 绘制从中心到拖拽位置的连线 + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.lineTo(dragPosition.x, dragPosition.y); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + } + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor, isDragging, dragPosition]); + + // 处理颜色选择 - 必须在 useEffect 之前定义 + const handleColorSelection = useCallback((x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }, [radius, onPrimaryChange, onSecondaryChange]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 添加全局鼠标事件监听,处理从颜色点拖拽到外部的情况 + useEffect(() => { + if (!isDragging) return; + + const handleGlobalMouseMove = (e: MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 计算相对于色轮中心的位置,限制在色轮范围内 + const dx = x - center; + const dy = y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 限制在色轮范围内(留出一点边距) + const maxDistance = radius - 15; + let finalX = x; + let finalY = y; + + if (distance > maxDistance) { + const ratio = maxDistance / distance; + finalX = center + dx * ratio; + finalY = center + dy * ratio; + } + + setDragPosition({ x: finalX, y: finalY }); + handleColorSelection(finalX, finalY, isDragging); + }; + + const handleGlobalMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + document.addEventListener('mousemove', handleGlobalMouseMove); + document.addEventListener('mouseup', handleGlobalMouseUp); + + return () => { + document.removeEventListener('mousemove', handleGlobalMouseMove); + document.removeEventListener('mouseup', handleGlobalMouseUp); + }; + }, [isDragging, center, radius, handleColorSelection]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 计算鼠标位置对应的颜色 + const getColorAtPosition = (x: number, y: number): string => { + const { h, s } = getHslFromPosition(x, y, radius); + return hslToHex({ h, s, l: 50 }); + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 只有点击靠近颜色点时才允许拖拽 + if (distToPrimary < 25) { + setIsDragging('primary'); + setDragPosition(pos); + setShowDragPreview(true); + } else if (mode === 'free' && distToSecondary < 25) { + setIsDragging('secondary'); + setDragPosition(pos); + setShowDragPreview(true); + } + // 点击色轮其他位置不执行任何操作 + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + // 只有在拖拽状态下才处理 Canvas 上的鼠标移动 + if (!isDragging) return; + + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 计算相对于色轮中心的位置,限制在色轮范围内 + const dx = pos.x - center; + const dy = pos.y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 限制在色轮范围内(留出一点边距) + const maxDistance = radius - 15; + let finalX = pos.x; + let finalY = pos.y; + + if (distance > maxDistance) { + const ratio = maxDistance / distance; + finalX = center + dx * ratio; + finalY = center + dy * ratio; + } + + setDragPosition({ x: finalX, y: finalY }); + handleColorSelection(finalX, finalY, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + const handleMouseLeave = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + setHoveredColor(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
{ + e.preventDefault(); + e.stopPropagation(); + setIsDragging('primary'); + setDragPosition({ x: primaryPos.x, y: primaryPos.y }); + setShowDragPreview(true); + }} + onMouseUp={(e) => { + e.stopPropagation(); + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }} + onTouchStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging('primary'); + setDragPosition({ x: primaryPos.x, y: primaryPos.y }); + setShowDragPreview(true); + }} + onTouchEnd={(e) => { + e.stopPropagation(); + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }} + > + {/* 主色标签 */} +
+ 主色 +
+
+ + {/* 次色选择点 */} +
{ + if (mode === 'recommended') return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging('secondary'); + setDragPosition({ x: secondaryPos.x, y: secondaryPos.y }); + setShowDragPreview(true); + }} + onMouseUp={(e) => { + e.stopPropagation(); + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }} + onTouchStart={(e) => { + if (mode === 'recommended') return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging('secondary'); + setDragPosition({ x: secondaryPos.x, y: secondaryPos.y }); + setShowDragPreview(true); + }} + onTouchEnd={(e) => { + e.stopPropagation(); + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }} + > + {/* 次色标签 */} + {mode === 'free' && ( +
+ 搭配 +
+ )} +
+ + {/* 拖拽时的实时预览点(自由选择模式) */} + {mode === 'free' && isDragging && dragPosition && ( +
+ )} + + {/* 中心点 */} +
+ + {/* 拖拽时的颜色值提示 */} + {isDragging && dragPosition && ( +
+ {(isDragging === 'primary' ? primaryColor : secondaryColor).toUpperCase()} +
+ )} +
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/.history/src/lib/__tests__/colorUtils.test_20260316165122.ts b/.history/src/lib/__tests__/colorUtils.test_20260316165122.ts new file mode 100644 index 0000000..e761744 --- /dev/null +++ b/.history/src/lib/__tests__/colorUtils.test_20260316165122.ts @@ -0,0 +1,229 @@ +/** + * 色彩工具函数测试 + * 运行测试: npm test + */ + +import { + hexToHsl, + hslToHex, + generateColorHarmonies, + generateRecommendedPair, + getColorWheelPosition, + getHslFromPosition +} from '../colorUtils'; + +describe('Color Utilities', () => { + + describe('hexToHsl', () => { + it('should convert pure red correctly', () => { + const result = hexToHsl('#FF0000'); + expect(result.h).toBe(0); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert pure green correctly', () => { + const result = hexToHsl('#00FF00'); + expect(result.h).toBe(120); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert pure blue correctly', () => { + const result = hexToHsl('#0000FF'); + expect(result.h).toBe(240); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert white correctly', () => { + const result = hexToHsl('#FFFFFF'); + expect(result.s).toBe(0); + expect(result.l).toBe(100); + }); + + it('should convert black correctly', () => { + const result = hexToHsl('#000000'); + expect(result.s).toBe(0); + expect(result.l).toBe(0); + }); + }); + + describe('hslToHex', () => { + it('should convert HSL to hex correctly for red', () => { + const result = hslToHex({ h: 0, s: 100, l: 50 }); + expect(result).toBe('#FF0000'); + }); + + it('should convert HSL to hex correctly for green', () => { + const result = hslToHex({ h: 120, s: 100, l: 50 }); + expect(result).toBe('#00FF00'); + }); + + it('should convert HSL to hex correctly for blue', () => { + const result = hslToHex({ h: 240, s: 100, l: 50 }); + expect(result).toBe('#0000FF'); + }); + + it('should handle round-trip conversion', () => { + const originalHex = '#5135FF'; + const hsl = hexToHsl(originalHex); + const convertedHex = hslToHex(hsl); + expect(convertedHex).toBe(originalHex); + }); + }); + + describe('generateColorHarmonies', () => { + it('should generate 6 different harmony types', () => { + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + expect(harmonies).toHaveLength(6); + + const names = harmonies.map(h => h.name); + expect(names).toContain('互补色'); + expect(names).toContain('类比色'); + expect(names).toContain('三分色'); + expect(names).toContain('分裂互补'); + expect(names).toContain('四角形'); + expect(names).toContain('单色'); + }); + + it('should include base color in all harmonies', () => { + const baseColor = { h: 180, s: 80, l: 50 }; + const baseHex = hslToHex(baseColor); + const harmonies = generateColorHarmonies(baseColor); + + // 互补色应该包含原色 + const complementary = harmonies.find(h => h.name === '互补色'); + expect(complementary?.colors).toContain(baseHex); + }); + + it('should generate correct complementary color', () => { + const baseColor = { h: 0, s: 100, l: 50 }; // Red + const harmonies = generateColorHarmonies(baseColor); + const complementary = harmonies.find(h => h.name === '互补色'); + + // 红色的互补色应该是青色 (180度) + expect(complementary?.colors).toContain('#00FFFF'); + }); + + it('should generate correct triadic colors', () => { + const baseColor = { h: 0, s: 100, l: 50 }; // Red + const harmonies = generateColorHarmonies(baseColor); + const triadic = harmonies.find(h => h.name === '三分色'); + + expect(triadic?.colors).toHaveLength(3); + expect(triadic?.colors).toContain('#FF0000'); + expect(triadic?.colors).toContain('#00FF00'); // 120度 + expect(triadic?.colors).toContain('#0000FF'); // 240度 + }); + }); + + describe('generateRecommendedPair', () => { + it('should return primary color as provided', () => { + const result = generateRecommendedPair('#FF0000'); + expect(result.primary).toBe('#FF0000'); + }); + + it('should return complementary color as secondary', () => { + const result = generateRecommendedPair('#FF0000'); + expect(result.harmonyType).toBe('互补色'); + // 红色的互补色应该是青色 + expect(result.secondary).toBe('#00FFFF'); + }); + + it('should handle different base colors', () => { + const blueResult = generateRecommendedPair('#0000FF'); + expect(blueResult.primary).toBe('#0000FF'); + // 蓝色的互补色应该是黄色 + expect(blueResult.secondary).toBe('#FFFF00'); + }); + }); + + describe('getColorWheelPosition', () => { + it('should return center for zero saturation', () => { + const pos = getColorWheelPosition(0, 0, 100); + expect(pos.x).toBe(100); + expect(pos.y).toBe(100); + }); + + it('should return correct position for red at top', () => { + const pos = getColorWheelPosition(0, 100, 100); + expect(pos.x).toBe(100); + expect(pos.y).toBeCloseTo(0, 0); + }); + + it('should return correct position for green', () => { + const pos = getColorWheelPosition(120, 100, 100); + expect(pos.x).toBeGreaterThan(100); + expect(pos.y).toBeGreaterThan(100); + }); + }); + + describe('getHslFromPosition', () => { + it('should return zero saturation at center', () => { + const result = getHslFromPosition(100, 100, 100); + expect(result.s).toBe(0); + }); + + it('should return max saturation at edge', () => { + const result = getHslFromPosition(100, 0, 100); // Top center + expect(result.s).toBe(100); + }); + + it('should return correct hue for top position', () => { + const result = getHslFromPosition(100, 0, 100); + expect(result.h).toBe(0); // Red at top + }); + }); +}); + +// 手动运行测试的辅助函数 +export function runManualTests() { + console.log('=== 运行色彩工具函数手动测试 ===\n'); + + // 测试 1: HEX 转 HSL + console.log('1. HEX 转 HSL 测试:'); + const redHsl = hexToHsl('#FF0000'); + console.log(` #FF0000 -> HSL(${redHsl.h}, ${redHsl.s}%, ${redHsl.l}%)`); + console.log(` 期望: HSL(0, 100%, 50%)`); + console.log(` 结果: ${redHsl.h === 0 && redHsl.s === 100 && redHsl.l === 50 ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 2: HSL 转 HEX + console.log('2. HSL 转 HEX 测试:'); + const redHex = hslToHex({ h: 0, s: 100, l: 50 }); + console.log(` HSL(0, 100%, 50%) -> ${redHex}`); + console.log(` 期望: #FF0000`); + console.log(` 结果: ${redHex === '#FF0000' ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 3: 色彩和谐生成 + console.log('3. 色彩和谐生成测试:'); + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + console.log(` 生成了 ${harmonies.length} 种和谐配色方案:`); + harmonies.forEach(h => { + console.log(` - ${h.name}: ${h.colors.join(', ')}`); + }); + console.log(` 结果: ${harmonies.length === 6 ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 4: 推荐配色 + console.log('4. 推荐配色测试:'); + const recommendation = generateRecommendedPair('#FF0000'); + console.log(` 主色: ${recommendation.primary}`); + console.log(` 推荐搭配: ${recommendation.secondary}`); + console.log(` 和谐类型: ${recommendation.harmonyType}`); + console.log(` 期望搭配: #00FFFF (青色,红色的互补色)`); + console.log(` 结果: ${recommendation.secondary === '#00FFFF' ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 5: 色轮位置计算 + console.log('5. 色轮位置计算测试:'); + const pos = getColorWheelPosition(0, 100, 100); + console.log(` 色相0°, 饱和度100%, 半径100 -> 位置(${pos.x.toFixed(1)}, ${pos.y.toFixed(1)})`); + console.log(` 期望: (100, 0) - 顶部`); + console.log(` 结果: ${pos.x === 100 && pos.y < 1 ? '✓ 通过' : '✗ 失败'}\n`); + + console.log('=== 测试完成 ==='); +} + +// 如果在浏览器环境中运行,可以调用此函数 +if (typeof window !== 'undefined') { + (window as any).runColorTests = runManualTests; +} diff --git a/.history/src/lib/__tests__/colorUtils.test_20260316165406.ts b/.history/src/lib/__tests__/colorUtils.test_20260316165406.ts new file mode 100644 index 0000000..5172fa9 --- /dev/null +++ b/.history/src/lib/__tests__/colorUtils.test_20260316165406.ts @@ -0,0 +1,230 @@ +/** + * 色彩工具函数测试 + * 运行测试: npm test + */ + +import { + hexToHsl, + hslToHex, + generateColorHarmonies, + generateRecommendedPair, + getColorWheelPosition, + getHslFromPosition +} from '../colorUtils'; + +describe('Color Utilities', () => { + + describe('hexToHsl', () => { + it('should convert pure red correctly', () => { + const result = hexToHsl('#FF0000'); + expect(result.h).toBe(0); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert pure green correctly', () => { + const result = hexToHsl('#00FF00'); + expect(result.h).toBe(120); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert pure blue correctly', () => { + const result = hexToHsl('#0000FF'); + expect(result.h).toBe(240); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert white correctly', () => { + const result = hexToHsl('#FFFFFF'); + expect(result.s).toBe(0); + expect(result.l).toBe(100); + }); + + it('should convert black correctly', () => { + const result = hexToHsl('#000000'); + expect(result.s).toBe(0); + expect(result.l).toBe(0); + }); + }); + + describe('hslToHex', () => { + it('should convert HSL to hex correctly for red', () => { + const result = hslToHex({ h: 0, s: 100, l: 50 }); + expect(result).toBe('#FF0000'); + }); + + it('should convert HSL to hex correctly for green', () => { + const result = hslToHex({ h: 120, s: 100, l: 50 }); + expect(result).toBe('#00FF00'); + }); + + it('should convert HSL to hex correctly for blue', () => { + const result = hslToHex({ h: 240, s: 100, l: 50 }); + expect(result).toBe('#0000FF'); + }); + + it('should handle round-trip conversion', () => { + const originalHex = '#5135FF'; + const hsl = hexToHsl(originalHex); + const convertedHex = hslToHex(hsl); + expect(convertedHex).toBe(originalHex); + }); + }); + + describe('generateColorHarmonies', () => { + it('should generate 6 different harmony types', () => { + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + expect(harmonies).toHaveLength(6); + + const names = harmonies.map(h => h.name); + expect(names).toContain('互补色'); + expect(names).toContain('类比色'); + expect(names).toContain('三分色'); + expect(names).toContain('分裂互补'); + expect(names).toContain('四角形'); + expect(names).toContain('单色'); + }); + + it('should include base color in all harmonies', () => { + const baseColor = { h: 180, s: 80, l: 50 }; + const baseHex = hslToHex(baseColor); + const harmonies = generateColorHarmonies(baseColor); + + // 互补色应该包含原色 + const complementary = harmonies.find(h => h.name === '互补色'); + expect(complementary?.colors).toContain(baseHex); + }); + + it('should generate correct complementary color', () => { + const baseColor = { h: 0, s: 100, l: 50 }; // Red + const harmonies = generateColorHarmonies(baseColor); + const complementary = harmonies.find(h => h.name === '互补色'); + + // 红色的互补色应该是青色 (180度) + expect(complementary?.colors).toContain('#00FFFF'); + }); + + it('should generate correct triadic colors', () => { + const baseColor = { h: 0, s: 100, l: 50 }; // Red + const harmonies = generateColorHarmonies(baseColor); + const triadic = harmonies.find(h => h.name === '三分色'); + + expect(triadic?.colors).toHaveLength(3); + expect(triadic?.colors).toContain('#FF0000'); + expect(triadic?.colors).toContain('#00FF00'); // 120度 + expect(triadic?.colors).toContain('#0000FF'); // 240度 + }); + }); + + describe('generateRecommendedPair', () => { + it('should return primary color as provided', () => { + const result = generateRecommendedPair('#FF0000'); + expect(result.primary).toBe('#FF0000'); + }); + + it('should return complementary color as secondary', () => { + const result = generateRecommendedPair('#FF0000'); + expect(result.harmonyType).toBe('互补色'); + // 红色的互补色应该是青色 + expect(result.secondary).toBe('#00FFFF'); + }); + + it('should handle different base colors', () => { + const blueResult = generateRecommendedPair('#0000FF'); + expect(blueResult.primary).toBe('#0000FF'); + // 蓝色的互补色应该是黄色 + expect(blueResult.secondary).toBe('#FFFF00'); + }); + }); + + describe('getColorWheelPosition', () => { + it('should return center for zero saturation', () => { + const pos = getColorWheelPosition(0, 0, 100); + expect(pos.x).toBe(100); + expect(pos.y).toBe(100); + }); + + it('should return correct position for red at top', () => { + const pos = getColorWheelPosition(0, 100, 100); + expect(pos.x).toBe(100); + expect(pos.y).toBeCloseTo(0, 0); + }); + + it('should return correct position for green', () => { + const pos = getColorWheelPosition(120, 100, 100); + expect(pos.x).toBeGreaterThan(100); + expect(pos.y).toBeGreaterThan(100); + }); + }); + + describe('getHslFromPosition', () => { + it('should return zero saturation at center', () => { + const result = getHslFromPosition(100, 100, 100); + expect(result.s).toBe(0); + }); + + it('should return max saturation at edge', () => { + const result = getHslFromPosition(100, 0, 100); // Top center + expect(result.s).toBe(100); + }); + + it('should return correct hue for top position', () => { + const result = getHslFromPosition(100, 0, 100); + expect(result.h).toBe(0); // Red at top + }); + }); +}); + +// 手动运行测试的辅助函数 +export function runManualTests() { + console.log('=== 运行色彩工具函数手动测试 ===\n'); + + // 测试 1: HEX 转 HSL + console.log('1. HEX 转 HSL 测试:'); + const redHsl = hexToHsl('#FF0000'); + console.log(` #FF0000 -> HSL(${redHsl.h}, ${redHsl.s}%, ${redHsl.l}%)`); + console.log(` 期望: HSL(0, 100%, 50%)`); + console.log(` 结果: ${redHsl.h === 0 && redHsl.s === 100 && redHsl.l === 50 ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 2: HSL 转 HEX + console.log('2. HSL 转 HEX 测试:'); + const redHex = hslToHex({ h: 0, s: 100, l: 50 }); + console.log(` HSL(0, 100%, 50%) -> ${redHex}`); + console.log(` 期望: #FF0000`); + console.log(` 结果: ${redHex === '#FF0000' ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 3: 色彩和谐生成 + console.log('3. 色彩和谐生成测试:'); + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + console.log(` 生成了 ${harmonies.length} 种和谐配色方案:`); + harmonies.forEach(h => { + console.log(` - ${h.name}: ${h.colors.join(', ')}`); + }); + console.log(` 结果: ${harmonies.length === 6 ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 4: 推荐配色 + console.log('4. 推荐配色测试:'); + const recommendation = generateRecommendedPair('#FF0000'); + console.log(` 主色: ${recommendation.primary}`); + console.log(` 推荐搭配: ${recommendation.secondary}`); + console.log(` 和谐类型: ${recommendation.harmonyType}`); + console.log(` 期望搭配: #00FFFF (青色,红色的互补色)`); + console.log(` 结果: ${recommendation.secondary === '#00FFFF' ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 5: 色轮位置计算 + console.log('5. 色轮位置计算测试:'); + const pos = getColorWheelPosition(0, 100, 100); + console.log(` 色相0°, 饱和度100%, 半径100 -> 位置(${pos.x.toFixed(1)}, ${pos.y.toFixed(1)})`); + console.log(` 期望: (100, 0) - 顶部`); + console.log(` 结果: ${pos.x === 100 && pos.y < 1 ? '✓ 通过' : '✗ 失败'}\n`); + + console.log('=== 测试完成 ==='); +} + +// 如果在浏览器环境中运行,可以调用此函数 +if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as unknown as { runColorTests: typeof runManualTests }).runColorTests = runManualTests; +} diff --git a/.history/src/lib/__tests__/colorUtils.test_20260316165442.ts b/.history/src/lib/__tests__/colorUtils.test_20260316165442.ts new file mode 100644 index 0000000..a2dda43 --- /dev/null +++ b/.history/src/lib/__tests__/colorUtils.test_20260316165442.ts @@ -0,0 +1,229 @@ +/** + * 色彩工具函数测试 + * 运行测试: npm test + */ + +import { + hexToHsl, + hslToHex, + generateColorHarmonies, + generateRecommendedPair, + getColorWheelPosition, + getHslFromPosition +} from '../colorUtils'; + +describe('Color Utilities', () => { + + describe('hexToHsl', () => { + it('should convert pure red correctly', () => { + const result = hexToHsl('#FF0000'); + expect(result.h).toBe(0); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert pure green correctly', () => { + const result = hexToHsl('#00FF00'); + expect(result.h).toBe(120); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert pure blue correctly', () => { + const result = hexToHsl('#0000FF'); + expect(result.h).toBe(240); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert white correctly', () => { + const result = hexToHsl('#FFFFFF'); + expect(result.s).toBe(0); + expect(result.l).toBe(100); + }); + + it('should convert black correctly', () => { + const result = hexToHsl('#000000'); + expect(result.s).toBe(0); + expect(result.l).toBe(0); + }); + }); + + describe('hslToHex', () => { + it('should convert HSL to hex correctly for red', () => { + const result = hslToHex({ h: 0, s: 100, l: 50 }); + expect(result).toBe('#FF0000'); + }); + + it('should convert HSL to hex correctly for green', () => { + const result = hslToHex({ h: 120, s: 100, l: 50 }); + expect(result).toBe('#00FF00'); + }); + + it('should convert HSL to hex correctly for blue', () => { + const result = hslToHex({ h: 240, s: 100, l: 50 }); + expect(result).toBe('#0000FF'); + }); + + it('should handle round-trip conversion', () => { + const originalHex = '#5135FF'; + const hsl = hexToHsl(originalHex); + const convertedHex = hslToHex(hsl); + expect(convertedHex).toBe(originalHex); + }); + }); + + describe('generateColorHarmonies', () => { + it('should generate 6 different harmony types', () => { + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + expect(harmonies).toHaveLength(6); + + const names = harmonies.map(h => h.name); + expect(names).toContain('互补色'); + expect(names).toContain('类比色'); + expect(names).toContain('三分色'); + expect(names).toContain('分裂互补'); + expect(names).toContain('四角形'); + expect(names).toContain('单色'); + }); + + it('should include base color in all harmonies', () => { + const baseColor = { h: 180, s: 80, l: 50 }; + const baseHex = hslToHex(baseColor); + const harmonies = generateColorHarmonies(baseColor); + + // 互补色应该包含原色 + const complementary = harmonies.find(h => h.name === '互补色'); + expect(complementary?.colors).toContain(baseHex); + }); + + it('should generate correct complementary color', () => { + const baseColor = { h: 0, s: 100, l: 50 }; // Red + const harmonies = generateColorHarmonies(baseColor); + const complementary = harmonies.find(h => h.name === '互补色'); + + // 红色的互补色应该是青色 (180度) + expect(complementary?.colors).toContain('#00FFFF'); + }); + + it('should generate correct triadic colors', () => { + const baseColor = { h: 0, s: 100, l: 50 }; // Red + const harmonies = generateColorHarmonies(baseColor); + const triadic = harmonies.find(h => h.name === '三分色'); + + expect(triadic?.colors).toHaveLength(3); + expect(triadic?.colors).toContain('#FF0000'); + expect(triadic?.colors).toContain('#00FF00'); // 120度 + expect(triadic?.colors).toContain('#0000FF'); // 240度 + }); + }); + + describe('generateRecommendedPair', () => { + it('should return primary color as provided', () => { + const result = generateRecommendedPair('#FF0000'); + expect(result.primary).toBe('#FF0000'); + }); + + it('should return complementary color as secondary', () => { + const result = generateRecommendedPair('#FF0000'); + expect(result.harmonyType).toBe('互补色'); + // 红色的互补色应该是青色 + expect(result.secondary).toBe('#00FFFF'); + }); + + it('should handle different base colors', () => { + const blueResult = generateRecommendedPair('#0000FF'); + expect(blueResult.primary).toBe('#0000FF'); + // 蓝色的互补色应该是黄色 + expect(blueResult.secondary).toBe('#FFFF00'); + }); + }); + + describe('getColorWheelPosition', () => { + it('should return center for zero saturation', () => { + const pos = getColorWheelPosition(0, 0, 100); + expect(pos.x).toBe(100); + expect(pos.y).toBe(100); + }); + + it('should return correct position for red at top', () => { + const pos = getColorWheelPosition(0, 100, 100); + expect(pos.x).toBe(100); + expect(pos.y).toBeCloseTo(0, 0); + }); + + it('should return correct position for green', () => { + const pos = getColorWheelPosition(120, 100, 100); + expect(pos.x).toBeGreaterThan(100); + expect(pos.y).toBeGreaterThan(100); + }); + }); + + describe('getHslFromPosition', () => { + it('should return zero saturation at center', () => { + const result = getHslFromPosition(100, 100, 100); + expect(result.s).toBe(0); + }); + + it('should return max saturation at edge', () => { + const result = getHslFromPosition(100, 0, 100); // Top center + expect(result.s).toBe(100); + }); + + it('should return correct hue for top position', () => { + const result = getHslFromPosition(100, 0, 100); + expect(result.h).toBe(0); // Red at top + }); + }); +}); + +// 手动运行测试的辅助函数 +export function runManualTests() { + console.log('=== 运行色彩工具函数手动测试 ===\n'); + + // 测试 1: HEX 转 HSL + console.log('1. HEX 转 HSL 测试:'); + const redHsl = hexToHsl('#FF0000'); + console.log(` #FF0000 -> HSL(${redHsl.h}, ${redHsl.s}%, ${redHsl.l}%)`); + console.log(` 期望: HSL(0, 100%, 50%)`); + console.log(` 结果: ${redHsl.h === 0 && redHsl.s === 100 && redHsl.l === 50 ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 2: HSL 转 HEX + console.log('2. HSL 转 HEX 测试:'); + const redHex = hslToHex({ h: 0, s: 100, l: 50 }); + console.log(` HSL(0, 100%, 50%) -> ${redHex}`); + console.log(` 期望: #FF0000`); + console.log(` 结果: ${redHex === '#FF0000' ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 3: 色彩和谐生成 + console.log('3. 色彩和谐生成测试:'); + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + console.log(` 生成了 ${harmonies.length} 种和谐配色方案:`); + harmonies.forEach(h => { + console.log(` - ${h.name}: ${h.colors.join(', ')}`); + }); + console.log(` 结果: ${harmonies.length === 6 ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 4: 推荐配色 + console.log('4. 推荐配色测试:'); + const recommendation = generateRecommendedPair('#FF0000'); + console.log(` 主色: ${recommendation.primary}`); + console.log(` 推荐搭配: ${recommendation.secondary}`); + console.log(` 和谐类型: ${recommendation.harmonyType}`); + console.log(` 期望搭配: #00FFFF (青色,红色的互补色)`); + console.log(` 结果: ${recommendation.secondary === '#00FFFF' ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 5: 色轮位置计算 + console.log('5. 色轮位置计算测试:'); + const pos = getColorWheelPosition(0, 100, 100); + console.log(` 色相0°, 饱和度100%, 半径100 -> 位置(${pos.x.toFixed(1)}, ${pos.y.toFixed(1)})`); + console.log(` 期望: (100, 0) - 顶部`); + console.log(` 结果: ${pos.x === 100 && pos.y < 1 ? '✓ 通过' : '✗ 失败'}\n`); + + console.log('=== 测试完成 ==='); +} + +// 如果在浏览器环境中运行,可以调用此函数 +if (typeof window !== 'undefined') { + (window as unknown as { runColorTests: typeof runManualTests }).runColorTests = runManualTests; +} diff --git a/README.md b/README.md index 12b7a22..66f1877 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,45 @@ # Gradient Background Generator -A powerful Next.js application for creating stunning SVG gradient backgrounds with real-time preview and customizable color palettes. +A powerful Next.js application for creating stunning SVG gradient backgrounds with real-time preview and an interactive color wheel featuring intelligent color harmony recommendations. ## Features +- **Interactive Color Wheel**: Visual color selection with an intuitive circular interface +- **Dual Selection Modes**: + - **Free Selection Mode**: Manually select and position two colors anywhere on the color wheel + - **Recommended Mode**: Select a primary color and get AI-powered color harmony recommendations +- **Color Harmony Algorithms**: Based on established color theory principles: + - Complementary Colors (互补色) + - Analogous Colors (类比色) + - Triadic Colors (三分色) + - Split Complementary (分裂互补) + - Tetradic/Rectangle (四角形) + - Monochromatic (单色) - **Real-time Preview**: See your gradient backgrounds update instantly as you modify colors -- **Custom Color Palettes**: Add up to 8 colors to create unique gradients +- **Smart Gradient Generation**: Automatically generates smooth 4-color gradients from your selections - **Preset Templates**: Choose from professionally designed color combinations - **API Integration**: Generate gradients programmatically via REST API - **SVG Export**: Download your creations as high-quality SVG files - **Responsive Design**: Works seamlessly on desktop and mobile devices +## Color Theory & Algorithms + +This application implements classic color harmony rules based on the HSL (Hue, Saturation, Lightness) color wheel: + +### Color Harmony Types + +1. **Complementary (互补色)**: Colors opposite each other on the color wheel (180° apart). Creates high contrast and vibrant looks. + +2. **Analogous (类比色)**: Colors adjacent to each other on the color wheel (±30°). Creates serene and comfortable designs. + +3. **Triadic (三分色)**: Three colors evenly spaced around the color wheel (120° apart). Offers strong visual contrast while maintaining balance. + +4. **Split Complementary (分裂互补)**: A base color plus two colors adjacent to its complement. Provides high contrast with less tension. + +5. **Tetradic/Rectangle (四角形)**: Four colors arranged in two complementary pairs. Creates rich, complex color schemes. + +6. **Monochromatic (单色)**: Variations of a single hue using different saturation and lightness levels. Creates clean, elegant designs. + ## Getting Started Read the documentation at https://opennext.js.org/cloudflare. @@ -28,6 +57,25 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Testing + +Run the built-in test suite to verify color algorithms and utilities: + +```bash +# Start the development server first +npm run dev + +# Then visit the test page +open http://localhost:3000/test +``` + +The test page validates: +- HEX ↔ HSL color conversions +- Color harmony generation algorithms +- Complementary and triadic color calculations +- Color wheel position calculations +- Gradient color generation + ## Preview Preview the application locally on the Cloudflare runtime: @@ -67,6 +115,52 @@ GET https://gbg.nuclearrockstone.xyz/api?colors=hex_FF0000&colors=hex_00FF00&wid - `width`: Image width in pixels (100-2000) - `height`: Image height in pixels (100-2000) +## Project Structure + +``` +src/ +├── app/ +│ ├── api/ # API route for SVG generation +│ ├── test/ # Test page for color algorithms +│ ├── globals.css # Global styles +│ ├── layout.tsx # Root layout +│ └── page.tsx # Main gradient generator page +├── components/ +│ ├── ColorWheel.tsx # Interactive color wheel component +│ └── ui/ # UI components (Button, Card, Input) +├── hooks/ +│ └── useGradientGenerator.tsx # Gradient generation logic +├── lib/ +│ ├── colorUtils.ts # Color conversion & harmony algorithms +│ ├── constants.ts # Color presets +│ └── utils.ts # Utility functions +``` + +## How to Use + +### Using the Color Wheel + +1. **Select Mode**: Choose between "Free Selection" (自由选择) or "Recommended" (推荐搭配) mode +2. **Free Selection Mode**: + - Drag either color dot on the wheel + - Position colors anywhere you like + - The gradient updates in real-time +3. **Recommended Mode**: + - Drag the primary color (主色) to your desired hue + - The system automatically recommends a harmonious secondary color + - Choose from 6 different harmony types: + - Complementary, Analogous, Triadic, Split Complementary, Tetradic, Monochromatic + +### Manual Color Adjustment + +- Use the color list below the wheel to fine-tune individual colors +- Add up to 8 colors for complex gradients +- Remove colors by clicking the trash icon + +### Presets + +Click on any preset card to instantly apply a professionally designed color combination. + ## Learn More To learn more about Next.js, take a look at the following resources: @@ -75,3 +169,9 @@ To learn more about Next.js, take a look at the following resources: - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Color Theory References + +- [Color Wheel Theory - Adobe](https://www.adobe.com/creativecloud/design/discover/color-wheel.html) +- [Color Harmony - Wikipedia](https://en.wikipedia.org/wiki/Harmony_(color)) +- [HSL and HSV Color Models](https://en.wikipedia.org/wiki/HSL_and_HSV) diff --git a/next.config.ts b/next.config.ts index 9352f4e..a482f19 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,5 +8,12 @@ export default nextConfig; // Enable calling `getCloudflareContext()` in `next dev`. // See https://opennext.js.org/cloudflare/bindings#local-access-to-bindings. +// 仅在非 Windows 环境或明确需要时启用 import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; -initOpenNextCloudflareForDev(); + +// 检查是否在 Cloudflare 环境或需要绑定 +const shouldInitCloudflare = process.env.ENABLE_CLOUDFLARE_DEV === 'true' || process.platform !== 'win32'; + +if (shouldInitCloudflare) { + initOpenNextCloudflareForDev(); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 56b89e1..b870037 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,10 +5,13 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useGradientGenerator } from '@/hooks/useGradientGenerator'; import { colorPresets } from '@/lib/constants'; -import { colorToParam } from '@/lib/utils'; -import { Download, RefreshCw, Plus, Trash2, Palette, Sparkles, Layers, Code, Zap } from 'lucide-react'; +import { colorToParam, generateGradientColors } from '@/lib/utils'; +import { ColorWheel } from '@/components/ColorWheel'; +import { Download, RefreshCw, Plus, Trash2, Palette, Sparkles, Layers, Code, Zap, MousePointer2, Wand2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +type SelectionMode = 'free' | 'recommended'; + export default function GradientGenerator() { const { colors, @@ -26,12 +29,25 @@ export default function GradientGenerator() { const [newColor, setNewColor] = useState(''); const [apiLinkCopied, setApiLinkCopied] = useState(false); const [mounted, setMounted] = useState(false); + + // 色轮相关状态 + const [selectionMode, setSelectionMode] = useState('free'); + const [primaryColor, setPrimaryColor] = useState('#5135FF'); + const [secondaryColor, setSecondaryColor] = useState('#FF5828'); useEffect(() => { setMounted(true); generateGradient(); }, [generateGradient]); + // 当色轮颜色变化时更新渐变 + useEffect(() => { + if (mounted) { + const gradientColors = generateGradientColors(primaryColor, secondaryColor); + setColors(gradientColors); + } + }, [primaryColor, secondaryColor, mounted, setColors]); + const handleColorChange = (index: number, color: string) => { const newColors = [...colors]; newColors[index] = color; @@ -54,6 +70,11 @@ export default function GradientGenerator() { const applyPreset = (preset: typeof colorPresets[0]) => { setColors(preset.colors); + // 更新色轮颜色为预设的前两个颜色 + if (preset.colors.length >= 2) { + setPrimaryColor(preset.colors[0]); + setSecondaryColor(preset.colors[1]); + } }; const generateApiLink = () => { @@ -175,6 +196,65 @@ export default function GradientGenerator() { {/* Right Column: Controls */}
+ {/* Color Wheel Section */} +
+
+
+ +

Color Wheel

+
+
+ + {/* 模式切换 */} +
+ + +
+ + {/* 色轮组件 */} +
+ +
+ + {/* 模式说明 */} +
+ {selectionMode === 'free' ? ( +

在色轮上拖动两个颜色点,自由组合您喜欢的色彩搭配。

+ ) : ( +

选择主色后,系统会基于色彩和谐理论自动推荐最佳搭配色。

+ )} +
+
+ {/* Dimensions */}
@@ -184,26 +264,34 @@ export default function GradientGenerator() {
-
+
setWidth(Number(e.target.value))} - className="font-mono" + className="font-mono pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> - px + + px +
-
+
setHeight(Number(e.target.value))} - className="font-mono" + className="font-mono pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> - px + + px +
@@ -221,7 +309,7 @@ export default function GradientGenerator() {
-
+
{colors.map((color, index) => (
@@ -297,11 +385,9 @@ export default function GradientGenerator() { style={{ background: `linear-gradient(135deg, ${preset.colors.join(', ')})` }} />
-
- - {preset.name} - -
+ + {preset.name} + ))}
@@ -309,6 +395,11 @@ export default function GradientGenerator() {
+ + {/* Footer */} +
+

Built with Next.js & Tailwind CSS. Open source on GitHub.

+
); diff --git a/src/app/test/page.tsx b/src/app/test/page.tsx new file mode 100644 index 0000000..e428b50 --- /dev/null +++ b/src/app/test/page.tsx @@ -0,0 +1,335 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + hexToHsl, + hslToHex, + generateColorHarmonies, + generateRecommendedPair, + getColorWheelPosition, + getHslFromPosition +} from '@/lib/colorUtils'; +import { generateGradientColors } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { CheckCircle, XCircle, AlertCircle } from 'lucide-react'; + +interface TestResult { + name: string; + passed: boolean; + message: string; +} + +export default function TestPage() { + const [results, setResults] = useState([]); + const [isRunning, setIsRunning] = useState(false); + + const runTests = () => { + setIsRunning(true); + const testResults: TestResult[] = []; + + // 测试 1: HEX 转 HSL + try { + const redHsl = hexToHsl('#FF0000'); + const passed = redHsl.h === 0 && redHsl.s === 100 && redHsl.l === 50; + testResults.push({ + name: 'HEX 转 HSL', + passed, + message: passed + ? '#FF0000 正确转换为 HSL(0, 100%, 50%)' + : `转换结果: HSL(${redHsl.h}, ${redHsl.s}%, ${redHsl.l}%)` + }); + } catch (error) { + testResults.push({ name: 'HEX 转 HSL', passed: false, message: '测试执行出错' }); + } + + // 测试 2: HSL 转 HEX + try { + const redHex = hslToHex({ h: 0, s: 100, l: 50 }); + const passed = redHex === '#FF0000'; + testResults.push({ + name: 'HSL 转 HEX', + passed, + message: passed + ? 'HSL(0, 100%, 50%) 正确转换为 #FF0000' + : `转换结果: ${redHex}` + }); + } catch (error) { + testResults.push({ name: 'HSL 转 HEX', passed: false, message: '测试执行出错' }); + } + + // 测试 3: 双向转换 + try { + const originalHex = '#5135FF'; + const hsl = hexToHsl(originalHex); + const convertedHex = hslToHex(hsl); + const passed = convertedHex === originalHex; + testResults.push({ + name: 'HEX ↔ HSL 双向转换', + passed, + message: passed + ? `${originalHex} 双向转换成功` + : `转换结果: ${convertedHex}` + }); + } catch (error) { + testResults.push({ name: 'HEX ↔ HSL 双向转换', passed: false, message: '测试执行出错' }); + } + + // 测试 4: 色彩和谐生成 + try { + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + const passed = harmonies.length === 6; + const names = harmonies.map(h => h.name).join(', '); + testResults.push({ + name: '色彩和谐生成', + passed, + message: passed + ? `成功生成 6 种和谐配色: ${names}` + : `生成了 ${harmonies.length} 种配色` + }); + } catch (error) { + testResults.push({ name: '色彩和谐生成', passed: false, message: '测试执行出错' }); + } + + // 测试 5: 互补色计算 + try { + const harmonies = generateColorHarmonies({ h: 0, s: 100, l: 50 }); + const complementary = harmonies.find(h => h.name === '互补色'); + const passed = complementary?.colors.includes('#00FFFF') ?? false; + testResults.push({ + name: '互补色计算', + passed, + message: passed + ? '红色的互补色正确计算为青色 (#00FFFF)' + : `互补色结果: ${complementary?.colors.join(', ')}` + }); + } catch (error) { + testResults.push({ name: '互补色计算', passed: false, message: '测试执行出错' }); + } + + // 测试 6: 三分色计算 + try { + const harmonies = generateColorHarmonies({ h: 0, s: 100, l: 50 }); + const triadic = harmonies.find(h => h.name === '三分色'); + const hasRed = triadic?.colors.includes('#FF0000'); + const hasGreen = triadic?.colors.includes('#00FF00'); + const hasBlue = triadic?.colors.includes('#0000FF'); + const passed = hasRed && hasGreen && hasBlue; + testResults.push({ + name: '三分色计算', + passed, + message: passed + ? '正确生成红、绿、蓝三分色' + : `三分色结果: ${triadic?.colors.join(', ')}` + }); + } catch (error) { + testResults.push({ name: '三分色计算', passed: false, message: '测试执行出错' }); + } + + // 测试 7: 推荐配色 + try { + const recommendation = generateRecommendedPair('#FF0000'); + const passed = recommendation.secondary === '#00FFFF'; + testResults.push({ + name: '推荐配色算法', + passed, + message: passed + ? '红色正确推荐互补色青色' + : `推荐结果: ${recommendation.secondary}` + }); + } catch (error) { + testResults.push({ name: '推荐配色算法', passed: false, message: '测试执行出错' }); + } + + // 测试 8: 色轮位置计算 + try { + const pos = getColorWheelPosition(0, 100, 100); + const passed = pos.x === 100 && pos.y < 1; + testResults.push({ + name: '色轮位置计算', + passed, + message: passed + ? '色相0°正确位于色轮顶部' + : `位置: (${pos.x}, ${pos.y})` + }); + } catch (error) { + testResults.push({ name: '色轮位置计算', passed: false, message: '测试执行出错' }); + } + + // 测试 9: 位置转HSL + try { + const result = getHslFromPosition(100, 0, 100); + const passed = result.h === 0 && result.s === 100; + testResults.push({ + name: '位置转HSL', + passed, + message: passed + ? '色轮顶部正确转换为 HSL(0°, 100%)' + : `结果: HSL(${result.h}°, ${result.s}%)` + }); + } catch (error) { + testResults.push({ name: '位置转HSL', passed: false, message: '测试执行出错' }); + } + + // 测试 10: 渐变色彩生成 + try { + const gradientColors = generateGradientColors('#FF0000', '#0000FF'); + const passed = gradientColors.length === 4 && + gradientColors[0] === '#FF0000' && + gradientColors[3] === '#0000FF'; + testResults.push({ + name: '渐变色彩生成', + passed, + message: passed + ? '正确生成4色渐变数组 (红 -> 蓝)' + : `生成了 ${gradientColors.length} 个颜色: ${gradientColors.join(', ')}` + }); + } catch (error) { + testResults.push({ name: '渐变色彩生成', passed: false, message: '测试执行出错' }); + } + + setResults(testResults); + setIsRunning(false); + }; + + useEffect(() => { + runTests(); + }, []); + + const passedCount = results.filter(r => r.passed).length; + const totalCount = results.length; + + return ( +
+
+ + {/* Header */} +
+

+ 色彩模块测试 +

+

+ 验证色彩工具函数和色彩和谐算法的正确性 +

+
+ + {/* Summary Card */} + +
+
+

测试结果摘要

+

+ 通过: {passedCount} / {totalCount} +

+
+
+
+
+ {totalCount > 0 ? Math.round((passedCount / totalCount) * 100) : 0}% +
+
通过率
+
+ +
+
+ + {/* Progress Bar */} +
+
0 ? (passedCount / totalCount) * 100 : 0}%` }} + /> +
+ + + {/* Test Results */} +
+

详细结果

+ {results.map((result, index) => ( + +
+
+ {result.passed ? ( + + ) : ( + + )} +
+
+
+ {result.name} + + {result.passed ? '通过' : '失败'} + +
+

+ {result.message} +

+
+
+
+ ))} + + {results.length === 0 && ( + + +

点击"重新测试"开始测试

+
+ )} +
+ + {/* Color Harmony Preview */} + {results.length > 0 && ( + +

色彩和谐预览

+
+ {(() => { + const harmonies = generateColorHarmonies({ h: 200, s: 80, l: 50 }); + return harmonies.map((harmony) => ( +
+
+ {harmony.name} + + {harmony.colors.length} 色 + +
+
+ {harmony.colors.map((color, idx) => ( +
+ ))} +
+
+ )); + })()} +
+ + )} + + {/* Footer */} +
+

测试基于色彩和谐理论和 HSL 色彩空间

+
+
+
+ ); +} diff --git a/src/components/ColorWheel.tsx b/src/components/ColorWheel.tsx new file mode 100644 index 0000000..099ceca --- /dev/null +++ b/src/components/ColorWheel.tsx @@ -0,0 +1,541 @@ +'use client'; + +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { hexToHsl, hslToHex, getColorWheelPosition, getHslFromPosition, generateColorHarmonies } from '@/lib/colorUtils'; +import { cn } from '@/lib/utils'; + +interface ColorWheelProps { + primaryColor: string; + secondaryColor: string; + onPrimaryChange: (color: string) => void; + onSecondaryChange: (color: string) => void; + mode: 'free' | 'recommended'; + size?: number; +} + +export function ColorWheel({ + primaryColor, + secondaryColor, + onPrimaryChange, + onSecondaryChange, + mode, + size = 280, +}: ColorWheelProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState<'primary' | 'secondary' | null>(null); + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); + const [hoveredColor, setHoveredColor] = useState(null); + const [harmonies, setHarmonies] = useState>([]); + const [selectedHarmony, setSelectedHarmony] = useState('互补色'); + const [showDragPreview, setShowDragPreview] = useState(false); + + const radius = size / 2; + const center = radius; + + // 绘制色轮 + const drawColorWheel = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 清除画布 + ctx.clearRect(0, 0, size, size); + + // 绘制色相环 + for (let angle = 0; angle < 360; angle++) { + const startAngle = (angle - 90) * (Math.PI / 180); + const endAngle = (angle - 89) * (Math.PI / 180); + + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius - 2, startAngle, endAngle); + ctx.closePath(); + ctx.fillStyle = `hsl(${angle}, 100%, 50%)`; + ctx.fill(); + } + + // 绘制内部渐变(饱和度从外到内递减) + const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius - 2); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(center, center, radius - 2, 0, Math.PI * 2); + ctx.fill(); + + // 在自由选择模式下绘制拖拽预览效果 + if (mode === 'free' && isDragging && dragPosition) { + const { h, s } = getHslFromPosition(dragPosition.x, dragPosition.y, radius); + const previewColor = hslToHex({ h, s, l: 50 }); + + // 绘制预览圆环 + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(dragPosition.x, dragPosition.y, 15, 0, Math.PI * 2); + ctx.strokeStyle = previewColor; + ctx.lineWidth = 1; + ctx.stroke(); + + // 绘制从中心到拖拽位置的连线 + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.lineTo(dragPosition.x, dragPosition.y); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + } + + // 绘制推荐色彩标记(仅在推荐模式下) + if (mode === 'recommended') { + harmonies.forEach((harmony) => { + if (harmony.name === selectedHarmony) { + harmony.colors.forEach((color) => { + if (color.toUpperCase() !== primaryColor.toUpperCase()) { + const hsl = hexToHsl(color); + const pos = getColorWheelPosition(hsl.h, hsl.s, radius - 15); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // 绘制推荐标记 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + }); + } + }); + } + }, [size, radius, center, mode, harmonies, selectedHarmony, primaryColor, isDragging, dragPosition]); + + // 处理颜色选择 - 必须在 useEffect 之前定义 + const handleColorSelection = useCallback((x: number, y: number, type: 'primary' | 'secondary') => { + const { h, s } = getHslFromPosition(x, y, radius); + const l = 50; // 默认中等亮度 + const newColor = hslToHex({ h, s, l }); + + if (type === 'primary') { + onPrimaryChange(newColor); + } else { + onSecondaryChange(newColor); + } + }, [radius, onPrimaryChange, onSecondaryChange]); + + // 初始绘制和更新 + useEffect(() => { + drawColorWheel(); + }, [drawColorWheel]); + + // 添加全局鼠标事件监听,处理从颜色点拖拽到外部的情况 + useEffect(() => { + if (!isDragging) return; + + const handleGlobalMouseMove = (e: MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 计算相对于色轮中心的位置,限制在色轮范围内 + const dx = x - center; + const dy = y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 限制在色轮范围内(留出一点边距) + const maxDistance = radius - 15; + let finalX = x; + let finalY = y; + + if (distance > maxDistance) { + const ratio = maxDistance / distance; + finalX = center + dx * ratio; + finalY = center + dy * ratio; + } + + setDragPosition({ x: finalX, y: finalY }); + handleColorSelection(finalX, finalY, isDragging); + }; + + const handleGlobalMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + document.addEventListener('mousemove', handleGlobalMouseMove); + document.addEventListener('mouseup', handleGlobalMouseUp); + + return () => { + document.removeEventListener('mousemove', handleGlobalMouseMove); + document.removeEventListener('mouseup', handleGlobalMouseUp); + }; + }, [isDragging, center, radius, handleColorSelection]); + + // 当主色变化时更新和谐色 + useEffect(() => { + const newHarmonies = generateColorHarmonies(hexToHsl(primaryColor)); + setHarmonies(newHarmonies); + + // 在推荐模式下,自动更新次要颜色 + if (mode === 'recommended') { + const harmony = newHarmonies.find(h => h.name === selectedHarmony); + if (harmony && harmony.colors.length > 1) { + // 找到第一个不等于主色的颜色作为推荐 + const recommended = harmony.colors.find(c => c.toUpperCase() !== primaryColor.toUpperCase()); + if (recommended) { + onSecondaryChange(recommended); + } + } + } + }, [primaryColor, mode, selectedHarmony, onSecondaryChange]); + + // 获取鼠标/触摸位置 + const getPositionFromEvent = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + // 计算鼠标位置对应的颜色 + const getColorAtPosition = (x: number, y: number): string => { + const { h, s } = getHslFromPosition(x, y, radius); + return hslToHex({ h, s, l: 50 }); + }; + + // 鼠标/触摸事件处理 + const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 判断点击位置更接近哪个颜色点 + const primaryHsl = hexToHsl(primaryColor); + const secondaryHsl = hexToHsl(secondaryColor); + const primaryPos = getColorWheelPosition(primaryHsl.h, primaryHsl.s, radius - 15); + const secondaryPos = getColorWheelPosition(secondaryHsl.h, secondaryHsl.s, radius - 15); + + const distToPrimary = Math.sqrt(Math.pow(pos.x - primaryPos.x, 2) + Math.pow(pos.y - primaryPos.y, 2)); + const distToSecondary = Math.sqrt(Math.pow(pos.x - secondaryPos.x, 2) + Math.pow(pos.y - secondaryPos.y, 2)); + + // 只有点击靠近颜色点时才允许拖拽 + if (distToPrimary < 25) { + setIsDragging('primary'); + setDragPosition(pos); + setShowDragPreview(true); + } else if (mode === 'free' && distToSecondary < 25) { + setIsDragging('secondary'); + setDragPosition(pos); + setShowDragPreview(true); + } + // 点击色轮其他位置不执行任何操作 + }; + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + // 只有在拖拽状态下才处理 Canvas 上的鼠标移动 + if (!isDragging) return; + + e.preventDefault(); + const pos = getPositionFromEvent(e); + if (!pos) return; + + // 计算相对于色轮中心的位置,限制在色轮范围内 + const dx = pos.x - center; + const dy = pos.y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 限制在色轮范围内(留出一点边距) + const maxDistance = radius - 15; + let finalX = pos.x; + let finalY = pos.y; + + if (distance > maxDistance) { + const ratio = maxDistance / distance; + finalX = center + dx * ratio; + finalY = center + dy * ratio; + } + + setDragPosition({ x: finalX, y: finalY }); + handleColorSelection(finalX, finalY, isDragging); + }; + + const handleMouseUp = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }; + + const handleMouseLeave = () => { + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + setHoveredColor(null); + }; + + // 获取颜色点在色轮上的位置 + const getPrimaryPosition = () => { + const hsl = hexToHsl(primaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const getSecondaryPosition = () => { + const hsl = hexToHsl(secondaryColor); + return getColorWheelPosition(hsl.h, hsl.s, radius - 15); + }; + + const primaryPos = getPrimaryPosition(); + const secondaryPos = getSecondaryPosition(); + + return ( +
+ {/* 色轮容器 */} +
+ + + {/* 主色选择点 */} +
{ + e.preventDefault(); + e.stopPropagation(); + setIsDragging('primary'); + setDragPosition({ x: primaryPos.x, y: primaryPos.y }); + setShowDragPreview(true); + }} + onMouseUp={(e) => { + e.stopPropagation(); + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }} + onTouchStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging('primary'); + setDragPosition({ x: primaryPos.x, y: primaryPos.y }); + setShowDragPreview(true); + }} + onTouchEnd={(e) => { + e.stopPropagation(); + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }} + > + {/* 主色标签 */} +
+ 主色 +
+
+ + {/* 次色选择点 */} +
{ + if (mode === 'recommended') return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging('secondary'); + setDragPosition({ x: secondaryPos.x, y: secondaryPos.y }); + setShowDragPreview(true); + }} + onMouseUp={(e) => { + e.stopPropagation(); + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }} + onTouchStart={(e) => { + if (mode === 'recommended') return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging('secondary'); + setDragPosition({ x: secondaryPos.x, y: secondaryPos.y }); + setShowDragPreview(true); + }} + onTouchEnd={(e) => { + e.stopPropagation(); + setIsDragging(null); + setDragPosition(null); + setShowDragPreview(false); + }} + > + {/* 次色标签 */} + {mode === 'free' && ( +
+ 搭配 +
+ )} +
+ + {/* 拖拽时的实时预览点(自由选择模式) */} + {mode === 'free' && isDragging && dragPosition && ( +
+ )} + + {/* 中心点 */} +
+ + {/* 拖拽时的颜色值提示 */} + {isDragging && dragPosition && ( +
+ {(isDragging === 'primary' ? primaryColor : secondaryColor).toUpperCase()} +
+ )} +
+ + {/* 和谐模式选择器(仅在推荐模式下显示) */} + {mode === 'recommended' && ( +
+

推荐配色方案

+
+ {harmonies.map((harmony) => ( + + ))} +
+
+ )} + + {/* 颜色信息显示 */} +
+
+

主色

+
+
+ {primaryColor.toUpperCase()} +
+
+
+

搭配色

+
+
+ {secondaryColor.toUpperCase()} +
+
+
+
+ ); +} diff --git a/src/lib/__tests__/colorUtils.test.ts b/src/lib/__tests__/colorUtils.test.ts new file mode 100644 index 0000000..a2dda43 --- /dev/null +++ b/src/lib/__tests__/colorUtils.test.ts @@ -0,0 +1,229 @@ +/** + * 色彩工具函数测试 + * 运行测试: npm test + */ + +import { + hexToHsl, + hslToHex, + generateColorHarmonies, + generateRecommendedPair, + getColorWheelPosition, + getHslFromPosition +} from '../colorUtils'; + +describe('Color Utilities', () => { + + describe('hexToHsl', () => { + it('should convert pure red correctly', () => { + const result = hexToHsl('#FF0000'); + expect(result.h).toBe(0); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert pure green correctly', () => { + const result = hexToHsl('#00FF00'); + expect(result.h).toBe(120); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert pure blue correctly', () => { + const result = hexToHsl('#0000FF'); + expect(result.h).toBe(240); + expect(result.s).toBe(100); + expect(result.l).toBe(50); + }); + + it('should convert white correctly', () => { + const result = hexToHsl('#FFFFFF'); + expect(result.s).toBe(0); + expect(result.l).toBe(100); + }); + + it('should convert black correctly', () => { + const result = hexToHsl('#000000'); + expect(result.s).toBe(0); + expect(result.l).toBe(0); + }); + }); + + describe('hslToHex', () => { + it('should convert HSL to hex correctly for red', () => { + const result = hslToHex({ h: 0, s: 100, l: 50 }); + expect(result).toBe('#FF0000'); + }); + + it('should convert HSL to hex correctly for green', () => { + const result = hslToHex({ h: 120, s: 100, l: 50 }); + expect(result).toBe('#00FF00'); + }); + + it('should convert HSL to hex correctly for blue', () => { + const result = hslToHex({ h: 240, s: 100, l: 50 }); + expect(result).toBe('#0000FF'); + }); + + it('should handle round-trip conversion', () => { + const originalHex = '#5135FF'; + const hsl = hexToHsl(originalHex); + const convertedHex = hslToHex(hsl); + expect(convertedHex).toBe(originalHex); + }); + }); + + describe('generateColorHarmonies', () => { + it('should generate 6 different harmony types', () => { + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + expect(harmonies).toHaveLength(6); + + const names = harmonies.map(h => h.name); + expect(names).toContain('互补色'); + expect(names).toContain('类比色'); + expect(names).toContain('三分色'); + expect(names).toContain('分裂互补'); + expect(names).toContain('四角形'); + expect(names).toContain('单色'); + }); + + it('should include base color in all harmonies', () => { + const baseColor = { h: 180, s: 80, l: 50 }; + const baseHex = hslToHex(baseColor); + const harmonies = generateColorHarmonies(baseColor); + + // 互补色应该包含原色 + const complementary = harmonies.find(h => h.name === '互补色'); + expect(complementary?.colors).toContain(baseHex); + }); + + it('should generate correct complementary color', () => { + const baseColor = { h: 0, s: 100, l: 50 }; // Red + const harmonies = generateColorHarmonies(baseColor); + const complementary = harmonies.find(h => h.name === '互补色'); + + // 红色的互补色应该是青色 (180度) + expect(complementary?.colors).toContain('#00FFFF'); + }); + + it('should generate correct triadic colors', () => { + const baseColor = { h: 0, s: 100, l: 50 }; // Red + const harmonies = generateColorHarmonies(baseColor); + const triadic = harmonies.find(h => h.name === '三分色'); + + expect(triadic?.colors).toHaveLength(3); + expect(triadic?.colors).toContain('#FF0000'); + expect(triadic?.colors).toContain('#00FF00'); // 120度 + expect(triadic?.colors).toContain('#0000FF'); // 240度 + }); + }); + + describe('generateRecommendedPair', () => { + it('should return primary color as provided', () => { + const result = generateRecommendedPair('#FF0000'); + expect(result.primary).toBe('#FF0000'); + }); + + it('should return complementary color as secondary', () => { + const result = generateRecommendedPair('#FF0000'); + expect(result.harmonyType).toBe('互补色'); + // 红色的互补色应该是青色 + expect(result.secondary).toBe('#00FFFF'); + }); + + it('should handle different base colors', () => { + const blueResult = generateRecommendedPair('#0000FF'); + expect(blueResult.primary).toBe('#0000FF'); + // 蓝色的互补色应该是黄色 + expect(blueResult.secondary).toBe('#FFFF00'); + }); + }); + + describe('getColorWheelPosition', () => { + it('should return center for zero saturation', () => { + const pos = getColorWheelPosition(0, 0, 100); + expect(pos.x).toBe(100); + expect(pos.y).toBe(100); + }); + + it('should return correct position for red at top', () => { + const pos = getColorWheelPosition(0, 100, 100); + expect(pos.x).toBe(100); + expect(pos.y).toBeCloseTo(0, 0); + }); + + it('should return correct position for green', () => { + const pos = getColorWheelPosition(120, 100, 100); + expect(pos.x).toBeGreaterThan(100); + expect(pos.y).toBeGreaterThan(100); + }); + }); + + describe('getHslFromPosition', () => { + it('should return zero saturation at center', () => { + const result = getHslFromPosition(100, 100, 100); + expect(result.s).toBe(0); + }); + + it('should return max saturation at edge', () => { + const result = getHslFromPosition(100, 0, 100); // Top center + expect(result.s).toBe(100); + }); + + it('should return correct hue for top position', () => { + const result = getHslFromPosition(100, 0, 100); + expect(result.h).toBe(0); // Red at top + }); + }); +}); + +// 手动运行测试的辅助函数 +export function runManualTests() { + console.log('=== 运行色彩工具函数手动测试 ===\n'); + + // 测试 1: HEX 转 HSL + console.log('1. HEX 转 HSL 测试:'); + const redHsl = hexToHsl('#FF0000'); + console.log(` #FF0000 -> HSL(${redHsl.h}, ${redHsl.s}%, ${redHsl.l}%)`); + console.log(` 期望: HSL(0, 100%, 50%)`); + console.log(` 结果: ${redHsl.h === 0 && redHsl.s === 100 && redHsl.l === 50 ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 2: HSL 转 HEX + console.log('2. HSL 转 HEX 测试:'); + const redHex = hslToHex({ h: 0, s: 100, l: 50 }); + console.log(` HSL(0, 100%, 50%) -> ${redHex}`); + console.log(` 期望: #FF0000`); + console.log(` 结果: ${redHex === '#FF0000' ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 3: 色彩和谐生成 + console.log('3. 色彩和谐生成测试:'); + const harmonies = generateColorHarmonies({ h: 240, s: 100, l: 50 }); + console.log(` 生成了 ${harmonies.length} 种和谐配色方案:`); + harmonies.forEach(h => { + console.log(` - ${h.name}: ${h.colors.join(', ')}`); + }); + console.log(` 结果: ${harmonies.length === 6 ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 4: 推荐配色 + console.log('4. 推荐配色测试:'); + const recommendation = generateRecommendedPair('#FF0000'); + console.log(` 主色: ${recommendation.primary}`); + console.log(` 推荐搭配: ${recommendation.secondary}`); + console.log(` 和谐类型: ${recommendation.harmonyType}`); + console.log(` 期望搭配: #00FFFF (青色,红色的互补色)`); + console.log(` 结果: ${recommendation.secondary === '#00FFFF' ? '✓ 通过' : '✗ 失败'}\n`); + + // 测试 5: 色轮位置计算 + console.log('5. 色轮位置计算测试:'); + const pos = getColorWheelPosition(0, 100, 100); + console.log(` 色相0°, 饱和度100%, 半径100 -> 位置(${pos.x.toFixed(1)}, ${pos.y.toFixed(1)})`); + console.log(` 期望: (100, 0) - 顶部`); + console.log(` 结果: ${pos.x === 100 && pos.y < 1 ? '✓ 通过' : '✗ 失败'}\n`); + + console.log('=== 测试完成 ==='); +} + +// 如果在浏览器环境中运行,可以调用此函数 +if (typeof window !== 'undefined') { + (window as unknown as { runColorTests: typeof runManualTests }).runColorTests = runManualTests; +} diff --git a/src/lib/colorUtils.ts b/src/lib/colorUtils.ts new file mode 100644 index 0000000..65453e7 --- /dev/null +++ b/src/lib/colorUtils.ts @@ -0,0 +1,230 @@ +/** + * 色彩工具库 - 包含色彩转换和色彩和谐算法 + * 基于 HSL 色彩空间和色彩和谐理论 + */ + +export interface HSL { + h: number; // 色相: 0-360 + s: number; // 饱和度: 0-100 + l: number; // 亮度: 0-100 +} + +export interface ColorHarmony { + name: string; + colors: string[]; +} + +/** + * 将 HEX 颜色转换为 HSL + */ +export function hexToHsl(hex: string): HSL { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (max !== min) { + 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: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; +} + +/** + * 将 HSL 颜色转换为 HEX + */ +export function hslToHex(hsl: HSL): string { + const h = hsl.h / 360; + const s = hsl.s / 100; + const l = hsl.l / 100; + + let r: number, g: number, b: number; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + const toHex = (c: number) => { + const hex = Math.round(c * 255).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase(); +} + +/** + * 获取色轮上的位置(用于在色轮上显示颜色) + */ +export function getColorWheelPosition(hue: number, saturation: number, radius: number): { x: number; y: number } { + const angle = (hue - 90) * (Math.PI / 180); // 从12点钟方向开始 + const distance = (saturation / 100) * radius; + return { + x: radius + distance * Math.cos(angle), + y: radius + distance * Math.sin(angle), + }; +} + +/** + * 从色轮位置获取色相和饱和度 + */ +export function getHslFromPosition(x: number, y: number, radius: number): { h: number; s: number } { + const dx = x - radius; + const dy = y - radius; + let angle = Math.atan2(dy, dx) * (180 / Math.PI); + angle = (angle + 90 + 360) % 360; // 转换为0-360,从12点钟方向开始 + const distance = Math.min(Math.sqrt(dx * dx + dy * dy), radius); + const saturation = (distance / radius) * 100; + return { h: Math.round(angle), s: Math.round(saturation) }; +} + +/** + * 色彩和谐算法 - 生成协调的颜色组合 + */ +export function generateColorHarmonies(baseHsl: HSL): ColorHarmony[] { + const harmonies: ColorHarmony[] = []; + + // 互补色 (Complementary) - 色相环上相对的颜色 + harmonies.push({ + name: '互补色', + colors: [ + hslToHex(baseHsl), + hslToHex({ ...baseHsl, h: (baseHsl.h + 180) % 360 }), + ], + }); + + // 类比色 (Analogous) - 色相环上相邻的颜色 + harmonies.push({ + name: '类比色', + colors: [ + hslToHex({ ...baseHsl, h: (baseHsl.h - 30 + 360) % 360 }), + hslToHex(baseHsl), + hslToHex({ ...baseHsl, h: (baseHsl.h + 30) % 360 }), + ], + }); + + // 三分色 (Triadic) - 色相环上均匀分布的三个颜色 + harmonies.push({ + name: '三分色', + colors: [ + hslToHex(baseHsl), + hslToHex({ ...baseHsl, h: (baseHsl.h + 120) % 360 }), + hslToHex({ ...baseHsl, h: (baseHsl.h + 240) % 360 }), + ], + }); + + // 分裂互补色 (Split Complementary) - 基础色 + 互补色两侧的颜色 + harmonies.push({ + name: '分裂互补', + colors: [ + hslToHex(baseHsl), + hslToHex({ ...baseHsl, h: (baseHsl.h + 150) % 360 }), + hslToHex({ ...baseHsl, h: (baseHsl.h + 210) % 360 }), + ], + }); + + // 四角形 (Tetradic/Rectangle) - 两对互补色 + harmonies.push({ + name: '四角形', + colors: [ + hslToHex(baseHsl), + hslToHex({ ...baseHsl, h: (baseHsl.h + 60) % 360 }), + hslToHex({ ...baseHsl, h: (baseHsl.h + 180) % 360 }), + hslToHex({ ...baseHsl, h: (baseHsl.h + 240) % 360 }), + ], + }); + + // 单色 (Monochromatic) - 相同色相,不同饱和度和亮度 + harmonies.push({ + name: '单色', + colors: [ + hslToHex({ ...baseHsl, s: Math.max(20, baseHsl.s - 40), l: Math.min(90, baseHsl.l + 30) }), + hslToHex({ ...baseHsl, s: Math.max(40, baseHsl.s - 20), l: baseHsl.l }), + hslToHex(baseHsl), + hslToHex({ ...baseHsl, s: Math.min(100, baseHsl.s + 20), l: Math.max(20, baseHsl.l - 20) }), + ], + }); + + return harmonies; +} + +/** + * 生成推荐的双色组合 + * 基于色彩和谐理论,为用户选择的第一个颜色推荐最佳搭配 + */ +export function generateRecommendedPair(baseColor: string): { primary: string; secondary: string; harmonyType: string } { + const baseHsl = hexToHsl(baseColor); + + // 使用互补色作为默认推荐(对比最强烈,最常用) + const complementaryHue = (baseHsl.h + 180) % 360; + const secondaryHsl: HSL = { + h: complementaryHue, + s: Math.min(100, baseHsl.s + 10), // 稍微调整饱和度 + l: Math.max(30, Math.min(70, baseHsl.l)), // 保持中等亮度 + }; + + return { + primary: baseColor, + secondary: hslToHex(secondaryHsl), + harmonyType: '互补色', + }; +} + +/** + * 根据两个颜色生成渐变推荐 + */ +export function generateGradientFromPair(color1: string, color2: string): string[] { + const hsl1 = hexToHsl(color1); + const hsl2 = hexToHsl(color2); + + // 生成中间过渡色 + const steps = 4; + const colors: string[] = [color1]; + + for (let i = 1; i < steps - 1; i++) { + const ratio = i / (steps - 1); + const h = hsl1.h + (hsl2.h - hsl1.h) * ratio; + const s = hsl1.s + (hsl2.s - hsl1.s) * ratio; + const l = hsl1.l + (hsl2.l - hsl1.l) * ratio; + colors.push(hslToHex({ h: Math.round(h), s: Math.round(s), l: Math.round(l) })); + } + + colors.push(color2); + return colors; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 539e026..6bcd26b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,6 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" +import { hexToHsl, hslToHex } from "./colorUtils"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -17,4 +18,46 @@ export function paramToColor(param: string): string { return '#' + param.slice(4); } return param; +} + +/** + * 根据两个基础颜色生成渐变色彩数组 + * 生成4个颜色:color1, 两个过渡色, color2 + */ +export function generateGradientColors(color1: string, color2: string): string[] { + const hsl1 = hexToHsl(color1); + const hsl2 = hexToHsl(color2); + + const colors: string[] = [color1]; + + // 生成2个中间过渡色 + for (let i = 1; i <= 2; i++) { + const ratio = i / 3; + + // 处理色相的环形特性(最短路径) + let h1 = hsl1.h; + let h2 = hsl2.h; + + // 确保选择色相环上的最短路径 + if (Math.abs(h2 - h1) > 180) { + if (h2 > h1) { + h1 += 360; + } else { + h2 += 360; + } + } + + const h = (h1 + (h2 - h1) * ratio) % 360; + const s = hsl1.s + (hsl2.s - hsl1.s) * ratio; + const l = hsl1.l + (hsl2.l - hsl1.l) * ratio; + + colors.push(hslToHex({ + h: Math.round(h < 0 ? h + 360 : h), + s: Math.round(s), + l: Math.round(l) + })); + } + + colors.push(color2); + return colors; } \ No newline at end of file