※当サイトの記事には、広告・プロモーションが含まれます。

JavaScriptで矩形選択を作成、リサイズ、移動、選択範囲内のDOMの情報を取得したりする

gigazine.net

今回の研究では、将来の都市人口を左右する要因についての調査は行われませんでした。しかし、ストラダール氏は住宅価格の上昇や産業の衰退、出生率の低下、州税の違い、気候変動の影響といった地域によって異なる変数が複雑に絡み合い、人口が減少または増加する可能性があると述べています。

アメリカでは約半数の都市が2100年までにゴーストタウンになるかもしれないと研究者が警告 - GIGAZINE

すでに一部の都市では、人がいなくなることの悪影響が顕在化しつつあります。ミシシッピ州の州都であるジャクソンでは2022年の洪水で水道処理施設が打撃を受け、1週間以上も安全な水道水の供給ができない状態が続きました。ジャクソンは人口の70%以上を黒人が占めており、白人離れによる税収の低下で水道設備の修繕が追いつかなかったことが、この事態を招いたと指摘されています。

アメリカでは約半数の都市が2100年までにゴーストタウンになるかもしれないと研究者が警告 - GIGAZINE

研究チームは、「人口の大幅な減少がもたらすのは、前例のない課題です。交通機関・清潔な水・電気・インターネットアクセスといった基本的なサービスに支障を来す可能性があります」と警告しました。

アメリカでは約半数の都市が2100年までにゴーストタウンになるかもしれないと研究者が警告 - GIGAZINE

⇧ 突き詰めるとお金の問題ってことになると...。

日本でも限界集落の問題が発展して、ゴーストタウン化が加速していくんかな...

ライフラインに関わる部分は、国が責任を持ってもらいたいのだけど、日本でも水道について民営化を導入してる自治体があるらしいのだけど、狂気の沙汰極まれりですな...

ここ30年ぐらいの日本政府って日本が滅亡に向かうような施策ばかりしているように見えて、ここまでの惨状を見るに、日本滅亡すると得する国から報酬でももらっているんじゃないかと勘繰りたくもなってきますな...

とりあえず、

www.jcer.or.jp

⇧ 外国人の受け入れをするしかないとしても、

www.sankei.com

⇧ こういう問題が起きてるからして、はいそうですか、ってわけにはいかないと思うんよね...

とりあえず、問題を起こした外国人については、精査した上で過失があるのであれば強制送還して、二度と日本に入国できないようにするような対応をした方が良いとは思いますな。

ルールを守っている他の外国人の方にも迷惑ですし、郷に入っては郷に従え、じゃないですが、日本で生活する選択をしたならば、日本のルールに従うのが当然でしょうし。

JavaScriptで矩形選択を作成、リサイズ、移動、選択範囲内のDOMの情報を取得したりする

意外に、ネットで検索しても実現したいことについての情報が見当たらなかったので、実装してみました。

例の如く、無茶苦茶に時間かかったし、JavaScriptのイベントの挙動の理解が曖昧なので、ソースコードは酷い感じですが、備忘録として掲載しときます。

とりあえず、フロントエンドの有識者の方が、今時のJavaScriptフレームワーク(React、Vue、Angularなど)による実装版を情報発信してくれるまでは、参考になるのではないかと。

まぁ、ネットの検索に出て来ないということは、需要が無いのかもしれませんが...

話が脱線しましたが、以下、ソースコードになります。

VS CodeVisual Studio Code)でコーディングしてます。

■test_rectangle_selection/.vscode/settings.json

{
    "liveServer.settings.port": 5501
}

■test_rectangle_selection/rectangle-selection.js

let startX, startY;
let selectedElements = [];
let selectionBox;
let isSelectBox = false;
let isCreatedSelectBox = false;
let isMoving = false;

document.addEventListener("DOMContentLoaded", () => {
  createElementsWithRandomCoordinates();
});

/**
 * 画面に配置するDOMの情報を作成する
 * (実際は、サーバーサイドから取得する想定)
 *
 */
