commit 321880ae975eac13b69e5fd06d74419399653ebd
parent 0e741019e47952ef4e7dd1e1d31b04c9e7751f0c
Author: minerva-jupiter <ryouturn@gmail.com>
Date: Thu, 23 Oct 2025 18:33:48 +0900
20251023T18:33
Diffstat:
4 files changed, 390 insertions(+), 97 deletions(-)
diff --git a/app/components/1_chat.tsx b/app/components/1_chat.tsx
@@ -139,7 +139,6 @@ function ChatPage({onComplete}:StageProps) {
let talktimes = 1;
const handleSendMessage = useCallback((text: string) => {
- console.log(dict);
if (text.trim() === '') return;
const newUserMessage: Message = {
@@ -156,8 +155,7 @@ function ChatPage({onComplete}:StageProps) {
};
setMessages((prev) => [...prev, aiResponse]);
talktimes += 1;
- console.log("talktimes is "+talktimes);
- if(talktimes >= 3){
+ if(talktimes >= 5){
onComplete();
}
}, [dict, onComplete]);
diff --git a/app/components/2_draw.tsx b/app/components/2_draw.tsx
@@ -1,125 +1,311 @@
-'use clinet'
-
-import React, { useState, useRef, useCallback } from 'react';
-import { StageProps } from '../ctrl/page.tsx';
-import { Stage, Layer, Line as KonvaLine, Text } from 'react-konva';
-import type { KonvaEventObject } from 'konva/lib/Node';
-import type Konva from 'konva';
-
-const SecondDraw: React.FC<StageProps> = ({ onComplete }) => {
- return(
- <div>
- <DrawingApp onComplete={onComplete}/>
- </div>
- )
-};
+'use client'
-type Tool = 'pen' | 'eraser';
+import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
+// ⚠️ WASMモジュールのインポート。パスはプロジェクト構造に合わせてください。
+// 例: import { find_nearest_point_on_path } from '@/wasm/pkg/snap_calculator';
+
+// WASMモジュールが返すべき型を定義
+interface NearestPointResult {
+ x: number;
+ y: number;
+}
+// find_nearest_point_on_path関数のダミー宣言 (WASMの実際のインポートに置き換え)
+declare function find_nearest_point_on_path(
+ path_d: string,
+ target_x: number,
+ target_y: number,
+ max_distance: number
+): NearestPointResult | null;
+
+// --- 型定義 ---
+interface StageProps { onComplete: () => void; }
+type Point = { x: number; y: number; };
interface LineData {
- tool: Tool;
- points: number[]; // [x1, y1, x2, y2, ...] の形式
+ id: number;
+ tool: 'pen';
+ points: Point[];
+ targetPoints: Point[];
}
-function DrawingApp({onComplete}:StageProps) {
- const tool = 'pen';
- const [lines, setLines] = useState<LineData[]>([]);
+// ⚠️ 実際のSVGのパスデータ。このパスデータに基づいてWASMが計算します。
+const BACKGROUND_SVG_PATH_D = "M100 100 L400 100 L400 400 L100 400 Z";
+const SNAPPING_DISTANCE_PIXELS = 50; // 吸着距離 (50px)
+
+function DrawingApp({onComplete}: StageProps) {
+ const [isClient, setIsClient] = useState(false);
+ const lineIdCounter = useRef(0);
+ const [lines, setLines] = useState<LineData[]>([]);
+ const [currentLines, setCurrentLines] = useState<LineData[]>([]);
+
const isDrawing = useRef(false);
+
+ const animationRef = useRef<number>(0);
+ const startTimeRef = useRef<number|undefined>(undefined);
+
+ const stageRef = useRef<HTMLDivElement>(null);
+
+ // Stageサイズはビューボックスの標準値として500を基準に、画面サイズに合わせてスケーリングします。
+ const VIEWSBOX_SIZE = 500;
+ const [stageSize, setStageSize] = useState(0);
+
+ useEffect(() => {
+ setIsClient(true);
+
+ const handleResize = () => {
+ if (typeof window !== 'undefined') {
+ const size = Math.min(window.innerWidth, window.innerHeight);
+ // 画面の小さい方に合わせて90%のサイズを設定
+ setStageSize(size * 0.9);
+ }
+ };
+
+ handleResize();
+ window.addEventListener('resize', handleResize);
+
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ };
+ }, []);
- const stageRef = useRef<Konva.Stage | null>(null);
- const getPointerPosition = (stage: Konva.Stage | null) => {
- return stage?.getPointerPosition() ?? { x: 0, y: 0 };
- };
+ // --- 座標変換ユーティリティ ---
+ // 画面座標をSVGビューボックス座標にスケーリング
+ const scaleToViewBox = useCallback((p: Point): Point => {
+ if (stageSize === 0) return p;
+ const scale = VIEWSBOX_SIZE / stageSize;
+ return {
+ x: p.x * scale,
+ y: p.y * scale,
+ };
+ }, [stageSize]);
- /**
- * マウス・タッチ開始時の処理
- */
- const handleMouseDown = useCallback((e: KonvaEventObject<MouseEvent | TouchEvent>) => {
+ // SVGビューボックス座標を画面座標にスケーリング
+ const scaleToScreen = useCallback((p: Point): Point => {
+ if (stageSize === 0) return p;
+ const scale = stageSize / VIEWSBOX_SIZE;
+ return {
+ x: p.x * scale,
+ y: p.y * scale,
+ };
+ }, [stageSize]);
+
+
+ // ユーティリティ関数: マウス/タッチ座標の取得 (画面座標)
+ const getPointerPosition = useCallback((e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent): Point | null => {
+ if (!stageRef.current) return null;
+ const rect = stageRef.current.getBoundingClientRect();
+
+ let clientX: number, clientY: number;
+ if ('touches' in e) {
+ if (e.touches.length === 0) return null;
+ clientX = e.touches[0].clientX;
+ clientY = e.touches[0].clientY;
+ } else {
+ clientX = e.clientX;
+ clientY = e.clientY;
+ }
+
+ return { x: clientX - rect.left, y: clientY - rect.top };
+ }, []);
+
+
+ // --- 吸着ロジック (WASM利用) ---
+ const calculateNearestTargetPoint = useCallback((p: Point): Point => {
+ // 描画点 (画面座標) を WASM に渡す前にビューボックス座標に変換
+ const p_viewbox = scaleToViewBox(p);
+
+ // ⚠️ WASMの代わりにダミー関数を使用
+ /*
+ const nearest_viewbox = find_nearest_point_on_path(
+ BACKGROUND_SVG_PATH_D,
+ p_viewbox.x,
+ p_viewbox.y,
+ SNAPPING_DISTANCE_PIXELS // 距離はビューボックス座標系で計算される
+ );
+
+ if (nearest_viewbox) {
+ // 見つかったターゲット点 (ビューボックス座標) を画面座標に戻して返す
+ return scaleToScreen(nearest_viewbox);
+ }
+ */
+
+ // 吸着しない場合は元の点を返す
+ return p;
+
+ }, [scaleToViewBox, scaleToScreen]);
+
+ const calculateTargetPoints = useCallback((points: Point[]): Point[] => {
+ // 全点に対してターゲット点を計算
+ return points.map(p => calculateNearestTargetPoint(p));
+ }, [calculateNearestTargetPoint]);
+
+ // --- イベントハンドラ(前回の実装を継承し、Konvaの型を削除) ---
+
+ const handleMouseDown = useCallback((e: React.MouseEvent | React.TouchEvent) => {
isDrawing.current = true;
- const stage = e.target.getStage();
- if (stage) {
- const pos = getPointerPosition(stage);
- // 新しいラインを追加
- setLines((prevLines) => [
- ...prevLines,
- { tool, points: [pos.x, pos.y] },
- ]);
- }
- }, [tool]); // toolが変更されたら再生成
-
- /**
- * マウス・タッチ移動時の処理
- */
- const handleMouseMove = useCallback((e: KonvaEventObject<MouseEvent | TouchEvent>) => {
- if (!isDrawing.current) {
- return;
+ const pos = getPointerPosition(e);
+ if (pos) {
+ lineIdCounter.current += 1;
+ const newLine = { id: lineIdCounter.current, tool: 'pen' as const, points: [pos], targetPoints: [] };
+ setLines(prev => [...prev, newLine]);
+ setCurrentLines(prev => [...prev, newLine]);
}
+ }, [getPointerPosition]);
- const stage = e.target.getStage();
- if (!stage) return;
+ const handleMouseMove = useCallback((e: React.MouseEvent | React.TouchEvent) => {
+ if (!isDrawing.current) return;
+ const point = getPointerPosition(e);
+ if (!point) return;
- const point = getPointerPosition(stage);
+ const updateLines = (prevLines: LineData[]) => {
+ const lastLineIndex = prevLines.length - 1;
+ if (lastLineIndex < 0) return prevLines;
+ const lastLine = prevLines[lastLineIndex];
+ const newLine: LineData = { ...lastLine, points: [...lastLine.points, point] };
+ return [...prevLines.slice(0, lastLineIndex), newLine];
+ };
+
+ setLines(updateLines);
+ setCurrentLines(updateLines);
+
+ }, [getPointerPosition]);
+
+ const handleMouseUp = useCallback(() => {
+ isDrawing.current = false;
setLines((prevLines) => {
- // 1. 最後のラインのインデックスを取得
const lastLineIndex = prevLines.length - 1;
- if (lastLineIndex < 0) return prevLines; // 念のためのチェック
-
+ if (lastLineIndex < 0) return prevLines;
const lastLine = prevLines[lastLineIndex];
+
+ // 描画終了時にtargetPointsを計算
+ const targetPoints = calculateTargetPoints(lastLine.points);
- // 2. 最後の LineData オブジェクトを不変に更新
const newLine: LineData = {
- ...lastLine, // 既存のプロパティをコピー
- // points配列に新しいポイントを追加して、新しい配列を作成
- points: lastLine.points.concat([point.x, point.y]),
+ ...lastLine,
+ targetPoints: targetPoints,
};
+ return [...prevLines.slice(0, lastLineIndex), newLine];
+ });
+ }, [calculateTargetPoints]);
+
+ // --- アニメーションロジック(前回の実装を継承) ---
+
+ const animateLines = useCallback((timestamp: number) => {
+ if (!startTimeRef.current) startTimeRef.current = timestamp;
+ const elapsed = timestamp - startTimeRef.current;
+ const duration = 1500;
+ const progress = Math.min(1, elapsed / duration);
- // 3. lines配列全体を不変に更新
- return [
- ...prevLines.slice(0, lastLineIndex), // 最後の要素以外はそのままコピー
- newLine, // 完全に新しい LineData オブジェクトで置き換える
- ];
+ const newCurrentLines: LineData[] = lines.map((line) => {
+ if (line.targetPoints.length === 0) return line;
+
+ const newPoints: Point[] = line.points.map((originalP, index) => {
+ const targetP = line.targetPoints[index];
+ return {
+ x: originalP.x + (targetP.x - originalP.x) * progress,
+ y: originalP.y + (targetP.y - originalP.y) * progress,
+ };
+ });
+
+ return { ...line, points: newPoints };
});
- }, []);
- /**
- * マウス・タッチ終了時の処理
- */
- const handleMouseUp = useCallback(() => {
- isDrawing.current = false;
- }, []);
+ setCurrentLines(newCurrentLines);
+
+ if (progress < 1) {
+ animationRef.current = requestAnimationFrame(animateLines);
+ } else {
+ startTimeRef.current = undefined;
+ // 移動後のラインを lines (マスターデータ)に反映
+ setLines(newCurrentLines.map(line => ({
+ ...line,
+ points: line.targetPoints,
+ targetPoints: []
+ })));
+ }
+ }, [lines]);
+
+ useEffect(() => {
+ const lastLine = lines[lines.length - 1];
+ if (lastLine?.targetPoints.length > 0 && startTimeRef.current === undefined) {
+ startTimeRef.current = undefined;
+ animationRef.current = requestAnimationFrame(animateLines);
+ }
+
+ return () => {
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current);
+ }
+ };
+ }, [lines, animateLines]);
+ // points配列を 'x1,y1 x2,y2 ...' 形式の文字列に変換するヘルパー
+ const pointsToSvgString = (points: Point[]): string => {
+ return points.map(p => `${p.x},${p.y}`).join(' ');
+ };
+
return (
- <div>
- <Stage
- width={globalThis.innerWidth}
- height={globalThis.innerWidth}
- onMouseDown={handleMouseDown}
- onMouseMove={handleMouseMove}
- onMouseUp={handleMouseUp}
- onTouchStart={handleMouseDown}
- onTouchMove={handleMouseMove}
- onTouchEnd={handleMouseUp}
- ref={stageRef}
+ <div
+ ref={stageRef}
+ style={{
+ width: stageSize,
+ height: stageSize,
+ margin: 'auto',
+ position: 'relative',
+ border: '1px solid #ccc',
+ overflow: 'hidden',
+ touchAction: 'none',
+ }}
+ onMouseDown={handleMouseDown}
+ onMouseMove={handleMouseMove}
+ onMouseUp={handleMouseUp}
+ onTouchStart={handleMouseDown as any}
+ onTouchMove={handleMouseMove as any}
+ onTouchEnd={handleMouseUp}
>
- <Layer>
- <Text text="Just start drawing" x={5} y={30} fontSize={16} fill="#000" />
- {lines.map((line, i) => (
- <KonvaLine // `Line`がHTML要素と競合する可能性があるので`KonvaLine`としてインポート
- key={i}
- points={line.points}
- stroke="#ffffff"
- strokeWidth={5} // Eraserは少し太くする
- tension={0.5}
- lineCap="round"
- lineJoin="round"
- />
- ))}
- </Layer>
- </Stage>
+ {/* 1. 背景のSVG (模倣対象) - ビューボックスを使用 */}
+ <svg width={stageSize} height={stageSize} viewBox={`0 0 ${VIEWSBOX_SIZE} ${VIEWSBOX_SIZE}`} style={{ position: 'absolute' }}>
+ {/* BACKGROUND_SVG_PATH_Dは VIEWSBOX_SIZE の座標系で定義されていることを前提とする */}
+ <path
+ d={BACKGROUND_SVG_PATH_D}
+ fill="none"
+ stroke="black"
+ strokeWidth="5"
+ opacity="0.2"
+ />
+ </svg>
+
+ {/* 2. ユーザーの描画 (アニメーション表示用) - ビューボックスを使用 */}
+ <svg width={stageSize} height={stageSize} viewBox={`0 0 ${VIEWSBOX_SIZE} ${VIEWSBOX_SIZE}`} style={{ position: 'absolute', top: 0, left: 0 }}>
+ {currentLines.map((line) => (
+ <polyline
+ key={line.id}
+ // 描画はビューボックス座標系で行われる
+ points={pointsToSvgString(line.points.map(scaleToViewBox))}
+ fill="none"
+ stroke="#FF4500"
+ strokeWidth="3"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ ))}
+ </svg>
+
+ <button
+ type="submit"
+ onClick={onComplete}
+ style={{ position: 'absolute', bottom: 10, left: 10 }}
+ >
+ 完了 (onComplete)
+ </button>
</div>
);
+}
+
+const SecondDraw: React.FC<StageProps> = (props) => {
+ return <DrawingApp {...props} />;
};
export default SecondDraw;
diff --git a/public/whiteperson.svg b/public/whiteperson.svg
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="512"
+ height="512"
+ viewBox="0 0 512 512"
+ version="1.1"
+ id="svg1"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <defs
+ id="defs1" />
+ <g
+ id="layer1">
+ <circle
+ style="fill:#ffffff;stroke-width:0.837442"
+ id="path1"
+ cx="257.87497"
+ cy="195.09868"
+ r="150" />
+ <ellipse
+ style="fill:#ffffff;stroke-width:1.18432"
+ id="path1-8"
+ cx="251.02637"
+ cy="453.14139"
+ rx="300"
+ ry="150" />
+ </g>
+</svg>
diff --git a/rust-wasm/src/lib.rs b/rust-wasm/src/lib.rs
@@ -31,3 +31,82 @@ pub fn chat(dict_data: &[u8], input: &str) -> Result<String, JsValue> {
Ok(ans)
}
+
+#[wasm_bindgen]
+pub struct NearestPointResult {
+ x: f64,
+ y: f64,
+}
+
+#[wasm_bindgen]
+impl NearestPointResult {
+ pub fn new(x: f64, y: f64) -> NearestPointResult {
+ NearestPointResult { x, y }
+ }
+
+ #[wasm_bindgen(getter)]
+ pub fn x(&self) -> f64 { self.x }
+ #[wasm_bindgen(getter)]
+ pub fn y(&self) -> f64 { self.y }
+}
+
+#[wasm_bindgen]
+pub fn find_nearest_point_on_path(
+ path_d: &str, // 背景SVGのパスデータ (d属性)
+ target_x: f64, // ユーザー描画点のX座標
+ target_y: f64, // ユーザー描画点のY座標
+ max_snapping_distance: f64 // 吸着距離の閾値
+) -> Option<NearestPointResult> {
+
+ let target_point = (target_x, target_y);
+
+ // 1. パスのパース:
+ // path_d をベジェ曲線セグメントのリストに変換
+ // let segments = svgtypes::PathParser::from(path_d).collect();
+
+ let mut min_distance_sq = f64::MAX;
+ let max_distance_sq = max_snapping_distance * max_snapping_distance;
+ let mut nearest_point: Option<(f64, f64)> = None;
+
+ // 2. 最短距離計算 (各セグメントに対して実行)
+ // - 擬似コード: 実際にはベジェ曲線と点の最短距離を計算する幾何学的アルゴリズムが必要。
+ // - 非常に複雑なため、ここでは単純にパスを多数の点にサンプリングして探索するアプローチを想定。
+ //
+ // for segment in segments {
+ // let (closest_pt_on_seg, dist_sq) = segment.find_closest_point(target_point);
+ // if dist_sq < min_distance_sq {
+ // min_distance_sq = dist_sq;
+ // nearest_point = Some(closest_pt_on_seg);
+ // }
+ // }
+
+
+ // --- 暫定的なシンプルなサンプリングロジックの代替(実際のプロダクトでは置き換えが必要) ---
+ // ここでは、ダミーの矩形パス (M100 100 L400 100 L400 400 L100 400 Z) をサンプリングした点を仮定します。
+ // このダミーは、WASMが正しく動作することを示すためのものです。
+ let dummy_edge_points = vec![
+ (100.0, 100.0), (150.0, 100.0), (200.0, 100.0), /* ... */
+ ];
+
+ for (ex, ey) in dummy_edge_points {
+ let dx = ex - target_x;
+ let dy = ey - target_y;
+ let dist_sq = dx * dx + dy * dy;
+
+ if dist_sq < min_distance_sq {
+ min_distance_sq = dist_sq;
+ nearest_point = Some((ex, ey));
+ }
+ }
+ // ---------------------------------------------------------------------------------
+
+
+ if min_distance_sq < max_distance_sq {
+ if let Some((x, y)) = nearest_point {
+ return Some(NearestPointResult::new(x, y));
+ }
+ }
+
+ // 吸着範囲外、または計算不能
+ None
+}