Robuust Snappingsysteem volgens SOLID Principes

Geschreven door: bert
| Datum: 28 / 04 / 2025

# Introductie

Snapping is een essentiële functionaliteit in grafische en geografische toepassingen, waarbij een punt of cursor automatisch "vastklikt" aan bestaande elementen zoals punten, lijnen of hoeken. Dit verhoogt de precisie en bruikbaarheid van de toepassing aanzienlijk.

In deze blog bespreken we hoe je een robuust snappingsysteem kunt ontwikkelen volgens de SOLID principes, zonder afhankelijkheid van externe bibliotheken. We zullen werken met pure JavaScript en eenvoudige geometrie.

# De SOLID Principes

SOLID is een acroniem voor vijf ontwerpprincipes die helpen bij het bouwen van onderhoudbare, begrijpelijke en flexibele software:

  1. Single Responsibility Principle: Een klasse heeft slechts één verantwoordelijkheid
  2. Open/Closed Principle: Software moet open zijn voor uitbreiding, maar gesloten voor wijziging
  3. Liskov Substitution Principle: Objecten van een afgeleide klasse moeten objecten van hun basisklasse kunnen vervangen
  4. Interface Segregation Principle: Meerdere specifieke interfaces zijn beter dan één algemene interface
  5. Dependency Inversion Principle: Afhankelijkheden moeten op abstracties gebaseerd zijn, niet op concrete implementaties

# Use Case: Een Snappingsysteem voor een Canvas-applicatie

We gaan een snappingsysteem bouwen voor een simpele canvas-gebaseerde tekentoepassing. Gebruikers moeten kunnen snappen aan:

  • Punten
  • Lijnen
  • Rasters
  • Hoeken van objecten

# Een Simpel Geometrisch Model

Voordat we beginnen met het snappingsysteem, definiëren we wat eenvoudige geometrische types:

// Punt in 2D
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  // Bereken afstand tussen twee punten
  distanceTo(point) {
    const dx = this.x - point.x;
    const dy = this.y - point.y;
    return Math.sqrt(dx * dx + dy * dy);
  }
}

// Lijn tussen twee punten
class Line {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  // Dichtstbijzijnde punt op de lijn vanaf een gegeven punt
  closestPointTo(point) {
    // Vector van start naar eind
    const dx = this.end.x - this.start.x;
    const dy = this.end.y - this.start.y;

    // Als start en eind hetzelfde zijn, return start
    if (dx === 0 && dy === 0) return this.start;

    // Projectie van punt op lijn
    const t = ((point.x - this.start.x) * dx + (point.y - this.start.y) * dy) / 
              (dx * dx + dy * dy);

    // Begrens t tussen 0 en 1 voor een lijnsegment
    const clampedT = Math.max(0, Math.min(1, t));

    // Bereken het dichtstbijzijnde punt
    return new Point(
      this.start.x + clampedT * dx,
      this.start.y + clampedT * dy
    );
  }

  // Afstand van punt tot lijn
  distanceTo(point) {
    const closestPoint = this.closestPointTo(point);
    return point.distanceTo(closestPoint);
  }
}

// Rechthoek (voor bounding boxes)
class Rectangle {
  constructor(x, y, width, height) {
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;
  }

  // De vier hoekpunten van de rechthoek
  getCorners() {
    return [
      new Point(this.x, this.y),
      new Point(this.x + this.width, this.y),
      new Point(this.x + this.width, this.y + this.height),
      new Point(this.x, this.y + this.height)
    ];
  }

  // Controleer of een punt binnen de rechthoek ligt
  contains(point) {
    return point.x >= this.x && 
           point.x <= this.x + this.width &&
           point.y >= this.y && 
           point.y <= this.y + this.height;
  }
}

# Het Snappingsysteem volgens SOLID

# 1. Single Responsibility Principle

We verdelen ons systeem in componenten met één duidelijke verantwoordelijkheid:

