"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 = { 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([]); const [selectedCodes, setSelectedCodes] = useState>(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(null); const [history, setHistory] = useState([]); const [historyQuery, setHistoryQuery] = useState(""); const [status, setStatus] = useState("示例图已准备好,可以直接转换"); const sourceImageRef = useRef(null); const cropCanvasRef = useRef(null); const canvasRef = useRef(null); const cropPreviewRef = useRef(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(); 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(); 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) => { 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) => { event.currentTarget.setPointerCapture(event.pointerId); cropDragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, cropX, cropY }; }; const handleCropPointerMove = (event: React.PointerEvent) => { 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) => { 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) => { 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) => { 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 (
豆格工坊 图片转拼豆图纸 · 本地处理

PIXEL BEAD STUDIO

把喜欢的图片,
变成一格一格的快乐。

上传图片,选择图纸大小和颜色数量;点击任意像素格,就能单独查看该颜色需要摆放的区域。

{gridWidth} × {gridHeight}当前图纸格数

02 / 图纸预览

点击格子,筛选颜色

setZoom(Number(e.target.value))} /> {zoom}px
setHoveredPixel(null)} aria-label="转换后的拼豆像素图,点击格子可选择颜色" /> {hoveredPixel &&
第 {hoveredPixel.row} 行 · 第 {hoveredPixel.column} 列 {hoveredPixel.color.code} · {hoveredPixel.color.name} · {hoveredPixel.color.hex}
}
{status}
本图所需颜色与用量按数量从多到少,共 {usedColors.length} 色
{usedColors.map((color) => )}

LOCAL PATTERN ARCHIVE

历史图纸

历史保存在当前浏览器,最多保留 30 份。

setHistoryQuery(e.target.value)} placeholder="按名称或 32x32 查询" />
{filteredHistory.length ?
{filteredHistory.map((entry) => { const colorCounts = new Map(); 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
{topCodes.map(([code, count]) => color.code === code)?.hex, flexGrow: count }} />)}

{entry.name}

{entry.width} × {entry.height} · {new Date(entry.savedAt).toLocaleString("zh-CN", { hour12: false })}

; })}
:
还没有符合条件的历史图纸。转换完成后点击“保存历史”即可在这里查询。
}
); }