Skip to content

Commit 718e7f3

Browse files
natsunatsu
authored andcommitted
feat: セル連続入力(ペイントモード)
1 parent 27db230 commit 718e7f3

2 files changed

Lines changed: 186 additions & 50 deletions

File tree

app/admin/cells/page.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,9 @@ export default function CellsEditPage() {
197197
<p>グリーン上の傷み・禁止・雨天エリアをセルで管理します。</p>
198198
<p>1. 上部のタブで傷み/禁止/雨天を切り替え</p>
199199
<p>2. 左のグリッドでホールを選択</p>
200-
<p>3. 右のキャンバスでセルをクリックして選択</p>
201-
<p>4. コメントを入力して「保存」</p>
200+
<p>3. 右のキャンバスでセルをタップして選択・解除</p>
201+
<p>4. ドラッグで連続してセルを塗れます</p>
202+
<p>5. コメントを入力して「保存」</p>
202203
</HelpButton>
203204
</PageHeader>
204205

@@ -395,6 +396,8 @@ export default function CellsEditPage() {
395396
banCells={cellMode === "ban" ? displayCells : []}
396397
rainCells={cellMode === "rain" ? displayCells : []}
397398
onCellClick={handleCellClick}
399+
enablePaintMode={true}
400+
activeCells={displayCells}
398401
showExit={false}
399402
showExitRoute={false}
400403
showBoundaryBuffer={false}

components/greens/GreenCanvas.tsx

Lines changed: 181 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
Rect,
1212
} from "react-konva";
1313
import Konva from "konva";
14-
import { Fragment } from "react";
14+
import { Fragment, useRef, useCallback } from "react";
1515
import { HOLE_CONFIGS } from "@/config/holes";
1616
import {
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

Comments
 (0)