DEV Community

Building a ChemDraw clone with DDD (Part III): Interacting with Atoms and Bonds

For a chemical draw to be truly functional, drawing isn't enough; the user must be able to interact with every element on the screen. Selecting an atom is intuitive, but how do you select a chemical bond (a line segment) with pixel-perfect precision? In this post, I'll break down the mathematical process and the challenges of implementing this collision engine.

Selecting Atoms: The Easy Path

Detecting if the cursor is over an atom is the ideal scenario. Since our domain already stores the (x, y) coordinates of each node, the solution is applying Euclidean distance.

We calculate the separation between the mouse (P) and each atom (A) with the formula:

d=(x2x1)2+(y2y1)2d = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2}
.

If the distance is shorter than a defined search radius, the atom is considered a candidate. To ensure precision, if multiple atoms are within range, we always select the one with the minimum distance.

class Molecule {
  // ...
  private findClosestAtom(
    x: number,
    y: number,
    radius: number,
  ): { atom: Atom; distance: number } | null {
    const candidates = [...this._atoms.values()]
      .map((atom) => ({
        atom,
        distance: Math.sqrt(Math.pow(atom.x - x, 2) + Math.pow(atom.y - y, 2)),
      }))
      .filter(({ distance }) => distance <= radius)
      .sort((a, b) => a.distance - b.distance);

    return candidates[0] || null;
  }
  // ...
Enter fullscreen mode Exit fullscreen mode

Selecting Bonds: The "Infinite Line" Problem

With bonds, things get complicated. A bond is a line segment, not a single point. My first instinct was to use the classic equation for the distance from a point to a line:

d=Ax+By+CA2+B2d = \frac{|Ax + By + C|}{\sqrt{A^2 + B^2}}

The problem: This formula assumes the line is infinite. If you click miles away but happen to be perfectly aligned with the bond's trajectory, the formula will trigger a "hit". In a molecule with hundreds of bonds, this creates "ghost collisions" across the entire canvas.

The Solution: Projection and Clamping

To solve this, we need to find the closest point specifically within the segment bounded by atoms A and B. Here's where the vector math comes in.

The algorithm projects the mouse coordinates onto the line and calculates the "projection factor" t.

  • If t = 0, the closest point is atom A.
  • If t = 1, the closest point is atom B.
  • If t is between 0 and 1, the closest point lies somewhere along the bond.

Diagram

Key here is clamping: forcing t to stay between 0 and 1 using Math.max(0, Math.min(1, t)). This effectively "encloses" the collision logic within the physical boundaries of the bond.

private pointToSegmentDistance(
  px: number, py: number, // Mouse point
  ax: number, ay: number, // Atom A (Start)
  bx: number, by: number  // Atom B (End)
): number {
  const abx = bx - ax;
  const aby = by - ay;
  const apx = px - ax;
  const apy = py - ay;

  const abLengthSq = abx * abx + aby * aby;

  // If atoms overlap, return distance to point A
  if (abLengthSq === 0) return Math.sqrt(apx * apx + apy * apy);

  // Scalar projection restricted to the [0, 1] range (Clamping)
  const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLengthSq));

  // Find the coordinates of the closest point on the segment
  const closestX = ax + t * abx;
  const closestY = ay + t * aby;

  // Final distance between mouse and the closest point on the segment
  return Math.sqrt(Math.pow(px - closestX, 2) + Math.pow(py - closestY, 2));
}
Enter fullscreen mode Exit fullscreen mode

Atoms vs Bonds

A common UX challenge in chemical editors is overlap. What happens if the mouse is near a bond but also directly over an atom?

In chemistry, the atom is the primary unit of interaction. Therefore, I implemented a simple but effective business rule: Atoms always have priority. If the engine detects a collision with both an atom and a bond, the atom "hijacks" the selection event. This prevents users from accidentally deleting a bond when they intended to edit an element.

Conclusion: The "God Object" Warning

While this collision engine works perfectly, it has exposed a growing architectural debt. My Molecule class—which should only care about chemical valences and atoms—is now bloated with 2D geometry formulas and pixel coordinates.

I’ve created a God Object.

In the next post, I’ll perform "major surgery" on this architecture. We will split the system into two Bounded Contexts: a Chemistry context for the logic and a Spatial context for the geometry.

Top comments (0)