新增专心拼豆模式及相关弹窗,优化用户交互体验,提升功能可用性。

This commit is contained in:
zihanjian
2025-06-06 18:54:00 +08:00
parent 2577294390
commit 2e42ba01b8
10 changed files with 1543 additions and 3 deletions

378
src/app/focus/page.tsx Normal file
View File

@@ -0,0 +1,378 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { MappedPixel } from '../../utils/pixelation';
import {
getAllConnectedRegions,
isRegionCompleted,
getRegionCenter,
sortRegionsByDistance,
sortRegionsBySize,
getConnectedRegion
} from '../../utils/floodFillUtils';
import FocusCanvas from '../../components/FocusCanvas';
import ColorStatusBar from '../../components/ColorStatusBar';
import ProgressBar from '../../components/ProgressBar';
import ToolBar from '../../components/ToolBar';
import ColorPanel from '../../components/ColorPanel';
import SettingsPanel from '../../components/SettingsPanel';
interface FocusModeState {
// 当前状态
currentColor: string;
selectedCell: { row: number; col: number } | null;
// 画布状态
canvasScale: number;
canvasOffset: { x: number; y: number };
// 进度状态
completedCells: Set<string>;
colorProgress: Record<string, { completed: number; total: number }>;
// 引导状态 - 改为区域推荐
recommendedRegion: { row: number; col: number }[] | null;
recommendedCell: { row: number; col: number } | null; // 保留用于定位显示
guidanceMode: 'nearest' | 'largest' | 'edge-first';
// UI状态
showColorPanel: boolean;
showSettingsPanel: boolean;
isPaused: boolean;
}
export default function FocusMode() {
// 从localStorage或URL参数获取像素数据
const [mappedPixelData, setMappedPixelData] = useState<MappedPixel[][] | null>(null);
const [gridDimensions, setGridDimensions] = useState<{ N: number; M: number } | null>(null);
// 专心模式状态
const [focusState, setFocusState] = useState<FocusModeState>({
currentColor: '',
selectedCell: null,
canvasScale: 1,
canvasOffset: { x: 0, y: 0 },
completedCells: new Set<string>(),
colorProgress: {},
recommendedRegion: null,
recommendedCell: null,
guidanceMode: 'nearest',
showColorPanel: false,
showSettingsPanel: false,
isPaused: false
});
// 可用颜色列表
const [availableColors, setAvailableColors] = useState<Array<{
color: string;
name: string;
total: number;
completed: number;
}>>([]);
// 从localStorage加载数据
useEffect(() => {
const savedPixelData = localStorage.getItem('focusMode_pixelData');
const savedGridDimensions = localStorage.getItem('focusMode_gridDimensions');
const savedColorCounts = localStorage.getItem('focusMode_colorCounts');
if (savedPixelData && savedGridDimensions && savedColorCounts) {
try {
const pixelData = JSON.parse(savedPixelData);
const dimensions = JSON.parse(savedGridDimensions);
const colorCounts = JSON.parse(savedColorCounts);
setMappedPixelData(pixelData);
setGridDimensions(dimensions);
// 计算颜色进度
const colors = Object.entries(colorCounts).map(([colorKey, colorData]) => {
const data = colorData as { color: string; count: number };
return {
color: data.color,
name: colorKey, // 使用色号作为名称
total: data.count,
completed: 0
};
});
setAvailableColors(colors);
// 设置初始当前颜色
if (colors.length > 0) {
setFocusState(prev => ({
...prev,
currentColor: colors[0].color,
colorProgress: colors.reduce((acc, color) => ({
...acc,
[color.color]: { completed: 0, total: color.total }
}), {})
}));
}
} catch (error) {
console.error('Failed to load focus mode data:', error);
// 重定向到主页面
window.location.href = '/';
}
} else {
// 没有数据,重定向到主页面
window.location.href = '/';
}
}, []);
// 计算推荐的下一个区域
const calculateRecommendedRegion = useCallback(() => {
if (!mappedPixelData || !focusState.currentColor) return { region: null, cell: null };
// 获取当前颜色的所有连通区域
const allRegions = getAllConnectedRegions(mappedPixelData, focusState.currentColor);
// 筛选出未完成的区域
const incompleteRegions = allRegions.filter(region =>
!isRegionCompleted(region, focusState.completedCells)
);
if (incompleteRegions.length === 0) {
return { region: null, cell: null };
}
let selectedRegion: { row: number; col: number }[];
// 根据引导模式选择推荐区域
switch (focusState.guidanceMode) {
case 'nearest':
// 找最近的区域(相对于上一个完成的格子或中心点)
const referencePoint = focusState.selectedCell ?? {
row: Math.floor(mappedPixelData.length / 2),
col: Math.floor(mappedPixelData[0].length / 2)
};
const sortedByDistance = sortRegionsByDistance(incompleteRegions, referencePoint);
selectedRegion = sortedByDistance[0];
break;
case 'largest':
// 找最大的连通区域
const sortedBySize = sortRegionsBySize(incompleteRegions);
selectedRegion = sortedBySize[0];
break;
case 'edge-first':
// 优先选择包含边缘格子的区域
const M = mappedPixelData.length;
const N = mappedPixelData[0].length;
const edgeRegions = incompleteRegions.filter(region =>
region.some(cell =>
cell.row === 0 || cell.row === M - 1 ||
cell.col === 0 || cell.col === N - 1
)
);
if (edgeRegions.length > 0) {
selectedRegion = edgeRegions[0];
} else {
selectedRegion = incompleteRegions[0];
}
break;
default:
selectedRegion = incompleteRegions[0];
}
// 计算区域中心作为推荐显示位置
const centerCell = getRegionCenter(selectedRegion);
return {
region: selectedRegion,
cell: centerCell
};
}, [mappedPixelData, focusState.currentColor, focusState.completedCells, focusState.selectedCell, focusState.guidanceMode]);
// 更新推荐区域
useEffect(() => {
const { region, cell } = calculateRecommendedRegion();
setFocusState(prev => ({
...prev,
recommendedRegion: region,
recommendedCell: cell
}));
}, [calculateRecommendedRegion]);
// 处理格子点击 - 改为区域洪水填充标记
const handleCellClick = useCallback((row: number, col: number) => {
if (!mappedPixelData) return;
const cellColor = mappedPixelData[row][col].color;
// 如果点击的是当前颜色的格子,对整个连通区域进行标记
if (cellColor === focusState.currentColor) {
// 获取点击位置的连通区域
const region = getConnectedRegion(mappedPixelData, row, col, focusState.currentColor);
if (region.length === 0) return;
const newCompletedCells = new Set(focusState.completedCells);
// 检查区域是否已完成
const isCurrentlyCompleted = isRegionCompleted(region, focusState.completedCells);
if (isCurrentlyCompleted) {
// 如果区域已完成,取消整个区域的完成状态
region.forEach(({ row: r, col: c }) => {
newCompletedCells.delete(`${r},${c}`);
});
} else {
// 如果区域未完成,标记整个区域为完成
region.forEach(({ row: r, col: c }) => {
newCompletedCells.add(`${r},${c}`);
});
}
// 更新进度
const newColorProgress = { ...focusState.colorProgress };
if (newColorProgress[focusState.currentColor]) {
newColorProgress[focusState.currentColor].completed = Array.from(newCompletedCells)
.filter(key => {
const [r, c] = key.split(',').map(Number);
return mappedPixelData[r]?.[c]?.color === focusState.currentColor;
}).length;
}
setFocusState(prev => ({
...prev,
completedCells: newCompletedCells,
selectedCell: { row, col },
colorProgress: newColorProgress
}));
// 更新可用颜色的完成数
setAvailableColors(prev => prev.map(color => {
if (color.color === focusState.currentColor) {
return {
...color,
completed: newColorProgress[focusState.currentColor]?.completed || 0
};
}
return color;
}));
}
}, [mappedPixelData, focusState.currentColor, focusState.completedCells, focusState.colorProgress]);
// 处理颜色切换
const handleColorChange = useCallback((color: string) => {
setFocusState(prev => ({ ...prev, currentColor: color, showColorPanel: false }));
}, []);
// 处理定位到推荐位置
const handleLocateRecommended = useCallback(() => {
if (focusState.recommendedCell) {
// 计算需要的偏移量使推荐格子居中
const { row, col } = focusState.recommendedCell;
// 这里简化处理,实际需要根据画布尺寸计算
setFocusState(prev => ({
...prev,
canvasOffset: { x: -col * 20, y: -row * 20 } // 假设每个格子20px
}));
}
}, [focusState.recommendedCell]);
if (!mappedPixelData || !gridDimensions) {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
const currentColorInfo = availableColors.find(c => c.color === focusState.currentColor);
const progressPercentage = currentColorInfo ?
Math.round((currentColorInfo.completed / currentColorInfo.total) * 100) : 0;
return (
<div className="h-screen flex flex-col bg-gray-50">
{/* 顶部导航栏 */}
<header className="h-15 bg-white shadow-sm border-b border-gray-200 px-4 py-3 flex items-center justify-between">
<button
onClick={() => window.history.back()}
className="flex items-center text-gray-600 hover:text-gray-800"
>
<svg className="w-6 h-6 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 className="text-lg font-medium text-gray-800">AlphaTest</h1>
<button
onClick={() => setFocusState(prev => ({ ...prev, showSettingsPanel: true }))}
className="text-gray-600 hover:text-gray-800"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</header>
{/* 当前颜色状态栏 */}
<ColorStatusBar
currentColor={focusState.currentColor}
colorInfo={currentColorInfo}
progressPercentage={progressPercentage}
/>
{/* 主画布区域 */}
<div className="flex-1 relative overflow-hidden">
<FocusCanvas
mappedPixelData={mappedPixelData}
gridDimensions={gridDimensions}
currentColor={focusState.currentColor}
completedCells={focusState.completedCells}
recommendedCell={focusState.recommendedCell}
recommendedRegion={focusState.recommendedRegion}
canvasScale={focusState.canvasScale}
canvasOffset={focusState.canvasOffset}
onCellClick={handleCellClick}
onScaleChange={(scale: number) => setFocusState(prev => ({ ...prev, canvasScale: scale }))}
onOffsetChange={(offset: { x: number; y: number }) => setFocusState(prev => ({ ...prev, canvasOffset: offset }))}
/>
</div>
{/* 快速进度条 */}
<ProgressBar
progressPercentage={progressPercentage}
recommendedCell={focusState.recommendedCell}
colorInfo={currentColorInfo}
/>
{/* 底部工具栏 */}
<ToolBar
onColorSelect={() => setFocusState(prev => ({ ...prev, showColorPanel: true }))}
onLocate={handleLocateRecommended}
onUndo={() => {/* TODO: 实现撤销功能 */}}
onPause={() => setFocusState(prev => ({ ...prev, isPaused: !prev.isPaused }))}
isPaused={focusState.isPaused}
/>
{/* 颜色选择面板 */}
{focusState.showColorPanel && (
<ColorPanel
colors={availableColors}
currentColor={focusState.currentColor}
onColorSelect={handleColorChange}
onClose={() => setFocusState(prev => ({ ...prev, showColorPanel: false }))}
/>
)}
{/* 设置面板 */}
{focusState.showSettingsPanel && (
<SettingsPanel
guidanceMode={focusState.guidanceMode}
onGuidanceModeChange={(mode: 'nearest' | 'largest' | 'edge-first') => setFocusState(prev => ({ ...prev, guidanceMode: mode }))}
onClose={() => setFocusState(prev => ({ ...prev, showSettingsPanel: false }))}
/>
)}
</div>
);
}

View File

@@ -94,6 +94,7 @@ import { TRANSPARENT_KEY, transparentColorData } from '../utils/pixelEditingUtil
// 1. 导入新的 DonationModal 组件
import DonationModal from '../components/DonationModal';
import FocusModePreDownloadModal from '../components/FocusModePreDownloadModal';
export default function Home() {
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
@@ -176,6 +177,9 @@ export default function Home() {
// 新增:活跃工具层级管理
const [activeFloatingTool, setActiveFloatingTool] = useState<'palette' | 'magnifier' | null>(null);
// 新增:专心拼豆模式进入前下载提醒弹窗
const [isFocusModePreDownloadModalOpen, setIsFocusModePreDownloadModalOpen] = useState<boolean>(false);
// 放大镜切换处理函数
const handleToggleMagnifier = () => {
const newActiveState = !isMagnifierActive;
@@ -373,6 +377,21 @@ export default function Home() {
// --- Event Handlers ---
// 专心拼豆模式相关处理函数
const handleEnterFocusMode = () => {
setIsFocusModePreDownloadModalOpen(true);
};
const handleProceedToFocusMode = () => {
// 保存数据到localStorage供专心拼豆模式使用
localStorage.setItem('focusMode_pixelData', JSON.stringify(mappedPixelData));
localStorage.setItem('focusMode_gridDimensions', JSON.stringify(gridDimensions));
localStorage.setItem('focusMode_colorCounts', JSON.stringify(colorCounts));
// 跳转到专心拼豆页面
window.location.href = '/focus';
};
// 添加一个安全的文件输入触发函数
const triggerFileInput = useCallback(() => {
// 检查组件是否已挂载
@@ -2231,8 +2250,8 @@ export default function Home() {
{/* ++ RENDER Enter Manual Mode Button ONLY when NOT in manual mode (before downloads) ++ */}
{!isManualColoringMode && originalImageSrc && mappedPixelData && gridDimensions && (
<div className="w-full md:max-w-2xl mt-4"> {/* Wrapper div */}
{/* Keeping button styles bright for visibility in both modes */}
<div className="w-full md:max-w-2xl mt-4 space-y-3"> {/* Wrapper div */}
{/* Manual Edit Mode Button */}
<button
onClick={() => {
setIsManualColoringMode(true); // Enter mode
@@ -2242,7 +2261,19 @@ export default function Home() {
className={`w-full py-2.5 px-4 text-sm sm:text-base rounded-lg transition-all duration-300 flex items-center justify-center gap-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg hover:translate-y-[-1px]`}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /> </svg>
</button>
{/* Focus Mode Button */}
<button
onClick={handleEnterFocusMode}
className={`w-full py-2.5 px-4 text-sm sm:text-base rounded-lg transition-all duration-300 flex items-center justify-center gap-2 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white shadow-md hover:shadow-lg hover:translate-y-[-1px]`}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
AplhaTest
</button>
</div>
)} {/* ++ End of RENDER Enter Manual Mode Button ++ */}
@@ -2381,6 +2412,16 @@ export default function Home() {
onOptionsChange={setDownloadOptions}
onDownload={handleDownloadRequest}
/>
{/* 专心拼豆模式进入前下载提醒弹窗 */}
<FocusModePreDownloadModal
isOpen={isFocusModePreDownloadModalOpen}
onClose={() => setIsFocusModePreDownloadModalOpen(false)}
onProceedWithoutDownload={handleProceedToFocusMode}
mappedPixelData={mappedPixelData}
gridDimensions={gridDimensions}
selectedColorSystem={selectedColorSystem}
/>
</div>
</>
);

View File

@@ -0,0 +1,168 @@
import React, { useState } from 'react';
interface ColorInfo {
color: string;
name: string;
total: number;
completed: number;
}
interface ColorPanelProps {
colors: ColorInfo[];
currentColor: string;
onColorSelect: (color: string) => void;
onClose: () => void;
}
const ColorPanel: React.FC<ColorPanelProps> = ({
colors,
currentColor,
onColorSelect,
onClose
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'progress' | 'name' | 'total'>('progress');
// 过滤和排序颜色
const filteredAndSortedColors = colors
.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.color.toLowerCase().includes(searchTerm.toLowerCase())
)
.sort((a, b) => {
switch (sortBy) {
case 'progress':
const progressA = (a.completed / a.total) * 100;
const progressB = (b.completed / b.total) * 100;
return progressA - progressB; // 进度低的在前
case 'name':
return a.name.localeCompare(b.name);
case 'total':
return b.total - a.total; // 数量多的在前
default:
return 0;
}
});
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end">
<div className="w-full bg-white rounded-t-2xl max-h-[80vh] flex flex-col">
{/* 拖拽指示条 */}
<div className="flex justify-center py-2">
<div className="w-10 h-1 bg-gray-300 rounded-full"></div>
</div>
{/* 搜索框 */}
<div className="px-4 pb-3">
<div className="relative">
<input
type="text"
placeholder="搜索颜色..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<svg
className="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
{/* 排序选项 */}
<div className="px-4 pb-3">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'progress' | 'name' | 'total')}
className="w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="progress"></option>
<option value="name"></option>
<option value="total"></option>
</select>
</div>
{/* 颜色列表 */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
{filteredAndSortedColors.map((colorInfo) => {
const progressPercentage = Math.round((colorInfo.completed / colorInfo.total) * 100);
const isSelected = colorInfo.color === currentColor;
const isCompleted = progressPercentage === 100;
return (
<button
key={colorInfo.color}
onClick={() => onColorSelect(colorInfo.color)}
className={`w-full p-3 mb-2 rounded-lg border-2 transition-all ${
isSelected
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300'
} ${isCompleted ? 'opacity-60' : ''}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className="w-10 h-10 rounded-full border-2 border-gray-300 flex-shrink-0"
style={{ backgroundColor: colorInfo.color }}
/>
<div className="text-left">
<div className="text-sm font-medium text-gray-800">
{colorInfo.color.toUpperCase()}
</div>
<div className="text-xs text-gray-500">
{colorInfo.completed}/{colorInfo.total} ({progressPercentage}%)
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{isCompleted && (
<div className="text-green-500">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
{isSelected && (
<div className="text-blue-500">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
</div>
{/* 进度条 */}
<div className="mt-2 w-full bg-gray-200 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all ${
isCompleted ? 'bg-green-500' : 'bg-blue-500'
}`}
style={{ width: `${progressPercentage}%` }}
/>
</div>
</button>
);
})}
</div>
{/* 关闭按钮 */}
<div className="p-4 border-t border-gray-200">
<button
onClick={onClose}
className="w-full py-3 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
</button>
</div>
</div>
</div>
);
};
export default ColorPanel;

View File

@@ -0,0 +1,55 @@
import React from 'react';
interface ColorStatusBarProps {
currentColor: string;
colorInfo?: {
color: string;
name: string;
total: number;
completed: number;
};
progressPercentage: number;
}
const ColorStatusBar: React.FC<ColorStatusBarProps> = ({
currentColor,
colorInfo,
progressPercentage
}) => {
if (!colorInfo) {
return (
<div className="h-12 bg-white border-b border-gray-200 px-4 py-2 flex items-center">
<div className="text-gray-500"></div>
</div>
);
}
const estimatedTime = Math.ceil((colorInfo.total - colorInfo.completed) * 0.5); // 假设每个格子0.5分钟
return (
<div className="h-12 bg-white border-b border-gray-200 px-4 py-2 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className="w-8 h-8 rounded-full border-2 border-gray-300"
style={{ backgroundColor: currentColor }}
/>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-800">
{colorInfo.completed}/{colorInfo.total}
</div>
<div className="text-xs text-gray-500">
{estimatedTime}
</div>
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-blue-600">
{progressPercentage}%
</div>
</div>
</div>
);
};
export default ColorStatusBar;

View File

@@ -0,0 +1,315 @@
import React, { useRef, useEffect, useCallback, useState } from 'react';
import { MappedPixel } from '../utils/pixelation';
interface FocusCanvasProps {
mappedPixelData: MappedPixel[][];
gridDimensions: { N: number; M: number };
currentColor: string;
completedCells: Set<string>;
recommendedCell: { row: number; col: number } | null;
recommendedRegion: { row: number; col: number }[] | null;
canvasScale: number;
canvasOffset: { x: number; y: number };
onCellClick: (row: number, col: number) => void;
onScaleChange: (scale: number) => void;
onOffsetChange: (offset: { x: number; y: number }) => void;
}
const FocusCanvas: React.FC<FocusCanvasProps> = ({
mappedPixelData,
gridDimensions,
currentColor,
completedCells,
recommendedCell,
recommendedRegion,
canvasScale,
canvasOffset,
onCellClick,
onScaleChange,
onOffsetChange
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [lastPanPoint, setLastPanPoint] = useState<{ x: number; y: number } | null>(null);
// 计算格子大小
const cellSize = Math.max(15, Math.min(40, 300 / Math.max(gridDimensions.N, gridDimensions.M)));
// 渲染画布
const renderCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas || !mappedPixelData) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 设置画布尺寸
const canvasWidth = gridDimensions.N * cellSize;
const canvasHeight = gridDimensions.M * cellSize;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
canvas.style.width = `${canvasWidth}px`;
canvas.style.height = `${canvasHeight}px`;
// 清空画布
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// 渲染每个格子
for (let row = 0; row < gridDimensions.M; row++) {
for (let col = 0; col < gridDimensions.N; col++) {
const pixel = mappedPixelData[row][col];
const x = col * cellSize;
const y = row * cellSize;
const cellKey = `${row},${col}`;
// 确定格子颜色
let fillColor = pixel.color;
let isGrayedOut = false;
// 如果不是当前颜色,显示为灰度
if (pixel.color !== currentColor) {
// 转换为灰度
const hex = pixel.color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
fillColor = `rgb(${gray}, ${gray}, ${gray})`;
isGrayedOut = true;
}
// 绘制格子背景
ctx.fillStyle = fillColor;
ctx.fillRect(x, y, cellSize, cellSize);
// 如果是已完成的格子,添加勾选标记
if (completedCells.has(cellKey)) {
ctx.fillStyle = 'rgba(0, 255, 0, 0.6)';
ctx.fillRect(x, y, cellSize, cellSize);
// 绘制勾选图标
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x + cellSize * 0.2, y + cellSize * 0.5);
ctx.lineTo(x + cellSize * 0.4, y + cellSize * 0.7);
ctx.lineTo(x + cellSize * 0.8, y + cellSize * 0.3);
ctx.stroke();
}
// 如果是推荐区域的一部分,添加高亮边框
const isInRecommendedRegion = recommendedRegion?.some(cell =>
cell.row === row && cell.col === col
);
if (isInRecommendedRegion) {
ctx.strokeStyle = '#ff4444';
ctx.lineWidth = 3;
ctx.setLineDash([5, 5]);
ctx.strokeRect(x + 1, y + 1, cellSize - 2, cellSize - 2);
ctx.setLineDash([]);
}
// 如果是推荐区域的中心点,添加特殊标记
if (recommendedCell && recommendedCell.row === row && recommendedCell.col === col && isInRecommendedRegion) {
// 绘制中心点标记
ctx.fillStyle = '#ff4444';
ctx.beginPath();
ctx.arc(x + cellSize / 2, y + cellSize / 2, 4, 0, 2 * Math.PI);
ctx.fill();
}
// 绘制网格线
ctx.strokeStyle = isGrayedOut ? '#ccc' : '#e0e0e0';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, cellSize, cellSize);
// 绘制色号文字(只在格子足够大时)
if (cellSize > 20 && !isGrayedOut) {
ctx.fillStyle = '#333';
ctx.font = `${Math.max(8, cellSize / 4)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 简化色号显示(取色值的后几位)
const colorText = pixel.color.substring(1, 4).toUpperCase();
ctx.fillText(colorText, x + cellSize / 2, y + cellSize / 2);
}
}
}
}, [mappedPixelData, gridDimensions, cellSize, currentColor, completedCells, recommendedCell, recommendedRegion]);
// 处理触摸/鼠标事件
const getEventPosition = (event: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
let clientX: number, clientY: number;
if ('touches' in event) {
if (event.touches.length === 0) return null;
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else {
clientX = event.clientX;
clientY = event.clientY;
}
return {
x: (clientX - rect.left) / canvasScale,
y: (clientY - rect.top) / canvasScale
};
};
const getGridPosition = (x: number, y: number) => {
const col = Math.floor(x / cellSize);
const row = Math.floor(y / cellSize);
if (row >= 0 && row < gridDimensions.M && col >= 0 && col < gridDimensions.N) {
return { row, col };
}
return null;
};
// 处理点击
const handleClick = useCallback((event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
const pos = getEventPosition(event);
if (!pos) return;
const gridPos = getGridPosition(pos.x, pos.y);
if (gridPos) {
onCellClick(gridPos.row, gridPos.col);
}
}, [onCellClick, canvasScale, cellSize, gridDimensions]);
// 处理缩放
const handleWheel = useCallback((event: React.WheelEvent) => {
event.preventDefault();
const delta = event.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.3, Math.min(3, canvasScale * delta));
onScaleChange(newScale);
}, [canvasScale, onScaleChange]);
// 处理双指缩放(触摸)
const handleTouchStart = useCallback((event: React.TouchEvent) => {
if (event.touches.length === 1) {
// 单指拖拽开始
setIsDragging(true);
setLastPanPoint({
x: event.touches[0].clientX,
y: event.touches[0].clientY
});
} else if (event.touches.length === 2) {
// 双指缩放开始
event.preventDefault();
setIsDragging(false);
}
}, []);
const handleTouchMove = useCallback((event: React.TouchEvent) => {
event.preventDefault();
if (event.touches.length === 1 && isDragging && lastPanPoint) {
// 单指拖拽
const deltaX = event.touches[0].clientX - lastPanPoint.x;
const deltaY = event.touches[0].clientY - lastPanPoint.y;
onOffsetChange({
x: canvasOffset.x + deltaX,
y: canvasOffset.y + deltaY
});
setLastPanPoint({
x: event.touches[0].clientX,
y: event.touches[0].clientY
});
} else if (event.touches.length === 2) {
// 双指缩放处理(简化版本)
// 这里可以添加更复杂的双指缩放逻辑
}
}, [isDragging, lastPanPoint, canvasOffset, onOffsetChange]);
const handleTouchEnd = useCallback((event: React.TouchEvent) => {
if (event.touches.length === 0) {
setIsDragging(false);
setLastPanPoint(null);
// 如果没有移动太多,视为点击
if (!isDragging) {
handleClick(event);
}
}
}, [isDragging, handleClick]);
// 鼠标拖拽处理
const handleMouseDown = useCallback((event: React.MouseEvent) => {
setIsDragging(true);
setLastPanPoint({
x: event.clientX,
y: event.clientY
});
}, []);
const handleMouseMove = useCallback((event: React.MouseEvent) => {
if (isDragging && lastPanPoint) {
const deltaX = event.clientX - lastPanPoint.x;
const deltaY = event.clientY - lastPanPoint.y;
onOffsetChange({
x: canvasOffset.x + deltaX,
y: canvasOffset.y + deltaY
});
setLastPanPoint({
x: event.clientX,
y: event.clientY
});
}
}, [isDragging, lastPanPoint, canvasOffset, onOffsetChange]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setLastPanPoint(null);
}, []);
// 渲染画布
useEffect(() => {
renderCanvas();
}, [renderCanvas]);
return (
<div
ref={containerRef}
className="w-full h-full flex items-center justify-center overflow-hidden bg-gray-100"
style={{ touchAction: 'none' }}
>
<div
style={{
transform: `scale(${canvasScale}) translate(${canvasOffset.x}px, ${canvasOffset.y}px)`,
transformOrigin: 'center center'
}}
>
<canvas
ref={canvasRef}
className="cursor-crosshair border border-gray-300"
onClick={handleClick}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
</div>
</div>
);
};
export default FocusCanvas;

View File

@@ -0,0 +1,115 @@
'use client';
import React from 'react';
import { MappedPixel } from '../utils/pixelation';
import { ColorSystem } from '../utils/colorSystemUtils';
import { exportCsvData } from '../utils/imageDownloader';
interface FocusModePreDownloadModalProps {
isOpen: boolean;
onClose: () => void;
onProceedWithoutDownload: () => void;
mappedPixelData: MappedPixel[][] | null;
gridDimensions: { N: number; M: number } | null;
selectedColorSystem: ColorSystem;
}
const FocusModePreDownloadModal: React.FC<FocusModePreDownloadModalProps> = ({
isOpen,
onClose,
onProceedWithoutDownload,
mappedPixelData,
gridDimensions,
selectedColorSystem
}) => {
if (!isOpen) return null;
const handleDownloadAndProceed = () => {
// 下载CSV数据文件
exportCsvData({
mappedPixelData,
gridDimensions,
selectedColorSystem
});
// 稍等一下让下载开始,然后进入专心拼豆模式
setTimeout(() => {
onProceedWithoutDownload();
}, 500);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-md w-full p-6 space-y-4">
{/* 标题 */}
<div className="text-center">
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/50 rounded-full flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
</h3>
</div>
{/* 提醒内容 */}
<div className="text-sm text-gray-600 dark:text-gray-300 space-y-3">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800/40 rounded-lg p-3">
<div className="flex items-start space-x-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<p className="font-medium text-yellow-800 dark:text-yellow-200"></p>
<p className="text-yellow-700 dark:text-yellow-300">
CSV格式便使
</p>
</div>
</div>
</div>
<div className="space-y-2">
<p></p>
<ul className="text-xs space-y-1 text-gray-500 dark:text-gray-400">
<li> </li>
<li> </li>
<li> </li>
<li> 退</li>
</ul>
</div>
</div>
{/* 操作按钮 */}
<div className="flex flex-col space-y-2 pt-4">
<button
onClick={handleDownloadAndProceed}
className="w-full py-2.5 px-4 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white rounded-lg font-medium transition-all duration-200 flex items-center justify-center space-x-2"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span></span>
</button>
<button
onClick={onProceedWithoutDownload}
className="w-full py-2.5 px-4 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg font-medium transition-all duration-200"
>
</button>
<button
onClick={onClose}
className="w-full py-2 px-4 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-sm transition-colors"
>
</button>
</div>
</div>
</div>
);
};
export default FocusModePreDownloadModal;

View File

@@ -0,0 +1,53 @@
import React from 'react';
interface ProgressBarProps {
progressPercentage: number;
recommendedCell?: { row: number; col: number } | null;
colorInfo?: {
color: string;
name: string;
total: number;
completed: number;
};
}
const ProgressBar: React.FC<ProgressBarProps> = ({
progressPercentage,
recommendedCell
}) => {
// 生成7个圆点来表示进度
const progressDots = Array.from({ length: 7 }, (_, index) => {
const threshold = (index + 1) * (100 / 7);
const isFilled = progressPercentage >= threshold;
return (
<div
key={index}
className={`w-3 h-3 rounded-full ${
isFilled ? 'bg-blue-500' : 'bg-gray-300'
}`}
/>
);
});
return (
<div className="h-10 bg-white border-b border-gray-200 px-4 py-2 flex items-center justify-between">
<div className="flex items-center space-x-2">
{progressDots}
<span className="ml-2 text-sm font-medium text-gray-700">
{progressPercentage}%
</span>
</div>
<div className="text-xs text-gray-500">
{recommendedCell ? (
<span> {recommendedCell.row + 1},{recommendedCell.col + 1}</span>
) : (
<span></span>
)}
</div>
</div>
);
};
export default ProgressBar;

View File

@@ -0,0 +1,192 @@
import React from 'react';
interface SettingsPanelProps {
guidanceMode: 'nearest' | 'largest' | 'edge-first';
onGuidanceModeChange: (mode: 'nearest' | 'largest' | 'edge-first') => void;
onClose: () => void;
}
const SettingsPanel: React.FC<SettingsPanelProps> = ({
guidanceMode,
onGuidanceModeChange,
onClose
}) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-start justify-end">
<div className="w-80 max-w-[90vw] h-full bg-white shadow-lg flex flex-col">
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-800"></h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 设置内容 */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* 引导设置 */}
<div>
<h3 className="text-base font-medium text-gray-800 mb-3"></h3>
<div className="space-y-3">
<label className="flex items-center">
<input
type="radio"
name="guidanceMode"
value="nearest"
checked={guidanceMode === 'nearest'}
onChange={(e) => onGuidanceModeChange(e.target.value as 'nearest')}
className="mr-3 text-blue-600"
/>
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-xs text-gray-500"></div>
</div>
</label>
<label className="flex items-center">
<input
type="radio"
name="guidanceMode"
value="largest"
checked={guidanceMode === 'largest'}
onChange={(e) => onGuidanceModeChange(e.target.value as 'largest')}
className="mr-3 text-blue-600"
/>
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-xs text-gray-500"></div>
</div>
</label>
<label className="flex items-center">
<input
type="radio"
name="guidanceMode"
value="edge-first"
checked={guidanceMode === 'edge-first'}
onChange={(e) => onGuidanceModeChange(e.target.value as 'edge-first')}
className="mr-3 text-blue-600"
/>
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-xs text-gray-500"></div>
</div>
</label>
</div>
</div>
{/* 显示设置 */}
<div>
<h3 className="text-base font-medium text-gray-800 mb-3"></h3>
<div className="space-y-3">
<label className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-700">线</div>
<div className="text-xs text-gray-500"></div>
</div>
<input
type="checkbox"
defaultChecked={true}
className="h-4 w-4 text-blue-600 rounded"
/>
</label>
<label className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-xs text-gray-500"></div>
</div>
<input
type="checkbox"
defaultChecked={true}
className="h-4 w-4 text-blue-600 rounded"
/>
</label>
<div>
<label className="text-sm font-medium text-gray-700 block mb-2">
</label>
<input
type="range"
min="0"
max="100"
defaultValue="80"
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span></span>
<span></span>
</div>
</div>
</div>
</div>
{/* 反馈设置 */}
<div>
<h3 className="text-base font-medium text-gray-800 mb-3"></h3>
<div className="space-y-3">
<label className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-xs text-gray-500"></div>
</div>
<input
type="checkbox"
defaultChecked={true}
className="h-4 w-4 text-blue-600 rounded"
/>
</label>
<label className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-xs text-gray-500"></div>
</div>
<input
type="checkbox"
defaultChecked={false}
className="h-4 w-4 text-blue-600 rounded"
/>
</label>
</div>
</div>
{/* 进度重置 */}
<div>
<h3 className="text-base font-medium text-gray-800 mb-3"></h3>
<div className="space-y-3">
<button className="w-full py-2 px-4 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 transition-colors text-sm">
</button>
<button className="w-full py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors text-sm">
</button>
</div>
</div>
{/* 关于信息 */}
<div>
<h3 className="text-base font-medium text-gray-800 mb-3"></h3>
<div className="text-sm text-gray-600 space-y-2">
<p> v1.0</p>
<p></p>
<div className="pt-2 text-xs text-gray-500">
<p>💡 </p>
<p>💡 </p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default SettingsPanel;

View File

@@ -0,0 +1,78 @@
import React from 'react';
interface ToolBarProps {
onColorSelect: () => void;
onLocate: () => void;
onUndo: () => void;
onPause: () => void;
isPaused: boolean;
}
const ToolBar: React.FC<ToolBarProps> = ({
onColorSelect,
onLocate,
onUndo,
onPause,
isPaused
}) => {
return (
<div className="h-15 bg-white border-t border-gray-200 px-4 py-2 flex items-center justify-around">
{/* 颜色选择 */}
<button
onClick={onColorSelect}
className="flex flex-col items-center space-y-1 text-gray-600 hover:text-blue-600 transition-colors"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 2a2 2 0 00-2 2v11a3 3 0 106 0V4a2 2 0 00-2-2H4zm1 14a1 1 0 100-2 1 1 0 000 2zm5-1.757l4.9-4.9a2 2 0 000-2.828L13.485 5.1a2 2 0 00-2.828 0L10 5.757v8.486zM16 18H9.071l6-6H16a2 2 0 012 2v2a2 2 0 01-2 2z" clipRule="evenodd" />
</svg>
<span className="text-xs"></span>
</button>
{/* 定位 */}
<button
onClick={onLocate}
className="flex flex-col items-center space-y-1 text-gray-600 hover:text-green-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-xs"></span>
</button>
{/* 撤销 */}
<button
onClick={onUndo}
className="flex flex-col items-center space-y-1 text-gray-600 hover:text-orange-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
<span className="text-xs"></span>
</button>
{/* 暂停/继续 */}
<button
onClick={onPause}
className={`flex flex-col items-center space-y-1 transition-colors ${
isPaused
? 'text-green-600 hover:text-green-700'
: 'text-gray-600 hover:text-red-600'
}`}
>
{isPaused ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)}
<span className="text-xs">{isPaused ? '继续' : '暂停'}</span>
</button>
</div>
);
};
export default ToolBar;

145
src/utils/floodFillUtils.ts Normal file
View File

@@ -0,0 +1,145 @@
import { MappedPixel } from './pixelation';
// 洪水填充获取连通区域
export function getConnectedRegion(
mappedPixelData: MappedPixel[][],
startRow: number,
startCol: number,
targetColor: string
): { row: number; col: number }[] {
if (!mappedPixelData || !mappedPixelData[startRow] || !mappedPixelData[startRow][startCol]) {
return [];
}
const M = mappedPixelData.length;
const N = mappedPixelData[0].length;
const visited = Array(M).fill(null).map(() => Array(N).fill(false));
const region: { row: number; col: number }[] = [];
// 使用栈实现非递归洪水填充
const stack = [{ row: startRow, col: startCol }];
while (stack.length > 0) {
const { row, col } = stack.pop()!;
// 检查边界
if (row < 0 || row >= M || col < 0 || col >= N || visited[row][col]) {
continue;
}
const currentCell = mappedPixelData[row][col];
// 检查是否是目标颜色且不是外部区域
if (!currentCell || currentCell.isExternal || currentCell.color !== targetColor) {
continue;
}
// 标记为已访问
visited[row][col] = true;
// 添加到区域
region.push({ row, col });
// 添加相邻像素到栈中
stack.push(
{ row: row - 1, col }, // 上
{ row: row + 1, col }, // 下
{ row, col: col - 1 }, // 左
{ row, col: col + 1 } // 右
);
}
return region;
}
// 获取所有同颜色的连通区域
export function getAllConnectedRegions(
mappedPixelData: MappedPixel[][],
targetColor: string
): { row: number; col: number }[][] {
if (!mappedPixelData || mappedPixelData.length === 0) {
return [];
}
const M = mappedPixelData.length;
const N = mappedPixelData[0].length;
const visited = Array(M).fill(null).map(() => Array(N).fill(false));
const regions: { row: number; col: number }[][] = [];
for (let row = 0; row < M; row++) {
for (let col = 0; col < N; col++) {
if (!visited[row][col]) {
const currentCell = mappedPixelData[row][col];
if (currentCell && !currentCell.isExternal && currentCell.color === targetColor) {
const region = getConnectedRegion(mappedPixelData, row, col, targetColor);
if (region.length > 0) {
regions.push(region);
// 标记该区域的所有像素为已访问
region.forEach(({ row: r, col: c }) => {
visited[r][c] = true;
});
}
}
}
}
}
return regions;
}
// 检查区域是否完全已完成
export function isRegionCompleted(
region: { row: number; col: number }[],
completedCells: Set<string>
): boolean {
return region.every(({ row, col }) => completedCells.has(`${row},${col}`));
}
// 检查区域是否部分已完成
export function isRegionPartiallyCompleted(
region: { row: number; col: number }[],
completedCells: Set<string>
): boolean {
return region.some(({ row, col }) => completedCells.has(`${row},${col}`));
}
// 获取区域的中心点(用于定位和显示)
export function getRegionCenter(region: { row: number; col: number }[]): { row: number; col: number } {
if (region.length === 0) {
return { row: 0, col: 0 };
}
const totalRow = region.reduce((sum, cell) => sum + cell.row, 0);
const totalCol = region.reduce((sum, cell) => sum + cell.col, 0);
return {
row: Math.floor(totalRow / region.length),
col: Math.floor(totalCol / region.length)
};
}
// 根据距离排序区域(用于最近优先的引导模式)
export function sortRegionsByDistance(
regions: { row: number; col: number }[][],
referencePoint: { row: number; col: number }
): { row: number; col: number }[][] {
return regions.sort((a, b) => {
const centerA = getRegionCenter(a);
const centerB = getRegionCenter(b);
const distanceA = Math.abs(centerA.row - referencePoint.row) + Math.abs(centerA.col - referencePoint.col);
const distanceB = Math.abs(centerB.row - referencePoint.row) + Math.abs(centerB.col - referencePoint.col);
return distanceA - distanceB;
});
}
// 根据大小排序区域(用于最大优先的引导模式)
export function sortRegionsBySize(
regions: { row: number; col: number }[][]
): { row: number; col: number }[][] {
return regions.sort((a, b) => b.length - a.length);
}