Techに戻る

Next.js + MapLibre で道路ラインを描画・編集する最小UI実装

クリックで点を追加、ドラッグで移動、右クリック/キーで削除。最小UIでルートの描画・編集を実装した要点を整理します。

課題

  • 道路ライン編集中に、既存線分の途中へ頂点をワンクリックで挿入したい
  • 追加UIは避けて直感操作にしたい(ダイアログなし、ショートカット不要)
  • モバイルでも狙いやすく、誤操作(ドラッグ直後のクリックなど)を防ぎたい

解決アプローチ(要点)

  • GeoJSONのソース/レイヤーを再利用し、setDataで差分更新
  • クリックで点を追加(点上クリック時は追加しない)
  • ドラッグで点を移動、右クリック/Deleteで削除、Escで終了
  • 選択点用レイヤーでハイライト表示
  • 初期ルートがあればfitBoundsで自動表示調整

sample

実装

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]);

再現手順(最小)

  1. 既存の DrawingMap に以下を加える:
    • insertPointOnLine を追加
    • 透明の route-line-hitarea レイヤーを追加
    • 編集モードのクリック処理で、線レイヤーをヒット判定し insertPointOnLine を呼ぶ
  2. カーソルフィードバックを追加(線上: copy、点上: grab)。
  3. 編集モードの説明テキストへ「線をクリック: 途中点を追加」を明記。

UX 上の工夫

  • 視覚: カーソル種別で操作意図を伝える。
  • 判定: 透明太線でヒット範囲を拡大しストレスを低減。
  • 状態: 挿入直後にその点を選択し、次の操作(ドラッグ等)へ自然に誘導。

補足(パフォーマンス/テスト)

  • 計算は軽量な線分投影のみ。頂点数が多くなければUI P50はおおむね<200ms。
  • 単体: insertPointOnLine の投影・挿入インデックス計算(座標モックで境界値を含む)。
  • E2E:編集モード→線クリック→点が1つ増える、カーソル変化、説明テキスト表示を確認。

まとめ

  • 課題:途中点挿入を直感操作・最小UIで実現したい。
  • 解決:透明ヒットレイヤーで線上クリックを確実化し、最近傍線分への投影で頂点を挿入。
  • 効果:ワンクリックでの編集体験と、既存フローへの自然な統合を両立。

この記事は役に立ちましたか?

フィードバックはブログの改善に活用させていただきます