Skip to content

Instantly share code, notes, and snippets.

@goldengrape
Created March 7, 2025 00:32
Show Gist options
  • Save goldengrape/ad4a488f613f9fa43ecae8fbdd980871 to your computer and use it in GitHub Desktop.
Save goldengrape/ad4a488f613f9fa43ecae8fbdd980871 to your computer and use it in GitHub Desktop.
<!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