@@ -11,7 +11,7 @@ import {
1111 Rect ,
1212} from "react-konva" ;
1313import Konva from "konva" ;
14- import { Fragment } from "react" ;
14+ import { Fragment , useRef , useCallback } from "react" ;
1515import { HOLE_CONFIGS } from "@/config/holes" ;
1616import {
1717 PAST_PIN_RESTRICTION_RADIUS ,
@@ -41,6 +41,10 @@ interface Props {
4141 banCells ?: string [ ] ;
4242 rainCells ?: string [ ] ;
4343 onCellClick ?: ( cellId : string ) => void ;
44+ /** 長押しドラッグでの連続入力を有効にする(onCellClick必須) */
45+ enablePaintMode ?: boolean ;
46+ /** 連続入力時に「追加」か「削除」かを判定するための現在の選択済みセル */
47+ activeCells ?: string [ ] ;
4448 currentPin ?: Pin ;
4549 onPinPlaced ?: ( currentPin : Pin ) => void ;
4650 pastPins ?: Pin [ ] ;
@@ -65,6 +69,8 @@ export default function GreenCanvas({
6569 banCells = [ ] ,
6670 rainCells = [ ] ,
6771 onCellClick,
72+ enablePaintMode = false ,
73+ activeCells = [ ] ,
6874 currentPin,
6975 onPinPlaced,
7076 pastPins,
@@ -80,12 +86,124 @@ export default function GreenCanvas({
8086 isRainyDay = false ,
8187} : Props ) {
8288 const holeData = useHoleData ( hole ) ;
89+ const scale = width / CANVAS_SIZE ;
90+
91+ // ペイントモード用(ドラッグでセル連続入力)
92+ const isPaintingRef = useRef ( false ) ;
93+ const paintedCellsRef = useRef < Set < string > > ( new Set ( ) ) ;
94+ const lastCellRef = useRef < { x : number ; y : number } | null > ( null ) ;
95+
96+ // ピクセル座標 → セル座標に変換
97+ const pixelToCell = useCallback (
98+ ( pos : { x : number ; y : number } ) : { x : number ; y : number } => ( {
99+ x : Math . floor ( pos . x / scale / YD_TO_PX ) ,
100+ y : Math . floor ( pos . y / scale / YD_TO_PX ) ,
101+ } ) ,
102+ [ scale ] ,
103+ ) ;
104+
105+ // セル座標 → セルID。グリーン外ならnull
106+ const toCellId = useCallback (
107+ ( cell : { x : number ; y : number } ) : string | null => {
108+ if ( ! holeData ) return null ;
109+ const id = `cell_${ cell . x } _${ cell . y } ` ;
110+ return holeData . cells . find ( ( c ) => c . id === id ) ? id : null ;
111+ } ,
112+ [ holeData ] ,
113+ ) ;
114+
115+ // 1セルを塗る(1ストローク中の重複防止)
116+ const paintCell = useCallback (
117+ ( cellId : string ) => {
118+ if ( ! onCellClick || paintedCellsRef . current . has ( cellId ) ) return ;
119+ paintedCellsRef . current . add ( cellId ) ;
120+ onCellClick ( cellId ) ;
121+ } ,
122+ [ onCellClick ] ,
123+ ) ;
124+
125+ // 2点間のセルを直線補間して塗る(高速ドラッグ時のコマ飛び防止)
126+ const paintCellsBetween = useCallback (
127+ ( from : { x : number ; y : number } , to : { x : number ; y : number } ) => {
128+ let { x, y } = from ;
129+ const goalX = to . x ;
130+ const goalY = to . y ;
131+ const distX = Math . abs ( goalX - x ) ;
132+ const distY = Math . abs ( goalY - y ) ;
133+ const stepX = x < goalX ? 1 : - 1 ;
134+ const stepY = y < goalY ? 1 : - 1 ;
135+ let error = distX - distY ;
136+
137+ while ( true ) {
138+ const cellId = toCellId ( { x, y } ) ;
139+ if ( cellId ) paintCell ( cellId ) ;
140+ if ( x === goalX && y === goalY ) break ;
141+ const doubleError = 2 * error ;
142+ if ( doubleError > - distY ) {
143+ error -= distY ;
144+ x += stepX ;
145+ }
146+ if ( doubleError < distX ) {
147+ error += distX ;
148+ y += stepY ;
149+ }
150+ }
151+ } ,
152+ [ toCellId , paintCell ] ,
153+ ) ;
154+
155+ // ペイント開始
156+ const handlePaintStart = useCallback (
157+ ( e : Konva . KonvaEventObject < MouseEvent | TouchEvent > ) => {
158+ const stage = e . target . getStage ( ) ;
159+ if ( ! stage ) return ;
160+ const pos = stage . getPointerPosition ( ) ;
161+ if ( ! pos || ! onCellClick || ! enablePaintMode ) return ;
162+
163+ isPaintingRef . current = true ;
164+ paintedCellsRef . current . clear ( ) ;
165+
166+ const cell = pixelToCell ( pos ) ;
167+ lastCellRef . current = cell ;
168+
169+ const cellId = toCellId ( cell ) ;
170+ if ( cellId ) paintCell ( cellId ) ;
171+ } ,
172+ [ onCellClick , enablePaintMode , pixelToCell , toCellId , paintCell ] ,
173+ ) ;
174+
175+ // ドラッグ中(前回→現在のセル間を補間して塗る)
176+ const handlePaintMove = useCallback (
177+ ( e : Konva . KonvaEventObject < MouseEvent | TouchEvent > ) => {
178+ if ( ! isPaintingRef . current ) return ;
179+
180+ const stage = e . target . getStage ( ) ;
181+ if ( ! stage ) return ;
182+ const pos = stage . getPointerPosition ( ) ;
183+ if ( ! pos ) return ;
184+
185+ const currentCell = pixelToCell ( pos ) ;
186+
187+ if ( lastCellRef . current ) {
188+ paintCellsBetween ( lastCellRef . current , currentCell ) ;
189+ }
190+
191+ lastCellRef . current = currentCell ;
192+ } ,
193+ [ pixelToCell , paintCellsBetween ] ,
194+ ) ;
195+
196+ // ペイント終了
197+ const handlePaintEnd = useCallback ( ( ) => {
198+ isPaintingRef . current = false ;
199+ paintedCellsRef . current . clear ( ) ;
200+ lastCellRef . current = null ;
201+ } , [ ] ) ;
83202
84203 if ( ! holeData ) {
85204 return < div > 読み込み中...</ div > ;
86205 }
87206
88- const scale = width / CANVAS_SIZE ;
89207 const config = HOLE_CONFIGS [ hole . padStart ( 2 , "0" ) ] ;
90208
91209 //外周制限を計算
@@ -99,8 +217,48 @@ export default function GreenCanvas({
99217 ? getOffsetSlope ( holeData . slope . slope . d , SLOPE_BUFFER )
100218 : [ ] ;
101219
102- // タッチ・クリック共通ハンドラ
103- const handleStageInteraction = (
220+ // ピン配置処理
221+ const handlePinPlacement = ( pos : { x : number ; y : number } ) => {
222+ if ( ! onPinPlaced || ! currentPin ) return ;
223+
224+ const snappedX = Math . round ( pos . x / scale / YD_TO_PX ) ;
225+ const snappedY = Math . round ( pos . y / scale / YD_TO_PX ) ;
226+
227+ const surroundingCellIds = [
228+ `cell_${ Math . floor ( snappedX ) } _${ Math . floor ( snappedY ) } ` ,
229+ `cell_${ Math . floor ( snappedX ) - 1 } _${ Math . floor ( snappedY ) } ` ,
230+ `cell_${ Math . floor ( snappedX ) } _${ Math . floor ( snappedY ) - 1 } ` ,
231+ `cell_${ Math . floor ( snappedX ) - 1 } _${ Math . floor ( snappedY ) - 1 } ` ,
232+ ] ;
233+
234+ const isOnBanCell = surroundingCellIds . some ( ( id ) => banCells . includes ( id ) ) ;
235+ const isOnDamageCell = surroundingCellIds . some ( ( id ) =>
236+ damageCells . includes ( id ) ,
237+ ) ;
238+ const isOnRainCell =
239+ isRainyDay && surroundingCellIds . some ( ( id ) => rainCells . includes ( id ) ) ;
240+
241+ if (
242+ isInsideGreen (
243+ { id : currentPin . id , x : snappedX , y : snappedY } ,
244+ holeData . cells ,
245+ ) &&
246+ isPointInPolygon ( snappedX , snappedY , boundaryBufferPoints ) &&
247+ ! isPointInPolygon ( snappedX , snappedY , slopeBufferPoints ) &&
248+ ! isOnBanCell &&
249+ ! isOnDamageCell &&
250+ ! isOnRainCell
251+ ) {
252+ onPinPlaced ( {
253+ id : currentPin . id ,
254+ x : snappedX ,
255+ y : snappedY ,
256+ } ) ;
257+ }
258+ } ;
259+
260+ // 単発タップ/クリック(ペイントモード無効時)
261+ const handleTapOrClick = (
104262 e : Konva . KonvaEventObject < MouseEvent | TouchEvent > ,
105263 ) => {
106264 const stage = e . target . getStage ( ) ;
@@ -110,60 +268,35 @@ export default function GreenCanvas({
110268
111269 // セルクリック処理
112270 if ( onCellClick ) {
113- const x = Math . floor ( pos . x / scale / YD_TO_PX ) ;
114- const y = Math . floor ( pos . y / scale / YD_TO_PX ) ;
115- const cellId = `cell_${ x } _${ y } ` ;
116- const cell = holeData . cells . find ( ( c ) => c . id === cellId ) ;
117- if ( cell ) {
118- onCellClick ( cellId ) ;
119- }
271+ const cellId = toCellId ( pixelToCell ( pos ) ) ;
272+ if ( cellId ) onCellClick ( cellId ) ;
120273 return ;
121274 }
122275
123276 // ピン配置処理
124277 if ( onPinPlaced && currentPin ) {
125- const snappedX = Math . round ( pos . x / scale / YD_TO_PX ) ;
126- const snappedY = Math . round ( pos . y / scale / YD_TO_PX ) ;
127-
128- const surroundingCellIds = [
129- `cell_${ Math . floor ( snappedX ) } _${ Math . floor ( snappedY ) } ` ,
130- `cell_${ Math . floor ( snappedX ) - 1 } _${ Math . floor ( snappedY ) } ` ,
131- `cell_${ Math . floor ( snappedX ) } _${ Math . floor ( snappedY ) - 1 } ` ,
132- `cell_${ Math . floor ( snappedX ) - 1 } _${ Math . floor ( snappedY ) - 1 } ` ,
133- ] ;
134-
135- const isOnBanCell = surroundingCellIds . some ( ( id ) =>
136- banCells . includes ( id ) ,
137- ) ;
138- const isOnDamageCell = surroundingCellIds . some ( ( id ) =>
139- damageCells . includes ( id ) ,
140- ) ;
141- const isOnRainCell =
142- isRainyDay && surroundingCellIds . some ( ( id ) => rainCells . includes ( id ) ) ;
143-
144- if (
145- isInsideGreen (
146- { id : currentPin . id , x : snappedX , y : snappedY } ,
147- holeData . cells ,
148- ) &&
149- isPointInPolygon ( snappedX , snappedY , boundaryBufferPoints ) &&
150- ! isPointInPolygon ( snappedX , snappedY , slopeBufferPoints ) &&
151- ! isOnBanCell &&
152- ! isOnDamageCell &&
153- ! isOnRainCell
154- ) {
155- onPinPlaced ( {
156- id : currentPin . id ,
157- x : snappedX ,
158- y : snappedY ,
159- } ) ;
160- }
278+ handlePinPlacement ( pos ) ;
161279 }
162280 } ;
163281
164282 return (
165283 < Stage width = { width } height = { height } scaleX = { scale } scaleY = { scale } >
166- < Layer onClick = { handleStageInteraction } onTap = { handleStageInteraction } >
284+ { /* ペイントモード: ドラッグ塗り / 無効時: 単発操作 */ }
285+ < Layer
286+ { ...( enablePaintMode && onCellClick
287+ ? {
288+ onMouseDown : handlePaintStart ,
289+ onMouseMove : handlePaintMove ,
290+ onMouseUp : handlePaintEnd ,
291+ onTouchStart : handlePaintStart ,
292+ onTouchMove : handlePaintMove ,
293+ onTouchEnd : handlePaintEnd ,
294+ }
295+ : {
296+ onClick : handleTapOrClick ,
297+ onTap : handleTapOrClick ,
298+ } ) }
299+ >
167300 { /* 背景レイヤー */ }
168301 { holeData . layers . map ( ( layer , index ) => (
169302 < Path
0 commit comments