Spaces:
Running
Running
Deploy color palette checker app
Browse files- app.js +137 -0
- index.html +42 -17
- style.css +128 -16
app.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const softAutumn = [
|
| 2 |
+
{ name: "Moss", hex: "#7A8450" },
|
| 3 |
+
{ name: "Olive", hex: "#6F6B3C" },
|
| 4 |
+
{ name: "Sage", hex: "#8A9273" },
|
| 5 |
+
{ name: "Camel", hex: "#B08A63" },
|
| 6 |
+
{ name: "Terracotta", hex: "#B66545" },
|
| 7 |
+
{ name: "Rust", hex: "#9A4F2F" },
|
| 8 |
+
{ name: "Peach", hex: "#D7A47E" },
|
| 9 |
+
{ name: "Dusty Coral", hex: "#C9826D" },
|
| 10 |
+
{ name: "Warm Taupe", hex: "#8E7360" },
|
| 11 |
+
{ name: "Mushroom", hex: "#A38B78" },
|
| 12 |
+
{ name: "Warm Navy", hex: "#3E4D58" },
|
| 13 |
+
{ name: "Soft Teal", hex: "#5F7E78" }
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
+
const video = document.getElementById("video");
|
| 17 |
+
const startBtn = document.getElementById("startBtn");
|
| 18 |
+
const scanBtn = document.getElementById("scanBtn");
|
| 19 |
+
const statusEl = document.getElementById("status");
|
| 20 |
+
const sampleSwatch = document.getElementById("sampleSwatch");
|
| 21 |
+
const sampleHexEl = document.getElementById("sampleHex");
|
| 22 |
+
const paletteGrid = document.getElementById("paletteGrid");
|
| 23 |
+
const captureCanvas = document.getElementById("captureCanvas");
|
| 24 |
+
const ctx = captureCanvas.getContext("2d", { willReadFrequently: true });
|
| 25 |
+
|
| 26 |
+
let stream;
|
| 27 |
+
|
| 28 |
+
function hexToRgb(hex) {
|
| 29 |
+
const clean = hex.replace("#", "");
|
| 30 |
+
return {
|
| 31 |
+
r: parseInt(clean.slice(0, 2), 16),
|
| 32 |
+
g: parseInt(clean.slice(2, 4), 16),
|
| 33 |
+
b: parseInt(clean.slice(4, 6), 16)
|
| 34 |
+
};
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function rgbToHex({ r, g, b }) {
|
| 38 |
+
return (
|
| 39 |
+
"#" +
|
| 40 |
+
[r, g, b]
|
| 41 |
+
.map((value) => value.toString(16).padStart(2, "0"))
|
| 42 |
+
.join("")
|
| 43 |
+
.toUpperCase()
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function colorDistance(a, b) {
|
| 48 |
+
return Math.sqrt(
|
| 49 |
+
2 * (a.r - b.r) ** 2 +
|
| 50 |
+
4 * (a.g - b.g) ** 2 +
|
| 51 |
+
3 * (a.b - b.b) ** 2
|
| 52 |
+
);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function averageCenterColor() {
|
| 56 |
+
const w = captureCanvas.width;
|
| 57 |
+
const h = captureCanvas.height;
|
| 58 |
+
ctx.drawImage(video, 0, 0, w, h);
|
| 59 |
+
|
| 60 |
+
const data = ctx.getImageData(0, 0, w, h).data;
|
| 61 |
+
let r = 0;
|
| 62 |
+
let g = 0;
|
| 63 |
+
let b = 0;
|
| 64 |
+
let n = 0;
|
| 65 |
+
|
| 66 |
+
for (let y = 10; y < 30; y++) {
|
| 67 |
+
for (let x = 10; x < 30; x++) {
|
| 68 |
+
const i = (y * w + x) * 4;
|
| 69 |
+
r += data[i];
|
| 70 |
+
g += data[i + 1];
|
| 71 |
+
b += data[i + 2];
|
| 72 |
+
n += 1;
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
r: Math.round(r / n),
|
| 78 |
+
g: Math.round(g / n),
|
| 79 |
+
b: Math.round(b / n)
|
| 80 |
+
};
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function renderPalette() {
|
| 84 |
+
paletteGrid.innerHTML = "";
|
| 85 |
+
|
| 86 |
+
for (const color of softAutumn) {
|
| 87 |
+
const chip = document.createElement("div");
|
| 88 |
+
chip.className = "chip";
|
| 89 |
+
chip.innerHTML = `<div class="chip-color" style="background:${color.hex}"></div>${color.name}`;
|
| 90 |
+
paletteGrid.appendChild(chip);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
startBtn.addEventListener("click", async () => {
|
| 95 |
+
try {
|
| 96 |
+
stream = await navigator.mediaDevices.getUserMedia({
|
| 97 |
+
video: { facingMode: { ideal: "environment" } },
|
| 98 |
+
audio: false
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
video.srcObject = stream;
|
| 102 |
+
scanBtn.disabled = false;
|
| 103 |
+
statusEl.textContent = "Status: camera ready.";
|
| 104 |
+
} catch (error) {
|
| 105 |
+
statusEl.textContent = `Status: camera error: ${error.message}`;
|
| 106 |
+
}
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
scanBtn.addEventListener("click", () => {
|
| 110 |
+
if (!video.videoWidth) {
|
| 111 |
+
statusEl.textContent = "Status: camera is not ready yet.";
|
| 112 |
+
return;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const sample = averageCenterColor();
|
| 116 |
+
const sampleHex = rgbToHex(sample);
|
| 117 |
+
let closest;
|
| 118 |
+
|
| 119 |
+
for (const paletteColor of softAutumn) {
|
| 120 |
+
const distance = colorDistance(sample, hexToRgb(paletteColor.hex));
|
| 121 |
+
if (!closest || distance < closest.distance) {
|
| 122 |
+
closest = { ...paletteColor, distance };
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
const threshold = 85;
|
| 127 |
+
const isMatch = closest.distance <= threshold;
|
| 128 |
+
|
| 129 |
+
sampleSwatch.style.background = sampleHex;
|
| 130 |
+
sampleHexEl.textContent = sampleHex;
|
| 131 |
+
|
| 132 |
+
statusEl.innerHTML = isMatch
|
| 133 |
+
? `<span class="ok">Status: Match YES.</span> Closest: ${closest.name} ${closest.hex} (distance ${closest.distance.toFixed(1)})`
|
| 134 |
+
: `<span class="bad">Status: Match NO.</span> Closest: ${closest.name} ${closest.hex} (distance ${closest.distance.toFixed(1)})`;
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
renderPalette();
|
index.html
CHANGED
|
@@ -1,19 +1,44 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
| 6 |
+
<title>Soft Autumn Palette Checker</title>
|
| 7 |
+
<link rel="stylesheet" href="style.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<main class="app">
|
| 11 |
+
<header>
|
| 12 |
+
<h1>Soft Autumn Palette Checker</h1>
|
| 13 |
+
<p>Point your camera at clothing or fabric and scan the center color.</p>
|
| 14 |
+
</header>
|
| 15 |
+
|
| 16 |
+
<section class="camera-wrap">
|
| 17 |
+
<video id="video" autoplay playsinline muted></video>
|
| 18 |
+
<div class="reticle" aria-hidden="true"></div>
|
| 19 |
+
</section>
|
| 20 |
+
|
| 21 |
+
<section class="controls">
|
| 22 |
+
<button id="startBtn" type="button">Start Camera</button>
|
| 23 |
+
<button id="scanBtn" type="button" disabled>Scan Center Color</button>
|
| 24 |
+
</section>
|
| 25 |
+
|
| 26 |
+
<section class="panel">
|
| 27 |
+
<p id="status">Status: camera not started.</p>
|
| 28 |
+
<div class="sample-row">
|
| 29 |
+
<span>Sample</span>
|
| 30 |
+
<div id="sampleSwatch" class="swatch"></div>
|
| 31 |
+
<code id="sampleHex">#------</code>
|
| 32 |
+
</div>
|
| 33 |
+
</section>
|
| 34 |
+
|
| 35 |
+
<section class="panel">
|
| 36 |
+
<h2>Soft Autumn Palette</h2>
|
| 37 |
+
<div id="paletteGrid" class="palette-grid"></div>
|
| 38 |
+
</section>
|
| 39 |
+
</main>
|
| 40 |
+
|
| 41 |
+
<canvas id="captureCanvas" width="40" height="40" hidden></canvas>
|
| 42 |
+
<script src="app.js"></script>
|
| 43 |
+
</body>
|
| 44 |
</html>
|
style.css
CHANGED
|
@@ -1,28 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
body {
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
}
|
| 5 |
|
| 6 |
h1 {
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
p {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
-
.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
padding: 16px;
|
| 22 |
-
border: 1px solid lightgray;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
}
|
| 25 |
|
| 26 |
-
.
|
| 27 |
-
|
|
|
|
| 28 |
}
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #f7f3ec;
|
| 3 |
+
--ink: #2f2a24;
|
| 4 |
+
--panel: #fffaf2;
|
| 5 |
+
--accent: #7a5b3d;
|
| 6 |
+
--ok: #1f7a43;
|
| 7 |
+
--bad: #a1352f;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
* {
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
body {
|
| 15 |
+
margin: 0;
|
| 16 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 17 |
+
color: var(--ink);
|
| 18 |
+
background: radial-gradient(circle at top, #fffdf8, var(--bg));
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.app {
|
| 22 |
+
max-width: 760px;
|
| 23 |
+
margin: 0 auto;
|
| 24 |
+
padding: 16px;
|
| 25 |
}
|
| 26 |
|
| 27 |
h1 {
|
| 28 |
+
margin: 0 0 6px;
|
| 29 |
+
font-size: 1.35rem;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
h2 {
|
| 33 |
+
margin: 0 0 10px;
|
| 34 |
+
font-size: 1rem;
|
| 35 |
}
|
| 36 |
|
| 37 |
p {
|
| 38 |
+
margin: 0 0 14px;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.camera-wrap {
|
| 42 |
+
position: relative;
|
| 43 |
+
border-radius: 14px;
|
| 44 |
+
overflow: hidden;
|
| 45 |
+
background: #ddd;
|
| 46 |
+
aspect-ratio: 3 / 4;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
video {
|
| 50 |
+
width: 100%;
|
| 51 |
+
height: 100%;
|
| 52 |
+
object-fit: cover;
|
| 53 |
+
display: block;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.reticle {
|
| 57 |
+
position: absolute;
|
| 58 |
+
left: 50%;
|
| 59 |
+
top: 50%;
|
| 60 |
+
width: 70px;
|
| 61 |
+
height: 70px;
|
| 62 |
+
transform: translate(-50%, -50%);
|
| 63 |
+
border: 3px solid #fff;
|
| 64 |
+
border-radius: 10px;
|
| 65 |
+
box-shadow: 0 0 0 200vmax rgba(0, 0, 0, 0.1);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.controls {
|
| 69 |
+
display: flex;
|
| 70 |
+
gap: 10px;
|
| 71 |
+
margin-top: 12px;
|
| 72 |
+
flex-wrap: wrap;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
button {
|
| 76 |
+
border: 0;
|
| 77 |
+
border-radius: 10px;
|
| 78 |
+
padding: 10px 14px;
|
| 79 |
+
background: var(--accent);
|
| 80 |
+
color: #fff;
|
| 81 |
+
font-weight: 600;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
button:disabled {
|
| 85 |
+
opacity: 0.55;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.panel {
|
| 89 |
+
margin-top: 12px;
|
| 90 |
+
background: var(--panel);
|
| 91 |
+
border: 1px solid #eadfcd;
|
| 92 |
+
border-radius: 12px;
|
| 93 |
+
padding: 12px;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.sample-row {
|
| 97 |
+
display: flex;
|
| 98 |
+
align-items: center;
|
| 99 |
+
gap: 10px;
|
| 100 |
+
margin-top: 8px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.swatch {
|
| 104 |
+
width: 34px;
|
| 105 |
+
height: 34px;
|
| 106 |
+
border-radius: 8px;
|
| 107 |
+
border: 1px solid #bbb;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.palette-grid {
|
| 111 |
+
display: grid;
|
| 112 |
+
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
| 113 |
+
gap: 8px;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.chip {
|
| 117 |
+
border-radius: 8px;
|
| 118 |
+
padding: 6px;
|
| 119 |
+
text-align: center;
|
| 120 |
+
font-size: 12px;
|
| 121 |
+
border: 1px solid #d9cfbf;
|
| 122 |
+
background: #fff;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.chip-color {
|
| 126 |
+
height: 26px;
|
| 127 |
+
border-radius: 6px;
|
| 128 |
+
border: 1px solid rgba(0, 0, 0, 0.15);
|
| 129 |
+
margin-bottom: 4px;
|
| 130 |
}
|
| 131 |
|
| 132 |
+
.ok {
|
| 133 |
+
color: var(--ok);
|
| 134 |
+
font-weight: 700;
|
|
|
|
|
|
|
|
|
|
| 135 |
}
|
| 136 |
|
| 137 |
+
.bad {
|
| 138 |
+
color: var(--bad);
|
| 139 |
+
font-weight: 700;
|
| 140 |
}
|