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

1731 lines
76 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 CropRect = {
x: number;
y: number;
width: number;
height: number;
};
type CropSelectionMode = "free" | "square";
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<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 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<IDBDatabase>((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<SavedPattern[]> {
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<void>((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<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]);
}
function isPerceptualBridge(color: Oklab, first: Oklab, second: Oklab) {
const vector = [second[0] - first[0], second[1] - first[1], second[2] - first[2]] as Oklab;
const lengthSquared = vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2;
if (lengthSquared < 0.07 ** 2) return false;
const offset = [color[0] - first[0], color[1] - first[1], color[2] - first[2]] as Oklab;
const position = (offset[0] * vector[0] + offset[1] * vector[1] + offset[2] * vector[2]) / lengthSquared;
if (position < 0.12 || position > 0.88) return false;
const projection: Oklab = [
first[0] + vector[0] * position,
first[1] + vector[1] * position,
first[2] + vector[2] * position,
];
return oklabDistance(color, projection) <= 0.025;
}
const PALETTE: BeadColor[] = MARD_221.map(([code, hex]) => {
const rgb: [number, number, number] = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
return { code, name: `MARD ${MARD_SERIES_NAMES[code[0]]}`, hex, rgb, lab: rgbToOklab(rgb) };
});
const hexToRgb = (hex: string): [number, number, number] => [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
];
function nearestColor(lab: Oklab, palette: BeadColor[]) {
let closest = palette[0];
let smallest = Number.POSITIVE_INFINITY;
for (const color of palette) {
const distance = oklabDistance(lab, color.lab);
if (distance < smallest) {
smallest = distance;
closest = color;
}
}
return closest;
}
function mergePerceptualColors(data: Uint8ClampedArray) {
const pixelKeys: Array<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[] = [];
// 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<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 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<BeadColor | null>,
sourceData: Uint8ClampedArray,
width: number,
height: number,
colorLimit: number,
) {
if (colorLimit < 12) return colors;
const result = [...colors];
const sourceLabs: Array<Oklab | null> = [];
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<string>();
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<BeadColor | null>, width: number, height: number) {
const usage = new Map<string, number>();
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<string, { color: BeadColor; count: number }>();
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<BeadColor | null>, 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<string, { color: BeadColor; count: number }>();
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<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",
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<SamplingStrategy>("dominant");
const [cropRect, setCropRect] = useState<CropRect>(FULL_CROP);
const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
const [cropSelectionMode, setCropSelectionMode] = useState<CropSelectionMode>("free");
const [cropDialogOpen, setCropDialogOpen] = useState(false);
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 cropDialogCanvasRef = useRef<HTMLCanvasElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const patternStageRef = useRef<HTMLDivElement | null>(null);
const zoomRef = useRef(zoom);
const pendingWheelZoomRef = useRef<{
stage: HTMLDivElement;
cursorX: number;
cursorY: number;
canvasX: number;
canvasY: number;
} | null>(null);
const cropDialogStageRef = useRef<HTMLDivElement | null>(null);
const cropSelectionDragRef = useRef<CropSelectionDrag | null>(null);
const patternDragRef = useRef<{
pointerId: number;
x: number;
y: number;
scrollLeft: number;
scrollTop: number;
moved: boolean;
} | null>(null);
const suppressCanvasClickRef = useRef(false);
useEffect(() => {
logUsage({ event: "page_view" });
let cancelled = false;
const loadHistory = async () => {
try {
let saved = await readPatternHistory();
const legacy = localStorage.getItem(HISTORY_STORAGE_KEY);
if (saved.length === 0 && legacy) {
const parsed = JSON.parse(legacy);
if (Array.isArray(parsed)) {
saved = parsed.slice(0, HISTORY_LIMIT);
await writePatternHistory(saved);
}
localStorage.removeItem(HISTORY_STORAGE_KEY);
}
if (!cancelled) setHistory(saved.slice(0, HISTORY_LIMIT));
} catch {
if (!cancelled) setStatus("历史记录读取失败,请检查浏览器是否允许本地存储");
}
};
void loadHistory();
return () => { cancelled = true; };
}, []);
const convertImage = (
image = sourceImageRef.current,
crop = 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<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 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 = sourceImageRef.current;
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, sourceUrl]);
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<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]);
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<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);
setCropRect(FULL_CROP);
setDraftCropRect(FULL_CROP);
setStatus("图片已载入,点击“重新转换”生成图纸");
convertImage(image, FULL_CROP);
};
image.src = url;
};
const openCropDialog = () => {
setDraftCropRect(cropRect);
setCropDialogOpen(true);
};
const cropPoint = (event: React.PointerEvent<HTMLDivElement>) => {
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 = sourceImageRef.current;
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 = sourceImageRef.current;
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 confirmCrop = () => {
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
setStatus("框选区域太小,请在大图上拖出更大的选框");
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<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 = 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 = `${rowNumberToLetters(row + 1)}${column + 1}`;
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 (
<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)}` }}>
<canvas ref={cropCanvasRef} aria-label="当前取景区域预览" />
<button
type="button"
className="upload-overlay"
onClick={() => 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>
<button className="crop-select-button" onClick={openCropDialog}>选择取景区域</button>
<button className="reset-crop" onClick={() => { setCropRect(FULL_CROP); setDraftCropRect(FULL_CROP); setStatus("已恢复使用整张图片"); }}>使用整张图片</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="dominant">区域主色(减少杂色)</option><option value="smooth">平滑取色(细节丰富)</option></select></label>
<label className="field"><span>图片适应方式</span><select value={fitMode} onChange={(e) => setFitMode(e.target.value as "cover" | "contain")}><option value="contain">完整显示</option><option value="cover">裁剪铺满</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(3, z - 1))} aria-label="缩小"></button>
<input aria-label="缩放比例" type="range" min="3" max="42" step="1" value={zoom} onChange={(e) => setZoom(Number(e.target.value))} />
<button onClick={() => setZoom((z) => Math.min(42, z + 1))} aria-label="放大"></button>
<output>{zoom}px</output>
</div>
</div>
<div className="canvas-viewport">
<div
className="canvas-stage"
ref={patternStageRef}
title="鼠标位于预览区时,可滚动滚轮缩放图纸"
>
<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>
{cropDialogOpen && <div className="crop-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setCropDialogOpen(false); }}>
<section className="crop-dialog" role="dialog" aria-modal="true" aria-labelledby="crop-dialog-title">
<div className="crop-dialog-header">
<div><p className="section-kicker">IMAGE CROP</p><h2 id="crop-dialog-title">框选需要转换的区域</h2><p>框内拖动可移动,控制点可调整大小;生成格子时以所选区域正中心为采样基准。</p></div>
<button aria-label="关闭取景窗口" onClick={() => setCropDialogOpen(false)}>×</button>
</div>
<div className="crop-mode-bar">
<span>选框形状</span>
<div className="crop-mode-switch" role="group" aria-label="选框形状">
<button className={cropSelectionMode === "free" ? "active" : ""} aria-pressed={cropSelectionMode === "free"} onClick={() => chooseCropSelectionMode("free")}>自由矩形</button>
<button className={cropSelectionMode === "square" ? "active" : ""} aria-pressed={cropSelectionMode === "square"} onClick={() => chooseCropSelectionMode("square")}>正方形</button>
</div>
<span className="crop-size-readout">当前选框:{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}%</span>
</div>
<div className="crop-dialog-scroll">
<div
className="crop-dialog-stage"
ref={cropDialogStageRef}
onPointerDown={handleCropSelectionDown}
onPointerMove={handleCropSelectionMove}
onPointerUp={handleCropSelectionEnd}
onPointerCancel={handleCropSelectionEnd}
onPointerLeave={(event) => { if (!cropSelectionDragRef.current) event.currentTarget.classList.remove("can-move-selection"); }}
>
<canvas ref={cropDialogCanvasRef} aria-label="可框选的原始大图" />
<div className="crop-selection-mask crop-selection-top" style={{ height: `${draftCropRect.y * 100}%` }} />
<div className="crop-selection-mask crop-selection-left" style={{ top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.x * 100}%`, height: `${draftCropRect.height * 100}%` }} />
<div className="crop-selection-mask crop-selection-right" style={{ top: `${draftCropRect.y * 100}%`, left: `${(draftCropRect.x + draftCropRect.width) * 100}%`, right: 0, height: `${draftCropRect.height * 100}%` }} />
<div className="crop-selection-mask crop-selection-bottom" style={{ top: `${(draftCropRect.y + draftCropRect.height) * 100}%` }} />
<div className="crop-selection-box" style={{ left: `${draftCropRect.x * 100}%`, top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.width * 100}%`, height: `${draftCropRect.height * 100}%` }}>
{(["nw", "ne", "sw", "se"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)}
{cropSelectionMode === "free" && (["n", "s", "e", "w"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle crop-edge-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)}
</div>
</div>
</div>
<div className="crop-dialog-actions">
<button onClick={() => { setCropSelectionMode("free"); setDraftCropRect(FULL_CROP); }}>选择整张图</button>
<span>框外拖动可重选,框内拖动可移动,控制点可缩放</span>
<button onClick={() => setCropDialogOpen(false)}>取消</button>
<button className="confirm" disabled={draftCropRect.width < 0.03 || draftCropRect.height < 0.03} onClick={confirmCrop}>使用此区域</button>
</div>
</section>
</div>}
<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>
);
}