// SnapResult: Resultaat van een geslaagde snap
class SnapResult {
  constructor(point, sourceObject, distance, type) {
    this.point = point;         // Het punt waar we naar snappen
    this.sourceObject = sourceObject; // Het object waar we aan snappen
    this.distance = distance;   // Afstand tot het originele punt
    this.type = type;           // Type snap (bijv. 'punt', 'lijn', etc.)
  }
}

// SnapSource: Verantwoordelijk voor het leveren van snapbare objecten
class SnapSource {
  constructor() {
    this.enabled = true;
  }

  getSnapObjects() {
    throw new Error("Deze methode moet geïmplementeerd worden door subklassen");
  }

  isEnabled() {
    return this.enabled;
  }

  setEnabled(enabled) {
    this.enabled = enabled;
  }
}

// SnapStrategy: Bepaalt hoe we aan objecten snappen
class SnapStrategy {
  constructor() {
    this.enabled = true;
  }

  findSnapPoint(point, objects, tolerance) {
    throw new Error("Deze methode moet geïmplementeerd worden door subklassen");
  }

  isEnabled() {
    return this.enabled;
  }

  setEnabled(enabled) {
    this.enabled = enabled;
  }
}

// SnapVisualizer: Verantwoordelijk voor visuele feedback
class SnapVisualizer {
  showSnap(snapResult) {
    throw new Error("Deze methode moet geïmplementeerd worden door subklassen");
  }

  clearSnap() {
    throw new Error("Deze methode moet geïmplementeerd worden door subklassen");
  }
}

// SnapManager: Centrale coördinator van het snapsysteem
class SnapManager {
  constructor() {
    this.sources = [];
    this.strategies = [];
    this.visualizer = null;
    this.tolerance = 10;
  }

  // Methoden om componenten toe te voegen
  addSource(source) {
    this.sources.push(source);
  }

  addStrategy(strategy) {
    this.strategies.push(strategy);
  }

  setVisualizer(visualizer) {
    this.visualizer = visualizer;
  }

  // Hoofd snap functionaliteit
  findSnapPoint(point) {
    // Verzamel alle objecten van alle bronnen
    const allObjects = this.collectSnapObjects();

    // Als er geen objecten zijn, direct null teruggeven
    if (allObjects.length === 0) return null;

    // Zoek het beste resultaat over alle strategieën
    let bestResult = null;
    let minDistance = this.tolerance;

    for (const strategy of this.strategies) {
      if (!strategy.isEnabled()) continue;

      const result = strategy.findSnapPoint(point, allObjects, this.tolerance);

      if (result && result.distance < minDistance) {
        minDistance = result.distance;
        bestResult = result;
      }
    }

    // Toon visuele feedback als er een visualizer is
    if (this.visualizer) {
      if (bestResult) {
        this.visualizer.showSnap(bestResult);
      } else {
        this.visualizer.clearSnap();
      }
    }

    return bestResult;
  }

  collectSnapObjects() {
    return this.sources
      .filter(source => source.isEnabled())
      .flatMap(source => source.getSnapObjects());
  }

  setTolerance(tolerance) {
    this.tolerance = tolerance;
  }
}

# 2. Open/Closed Principle

We implementeren concrete strategieën zonder de basisklassen te wijzigen:

// PointCollection: Een simpele verzameling van punten
class PointCollection {
  constructor(points = []) {
    this.points = points;
  }

  addPoint(point) {
    this.points.push(point);
  }

  getPoints() {
    return this.points;
  }
}

// LineCollection: Een simpele verzameling van lijnen
class LineCollection {
  constructor(lines = []) {
    this.lines = lines;
  }

  addLine(line) {
    this.lines.push(line);
  }

  getLines() {
    return this.lines;
  }
}

// PointCollectionSource: Levert punten aan het snapsysteem
class PointCollectionSource extends SnapSource {
  constructor(pointCollection) {
    super();
    this.pointCollection = pointCollection;
  }

  getSnapObjects() {
    if (!this.isEnabled()) return [];
    return this.pointCollection.getPoints().map(point => ({
      type: 'point',
      data: point
    }));
  }
}

