vue-konva

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

1post · 30d
Nov 50 posts
  1. Zenn · · @jixiaopan

    倉庫UIの複数選択を、Konvaで「なぞる」操作にした 倉庫マップには、数百個の置場が小さな矩形として並びます。一つずつタップして状態を変えるのは遅いため、指で置場をなぞって選べる操作を作りました。 KonvaのTransformerで矩形範囲を囲む方式ではなく、stage.getIntersection()で指の真下にある図形を拾います。実装の要点は、5pxの閾値、ドラッグ開始時に決める選択・解除モード、同じ置場を二度処理しないためのSetの3つです。 数百個の置場を一つずつ押したくない 私がある製造業のお客様向けに作った倉庫監視 UI は、技術スタックが Nuxt 3 + Vue 3 + Konva.js(vue-konva でラップ)です。その中の中心的なインタラクションが、倉庫内の数百個の置場(rack)を 700×650 の canvas 上に密に並んだ小さな矩形として描き、オペレーターが一度に一群を選んで状態をまとめて変更できる、というものでした。 一つずつクリックするのは現実的でないので、複数選択は必須要件です。ただ最終的に、私は Konva 標準の「rubber-band 選択 + Transformer」の道は採らず、自前でなぞり式(paint)の複数選択を書きました——押したままなぞると、ポインターの真下にある rack が選択集合に加わっていく方式です。 この記事では、この設計上の選択、実際の状態機の実装、ハマったところ、そしてもう一つのフォールバックであるレンジ選択まで一緒に書いていきます。 背景:なぜ rubber-band 選択を使わなかったか Konva で複数選択をするとき、ネットで一番よく見るやり方は、半透明の矩形(rubber-band)をドラッグで描き、離したときにその矩形の getClientRect() を各 shape と Util.haveIntersection で当たり判定して、ヒットしたものに Transformer をかける、というものです。枯れたやり方で、ドキュメントも揃っています。 ただ、この倉庫UIには合わないと感じました。理由は三つあります。 倉庫マップは密に並んだグリッドです。数百個の矩形が隣り合って並んでいて、オペレーターのメンタルモデルは「この一列、このひと塊を塗りたい」であって、「ある領域を囲みたい」ではありません。密なグリッドの上では、「なぞる」方が「囲む」より直感的です——Excel でセルをドラッグして一帯を選ぶ感覚に近い。 対象デバイスは工業用タッチパネル / iPadです。タッチ画面で矩形枠をドラッグすると、ページ / canvas 自体のスクロールやズームのジェスチャーとぶつかりやすい。一方「一つの rack を押したまま横へなぞる」という動作は、意味的に「これらのマスの上をなぞっている」に近く、スクロールとの衝突が小さい。 rubber-band はさらに、選択枠の shape を自分で持って、それと各 rack の交差を計算する必要があります。shape が増えれば haveIntersection もひと回りループです。私が本当に気にしているのはポインターの真下にあるただ一つだけなのに。 特に効いたのがstage.getIntersection(pos)です。座標を渡すと、その点の最前面にあるshapeを返します。今回必要なのはポインター直下の置場一つだけなので、矩形との交差判定を自前で書く必要はありません。 なぞり式複数選択の実装 状態機:6 つの変数 ドラッグのロジック全体は、下の数個の ref で支えられています。これ以上凝ったものはありません。 // スライド選択の状態 const isDragging = ref(false); const dragStartPos = ref<{ x: number; y: number } | null>(null); const hasMoved = ref(false); // 実際に動いたかどうかのフラグ const dragMode = ref<"select" | "deselect">("select"); // 選択 / 選択解除 const processedRacksInDrag = ref<Set<string>>(new Set()); // 今回のドラッグで処理済みの置場 const DRAG_THRESHOLD = 5; // 5px 以上動いて初めてドラッグとみなす dragMode とこの Set が、この設計で一番語る価値のある二つで、後でそれぞれ分けて説明します。 イベント側は、マウスとタッチを同じ handler のセットにバインドしました——対象デバイスには工業用タッチパネルもあれば、デスクトップとして使う場面もあるので、二度書く必要はありません。 <v-layer @mousedown="handleDragStart" @mousemove="handleDragMove" @mouseup="handleDragEnd" @touchstart="handleDragStart" @touchmove="handleDragMove" @touchend="handleDragEnd" > 押下:まずこの一筆が「選択」か「解除」かを決める 押した瞬間に、当時は当たり前と思っていて、後になってかなり重要だと気づいたことをしています:始点の rack が今選択済みかどうかで、このドラッグ一筆全体を select か deselect にロックするのです。 const handleDragStart = (e: any) => { const stage = e.target.getStage(); if (!stage) return; const pos = stage.getPointerPosition(); if (!pos) return; // タッチでは座標が取れないことがあるので一手防いでおく // 始点の真下の置場を一目見て、この一筆のモードを決める const shape = stage.getIntersection(pos); if (shape) { const parkNo = shape.id(); if (parkNo) { const rack = findRackByParkNo(parkNo); if (rack) { // 下段 -1 / 上段 -2、一回の操作で両段を持つ const layer1ParkNo = parkNo.replace(/-2$/, "-1"); const layer2ParkNo = parkNo.replace(/-1$/, "-2"); const existing = updateRacks.value.find( (item) => item.parkNo === layer1ParkNo || item.parkNo === layer2ParkNo); dragMode.value = existing ? "deselect" : "select"; // 始点が選択済み → この一筆は全部解除 } } } dragStartPos.value = { x: pos.x, y: pos.y }; hasMoved.value = false; isDragging.value = true; processedRacksInDrag.value.clear(); // ドラッグごとに重複用 Set をクリア }; なぜ始点でモードを固定するのか? ロックしないと、選択済みのものをなぞり、続けて未選択のものをなぞったとき、選択なのか解除なのか乱れてしまうからです——同じ一筆の中で選択したり解除したりが入り混じり、挙動が暴れて見えます。「始点が未選択 → この一筆は選択だけ、始点が選択済み → この一筆は解除だけ」とロックすれば、ジェスチャー全体の意味が安定します。 移動:閾値 → ヒット → 重複排除 handleDragMove の中では、順に三つの関門を通します。 const handleDragMove = (e: any) => { if (!isDragging.value) return; const stage = e.target.getStage(); const pos = stage.getPointerPosition(); if (!pos || !dragStartPos.value) return; // 5px 未満ならクリックとして扱う const dx = pos.x - dragStartPos.value.x; const dy = pos.y - dragStartPos.value.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < DRAG_THRESHOLD) return; if (!hasMoved.value) hasMoved.value = true; // ポインター直下の shape は Konva に探させる const shape = stage.getIntersection(pos); if (!shape) return; const parkNo = shape.id(); // このドラッグですでに処理した置場は飛ばす if (!parkNo || processedRacksInDrag.value.has(parkNo)) return; const rack = findRackByParkNo(parkNo); if (!rack) return; if (!canSelectRack(rack)) return; // 選択可否のフィルタ(占有 / 禁止など) // …dragMode に従って、この rack の下段 -1 / 上段 -2 を一緒に push / filter… processedRacksInDrag.value.add(parkNo); processedRacksInDrag.value.add(layer1ParkNo); processedRacksInDrag.value.add(layer2ParkNo); }; 三つの関門のうち、ヒット判定のあの一行こそが設計全体の核心です。私は「点が矩形の中にあるか」の幾何計算は一切書かず、stage.getIntersection(pos) の一行で済ませています。返ってくるのはこの座標点の上、最前面の shape で、その id() がそのまま parkNo です。 ここには業務上の制約もあります。倉庫のラックは二段で、下段のparkNoは-1、上段は-2で終わります。一回の操作では両段を一緒に処理します。replace(/-2$/, "-1")とreplace(/-1$/, "-2")で対応する番号を求め、ペアで追加または削除します。片方だけ更新すると状態がずれるためです。 押上:きれいに片付ける const handleDragEnd = () => { isDragging.value = false; dragStartPos.value = null; hasMoved.value = false; dragMode.value = "select"; // デフォルトモードに戻す processedRacksInDrag.value.clear(); }; 何の工夫もなく、次の一筆に持ち越さないよう状態機をリセットするだけです。 Setで同じ置場の再処理を防ぐ mousemoveとtouchmoveにはthrottleもdebounceもかけていません。代わりに、processedRacksInDragというSetで、同じドラッグ中に同じ置場を繰り返し更新しないようにしています。 mousemoveは高頻度で発火します。同じ置場の上で指が少し揺れるだけでも、同じshapeを何度も拾います。そのたびにupdateRacksを更新すると、同じ置場が繰り返し追加・削除され、選択状態まで揺れてしまいます。 最初になぞったときだけ処理してSetへ記録し、同じparkNoを再び拾ったらSet.has()で飛ばします。これは描画全体を高速化する仕組みではありません。選択配列への重複更新を防ぎ、ジェスチャーの意味を安定させるための仕組みです。 ハイライト:fill を切り替え、枠は付けない rubber-band のやり方は普通、選択した要素に Transformer で枠線を描きます。私の方は Transformer がそもそも無いので、ハイライトは色で直接やります——v-rect の fill を reactive な関数にバインドします。 const handleColor = (rack: BasicParkInfo) => { // 選択集合の中 → オレンジでハイライト if (updateRacks.value.find((item) => item.parkNo === rack.parkNo)) return COLORS.second; // #ff7f00 if (rack.unusable === 1) return COLORS.warning; // 禁止 if (rack.cargoStatus !== 0) return COLORS.twoOccupied; // 荷あり return COLORS.emptyStatus; // 空 }; updateRacks(選択集合)が変わると、すべての v-rect の handleColor が計算し直され、選ばれたマスはすぐに #ff7f00 になります。利点は、ハイライトと「選択されているか」が同じデータソースで、Transformer ノードの一群を別途維持しなくていいこと。代償は後のハマりどころで触れますが——マスが多いとこの再計算にはコストがあります。 ハマったところいくつか クリックとドラッグが同じ要素を取り合う。 同じ v-rect の上に、@click / @tap の単一選択と、外側 layer の mousedown→move→up の複数選択が両方あります。 @tap="canSelectRack(rack) && toggleSelection(rack.parkNo)" @click="canSelectRack(rack) && toggleSelection(rack.parkNo)" この二つがどうぶつからないか? すべてあの 5px 閾値のおかげです。指を押してほとんど動かさず(変位 < 5px)に離すと、handleDragMove 内の距離判定がずっと return し、hasMoved は false のままなので、ドラッグは実質何もせず、@click の単一選択だけが効きます。閾値のこの数字は当時調整しました:小さすぎると、手が少し震えただけでドラッグ扱いになって一帯を誤選択する。大きすぎると、なぞり始めにそれなりに動かさないと反応せず、操作感が鈍い。5px は試行で出した、タッチ画面で操作感が安定する値です。 タッチ画面では getPointerPosition() が null を返す。 これは忘れやすいポイントです。タッチイベントは特定のタイミングで座標が取れないので、各 handler の冒頭で if (!pos) return を一枚挟まないと、後の pos.x がそのまま落ちます。 二段ラックの同期。 先ほどの-1 / -2のペア処理は、当時の業務ルールに合わせたものです。どこか一か所でも対応するもう一段を更新し忘れると、二段の状態がずれてしまいます。そのため、どの分岐でも二つのparkNoを一緒にSetへ入れ、同時に選択集合へ追加します。 fill 切り替えの再描画コスト。 ハイライトは「選択集合が変わる → すべての v-rect が handleColor を再計算」なので、マスが特に多い拠点では、一回の選択で広範囲の再評価が走ります。ここは後で別の Konva 性能の記事でもっと体系的に扱いましたが、ここでは注意点として:fill でハイライトすると Transformer を省けますが、再描画コストがあることは忘れないでください。 もう一つの方式:2 回クリックの一行レンジ選択 なぞり式は「ある形をきっちり塗り出したい」ときは手に馴染みますが、ドラッグが不便な場面もあります——たとえば「この一行の 3 列目から 18 列目まで」を選びたいとき、なぞるのは遅いし斜めにずれやすい。そこで別のコンポーネントに、クリックベースのレンジ選択をもう一つ作り、「複数選択モード」のスイッチで切り替えるようにしました。 その状態機はとても単純で、「最初の一点」を覚えておくだけです。 const multiSelectMode = ref(false); const firstSelectedLocation = ref<{ parkNo: string; rowKey: string; colKey: string } | null>(null); クリックの処理は二段式です。 const toggleSelection = (/* parkNo, rowKey, colKey, … */) => { if (multiSelectMode.value) { if (!firstSelectedLocation.value) { // 1 回目のクリック:始点を記録 firstSelectedLocation.value = { parkNo, rowKey, colKey }; toggleSingleSelection(/* … */); } else { // 2 回目のクリック:同じ行 → レンジ選択;違う行 → 通常の単一選択扱い if (firstSelectedLocation.value.rowKey === rowKey) { handleRangeSelection(firstSelectedLocation.value.colKey, colKey, rowKey); } else { toggleSingleSelection(/* … */); } firstSelectedLocation.value = null; // 使ったらクリアして次のペアに備える } return; } toggleSingleSelection(/* … */); // 複数選択モードでない:純粋な単一選択 }; レンジ展開の実体は列の index で行います。ここに当時危うく忘れかけたディテールが一つ——両方向のクリックに対応する必要があります:先に右を点いてから左、と先に左を点いてから右で、結果は同じであるべき。Math.min/max で起点・終点の index を正しい向きに揃えます。 const handleRangeSelection = (startCol, endCol, rowKey) => { const allColKeys = Object.keys(rowData).sort((a, b) => parseInt(a) - parseInt(b)); const startIndex = allColKeys.indexOf(startCol); const endIndex = allColKeys.indexOf(endCol); const minIndex = Math.min(startIndex, endIndex); // 両方向どちらも正しく const maxIndex = Math.max(startIndex, endIndex); // shouldSelect:始点が選択済みか、で この区間を「全選択」か「全解除」かを決める for (let i = minIndex; i <= maxIndex; i++) { // …範囲内を列ごとに push / filter… } }; ここはなぞり式のあの dragMode と同じ発想です:始点の状態で区間全体を選択か解除か一度に決め、途中で変えない。このほかに group モードもあって、行頭を点くとその行の rack をまとめて push します。ロジックは同じ理屈(一行の全列を flat() で出し、from/to/状態変更の可否でフィルタしてから一括で選択集合へ)なので、貼りません。 選び終わると、どちらの方式も emit("selectedItems", …) で選択集合を親コンポーネントへ投げ、最後に確定ボタンが一括で WCS(倉庫制御システム)の確定 API へ送ります——選択するレイヤーと送信するレイヤーは分かれていて、UI は選択集合を貯めることだけを担います。 実装して分かったこと Transformerとrubber-bandは複数選択の定番ですが、密なグリッドをタッチ操作する今回の画面では、なぞる方式のほうが要件に合いました。 ヒット判定はすべて stage.getIntersection(pos) に任せ、幾何の交差を自分で計算しない。だから選択枠の shape も要らない。 5px 閾値でクリックとドラッグを一刀両断にし、単一選択と複数選択を同じ要素の上に共存させる。 押した時に始点の状態で dragMode をロックし、一筆のジェスチャーの意味を安定させる。 throttleは入れず、Setで同じ置場への重複更新を防ぐ。 ハイライトは reactive な fill の切り替えで行い、Transformer の枠線にはしない。 ドラッグが手に馴染まない場面は、2 回クリックの一行レンジ選択でフォローする。 もし続けて改善するなら、次はこの状態機をコンポーネントからcomposable に切り出すでしょう——isDragging / dragMode / processedRacksInDrag のこれらの変数と三つの handler は、それ自体で完結した小さなユニットなので、切り出せば別の canvas に再利用でき、単体でテストもしやすくなります。 参考リンク Konva.js — Stage.getIntersection()(公式 API) Konva.js — Select and Transform(公式 rubber-band サンプル) vue-konva(公式) Vue 3 — Composables(公式)

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

1
PackageVersionDownloads · 30dTrend · 30d
vue-konvav4.0.1
v4.0.1
≥154.6K

Live mentions badge

Show how many posts mention your project in the last 30 days.

Live mentions · last 30 days
konvajs/vue-konva mentions in the last 30 days