function createElementsWithRandomCoordinates() {
  const container = document.getElementById("container");

  for (let i = 0; i < 48; i++) {
    const latitude = Math.random() * 180 - 90; // 緯度をランダム生成 (-90 から 90 の範囲)
    const longitude = Math.random() * 360 - 180; // 経度をランダム生成 (-180 から 180 の範囲)

    const element = document.createElement("div");
    element.className = "draggable";
    element.id = i; // 一意なIDを追加
    const { x, y } = getScreenCoordinates(latitude, longitude);
    element.style.left = x + "px";
    element.style.top = y + "px";
    container.appendChild(element);

    element.addEventListener("mousedown", (event) => {
      event.stopPropagation();
      startX = event.clientX;
      startY = event.clientY;
      toggleElementSelection(element);
      updateSelectedElementsColor(); // 色の更新をここで呼び出す
    });
  }
}

/**
 *
 *
 */
function getScreenCoordinates(latitude, longitude) {
  const container = document.getElementById("container");
  const containerRect = container.getBoundingClientRect();
  const mapWidth = containerRect.width;
  const mapHeight = containerRect.height;

  // 緯度経度から画面上の座標に変換
  const x = ((longitude + 180) / 360) * mapWidth;
  const y = ((-latitude + 90) / 180) * mapHeight;

  return { x, y };
}

//function toggleElementSelection(element) {
//    const index = selectedElements.indexOf(element);
//    if (index === -1) {
//        element.classList.add('selected'); // .selected クラスを追加
//        selectedElements.push(element);
//    } else {
//        element.classList.remove('selected'); // .selected クラスを削除
//        selectedElements.splice(index, 1);
//    }
//}

/**
 * 矩形選択で選択されたDOMの情報を更新する
 *
 */
function getSelectedElements() {
  const selected = [];
  document.querySelectorAll(".selected").forEach((item) => {
    selected.push(item);
  });
  return selected;
}

/**
 * 選択されているかどうか判定する
 *
 */
function isElementSelected(item, containerRect) {
  const selectionRect = selectionBox.getBoundingClientRect();
  const itemRect = item.getBoundingClientRect();
  return (
    itemRect.left < containerRect.right &&
    itemRect.right > containerRect.left &&
    itemRect.top < containerRect.bottom &&
    itemRect.bottom > containerRect.top &&
    selectionRect.left < itemRect.right &&
    selectionRect.right > itemRect.left &&
    selectionRect.top < itemRect.bottom &&
    selectionRect.bottom > itemRect.top
  );
}

/**
 * 矩形選択で選択されたDOMの色を変更する
 * (選択されてるDOMが分かりやすいように)
 */
function updateSelectedElementsColor() {
  const containerRect = document
    .getElementById("container")
    .getBoundingClientRect();
  document.querySelectorAll(".draggable").forEach((item) => {
    if (isElementSelected(item, containerRect)) {
      item.style.backgroundColor = "blue"; // 選択された要素の背景色を青に変更
      item.classList.add("selected"); // 選択された要素に.selectedクラスを追加
    } else {
      item.style.backgroundColor = "red"; // 選択されていない要素の背景色を赤に変更
      item.classList.remove("selected"); // 選択されていない要素から.selectedクラスを削除
    }
  });
}

// 2つの矩形が重なっているかを判定するヘルパー関数
function isElementInsideRect(elementRect, selectionRect) {
  return (
    elementRect.left >= selectionRect.left &&
    elementRect.top >= selectionRect.top &&
    elementRect.right <= selectionRect.right &&
    elementRect.bottom >= selectionRect.bottom
  );
}

async function createSelectionBox(startX, startY) {
  // 選択範囲を表す矩形を作成
  selectionBox = document.createElement("div");
  selectionBox.id = "selection-box";
  selectionBox.style.left = startX + "px";
  selectionBox.style.top = startY + "px";
  selectionBox.style.width = "0px";
  selectionBox.style.height = "0px";

  const container = document.getElementById("container");

  // 四つ角のDOM要素を作成し、選択範囲の矩形の四つ角に配置
  const cornerNames = ["left-top", "right-top", "right-bottom", "left-bottom"];

  cornerNames.forEach((cornerName) => {
    const corner = document.createElement("div");
    corner.id = cornerName;
    corner.className = "resize-handle";
    selectionBox.appendChild(corner);
  });

  container.appendChild(selectionBox);

  // 選択範囲の作成後にリサイズハンドルにイベントリスナーを追加
  document.querySelectorAll(".resize-handle").forEach((resizeHandle) => {
    resizeHandle.addEventListener("mousedown", handleResize);
  });
}

/**
 * 矩形選択のDOMを作成する
 *
 */
