Files
pixel-bead-pattern/app/page.tsx
T

564 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import { MARD_221 } from "./mard-palette";
type BeadColor = {
code: string;
name: string;
hex: string;
rgb: [number, number, number];
};
type Pixel = {
color: BeadColor;
};
type HoveredPixel = {
row: number;
column: number;
color: BeadColor;
left: number;
top: number;
};
type SavedPattern = {
id: string;
name: string;
savedAt: string;
width: number;
height: number;
colorLimit: number;
codes: string[];
};
const MARD_SERIES_NAMES: Record<string, string> = {
A: "黄橙系", B: "绿色系", C: "蓝青系", D: "紫蓝系", E: "粉红系",
F: "红色系", G: "肤棕系", H: "黑白灰系", M: "莫兰迪系",
};
const PALETTE: BeadColor[] = MARD_221.map(([code, hex]) => ({
code,
name: `MARD ${MARD_SERIES_NAMES[code[0]]}`,
hex,
rgb: [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)],
}));
const hexToRgb = (hex: string): [number, number, number] => [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
];
function colorDistance(a: [number, number, number], b: [number, number, number]) {
const meanR = (a[0] + b[0]) / 2;
const r = a[0] - b[0];
const g = a[1] - b[1];
const blue = a[2] - b[2];
return (2 + meanR / 256) * r * r + 4 * g * g + (2 + (255 - meanR) / 256) * blue * blue;
}
function nearestColor(rgb: [number, number, number], palette: BeadColor[]) {
let closest = palette[0];
let smallest = Number.POSITIVE_INFINITY;
for (const color of palette) {
const distance = colorDistance(rgb, color.rgb);
if (distance < smallest) {
smallest = distance;
closest = color;
}
}
return closest;
}
function drawFittedImage(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
width: number,
height: number,
fitMode: "cover" | "contain",
cropZoom: number,
cropX: number,
cropY: number,
) {
const imageRatio = image.naturalWidth / image.naturalHeight;
const boxRatio = width / height;
let drawWidth = width;
let drawHeight = height;
if ((fitMode === "cover" && imageRatio > boxRatio) || (fitMode === "contain" && imageRatio < boxRatio)) {
drawHeight = height;
drawWidth = height * imageRatio;
} else {
drawWidth = width;
drawHeight = width / imageRatio;
}
drawWidth *= cropZoom;
drawHeight *= cropZoom;
const overflowX = Math.max(0, (drawWidth - width) / 2);
const overflowY = Math.max(0, (drawHeight - height) / 2);
const drawX = (width - drawWidth) / 2 + cropX * overflowX;
const drawY = (height - drawHeight) / 2 + cropY * overflowY;
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
function makeDemoImage() {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 640;
const ctx = canvas.getContext("2d")!;
const sky = ctx.createLinearGradient(0, 0, 0, 640);
sky.addColorStop(0, "#bfe8f1");
sky.addColorStop(0.65, "#fff2ce");
sky.addColorStop(1, "#f6c783");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, 640, 640);
ctx.fillStyle = "#f6c742";
ctx.beginPath(); ctx.arc(488, 134, 70, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#58a95b";
ctx.beginPath(); ctx.moveTo(0, 455); ctx.quadraticCurveTo(135, 310, 300, 455); ctx.quadraticCurveTo(480, 285, 640, 435); ctx.lineTo(640, 640); ctx.lineTo(0, 640); ctx.fill();
ctx.fillStyle = "#27865a";
ctx.beginPath(); ctx.moveTo(0, 535); ctx.quadraticCurveTo(180, 390, 350, 520); ctx.quadraticCurveTo(505, 410, 640, 505); ctx.lineTo(640, 640); ctx.lineTo(0, 640); ctx.fill();
ctx.fillStyle = "#f7f5ed";
ctx.fillRect(245, 390, 152, 126);
ctx.fillStyle = "#d9383a";
ctx.beginPath(); ctx.moveTo(218, 405); ctx.lineTo(321, 323); ctx.lineTo(424, 405); ctx.closePath(); ctx.fill();
ctx.fillStyle = "#754a32";
ctx.fillRect(300, 447, 43, 69);
ctx.fillStyle = "#79cbe1";
ctx.fillRect(258, 415, 39, 37); ctx.fillRect(350, 415, 34, 37);
return canvas.toDataURL("image/png");
}
export default function Home() {
const [sourceUrl, setSourceUrl] = useState("");
const [sourceName, setSourceName] = useState("示例:田野小屋");
const [requestedWidth, setRequestedWidth] = useState(32);
const [requestedHeight, setRequestedHeight] = useState(32);
const [gridWidth, setGridWidth] = useState(32);
const [gridHeight, setGridHeight] = useState(32);
const [colorLimit, setColorLimit] = useState(18);
const [fitMode, setFitMode] = useState<"cover" | "contain">("cover");
const [cropZoom, setCropZoom] = useState(1);
const [cropX, setCropX] = useState(0);
const [cropY, setCropY] = useState(0);
const [pixels, setPixels] = useState<Pixel[]>([]);
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
const [onlySelected, setOnlySelected] = useState(false);
const [zoom, setZoom] = useState(24);
const [showGrid, setShowGrid] = useState(true);
const [showCodes, setShowCodes] = useState(true);
const [query, setQuery] = useState("");
const [hoveredPixel, setHoveredPixel] = useState<HoveredPixel | null>(null);
const [history, setHistory] = useState<SavedPattern[]>([]);
const [historyQuery, setHistoryQuery] = useState("");
const [status, setStatus] = useState("示例图已准备好,可以直接转换");
const sourceImageRef = useRef<HTMLImageElement | null>(null);
const cropCanvasRef = useRef<HTMLCanvasElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const cropPreviewRef = useRef<HTMLDivElement | null>(null);
const cropDragRef = useRef<{ pointerId: number; x: number; y: number; cropX: number; cropY: number } | null>(null);
useEffect(() => {
const saved = localStorage.getItem("bead-pattern-history");
if (!saved) return;
queueMicrotask(() => {
try { setHistory(JSON.parse(saved)); } catch { localStorage.removeItem("bead-pattern-history"); }
});
}, []);
const convertImage = (
image = sourceImageRef.current,
crop = { zoom: cropZoom, x: cropX, y: cropY },
) => {
if (!image) return;
const width = Math.max(8, Math.min(256, requestedWidth));
const height = Math.max(8, Math.min(256, requestedHeight));
const sample = document.createElement("canvas");
sample.width = width;
sample.height = height;
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
ctx.fillStyle = "#f7f5ed";
ctx.fillRect(0, 0, width, height);
drawFittedImage(ctx, image, width, height, fitMode, crop.zoom, crop.x, crop.y);
const data = ctx.getImageData(0, 0, width, height).data;
const initialMatches: BeadColor[] = [];
const counts = new Map<string, number>();
for (let i = 0; i < data.length; i += 4) {
const match = nearestColor([data[i], data[i + 1], data[i + 2]], PALETTE);
initialMatches.push(match);
counts.set(match.code, (counts.get(match.code) ?? 0) + 1);
}
const limitedPalette = [...PALETTE]
.sort((a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0))
.slice(0, Math.max(2, Math.min(colorLimit, PALETTE.length)));
const converted = initialMatches.map((match, index) => {
const rgb: [number, number, number] = [data[index * 4], data[index * 4 + 1], data[index * 4 + 2]];
return { color: limitedPalette.some((c) => c.code === match.code) ? match : nearestColor(rgb, limitedPalette) };
});
setGridWidth(width);
setGridHeight(height);
setRequestedWidth(width);
setRequestedHeight(height);
setPixels(converted);
setSelectedCodes(new Set());
setStatus(`已转换为 ${width} × ${height},共 ${width * height} 颗拼豆`);
};
useEffect(() => {
const demo = makeDemoImage();
const image = new Image();
image.onload = () => {
sourceImageRef.current = image;
setSourceUrl(demo);
convertImage(image);
};
image.src = demo;
// This intentionally runs once to create the initial example.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const canvas = cropCanvasRef.current;
const image = sourceImageRef.current;
if (!canvas || !image) return;
const targetWidth = Math.max(8, Math.min(256, requestedWidth || 8));
const targetHeight = Math.max(8, Math.min(256, requestedHeight || 8));
const ratio = targetWidth / targetHeight;
const previewWidth = ratio >= 1 ? 600 : Math.max(120, Math.round(600 * ratio));
const previewHeight = ratio >= 1 ? Math.max(120, Math.round(600 / ratio)) : 600;
canvas.width = previewWidth;
canvas.height = previewHeight;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#f7f5ed";
ctx.fillRect(0, 0, previewWidth, previewHeight);
drawFittedImage(ctx, image, previewWidth, previewHeight, fitMode, cropZoom, cropX, cropY);
}, [sourceUrl, requestedWidth, requestedHeight, fitMode, cropZoom, cropX, cropY]);
const usedColors = useMemo(() => {
const counts = new Map<string, number>();
pixels.forEach(({ color }) => counts.set(color.code, (counts.get(color.code) ?? 0) + 1));
return PALETTE.filter((color) => counts.has(color.code))
.map((color) => ({ ...color, count: counts.get(color.code)! }))
.sort((a, b) => b.count - a.count);
}, [pixels]);
const filteredColors = useMemo(() => {
const key = query.trim().toLowerCase();
return usedColors.filter((color) => !key || color.code.toLowerCase().includes(key) || color.name.includes(key) || color.hex.toLowerCase().includes(key));
}, [query, usedColors]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || pixels.length === 0) return;
const cell = zoom;
const ruler = Math.max(22, cell);
const ratio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = (gridWidth * cell + ruler) * ratio;
canvas.height = (gridHeight * cell + ruler) * ratio;
canvas.style.width = `${gridWidth * cell + ruler}px`;
canvas.style.height = `${gridHeight * cell + ruler}px`;
const ctx = canvas.getContext("2d")!;
ctx.scale(ratio, ratio);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, gridWidth * cell + ruler, gridHeight * cell + ruler);
ctx.fillStyle = "#f0eee7";
ctx.fillRect(ruler, 0, gridWidth * cell, ruler);
ctx.fillRect(0, ruler, ruler, gridHeight * cell);
ctx.strokeStyle = "rgba(32,38,36,.25)";
ctx.lineWidth = 0.6;
ctx.font = `600 ${Math.max(7, Math.min(10, cell * 0.35))}px Arial`;
ctx.fillStyle = "#56605b";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const labelEvery = cell < 10 ? 10 : cell < 16 ? 5 : 1;
for (let column = 0; column < gridWidth; column++) {
const x = ruler + column * cell;
ctx.strokeRect(x + 0.3, 0.3, cell - 0.6, ruler - 0.6);
if (column === 0 || (column + 1) % labelEvery === 0) ctx.fillText(String(column + 1), x + cell / 2, ruler / 2);
}
for (let row = 0; row < gridHeight; row++) {
const y = ruler + row * cell;
ctx.strokeRect(0.3, y + 0.3, ruler - 0.6, cell - 0.6);
if (row === 0 || (row + 1) % labelEvery === 0) ctx.fillText(String(row + 1), ruler / 2, y + cell / 2);
}
pixels.forEach(({ color }, index) => {
const x = ruler + (index % gridWidth) * cell;
const y = ruler + Math.floor(index / gridWidth) * cell;
const hasSelection = selectedCodes.size > 0;
const isSelected = selectedCodes.has(color.code);
if (onlySelected && hasSelection && !isSelected) {
ctx.fillStyle = "#ffffff";
} else {
ctx.fillStyle = color.hex;
ctx.globalAlpha = hasSelection && !isSelected ? 0.12 : 1;
ctx.fillRect(x, y, cell, cell);
ctx.globalAlpha = 1;
}
if (showGrid) {
ctx.strokeStyle = hasSelection && !isSelected ? "rgba(32,38,36,.07)" : "rgba(32,38,36,.22)";
ctx.lineWidth = 0.6;
ctx.strokeRect(x + 0.3, y + 0.3, cell - 0.6, cell - 0.6);
}
if (showCodes && cell >= 22 && (!hasSelection || isSelected)) {
const [r, g, b] = hexToRgb(color.hex);
ctx.fillStyle = r * 0.299 + g * 0.587 + b * 0.114 > 160 ? "#1f2925" : "#ffffff";
ctx.font = `600 ${Math.max(7, cell * 0.28)}px Arial`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(color.code, x + cell / 2, y + cell / 2);
}
});
}, [pixels, gridWidth, gridHeight, zoom, selectedCodes, onlySelected, showGrid, showCodes]);
const handleUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
setStatus("请选择 JPG、PNG 或 WebP 图片");
return;
}
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
if (sourceUrl.startsWith("blob:")) URL.revokeObjectURL(sourceUrl);
sourceImageRef.current = image;
setSourceUrl(url);
setSourceName(file.name);
setCropZoom(1);
setCropX(0);
setCropY(0);
setStatus("图片已载入,点击“重新转换”生成图纸");
convertImage(image, { zoom: 1, x: 0, y: 0 });
};
image.src = url;
};
const handleCropPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
cropDragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, cropX, cropY };
};
const handleCropPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = cropDragRef.current;
const preview = cropPreviewRef.current;
if (!drag || drag.pointerId !== event.pointerId || !preview) return;
const rect = preview.getBoundingClientRect();
const nextX = drag.cropX + ((event.clientX - drag.x) / Math.max(1, rect.width)) * 2;
const nextY = drag.cropY + ((event.clientY - drag.y) / Math.max(1, rect.height)) * 2;
setCropX(Math.max(-1, Math.min(1, nextX)));
setCropY(Math.max(-1, Math.min(1, nextY)));
};
const handleCropPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (cropDragRef.current?.pointerId === event.pointerId) cropDragRef.current = null;
};
const toggleColor = (code: string) => {
setSelectedCodes((current) => {
const next = new Set(current);
if (next.has(code)) next.delete(code); else next.add(code);
return next;
});
};
const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const ruler = Math.max(22, zoom);
const x = Math.floor((event.clientX - rect.left - ruler) / zoom);
const y = Math.floor((event.clientY - rect.top - ruler) / zoom);
const pixel = pixels[y * gridWidth + x];
if (x >= 0 && y >= 0 && x < gridWidth && y < gridHeight && pixel) toggleColor(pixel.color.code);
};
const handleCanvasMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const ruler = Math.max(22, zoom);
const column = Math.floor((event.clientX - rect.left - ruler) / zoom);
const row = Math.floor((event.clientY - rect.top - ruler) / zoom);
const pixel = pixels[row * gridWidth + column];
if (column < 0 || row < 0 || column >= gridWidth || row >= gridHeight || !pixel) {
setHoveredPixel(null);
return;
}
setHoveredPixel({ row: row + 1, column: column + 1, color: pixel.color, left: event.clientX - rect.left + 14, top: event.clientY - rect.top + 14 });
};
const persistHistory = (next: SavedPattern[]) => {
setHistory(next);
localStorage.setItem("bead-pattern-history", JSON.stringify(next));
};
const savePattern = () => {
if (!pixels.length) return;
const entry: SavedPattern = {
id: crypto.randomUUID(),
name: sourceName.replace(/\.[^.]+$/, "") || "未命名图纸",
savedAt: new Date().toISOString(),
width: gridWidth,
height: gridHeight,
colorLimit,
codes: pixels.map((pixel) => pixel.color.code),
};
persistHistory([entry, ...history].slice(0, 30));
setStatus("图纸已保存到本机历史记录");
};
const restorePattern = (entry: SavedPattern) => {
const colorMap = new Map(PALETTE.map((color) => [color.code, color]));
setPixels(entry.codes.map((code) => ({ color: colorMap.get(code) ?? PALETTE[0] })));
setGridWidth(entry.width);
setGridHeight(entry.height);
setRequestedWidth(entry.width);
setRequestedHeight(entry.height);
setColorLimit(entry.colorLimit);
setSourceName(entry.name);
setSelectedCodes(new Set());
setStatus(`已恢复历史图纸:${entry.name}`);
document.getElementById("pattern-preview")?.scrollIntoView({ behavior: "smooth" });
};
const removePattern = (id: string) => persistHistory(history.filter((entry) => entry.id !== id));
const filteredHistory = useMemo(() => {
const key = historyQuery.trim().toLowerCase();
return history.filter((entry) => !key || entry.name.toLowerCase().includes(key) || `${entry.width}x${entry.height}`.includes(key));
}, [history, historyQuery]);
const exportPng = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const link = document.createElement("a");
link.download = `拼豆图纸-${gridWidth}x${gridHeight}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
};
return (
<main>
<header className="topbar">
<a className="brand" href="#top" aria-label="豆格工坊首页">
<span className="brand-mark" aria-hidden="true"><i /><i /><i /><i /></span>
<span>豆格工坊</span>
</a>
<span className="top-note">图片转拼豆图纸 · 本地处理</span>
</header>
<section className="hero" id="top">
<div>
<p className="eyebrow">PIXEL BEAD STUDIO</p>
<h1>把喜欢的图片,<br /><em>变成一格一格的快乐。</em></h1>
<p className="hero-copy">上传图片,选择图纸大小和颜色数量;点击任意像素格,就能单独查看该颜色需要摆放的区域。</p>
</div>
<div className="hero-badge"><strong>{gridWidth} × {gridHeight}</strong><span>当前图纸格数</span></div>
</section>
<section className="workspace" aria-label="拼豆图纸转换工具">
<aside className="control-panel">
<div className="panel-heading"><span>01</span><div><h2>图片与规格</h2><p>先决定图纸需要多少格</p></div></div>
<div className="upload-card crop-preview" style={{ aspectRatio: `${Math.max(8, requestedWidth || 8)} / ${Math.max(8, requestedHeight || 8)}` }} ref={cropPreviewRef} onPointerDown={handleCropPointerDown} onPointerMove={handleCropPointerMove} onPointerUp={handleCropPointerUp} onPointerCancel={handleCropPointerUp}>
<canvas ref={cropCanvasRef} aria-label="转换区域预览,可拖动调整取景" />
<span className="crop-frame" aria-hidden="true" />
<label className="upload-overlay">更换图片<input type="file" accept="image/png,image/jpeg,image/webp" onChange={handleUpload} /></label>
</div>
<p className="file-name" title={sourceName}>{sourceName}</p>
<label className="field crop-zoom"><span>取景缩放 <b>{cropZoom.toFixed(1)}×</b></span><input type="range" min="1" max="5" step="0.1" value={cropZoom} onChange={(e) => setCropZoom(Number(e.target.value))} /></label>
<button className="reset-crop" onClick={() => { setCropZoom(1); setCropX(0); setCropY(0); }}>复位取景</button>
<div className="field-row">
<label><span>横向格数</span><input type="number" min="8" max="256" value={requestedWidth} onChange={(e) => setRequestedWidth(Number(e.target.value))} /></label>
<span className="times">×</span>
<label><span>纵向格数</span><input type="number" min="8" max="256" value={requestedHeight} onChange={(e) => setRequestedHeight(Number(e.target.value))} /></label>
</div>
<label className="field"><span>最多使用颜色 <b>{colorLimit}</b></span><input type="range" min="2" max="64" value={colorLimit} onChange={(e) => setColorLimit(Number(e.target.value))} /></label>
<label className="field"><span>图片适应方式</span><select value={fitMode} onChange={(e) => setFitMode(e.target.value as "cover" | "contain")}><option value="cover">裁剪铺满</option><option value="contain">完整显示</option></select></label>
<button className="primary-button" onClick={() => convertImage()}>重新转换</button>
<p className="privacy-note">图片只在当前浏览器中处理,不会上传到服务器。</p>
</aside>
<section className="pattern-panel" id="pattern-preview">
<div className="pattern-toolbar">
<div><p className="section-kicker">02 / 图纸预览</p><h2>点击格子,筛选颜色</h2></div>
<div className="zoom-control" aria-label="图纸缩放">
<button onClick={() => setZoom((z) => Math.max(8, z - 2))} aria-label="缩小"></button>
<input aria-label="缩放比例" type="range" min="8" max="42" value={zoom} onChange={(e) => setZoom(Number(e.target.value))} />
<button onClick={() => setZoom((z) => Math.min(42, z + 2))} aria-label="放大"></button>
<output>{zoom}px</output>
</div>
</div>
<div className="canvas-stage">
<div className="canvas-wrap">
<canvas ref={canvasRef} onClick={handleCanvasClick} onMouseMove={handleCanvasMove} onMouseLeave={() => setHoveredPixel(null)} aria-label="转换后的拼豆像素图,点击格子可选择颜色" />
{hoveredPixel && <div className="pixel-tooltip" style={{ left: hoveredPixel.left, top: hoveredPixel.top }}>
<span className="tooltip-swatch" style={{ backgroundColor: hoveredPixel.color.hex }} />
<strong> {hoveredPixel.row} · {hoveredPixel.column} </strong>
<small>{hoveredPixel.color.code} · {hoveredPixel.color.name} · {hoveredPixel.color.hex}</small>
</div>}
</div>
</div>
<div className="pattern-footer">
<span className="status-dot" /> <span>{status}</span>
<div className="view-options">
<label><input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> 网格</label>
<label><input type="checkbox" checked={showCodes} onChange={(e) => setShowCodes(e.target.checked)} /> 格内编号</label>
<button className="save-button" onClick={savePattern}>保存历史</button>
<button onClick={exportPng}>下载 PNG</button>
</div>
</div>
<div className="pattern-legend">
<div className="legend-heading"><strong>本图所需颜色与用量</strong><span>按数量从多到少,共 {usedColors.length} </span></div>
<div className="legend-list">
{usedColors.map((color) => <button key={color.code} className={selectedCodes.has(color.code) ? "active" : ""} onClick={() => toggleColor(color.code)}>
<i style={{ backgroundColor: color.hex }} /><strong>{color.code}</strong><span>{color.name}</span><b>{color.count} </b>
</button>)}
</div>
</div>
</section>
<aside className="color-panel">
<div className="panel-heading"><span>03</span><div><h2>分色查看</h2><p>{usedColors.length} 种颜色 · {pixels.length} </p></div></div>
<div className="search-box"><span></span><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="输入 A6、MARD A 或 #FEAC4C" /></div>
<div className="selection-tools">
<label className="switch-row"><input type="checkbox" checked={onlySelected} onChange={(e) => setOnlySelected(e.target.checked)} /><span className="switch" /><span>只显示选中颜色</span></label>
{selectedCodes.size > 0 && <button onClick={() => setSelectedCodes(new Set())}>清除 {selectedCodes.size} </button>}
</div>
<div className="color-list">
{filteredColors.map((color) => {
const active = selectedCodes.has(color.code);
return <button key={color.code} className={`color-item ${active ? "active" : ""}`} onClick={() => toggleColor(color.code)} aria-pressed={active}>
<span className="swatch" style={{ backgroundColor: color.hex }} />
<span className="color-meta"><strong>{color.code} · {color.name}</strong><small>{color.hex}</small></span>
<b>{color.count}<small></small></b>
</button>;
})}
</div>
<p className="palette-note"><strong>MARD 221 基础色卡</strong>:默认使用 A/B/C/D/E/F/G/H/M 九个系列。屏幕色值仅用于近似匹配,实体豆颜色会受批次、光线和屏幕影响。</p>
</aside>
</section>
<section className="history-section" aria-labelledby="history-title">
<div className="history-heading"><div><p className="section-kicker">LOCAL PATTERN ARCHIVE</p><h2 id="history-title">历史图纸</h2><p>历史保存在当前浏览器,最多保留 30 份。</p></div>
<div className="search-box"><span></span><input value={historyQuery} onChange={(e) => setHistoryQuery(e.target.value)} placeholder="按名称或 32x32 查询" /></div>
</div>
{filteredHistory.length ? <div className="history-grid">{filteredHistory.map((entry) => {
const colorCounts = new Map<string, number>();
entry.codes.forEach((code) => colorCounts.set(code, (colorCounts.get(code) ?? 0) + 1));
const topCodes = [...colorCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
return <article className="history-card" key={entry.id}>
<div className="history-swatches">{topCodes.map(([code, count]) => <i key={code} style={{ backgroundColor: PALETTE.find((color) => color.code === code)?.hex, flexGrow: count }} />)}</div>
<div><h3>{entry.name}</h3><p>{entry.width} × {entry.height} · {new Date(entry.savedAt).toLocaleString("zh-CN", { hour12: false })}</p></div>
<div className="history-actions"><button onClick={() => restorePattern(entry)}>打开</button><button onClick={() => removePattern(entry.id)}>删除</button></div>
</article>;
})}</div> : <div className="history-empty">还没有符合条件的历史图纸。转换完成后点击“保存历史”即可在这里查询。</div>}
</section>
</main>
);
}