1752 lines
76 KiB
TypeScript
1752 lines
76 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";
|
||
|
||
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 PALETTE_BY_CODE = new Map(PALETTE.map((color) => [color.code, color]));
|
||
|
||
const hexToRgb = (hex: string): [number, number, number] => [
|
||
parseInt(hex.slice(1, 3), 16),
|
||
parseInt(hex.slice(3, 5), 16),
|
||
parseInt(hex.slice(5, 7), 16),
|
||
];
|
||
|
||
function HistoryThumbnail({ entry }: { entry: SavedPattern }) {
|
||
const thumbnailRef = useRef<HTMLCanvasElement | null>(null);
|
||
useEffect(() => {
|
||
const canvas = thumbnailRef.current;
|
||
if (!canvas) return;
|
||
canvas.width = Math.max(1, entry.width);
|
||
canvas.height = Math.max(1, entry.height);
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
entry.codes.forEach((code, index) => {
|
||
if (!code) return;
|
||
const color = PALETTE_BY_CODE.get(code);
|
||
if (!color) return;
|
||
ctx.fillStyle = color.hex;
|
||
ctx.fillRect(index % entry.width, Math.floor(index / entry.width), 1, 1);
|
||
});
|
||
}, [entry]);
|
||
return <div className="history-thumbnail" aria-label={`${entry.name} 的完整格子图缩略图`}>
|
||
<canvas ref={thumbnailRef} />
|
||
</div>;
|
||
}
|
||
|
||
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 = color.code;
|
||
const [red, green, blue] = hexToRgb(color.hex);
|
||
ctx.fillStyle = red * 0.299 + green * 0.587 + blue * 0.114 > 160 ? "#18211d" : "#ffffff";
|
||
let fontSize = Math.max(6, Math.floor(cell * 0.29));
|
||
ctx.font = `700 ${fontSize}px Arial`;
|
||
while (fontSize > 6 && ctx.measureText(label).width > cell - 3) {
|
||
fontSize -= 1;
|
||
ctx.font = `700 ${fontSize}px Arial`;
|
||
}
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText(label, x + cell / 2, y + cell / 2);
|
||
}
|
||
ctx.strokeStyle = "rgba(32,38,36,.24)";
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(x + 0.5, y + 0.5, cell - 1, cell - 1);
|
||
});
|
||
|
||
ctx.strokeStyle = "rgba(32,38,36,.58)";
|
||
ctx.lineWidth = 2;
|
||
for (let column = 5; column < gridWidth; column += 5) {
|
||
const x = ruler + column * cell;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, ruler);
|
||
ctx.lineTo(x, ruler + gridPixelHeight);
|
||
ctx.stroke();
|
||
}
|
||
for (let row = 5; row < gridHeight; row += 5) {
|
||
const y = ruler + row * cell;
|
||
ctx.beginPath();
|
||
ctx.moveTo(ruler, y);
|
||
ctx.lineTo(ruler + gridPixelWidth, y);
|
||
ctx.stroke();
|
||
}
|
||
ctx.strokeStyle = "#202624";
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(ruler, ruler, gridPixelWidth, gridPixelHeight);
|
||
|
||
const legendTop = ruler + gridPixelHeight;
|
||
if (usedColors.length > 0) {
|
||
ctx.strokeStyle = "#d7d2c7";
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, legendTop + 0.5);
|
||
ctx.lineTo(canvasWidth, legendTop + 0.5);
|
||
ctx.stroke();
|
||
usedColors.forEach((color, index) => {
|
||
const column = index % legendColumns;
|
||
const row = Math.floor(index / legendColumns);
|
||
const x = legendPadding + column * legendItemWidth;
|
||
const y = legendTop + legendPadding + row * legendItemHeight;
|
||
ctx.fillStyle = color.hex;
|
||
ctx.beginPath();
|
||
ctx.arc(x + 11, y + 11, 9, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.strokeStyle = "rgba(32,38,36,.28)";
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
ctx.fillStyle = "#202624";
|
||
ctx.font = "700 13px Arial";
|
||
ctx.textAlign = "left";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText(color.code, x + 26, y + 11);
|
||
});
|
||
}
|
||
|
||
canvas.toBlob((blob) => {
|
||
if (!blob) {
|
||
setStatus("PNG 生成失败,请减少图纸格数后重试");
|
||
return;
|
||
}
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.download = `拼豆图纸-${gridWidth}x${gridHeight}.png`;
|
||
link.href = url;
|
||
link.click();
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
setStatus("PNG 已生成:格内为 MARD 色号,底部为色块和对应色号");
|
||
logUsage({ event: "download_png", width: gridWidth, height: gridHeight, colors: usedColors.length });
|
||
}, "image/png");
|
||
};
|
||
|
||
return (
|
||
<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) => {
|
||
return <article className="history-card" key={entry.id}>
|
||
<HistoryThumbnail entry={entry} />
|
||
<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>
|
||
);
|
||
}
|