document.addEventListener("mousedown", (event) => {
  console.log("[mousedown]start");
  startX = event.clientX;
  startY = event.clientY;

  if (!isCreatedSelectBox && !isSelectBox) {
    console.log("[mousedown]!isCreatedSelectBox && !isSelectBox");
    createSelectionBox(startX, startY).then(() => {
      isCreatedSelectBox = true;
      isSelectBox = true;
    });
  } else if (event.target.id === "selection-box") {
    console.log("[mousedown]event.target.id === selection-box");
    // 選択範囲内をクリックした場合、移動を開始
    isMoving = true;
  }
  console.log("[mousedown]end");
});

document.addEventListener("dblclick", (event) => {
  console.log("[dblclick]start");
  if (event.target.id === "selection-box") {
    console.log(event.target.id);
    console.dir(selectedElements);
    // 選択された要素のDOMをリセット
    selectedElements.forEach((item) => {
      console.log(item);
      item.classList.remove("selected");
      item.style.backgroundColor = "red";
    });

    // 選択された要素の色をリセット
//    updateSelectedElementsColor();

    selectedElements = []; // 配列を空にする

    // 矩形選択のDOMを削除
    const localSelectTionBoxElement = document.getElementById("selection-box");
    console.log(isSelectBox);
    if (localSelectTionBoxElement) {
      console.log("[dbclick]isSelectBox");
      //document.getElementById("container").removeChild(selectionBox);
      
      localSelectTionBoxElement.remove();
      selectionBox=[];
      isSelectBox = false;
      isCreatedSelectBox = false;

    }
  }
  console.log("[dblclick]end");
});

/**
 * 矩形選択のDOMを移動する処理を'mousemove'イベントに登録
 *
 */
document.addEventListener("mousemove", (event) => {
  const currentX = event.clientX;
  const currentY = event.clientY;

  if (isSelectBox && !isMoving) {
    const width = Math.abs(currentX - startX);
    const height = Math.abs(currentY - startY);
    const left = Math.min(currentX, startX);
    const top = Math.min(currentY, startY);

    selectionBox.style.width = width + "px";
    selectionBox.style.height = height + "px";
    selectionBox.style.left = left + "px";
    selectionBox.style.top = top + "px";

    updateSelectedElementsColor(); // 選択された要素の色を更新
  }

  if (isMoving) {
    const deltaX = currentX - startX;
    const deltaY = currentY - startY;

    selectionBox.style.left =
      parseFloat(selectionBox.style.left) + deltaX + "px";
    selectionBox.style.top = parseFloat(selectionBox.style.top) + deltaY + "px";

    startX = currentX;
    startY = currentY;

    updateSelectedElementsColor(); // 選択された要素の色を更新
    // 選択された要素を更新
    selectedElements = getSelectedElements();
    console.log("[mousemove][getSelectedElements()]");
    console.dir(selectedElements);
  }
});

/**
 * 'mouseup'イベント時の処理
 *
 */
document.addEventListener("mouseup", () => {
  if (isSelectBox) {
    selectedElements = getSelectedElements();
    console.log("[mouseup]isSelectBox = true");
    console.dir(selectedElements);
    isSelectBox = false;

} else if (isMoving) {
    isMoving = false;
  }
});

/**
 * 矩形選択のDOMを拡大・縮小する
 *
 */
