Created
March 7, 2025 00:32
-
-
Save goldengrape/ad4a488f613f9fa43ecae8fbdd980871 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="zh"> | |
<head> | |
<meta charset="UTF-8"> | |
<title>色彩敏感度测试</title> | |
<style> | |
:root { | |
/* macOS 配色 */ | |
--mac-background: #f5f5f7; | |
--mac-panel: #ffffff; | |
--mac-text: #1d1d1f; | |
--mac-secondary-text: #86868b; | |
--mac-border: #d2d2d7; | |
--mac-accent: #0071e3; | |
--mac-accent-hover: #0077ed; | |
--mac-button-text: #ffffff; | |
--mac-shadow: rgba(0, 0, 0, 0.1); | |
} | |
body { | |
margin: 0; | |
display: flex; | |
flex-direction: column; | |
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; | |
background-color: var(--mac-background); | |
color: var(--mac-text); | |
height: 100vh; | |
} | |
/* 顶部控制面板 */ | |
#controls { | |
width: 100%; | |
background: var(--mac-panel); | |
padding: 16px 20px; | |
box-sizing: border-box; | |
border-bottom: 1px solid var(--mac-border); | |
box-shadow: 0 1px 5px var(--mac-shadow); | |
display: flex; | |
flex-wrap: wrap; | |
align-items: center; | |
gap: 16px; | |
z-index: 10; | |
} | |
#controls label { | |
display: flex; | |
align-items: center; | |
font-size: 14px; | |
color: var(--mac-text); | |
} | |
#controls input[type="color"] { | |
-webkit-appearance: none; | |
width: 28px; | |
height: 28px; | |
border: none; | |
border-radius: 4px; | |
margin-left: 8px; | |
padding: 0; | |
cursor: pointer; | |
} | |
#controls input[type="color"]::-webkit-color-swatch-wrapper { | |
padding: 0; | |
border-radius: 4px; | |
border: 1px solid var(--mac-border); | |
} | |
#controls input[type="color"]::-webkit-color-swatch { | |
border: none; | |
border-radius: 3px; | |
} | |
#controls input[type="number"] { | |
border: 1px solid var(--mac-border); | |
border-radius: 6px; | |
padding: 4px 8px; | |
margin-left: 8px; | |
width: 60px; | |
font-size: 14px; | |
outline: none; | |
transition: border-color 0.2s; | |
} | |
#controls input[type="number"]:focus { | |
border-color: var(--mac-accent); | |
box-shadow: 0 0 0 2px rgba(0, 113, 227, 0.2); | |
} | |
/* macOS 风格按钮 */ | |
#controls button { | |
background-color: var(--mac-accent); | |
color: var(--mac-button-text); | |
border: none; | |
border-radius: 6px; | |
padding: 6px 12px; | |
font-size: 14px; | |
font-weight: 500; | |
cursor: pointer; | |
transition: background-color 0.2s; | |
margin-left: 5px; | |
} | |
#controls button:hover { | |
background-color: var(--mac-accent-hover); | |
} | |
#controls button:active { | |
transform: scale(0.98); | |
} | |
/* 分组控件 */ | |
.control-group { | |
display: flex; | |
gap: 16px; | |
align-items: center; | |
flex-wrap: wrap; | |
} | |
.spacer { | |
flex: 1; | |
} | |
/* 画布区域 */ | |
#canvasContainer { | |
flex: 1; | |
position: relative; | |
background-color: var(--mac-panel); | |
margin: 16px; | |
border-radius: 8px; | |
box-shadow: 0 2px 10px var(--mac-shadow); | |
overflow: hidden; | |
} | |
canvas { | |
display: block; | |
} | |
/* 底部状态栏 */ | |
#statusBar { | |
background-color: var(--mac-panel); | |
border-top: 1px solid var(--mac-border); | |
padding: 8px 16px; | |
font-size: 12px; | |
color: var(--mac-secondary-text); | |
display: flex; | |
justify-content: space-between; | |
} | |
/* 响应式设计调整 */ | |
@media (max-width: 768px) { | |
#controls { | |
padding: 12px; | |
gap: 12px; | |
} | |
.control-group { | |
width: 100%; | |
justify-content: space-between; | |
} | |
#canvasContainer { | |
margin: 10px; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div id="controls"> | |
<div class="control-group"> | |
<label>前景颜色A | |
<input type="color" id="colorA" value="#ff0000"> | |
</label> | |
<label>前景颜色B | |
<input type="color" id="colorB" value="#00ff00"> | |
</label> | |
</div> | |
<div class="control-group"> | |
<label>空间周期 | |
<input type="number" id="period" value="20" min="1"> | |
</label> | |
<label>初速度 | |
<input type="number" id="speed" value="200" min="0"> | |
</label> | |
</div> | |
<div class="spacer"></div> | |
<div class="control-group"> | |
<button id="pre">增大差异</button> | |
<button id="next">减小差异</button> | |
</div> | |
</div> | |
<div id="canvasContainer"> | |
<canvas id="mainCanvas"></canvas> | |
</div> | |
<div id="statusBar"> | |
<span id="currentPosition">位置: 左侧</span> | |
<span id="colorDistance">颜色差异: 计算中...</span> | |
</div> | |
<script> | |
// 获取画布与上下文 | |
const canvas = document.getElementById("mainCanvas"); | |
const ctx = canvas.getContext("2d"); | |
// 状态栏元素 | |
const positionStatus = document.getElementById("currentPosition"); | |
const colorDistanceStatus = document.getElementById("colorDistance"); | |
// 根据顶部控制面板调整画布尺寸 | |
function resizeCanvas() { | |
const controlsHeight = document.getElementById("controls").offsetHeight; | |
const statusBarHeight = document.getElementById("statusBar").offsetHeight; | |
canvas.width = window.innerWidth; | |
canvas.height = window.innerHeight - controlsHeight - statusBarHeight - 32; // 考虑边距 | |
} | |
window.addEventListener("resize", resizeCanvas); | |
resizeCanvas(); | |
// 控件引用 | |
const colorAInput = document.getElementById("colorA"); | |
const colorBInput = document.getElementById("colorB"); | |
const periodInput = document.getElementById("period"); | |
const speedInput = document.getElementById("speed"); | |
const preButton = document.getElementById("pre"); | |
const nextButton = document.getElementById("next"); | |
// 小球参数 | |
const ballDiameter = 100; | |
const ballRadius = ballDiameter / 2; | |
let ballX = canvas.width / 2, ballY = canvas.height / 2; | |
let ballVX = 0, ballVY = 0; | |
// 记录前景小球所在区域:"left" 或 "right" | |
let selectedBlock = 'left'; | |
// 离屏画布生成前景图案 | |
const ballCanvas = document.createElement("canvas"); | |
ballCanvas.width = ballDiameter; | |
ballCanvas.height = ballDiameter; | |
const ballCtx = ballCanvas.getContext("2d"); | |
// 前景条纹参数 | |
let stripeAngle = Math.random() * Math.PI * 2; // 随机条纹方向 | |
let spatialPeriod = parseFloat(periodInput.value); | |
// ----------------------- | |
// 颜色转换及 CIE Lab 相关函数 | |
// ----------------------- | |
function hexToRgb(hex) { | |
hex = hex.replace("#", ""); | |
if (hex.length === 3) { | |
hex = hex.split("").map(c => c + c).join(""); | |
} | |
return { | |
r: parseInt(hex.substring(0, 2), 16), | |
g: parseInt(hex.substring(2, 4), 16), | |
b: parseInt(hex.substring(4, 6), 16) | |
}; | |
} | |
function pivotRgb(n) { | |
n = n / 255; | |
return n <= 0.04045 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); | |
} | |
function rgbToXyz({ r, g, b }) { | |
let R = pivotRgb(r), G = pivotRgb(g), B = pivotRgb(b); | |
return { | |
x: R * 0.4124 + G * 0.3576 + B * 0.1805, | |
y: R * 0.2126 + G * 0.7152 + B * 0.0722, | |
z: R * 0.0193 + G * 0.1192 + B * 0.9505 | |
}; | |
} | |
function pivotXyz(n) { | |
return n > 0.008856 ? Math.pow(n, 1/3) : (7.787 * n) + (16/116); | |
} | |
function rgbToLab(rgb) { | |
const xyz = rgbToXyz(rgb); | |
// D65 参考白点 | |
const refX = 0.95047, refY = 1.00000, refZ = 1.08883; | |
let x = xyz.x / refX; | |
let y = xyz.y / refY; | |
let z = xyz.z / refZ; | |
x = pivotXyz(x); | |
y = pivotXyz(y); | |
z = pivotXyz(z); | |
return { | |
L: (116 * y) - 16, | |
a: 500 * (x - y), | |
b: 200 * (y - z) | |
}; | |
} | |
function inversePivot(n) { | |
const n3 = n * n * n; | |
return n3 > 0.008856 ? n3 : (n - 16/116) / 7.787; | |
} | |
function labToXyz({ L, a, b }) { | |
let y = (L + 16) / 116; | |
let x = a / 500 + y; | |
let z = y - b / 200; | |
const refX = 0.95047, refY = 1.00000, refZ = 1.08883; | |
return { | |
x: refX * inversePivot(x), | |
y: refY * inversePivot(y), | |
z: refZ * inversePivot(z) | |
}; | |
} | |
function xyzToRgb(xyz) { | |
const X = xyz.x, Y = xyz.y, Z = xyz.z; | |
let R = X * 3.2406 + Y * -1.5372 + Z * -0.4986; | |
let G = X * -0.9689 + Y * 1.8758 + Z * 0.0415; | |
let B = X * 0.0557 + Y * -0.2040 + Z * 1.0570; | |
function pivot(n) { | |
return n <= 0.0031308 ? 12.92 * n : 1.055 * Math.pow(n, 1/2.4) - 0.055; | |
} | |
R = pivot(R); G = pivot(G); B = pivot(B); | |
return { | |
r: Math.min(255, Math.max(0, Math.round(R * 255))), | |
g: Math.min(255, Math.max(0, Math.round(G * 255))), | |
b: Math.min(255, Math.max(0, Math.round(B * 255))) | |
}; | |
} | |
function labToRgb(lab) { | |
return xyzToRgb(labToXyz(lab)); | |
} | |
function rgbToHex({ r, g, b }) { | |
return "#" + [r, g, b].map(x => { | |
let hex = x.toString(16); | |
return hex.length === 1 ? "0" + hex : hex; | |
}).join(""); | |
} | |
function interpolateLab(lab1, lab2, t) { | |
return { | |
L: lab1.L + (lab2.L - lab1.L) * t, | |
a: lab1.a + (lab2.a - lab1.a) * t, | |
b: lab1.b + (lab2.b - lab1.b) * t | |
}; | |
} | |
// 计算Lab空间中两点的距离 | |
function labDistance(lab1, lab2) { | |
const dL = lab1.L - lab2.L; | |
const da = lab1.a - lab2.a; | |
const db = lab1.b - lab2.b; | |
return Math.sqrt(dL*dL + da*da + db*db); | |
} | |
// ----------------------- | |
// 全局颜色参数(从输入控件初始值获得) | |
// ----------------------- | |
let labA = rgbToLab(hexToRgb(colorAInput.value)); | |
let labB = rgbToLab(hexToRgb(colorBInput.value)); | |
let backgroundColor = "#ffffff"; | |
// 根据 A、B 在 CIE Lab 空间中的均值计算背景色 | |
function updateBackground() { | |
labA = rgbToLab(hexToRgb(colorAInput.value)); | |
labB = rgbToLab(hexToRgb(colorBInput.value)); | |
const labAvg = { | |
L: (labA.L + labB.L) / 2, | |
a: (labA.a + labB.a) / 2, | |
b: (labA.b + labB.b) / 2 | |
}; | |
const rgbAvg = labToRgb(labAvg); | |
backgroundColor = rgbToHex(rgbAvg); | |
// 更新状态栏中的颜色差异 | |
colorDistanceStatus.textContent = `颜色差异: ${labDistance(labA, labB).toFixed(2)}`; | |
} | |
// ----------------------- | |
// 生成前景图案:在直径100像素的圆内绘制正弦条纹, | |
// 边缘用二维正态分布渐变衰减(σ = ballRadius/3) | |
// ----------------------- | |
function generateBallPattern() { | |
spatialPeriod = parseFloat(periodInput.value); | |
ballCtx.clearRect(0, 0, ballDiameter, ballDiameter); | |
const imageData = ballCtx.createImageData(ballDiameter, ballDiameter); | |
const data = imageData.data; | |
const sigma = ballRadius / 3; | |
for (let y = 0; y < ballDiameter; y++) { | |
for (let x = 0; x < ballDiameter; x++) { | |
const dx = x - ballRadius; | |
const dy = y - ballRadius; | |
const distance = Math.sqrt(dx * dx + dy * dy); | |
const idx = (y * ballDiameter + x) * 4; | |
if (distance <= ballRadius) { | |
const amp = Math.exp(-((dx * dx + dy * dy) / (2 * sigma * sigma))); | |
const projection = dx * Math.cos(stripeAngle) + dy * Math.sin(stripeAngle); | |
// 中心振幅1,边缘衰减(t趋向于0.5表示不偏向A或B) | |
const t = 0.5 + (Math.sin(2 * Math.PI * projection / spatialPeriod) * amp) / 2; | |
const labColor = interpolateLab(labA, labB, t); | |
const rgb = labToRgb(labColor); | |
data[idx] = rgb.r; | |
data[idx + 1] = rgb.g; | |
data[idx + 2] = rgb.b; | |
data[idx + 3] = 255; | |
} else { | |
data[idx + 3] = 0; | |
} | |
} | |
} | |
ballCtx.putImageData(imageData, 0, 0); | |
} | |
// ----------------------- | |
// 动画及边界碰撞处理 | |
// ----------------------- | |
let lastTime = null; | |
function animate(time) { | |
if (!lastTime) lastTime = time; | |
const delta = (time - lastTime) / 1000; | |
lastTime = time; | |
// 更新小球位置 | |
ballX += ballVX * delta; | |
ballY += ballVY * delta; | |
// 根据当前所在区域判断左右边界 | |
if (selectedBlock === 'left') { | |
if (ballX - ballRadius < 0) { | |
ballX = ballRadius; | |
ballVX = -ballVX; | |
} else if (ballX + ballRadius > canvas.width / 2) { | |
ballX = canvas.width / 2 - ballRadius; | |
ballVX = -ballVX; | |
} | |
} else { | |
if (ballX - ballRadius < canvas.width / 2) { | |
ballX = canvas.width / 2 + ballRadius; | |
ballVX = -ballVX; | |
} else if (ballX + ballRadius > canvas.width) { | |
ballX = canvas.width - ballRadius; | |
ballVX = -ballVX; | |
} | |
} | |
if (ballY - ballRadius < 0) { | |
ballY = ballRadius; | |
ballVY = -ballVY; | |
} else if (ballY + ballRadius > canvas.height) { | |
ballY = canvas.height - ballRadius; | |
ballVY = -ballVY; | |
} | |
// 绘制背景:左右两块分别填充背景色,带圆角和阴影效果 | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
// 左侧区域 | |
ctx.fillStyle = backgroundColor; | |
ctx.beginPath(); | |
ctx.moveTo(canvas.width / 2, 0); | |
ctx.lineTo(10, 0); | |
ctx.arcTo(0, 0, 0, 10, 10); | |
ctx.lineTo(0, canvas.height - 10); | |
ctx.arcTo(0, canvas.height, 10, canvas.height, 10); | |
ctx.lineTo(canvas.width / 2, canvas.height); | |
ctx.closePath(); | |
ctx.fill(); | |
// 右侧区域 | |
ctx.beginPath(); | |
ctx.moveTo(canvas.width / 2, 0); | |
ctx.lineTo(canvas.width - 10, 0); | |
ctx.arcTo(canvas.width, 0, canvas.width, 10, 10); | |
ctx.lineTo(canvas.width, canvas.height - 10); | |
ctx.arcTo(canvas.width, canvas.height, canvas.width - 10, canvas.height, 10); | |
ctx.lineTo(canvas.width / 2, canvas.height); | |
ctx.closePath(); | |
ctx.fill(); | |
// 绘制中间分隔线 | |
ctx.strokeStyle = "rgba(0, 0, 0, 1)"; | |
ctx.lineWidth = 3; | |
ctx.setLineDash([5, 3]); | |
ctx.beginPath(); | |
ctx.moveTo(canvas.width / 2, 0); | |
ctx.lineTo(canvas.width / 2, canvas.height); | |
ctx.stroke(); | |
ctx.setLineDash([]); | |
// 绘制小球(带阴影效果) | |
ctx.save(); | |
ctx.drawImage(ballCanvas, ballX - ballRadius, ballY - ballRadius); | |
ctx.restore(); | |
requestAnimationFrame(animate); | |
} | |
// ----------------------- | |
// 辅助函数:随机选择小球所在区域,并重置位置和运动状态 | |
// ----------------------- | |
function randomizeBallPosition() { | |
selectedBlock = (Math.random() < 0.5) ? 'left' : 'right'; | |
if (selectedBlock === 'left') { | |
ballX = canvas.width / 4; | |
positionStatus.textContent = `位置: 左侧`; | |
} else { | |
ballX = canvas.width * 3 / 4; | |
positionStatus.textContent = `位置: 右侧`; | |
} | |
ballY = canvas.height / 2; | |
const speed = parseFloat(speedInput.value); | |
const angle = Math.random() * Math.PI * 2; | |
ballVX = speed * Math.cos(angle); | |
ballVY = speed * Math.sin(angle); | |
} | |
// ----------------------- | |
// Next按钮:前景颜色向中点靠近(距离缩为原来的2/3),并随机选择前景区域 | |
// ----------------------- | |
function nextColor() { | |
const mid = { | |
L: (labA.L + labB.L) / 2, | |
a: (labA.a + labB.a) / 2, | |
b: (labA.b + labB.b) / 2 | |
}; | |
labA = { | |
L: mid.L + (labA.L - mid.L) * (2/3), | |
a: mid.a + (labA.a - mid.a) * (2/3), | |
b: mid.b + (labA.b - mid.b) * (2/3) | |
}; | |
labB = { | |
L: mid.L + (labB.L - mid.L) * (2/3), | |
a: mid.a + (labB.a - mid.a) * (2/3), | |
b: mid.b + (labB.b - mid.b) * (2/3) | |
}; | |
colorAInput.value = rgbToHex(labToRgb(labA)); | |
colorBInput.value = rgbToHex(labToRgb(labB)); | |
updateBackground(); | |
generateBallPattern(); | |
randomizeBallPosition(); | |
document.body.style.transition = "background-color 0.3s"; | |
document.body.style.backgroundColor = "rgba(0, 113, 227, 0.05)"; | |
setTimeout(() => { | |
document.body.style.backgroundColor = ""; | |
}, 300); | |
} | |
// ----------------------- | |
// Pre按钮:前景颜色向中点远离(每次距离增加1个单位),并随机选择前景区域 | |
// ----------------------- | |
function preColor() { | |
const mid = { | |
L: (labA.L + labB.L) / 2, | |
a: (labA.a + labB.a) / 2, | |
b: (labA.b + labB.b) / 2 | |
}; | |
let dA = { | |
L: labA.L - mid.L, | |
a: labA.a - mid.a, | |
b: labA.b - mid.b | |
}; | |
let distA = Math.sqrt(dA.L*dA.L + dA.a*dA.a + dA.b*dA.b); | |
if (distA > 0) { | |
let newDistA = distA + 1; | |
labA = { | |
L: mid.L + dA.L / distA * newDistA, | |
a: mid.a + dA.a / distA * newDistA, | |
b: mid.b + dA.b / distA * newDistA | |
}; | |
} | |
let dB = { | |
L: labB.L - mid.L, | |
a: labB.a - mid.a, | |
b: labB.b - mid.b | |
}; | |
let distB = Math.sqrt(dB.L*dB.L + dB.a*dB.a + dB.b*dB.b); | |
if (distB > 0) { | |
let newDistB = distB + 1; | |
labB = { | |
L: mid.L + dB.L / distB * newDistB, | |
a: mid.a + dB.a / distB * newDistB, | |
b: mid.b + dB.b / distB * newDistB | |
}; | |
} | |
colorAInput.value = rgbToHex(labToRgb(labA)); | |
colorBInput.value = rgbToHex(labToRgb(labB)); | |
updateBackground(); | |
generateBallPattern(); | |
randomizeBallPosition(); | |
document.body.style.transition = "background-color 0.3s"; | |
document.body.style.backgroundColor = "rgba(0, 113, 227, 0.05)"; | |
setTimeout(() => { | |
document.body.style.backgroundColor = ""; | |
}, 300); | |
} | |
// 控件事件监听 | |
colorAInput.addEventListener("change", () => { updateBackground(); generateBallPattern(); }); | |
colorBInput.addEventListener("change", () => { updateBackground(); generateBallPattern(); }); | |
periodInput.addEventListener("change", generateBallPattern); | |
speedInput.addEventListener("change", () => { | |
const speed = parseFloat(speedInput.value); | |
const currentAngle = Math.atan2(ballVY, ballVX); | |
ballVX = speed * Math.cos(currentAngle); | |
ballVY = speed * Math.sin(currentAngle); | |
// 当初速度变化时,立即更新前景图案 | |
generateBallPattern(); | |
}); | |
nextButton.addEventListener("click", nextColor); | |
preButton.addEventListener("click", preColor); | |
// 初始调用 | |
updateBackground(); | |
generateBallPattern(); | |
randomizeBallPosition(); | |
requestAnimationFrame(animate); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment