Initial MARD pixel bead pattern web

This commit is contained in:
wuyanwanwu
2026-08-13 23:00:44 +08:00
commit e3fe03468d
32 changed files with 11811 additions and 0 deletions
+487
View File
@@ -0,0 +1,487 @@
"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 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 [gridWidth, setGridWidth] = useState(32);
const [gridHeight, setGridHeight] = useState(32);
const [colorLimit, setColorLimit] = useState(18);
const [fitMode, setFitMode] = useState<"cover" | "contain">("cover");
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 canvasRef = useRef<HTMLCanvasElement | 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) => {
if (!image) return;
const width = Math.max(8, Math.min(128, gridWidth));
const height = Math.max(8, Math.min(128, gridHeight));
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);
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;
}
ctx.drawImage(image, (width - drawWidth) / 2, (height - drawHeight) / 2, drawWidth, drawHeight);
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);
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
}, []);
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";
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);
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);
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);
setStatus("图片已载入,点击“重新转换”生成图纸");
convertImage(image);
};
image.src = url;
};
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);
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>
<label className="upload-card">
{/* The source may be a local blob URL, so framework image optimization is not applicable. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
{sourceUrl ? <img src={sourceUrl} alt="待转换的原图预览" /> : <span className="upload-placeholder"></span>}
<span className="upload-overlay"></span>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={handleUpload} />
</label>
<p className="file-name" title={sourceName}>{sourceName}</p>
<div className="field-row">
<label><span></span><input type="number" min="8" max="128" value={gridWidth} onChange={(e) => setGridWidth(Number(e.target.value))} /></label>
<span className="times">×</span>
<label><span></span><input type="number" min="8" max="128" value={gridHeight} onChange={(e) => setGridHeight(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>
);
}