function handleResize(event) {
  event.stopPropagation();
  const resizeHandle = event.target;
  const initialMouseX = event.clientX;
  const initialMouseY = event.clientY;
  const initialWidth = parseFloat(selectionBox.style.width);
  const initialHeight = parseFloat(selectionBox.style.height);
  const initialLeft = parseFloat(selectionBox.style.left);
  const initialTop = parseFloat(selectionBox.style.top);

  let isResizing = true;

  const resizeHandler = (resizeEvent) => {
    if (!isResizing) return;

    const deltaX = resizeEvent.clientX - initialMouseX;
    const deltaY = resizeEvent.clientY - initialMouseY;

    let newWidth, newHeight, newLeft, newTop;

    if (resizeHandle.id === "left-top") {
      newWidth = initialWidth - deltaX;
      newHeight = initialHeight - deltaY;
      newLeft = initialLeft + deltaX;
      newTop = initialTop + deltaY;
    } else if (resizeHandle.id === "right-top") {
      newWidth = initialWidth + deltaX;
      newHeight = initialHeight - deltaY;
      newLeft = initialLeft;
      newTop = initialTop + deltaY;
    } else if (resizeHandle.id === "right-bottom") {
      newWidth = initialWidth + deltaX;
      newHeight = initialHeight + deltaY;
      newLeft = initialLeft;
      newTop = initialTop;
    } else if (resizeHandle.id === "left-bottom") {
      newWidth = initialWidth - deltaX;
      newHeight = initialHeight + deltaY;
      newLeft = initialLeft + deltaX;
      newTop = initialTop;
    }

    // 最小値の制限を設定
    newWidth = Math.max(newWidth, 50);
    newHeight = Math.max(newHeight, 50);

    selectionBox.style.width = newWidth + "px";
    selectionBox.style.height = newHeight + "px";
    selectionBox.style.left = newLeft + "px";
    selectionBox.style.top = newTop + "px";

    updateSelectedElementsColor(); // 選択された要素の色を更新
    // 選択された要素を更新
    selectedElements = getSelectedElements();
    console.log("[handleResize][getSelectedElements()]");
    console.dir(selectedElements);
  };

  const resizeEndHandler = () => {
    isResizing = false;
    window.removeEventListener("mousemove", resizeHandler);
    window.removeEventListener("mouseup", resizeEndHandler);
  };

  window.addEventListener("mousemove", resizeHandler);
  window.addEventListener("mouseup", resizeEndHandler);
}

if (selectionBox) {
  // selectionBoxが存在することを確認する
  selectionBox.addEventListener("mousedown", (event) => {
    if (!event.target.classList.contains("draggable")) {
      selectedElements.forEach((item) => {
        item.classList.remove("selected");
      });
      selectedElements = [];
      updateSelectedElementsColor(); // 選択された要素の色を更新
    }
  });
}

■test_rectangle_selection/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="rectangle-selection.js"></script>
    <style>
      #container {
        position: relative;
        width: 500px;
        height: 500px;
        border: 1px solid #000;
      }
      .draggable {
        position: absolute;
        cursor: move;
        width: 10px;
        height: 10px;
        background-color: red;
      }
      .selected {
        background-color: rgba(0, 0, 255, 0.5);
      }
      #selection-box {
        position: absolute;
        /*        border: 1px dashed #000; */
        background-color: rgba(0, 0, 255, 0.1);
        border: 2px dashed blue; /* 選択範囲の矩形のスタイル */
      }

      .resize-handle {
        position: absolute;
        width: 10px;
        height: 10px;
        background-color: white;
        border: 1px solid black;
        z-index: 1;
      }

      #left-top {
        top: -5px;
        left: -5px;
        cursor: nw-resize; /* 左上の四つ角のリサイズカーソル */
      }

      #right-top {
        top: -5px;
        right: -5px;
        cursor: ne-resize; /* 右上の四つ角のリサイズカーソル */
      }

      #right-bottom {
        bottom: -5px;
        right: -5px;
        cursor: se-resize; /* 右下の四つ角のリサイズカーソル */
      }

      #left-bottom {
        bottom: -5px;
        left: -5px;
        cursor: sw-resize; /* 左下の四つ角のリサイズカーソル */
      }
    </style>
    <title>Rectangular Selection</title>
  </head>
  <body>
    <div id="container"></div>
  </body>
</html>
    

ソースコードを保存して、VS CodeVisual Studio Code)の拡張機能の「Live Server」を起動すると、

ブラウザでHTMLが表示されるので、

⇧ マウスの左クリックを押し続けた状態でマウスを移動して、適当なところで左クリックを離すと、矩形選択(透明な青い色の範囲)できます。

矩形選択の四つ角にマウスを合わせて左クリックを押し続けた状態でマウスを移動して、適当なところで左クリックを離すと、矩形選択の範囲を拡大・縮小できます。

矩形選択の範囲(四つ角以外)上で左クリックを押し続けた状態でマウスを移動することで、矩形選択を移動できます。

矩形選択の範囲内に存在するDOMは、選択されていることが分かるように青い色に変わり、DOMの情報を取得し、グローバル変数の配列に格納してます。

矩形選択の範囲内でダブルクリックすると、矩形選択をリセットできます。

矩形選択は1つだけしか作れない仕様になってる感じかな。

ただ、JavaScript のイベントの遷移の順番など理解できていなかったので、結構、ソースコードがカオスな状況になってます。

 

メジャーどころのJavaScriptのイベントについては、

ict-skillup.com