// LineCollectionSource: Levert lijnen aan het snapsysteem
class LineCollectionSource extends SnapSource {
  constructor(lineCollection) {
    super();
    this.lineCollection = lineCollection;
  }

  getSnapObjects() {
    if (!this.isEnabled()) return [];
    return this.lineCollection.getLines().map(line => ({
      type: 'line',
      data: line
    }));
  }
}

// PointSnapStrategy: Strategie voor het snappen aan punten
class PointSnapStrategy extends SnapStrategy {
  constructor() {
    super();
  }

  findSnapPoint(point, objects, tolerance) {
    // Filter alleen punten
    const pointObjects = objects.filter(obj => obj.type === 'point');

    // Zoek dichtstbijzijnde punt binnen tolerantie
    let closest = null;
    let minDistance = tolerance;

    for (const obj of pointObjects) {
      const snapPoint = obj.data;
      const distance = new Point(point.x, point.y).distanceTo(snapPoint);

      if (distance < minDistance) {
        minDistance = distance;
        closest = new SnapResult(
          snapPoint,
          obj,
          distance,
          'point'
        );
      }
    }

    return closest;
  }
}

// LineSnapStrategy: Strategie voor het snappen aan lijnen
class LineSnapStrategy extends SnapStrategy {
  constructor() {
    super();
  }

  findSnapPoint(point, objects, tolerance) {
    // Filter alleen lijnen
    const lineObjects = objects.filter(obj => obj.type === 'line');

    // Zoek dichtstbijzijnde punt op een lijn binnen tolerantie
    let closest = null;
    let minDistance = tolerance;

    for (const obj of lineObjects) {
      const line = obj.data;
      const closestPoint = line.closestPointTo(new Point(point.x, point.y));
      const distance = new Point(point.x, point.y).distanceTo(closestPoint);

      if (distance < minDistance) {
        minDistance = distance;
        closest = new SnapResult(
          closestPoint,
          obj,
          distance,
          'line'
        );
      }
    }

    return closest;
  }
}

# 3. Liskov Substitution Principle

We zorgen ervoor dat alle implementaties van bronnen en strategieën volledig functioneel zijn:

// GridSnapSource: Een raster om aan te snappen
class GridSnapSource extends SnapSource {
  constructor(gridSize = 20) {
    super();
    this.gridSize = gridSize;
  }

  getSnapObjects() {
    if (!this.isEnabled()) return [];
    // Hier is geen echte objectenlijst, maar een speciaal object
    return [{
      type: 'grid',
      data: { gridSize: this.gridSize }
    }];
  }
}

// GridSnapStrategy: Strategie voor het snappen aan een raster
class GridSnapStrategy extends SnapStrategy {
  constructor() {
    super();
  }

  findSnapPoint(point, objects, tolerance) {
    // Zoek grid objecten
    const gridObjects = objects.filter(obj => obj.type === 'grid');
    if (gridObjects.length === 0) return null;

    // Neem het eerste grid object (meestal is er maar één)
    const gridSize = gridObjects[0].data.gridSize;

    // Bereken dichtstbijzijnde rasterpunt
    const snapX = Math.round(point.x / gridSize) * gridSize;
    const snapY = Math.round(point.y / gridSize) * gridSize;
    const snapPoint = new Point(snapX, snapY);

    // Controleer binnen tolerantie
    const distance = new Point(point.x, point.y).distanceTo(snapPoint);
    if (distance <= tolerance) {
      return new SnapResult(
        snapPoint,
        gridObjects[0],
        distance,
        'grid'
      );
    }

    return null;
  }
}

# 4. Interface Segregation Principle

We verdelen interfaces in kleinere delen voor specifieke taken:

// Prioritized interface voor strategieën met prioriteit
class PrioritizedSnapStrategy extends SnapStrategy {
  constructor(priority = 0) {
    super();
    this.priority = priority;
  }

  getPriority() {
    return this.priority;
  }

