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

896 lines
40 KiB
TypeScript
Raw Blame History

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