⇧ 上記サイト様が種類をまとめてくださっています。

自分で独自のイベントを定義したりもできますが、

developer.mozilla.org

⇧ イベントが発生した時に、行いたい処理を関数として登録しておくことで、イベントに絡めて処理をさせることができますと。

Service Workerなんかでも、

developer.mozilla.org

⇧ addEventListenerを使って、イベントに絡めて処理をさせてたりしますな。

イベントが依存し合ってることもあり、

www.javascripttutorial.net

■clickイベントのフロー

■dbclickイベントのフロー

⇧ 上図のようなイベントの数珠つなぎになっていたりすることもあるので、このあたりのイベントの遷移の順番とかを考慮したりする必要があるってことですな。

dbclickのイベントに至るまでに、

  • mousedown
  • mouseup
  • click

のイベントの実行を2セット行っていることから、上記のイベント群に絡めた処理と、dbclickイベントに絡めた処理がバッティングしないかなどを検討する必要があると。

まぁ、通常のWeb系のシステムで、dbclickイベントを利用するケースって滅多にないと思うので(独断と偏見の塊ですが...)、clickイベントに気を付ければ良いとは思いますが。

何が言いたいかと言うと、clickイベント、dbclickイベント、どちらのイベントでも何某かの処理を実施するようにしている場合、イベントの遷移の順番に気を付けて処理を考える必要があるってことですな。

このあたり、

qiita.com

dk521123.hatenablog.com

⇧ かなり前から、問題になってはいたようで、

qiita.com

⇧ 今時の、JavaScriptフレームワーク(React、Vue、Angularなど)でも、clickイベント時に少し待つ、という解に落ち着いている感じ、と言うか、他に方法が無さそうってことですかね。

clickイベントに、dbclickイベントかどうかを判定できる情報を持たせてくれていなんかな?と思って、ドキュメントを確認してみたところ、

developer.mozilla.org

⇧ とあり、clickイベントとdbclickイベントを判定できるプロパティが用意されてるらしい...

つまり、clickイベントの発生時に渡されるPointerEventオブジェクトの中のdetailプロパティの値で、

  • 1回目のclickイベント
    → PointerEventオブジェクトのdetailプロパティの値は、1
  • 2回目のclickイベント(dbclickイベントに遷移する)
    → PointerEventオブジェクトのdetailプロパティの値は、2

のどちらなのかを判断できるってことらしい。

何やら、

stackoverflow.com

⇧ 2011年頃の質問で、detailプロパティに言及して回答が、2018年頃ってことは、それまでは、detailプロパティが存在しなかったんかな?

detailプロパティに欠陥が無いのであれば、detailプロパティの値で、clickイベント、dbclickイベントの区別の判定を済ませたいなぁ...

 

話が脱線しましたが、今時の、JavaScriptフレームワーク(React、Vue、Angularなど)が使えていないですが、一旦、素のJavaScriptでザックリしたPoC(Proof of Concept)的なことはできましたと。

実際には、サーバーサイド側から情報を取得してくる必要はあると思いますが、必要な情報を考えたり、サーバーサイド側の実装まで行う余力が無かったので、そのあたりは割愛。

何となく、地理空間情報が絡むようなサービスなんかで利用できるんかな?

今回も、ChatGPTの間違った情報に振り回されて、徒に不毛な時間を味わうことになりましたと...

何だろうな、AIで生産性が上がっていると言えるのか、甚だ疑問だ...

IT系のシステム作る側のお気持ちからすると、AIに対する不信感が半端ないということは揺るぎない。これまでの仕打ちから察するに...

とは言え、他に選択肢が無いのも事実なので、覚悟してAIとともに地獄を突き進む他止む方なしとはいえる...

お気持ち的に、

アスタ・ラ・ビスタ、ベイビー」(Hasta la vista, baby)は、1991年SFアクション映画ターミネーター2』でアーノルド・シュワルツェネッガーが演じるタイトルキャラクターに関連するキャッチフレーズである。

アスタ・ラ・ビスタ、ベイビー - Wikipedia

日本語字幕および吹き替えでは「地獄で会おうぜ、ベイビー」(戸田奈津子による字幕)または「さっさと失せろ、ベイビー」と意訳された

アスタ・ラ・ビスタ、ベイビー - Wikipedia

⇧ の台詞を呟きたくなりますな...

毎度モヤモヤ感が半端ない...

今回はこのへんで。