"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]; lab: Oklab; }; type Oklab = [number, number, number]; type ColorBin = { key: number; count: number; rgb: [number, number, number]; lab: Oklab; }; type ColorCluster = { count: number; rgb: [number, number, number]; lab: Oklab; }; type SamplingStrategy = "smooth" | "dominant"; function logUsage(event: Record) { void fetch("/api/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(event), keepalive: true, }).catch(() => undefined); } type Pixel = { color: BeadColor | null; }; 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: Array; }; const TRANSPARENT_ALPHA_THRESHOLD = 128; const HISTORY_LIMIT = 30; const HISTORY_STORAGE_KEY = "bead-pattern-history"; const HISTORY_DB_NAME = "pixel-bead-pattern-history"; const HISTORY_STORE_NAME = "patterns"; function openHistoryDatabase() { return new Promise((resolve, reject) => { const request = indexedDB.open(HISTORY_DB_NAME, 1); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(HISTORY_STORE_NAME)) { request.result.createObjectStore(HISTORY_STORE_NAME); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async function readPatternHistory(): Promise { const database = await openHistoryDatabase(); return new Promise((resolve, reject) => { const transaction = database.transaction(HISTORY_STORE_NAME, "readonly"); const request = transaction.objectStore(HISTORY_STORE_NAME).get(HISTORY_STORAGE_KEY); request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []); request.onerror = () => reject(request.error); transaction.oncomplete = () => database.close(); }); } async function writePatternHistory(value: SavedPattern[]) { const database = await openHistoryDatabase(); return new Promise((resolve, reject) => { const transaction = database.transaction(HISTORY_STORE_NAME, "readwrite"); transaction.objectStore(HISTORY_STORE_NAME).put(value, HISTORY_STORAGE_KEY); transaction.oncomplete = () => { database.close(); resolve(); }; transaction.onerror = () => { database.close(); reject(transaction.error); }; transaction.onabort = () => { database.close(); reject(transaction.error); }; }); } const MARD_SERIES_NAMES: Record = { A: "黄橙系", B: "绿色系", C: "蓝青系", D: "紫蓝系", E: "粉红系", F: "红色系", G: "肤棕系", H: "黑白灰系", M: "莫兰迪系", }; function rgbToOklab([red, green, blue]: [number, number, number]): Oklab { const linear = (value: number) => { const channel = value / 255; return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; }; const r = linear(red); const g = linear(green); const b = linear(blue); const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); return [ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, ]; } function oklabDistance(a: Oklab, b: Oklab) { return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); } function isPerceptualBridge(color: Oklab, first: Oklab, second: Oklab) { const vector = [second[0] - first[0], second[1] - first[1], second[2] - first[2]] as Oklab; const lengthSquared = vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2; if (lengthSquared < 0.07 ** 2) return false; const offset = [color[0] - first[0], color[1] - first[1], color[2] - first[2]] as Oklab; const position = (offset[0] * vector[0] + offset[1] * vector[1] + offset[2] * vector[2]) / lengthSquared; if (position < 0.12 || position > 0.88) return false; const projection: Oklab = [ first[0] + vector[0] * position, first[1] + vector[1] * position, first[2] + vector[2] * position, ]; return oklabDistance(color, projection) <= 0.025; } const PALETTE: BeadColor[] = MARD_221.map(([code, hex]) => { const rgb: [number, number, number] = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; return { code, name: `MARD ${MARD_SERIES_NAMES[code[0]]}`, hex, rgb, lab: rgbToOklab(rgb) }; }); 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 nearestColor(lab: Oklab, palette: BeadColor[]) { let closest = palette[0]; let smallest = Number.POSITIVE_INFINITY; for (const color of palette) { const distance = oklabDistance(lab, color.lab); if (distance < smallest) { smallest = distance; closest = color; } } return closest; } function mergePerceptualColors(data: Uint8ClampedArray) { const pixelKeys: Array = []; const histogram = new Map(); for (let index = 0; index < data.length; index += 4) { if (data[index + 3] < TRANSPARENT_ALPHA_THRESHOLD) { pixelKeys.push(null); continue; } const red = data[index]; const green = data[index + 1]; const blue = data[index + 2]; const key = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3); pixelKeys.push(key); const bin = histogram.get(key); if (bin) { bin.count += 1; bin.red += red; bin.green += green; bin.blue += blue; } else { histogram.set(key, { count: 1, red, green, blue }); } } const bins: ColorBin[] = [...histogram.entries()].map(([key, value]) => { const rgb: [number, number, number] = [value.red / value.count, value.green / value.count, value.blue / value.count]; return { key, count: value.count, rgb, lab: rgbToOklab(rgb) }; }).sort((a, b) => b.count - a.count); const clusters: ColorCluster[] = []; const mergeThreshold = 0.025; for (const bin of bins) { let nearestIndex = -1; let nearestDistance = Number.POSITIVE_INFINITY; for (let index = 0; index < clusters.length; index++) { const distance = oklabDistance(bin.lab, clusters[index].lab); if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; } } if (nearestIndex >= 0 && nearestDistance <= mergeThreshold) { const cluster = clusters[nearestIndex]; const total = cluster.count + bin.count; cluster.rgb = cluster.rgb.map((value, channel) => (value * cluster.count + bin.rgb[channel] * bin.count) / total) as [number, number, number]; cluster.lab = cluster.lab.map((value, channel) => (value * cluster.count + bin.lab[channel] * bin.count) / total) as Oklab; cluster.count = total; } else { clusters.push({ count: bin.count, rgb: [...bin.rgb], lab: [...bin.lab] }); } } const binClusters = new Map(); for (const bin of bins) { let nearestIndex = 0; let nearestDistance = Number.POSITIVE_INFINITY; clusters.forEach((cluster, index) => { const distance = oklabDistance(bin.lab, cluster.lab); if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; } }); binClusters.set(bin.key, nearestIndex); } return { pixelKeys, clusters, binClusters }; } function chooseDistinctMardColors(clusters: ColorCluster[], maximum: number) { const candidates = new Map(); for (const cluster of clusters) { const color = nearestColor(cluster.lab, PALETTE); const error = oklabDistance(cluster.lab, color.lab); const chroma = Math.hypot(cluster.lab[1], cluster.lab[2]); const detailBonus = 1 + Math.min(0.65, chroma * 2.2) + (cluster.lab[0] < 0.28 ? 0.35 : 0); const current = candidates.get(color.code) ?? { color, count: 0, error: 0, score: 0 }; current.count += cluster.count; current.error += error * cluster.count; current.score += cluster.count * detailBonus / (1 + error * 5); candidates.set(color.code, current); } const separated: BeadColor[] = []; const minimumMardDistance = 0.035; const ranked = [...candidates.values()].sort((a, b) => b.score - a.score || a.error / a.count - b.error / b.count); for (const candidate of ranked) { if (separated.every((selected) => oklabDistance(candidate.color.lab, selected.lab) >= minimumMardDistance)) { separated.push(candidate.color); } } return separated.slice(0, Math.max(0, Math.min(maximum, separated.length))); } function sampleDominantRegions( image: HTMLImageElement, width: number, height: number, fitMode: "cover" | "contain", cropZoom: number, cropX: number, cropY: number, ) { // A mildly center-weighted 3x3 vote keeps geometric corners without letting // one anti-aliased center sample overpower the surrounding real colors. const scale = 3; const sampleWeights = [ [1, 1, 1], [1, 2, 1], [1, 1, 1], ]; const totalSampleWeight = 10; const sample = document.createElement("canvas"); sample.width = width * scale; sample.height = height * scale; const ctx = sample.getContext("2d", { willReadFrequently: true })!; ctx.clearRect(0, 0, sample.width, sample.height); ctx.imageSmoothingEnabled = false; drawFittedImage(ctx, image, sample.width, sample.height, fitMode, cropZoom, cropX, cropY); const source = ctx.getImageData(0, 0, sample.width, sample.height).data; const result = new Uint8ClampedArray(width * height * 4); const confidence = new Float32Array(width * height); const localMergeThreshold = 0.04; for (let row = 0; row < height; row++) { for (let column = 0; column < width; column++) { const groups: Array<{ count: number; weight: number; lab: Oklab; samples: Array<{ rgb: [number, number, number]; lab: Oklab }>; containsCenter: boolean }> = []; let transparentWeight = 0; for (let offsetY = 0; offsetY < scale; offsetY++) { for (let offsetX = 0; offsetX < scale; offsetX++) { const sourceIndex = ((row * scale + offsetY) * sample.width + column * scale + offsetX) * 4; const sampleWeight = sampleWeights[offsetY][offsetX]; if (source[sourceIndex + 3] < TRANSPARENT_ALPHA_THRESHOLD) { transparentWeight += sampleWeight; continue; } const rgb: [number, number, number] = [source[sourceIndex], source[sourceIndex + 1], source[sourceIndex + 2]]; const lab = rgbToOklab(rgb); const isCenter = offsetX === Math.floor(scale / 2) && offsetY === Math.floor(scale / 2); let closest: (typeof groups)[number] | undefined; let closestDistance = Number.POSITIVE_INFINITY; for (const group of groups) { const distance = oklabDistance(lab, group.lab); if (distance < closestDistance) { closest = group; closestDistance = distance; } } if (closest && closestDistance <= localMergeThreshold) { const totalWeight = closest.weight + sampleWeight; closest.lab = closest.lab.map((value, channel) => (value * closest.weight + lab[channel] * sampleWeight) / totalWeight) as Oklab; closest.weight = totalWeight; closest.count += 1; closest.samples.push({ rgb, lab }); closest.containsCenter ||= isCenter; } else { groups.push({ count: 1, weight: sampleWeight, lab: [...lab], samples: [{ rgb, lab }], containsCenter: isCenter }); } } } const targetIndex = (row * width + column) * 4; if (transparentWeight >= totalSampleWeight / 2 || groups.length === 0) { result[targetIndex + 3] = 0; confidence[row * width + column] = transparentWeight / totalSampleWeight; continue; } const rankedGroups = groups.map((group) => { let effectiveWeight = group.weight; if (group.count <= 2) { for (let first = 0; first < groups.length; first++) { for (let second = first + 1; second < groups.length; second++) { if (groups[first] === group || groups[second] === group) continue; if (groups[first].weight + groups[second].weight < group.weight) continue; if (isPerceptualBridge(group.lab, groups[first].lab, groups[second].lab)) effectiveWeight = 0; } } } return { group, effectiveWeight }; }); const dominant = rankedGroups.sort((a, b) => b.effectiveWeight - a.effectiveWeight || b.group.weight - a.group.weight || Number(b.group.containsCenter) - Number(a.group.containsCenter))[0].group; const representative = dominant.samples.reduce((best, current) => oklabDistance(current.lab, dominant.lab) < oklabDistance(best.lab, dominant.lab) ? current : best, ); result[targetIndex] = representative.rgb[0]; result[targetIndex + 1] = representative.rgb[1]; result[targetIndex + 2] = representative.rgb[2]; result[targetIndex + 3] = 255; confidence[row * width + column] = dominant.weight / Math.max(1, totalSampleWeight - transparentWeight); } } return { data: result, confidence }; } function cleanThinTransitionBands(colors: Array, width: number, height: number) { const cleaned = [...colors]; const usage = new Map(); colors.forEach((color) => { if (color) usage.set(color.code, (usage.get(color.code) ?? 0) + 1); }); const maximumTransitionUsage = Math.max(4, Math.ceil(width * height * 0.015)); let changed = 0; for (let row = 0; row < height; row++) { for (let column = 0; column < width; column++) { const index = row * width + column; const current = colors[index]; if (!current || (usage.get(current.code) ?? 0) > maximumTransitionUsage) continue; const neighbors = new Map(); for (let rowOffset = -1; rowOffset <= 1; rowOffset++) { for (let columnOffset = -1; columnOffset <= 1; columnOffset++) { if (rowOffset === 0 && columnOffset === 0) continue; const neighborRow = row + rowOffset; const neighborColumn = column + columnOffset; if (neighborRow < 0 || neighborColumn < 0 || neighborRow >= height || neighborColumn >= width) continue; const neighbor = colors[neighborRow * width + neighborColumn]; if (!neighbor || neighbor.code === current.code) continue; const entry = neighbors.get(neighbor.code) ?? { color: neighbor, count: 0 }; entry.count += 1; neighbors.set(neighbor.code, entry); } } const candidates = [...neighbors.values()].sort((a, b) => b.count - a.count).slice(0, 3); let replacement: BeadColor | null = null; let replacementSupport = 0; for (let first = 0; first < candidates.length; first++) { for (let second = first + 1; second < candidates.length; second++) { if (candidates[first].count + candidates[second].count < 4) continue; if (!isPerceptualBridge(current.lab, candidates[first].color.lab, candidates[second].color.lab)) continue; const preferred = candidates[first].count >= candidates[second].count ? candidates[first] : candidates[second]; if (preferred.count > replacementSupport) { replacement = preferred.color; replacementSupport = preferred.count; } } } if (replacement) { cleaned[index] = replacement; changed += 1; } } } return { colors: cleaned, changed }; } function cleanLowConfidenceIsolatedColors( colors: Array, confidence: Float32Array, width: number, height: number, ) { const cleaned = [...colors]; let changed = 0; const offsets = [-1, 0, 1]; for (let row = 0; row < height; row++) { for (let column = 0; column < width; column++) { const index = row * width + column; const current = colors[index]; if (!current) continue; if (confidence[index] >= 0.625) continue; const chroma = Math.hypot(current.lab[1], current.lab[2]); const protectedDetail = current.lab[0] < 0.32 || current.lab[0] > 0.96 || chroma > 0.14; if (protectedDetail) continue; const neighborCounts = new Map(); let neighborTotal = 0; let sameColorNeighbors = 0; for (const rowOffset of offsets) { for (const columnOffset of offsets) { if (rowOffset === 0 && columnOffset === 0) continue; const neighborRow = row + rowOffset; const neighborColumn = column + columnOffset; if (neighborRow < 0 || neighborColumn < 0 || neighborRow >= height || neighborColumn >= width) continue; const neighborIndex = neighborRow * width + neighborColumn; const neighbor = colors[neighborIndex]; if (!neighbor) continue; if (neighbor.code === current.code) sameColorNeighbors += 1; const entry = neighborCounts.get(neighbor.code) ?? { color: neighbor, count: 0, confidentCount: 0 }; entry.count += 1; if (confidence[neighborIndex] >= 0.625) entry.confidentCount += 1; neighborCounts.set(neighbor.code, entry); neighborTotal += 1; } } // A boundary corner is still part of a continuous object. Only a color with // no same-color support in all eight neighboring cells counts as isolated. if (sameColorNeighbors > 0) continue; const majority = [...neighborCounts.values()].sort((a, b) => b.count - a.count)[0]; if (!majority || majority.color.code === current.code) continue; const requiredCount = neighborTotal >= 7 ? 5 : Math.max(2, Math.ceil(neighborTotal * 0.67)); if (majority.count < requiredCount) continue; if (majority.confidentCount < Math.min(3, majority.count)) continue; // Large color jumps describe a real object boundary rather than an // anti-aliased transition color, so never let cleanup cross that edge. if (oklabDistance(current.lab, majority.color.lab) > 0.075) continue; cleaned[index] = majority.color; changed += 1; } } return { colors: cleaned, changed }; } 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(100); const [requestedHeight, setRequestedHeight] = useState(100); const [gridWidth, setGridWidth] = useState(100); const [gridHeight, setGridHeight] = useState(100); const [colorLimit, setColorLimit] = useState(64); const [fitMode, setFitMode] = useState<"cover" | "contain">("contain"); const [samplingStrategy, setSamplingStrategy] = useState("dominant"); 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(8); 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 fileInputRef = useRef(null); const cropCanvasRef = useRef(null); const canvasRef = useRef(null); const patternStageRef = useRef(null); const cropPreviewRef = useRef(null); const cropDragRef = useRef<{ pointerId: number; x: number; y: number; cropX: number; cropY: number } | null>(null); const patternDragRef = useRef<{ pointerId: number; x: number; y: number; scrollLeft: number; scrollTop: number; moved: boolean; } | null>(null); const suppressCanvasClickRef = useRef(false); useEffect(() => { logUsage({ event: "page_view" }); let cancelled = false; const loadHistory = async () => { try { let saved = await readPatternHistory(); const legacy = localStorage.getItem(HISTORY_STORAGE_KEY); if (saved.length === 0 && legacy) { const parsed = JSON.parse(legacy); if (Array.isArray(parsed)) { saved = parsed.slice(0, HISTORY_LIMIT); await writePatternHistory(saved); } localStorage.removeItem(HISTORY_STORAGE_KEY); } if (!cancelled) setHistory(saved.slice(0, HISTORY_LIMIT)); } catch { if (!cancelled) setStatus("历史记录读取失败,请检查浏览器是否允许本地存储"); } }; void loadHistory(); return () => { cancelled = true; }; }, []); const convertImage = ( image = sourceImageRef.current, crop = { zoom: cropZoom, x: cropX, y: cropY }, recordUsage = true, ) => { if (!image) return; const width = Math.max(8, Math.min(256, requestedWidth)); const height = Math.max(8, Math.min(256, requestedHeight)); let data: Uint8ClampedArray; let dominantConfidence: Float32Array | null = null; if (samplingStrategy === "dominant") { const sampled = sampleDominantRegions(image, width, height, fitMode, crop.zoom, crop.x, crop.y); data = sampled.data; dominantConfidence = sampled.confidence; } else { const sample = document.createElement("canvas"); sample.width = width; sample.height = height; const ctx = sample.getContext("2d", { willReadFrequently: true })!; ctx.clearRect(0, 0, width, height); drawFittedImage(ctx, image, width, height, fitMode, crop.zoom, crop.x, crop.y); data = ctx.getImageData(0, 0, width, height).data; } const { pixelKeys, clusters, binClusters } = mergePerceptualColors(data); const limitedPalette = chooseDistinctMardColors(clusters, Math.max(2, Math.min(colorLimit, PALETTE.length))); const clusterMatches = clusters.map((cluster) => nearestColor(cluster.lab, limitedPalette)); let matchedColors: Array = pixelKeys.map((key) => key === null ? null : clusterMatches[binClusters.get(key) ?? 0]); let cleanedCount = 0; if (dominantConfidence) { const cleaned = cleanLowConfidenceIsolatedColors(matchedColors, dominantConfidence, width, height); matchedColors = cleaned.colors; cleanedCount = cleaned.changed; const transitionCleaned = cleanThinTransitionBands(matchedColors, width, height); matchedColors = transitionCleaned.colors; cleanedCount += transitionCleaned.changed; } const converted = matchedColors.map((color) => ({ color })); setGridWidth(width); setGridHeight(height); setRequestedWidth(width); setRequestedHeight(height); setPixels(converted); setSelectedCodes(new Set()); const strategyName = samplingStrategy === "dominant" ? `区域主色 · 清理 ${cleanedCount} 个低可信孤立格` : "平滑取色"; const actualColorCount = new Set(matchedColors.filter((color): color is BeadColor => color !== null).map((color) => color.code)).size; const actualBeadCount = matchedColors.filter((color) => color !== null).length; if (recordUsage) { logUsage({ event: "conversion", width, height, sampling_strategy: samplingStrategy, color_limit: colorLimit, actual_colors: actualColorCount, bead_count: actualBeadCount, transparent_cells: width * height - actualBeadCount, }); } setStatus(`已转换为 ${width} × ${height} · ${actualBeadCount} 颗拼豆 · ${strategyName} · ${actualColorCount} 种差异色`); }; useEffect(() => { const demo = makeDemoImage(); const image = new Image(); image.onload = () => { sourceImageRef.current = image; setSourceUrl(demo); convertImage(image, undefined, false); }; 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 }) => { if (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 beadCount = useMemo(() => pixels.reduce((total, pixel) => total + (pixel.color ? 1 : 0), 0), [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; if (!color) { ctx.clearRect(x, y, cell, cell); if (showGrid) { ctx.strokeStyle = "rgba(32,38,36,.12)"; ctx.lineWidth = 0.6; ctx.strokeRect(x + 0.3, y + 0.3, cell - 0.6, cell - 0.6); } return; } 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); } }); if (showGrid) { ctx.strokeStyle = "rgba(32,38,36,.48)"; ctx.lineWidth = 1.5; for (let column = 5; column < gridWidth; column += 5) { const x = ruler + column * cell; ctx.beginPath(); ctx.moveTo(x, ruler); ctx.lineTo(x, ruler + gridHeight * cell); ctx.stroke(); } for (let row = 5; row < gridHeight; row += 5) { const y = ruler + row * cell; ctx.beginPath(); ctx.moveTo(ruler, y); ctx.lineTo(ruler + gridWidth * cell, y); ctx.stroke(); } } }, [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 handlePatternPointerDown = (event: React.PointerEvent) => { if (event.button !== 0) return; const stage = patternStageRef.current; if (!stage) return; suppressCanvasClickRef.current = false; event.currentTarget.setPointerCapture(event.pointerId); patternDragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, scrollLeft: stage.scrollLeft, scrollTop: stage.scrollTop, moved: false, }; }; const handlePatternPointerMove = (event: React.PointerEvent) => { const stage = patternStageRef.current; const drag = patternDragRef.current; if (!stage || !drag || drag.pointerId !== event.pointerId) return; const deltaX = event.clientX - drag.x; const deltaY = event.clientY - drag.y; if (!drag.moved && Math.hypot(deltaX, deltaY) < 5) return; drag.moved = true; stage.classList.add("is-dragging"); stage.scrollLeft = drag.scrollLeft - deltaX; stage.scrollTop = drag.scrollTop - deltaY; setHoveredPixel(null); }; const handlePatternPointerUp = (event: React.PointerEvent) => { const stage = patternStageRef.current; const drag = patternDragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; suppressCanvasClickRef.current = drag.moved; patternDragRef.current = null; stage?.classList.remove("is-dragging"); if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }; const handlePatternPointerCancel = (event: React.PointerEvent) => { const drag = patternDragRef.current; if (drag?.pointerId !== event.pointerId) return; patternDragRef.current = null; suppressCanvasClickRef.current = false; patternStageRef.current?.classList.remove("is-dragging"); if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }; const handlePatternLostPointerCapture = (event: React.PointerEvent) => { if (patternDragRef.current?.pointerId !== event.pointerId) return; patternDragRef.current = null; suppressCanvasClickRef.current = false; patternStageRef.current?.classList.remove("is-dragging"); }; const handleCanvasClick = (event: React.MouseEvent) => { if (suppressCanvasClickRef.current) { suppressCanvasClickRef.current = false; return; } 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?.color) toggleColor(pixel.color.code); }; const handleCanvasMove = (event: React.MouseEvent) => { if (patternDragRef.current?.moved) { setHoveredPixel(null); return; } 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?.color) { 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 = async (next: SavedPattern[]) => { await writePatternHistory(next); setHistory(next); }; const savePattern = async () => { 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 ?? null), }; try { await persistHistory([entry, ...history].slice(0, HISTORY_LIMIT)); setStatus("图纸已保存到本机历史记录"); } catch { setStatus("保存历史失败,请检查浏览器是否允许本地存储或剩余空间是否充足"); } }; const restorePattern = (entry: SavedPattern) => { const colorMap = new Map(PALETTE.map((color) => [color.code, color])); setPixels(entry.codes.map((code) => ({ color: code === null ? null : 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 = async (id: string) => { try { await persistHistory(history.filter((entry) => entry.id !== id)); } catch { setStatus("删除历史失败,请稍后重试"); } }; 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
event.preventDefault()} onClick={handleCanvasClick} onMouseMove={handleCanvasMove} onMouseLeave={() => 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) => { if (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 })}

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