  setPriority(priority) {
    this.priority = priority;
  }
}

// PointSnapStrategy met prioriteit
class PrioritizedPointSnapStrategy extends PrioritizedSnapStrategy {
  constructor(priority = 10) {
    super(priority);
  }

  findSnapPoint(point, objects, tolerance) {
    // Zelfde implementatie als eerder
    // ...
  }
}

// Een verbeterde SnapManager die rekening houdt met prioriteiten
class PrioritySnapManager extends SnapManager {
  constructor() {
    super();
  }

  findSnapPoint(point) {
    const allObjects = this.collectSnapObjects();
    if (allObjects.length === 0) return null;

    // Sorteer strategieën op prioriteit (hoogste eerst)
    const prioritizedStrategies = [...this.strategies]
      .filter(strategy => strategy.isEnabled())
      .sort((a, b) => {
        // Als het prioritized strategieën zijn, gebruik prioriteit
        const aPriority = a instanceof PrioritizedSnapStrategy ? a.getPriority() : 0;
        const bPriority = b instanceof PrioritizedSnapStrategy ? b.getPriority() : 0;
        return bPriority - aPriority;
      });

    // Rest van de logica blijft hetzelfde
    let bestResult = null;
    let minDistance = this.tolerance;

    for (const strategy of prioritizedStrategies) {
      const result = strategy.findSnapPoint(point, allObjects, this.tolerance);

      if (result && result.distance < minDistance) {
        minDistance = result.distance;
        bestResult = result;
      }
    }

    if (this.visualizer) {
      if (bestResult) {
        this.visualizer.showSnap(bestResult);
      } else {
        this.visualizer.clearSnap();
      }
    }

    return bestResult;
  }
}

# 5. Dependency Inversion Principle

We maken onze componenten afhankelijk van abstracties:

// Voor een eenvoudige implementatie van canvasvisualisatie
class CanvasSnapVisualizer extends SnapVisualizer {
  constructor(canvas) {
    super();
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.currentSnap = null;
  }

  showSnap(snapResult) {
    this.currentSnap = snapResult;
    this.redraw();
  }

  clearSnap() {
    this.currentSnap = null;
    this.redraw();
  }

  redraw() {
    // Wis de canvas
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    if (!this.currentSnap) return;

    // Teken een indicatie op basis van het type snap
    const point = this.currentSnap.point;

    this.ctx.save();

    switch (this.currentSnap.type) {
      case 'point':
        // Teken een cirkel voor punt-snapping
        this.ctx.strokeStyle = 'red';
        this.ctx.lineWidth = 2;
        this.ctx.beginPath();
        this.ctx.arc(point.x, point.y, 8, 0, Math.PI * 2);
        this.ctx.stroke();
        break;

      case 'line':
        // Teken een kruis voor lijn-snapping
        this.ctx.strokeStyle = 'blue';
        this.ctx.lineWidth = 2;
        this.ctx.beginPath();
        this.ctx.moveTo(point.x - 6, point.y - 6);
        this.ctx.lineTo(point.x + 6, point.y + 6);
        this.ctx.moveTo(point.x + 6, point.y - 6);
        this.ctx.lineTo(point.x - 6, point.y + 6);
        this.ctx.stroke();
        break;

      case 'grid':
        // Teken een vierkant voor grid-snapping
        this.ctx.strokeStyle = 'green';
        this.ctx.lineWidth = 2;
        this.ctx.beginPath();
        this.ctx.rect(point.x - 6, point.y - 6, 12, 12);
        this.ctx.stroke();
        break;
    }

    this.ctx.restore();
  }
}

# Praktische Implementatie

Laten we nu alles samenbrengen in een volledig werkend voorbeeld:

// Voorbeeld van gebruik in een simpele tekenapp

// Canvas instellen
const canvas = document.getElementById('drawingCanvas');
const ctx = canvas.getContext('2d');

// Maak collecties voor onze objecten
const pointCollection = new PointCollection();
const lineCollection = new LineCollection();

