2_draw.tsx (12589B)
1 "use client"; 2 import React, { useCallback, useEffect, useRef, useState } from "react"; 3 import init, { 4 find_nearest_point_on_path, 5 NearestPointResult, 6 } from "../../rust-wasm/pkg/rust_wasm.js"; 7 import { StageProps } from "../ctrl/page.tsx"; 8 type Tool = "pen"; 9 type Point = { x: number; y: number }; 10 interface LineData { 11 id: number; 12 tool: Tool; 13 points: Point[]; 14 targetPoints: Point[]; 15 } 16 const BACKGROUND_SVG_PATH_D_DEFAULT = ""; 17 const VIEWSBOX_SIZE = 500; 18 const SNAPPING_DISTANCE_PIXELS = 30; 19 const AUDIO_SOURCE = "/audio/002.flac"; 20 export default function DrawingApp({ onComplete }: StageProps) { 21 const [isClient, setIsClient] = useState(false); 22 const lineIdCounter = useRef(0); 23 const [lines, setLines] = useState<LineData[]>([]); 24 const [currentLines, setCurrentLines] = useState<LineData[]>([]); 25 const [stageWidth, setStageWidth] = useState(0); 26 const [stageHeight, setStageHeight] = useState(0); 27 const [backgroundPathD, setBackgroundPathD] = useState( 28 BACKGROUND_SVG_PATH_D_DEFAULT, 29 ); 30 const [viewBoxSize, setViewBoxSize] = useState(VIEWSBOX_SIZE); 31 const isDrawing = useRef(false); 32 const animationRef = useRef<number>(0); 33 const startTimeRef = useRef<number | undefined>(undefined); 34 const stageRef = useRef<HTMLDivElement>(null); 35 useEffect(() => { 36 init(); 37 }, []); 38 // --- 1. サイズ計算とステージ設定 (画面全体を使用) --- 39 useEffect(() => { 40 setIsClient(true); 41 const handleResize = () => { 42 if (typeof globalThis !== "undefined") { 43 setStageWidth(globalThis.innerWidth); 44 setStageHeight(globalThis.innerHeight); 45 } 46 }; 47 handleResize(); 48 globalThis.addEventListener("resize", handleResize); 49 return () => { 50 globalThis.removeEventListener("resize", handleResize); 51 }; 52 }, []); 53 // --- 2. 座標変換ユーティリティ --- 54 const scaleToViewBox = useCallback( 55 (p: Point): Point => { 56 if (stageWidth === 0 || stageHeight === 0) return p; 57 const effectiveSize = Math.min(stageWidth, stageHeight); 58 const scale = viewBoxSize / effectiveSize; 59 const offsetX = (stageWidth - effectiveSize) / 2; 60 const offsetY = (stageHeight - effectiveSize) / 2; 61 return { 62 x: (p.x - offsetX) * scale, 63 y: (p.y - offsetY) * scale, 64 }; 65 }, 66 [stageWidth, stageHeight, viewBoxSize], 67 ); 68 const scaleToScreen = useCallback( 69 (p: Point): Point => { 70 if (stageWidth === 0 || stageHeight === 0) return p; 71 const effectiveSize = Math.min(stageWidth, stageHeight); 72 const scale = effectiveSize / viewBoxSize; 73 const offsetX = (stageWidth - effectiveSize) / 2; 74 const offsetY = (stageHeight - effectiveSize) / 2; 75 return { 76 x: p.x * scale + offsetX, 77 y: p.y * scale + offsetY, 78 }; 79 }, 80 [stageWidth, stageHeight, viewBoxSize], 81 ); 82 const getPointerPosition = useCallback( 83 ( 84 e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent, 85 ): Point | null => { 86 if (!stageRef.current) return null; 87 const rect = stageRef.current.getBoundingClientRect(); 88 let clientX: number, clientY: number; 89 if ("touches" in e) { 90 if (e.touches.length === 0) return null; 91 clientX = e.touches[0].clientX; 92 clientY = e.touches[0].clientY; 93 } else { 94 clientX = e.clientX; 95 clientY = e.clientY; 96 } 97 98 return { x: clientX - rect.left, y: clientY - rect.top }; 99 }, 100 [], 101 ); 102 // --- 3. SVGコンテンツの取得ロジック (DOMParser) --- 103 useEffect(() => { 104 const fetchAndParseSvg = async () => { 105 try { 106 const response = await fetch("/whiteperson.svg"); 107 const svgText = await response.text(); 108 const parser = new DOMParser(); 109 const doc = parser.parseFromString(svgText, "image/svg+xml"); 110 const svgElement = doc.querySelector("svg"); 111 if (svgElement) { 112 const viewBoxAttr = svgElement.getAttribute("viewBox"); 113 if (viewBoxAttr) { 114 const parts = viewBoxAttr.trim().split(/\s+/); 115 if (parts.length === 4) { 116 const size = Math.max(parseFloat(parts[2]), parseFloat(parts[3])); 117 if (!isNaN(size) && size > 0) { 118 setViewBoxSize(size); 119 } 120 } 121 } 122 } 123 const pathElements = doc.querySelectorAll("path"); 124 let foundDValue = null; 125 for (const element of Array.from(pathElements)) { 126 const dValue = element.getAttribute("d"); 127 if (dValue && dValue.trim().length > 0) { 128 foundDValue = dValue; 129 break; 130 } 131 } 132 if (foundDValue) { 133 setBackgroundPathD(foundDValue); 134 return; 135 } 136 console.error( 137 "Error: <path> element or 'd' attribute not found in whiteperson.svg.", 138 ); 139 } catch (error) { 140 console.error("Failed to load or parse whiteperson.svg:", error); 141 } 142 }; 143 if (isClient && backgroundPathD === "") { 144 fetchAndParseSvg(); 145 } 146 }, [isClient, backgroundPathD]); 147 // --- 4. 吸着ロジック (WASM呼び出し) --- 148 const calculateNearestTargetPoint = useCallback( 149 (p: Point): Point => { 150 const p_viewbox = scaleToViewBox(p); 151 const effectiveSize = Math.min(stageWidth, stageHeight); 152 const snapping_distance_viewbox = 153 SNAPPING_DISTANCE_PIXELS * (viewBoxSize / effectiveSize); 154 // ⚠️ WASM呼び出し 155 const nearest_viewbox: NearestPointResult | undefined = 156 find_nearest_point_on_path( 157 backgroundPathD, 158 p_viewbox.x, 159 p_viewbox.y, 160 snapping_distance_viewbox, 161 ); 162 console.log("nearest_viewbox is ", nearest_viewbox); 163 if (nearest_viewbox) { 164 return scaleToScreen(nearest_viewbox); 165 } 166 return p; 167 }, 168 [ 169 scaleToViewBox, 170 scaleToScreen, 171 stageWidth, 172 stageHeight, 173 viewBoxSize, 174 backgroundPathD, 175 ], 176 ); 177 const calculateTargetPoints = useCallback( 178 (points: Point[]): Point[] => { 179 return points.map((p) => calculateNearestTargetPoint(p)); 180 }, 181 [calculateNearestTargetPoint], 182 ); 183 // --- 5. イベントハンドラ (吹き飛んでいた部分を再定義) --- 184 /** 185 * マウス・タッチ開始時の処理 186 */ 187 const handleMouseDown = useCallback( 188 (e: React.MouseEvent | React.TouchEvent) => { 189 isDrawing.current = true; 190 const pos = getPointerPosition(e); 191 if (pos) { 192 lineIdCounter.current += 1; 193 const newLine: LineData = { 194 id: lineIdCounter.current, 195 tool: "pen", 196 points: [pos], 197 targetPoints: [], 198 }; 199 setLines((prev) => [...prev, newLine]); 200 setCurrentLines((prev) => [...prev, newLine]); 201 } 202 }, 203 [getPointerPosition], 204 ); 205 /** 206 * マウス・タッチ移動時の処理 207 */ 208 const handleMouseMove = useCallback( 209 (e: React.MouseEvent | React.TouchEvent) => { 210 if (!isDrawing.current) return; 211 const point = getPointerPosition(e); 212 if (!point) return; 213 const updateLines = (prevLines: LineData[]) => { 214 const lastLineIndex = prevLines.length - 1; 215 if (lastLineIndex < 0) return prevLines; 216 const lastLine = prevLines[lastLineIndex]; 217 const newPoints: Point[] = [...lastLine.points, point]; 218 return [ 219 ...prevLines.slice(0, lastLineIndex), 220 { 221 ...lastLine, 222 points: newPoints, 223 }, 224 ]; 225 }; 226 setLines(updateLines); 227 setCurrentLines(updateLines); 228 }, 229 [getPointerPosition], 230 ); 231 /** 232 * マウス・タッチ終了時の処理 233 */ 234 const handleMouseUp = useCallback(() => { 235 isDrawing.current = false; 236 setLines((prevLines) => { 237 const lastLineIndex = prevLines.length - 1; 238 if (lastLineIndex < 0) return prevLines; 239 const lastLine = prevLines[lastLineIndex]; 240 const targetPoints = calculateTargetPoints(lastLine.points); 241 const newLine: LineData = { 242 ...lastLine, 243 targetPoints: targetPoints, 244 }; 245 return [...prevLines.slice(0, lastLineIndex), newLine]; 246 }); 247 }, [calculateTargetPoints]); 248 // --- 6. アニメーションロジック (変更なし) --- 249 const animateLines = useCallback( 250 (timestamp: number) => { 251 if (!startTimeRef.current) startTimeRef.current = timestamp; 252 const elapsed = timestamp - startTimeRef.current; 253 const duration = 1500; 254 const progress = Math.min(1, elapsed / duration); 255 const newCurrentLines: LineData[] = lines.map((line) => { 256 if (line.targetPoints.length === 0) return line; 257 const newPoints: Point[] = line.points.map((originalP, index) => { 258 const targetP = line.targetPoints[index]; 259 return { 260 x: originalP.x + (targetP.x - originalP.x) * progress, 261 y: originalP.y + (targetP.y - originalP.y) * progress, 262 }; 263 }); 264 return { ...line, points: newPoints }; 265 }); 266 setCurrentLines(newCurrentLines); 267 if (progress < 1) { 268 animationRef.current = requestAnimationFrame(animateLines); 269 } else { 270 startTimeRef.current = undefined; 271 setLines( 272 newCurrentLines.map((line) => ({ 273 ...line, 274 points: line.targetPoints, 275 targetPoints: [], 276 })), 277 ); 278 } 279 }, 280 [lines], 281 ); 282 useEffect(() => { 283 const lastLine = lines[lines.length - 1]; 284 if ( 285 lastLine?.targetPoints.length > 0 && 286 startTimeRef.current === undefined 287 ) { 288 startTimeRef.current = undefined; 289 animationRef.current = requestAnimationFrame(animateLines); 290 } 291 return () => { 292 if (animationRef.current) { 293 cancelAnimationFrame(animationRef.current); 294 } 295 }; 296 }, [lines, animateLines]); 297 const pointsToSvgString = (points: Point[]): string => { 298 return points.map((p) => `${p.x},${p.y}`).join(" "); 299 }; 300 // for audio 301 const audioRef = useRef<HTMLAudioElement | null>(null); 302 useEffect(() => { 303 if (audioRef.current) { 304 return; 305 } 306 const audio = new Audio(AUDIO_SOURCE); 307 audioRef.current = audio; 308 const handleAudioEnded = () => { 309 console.log("Audio playback finished. Calling onComplete."); 310 onComplete(); 311 }; 312 audio.addEventListener("ended", handleAudioEnded); 313 const playAudio = () => { 314 audio 315 .play() 316 .catch((e) => 317 console.log( 318 "Audio playback failed (may require user interaction):", 319 e, 320 ), 321 ); 322 }; 323 // ロード完了を待って再生を試みる 324 audio.oncanplaythrough = playAudio; 325 // アンマウント時のクリーンアップ 326 return () => { 327 if (audioRef.current) { 328 audioRef.current.pause(); 329 audioRef.current.removeEventListener("ended", handleAudioEnded); 330 audioRef.current = null; 331 } 332 }; 333 }, [onComplete]); 334 return ( 335 <div 336 ref={stageRef} 337 style={{ 338 width: stageWidth, 339 height: stageHeight, 340 margin: 0, 341 position: "fixed", 342 top: 0, 343 left: 0, 344 overflow: "hidden", 345 touchAction: "none", 346 }} 347 onMouseDown={handleMouseDown} 348 onMouseMove={handleMouseMove} 349 onMouseUp={handleMouseUp} 350 onTouchStart={handleMouseDown} 351 onTouchMove={handleMouseMove} 352 onTouchEnd={handleMouseUp} 353 > 354 {/* 1. 背景のSVG (模倣対象) */} 355 {backgroundPathD && ( 356 <svg 357 width={stageWidth} 358 height={stageHeight} 359 viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`} 360 style={{ position: "absolute" }} 361 preserveAspectRatio="xMidYMid meet" 362 > 363 <path 364 d={backgroundPathD} 365 fill="none" 366 stroke="white" 367 strokeWidth="5" 368 opacity="0.2" 369 /> 370 </svg> 371 )} 372 373 {/* 2. ユーザーの描画 (アニメーション表示用) */} 374 <svg 375 width={stageWidth} 376 height={stageHeight} 377 viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`} 378 style={{ position: "absolute", top: 0, left: 0 }} 379 preserveAspectRatio="xMidYMid meet" 380 > 381 {currentLines.map((line) => ( 382 <polyline 383 key={line.id} 384 points={pointsToSvgString(line.points.map(scaleToViewBox))} 385 fill="none" 386 stroke="#FF4500" 387 strokeWidth="3" 388 strokeLinecap="round" 389 strokeLinejoin="round" 390 /> 391 ))} 392 </svg> 393 </div> 394 ); 395 }