Initial commit

This commit is contained in:
Zylan
2025-04-25 01:39:02 +08:00
parent 8821c4aeae
commit b033e2426c
3 changed files with 708 additions and 120 deletions

126
README.md
View File

@@ -1,36 +1,116 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# 拼豆底稿生成器
> Perler Beads Generator (Palette Mapping Edition)
## Getting Started
一个基于 Web 的工具可以将普通图片转换为适配Mard特定调色板拼豆颜色的像素画图纸。用户可以上传图片调整像素化粒度预览效果并下载带有颜色编码的网格图纸和对应的 JSON 数据。
First, run the development server:
## 功能特点
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
* **图片上传**: 支持拖放或点击选择 JPG/PNG 图片。
* **可调粒度**: 通过滑块控制像素化的精细程度(网格宽度)。
* **颜色映射**: 将图像颜色自动映射到预定义的拼豆调色板。
* **实时预览**: 在网页上即时显示映射后的像素画预览(带网格线,无颜色编码)。
* **带 Key 图纸下载**: 下载带有颜色编码Key和网格线的清晰 PNG 图纸。
* **JSON 数据下载**: 下载包含每个格子对应颜色编码的二维数组 JSON 文件。
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## 技术实现
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
* **框架**: [Next.js](https://nextjs.org/) (React) 与 TypeScript
* **样式**: [Tailwind CSS](https://tailwindcss.com/) 用于响应式布局和样式。
* **核心逻辑**: 浏览器端 [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) 用于图像处理和绘制。
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
### 核心算法:像素化与颜色映射
## Learn More
应用程序的核心在于将任意图像颜色精确映射到有限的拼豆调色板上。主要步骤如下:
To learn more about Next.js, take a look at the following resources:
1. **图像加载与预处理**:
* 用户上传图片后,使用 `FileReader` 将其读取为 Data URL。
* 创建一个 `Image` 对象加载该 URL。
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
2. **网格划分**:
* 根据用户选择的"精细度"(`granularity`) 确定像素画在宽度方向上的格子数量 `N`
* 根据原图的宽高比计算高度方向的格子数量 `M = round(N * height / width)`,确保 `M` 至少为 1。
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
3. **平均颜色计算**:
* 在内存中创建一个隐藏的 Canvas (`originalCanvasRef`),并绘制原始图片。
* 遍历 `N x M` 网格中的每一个单元格。
* 对于每个单元格,计算其在原图上对应的像素区域。
* 使用 `originalCtx.getImageData()` 获取该区域内所有像素的 RGBA 数据。
* 计算该区域内所有**不完全透明**例如Alpha > 128像素的**平均 RGB 值**。忽略 Alpha 值本身用于颜色距离计算,只关注颜色本身。如果单元格内所有像素都透明,则将其视为默认颜色(如白色 T1
## Deploy on Vercel
4. **颜色映射 (关键步骤)**:
* **调色板**: 项目代码中预先定义了一个 `beadPalette` 数组,包含每个拼豆颜色的 Key (如 "H7")、Hex 值 (如 "#000000") 和预计算的 RGB 值。
* **查找最近色**: 对于上一步计算出的每个单元格的平均 RGB 值 (`avgRgb`),调用 `findClosestPaletteColor` 函数。
* **距离度量**: 该函数遍历 `beadPalette` 中的所有颜色,使用**欧氏距离**计算 `avgRgb` 与调色板中每个颜色 RGB 值之间的距离:
\[ d = \sqrt{(R_{avg}-R_{palette})^2 + (G_{avg}-G_{palette})^2 + (B_{avg}-B_{palette})^2} \]
* **匹配**: 选择欧氏距离最小的那个调色板颜色作为该单元格的最终映射结果。
* **存储结果**: 将每个单元格匹配到的拼豆 Key 和 Hex 颜色存储在一个二维数组状态 `mappedPixelData` 中。
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
5. **生成预览图**:
* 获取页面上可见的 Canvas (`pixelatedCanvasRef`) 的上下文 `pixelatedCtx`
* 遍历 `mappedPixelData`
* 对于每个单元格,使用其**映射后的拼豆颜色** (`mappedPixelData[j][i].color`) 填充对应的矩形区域。
* 在每个填充的色块上绘制**浅灰色细边框**,形成网格线效果。
* **注意**: 预览图上不绘制颜色 Key仅显示颜色和网格。
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
6. **生成带 Key 的下载图纸 (`handleDownloadImage`)**:
* 用户点击"下载图纸 (带 Key)"按钮时触发。
* 动态创建**新的、临时的 Canvas** (`downloadCanvas`)。
* 定义一个固定的单元格渲染尺寸 `downloadCellSize`(例如 30 像素),确保足够容纳文字。
* 设置 `downloadCanvas` 的尺寸为 `(N * downloadCellSize) x (M * downloadCellSize)`
* 获取其上下文 `ctx` 并设置 `ctx.imageSmoothingEnabled = false` 以保证像素块和文字的清晰度。
* 遍历 `mappedPixelData`
* 使用**映射后的拼豆颜色**填充 `downloadCellSize x downloadCellSize` 的背景矩形。
* 绘制**灰色细边框**。
* 使用 `getContrastColor` 函数(基于亮度计算)选择与背景色对比度高的文字颜色(黑色或白色)。
* 在单元格中央绘制对应的**拼豆颜色 Key** (`mappedPixelData[j][i].key`)。
* 使用 `downloadCanvas.toDataURL('image/png')` 生成图片数据并触发下载。
7. **生成 JSON 数据 (`handleDownloadJson`)**:
* 用户点击"下载数据 (JSON)"按钮时触发。
*`mappedPixelData` 提取出一个只包含颜色 Key 的二维数组 `keyGrid`
*`keyGrid` 序列化为格式化的 JSON 字符串。
* 创建 Blob 对象并生成可下载的 `.json` 文件。
### 调色板数据
拼豆的颜色数据定义在 `src/app/page.tsx` 文件顶部的 `beadPaletteData` 对象中。该数据由用户提供,并预处理为包含 Key、Hex 和 RGB 值的 `beadPalette` 数组。
**重要**: 调色板数据的准确性直接影响最终的颜色映射结果。请在使用前仔细核对 Key 和 Hex 值。如有需要,可以直接修改 `beadPaletteData` 来添加、删除或修改颜色。
## 本地开发
1. 克隆项目:
```bash
git clone <repository-url>
cd perler-beads-generator
```
2. 安装依赖:
```bash
npm install
# or yarn install or pnpm install
```
3. 启动开发服务器:
```bash
npm run dev
# or yarn dev or pnpm dev
```
4. 在浏览器中打开 `http://localhost:3000`。
## 部署
该项目可以轻松部署到 [Vercel](https://vercel.com/) 平台:
1. 将代码推送到 GitHub/GitLab/Bitbucket 仓库。
2. 在 Vercel 上导入该 Git 仓库。
3. Vercel 会自动识别 Next.js 项目并进行部署。
## 未来可能的改进
* **颜色距离算法**: 使用更符合人类视觉感知的颜色距离算法,如 CIEDE2000 (Delta E),以获得更精确的颜色匹配(但这会显著增加计算复杂度)。
* **性能优化**: 对于超大图片或极高精细度,考虑使用 Web Workers 将图像处理和颜色计算移到后台线程,防止 UI 卡顿。
* **调色板管理**: 提供 UI 界面允许用户上传、编辑或选择不同的调色板。
* **预览交互**: 在预览图上悬停显示颜色 Key 或统计颜色用量。
## 许可证
Apache 2.0

View File

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "像素画生成器 | Perler Beads Generator",
description: "上传图片,调整精细度,一键生成像素画图纸,简单实用的像素画生成工具",
};
export default function RootLayout({

View File

@@ -1,103 +1,611 @@
import Image from "next/image";
'use client';
import React, { useState, useRef, ChangeEvent, DragEvent, useEffect } from 'react';
// Image component from next/image might not be strictly needed if you only use canvas and basic elements,
// but keep it if you plan to add other images later or use the SVG icon below.
import Image from 'next/image';
// Helper function to convert Hex to RGB
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
// Helper function to calculate Euclidean distance in RGB space
function colorDistance(rgb1: { r: number; g: number; b: number }, rgb2: { r: number; g: number; b: number }): number {
const dr = rgb1.r - rgb2.r;
const dg = rgb1.g - rgb2.g;
const db = rgb1.b - rgb2.b;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
// Interface for our palette colors
interface PaletteColor {
key: string;
hex: string;
rgb: { r: number; g: number; b: number };
}
// The Bead Palette (parsed from your table - ensure accuracy!)
// IMPORTANT: Corrected ZG5 hex value assuming typo. Manually verify all values.
// Added T1 (White) as it's often crucial. Add H7 (Black) if it wasn't just a mix placeholder.
const beadPaletteData: { [key: string]: string } = {
"ZG1": "#DAABB3", "B16": "#C5ED9C", "D4": "#182A84", "F3": "#F74941", "H6": "#2F2B2F", "P17": "#FEA324",
"ZG2": "#D6AA87", "B17": "#9BB13A", "D5": "#B843C5", "F4": "#FC283C", "H7": "#000000", "P18": "#FEB89F", // Assuming H7 mix is Black
"ZG3": "#C1BD8D", "B18": "#E6EE49", "D6": "#AC7BDE", "F5": "#E7002F", "H8": "#E7D6DB", "P19": "#FFFEEC", // Corrected P19? was FFE0E9
"ZG4": "#96869F", "B19": "#24B88C", "D7": "#8854B3", "F6": "#943630", "H9": "#EDEDED", "P20": "#FEBECF",
"ZG5": "#8490A6", "B20": "#C2F0CC", "D8": "#E2D3FF", "F7": "#971937", "H10": "#EEE9EA", "P21": "#ECBEBF", // Corrected ZG5 from 8490.6
"ZG6": "#94BFE2", "B21": "#156A6B", "D9": "#D5B9F8", "F8": "#BC0028", "H11": "#CECDD5", "P22": "#E4A89F",
"ZG7": "#E2A9D2", "B22": "#0B3C43", "D10": "#361851", "F9": "#E2677A", "H12": "#FFF5ED", "P23": "#A56268",
"ZG8": "#AB91C0", "B23": "#303A21", "D11": "#B9BAE1", "F10": "#8A4526", "H13": "#F5ECD2", "Q1": "#F2A5E8", // Duplicated H13 key? Using first definition.
"A1": "#FAF4C8", "B24": "#EEFCA5", "D12": "#DE9AD4", "F11": "#5A2121", "H14": "#CFD7D3", "Q2": "#E9EC91",
"A2": "#FFFFD5", "B25": "#4E846D", "D13": "#B90095", "F12": "#FD4E6A", "H15": "#98A6A8", "Q3": "#FFFF00",
"A3": "#FEFF8B", "B26": "#8D7A35", "D14": "#8B279B", "F13": "#F35744", "H16": "#1D1414", "Q4": "#FFEBFA",
"A4": "#FBED56", "B27": "#CCE1AF", "D15": "#2F1F90", "F14": "#FFA9AD", "H17": "#F1EDED", "Q5": "#76CEDE",
"A5": "#F4D738", "B28": "#9EE5B9", "D16": "#E3E1EE", "F15": "#D30022", "H18": "#FFFDF0", "R1": "#D50D21",
"A6": "#FEAC4C", "B29": "#C5E254", "D17": "#C4D4F6", "F16": "#FEC2A6", "H19": "#F6EFE2", "R2": "#F92F83",
"A7": "#FE8B4C", "B30": "#E2FCB1", "D18": "#A45EC7", "F17": "#E69C79", "H20": "#949FA3", "R3": "#FD8324",
"A8": "#FFDA45", "B31": "#B0E792", "D19": "#D8C3D7", "F18": "#D37C46", "H21": "#FFFBE1", "R4": "#F8EC31",
"A9": "#FF995B", "B32": "#9CAB5A", "D20": "#9C32B2", "F19": "#C1444A", "H22": "#CACAD4", "R5": "#35C75B",
"A10": "#F77C31", "C1": "#E8FFE7", "D21": "#9A009B", "F20": "#CD9391", "H23": "#9A9D94", "R6": "#238891",
"A11": "#FFDD99", "C2": "#A9F9FC", "D22": "#333A95", "F21": "#F7B4C6", "M1": "#BCC6B8", "R7": "#19779D",
"A12": "#FE9F72", "C3": "#A0E2FB", "D23": "#EBDAFC", "F22": "#FDC0D0", "M2": "#8AA386", "R8": "#1A60C3",
"A13": "#FFC365", "C4": "#41CCFF", "D24": "#7786E5", "F23": "#F67E66", "M3": "#697D80", "R9": "#9A56B4",
"A14": "#FD543D", "C5": "#01ACEB", "D25": "#494FC7", "F24": "#E698AA", "M4": "#E3D2BC", "R10": "#FFDB4C",
"A15": "#FFF365", "C6": "#50AAF0", "D26": "#DFC2F8", "F25": "#E54B4F", "M5": "#D0CCAA", "R11": "#FFEBFA", // Duplicate Q4
"A16": "#FFFF9F", "C7": "#3677D2", "E1": "#FDD3CC", "G1": "#FFE2CE", "M6": "#B0A782", "R12": "#D8D5CE",
"A17": "#FFE36E", "C8": "#0F54C0", "E2": "#FEC0DF", "G2": "#FFC4AA", "M7": "#B4A497", "R13": "#55514C",
"A18": "#FEBE7D", "C9": "#324BCA", "E3": "#FFB7E7", "G3": "#F4C3A5", "M8": "#B38281", "R14": "#9FE4DF",
"A19": "#FD7C72", "C10": "#3EBCE2", "E4": "#E8649E", "G4": "#E1B383", "M9": "#A58767", "R15": "#77CEE9",
"A20": "#FFD568", "C11": "#28DDDE", "E5": "#F551A2", "G5": "#EDB045", "M10": "#C5B2BC", "R16": "#3ECFCA",
"A21": "#FFE395", "C12": "#1C334D", "E6": "#F13D74", "G6": "#E99C17", "M11": "#9F7594", "R17": "#4A867A",
"A22": "#F4F57D", "C13": "#CDE8FF", "E7": "#C63478", "G7": "#9D5B3E", "M12": "#644749", "R18": "#7FCD9D",
"A23": "#E6C9B7", "C14": "#D5FDFF", "E8": "#FFDBE9", "G8": "#753832", "M13": "#D19066", "R19": "#CDE55D",
"A24": "#F7F8A2", "C15": "#22C4C6", "E9": "#E970CC", "G9": "#E6B483", "M14": "#C77362", "R20": "#E8C7B4",
"A25": "#FFD67D", "C16": "#1557A8", "E10": "#D33793", "G10": "#D98C39", "M15": "#757D78", "R21": "#AD6F3C",
"A26": "#FFC830", "C17": "#04D1F6", "E11": "#FCDDD2", "G11": "#E0C593", "P1": "#FCF7F8", "R22": "#6C372F",
"B1": "#E6EE31", "C18": "#1D3344", "E12": "#F78FC3", "G12": "#FFC890", "P2": "#B0A9AC", "R23": "#FEB872",
"B2": "#63F347", "C19": "#1887A2", "E13": "#B5006D", "G13": "#B7714A", "P3": "#AFDCAB", "R24": "#F3C1C0",
"B3": "#9EF780", "C20": "#176DAF", "E14": "#FFD1BA", "G14": "#8D614C", "P4": "#FEA49F", "R25": "#C9675E",
"B4": "#5DE035", "C21": "#BEDDFF", "E15": "#F8C7C9", "G15": "#FCF9E0", "P5": "#EE8C3E", "R26": "#D293BE",
"B5": "#35E352", "C22": "#67B4BE", "E16": "#FFF3EB", "G16": "#F2D9BA", "P6": "#5FD0A7", "R27": "#EA8CB1", // Corrected P6 from 5FDOA7
"B6": "#65E2A6", "C23": "#C8E2FF", "E17": "#FFE2EA", "G17": "#78524B", "P7": "#EB9270", "R28": "#9C87D6",
"B7": "#3DAF80", "C24": "#7CC4FF", "E18": "#FFC7DB", "G18": "#FFE4CC", "P8": "#F0D958", "T1": "#FFFFFF", // Added T1 White
"B8": "#1C9C4F", "C25": "#A9E5E5", "E19": "#FEBAD5", "G19": "#E07935", "P9": "#D9D9D9", "Y1": "#FD6FB4",
"B9": "#27523A", "C26": "#3CAED8", "E20": "#D8C7D1", "G20": "#A94023", "P10": "#D9C7EA", "Y2": "#FEB481",
"B10": "#95D3C2", "C27": "#D3DFFA", "E21": "#BD9DA1", "G21": "#B88558", "P11": "#F3ECC9", "Y3": "#D7FAA0", // Corrected Y3 from D7FAAO
"B11": "#5D722A", "C28": "#BBCFED", "E22": "#B785A1", /*H13: "#FDFBFF", Duplicate*/ "H2": "#FEFFFF", "P12": "#E6EEF2", "Y4": "#8BDBFA",
"B12": "#166F41", "C29": "#34488E", "E23": "#937A8D", /*H2: "#FEFFFF", Duplicate*/ "H3": "#B6B1BA", "P13": "#AACBEF", "Y5": "#E987EA",
"B13": "#CAEB7B", "D1": "#AEB4F2", "E24": "#E1BCE8", "H4": "#89858C", "P14": "#337680",
"B14": "#ADE946", "D2": "#858EDD", "F1": "#FD957B", "H5": "#48464E", "P15": "#668575",
"B15": "#2E5132", "D3": "#2F54AF", "F2": "#FC3D46", /*H5: "#48464E", Duplicate*/ "P16": "#FEBF45",
};
// Pre-process the palette for easier use
const beadPalette: PaletteColor[] = Object.entries(beadPaletteData)
.map(([key, hex]) => {
const rgb = hexToRgb(hex);
// Filter out invalid hex codes during processing
if (!rgb) {
console.warn(`Invalid hex code "${hex}" for key "${key}". Skipping.`);
return null;
}
return { key, hex, rgb };
})
.filter((color): color is PaletteColor => color !== null); // Type guard to remove nulls
// Helper function to find the closest color in the palette
function findClosestPaletteColor(
avgRgb: { r: number; g: number; b: number },
palette: PaletteColor[]
): PaletteColor {
let minDistance = Infinity;
let closestColor = palette[0]; // Default to the first color
if (!closestColor) {
// Handle case where palette might be empty after filtering
// Return a default or throw an error
console.error("Bead palette is empty or invalid!");
// Returning a dummy black color to prevent crashes downstream
return { key: 'ERR', hex: '#000000', rgb: { r: 0, g: 0, b: 0 } };
}
for (const paletteColor of palette) {
const distance = colorDistance(avgRgb, paletteColor.rgb);
if (distance < minDistance) {
minDistance = distance;
closestColor = paletteColor;
}
// Optimization: if distance is 0, we found an exact match
if (distance === 0) break;
}
return closestColor;
}
// Helper to get contrasting text color (simple version)
function getContrastColor(hex: string): string {
const rgb = hexToRgb(hex);
if (!rgb) return '#000000'; // Default to black
// Simple brightness check (Luma formula Y = 0.2126 R + 0.7152 G + 0.0722 B)
const luma = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
return luma > 0.5 ? '#000000' : '#FFFFFF'; // Dark background -> white text, Light background -> black text
}
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
const [granularity, setGranularity] = useState<number>(50);
const originalCanvasRef = useRef<HTMLCanvasElement>(null);
const pixelatedCanvasRef = useRef<HTMLCanvasElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 新增状态:存储映射后的像素数据 { key: string, color: string (hex) }
const [mappedPixelData, setMappedPixelData] = useState<{ key: string; color: string }[][] | null>(null);
const [gridDimensions, setGridDimensions] = useState<{ N: number; M: number } | null>(null);
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
// Handle file selection via input click
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
processFile(file);
}
};
// Handle file drop
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.files && event.dataTransfer.files[0]) {
const file = event.dataTransfer.files[0];
if (file.type.startsWith('image/')) {
processFile(file);
} else {
alert("请拖放图片文件 (JPG, PNG)");
}
}
};
// Handle drag over event
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation(); // Necessary to allow dropping
};
// Process the selected/dropped file
const processFile = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
setOriginalImageSrc(result);
setMappedPixelData(null); // Clear mapped data on new file
setGridDimensions(null);
};
reader.onerror = () => {
console.error("文件读取失败");
alert("无法读取文件。");
}
reader.readAsDataURL(file);
};
// Handle granularity slider change
const handleGranularityChange = (event: ChangeEvent<HTMLInputElement>) => {
const newGranularity = parseInt(event.target.value, 10);
setGranularity(newGranularity);
};
// Core function: Pixelate the image
const pixelateImage = (imageSrc: string, detailLevel: number) => {
console.log("Attempting to pixelate and map colors...");
const originalCanvas = originalCanvasRef.current;
const pixelatedCanvas = pixelatedCanvasRef.current;
// Enhanced checks for refs
if (!originalCanvas) {
console.error("Original canvas ref is not available.");
return;
}
if (!pixelatedCanvas) {
console.error("Pixelated canvas ref is not available.");
return;
}
const originalCtx = originalCanvas.getContext('2d', { willReadFrequently: true });
const pixelatedCtx = pixelatedCanvas.getContext('2d');
// Enhanced checks for contexts
if (!originalCtx) {
console.error("Original canvas context not found.");
return;
}
if (!pixelatedCtx) {
console.error("Pixelated canvas context not found.");
return;
}
console.log("Canvas contexts obtained.");
const img = new window.Image(); // Use window.Image for clarity in browser environment
img.onload = () => {
console.log("Image loaded successfully.");
// 1. Determine grid dimensions (N x M)
const aspectRatio = img.height / img.width;
const N = detailLevel; // Number of cells horizontally
const M = Math.max(1, Math.round(N * aspectRatio)); // Number of cells vertically, ensure at least 1
if (N <= 0 || M <= 0) {
console.error("Invalid grid dimensions calculated:", { N, M });
return;
}
console.log(`Grid size calculated: ${N}x${M}`);
// 2. Set Canvas dimensions
// Output size can be fixed or dynamic. Fixed makes UI predictable.
const outputWidth = 500; // Example fixed output width
const outputHeight = Math.round(outputWidth * aspectRatio);
originalCanvas.width = img.width; // Use original size for accurate color sampling
originalCanvas.height = img.height;
pixelatedCanvas.width = outputWidth;
pixelatedCanvas.height = outputHeight;
console.log(`Canvas dimensions set: Original ${img.width}x${img.height}, Output ${outputWidth}x${outputHeight}`);
// 3. Draw original image onto the hidden canvas for pixel reading
originalCtx.drawImage(img, 0, 0, img.width, img.height);
console.log("Original image drawn on hidden canvas.");
// 4. Calculate cell dimensions in the original image coordinate system
const cellWidthOriginal = img.width / N;
const cellHeightOriginal = img.height / M;
// 5. Calculate cell dimensions in the output canvas coordinate system
const cellWidthOutput = outputWidth / N;
const cellHeightOutput = outputHeight / M;
// 6. Iterate through each cell, calculate average color, and draw
pixelatedCtx.clearRect(0, 0, outputWidth, outputHeight); // Clear previous result
console.log("Pixelated canvas cleared. Starting cell processing...");
let processedCells = 0;
// 创建一个新的二维数组来存储颜色
const newMappedData: { key: string; color: string }[][] = Array(M).fill(null).map(() => Array(N).fill({ key: '?', color: '#FFFFFF' }));
for (let j = 0; j < M; j++) { // Rows (y)
for (let i = 0; i < N; i++) { // Columns (x)
// Calculate the pixel region in the original image for the current cell
const startXOriginal = Math.floor(i * cellWidthOriginal);
const startYOriginal = Math.floor(j * cellHeightOriginal);
// Use Math.ceil for end coordinates and clamp to image bounds to avoid errors
// Ensure width/height are at least 1 pixel to avoid getImageData errors
const currentCellWidth = Math.max(1, Math.min(Math.ceil((i + 1) * cellWidthOriginal), img.width) - startXOriginal);
const currentCellHeight = Math.max(1, Math.min(Math.ceil((j + 1) * cellHeightOriginal), img.height) - startYOriginal);
if (currentCellWidth <= 0 || currentCellHeight <= 0) {
console.warn(`Skipping invalid cell at (${i},${j}) with dimensions ${currentCellWidth}x${currentCellHeight}`);
continue; // Skip empty or invalid cells
}
let imageData;
try {
// Get pixel data for the current cell from the hidden canvas
imageData = originalCtx.getImageData(startXOriginal, startYOriginal, currentCellWidth, currentCellHeight);
} catch (e) {
console.error(`Failed to getImageData for cell (${i},${j}):`, e, { startXOriginal, startYOriginal, currentCellWidth, currentCellHeight, imgWidth: img.width, imgHeight: img.height });
continue; // Skip this cell if data cannot be retrieved
}
const data = imageData.data;
let r = 0, g = 0, b = 0, a = 0;
let pixelCount = 0;
// Calculate average RGBA for the cell
for (let p = 0; p < data.length; p += 4) {
// Option: skip fully transparent pixels if desired
// if (data[p + 3] === 0) continue;
r += data[p];
g += data[p + 1];
b += data[p + 2];
a += data[p + 3]; // Averaging alpha channel as well
pixelCount++;
}
if (pixelCount > 0) {
r = Math.round(r / pixelCount);
g = Math.round(g / pixelCount);
b = Math.round(b / pixelCount);
a = Math.round(a / pixelCount); // Calculate average alpha
// Alternative: Force opaque: a = 255;
const avgRgb = { r, g, b };
// Find closest bead color
const closestBead = findClosestPaletteColor(avgRgb, beadPalette);
// Store mapped data
newMappedData[j][i] = { key: closestBead.key, color: closestBead.hex };
// 7. Draw the averaged color block onto the output canvas
pixelatedCtx.fillStyle = closestBead.hex;
const drawX = i * cellWidthOutput;
const drawY = j * cellHeightOutput;
pixelatedCtx.fillRect(drawX, drawY, cellWidthOutput + 0.5, cellHeightOutput + 0.5);
// --- ADD GRID LINES FOR PREVIEW ---
pixelatedCtx.strokeStyle = '#EEEEEE'; // Very light gray for preview grid
pixelatedCtx.lineWidth = 1;
// Offset by 0.5 for crisp 1px lines
pixelatedCtx.strokeRect(drawX + 0.5, drawY + 0.5, cellWidthOutput, cellHeightOutput);
// --- END ADD GRID LINES ---
processedCells++;
} else {
// Handle case of fully transparent cell (draw as white/transparent?)
// Define a fallback structure consistent with mappedPixelData state
const fallbackColorData = { key: 'T1', color: '#FFFFFF' };
// Find the actual T1 color from the palette if it exists
const t1PaletteColor = beadPalette.find(p => p.key === 'T1');
// Use T1 if found, otherwise use the fallback. Ensure structure has .key and .color
const defaultColor = t1PaletteColor
? { key: t1PaletteColor.key, color: t1PaletteColor.hex }
: fallbackColorData;
newMappedData[j][i] = { key: defaultColor.key, color: defaultColor.color };
pixelatedCtx.fillStyle = defaultColor.color;
const drawX = i * cellWidthOutput;
const drawY = j * cellHeightOutput;
pixelatedCtx.fillRect(drawX, drawY, cellWidthOutput + 0.5, cellHeightOutput + 0.5);
// --- ADD GRID LINES FOR DEFAULT CELLS IN PREVIEW ---
pixelatedCtx.strokeStyle = '#EEEEEE'; // Very light gray
pixelatedCtx.lineWidth = 1;
pixelatedCtx.strokeRect(drawX + 0.5, drawY + 0.5, cellWidthOutput, cellHeightOutput);
// --- END ADD GRID LINES ---
}
}
}
// 更新颜色网格状态
setMappedPixelData(newMappedData);
setGridDimensions({ N, M }); // 存储网格尺寸
console.log(`Pixelation complete: ${N}x${M} grid, processed ${processedCells} cells`);
};
img.onerror = (error: Event | string) => {
console.error("Image loading failed:", error);
alert("无法加载图片,请检查文件格式或网络连接。");
setOriginalImageSrc(null); // Reset state on error
setMappedPixelData(null);
setGridDimensions(null);
};
console.log("Setting image source...");
img.src = imageSrc; // Start loading the image
};
// Use useEffect to trigger pixelation when image or granularity changes
useEffect(() => {
if (originalImageSrc) {
// Ensure canvas refs are available before proceeding
if (originalCanvasRef.current && pixelatedCanvasRef.current) {
console.log("useEffect triggered: Processing image due to src or granularity change.");
pixelateImage(originalImageSrc, granularity);
} else {
// This case should be rare after initial mount, log it if it happens.
console.warn("useEffect triggered, but canvas refs are not ready yet. Pixelation might be delayed.");
// Consider a small delay/retry if this proves problematic, but usually unnecessary.
const timeoutId = setTimeout(() => {
if (originalImageSrc && originalCanvasRef.current && pixelatedCanvasRef.current) {
console.log("Retrying pixelation after short delay.");
pixelateImage(originalImageSrc, granularity);
}
}, 100); // 100ms delay
return () => clearTimeout(timeoutId); // Cleanup timeout on unmount or change
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- pixelateImage is stable if defined outside useEffect
}, [originalImageSrc, granularity]); // Dependencies: run when image or granularity changes
// Download function
const handleDownloadImage = () => {
if (!mappedPixelData || !gridDimensions || gridDimensions.N === 0 || gridDimensions.M === 0) {
console.error("下载失败: 映射数据或尺寸无效。");
alert("无法下载图纸,数据未生成或无效。");
return;
}
const { N, M } = gridDimensions;
const downloadCellSize = 30; // 每个格子在下载图片中的像素边长 (调大以容纳文字)
const downloadWidth = N * downloadCellSize;
const downloadHeight = M * downloadCellSize;
const downloadCanvas = document.createElement('canvas');
downloadCanvas.width = downloadWidth;
downloadCanvas.height = downloadHeight;
const ctx = downloadCanvas.getContext('2d');
if (!ctx) {
console.error("下载失败: 无法创建临时 Canvas Context。");
alert("无法下载图纸。");
return;
}
ctx.imageSmoothingEnabled = false; // 保证边缘清晰
console.log(`Generating download grid image: ${downloadWidth}x${downloadHeight} (Cell Size: ${downloadCellSize}px)`);
// 设置字体样式 (稍后会用到)
const fontSize = Math.max(8, Math.floor(downloadCellSize * 0.4)); // 动态计算字体大小最小8px
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 遍历映射数据,绘制每个格子和 Key
for (let j = 0; j < M; j++) {
for (let i = 0; i < N; i++) {
const cellData = mappedPixelData[j][i];
const cellColor = cellData?.color || '#FFFFFF'; // 默认为白色
const cellKey = cellData?.key || '?'; // 默认为 '?'
const drawX = i * downloadCellSize;
const drawY = j * downloadCellSize;
// 1. 绘制背景色块
ctx.fillStyle = cellColor;
ctx.fillRect(drawX, drawY, downloadCellSize, downloadCellSize);
// 2. 绘制边框 (浅灰色) - 可选
ctx.strokeStyle = '#DDDDDD'; // 浅灰色边框
ctx.lineWidth = 1; // 1像素宽
ctx.strokeRect(drawX + 0.5, drawY + 0.5, downloadCellSize -1, downloadCellSize - 1); // 偏移0.5px使线宽为1px
// 3. 绘制 Key 文字
ctx.fillStyle = getContrastColor(cellColor); // 获取对比色
ctx.fillText(cellKey, drawX + downloadCellSize / 2, drawY + downloadCellSize / 2);
}
}
// 生成并下载图片
try {
const dataURL = downloadCanvas.toDataURL('image/png');
const link = document.createElement('a');
// 更新文件名以反映内容
link.download = `bead-grid-${N}x${M}-keys.png`;
link.href = dataURL;
document.body.appendChild(link); link.click(); document.body.removeChild(link);
console.log("Grid image with keys download initiated.");
} catch (e) {
console.error("下载图纸失败:", e); alert("无法生成图纸下载链接。");
}
};
// New function: handleDownloadJson
const handleDownloadJson = () => {
if (!mappedPixelData || !gridDimensions || gridDimensions.N === 0 || gridDimensions.M === 0) {
console.error("下载JSON失败: 映射数据或尺寸无效。");
alert("无法下载JSON数据未生成或无效。");
return;
}
const { N, M } = gridDimensions;
// Create a 2D array of keys only
const keyGrid = mappedPixelData.map(row =>
row.map(cell => cell?.key || '?') // Get key, default to '?' if cell data is missing
);
const jsonString = JSON.stringify(keyGrid, null, 2); // Pretty print JSON
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `bead-map-${N}x${M}.json`;
link.href = url;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url); // Clean up blob URL
console.log("JSON map download initiated.");
};
return (
<div className="min-h-screen p-4 sm:p-6 flex flex-col items-center bg-gray-50 font-[family-name:var(--font-geist-sans)]">
<header className="w-full max-w-4xl text-center mt-6 mb-5 sm:mt-8 sm:mb-6">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800">稿</h1>
<p className="mt-2 text-sm sm:text-base text-gray-600">Mard色号的图纸和JSON</p>
</header>
<main className="w-full max-w-4xl flex flex-col items-center space-y-5 sm:space-y-6">
{/* Drop Zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragOver} // Optional: Add visual feedback on drag enter
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-lg p-6 sm:p-8 text-center cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-colors w-full max-w-md flex flex-col justify-center items-center"
style={{ minHeight: '130px' }}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10 sm:h-12 sm:w-12 text-gray-400 mb-2 sm:mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-xs sm:text-sm text-gray-500"><span className="font-medium text-blue-600"></span></p>
<p className="text-xs text-gray-400 mt-1"> JPG, PNG </p>
</div>
<input
type="file"
accept="image/jpeg, image/png"
onChange={handleFileChange}
ref={fileInputRef}
className="hidden"
/>
{/* Controls and Output Area - Shown only after image upload */}
{originalImageSrc && (
<div className="w-full flex flex-col items-center space-y-5 sm:space-y-6">
{/* Granularity Slider */}
<div className="w-full max-w-md bg-white p-3 sm:p-4 rounded-lg shadow">
<label htmlFor="granularity" className="block text-xs sm:text-sm font-medium text-gray-700 mb-1 sm:mb-2">
: <span className="font-semibold text-blue-600">{granularity}</span>
</label>
<input
type="range"
id="granularity"
min="10" // Min grid width
max="200" // Max grid width
step="1" // Adjust step as needed
value={granularity}
onChange={handleGranularityChange}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600" // Style for modern browsers
/>
<div className="flex justify-between text-xs text-gray-500 mt-1 px-1">
<span></span>
<span></span>
</div>
</div>
{/* Output Section */}
<div className="w-full max-w-2xl">
{/* Hidden Canvas for original image sampling */}
<canvas ref={originalCanvasRef} className="hidden"></canvas>
{/* Visible Canvas for pixelated result */}
<div className="bg-white p-3 sm:p-4 rounded-lg shadow">
<h2 className="text-base sm:text-lg font-medium mb-3 sm:mb-4 text-center text-gray-800">Mard色号</h2>
{/* Container to center canvas and provide background */}
<div className="flex justify-center mb-3 sm:mb-4 bg-gray-100 p-2 rounded overflow-hidden" style={{ minHeight: '150px' }}>
<canvas
ref={pixelatedCanvasRef}
className="border border-gray-300 max-w-full h-auto rounded block" // Use block to prevent extra space below canvas
style={{
maxHeight: '60vh', // Limit height for large aspect ratios
imageRendering: 'pixelated', // Crucial for sharp pixels
// background: 'url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'10\' height=\'10\'><rect width=\'5\' height=\'5\' style=\'fill:rgb(200,200,200)\'/><rect x=\'5\' y=\'5\' width=\'5\' height=\'5\' style=\'fill:rgb(200,200,200)\'/></svg>")' // Optional checkerboard background
}}
></canvas>
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<button
onClick={handleDownloadImage}
disabled={!mappedPixelData}
className="flex-1 py-2 px-4 bg-green-600 text-white text-sm sm:text-base rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
()
</button>
<button
onClick={handleDownloadJson}
disabled={!mappedPixelData}
className="flex-1 py-2 px-4 bg-purple-600 text-white text-sm sm:text-base rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
(JSON)
</button>
</div>
</div>
</div>
</div>
)}
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
<footer className="w-full max-w-4xl mt-10 mb-6 py-4 text-center text-xs sm:text-sm text-gray-500 border-t border-gray-200">
<p> () &copy; {new Date().getFullYear()}</p>
{/* Optional: Add link to source code or your website */}
{/* <p className="mt-1"><a href="#" className="hover:underline">GitHub Repo</a></p> */}
</footer>
</div>
);
}
}