976 lines
44 KiB
TypeScript
976 lines
44 KiB
TypeScript
"use client";
|
||
|
||
import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
|
||
import { MARD_221 } from "./mard-palette";
|
||
|
||
type BeadColor = {
|
||
code: string;
|
||
name: string;
|
||
hex: string;
|
||
rgb: [number, number, number];
|
||
lab: Oklab;
|
||
};
|
||
|
||
type Oklab = [number, number, number];
|
||
|
||
type ColorBin = {
|
||
key: number;
|
||
count: number;
|
||
rgb: [number, number, number];
|
||
lab: Oklab;
|
||
};
|
||
|
||
type ColorCluster = {
|
||
count: number;
|
||
rgb: [number, number, number];
|
||
lab: Oklab;
|
||
};
|
||
|
||
type SamplingStrategy = "smooth" | "dominant";
|
||
|
||
function logUsage(event: Record<string, unknown>) {
|
||
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<string | null>;
|
||
};
|
||
|
||
const TRANSPARENT_ALPHA_THRESHOLD = 128;
|
||
|
||
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: Array<number | null> = [];
|
||
const histogram = new Map<number, { count: number; red: number; green: number; blue: number }>();
|
||
for (let index = 0; index < data.length; index += 4) {
|
||
if (data[index + 3] < TRANSPARENT_ALPHA_THRESHOLD) {
|
||
pixelKeys.push(null);
|
||
continue;
|
||
}
|
||
const red = data[index];
|
||
const green = data[index + 1];
|
||
const blue = data[index + 2];
|
||
const key = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3);
|
||
pixelKeys.push(key);
|
||
const bin = histogram.get(key);
|
||
if (bin) {
|
||
bin.count += 1;
|
||
bin.red += red;
|
||
bin.green += green;
|
||
bin.blue += blue;
|
||
} else {
|
||
histogram.set(key, { count: 1, red, green, blue });
|
||
}
|
||
}
|
||
|
||
const bins: ColorBin[] = [...histogram.entries()].map(([key, value]) => {
|
||
const rgb: [number, number, number] = [value.red / value.count, value.green / value.count, value.blue / value.count];
|
||
return { key, count: value.count, rgb, lab: rgbToOklab(rgb) };
|
||
}).sort((a, b) => b.count - a.count);
|
||
|
||
const clusters: ColorCluster[] = [];
|
||
const mergeThreshold = 0.025;
|
||
for (const bin of bins) {
|
||
let nearestIndex = -1;
|
||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||
for (let index = 0; index < clusters.length; index++) {
|
||
const distance = oklabDistance(bin.lab, clusters[index].lab);
|
||
if (distance < nearestDistance) {
|
||
nearestDistance = distance;
|
||
nearestIndex = index;
|
||
}
|
||
}
|
||
if (nearestIndex >= 0 && nearestDistance <= mergeThreshold) {
|
||
const cluster = clusters[nearestIndex];
|
||
const total = cluster.count + bin.count;
|
||
cluster.rgb = cluster.rgb.map((value, channel) => (value * cluster.count + bin.rgb[channel] * bin.count) / total) as [number, number, number];
|
||
cluster.lab = cluster.lab.map((value, channel) => (value * cluster.count + bin.lab[channel] * bin.count) / total) as Oklab;
|
||
cluster.count = total;
|
||
} else {
|
||
clusters.push({ count: bin.count, rgb: [...bin.rgb], lab: [...bin.lab] });
|
||
}
|
||
}
|
||
|
||
const binClusters = new Map<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(0, Math.min(maximum, separated.length)));
|
||
}
|
||
|
||
function sampleDominantRegions(
|
||
image: HTMLImageElement,
|
||
width: number,
|
||
height: number,
|
||
fitMode: "cover" | "contain",
|
||
cropZoom: number,
|
||
cropX: number,
|
||
cropY: number,
|
||
) {
|
||
// An odd sampling grid has a real center point and avoids directional ties
|
||
// at object boundaries (the former 4x4 grid favored one side on exact ties).
|
||
const scale = 5;
|
||
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);
|
||
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 }>; containsCenter: boolean }> = [];
|
||
let transparentCount = 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;
|
||
if (source[sourceIndex + 3] < TRANSPARENT_ALPHA_THRESHOLD) {
|
||
transparentCount += 1;
|
||
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 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 });
|
||
closest.containsCenter ||= isCenter;
|
||
} else {
|
||
groups.push({ count: 1, lab: [...lab], samples: [{ rgb, lab }], containsCenter: isCenter });
|
||
}
|
||
}
|
||
}
|
||
const targetIndex = (row * width + column) * 4;
|
||
if (transparentCount >= scale * scale / 2 || groups.length === 0) {
|
||
result[targetIndex + 3] = 0;
|
||
confidence[row * width + column] = transparentCount / (scale * scale);
|
||
continue;
|
||
}
|
||
const dominant = groups.sort((a, b) => b.count - a.count || Number(b.containsCenter) - Number(a.containsCenter))[0];
|
||
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.count / Math.max(1, scale * scale - transparentCount);
|
||
}
|
||
}
|
||
return { data: result, confidence };
|
||
}
|
||
|
||
function cleanLowConfidenceIsolatedColors(
|
||
colors: Array<BeadColor | null>,
|
||
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<string, { color: BeadColor; count: number; confidentCount: number }>();
|
||
let neighborTotal = 0;
|
||
let sameColorNeighbors = 0;
|
||
for (const rowOffset of offsets) {
|
||
for (const columnOffset of offsets) {
|
||
if (rowOffset === 0 && columnOffset === 0) continue;
|
||
const neighborRow = row + rowOffset;
|
||
const neighborColumn = column + columnOffset;
|
||
if (neighborRow < 0 || neighborColumn < 0 || neighborRow >= height || neighborColumn >= width) continue;
|
||
const neighborIndex = neighborRow * width + neighborColumn;
|
||
const neighbor = colors[neighborIndex];
|
||
if (!neighbor) continue;
|
||
if (neighbor.code === current.code) sameColorNeighbors += 1;
|
||
const entry = neighborCounts.get(neighbor.code) ?? { color: neighbor, count: 0, confidentCount: 0 };
|
||
entry.count += 1;
|
||
if (confidence[neighborIndex] >= 0.625) entry.confidentCount += 1;
|
||
neighborCounts.set(neighbor.code, entry);
|
||
neighborTotal += 1;
|
||
}
|
||
}
|
||
// A boundary corner is still part of a continuous object. Only a color with
|
||
// no same-color support in all eight neighboring cells counts as isolated.
|
||
if (sameColorNeighbors > 0) continue;
|
||
const majority = [...neighborCounts.values()].sort((a, b) => b.count - a.count)[0];
|
||
if (!majority || majority.color.code === current.code) continue;
|
||
const requiredCount = neighborTotal >= 7 ? 5 : Math.max(2, Math.ceil(neighborTotal * 0.67));
|
||
if (majority.count < requiredCount) continue;
|
||
if (majority.confidentCount < Math.min(3, majority.count)) continue;
|
||
// Large color jumps describe a real object boundary rather than an
|
||
// anti-aliased transition color, so never let cleanup cross that edge.
|
||
if (oklabDistance(current.lab, majority.color.lab) > 0.075) continue;
|
||
cleaned[index] = majority.color;
|
||
changed += 1;
|
||
}
|
||
}
|
||
return { colors: cleaned, changed };
|
||
}
|
||
|
||
function drawFittedImage(
|
||
ctx: CanvasRenderingContext2D,
|
||
image: HTMLImageElement,
|
||
width: number,
|
||
height: number,
|
||
fitMode: "cover" | "contain",
|
||
cropZoom: number,
|
||
cropX: number,
|
||
cropY: number,
|
||
) {
|
||
const imageRatio = image.naturalWidth / image.naturalHeight;
|
||
const boxRatio = width / height;
|
||
let drawWidth = width;
|
||
let drawHeight = height;
|
||
if ((fitMode === "cover" && imageRatio > boxRatio) || (fitMode === "contain" && imageRatio < boxRatio)) {
|
||
drawHeight = height;
|
||
drawWidth = height * imageRatio;
|
||
} else {
|
||
drawWidth = width;
|
||
drawHeight = width / imageRatio;
|
||
}
|
||
drawWidth *= cropZoom;
|
||
drawHeight *= cropZoom;
|
||
const overflowX = Math.max(0, (drawWidth - width) / 2);
|
||
const overflowY = Math.max(0, (drawHeight - height) / 2);
|
||
const drawX = (width - drawWidth) / 2 + cropX * overflowX;
|
||
const drawY = (height - drawHeight) / 2 + cropY * overflowY;
|
||
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||
}
|
||
|
||
function makeDemoImage() {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = 640;
|
||
canvas.height = 640;
|
||
const ctx = canvas.getContext("2d")!;
|
||
const sky = ctx.createLinearGradient(0, 0, 0, 640);
|
||
sky.addColorStop(0, "#bfe8f1");
|
||
sky.addColorStop(0.65, "#fff2ce");
|
||
sky.addColorStop(1, "#f6c783");
|
||
ctx.fillStyle = sky;
|
||
ctx.fillRect(0, 0, 640, 640);
|
||
ctx.fillStyle = "#f6c742";
|
||
ctx.beginPath(); ctx.arc(488, 134, 70, 0, Math.PI * 2); ctx.fill();
|
||
ctx.fillStyle = "#58a95b";
|
||
ctx.beginPath(); ctx.moveTo(0, 455); ctx.quadraticCurveTo(135, 310, 300, 455); ctx.quadraticCurveTo(480, 285, 640, 435); ctx.lineTo(640, 640); ctx.lineTo(0, 640); ctx.fill();
|
||
ctx.fillStyle = "#27865a";
|
||
ctx.beginPath(); ctx.moveTo(0, 535); ctx.quadraticCurveTo(180, 390, 350, 520); ctx.quadraticCurveTo(505, 410, 640, 505); ctx.lineTo(640, 640); ctx.lineTo(0, 640); ctx.fill();
|
||
ctx.fillStyle = "#f7f5ed";
|
||
ctx.fillRect(245, 390, 152, 126);
|
||
ctx.fillStyle = "#d9383a";
|
||
ctx.beginPath(); ctx.moveTo(218, 405); ctx.lineTo(321, 323); ctx.lineTo(424, 405); ctx.closePath(); ctx.fill();
|
||
ctx.fillStyle = "#754a32";
|
||
ctx.fillRect(300, 447, 43, 69);
|
||
ctx.fillStyle = "#79cbe1";
|
||
ctx.fillRect(258, 415, 39, 37); ctx.fillRect(350, 415, 34, 37);
|
||
return canvas.toDataURL("image/png");
|
||
}
|
||
|
||
export default function Home() {
|
||
const [sourceUrl, setSourceUrl] = useState("");
|
||
const [sourceName, setSourceName] = useState("示例:田野小屋");
|
||
const [requestedWidth, setRequestedWidth] = useState(100);
|
||
const [requestedHeight, setRequestedHeight] = useState(100);
|
||
const [gridWidth, setGridWidth] = useState(100);
|
||
const [gridHeight, setGridHeight] = useState(100);
|
||
const [colorLimit, setColorLimit] = useState(64);
|
||
const [fitMode, setFitMode] = useState<"cover" | "contain">("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(8);
|
||
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(() => {
|
||
logUsage({ event: "page_view" });
|
||
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 },
|
||
recordUsage = true,
|
||
) => {
|
||
if (!image) return;
|
||
const width = Math.max(8, Math.min(256, requestedWidth));
|
||
const height = Math.max(8, Math.min(256, requestedHeight));
|
||
let data: Uint8ClampedArray;
|
||
let dominantConfidence: Float32Array | null = null;
|
||
if (samplingStrategy === "dominant") {
|
||
const sampled = sampleDominantRegions(image, width, height, fitMode, crop.zoom, crop.x, crop.y);
|
||
data = sampled.data;
|
||
dominantConfidence = sampled.confidence;
|
||
} else {
|
||
const sample = document.createElement("canvas");
|
||
sample.width = width;
|
||
sample.height = height;
|
||
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
|
||
ctx.clearRect(0, 0, width, height);
|
||
drawFittedImage(ctx, image, width, height, fitMode, crop.zoom, crop.x, crop.y);
|
||
data = ctx.getImageData(0, 0, width, height).data;
|
||
}
|
||
const { pixelKeys, clusters, binClusters } = mergePerceptualColors(data);
|
||
const limitedPalette = chooseDistinctMardColors(clusters, Math.max(2, Math.min(colorLimit, PALETTE.length)));
|
||
const clusterMatches = clusters.map((cluster) => nearestColor(cluster.lab, limitedPalette));
|
||
let matchedColors: Array<BeadColor | null> = 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 converted = matchedColors.map((color) => ({ color }));
|
||
setGridWidth(width);
|
||
setGridHeight(height);
|
||
setRequestedWidth(width);
|
||
setRequestedHeight(height);
|
||
setPixels(converted);
|
||
setSelectedCodes(new Set());
|
||
const strategyName = samplingStrategy === "dominant" ? `区域主色 · 清理 ${cleanedCount} 个低可信孤立格` : "平滑取色";
|
||
const actualColorCount = new Set(matchedColors.filter((color): color is BeadColor => color !== null).map((color) => color.code)).size;
|
||
const actualBeadCount = matchedColors.filter((color) => color !== null).length;
|
||
if (recordUsage) {
|
||
logUsage({
|
||
event: "conversion",
|
||
width,
|
||
height,
|
||
sampling_strategy: samplingStrategy,
|
||
color_limit: colorLimit,
|
||
actual_colors: actualColorCount,
|
||
bead_count: actualBeadCount,
|
||
transparent_cells: width * height - actualBeadCount,
|
||
});
|
||
}
|
||
setStatus(`已转换为 ${width} × ${height} · ${actualBeadCount} 颗拼豆 · ${strategyName} · ${actualColorCount} 种差异色`);
|
||
};
|
||
|
||
useEffect(() => {
|
||
const demo = makeDemoImage();
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
sourceImageRef.current = image;
|
||
setSourceUrl(demo);
|
||
convertImage(image, undefined, false);
|
||
};
|
||
image.src = demo;
|
||
// This intentionally runs once to create the initial example.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const canvas = cropCanvasRef.current;
|
||
const image = sourceImageRef.current;
|
||
if (!canvas || !image) return;
|
||
const targetWidth = Math.max(8, Math.min(256, requestedWidth || 8));
|
||
const targetHeight = Math.max(8, Math.min(256, requestedHeight || 8));
|
||
const ratio = targetWidth / targetHeight;
|
||
const previewWidth = ratio >= 1 ? 600 : Math.max(120, Math.round(600 * ratio));
|
||
const previewHeight = ratio >= 1 ? Math.max(120, Math.round(600 / ratio)) : 600;
|
||
canvas.width = previewWidth;
|
||
canvas.height = previewHeight;
|
||
const ctx = canvas.getContext("2d")!;
|
||
ctx.fillStyle = "#f7f5ed";
|
||
ctx.fillRect(0, 0, previewWidth, previewHeight);
|
||
drawFittedImage(ctx, image, previewWidth, previewHeight, fitMode, cropZoom, cropX, cropY);
|
||
}, [sourceUrl, requestedWidth, requestedHeight, fitMode, cropZoom, cropX, cropY]);
|
||
|
||
const usedColors = useMemo(() => {
|
||
const counts = new Map<string, number>();
|
||
pixels.forEach(({ color }) => { if (color) counts.set(color.code, (counts.get(color.code) ?? 0) + 1); });
|
||
return PALETTE.filter((color) => counts.has(color.code))
|
||
.map((color) => ({ ...color, count: counts.get(color.code)! }))
|
||
.sort((a, b) => b.count - a.count);
|
||
}, [pixels]);
|
||
|
||
const beadCount = useMemo(() => pixels.reduce((total, pixel) => total + (pixel.color ? 1 : 0), 0), [pixels]);
|
||
|
||
const filteredColors = useMemo(() => {
|
||
const key = query.trim().toLowerCase();
|
||
return usedColors.filter((color) => !key || color.code.toLowerCase().includes(key) || color.name.includes(key) || color.hex.toLowerCase().includes(key));
|
||
}, [query, usedColors]);
|
||
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas || pixels.length === 0) return;
|
||
const cell = zoom;
|
||
const ruler = Math.max(22, cell);
|
||
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
||
canvas.width = (gridWidth * cell + ruler) * ratio;
|
||
canvas.height = (gridHeight * cell + ruler) * ratio;
|
||
canvas.style.width = `${gridWidth * cell + ruler}px`;
|
||
canvas.style.height = `${gridHeight * cell + ruler}px`;
|
||
const ctx = canvas.getContext("2d")!;
|
||
ctx.scale(ratio, ratio);
|
||
ctx.fillStyle = "#ffffff";
|
||
ctx.fillRect(0, 0, gridWidth * cell + ruler, gridHeight * cell + ruler);
|
||
ctx.fillStyle = "#f0eee7";
|
||
ctx.fillRect(ruler, 0, gridWidth * cell, ruler);
|
||
ctx.fillRect(0, ruler, ruler, gridHeight * cell);
|
||
ctx.strokeStyle = "rgba(32,38,36,.25)";
|
||
ctx.lineWidth = 0.6;
|
||
ctx.font = `600 ${Math.max(7, Math.min(10, cell * 0.35))}px Arial`;
|
||
ctx.fillStyle = "#56605b";
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
const labelEvery = cell < 10 ? 10 : cell < 16 ? 5 : 1;
|
||
for (let column = 0; column < gridWidth; column++) {
|
||
const x = ruler + column * cell;
|
||
ctx.strokeRect(x + 0.3, 0.3, cell - 0.6, ruler - 0.6);
|
||
if (column === 0 || (column + 1) % labelEvery === 0) ctx.fillText(String(column + 1), x + cell / 2, ruler / 2);
|
||
}
|
||
for (let row = 0; row < gridHeight; row++) {
|
||
const y = ruler + row * cell;
|
||
ctx.strokeRect(0.3, y + 0.3, ruler - 0.6, cell - 0.6);
|
||
if (row === 0 || (row + 1) % labelEvery === 0) ctx.fillText(String(row + 1), ruler / 2, y + cell / 2);
|
||
}
|
||
pixels.forEach(({ color }, index) => {
|
||
const x = ruler + (index % gridWidth) * cell;
|
||
const y = ruler + Math.floor(index / gridWidth) * cell;
|
||
if (!color) {
|
||
ctx.clearRect(x, y, cell, cell);
|
||
if (showGrid) {
|
||
ctx.strokeStyle = "rgba(32,38,36,.12)";
|
||
ctx.lineWidth = 0.6;
|
||
ctx.strokeRect(x + 0.3, y + 0.3, cell - 0.6, cell - 0.6);
|
||
}
|
||
return;
|
||
}
|
||
const hasSelection = selectedCodes.size > 0;
|
||
const isSelected = selectedCodes.has(color.code);
|
||
if (onlySelected && hasSelection && !isSelected) {
|
||
ctx.fillStyle = "#ffffff";
|
||
} else {
|
||
ctx.fillStyle = color.hex;
|
||
ctx.globalAlpha = hasSelection && !isSelected ? 0.12 : 1;
|
||
ctx.fillRect(x, y, cell, cell);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
if (showGrid) {
|
||
ctx.strokeStyle = hasSelection && !isSelected ? "rgba(32,38,36,.07)" : "rgba(32,38,36,.22)";
|
||
ctx.lineWidth = 0.6;
|
||
ctx.strokeRect(x + 0.3, y + 0.3, cell - 0.6, cell - 0.6);
|
||
}
|
||
if (showCodes && cell >= 22 && (!hasSelection || isSelected)) {
|
||
const [r, g, b] = hexToRgb(color.hex);
|
||
ctx.fillStyle = r * 0.299 + g * 0.587 + b * 0.114 > 160 ? "#1f2925" : "#ffffff";
|
||
ctx.font = `600 ${Math.max(7, cell * 0.28)}px Arial`;
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText(color.code, x + cell / 2, y + cell / 2);
|
||
}
|
||
});
|
||
if (showGrid) {
|
||
ctx.strokeStyle = "rgba(32,38,36,.48)";
|
||
ctx.lineWidth = 1.5;
|
||
for (let column = 5; column < gridWidth; column += 5) {
|
||
const x = ruler + column * cell;
|
||
ctx.beginPath(); ctx.moveTo(x, ruler); ctx.lineTo(x, ruler + gridHeight * cell); ctx.stroke();
|
||
}
|
||
for (let row = 5; row < gridHeight; row += 5) {
|
||
const y = ruler + row * cell;
|
||
ctx.beginPath(); ctx.moveTo(ruler, y); ctx.lineTo(ruler + gridWidth * cell, y); ctx.stroke();
|
||
}
|
||
}
|
||
}, [pixels, gridWidth, gridHeight, zoom, selectedCodes, onlySelected, showGrid, showCodes]);
|
||
|
||
const handleUpload = (event: ChangeEvent<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?.color) 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?.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 = (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 ?? null),
|
||
};
|
||
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: 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 = (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-viewport">
|
||
<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>
|
||
|
||
<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} 种颜色 · {beadCount} 颗</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) => { if (code) colorCounts.set(code, (colorCounts.get(code) ?? 0) + 1); });
|
||
const topCodes = [...colorCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
|
||
return <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>
|
||
);
|
||
}
|