// Voeg wat voorbeelddata toe
pointCollection.addPoint(new Point(100, 100));
pointCollection.addPoint(new Point(200, 200));
pointCollection.addPoint(new Point(300, 150));

lineCollection.addLine(new Line(new Point(50, 50), new Point(250, 50)));
lineCollection.addLine(new Line(new Point(50, 50), new Point(50, 250)));

// Maak bronnen voor het snapsysteem
const pointSource = new PointCollectionSource(pointCollection);
const lineSource = new LineCollectionSource(lineCollection);
const gridSource = new GridSnapSource(20);

// Maak strategieën voor het snapsysteem
const pointStrategy = new PrioritizedPointSnapStrategy(10); // Hoogste prioriteit
const lineStrategy = new PrioritizedSnapStrategy(5);
lineStrategy.findSnapPoint = function(point, objects, tolerance) {
  const lineObjects = objects.filter(obj => obj.type === 'line');
  let closest = null;
  let minDistance = tolerance;

  for (const obj of lineObjects) {
    const line = obj.data;
    const closestPoint = line.closestPointTo(new Point(point.x, point.y));
    const distance = new Point(point.x, point.y).distanceTo(closestPoint);

    if (distance < minDistance) {
      minDistance = distance;
      closest = new SnapResult(
        closestPoint,
        obj,
        distance,
        'line'
      );
    }
  }

  return closest;
};

const gridStrategy = new GridSnapStrategy();
gridStrategy.setPriority = function(priority) { this.priority = priority; };
gridStrategy.getPriority = function() { return this.priority || 0; };
gridStrategy.setPriority(2); // Laagste prioriteit

// Maak een visualizer voor feedback
const visualizer = new CanvasSnapVisualizer(canvas);

// Maak de snap manager
const snapManager = new PrioritySnapManager();

// Voeg componenten toe aan de manager
snapManager.addSource(pointSource);
snapManager.addSource(lineSource);
snapManager.addSource(gridSource);

snapManager.addStrategy(pointStrategy);
snapManager.addStrategy(lineStrategy);
snapManager.addStrategy(gridStrategy);

snapManager.setVisualizer(visualizer);
snapManager.setTolerance(15);

// Teken alle objecten op de canvas
function drawObjects() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Teken raster (licht)
  const gridSize = 20;
  ctx.strokeStyle = '#e0e0e0';
  ctx.lineWidth = 1;

  for (let x = 0; x < canvas.width; x += gridSize) {
    ctx.beginPath();
    ctx.moveTo(x, 0);
    ctx.lineTo(x, canvas.height);
    ctx.stroke();
  }

  for (let y = 0; y < canvas.height; y += gridSize) {
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(canvas.width, y);
    ctx.stroke();
  }

  // Teken punten
  ctx.fillStyle = 'blue';
  for (const point of pointCollection.getPoints()) {
    ctx.beginPath();
    ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
    ctx.fill();
  }

  // Teken lijnen
  ctx.strokeStyle = 'black';
  ctx.lineWidth = 2;
  for (const line of lineCollection.getLines()) {
    ctx.beginPath();
    ctx.moveTo(line.start.x, line.start.y);
    ctx.lineTo(line.end.x, line.end.y);
    ctx.stroke();
  }
}

// Eerste render
drawObjects();

// Luister naar muisbewegingen voor snapping
canvas.addEventListener('mousemove', (event) => {
  const rect = canvas.getBoundingClientRect();
  const mouseX = event.clientX - rect.left;
  const mouseY = event.clientY - rect.top;

  // Vind een snap punt
  const snapResult = snapManager.findSnapPoint(new Point(mouseX, mouseY));

  // Teken alles opnieuw, inclusief snap visualisatie
  drawObjects();
});

// UI Controls
document.getElementById('pointSnap').addEventListener('change', (e) => {
  pointStrategy.setEnabled(e.target.checked);
});

document.getElementById('lineSnap').addEventListener('change', (e) => {
  lineStrategy.setEnabled(e.target.checked);
});

