AIdentity

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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 }