"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"; type CropRect = { x: number; y: number; width: number; height: number; }; type CropSelectionMode = "free" | "square"; type CropDialogPurpose = "source" | "pattern-import"; type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se"; type CropSelectionDrag = { pointerId: number; mode: "create" | "move" | "resize"; startX: number; startY: number; origin: CropRect; handle?: CropResizeHandle; }; const FULL_CROP: CropRect = { x: 0, y: 0, width: 1, height: 1 }; 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 createHistoryId() { if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID(); return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function rowNumberToLetters(rowNumber: number) { let value = Math.max(1, Math.floor(rowNumber)); let letters = ""; while (value > 0) { value -= 1; letters = String.fromCharCode(65 + (value % 26)) + letters; value = Math.floor(value / 26); } return letters; } 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 PALETTE_BY_CODE = new Map(PALETTE.map((color) => [color.code, color])); 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 HistoryThumbnail({ entry }: { entry: SavedPattern }) { const thumbnailRef = useRef(null); useEffect(() => { const canvas = thumbnailRef.current; if (!canvas) return; canvas.width = Math.max(1, entry.width); canvas.height = Math.max(1, entry.height); const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); entry.codes.forEach((code, index) => { if (!code) return; const color = PALETTE_BY_CODE.get(code); if (!color) return; ctx.fillStyle = color.hex; ctx.fillRect(index % entry.width, Math.floor(index / entry.width), 1, 1); }); }, [entry]); return
; } 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; } type NormalizedGlyph = { pixels: Uint8Array; count: number; }; let mardCodeTemplates: Array<{ code: string; glyph: NormalizedGlyph }> | null = null; function normalizeGlyph(mask: Uint8Array, width: number, height: number, targetWidth = 32, targetHeight = 16): NormalizedGlyph | null { let left = width; let right = -1; let top = height; let bottom = -1; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (!mask[y * width + x]) continue; left = Math.min(left, x); right = Math.max(right, x); top = Math.min(top, y); bottom = Math.max(bottom, y); } } if (right < left || bottom < top) return null; const sourceWidth = right - left + 1; const sourceHeight = bottom - top + 1; const scale = Math.min((targetWidth - 2) / sourceWidth, (targetHeight - 2) / sourceHeight); const drawWidth = Math.max(1, Math.round(sourceWidth * scale)); const drawHeight = Math.max(1, Math.round(sourceHeight * scale)); const offsetX = Math.floor((targetWidth - drawWidth) / 2); const offsetY = Math.floor((targetHeight - drawHeight) / 2); const pixels = new Uint8Array(targetWidth * targetHeight); let count = 0; for (let y = 0; y < drawHeight; y++) { for (let x = 0; x < drawWidth; x++) { const sourceX = left + Math.min(sourceWidth - 1, Math.floor(x / scale)); const sourceY = top + Math.min(sourceHeight - 1, Math.floor(y / scale)); if (!mask[sourceY * width + sourceX]) continue; pixels[(offsetY + y) * targetWidth + offsetX + x] = 1; count += 1; } } return count >= 3 ? { pixels, count } : null; } function buildMardCodeTemplates() { if (mardCodeTemplates) return mardCodeTemplates; const canvas = document.createElement("canvas"); canvas.width = 96; canvas.height = 48; const ctx = canvas.getContext("2d", { willReadFrequently: true })!; mardCodeTemplates = PALETTE.map((color) => { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#000000"; ctx.font = "700 28px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(color.code, canvas.width / 2, canvas.height / 2); const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; const mask = new Uint8Array(canvas.width * canvas.height); for (let index = 0; index < mask.length; index++) { const offset = index * 4; const luminance = data[offset] * 0.299 + data[offset + 1] * 0.587 + data[offset + 2] * 0.114; if (luminance < 150) mask[index] = 1; } return { code: color.code, glyph: normalizeGlyph(mask, canvas.width, canvas.height)! }; }); return mardCodeTemplates; } function glyphSimilarity(first: NormalizedGlyph, second: NormalizedGlyph) { let intersection = 0; for (let index = 0; index < first.pixels.length; index++) { if (first.pixels[index] && second.pixels[index]) intersection += 1; } return (2 * intersection) / Math.max(1, first.count + second.count); } function dominantCellBackground(data: Uint8ClampedArray, canvasWidth: number, startX: number, startY: number, cellSize: number) { const bins = new Map(); const inset = Math.max(2, Math.floor(cellSize * 0.12)); for (let y = inset; y < cellSize - inset; y++) { for (let x = inset; x < cellSize - inset; x++) { const nx = x / cellSize; const ny = y / cellSize; if (nx > 0.34 && nx < 0.66 && ny > 0.27 && ny < 0.73) continue; const index = ((startY + y) * canvasWidth + startX + x) * 4; const alpha = data[index + 3]; const key = alpha < TRANSPARENT_ALPHA_THRESHOLD ? -1 : ((data[index] >> 4) << 8) | ((data[index + 1] >> 4) << 4) | (data[index + 2] >> 4); const bin = bins.get(key) ?? { count: 0, red: 0, green: 0, blue: 0, alpha: 0 }; bin.count += 1; bin.red += data[index]; bin.green += data[index + 1]; bin.blue += data[index + 2]; bin.alpha += alpha; bins.set(key, bin); } } const dominant = [...bins.values()].sort((a, b) => b.count - a.count)[0]; if (!dominant) return { rgb: [255, 255, 255] as [number, number, number], alpha: 0 }; return { rgb: [ Math.round(dominant.red / dominant.count), Math.round(dominant.green / dominant.count), Math.round(dominant.blue / dominant.count), ] as [number, number, number], alpha: Math.round(dominant.alpha / dominant.count), }; } function extractCellGlyph( data: Uint8ClampedArray, canvasWidth: number, startX: number, startY: number, cellSize: number, background: [number, number, number], ) { const mask = new Uint8Array(cellSize * cellSize); const marginX = Math.max(2, Math.floor(cellSize * 0.12)); const marginY = Math.max(2, Math.floor(cellSize * 0.16)); for (let y = marginY; y < cellSize - marginY; y++) { for (let x = marginX; x < cellSize - marginX; x++) { const index = ((startY + y) * canvasWidth + startX + x) * 4; if (data[index + 3] < TRANSPARENT_ALPHA_THRESHOLD) continue; const difference = Math.hypot( data[index] - background[0], data[index + 1] - background[1], data[index + 2] - background[2], ); if (difference >= 72) mask[y * cellSize + x] = 1; } } return normalizeGlyph(mask, cellSize, cellSize); } function recognizeMardCode(glyph: NormalizedGlyph, backgroundLab: Oklab) { let bestCode = ""; let bestScore = 0; const candidates = buildMardCodeTemplates() .map((template) => ({ template, distance: oklabDistance(backgroundLab, PALETTE_BY_CODE.get(template.code)!.lab) })) .sort((first, second) => first.distance - second.distance) .slice(0, 24); for (const { template, distance } of candidates) { const rawScore = glyphSimilarity(glyph, template.glyph); const colorPenalty = Math.min(0.16, distance * 0.35); const score = rawScore - colorPenalty; if (score > bestScore) { bestScore = score; bestCode = template.code; } } return { code: bestCode, score: bestScore }; } function importMardPatternImage( image: HTMLImageElement, crop: CropRect, width: number, height: number, missingCodeAsEmpty: boolean, ) { const maximumSide = 4096; const cellSize = Math.max(10, Math.min(36, Math.floor(maximumSide / Math.max(width, height)))); const canvas = document.createElement("canvas"); canvas.width = width * cellSize; canvas.height = height * cellSize; const ctx = canvas.getContext("2d", { willReadFrequently: true })!; ctx.imageSmoothingEnabled = true; ctx.drawImage( image, crop.x * image.naturalWidth, crop.y * image.naturalHeight, crop.width * image.naturalWidth, crop.height * image.naturalHeight, 0, 0, canvas.width, canvas.height, ); const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; const pixels: Pixel[] = []; let recognizedCodes = 0; let colorMatches = 0; let emptyCells = 0; const recognitionCache = new Map(); for (let row = 0; row < height; row++) { for (let column = 0; column < width; column++) { const startX = column * cellSize; const startY = row * cellSize; const background = dominantCellBackground(data, canvas.width, startX, startY, cellSize); const backgroundLab = rgbToOklab(background.rgb); const glyph = extractCellGlyph(data, canvas.width, startX, startY, cellSize, background.rgb); let recognized: { code: string; score: number } | null = null; if (glyph) { let key = ""; for (let index = 0; index < glyph.pixels.length; index++) key += glyph.pixels[index] ? "1" : "0"; recognized = recognitionCache.get(key) ?? recognizeMardCode(glyph, backgroundLab); recognitionCache.set(key, recognized); } if (recognized && recognized.score >= 0.58 && PALETTE_BY_CODE.has(recognized.code)) { pixels.push({ color: PALETTE_BY_CODE.get(recognized.code)! }); recognizedCodes += 1; continue; } if (missingCodeAsEmpty) { pixels.push({ color: null }); emptyCells += 1; continue; } const chroma = Math.hypot(backgroundLab[1], backgroundLab[2]); if (background.alpha < TRANSPARENT_ALPHA_THRESHOLD || (!glyph && backgroundLab[0] > 0.93 && chroma < 0.025)) { pixels.push({ color: null }); emptyCells += 1; continue; } pixels.push({ color: nearestColor(backgroundLab, PALETTE) }); colorMatches += 1; } } return { pixels, recognizedCodes, colorMatches, emptyCells }; } 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[] = []; // Keep enough source-color resolution for broad gradients. The later MARD // selection still merges close colors globally and rejects tiny edge noise. const mergeThreshold = 0.015; 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 matchClustersToPalette(clusters: ColorCluster[], limitedPalette: BeadColor[], colorLimit: number) { const matches = clusters.map((cluster) => nearestColor(cluster.lab, limitedPalette)); if (colorLimit < 12) return matches; // A large, smooth gradient may contain several useful MARD steps which the // global minimum-distance filter deliberately merges. Let only substantial // source clusters recover their accurate shade; tiny edge colors stay out. const totalPixels = clusters.reduce((total, cluster) => total + cluster.count, 0); const broadClusterThreshold = Math.max(24, totalPixels * 0.012); const maximumExtraShades = Math.min(8, Math.max(0, colorLimit - limitedPalette.length)); const extraShades: BeadColor[] = []; const rankedClusters = clusters .map((cluster, index) => ({ cluster, index, exact: nearestColor(cluster.lab, PALETTE) })) .filter(({ cluster, exact, index }) => cluster.count >= broadClusterThreshold && exact.code !== matches[index].code) .sort((a, b) => b.cluster.count - a.cluster.count); for (const candidate of rankedClusters) { if (extraShades.length >= maximumExtraShades) break; if (extraShades.some((color) => color.code === candidate.exact.code)) { matches[candidate.index] = candidate.exact; continue; } const nearestSelectedDistance = Math.min(...limitedPalette.map((color) => oklabDistance(color.lab, candidate.exact.lab))); if (nearestSelectedDistance < 0.018) continue; extraShades.push(candidate.exact); matches[candidate.index] = candidate.exact; } return matches; } function preserveSmoothGradientSteps( colors: Array, sourceData: Uint8ClampedArray, width: number, height: number, colorLimit: number, ) { if (colorLimit < 12) return colors; const result = [...colors]; const sourceLabs: Array = []; for (let index = 0; index < sourceData.length; index += 4) { sourceLabs.push(sourceData[index + 3] < TRANSPARENT_ALPHA_THRESHOLD ? null : rgbToOklab([sourceData[index], sourceData[index + 1], sourceData[index + 2]])); } const smoothMask = new Uint8Array(width * height); const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]]; for (let row = 1; row < height - 1; row++) { for (let column = 1; column < width - 1; column++) { const index = row * width + column; const current = sourceLabs[index]; if (!current) continue; let maximumNeighborDistance = 0; for (const [rowOffset, columnOffset] of directions) { const neighbor = sourceLabs[(row + rowOffset) * width + column + columnOffset]; if (!neighbor) { maximumNeighborDistance = Number.POSITIVE_INFINITY; break; } maximumNeighborDistance = Math.max(maximumNeighborDistance, oklabDistance(current, neighbor)); } if (maximumNeighborDistance <= 0.018) smoothMask[index] = 1; } } // Require a real two-dimensional smooth patch, so a one-cell antialiased // outline can never qualify as a gradient. const gradientPalette = new Set(); for (let row = 2; row < height - 2; row++) { for (let column = 2; column < width - 2; column++) { const index = row * width + column; if (!smoothMask[index]) continue; let smoothNeighbors = 0; for (let rowOffset = -1; rowOffset <= 1; rowOffset++) { for (let columnOffset = -1; columnOffset <= 1; columnOffset++) { if (smoothMask[(row + rowOffset) * width + column + columnOffset]) smoothNeighbors += 1; } } if (smoothNeighbors < 7) continue; const sourceLab = sourceLabs[index]!; const sourceChroma = Math.hypot(sourceLab[1], sourceLab[2]); // Near-neutral pixels should use the neutral MARD series. Otherwise a // barely visible numerical hue can create a conspicuous lavender/pink // stripe inside an otherwise blue-to-cream gradient. const gradientCandidates = sourceChroma < 0.035 ? PALETTE.filter((color) => color.code.startsWith("H")) : PALETTE; const exact = nearestColor(sourceLab, gradientCandidates); if (!gradientPalette.has(exact.code) && gradientPalette.size >= 8) continue; gradientPalette.add(exact.code); result[index] = exact; } } return result; } function cleanGloballyRareIsolatedColors(colors: Array, width: number, height: number) { const usage = new Map(); colors.forEach((color) => { if (color) usage.set(color.code, (usage.get(color.code) ?? 0) + 1); }); const cleaned = [...colors]; 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) > 2) 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 majority = [...neighbors.values()].sort((a, b) => b.count - a.count)[0]; if (!majority) continue; const requiredSupport = (usage.get(current.code) ?? 0) === 1 ? 3 : 5; if (majority.count < requiredSupport) continue; cleaned[index] = majority.color; changed += 1; } } return { colors: cleaned, changed }; } function sampleDominantRegions( image: HTMLImageElement, width: number, height: number, fitMode: "cover" | "contain", crop: CropRect, ) { // A mildly center-weighted 3x3 vote keeps hard object boundaries clean. // Broad gradients are preserved later when the MARD palette is selected, // rather than by blending pixels across object edges here. 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, crop); 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]; 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) continue; const neighbors = new Map(); let sameColorNeighbors = 0; 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) continue; if (neighbor.code === current.code) { sameColorNeighbors += 1; continue; } const entry = neighbors.get(neighbor.code) ?? { color: neighbor, count: 0 }; entry.count += 1; neighbors.set(neighbor.code, entry); } } // A true gradient band has broad same-color support. Anti-aliased edge // colors form a one-cell-wide chain and have at most two same-color // neighbors, regardless of how often that edge color occurs globally. if (sameColorNeighbors > 2) continue; 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", crop: CropRect, ) { const sourceX = crop.x * image.naturalWidth; const sourceY = crop.y * image.naturalHeight; const sourceWidth = crop.width * image.naturalWidth; const sourceHeight = crop.height * image.naturalHeight; const imageRatio = sourceWidth / sourceHeight; const boxRatio = width / height; const sourceCenterX = sourceX + sourceWidth / 2; const sourceCenterY = sourceY + sourceHeight / 2; if (fitMode === "cover") { let centeredSourceWidth = sourceWidth; let centeredSourceHeight = sourceHeight; if (imageRatio > boxRatio) centeredSourceWidth = sourceHeight * boxRatio; else centeredSourceHeight = sourceWidth / boxRatio; ctx.drawImage( image, sourceCenterX - centeredSourceWidth / 2, sourceCenterY - centeredSourceHeight / 2, centeredSourceWidth, centeredSourceHeight, 0, 0, width, height, ); return; } let drawWidth = width; let drawHeight = height; if (imageRatio < boxRatio) { drawHeight = height; drawWidth = height * imageRatio; } else { drawWidth = width; drawHeight = width / imageRatio; } const drawX = (width - drawWidth) / 2; const drawY = (height - drawHeight) / 2; // The selected area's center is always the grid's center. With odd grid // sizes it lands on the middle cell; with even sizes it lands on the // intersection of the four middle cells. ctx.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 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 [cropRect, setCropRect] = useState(FULL_CROP); const [draftCropRect, setDraftCropRect] = useState(FULL_CROP); const [cropSelectionMode, setCropSelectionMode] = useState("free"); const [cropDialogOpen, setCropDialogOpen] = useState(false); const [cropDialogPurpose, setCropDialogPurpose] = useState("source"); const [patternImportWidth, setPatternImportWidth] = useState(100); const [patternImportHeight, setPatternImportHeight] = useState(100); const [missingCodeAsEmpty, setMissingCodeAsEmpty] = useState(true); const [patternImportName, setPatternImportName] = useState(""); const [patternImportUrl, setPatternImportUrl] = useState(""); const [patternImportBusy, setPatternImportBusy] = useState(false); 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 patternImportInputRef = useRef(null); const patternImportImageRef = useRef(null); const cropCanvasRef = useRef(null); const cropDialogCanvasRef = useRef(null); const canvasRef = useRef(null); const patternStageRef = useRef(null); const zoomRef = useRef(zoom); const pendingWheelZoomRef = useRef<{ stage: HTMLDivElement; cursorX: number; cursorY: number; canvasX: number; canvasY: number; } | null>(null); const cropDialogStageRef = useRef(null); const cropSelectionDragRef = useRef(null); const patternDragRef = useRef<{ pointerId: number; x: number; y: number; scrollLeft: number; scrollTop: number; moved: boolean; } | null>(null); const suppressCanvasClickRef = useRef(false); const cropDialogImage = cropDialogPurpose === "pattern-import" ? patternImportImageRef.current : sourceImageRef.current; 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 = cropRect, 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); 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); 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 = matchClustersToPalette(clusters, limitedPalette, colorLimit); 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; matchedColors = preserveSmoothGradientSteps(matchedColors, data, width, height, colorLimit); const rareCleaned = cleanGloballyRareIsolatedColors(matchedColors, width, height); matchedColors = rareCleaned.colors; cleanedCount += rareCleaned.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, FULL_CROP, 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, cropRect); }, [sourceUrl, requestedWidth, requestedHeight, fitMode, cropRect]); useEffect(() => { if (!cropDialogOpen) return; const canvas = cropDialogCanvasRef.current; const image = cropDialogImage; if (!canvas || !image) return; const maximumWidth = 1200; const maximumHeight = 760; const scale = Math.min(maximumWidth / image.naturalWidth, maximumHeight / image.naturalHeight, 1); canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)); const ctx = canvas.getContext("2d")!; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(image, 0, 0, canvas.width, canvas.height); }, [cropDialogOpen, cropDialogPurpose, sourceUrl, patternImportUrl, cropDialogImage]); useEffect(() => { if (!cropDialogOpen) return; const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setCropDialogOpen(false); }; window.addEventListener("keydown", closeOnEscape); return () => { document.body.style.overflow = previousOverflow; window.removeEventListener("keydown", closeOnEscape); }; }, [cropDialogOpen]); 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]); useEffect(() => { zoomRef.current = zoom; const pending = pendingWheelZoomRef.current; if (!pending) return; pendingWheelZoomRef.current = null; const frame = window.requestAnimationFrame(() => { const { stage } = pending; const canvas = canvasRef.current; if (!canvas) return; const stageRect = stage.getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect(); const anchorX = canvasRect.left + canvasRect.width * pending.canvasX; const anchorY = canvasRect.top + canvasRect.height * pending.canvasY; stage.scrollLeft += anchorX - (stageRect.left + pending.cursorX); stage.scrollTop += anchorY - (stageRect.top + pending.cursorY); }); return () => window.cancelAnimationFrame(frame); }, [zoom]); useEffect(() => { const stage = patternStageRef.current; if (!stage) return; const handleWheel = (event: WheelEvent) => { if (pixels.length === 0 || event.deltaY === 0) return; event.preventDefault(); const currentZoom = zoomRef.current; const nextZoom = Math.max(3, Math.min(42, currentZoom + (event.deltaY < 0 ? 1 : -1))); if (nextZoom === currentZoom) return; const rect = stage.getBoundingClientRect(); const canvasRect = canvasRef.current?.getBoundingClientRect(); const cursorX = event.clientX - rect.left; const cursorY = event.clientY - rect.top; pendingWheelZoomRef.current = { stage, cursorX, cursorY, canvasX: canvasRect ? (event.clientX - canvasRect.left) / Math.max(1, canvasRect.width) : 0.5, canvasY: canvasRect ? (event.clientY - canvasRect.top) / Math.max(1, canvasRect.height) : 0.5, }; zoomRef.current = nextZoom; setZoom(nextZoom); }; stage.addEventListener("wheel", handleWheel, { passive: false }); return () => stage.removeEventListener("wheel", handleWheel); }, [pixels.length]); 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); setCropRect(FULL_CROP); setDraftCropRect(FULL_CROP); setStatus("图片已载入,点击“重新转换”生成图纸"); convertImage(image, FULL_CROP); }; image.src = url; }; const handlePatternImportUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ""; 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 (patternImportUrl.startsWith("blob:") && patternImportUrl !== sourceUrl) URL.revokeObjectURL(patternImportUrl); patternImportImageRef.current = image; setPatternImportUrl(url); setPatternImportName(file.name); setPatternImportWidth(gridWidth); setPatternImportHeight(gridHeight); setCropDialogPurpose("pattern-import"); setCropSelectionMode("free"); setDraftCropRect(FULL_CROP); setCropDialogOpen(true); setStatus("图纸图片已载入,请框选实际网格并填写行列数"); }; image.onerror = () => { URL.revokeObjectURL(url); setStatus("图纸图片读取失败,请换一张图片重试"); }; image.src = url; }; const openCropDialog = () => { setCropDialogPurpose("source"); setDraftCropRect(cropRect); setCropDialogOpen(true); }; const cropPoint = (event: React.PointerEvent) => { const stage = cropDialogStageRef.current; if (!stage) return { x: 0, y: 0 }; const rect = stage.getBoundingClientRect(); return { x: Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))), y: Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height))), }; }; const updateDraftCrop = (startX: number, startY: number, endX: number, endY: number) => { const directionX = endX >= startX ? 1 : -1; const directionY = endY >= startY ? 1 : -1; let width = Math.abs(endX - startX); let height = Math.abs(endY - startY); if (cropSelectionMode === "square") { const image = cropDialogImage; if (!image) return; const normalizedSquareRatio = image.naturalHeight / image.naturalWidth; if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio; else height = width / Math.max(normalizedSquareRatio, 0.0001); const availableWidth = directionX > 0 ? 1 - startX : startX; const availableHeight = directionY > 0 ? 1 - startY : startY; const scale = Math.min(1, availableWidth / Math.max(width, 0.0001), availableHeight / Math.max(height, 0.0001)); width *= scale; height *= scale; } width = Math.max(0.01, Math.min(1, width)); height = Math.max(0.01, Math.min(1, height)); setDraftCropRect({ x: Math.max(0, Math.min(1 - width, directionX > 0 ? startX : startX - width)), y: Math.max(0, Math.min(1 - height, directionY > 0 ? startY : startY - height)), width, height, }); }; const pointInsideCrop = (point: { x: number; y: number }) => ( (draftCropRect.width < 0.995 || draftCropRect.height < 0.995) && point.x >= draftCropRect.x && point.x <= draftCropRect.x + draftCropRect.width && point.y >= draftCropRect.y && point.y <= draftCropRect.y + draftCropRect.height ); const chooseCropSelectionMode = (mode: CropSelectionMode) => { setCropSelectionMode(mode); if (mode !== "square") return; const image = cropDialogImage; if (!image) return; const currentCenterX = draftCropRect.x + draftCropRect.width / 2; const currentCenterY = draftCropRect.y + draftCropRect.height / 2; const sideInPixels = Math.min( draftCropRect.width * image.naturalWidth, draftCropRect.height * image.naturalHeight, ); const width = sideInPixels / image.naturalWidth; const height = sideInPixels / image.naturalHeight; setDraftCropRect({ x: Math.max(0, Math.min(1 - width, currentCenterX - width / 2)), y: Math.max(0, Math.min(1 - height, currentCenterY - height / 2)), width, height, }); }; const resizeDraftCrop = (drag: CropSelectionDrag, point: { x: number; y: number }) => { const handle = drag.handle; if (!handle) return; const origin = drag.origin; if (cropSelectionMode === "square") { const anchorX = handle.includes("w") ? origin.x + origin.width : origin.x; const anchorY = handle.includes("n") ? origin.y + origin.height : origin.y; updateDraftCrop(anchorX, anchorY, point.x, point.y); return; } const minimumSize = 0.03; let left = origin.x; let top = origin.y; let right = origin.x + origin.width; let bottom = origin.y + origin.height; if (handle.includes("w")) left = Math.max(0, Math.min(right - minimumSize, point.x)); if (handle.includes("e")) right = Math.min(1, Math.max(left + minimumSize, point.x)); if (handle.includes("n")) top = Math.max(0, Math.min(bottom - minimumSize, point.y)); if (handle.includes("s")) bottom = Math.min(1, Math.max(top + minimumSize, point.y)); setDraftCropRect({ x: left, y: top, width: right - left, height: bottom - top }); }; const handleCropSelectionDown = (event: React.PointerEvent) => { if (event.button !== 0) return; const point = cropPoint(event); event.currentTarget.setPointerCapture(event.pointerId); const resizeHandle = (event.target as HTMLElement).dataset.cropHandle as CropResizeHandle | undefined; if (resizeHandle) { cropSelectionDragRef.current = { pointerId: event.pointerId, mode: "resize", startX: point.x, startY: point.y, origin: draftCropRect, handle: resizeHandle, }; event.currentTarget.classList.add("is-resizing-selection"); return; } if (pointInsideCrop(point) && draftCropRect.width >= 0.03 && draftCropRect.height >= 0.03) { cropSelectionDragRef.current = { pointerId: event.pointerId, mode: "move", startX: point.x, startY: point.y, origin: draftCropRect, }; event.currentTarget.classList.add("is-moving-selection"); return; } cropSelectionDragRef.current = { pointerId: event.pointerId, mode: "create", startX: point.x, startY: point.y, origin: draftCropRect, }; setDraftCropRect({ x: Math.min(0.99, point.x), y: Math.min(0.99, point.y), width: 0.01, height: 0.01 }); }; const handleCropSelectionMove = (event: React.PointerEvent) => { const drag = cropSelectionDragRef.current; const point = cropPoint(event); if (!drag) { event.currentTarget.classList.toggle("can-move-selection", pointInsideCrop(point)); return; } if (drag.pointerId !== event.pointerId) return; if (drag.mode === "resize") { resizeDraftCrop(drag, point); return; } if (drag.mode === "move") { const x = Math.max(0, Math.min(1 - drag.origin.width, drag.origin.x + point.x - drag.startX)); const y = Math.max(0, Math.min(1 - drag.origin.height, drag.origin.y + point.y - drag.startY)); setDraftCropRect({ ...drag.origin, x, y }); return; } updateDraftCrop(drag.startX, drag.startY, point.x, point.y); }; const handleCropSelectionEnd = (event: React.PointerEvent) => { if (cropSelectionDragRef.current?.pointerId === event.pointerId) cropSelectionDragRef.current = null; event.currentTarget.classList.remove("is-moving-selection"); event.currentTarget.classList.remove("is-resizing-selection"); if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }; const confirmPatternImport = async () => { const image = patternImportImageRef.current; const width = Math.max(1, Math.min(256, Math.round(patternImportWidth))); const height = Math.max(1, Math.min(256, Math.round(patternImportHeight))); if (!image) { setStatus("请先选择要导入的图纸图片"); return; } if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) { setStatus("网格区域太小,请重新框选"); return; } setPatternImportBusy(true); setStatus("正在读取 MARD 编码并匹配格子底色…"); await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); try { const result = importMardPatternImage(image, draftCropRect, width, height, missingCodeAsEmpty); const actualColors = new Set(result.pixels.flatMap(({ color }) => color ? [color.code] : [])).size; const beadCount = result.pixels.reduce((total, { color }) => total + (color ? 1 : 0), 0); setPixels(result.pixels); setGridWidth(width); setGridHeight(height); setRequestedWidth(width); setRequestedHeight(height); setSelectedCodes(new Set()); setCropRect(draftCropRect); sourceImageRef.current = image; setSourceUrl(patternImportUrl); setSourceName(`导入图纸:${patternImportName}`); setCropDialogOpen(false); setStatus(`已导入 ${width} × ${height} · 读取编码 ${result.recognizedCodes} 格 · 底色匹配 ${result.colorMatches} 格 · 空白 ${result.emptyCells} 格`); logUsage({ event: "pattern_import", width, height, recognized_codes: result.recognizedCodes, color_matches: result.colorMatches, transparent_cells: result.emptyCells, actual_colors: actualColors, bead_count: beadCount, missing_code_as_empty: missingCodeAsEmpty, }); window.requestAnimationFrame(() => document.getElementById("pattern-preview")?.scrollIntoView({ behavior: "smooth", block: "start" })); } catch { setStatus("图纸识别失败,请确认选框与网格行列准确后重试"); } finally { setPatternImportBusy(false); } }; const confirmCrop = () => { if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) { setStatus("框选区域太小,请在大图上拖出更大的选框"); return; } if (cropDialogPurpose === "pattern-import") { void confirmPatternImport(); return; } setCropRect(draftCropRect); setCropDialogOpen(false); convertImage(sourceImageRef.current, draftCropRect); }; 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; try { const entry: SavedPattern = { id: createHistoryId(), name: sourceName.replace(/\.[^.]+$/, "") || "未命名图纸", savedAt: new Date().toISOString(), width: gridWidth, height: gridHeight, colorLimit, codes: pixels.map((pixel) => pixel.color?.code ?? null), }; await persistHistory([entry, ...history].slice(0, HISTORY_LIMIT)); setHistoryQuery(""); setStatus("图纸已保存到本机历史记录"); requestAnimationFrame(() => document.getElementById("history-title")?.scrollIntoView({ behavior: "smooth", block: "start" })); } 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 = () => { if (!pixels.length) return; const longestSide = Math.max(gridWidth, gridHeight); const cell = longestSide <= 120 ? 36 : longestSide <= 180 ? 30 : 24; const ruler = Math.max(42, cell + 12); const gridPixelWidth = gridWidth * cell; const gridPixelHeight = gridHeight * cell; const canvasWidth = ruler + gridPixelWidth; const legendPadding = 18; const legendItemWidth = 78; const legendItemHeight = 36; const legendColumns = Math.max(1, Math.floor((canvasWidth - legendPadding * 2) / legendItemWidth)); const legendRows = Math.ceil(usedColors.length / legendColumns); const legendHeight = usedColors.length > 0 ? legendPadding * 2 + legendRows * legendItemHeight : 0; const canvas = document.createElement("canvas"); canvas.width = canvasWidth; canvas.height = ruler + gridPixelHeight + legendHeight; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#f0eee7"; ctx.fillRect(ruler, 0, gridPixelWidth, ruler); ctx.fillRect(0, ruler, ruler, gridPixelHeight); ctx.strokeStyle = "rgba(32,38,36,.34)"; ctx.lineWidth = 1; ctx.font = `700 ${Math.max(9, Math.floor(cell * 0.32))}px Arial`; ctx.fillStyle = "#45504b"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; for (let column = 0; column < gridWidth; column += 1) { const x = ruler + column * cell; ctx.strokeRect(x + 0.5, 0.5, cell - 1, ruler - 1); ctx.fillText(String(column + 1), x + cell / 2, ruler / 2); } for (let row = 0; row < gridHeight; row += 1) { const y = ruler + row * cell; ctx.strokeRect(0.5, y + 0.5, ruler - 1, cell - 1); ctx.fillText(rowNumberToLetters(row + 1), ruler / 2, y + cell / 2); } pixels.forEach(({ color }, index) => { const column = index % gridWidth; const row = Math.floor(index / gridWidth); const x = ruler + column * cell; const y = ruler + row * cell; if (color) { ctx.fillStyle = color.hex; ctx.fillRect(x, y, cell, cell); const label = color.code; const [red, green, blue] = hexToRgb(color.hex); ctx.fillStyle = red * 0.299 + green * 0.587 + blue * 0.114 > 160 ? "#18211d" : "#ffffff"; let fontSize = Math.max(6, Math.floor(cell * 0.29)); ctx.font = `700 ${fontSize}px Arial`; while (fontSize > 6 && ctx.measureText(label).width > cell - 3) { fontSize -= 1; ctx.font = `700 ${fontSize}px Arial`; } ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(label, x + cell / 2, y + cell / 2); } ctx.strokeStyle = "rgba(32,38,36,.24)"; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, cell - 1, cell - 1); }); ctx.strokeStyle = "rgba(32,38,36,.58)"; ctx.lineWidth = 2; for (let column = 5; column < gridWidth; column += 5) { const x = ruler + column * cell; ctx.beginPath(); ctx.moveTo(x, ruler); ctx.lineTo(x, ruler + gridPixelHeight); ctx.stroke(); } for (let row = 5; row < gridHeight; row += 5) { const y = ruler + row * cell; ctx.beginPath(); ctx.moveTo(ruler, y); ctx.lineTo(ruler + gridPixelWidth, y); ctx.stroke(); } ctx.strokeStyle = "#202624"; ctx.lineWidth = 2; ctx.strokeRect(ruler, ruler, gridPixelWidth, gridPixelHeight); const legendTop = ruler + gridPixelHeight; if (usedColors.length > 0) { ctx.strokeStyle = "#d7d2c7"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, legendTop + 0.5); ctx.lineTo(canvasWidth, legendTop + 0.5); ctx.stroke(); usedColors.forEach((color, index) => { const column = index % legendColumns; const row = Math.floor(index / legendColumns); const x = legendPadding + column * legendItemWidth; const y = legendTop + legendPadding + row * legendItemHeight; ctx.fillStyle = color.hex; ctx.beginPath(); ctx.arc(x + 11, y + 11, 9, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "rgba(32,38,36,.28)"; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = "#202624"; ctx.font = "700 13px Arial"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(color.code, x + 26, y + 11); }); } canvas.toBlob((blob) => { if (!blob) { setStatus("PNG 生成失败,请减少图纸格数后重试"); return; } const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.download = `拼豆图纸-${gridWidth}x${gridHeight}.png`; link.href = url; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); setStatus("PNG 已生成:格内为 MARD 色号,底部为色块和对应色号"); logUsage({ event: "download_png", width: gridWidth, height: gridHeight, colors: usedColors.length }); }, "image/png"); }; 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) => )}
{cropDialogOpen &&
{ if (event.target === event.currentTarget) setCropDialogOpen(false); }}>
{cropDialogPurpose === "pattern-import" ?

MARD PATTERN IMPORT

导入现成 MARD 图纸

选框边缘请贴合实际格子区域,不要包含外侧行列标尺;再填写网格的列数和行数。

:

IMAGE CROP

框选需要转换的区域

框内拖动可移动,控制点可调整大小;生成格子时以所选区域正中心为采样基准。

}
{cropDialogPurpose === "pattern-import" ? <>
×
: <> 选框形状
} 当前选框:{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}%
{ if (!cropSelectionDragRef.current) event.currentTarget.classList.remove("can-move-selection"); }} >
{cropDialogPurpose === "pattern-import" && patternImportWidth > 0 && patternImportHeight > 0 &&