document.getElementById('gridSnap').addEventListener('change', (e) => {
  gridSource.setEnabled(e.target.checked);
});

document.getElementById('tolerance').addEventListener('input', (e) => {
  snapManager.setTolerance(parseInt(e.target.value));
});

# Voordelen van deze SOLID-aanpak

De SOLID-principes hebben ons geholpen bij het ontwikkelen van een robuust snappingsysteem:

  1. Single Responsibility Principle: Elke klasse heeft een duidelijke en specifieke taak, wat leidt tot logischer en beter onderhoudbare code.

  2. Open/Closed Principle: We kunnen nieuwe snappingstrategieën of -bronnen toevoegen zonder bestaande code te hoeven wijzigen.

  3. Liskov Substitution Principle: Alle implementaties van de basisklassen voldoen aan hun interface en kunnen probleemloos worden gebruikt waar de basisklasse wordt verwacht.

  4. Interface Segregation Principle: Door interfaces op te splitsen, kunnen we flexibeler zijn in implementaties en hebben classes alleen toegang tot wat ze echt nodig hebben.

  5. Dependency Inversion Principle: De afhankelijkheid van concrete implementaties is beperkt doordat alle componenten afhankelijk zijn van abstracties.

# Uitbreidbaarheid

De structuur maakt het makkelijk om nieuwe functionaliteit toe te voegen:

  1. Nieuwe soorten objecten: Voeg simpelweg een nieuwe subklasse van SnapSource toe.
  2. Nieuwe snappingmethoden: Maak een nieuwe strategie door SnapStrategy uit te breiden.
  3. Andere visualisatie: Implementeer een nieuwe SnapVisualizer.

# Conclusie

Door SOLID-principes toe te passen, hebben we een modulair, flexibel en onderhoudbaar snappingsysteem ontworpen. De architectuur is niet alleen geschikt voor onze huidige behoeften, maar kan ook gemakkelijk worden uitgebreid en aangepast voor toekomstige vereisten.

Dit systeem kan worden geïmplementeerd in een breed scala van grafische toepassingen, van eenvoudige tekenprogramma's tot complexe CAD-systemen, waarbij het gebruikers helpt om nauwkeurig te werken en de gebruikerservaring aanzienlijk verbetert.

# HTML en Volledige Code Voorbeeld

Voor een werkende demo, kun je onderstaande HTML gebruiken:

<!DOCTYPE html>
<html lang="nl">
<head>
  <meta charset="UTF-8">
  <title>SOLID Snapping Systeem Demo</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      max-width: 800px;
      margin: 0 auto;
      padding: 20px;
    }

    canvas {
      border: 1px solid #ccc;
      margin: 20px 0;
    }

    .controls {
      margin-bottom: 20px;
    }

    label {
      margin-right: 15px;
    }
  </style>
</head>
<body>
  <h1>SOLID Snapping Systeem Demo</h1>

  <div class="controls">
    <label><input type="checkbox" id="pointSnap" checked> Snap aan punten</label>
    <label><input type="checkbox" id="lineSnap" checked> Snap aan lijnen</label>
    <label><input type="checkbox" id="gridSnap" checked> Snap aan raster</label>
    <div>
      <label>Tolerantie: <input type="range" id="tolerance" min="1" max="30" value="15"> <span id="toleranceValue">15</span>px</label>
    </div>
  </div>

  <canvas id="drawingCanvas" width="600" height="400"></canvas>
  <p>Beweeg de muis over het canvas om snapping in actie te zien.</p>

  <script>
    // Hier komt alle JavaScript code die we hierboven hebben ontwikkeld
  </script>
</body>
</html>

Dit voorbeeld laat zien hoe je een robuust snappingsysteem kunt bouwen volgens SOLID principes, met minimale afhankelijkheden en maximale flexibiliteit. Het is een uitstekende basis voor elke toepassing waar precies plaatsen van elementen belangrijk is.

Reacties (0 )

Geen reacties beschikbaar.