クリックで点を追加、ドラッグで移動、右クリック/キーで削除。最小UIでルートの描画・編集を実装した要点を整理します。
課題
- 道路ライン編集中に、既存線分の途中へ頂点をワンクリックで挿入したい
- 追加UIは避けて直感操作にしたい(ダイアログなし、ショートカット不要)
- モバイルでも狙いやすく、誤操作(ドラッグ直後のクリックなど)を防ぎたい
解決アプローチ(要点)
- GeoJSONのソース/レイヤーを再利用し、
setDataで差分更新 - クリックで点を追加(点上クリック時は追加しない)
- ドラッグで点を移動、右クリック/Deleteで削除、Escで終了
- 選択点用レイヤーでハイライト表示
- 初期ルートがあれば
fitBoundsで自動表示調整

実装
1) 線分上に点を挿入するロジック
typescript
const updateRouteOnMap = useCallback(() => {
if (!map || !map.isStyleLoaded()) return;
try {
if (coordinates.length === 0) {
if (map.getLayer('route-line')) map.removeLayer('route-line');
if (map.getLayer('route-points')) map.removeLayer('route-points');
if (map.getLayer('route-points-selected')) map.removeLayer('route-points-selected');
if (map.getSource('route')) map.removeSource('route');
if (map.getSource('route-points')) map.removeSource('route-points');
if (map.getSource('route-points-selected')) map.removeSource('route-points-selected');
return;
}
const routeCollection = { type: 'FeatureCollection' as const, features: [
{ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates } },
]};
const pointsCollection = { type: 'FeatureCollection' as const, features: coordinates.map((coord, index) => ({
type: 'Feature' as const, properties: { index }, geometry: { type: 'Point' as const, coordinates: coord },
}))};
const selectedPointsCollection = { type: 'FeatureCollection' as const, features: selectedPointIndex !== null && coordinates[selectedPointIndex]
? [{ type: 'Feature' as const, properties: { index: selectedPointIndex }, geometry: { type: 'Point' as const, coordinates: coordinates[selectedPointIndex] } }]
: [] };
const routeSource = map.getSource('route') as GeoJSONSource | undefined;
if (routeSource) routeSource.setData(routeCollection as unknown as GeoJSON.FeatureCollection);
else map.addSource('route', { type: 'geojson', data: routeCollection as unknown as GeoJSON.FeatureCollection });
if (!map.getLayer('route-line')) {
map.addLayer({ id: 'route-line', type: 'line', source: 'route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b82f6', 'line-width': 4 } });
}
const pointsSource = map.getSource('route-points') as GeoJSONSource | undefined;
if (pointsSource) pointsSource.setData(pointsCollection as unknown as GeoJSON.FeatureCollection);
else map.addSource('route-points', { type: 'geojson', data: pointsCollection as unknown as GeoJSON.FeatureCollection });
if (!map.getLayer('route-points')) {
map.addLayer({ id: 'route-points', type: 'circle', source: 'route-points', paint: { 'circle-radius': 6, 'circle-color': '#3b82f6', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 } });
}
const selectedPointsSource = map.getSource('route-points-selected') as GeoJSONSource | undefined;
if (selectedPointsSource) selectedPointsSource.setData(selectedPointsCollection as unknown as GeoJSON.FeatureCollection);
else map.addSource('route-points-selected', { type: 'geojson', data: selectedPointsCollection as unknown as GeoJSON.FeatureCollection });
if (!map.getLayer('route-points-selected')) {
map.addLayer({ id: 'route-points-selected', type: 'circle', source: 'route-points-selected', paint: { 'circle-radius': 9, 'circle-color': '#ef4444', 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 } });
}
} catch (error) {
reportError('Error updating route on map:', error);
}
}, [map, coordinates, selectedPointIndex]);
2) 点の追加/選択/移動/削除(イベント処理)
typescript
useEffect(() => {
if (!map) return;
const handleClick = (e: maplibregl.MapMouseEvent) => {
if (isDraggingPoint) return; // ドラッグ終端の誤クリック防止
if (drawingRef.current) {
const features = map.queryRenderedFeatures(e.point, { layers: ['route-points', 'route-points-selected'] });
if (features.length === 0) { // 点上クリックでは追加しない
const newCoord = [e.lngLat.lng, e.lngLat.lat];
setCoordinates(prev => [...prev, newCoord]);
}
return;
}
if (editingRef.current) {
const features = map.queryRenderedFeatures(e.point, { layers: ['route-points'] });
if (features.length > 0) {
const index = features[0].properties?.index;
if (typeof index === 'number') { setSelectedPointIndex(index); return; } // 既存点を選択
}
setSelectedPointIndex(null); // 何も当たっていなければ選択解除
}
};
const handleMouseDown = (e: maplibregl.MapMouseEvent) => {
if (!drawingRef.current && !editingRef.current) return; // 非モード時は無視
const features = map.queryRenderedFeatures(e.point, { layers: ['route-points', 'route-points-selected'] });
if (features.length > 0) {
const index = features[0].properties?.index;
if (typeof index === 'number') {
e.preventDefault();
setIsDraggingPoint(true); // ドラッグ開始
draggingPointRef.current = index;
setSelectedPointIndex(index);
map.getCanvas().style.cursor = 'grabbing'; // UX: つかんでいる感の提示
map.dragPan.disable(); // パンを無効化して点ドラッグに集中
}
}
};
const handleMouseMove = (e: maplibregl.MapMouseEvent) => {
if (draggingPointRef.current !== null) {
const newCoord = [e.lngLat.lng, e.lngLat.lat]; // ドラッグ中はリアルタイム更新
setCoordinates(prev => { const updated = [...prev]; updated[draggingPointRef.current!] = newCoord; return updated; });
return;
}
if (drawingRef.current || editingRef.current) {
const features = map.queryRenderedFeatures(e.point, { layers: ['route-points', 'route-points-selected'] });
if (features.length > 0) map.getCanvas().style.cursor = 'grab'; // 点上
else if (drawingRef.current) map.getCanvas().style.cursor = 'crosshair'; // 描画中
else map.getCanvas().style.cursor = 'pointer'; // 編集中
}
};
const handleMouseUp = () => {
if (draggingPointRef.current !== null) {
setIsDraggingPoint(false);
draggingPointRef.current = null;
if (drawingRef.current) map.getCanvas().style.cursor = 'crosshair'; // 描画へ復帰
else if (editingRef.current) map.getCanvas().style.cursor = 'pointer'; // 編集へ復帰
map.dragPan.enable(); // パンを再有効化
}
};
const handleContextMenu = (e: maplibregl.MapMouseEvent & { originalEvent: MouseEvent }) => {
e.preventDefault(); // 右クリックメニュー抑止
if (e.originalEvent) { e.originalEvent.preventDefault(); e.originalEvent.stopPropagation(); }
if (drawingRef.current || editingRef.current) {
const features = map.queryRenderedFeatures(e.point, { layers: ['route-points', 'route-points-selected'] }); // 点に当たっていれば
if (features.length > 0) {
const index = features[0].properties?.index;
if (typeof index === 'number') {
setCoordinates(prev => prev.filter((_, i) => i !== index)); // その点を削除
setSelectedPointIndex(null);
}
}
}
};
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'Escape') stopMode(); // ESCで終了
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedPointIndex !== null && editingRef.current) {
e.preventDefault();
setCoordinates(prev => prev.filter((_, index) => index !== selectedPointIndex)); // Delete/Backspaceで削除
setSelectedPointIndex(null);
}
};
if (mode !== 'none') {
map.on('click', handleClick);
map.on('contextmenu', handleContextMenu);
document.addEventListener('keydown', handleKeyPress);
}
if (mode === 'drawing' || mode === 'editing') {
map.on('mousedown', handleMouseDown);
map.on('mousemove', handleMouseMove);
map.on('mouseup', handleMouseUp);
}
return () => {
map.off('click', handleClick);
map.off('contextmenu', handleContextMenu);
map.off('mousedown', handleMouseDown);
map.off('mousemove', handleMouseMove);
map.off('mouseup', handleMouseUp);
document.removeEventListener('keydown', handleKeyPress);
};
}, [map, mode, selectedPointIndex, stopMode, isDraggingPoint]);
再現手順(最小)
- 既存の
DrawingMapに以下を加える:insertPointOnLineを追加- 透明の
route-line-hitareaレイヤーを追加 - 編集モードのクリック処理で、線レイヤーをヒット判定し
insertPointOnLineを呼ぶ
- カーソルフィードバックを追加(線上:
copy、点上:grab)。 - 編集モードの説明テキストへ「線をクリック: 途中点を追加」を明記。
UX 上の工夫
- 視覚: カーソル種別で操作意図を伝える。
- 判定: 透明太線でヒット範囲を拡大しストレスを低減。
- 状態: 挿入直後にその点を選択し、次の操作(ドラッグ等)へ自然に誘導。
補足(パフォーマンス/テスト)
- 計算は軽量な線分投影のみ。頂点数が多くなければUI P50はおおむね<200ms。
- 単体:
insertPointOnLineの投影・挿入インデックス計算(座標モックで境界値を含む)。 - E2E:編集モード→線クリック→点が1つ増える、カーソル変化、説明テキスト表示を確認。
まとめ
- 課題:途中点挿入を直感操作・最小UIで実現したい。
- 解決:透明ヒットレイヤーで線上クリックを確実化し、最近傍線分への投影で頂点を挿入。
- 効果:ワンクリックでの編集体験と、既存フローへの自然な統合を両立。
この記事は役に立ちましたか?
フィードバックはブログの改善に活用させていただきます