创建一个设计师专用的色彩生成工具。核心功
2026-01-08
创建一个设计师专用的色彩生成工具。核心功能是基于色彩理论(互补色、三元色、类比色、分裂互补色)自动生成和谐的五色配色方案。界面中央应为一个交互式色轮,用户拖动主色点时,其他关联色点应根据选定的和声规则自动移动。下方展示生成的色卡,支持一键复制 HEX/RGB/HSL 代码,并提供“视觉无障碍测试”预览(模拟色盲视角)。UI 设计应极致优雅,采用纯白背景,突出色彩本身的鲜艳与纯净。
思考过程
这是一个非常专业的设计工具需求,需要创建一个优雅的色彩生成器。让我分析一下核心需求:
1. **色彩理论规则**:
- 互补色 (Complementary): 180度对角
- 三元色 (Triadic): 120度等分
- 类比色 (Analogous): 30度邻近
- 分裂互补色 (Split-complementary): 150度和210度
2. **交互式色轮**:
- 中央色轮
- 可拖动的主色点
- 关联色点自动移动
3. **色卡展示**:
- 五色配色方案
- HEX/RGB/HSL格式
- 一键复制功能
4. **视觉无障碍测试**:
- 色盲模拟(红绿色盲、蓝黄色盲等)
5. **UI设计**:
- 纯白背景
- 极致优雅
- 突出色彩
让我开始编写代码:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChromaHarmony - 专业色彩生成工具</title>
<style>
/* 样式代码 */
</style>
</head>
<body>
<!-- HTML内容 -->
<script>
// JavaScript代码
</script>
</body>
</html>
```
现在需要实现SVG色轮绘制、拖拽交互、HSL色彩计算、色彩和谐规则应用、色盲模拟滤镜、复制功能以及优雅的动画效果。
# ChromaHarmony - 专业色彩生成工具
这是一个功能完整、视觉优雅的设计师专用色彩工具,支持多种色彩和谐规则和无障碍测试。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChromaHarmony - 专业色彩生成工具</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #ffffff;
--text: #1a1a2e;
--text-light: #6b7280;
--border: #e5e7eb;
--shadow: rgba(0, 0, 0, 0.08);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
/* Header */
.header {
padding: 2rem 3rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
width: 40px;
height: 40px;
background: conic-gradient(
#ff6b6b, #feca57, #48dbfb, #ff9ff3, #ff6b6b
);
border-radius: 10px;
animation: logoSpin 10s linear infinite;
}
@keyframes logoSpin {
to { filter: hue-rotate(360deg); }
}
.logo h1 {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.5px;
}
.logo span {
color: var(--text-light);
font-weight: 400;
}
/* Main Layout */
.main-container {
display: grid;
grid-template-columns: 1fr 400px;
gap: 0;
min-height: calc(100vh - 85px);
}
/* Color Wheel Section */
.wheel-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
position: relative;
}
.harmony-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 2.5rem;
background: #f8f9fa;
padding: 6px;
border-radius: 12px;
}
.harmony-btn {
padding: 0.75rem 1.25rem;
border: none;
background: transparent;
color: var(--text-light);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border-radius: 8px;
transition: all 0.3s ease;
font-family: inherit;
}
.harmony-btn:hover {
color: var(--text);
}
.harmony-btn.active {
background: white;
color: var(--text);
box-shadow: 0 2px 8px var(--shadow);
}
/* Color Wheel */
.wheel-container {
position: relative;
width: 420px;
height: 420px;
}
.color-wheel {
width: 100%;
height: 100%;
border-radius: 50%;
background: conic-gradient(
hsl(0, 100%, 50%),
hsl(30, 100%, 50%),
hsl(60, 100%, 50%),
hsl(90, 100%, 50%),
hsl(120, 100%, 50%),
hsl(150, 100%, 50%),
hsl(180, 100%, 50%),
hsl(210, 100%, 50%),
hsl(240, 100%, 50%),
hsl(270, 100%, 50%),
hsl(300, 100%, 50%),
hsl(330, 100%, 50%),
hsl(360, 100%, 50%)
);
position: relative;
box-shadow:
0 0 0 1px rgba(0,0,0,0.05),
0 20px 60px rgba(0,0,0,0.1);
}
.color-wheel::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(
circle at center,
white 0%,
white 25%,
transparent 70%
);
}
.color-wheel::after {
content: '';
position: absolute;
inset: 35%;
border-radius: 50%;
background: white;
box-shadow:
inset 0 0 20px rgba(0,0,0,0.05),
0 0 30px rgba(255,255,255,0.8);
}
/* Color Points */
.color-point {
position: absolute;
width: 32px;
height: 32px;
border-radius: 50%;
transform: translate(-50%, -50%);
cursor: grab;
border: 4px solid white;
box-shadow:
0 4px 12px rgba(0,0,0,0.2),
0 0 0 1px rgba(0,0,0,0.05);
transition: transform 0.15s ease, box-shadow 0.15s ease;
z-index: 10;
}
.color-point:hover {
transform: translate(-50%, -50%) scale(1.15);
box-shadow:
0 6px 20px rgba(0,0,0,0.25),
0 0 0 1px rgba(0,0,0,0.05);
}
.color-point.dragging {
cursor: grabbing;
transform: translate(-50%, -50%) scale(1.2);
z-index: 20;
}
.color-point.primary {
width: 44px;
height: 44px;
border-width: 5px;
z-index: 15;
}
.color-point::after {
content: attr(data-label);
position: absolute;
top: 110%;
left: 50%;
transform: translateX(-50%);
font-size: 0.7rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s;
}
.color-point:hover::after {
opacity: 1;
}
/* Connection Lines */
.connection-lines {
position: absolute;
inset: 0;
pointer-events: none;
}
.connection-lines line {
stroke: rgba(0,0,0,0.1);
stroke-width: 2;
stroke-dasharray: 6 4;
}
/* Sidebar Panel */
.sidebar {
background: #fafafa;
border-left: 1px solid var(--border);
padding: 2rem;
display: flex;
flex-direction: column;
gap: 2rem;
overflow-y: auto;
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-light);
margin-bottom: 1rem;
}
/* Color Cards */
.color-cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.color-card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 12px var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
}
.color-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px var(--shadow);
}
.color-card-preview {
height: 60px;
position: relative;
}
.color-card-label {
position: absolute;
top: 8px;
left: 12px;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(255,255,255,0.9);
}
.color-card-info {
padding: 1rem;
}
.color-values {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.color-value-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.color-format {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-light);
width: 32px;
}
.color-code {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.8rem;
color: var(--text);
flex: 1;
padding: 0 0.5rem;
}
.copy-btn {
background: none;
border: none;
padding: 6px;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s, transform 0.2s;
border-radius: 6px;
}
.copy-btn:hover {
opacity: 1;
background: #f0f0f0;
}
.copy-btn:active {
transform: scale(0.9);
}
.copy-btn svg {
width: 16px;
height: 16px;
}
/* Accessibility Section */
.accessibility-section {
margin-top: auto;
}
.vision-modes {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.vision-btn {
padding: 0.75rem;
border: 2px solid var(--border);
background: white;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
color: var(--text);
}
.vision-btn:hover {
border-color: var(--text);
}
.vision-btn.active {
border-color: var(--text);
background: var(--text);
color: white;
}
/* Accessibility Preview */
.preview-strip {
display: flex;
height: 40px;
border-radius: 10px;
overflow: hidden;
margin-top: 1rem;
box-shadow: 0 2px 8px var(--shadow);
}
.preview-strip-color {
flex: 1;
transition: background-color 0.3s;
}
/* Export Button */
.export-btn {
width: 100%;
padding: 1rem;
background: var(--text);
color: white;
border: none;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.export-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
}
/* Toast Notification */
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--text);
color: white;
padding: 1rem 1.5rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 500;
opacity: 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
display: flex;
align-items: center;
gap: 0.5rem;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.toast svg {
width: 18px;
height: 18px;
}
/* Saturation/Lightness Sliders */
.slider-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.slider-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.slider-label {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-light);
}
.slider-label span:last-child {
font-weight: 600;
color: var(--text);
}
.slider {
-webkit-appearance: none;
width: 100%;
height: 8px;
border-radius: 4px;
outline: none;
cursor: pointer;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
border: 3px solid var(--text);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
#saturationSlider {
background: linear-gradient(to right, #ccc, hsl(0, 100%, 50%));
}
#lightnessSlider {
background: linear-gradient(to right, #000, hsl(0, 100%, 50%), #fff);
}
/* Harmony Info */
.harmony-info {
background: white;
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
}
.harmony-info h3 {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.harmony-info p {
font-size: 0.75rem;
color: var(--text-light);
line-height: 1.5;
}
/* Color Blindness Simulation Filter */
.color-blind-filter {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 0;
overflow: hidden;
}
/* Animations */
@keyframes pulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.1); }
}
.color-point.pulse {
animation: pulse 0.5s ease;
}
/* Responsive */
@media (max-width: 900px) {
.main-container {
grid-template-columns: 1fr;
}
.wheel-section {
padding: 2rem 1rem;
}
.wheel-container {
width: 320px;
height: 320px;
}
.sidebar {
border-left: none;
border-top: 1px solid var(--border);
}
.harmony-selector {
flex-wrap: wrap;
justify-content: center;
}
}
</style>
</head>
<body>
<!-- SVG Filters for Color Blindness Simulation -->
<svg class="color-blind-filter">
<defs>
<filter id="protanopia">
<feColorMatrix type="matrix" values="
0.567, 0.433, 0, 0, 0
0.558, 0.442, 0, 0, 0
0, 0.242, 0.758, 0, 0
0, 0, 0, 1, 0
"/>
</filter>
<filter id="deuteranopia">
<feColorMatrix type="matrix" values="
0.625, 0.375, 0, 0, 0
0.7, 0.3, 0, 0, 0
0, 0.3, 0.7, 0, 0
0, 0, 0, 1, 0
"/>
</filter>
<filter id="tritanopia">
<feColorMatrix type="matrix" values="
0.95, 0.05, 0, 0, 0
0, 0.433, 0.567, 0, 0
0, 0.475, 0.525, 0, 0
0, 0, 0, 1, 0
"/>
</filter>
<filter id="achromatopsia">
<feColorMatrix type="matrix" values="
0.299, 0.587, 0.114, 0, 0
0.299, 0.587, 0.114, 0, 0
0.299, 0.587, 0.114, 0, 0
0, 0, 0, 1, 0
"/>
</filter>
</defs>
</svg>
<header class="header">
<div class="logo">
<div class="logo-icon"></div>
<h1>Chroma<span>Harmony</span></h1>
</div>
<div class="header-actions">
</div>
</header>
<main class="main-container">
<section class="wheel-section">
<div class="harmony-selector">
<button class="harmony-btn active" data-harmony="complementary">互补色</button>
<button class="harmony-btn" data-harmony="triadic">三元色</button>
<button class="harmony-btn" data-harmony="analogous">类比色</button>
<button class="harmony-btn" data-harmony="split-complementary">分裂互补</button>
<button class="harmony-btn" data-harmony="tetradic">四元色</button>
</div>
<div class="wheel-container">
<svg class="connection-lines" viewBox="0 0 420 420">
<g id="connectionGroup"></g>
</svg>
<div class="color-wheel" id="colorWheel"></div>
<div class="color-point primary" id="primaryPoint" data-label="主色"></div>
<div class="color-point" id="point1" data-label="辅色1"></div>
<div class="color-point" id="point2" data-label="辅色2"></div>
<div class="color-point" id="point3" data-label="辅色3"></div>
<div class="color-point" id="point4" data-label="辅色4"></div>
</div>
</section>
<aside class="sidebar">
<div class="harmony-info" id="harmonyInfo">
<h3>互补色 Complementary</h3>
<p>在色轮上相隔180°的两种颜色,形成最强烈的对比,适合需要高对比度和视觉冲击力的设计。</p>
</div>
<div class="slider-section">
<div class="section-title">调整参数</div>
<div class="slider-group">
<div class="slider-label">
<span>饱和度</span>
<span id="saturationValue">80%</span>
</div>
<input type="range" class="slider" id="saturationSlider" min="20" max="100" value="80">
</div>
<div class="slider-group">
<div class="slider-label">
<span>明度</span>
<span id="lightnessValue">55%</span>
</div>
<input type="range" class="slider" id="lightnessSlider" min="25" max="75" value="55">
</div>
</div>
<div class="palette-section">
<div class="section-title">配色方案</div>
<div class="color-cards" id="colorCards"></div>
</div>
<div class="accessibility-section">
<div class="section-title">视觉无障碍测试</div>
<div class="vision-modes">
<button class="vision-btn active" data-mode="normal">正常视觉</button>
<button class="vision-btn" data-mode="protanopia">红色盲</button>
<button class="vision-btn" data-mode="deuteranopia">绿色盲</button>
<button class="vision-btn" data-mode="tritanopia">蓝色盲</button>
</div>
<div class="preview-strip" id="previewStrip"></div>
</div>
<button class="export-btn" id="exportBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
导出配色方案
</button>
</aside>
</main>
<div class="toast" id="toast">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span id="toastMessage">已复制到剪贴板</span>
</div>
<script>
// ===== State Management =====
const state = {
harmony: 'complementary',
primaryHue: 0,
saturation: 80,
lightness: 55,
visionMode: 'normal',
colors: []
};
// ===== Harmony Definitions =====
const harmonies = {
complementary: {
name: '互补色 Complementary',
description: '在色轮上相隔180°的两种颜色,形成最强烈的对比,适合需要高对比度和视觉冲击力的设计。',
angles: [0, 180, 150, 210, 30]
},
triadic: {
name: '三元色 Triadic',
description: '三种颜色在色轮上均匀分布,相隔120°,提供丰富的色彩组合同时保持平衡。',
angles: [0, 120, 240, 60, 180]
},
analogous: {
name: '类比色 Analogous',
description: '相邻的颜色组合,通常跨越30-60°,创造和谐、舒适的视觉效果。',
angles: [0, 30, 60, -30, -60]
},
'split-complementary': {
name: '分裂互补 Split-complementary',
description: '使用主色和其互补色两侧的颜色,既有对比又不过于强烈。',
angles: [0, 150, 210, 120, 240]
},
tetradic: {
name: '四元色 Tetradic',
description: '四种颜色形成矩形关系,提供丰富的色彩选择,适合复杂的设计项目。',
angles: [0, 90, 180, 270, 45]
}
};
// ===== Color Labels =====
const colorLabels = ['Primary', 'Secondary', 'Tertiary', 'Accent 1', 'Accent 2'];
// ===== DOM Elements =====
const elements = {
wheel: document.getElementById('colorWheel'),
wheelContainer: document.querySelector('.wheel-container'),
primaryPoint: document.getElementById('primaryPoint'),
points: [
document.getElementById('primaryPoint'),
document.getElementById('point1'),
document.getElementById('point2'),
document.getElementById('point3'),
document.getElementById('point4')
],
connectionGroup: document.getElementById('connectionGroup'),
harmonyBtns: document.querySelectorAll('.harmony-btn'),
colorCards: document.getElementById('colorCards'),
saturationSlider: document.getElementById('saturationSlider'),
lightnessSlider: document.getElementById('lightnessSlider'),
saturationValue: document.getElementById('saturationValue'),
lightnessValue: document.getElementById('lightnessValue'),
visionBtns: document.querySelectorAll('.vision-btn'),
previewStrip: document.getElementById('previewStrip'),
harmonyInfo: document.getElementById('harmonyInfo'),
toast: document.getElementById('toast'),
toastMessage: document.getElementById('toastMessage'),
exportBtn: document.getElementById('exportBtn')
};
// ===== Color Conversion Utilities =====
function hslToRgb(h, s, l) {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (0 <= h && h < 60) { r = c; g = x; b = 0; }
else if (60 <= h && h < 120) { r = x; g = c; b = 0; }
else if (120 <= h && h < 180) { r = 0; g = c; b = x; }
else if (180 <= h && h < 240) { r = 0; g = x; b = c; }
else if (240 <= h && h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255)
};
}
function rgbToHex(r, g, b) {
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('').toUpperCase();
}
function hslToHex(h, s, l) {
const rgb = hslToRgb(h, s, l);
return rgbToHex(rgb.r, rgb.g, rgb.b);
}
function getContrastColor(h, s, l) {
const rgb = hslToRgb(h, s, l);
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return luminance > 0.5 ? '#1a1a2e' : '#ffffff';
}
function normalizeHue(hue) {
return ((hue % 360) + 360) % 360;
}
// ===== Color Blindness Simulation =====
function simulateColorBlindness(r, g, b, type) {
const matrices = {
normal: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
protanopia: [[0.567, 0.433, 0], [0.558, 0.442, 0], [0, 0.242, 0.758]],
deuteranopia: [[0.625, 0.375, 0], [0.7, 0.3, 0], [0, 0.3, 0.7]],
tritanopia: [[0.95, 0.05, 0], [0, 0.433, 0.567], [0, 0.475, 0.525]]
};
const m = matrices[type] || matrices.normal;
return {
r: Math.round(m[0][0] * r + m[0][1] * g + m[0][2] * b),
g: Math.round(m[1][0] * r + m[1][1] * g + m[1][2] * b),
b: Math.round(m[2][0] * r + m[2][1] * g + m[2][2] * b)
};
}
// ===== Position Calculations =====
function getPointPosition(hue, radius = 160) {
const centerX = 210;
const centerY = 210;
const angle = (hue - 90) * (Math.PI / 180);
return {
x: centerX + radius * Math.cos(angle),
y: centerY + radius * Math.sin(angle)
};
}
function getHueFromPosition(x, y) {
const centerX = 210;
const centerY = 210;
const angle = Math.atan2(y - centerY, x - centerX);
let hue = angle * (180 / Math.PI) + 90;
return normalizeHue(hue);
}
// ===== Update Functions =====
function updateColors() {
const harmony = harmonies[state.harmony];
state.colors = harmony.angles.map((angle, index) => {
const hue = normalizeHue(state.primaryHue + angle);
const satAdjust = index === 0 ? 0 : (index % 2 === 0 ? -5 : 5);
const lightAdjust = index === 0 ? 0 : (index % 2 === 0 ? 5 : -5);
return {
hue,
saturation: Math.min(100, Math.max(20, state.saturation + satAdjust)),
lightness: Math.min(75, Math.max(25, state.lightness + lightAdjust)),
label: colorLabels[index]
};
});
}
function updatePoints() {
elements.points.forEach((point, index) => {
if (!state.colors[index]) return;
const color = state.colors[index];
const pos = getPointPosition(color.hue, index === 0 ? 165 : 155);
point.style.left = pos.x + 'px';
point.style.top = pos.y + 'px';
point.style.backgroundColor = hslToHex(color.hue, color.saturation, color.lightness);
});
}
function updateConnectionLines() {
const group = elements.connectionGroup;
group.innerHTML = '';
const positions = state.colors.map((color, index) =>
getPointPosition(color.hue, index === 0 ? 165 : 155)
);
// Connect all points to primary
for (let i = 1; i < positions.length; i++) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', positions[0].x);
line.setAttribute('y1', positions[0].y);
line.setAttribute('x2', positions[i].x);
line.setAttribute('y2', positions[i].y);
group.appendChild(line);
}
}
function updateColorCards() {
elements.colorCards.innerHTML = state.colors.map((color, index) => {
const hex = hslToHex(color.hue, color.saturation, color.lightness);
const rgb = hslToRgb(color.hue, color.saturation, color.lightness);
const contrastColor = getContrastColor(color.hue, color.saturation, color.lightness);
return `
<div class="color-card">
<div class="color-card-preview" style="background-color: ${hex}">
<span class="color-card-label" style="color: ${contrastColor === '#ffffff' ? '#333' : '#333'}">${color.label}</span>
</div>
<div class="color-card-info">
<div class="color-values">
<div class="color-value-row">
<span class="color-format">HEX</span>
<span class="color-code">${hex}</span>
<button class="copy-btn" data-copy="${hex}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
<div class="color-value-row">
<span class="color-format">RGB</span>
<span class="color-code">${rgb.r}, ${rgb.g}, ${rgb.b}</span>
<button class="copy-btn" data-copy="rgb(${rgb.r}, ${rgb.g}, ${rgb.b})">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
<div class="color-value-row">
<span class="color-format">HSL</span>
<span class="color-code">${Math.round(color.hue)}°, ${color.saturation}%, ${color.lightness}%</span>
<button class="copy-btn" data-copy="hsl(${Math.round(color.hue)}, ${color.saturation}%, ${color.lightness}%)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
</div>
</div>
</div>
`;
}).join('');
// Add copy event listeners
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const text = btn.dataset.copy;
navigator.clipboard.writeText(text).then(() => {
showToast(`已复制: ${text}`);
});
});
});
}
function updatePreviewStrip() {
elements.previewStrip.innerHTML = state.colors.map(color => {
const rgb = hslToRgb(color.hue, color.saturation, color.lightness);
let displayRgb = rgb;
if (state.visionMode !== 'normal') {
displayRgb = simulateColorBlindness(rgb.r, rgb.g, rgb.b, state.visionMode);
}
return `<div class="preview-strip-color" style="background-color: rgb(${displayRgb.r}, ${displayRgb.g}, ${displayRgb.b})"></div>`;
}).join('');
}
function updateHarmonyInfo() {
const harmony = harmonies[state.harmony];
elements.harmonyInfo.innerHTML = `
<h3>${harmony.name}</h3>
<p>${harmony.description}</p>
`;
}
function updateSliderBackgrounds() {
const hue = state.primaryHue;
elements.saturationSlider.style.background =
`linear-gradient(to right, hsl(${hue}, 20%, ${state.lightness}%), hsl(${hue}, 100%, ${state.lightness}%))`;
elements.lightnessSlider.style.background =
`linear-gradient(to right, hsl(${hue}, ${state.saturation}%, 20%), hsl(${hue}, ${state.saturation}%, 50%), hsl(${hue}, ${state.saturation}%, 80%))`;
}
function updateAll() {
updateColors();
updatePoints();
updateConnectionLines();
updateColorCards();
updatePreviewStrip();
updateSliderBackgrounds();
}
// ===== Toast Notification =====
function showToast(message) {
elements.toastMessage.textContent = message;
elements.toast.classList.add('show');
setTimeout(() => {
elements.toast.classList.remove('show');
}, 2000);
}
// ===== Drag Handling =====
let isDragging = false;
let dragTarget = null;
function handleDragStart(e) {
if (e.target.classList.contains('color-point')) {
isDragging = true;
dragTarget = e.target;
dragTarget.classList.add('dragging');
e.preventDefault();
}
}
function handleDragMove(e) {
if (!isDragging || !dragTarget) return;
const rect = elements.wheelContainer.getBoundingClientRect();
const x = (e.clientX || e.touches[0].clientX) - rect.left;
const y = (e.clientY || e.touches[0].clientY) - rect.top;
// Calculate distance from center
const centerX = 210;
const centerY = 210;
const distance = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2));
// Only update if within wheel bounds
if (distance > 50 && distance < 200) {
state.primaryHue = getHueFromPosition(x, y);
updateAll();
}
}
function handleDragEnd() {
if (dragTarget) {
dragTarget.classList.remove('dragging');
}
isDragging = false;
dragTarget = null;
}
// ===== Event Listeners =====
// Drag events
elements.wheelContainer.addEventListener('mousedown', handleDragStart);
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
// Touch events
elements.wheelContainer.addEventListener('touchstart', handleDragStart, { passive: false });
document.addEventListener('touchmove', handleDragMove, { passive: false });
document.addEventListener('touchend', handleDragEnd);
// Click on wheel
elements.wheel.addEventListener('click', (e) => {
const rect = elements.wheelContainer.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = 210;
const centerY = 210;
const distance = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2));
if (distance > 50 && distance < 200) {
state.primaryHue = getHueFromPosition(x, y);
elements.primaryPoint.classList.add('pulse');
setTimeout(() => elements.primaryPoint.classList.remove('pulse'), 500);
updateAll();
}
});
// Harmony buttons
elements.harmonyBtns.forEach(btn => {
btn.addEventListener('click', () => {
elements.harmonyBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.harmony = btn.dataset.harmony;
updateHarmonyInfo();
updateAll();
});
});
// Sliders
elements.saturationSlider.addEventListener('input', (e) => {
state.saturation = parseInt(e.target.value);
elements.saturationValue.textContent = state.saturation + '%';
updateAll();
});
elements.lightnessSlider.addEventListener('input', (e) => {
state.lightness = parseInt(e.target.value);
elements.lightnessValue.textContent = state.lightness + '%';
updateAll();
});
// Vision mode buttons
elements.visionBtns.forEach(btn => {
btn.addEventListener('click', () => {
elements.visionBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.visionMode = btn.dataset.mode;
updatePreviewStrip();
});
});
// Export button
elements.exportBtn.addEventListener('click', () => {
const exportData = {
harmony: state.harmony,
colors: state.colors.map(color => ({
label: color.label,
hex: hslToHex(color.hue, color.saturation, color.lightness),
rgb: hslToRgb(color.hue, color.saturation, color.lightness),
hsl: { h: Math.round(color.hue), s: color.saturation, l: color.lightness }
}))
};
// Create CSS variables string
let cssVars = ':root {\n';
exportData.colors.forEach((color, i) => {
cssVars += ` --color-${i + 1}: ${color.hex};\n`;
});
cssVars += '}';
// Create SCSS variables
let scssVars = '';
exportData.colors.forEach((color, i) => {
scssVars += `$color-${color.label.toLowerCase().replace(' ', '-')}: ${color.hex};\n`;
});
// Copy CSS to clipboard
navigator.clipboard.writeText(cssVars + '\n\n' + scssVars).then(() => {
showToast('配色方案已导出为 CSS/SCSS 变量!');
});
});
// ===== Keyboard Support =====
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
state.primaryHue = normalizeHue(state.primaryHue - 5);
updateAll();
} else if (e.key === 'ArrowRight') {
state.primaryHue = normalizeHue(state.primaryHue + 5);
updateAll();
}
});
// ===== Initialize =====
function init() {
state.primaryHue = 220; // Start with a nice blue
updateHarmonyInfo();
updateAll();
}
init();
</script>
</body>
</html>
```
## ✨ 功能亮点
### 🎨 五种色彩和谐规则
| 规则 | 描述 |
|------|------|
| **互补色** | 180° 对角,最强对比 |
| **三元色** | 120° 等分,丰富平衡 |
| **类比色** | 30° 邻近,和谐舒适 |
| **分裂互补** | 150°/210°,柔和对比 |
| **四元色** | 90° 矩形,复杂多彩 |
### 🔧 核心功能
1. **交互式色轮** - 拖动或点击选取主色,关联色自动跟随
2. **实时参数调整** - 饱和度/明度滑块精细控制
3. **一键复制** - 支持 HEX/RGB/HSL 三种格式
4. **视觉无障碍测试** - 红绿蓝三种色盲模拟
5. **一键导出** - 生成 CSS/SCSS 变量代码
### 🎯 设计特色
- 纯白极简背景,让色彩成为主角
- 精细的阴影和圆角,营造高级质感
- 流畅的动画过渡效果
- 完整的响应式适配