diff --git a/Features/README.md b/Features/README.md index a7ccb97..3cdbfaa 100644 --- a/Features/README.md +++ b/Features/README.md @@ -12,3 +12,5 @@ In diesem Ordner werden die Anforderungen für neue Features dokumentiert. ## Enthaltene Dateien - [`feature-vorlage.md`](./feature-vorlage.md) – Vorlage für neue Feature-Anforderungen. +- [`signale-erstellen-und-gleiskante-zuordnen.md`](./signale-erstellen-und-gleiskante-zuordnen.md) – Signale anlegen und einer Gleiskante zuordnen. +- [`lustige-giraffe-animation.md`](./lustige-giraffe-animation.md) – Lustige Giraffe zeichnet neue Elemente animiert. diff --git a/Features/lustige-giraffe-animation.md b/Features/lustige-giraffe-animation.md new file mode 100644 index 0000000..1b8477a --- /dev/null +++ b/Features/lustige-giraffe-animation.md @@ -0,0 +1,83 @@ +# Feature: Lustige Giraffe zeichnet neue Elemente + +## Übersicht + +- **Status:** Abgeschlossen +- **Autor:** Claude Code +- **Erstellt am:** 2026-07-09 +- **Letzte Änderung:** 2026-07-09 + +## Ziel / Problemstellung + +Das Anlegen neuer Elemente (Knoten, Kanten, Signale) soll spielerischer und +sichtbarer werden. Statt dass ein Element schlagartig erscheint, taucht eine +kleine, lustige Cartoon-Giraffe auf und „zeichnet" das neue Element vor den +Augen der Nutzerin animiert in die Fläche. Das gibt visuelles Feedback darüber, +was gerade entstanden ist, und macht den Editor sympathischer. + +## Anforderungen + +### Funktionale Anforderungen + +- [x] Beim Hinzufügen eines **Knotens** erscheint die Giraffe und zeichnet den + Knoten (wachsender Kreis) an der Klickposition. +- [x] Beim Hinzufügen einer **Kante** wird die Linie vom Start- zum Zielknoten + animiert gezogen. +- [x] Beim Hinzufügen eines **Signals** wird der Signalarm von der Gleiskante + bis zum Marker animiert gezeichnet. +- [x] Die Giraffe steht neben der „Stiftspitze" und wackelt leicht mit dem Kopf, + eine gestrichelte Linie verbindet ihre Schnauze mit der wachsenden Spitze. +- [x] Das Element wird erst nach Abschluss der Animation ins Modell übernommen, + sodass es tatsächlich gezeichnet erscheint statt sofort dazustehen. +- [x] Wird während einer laufenden Animation eine weitere Aktion ausgelöst + (schnelles Hinzufügen, Werkzeugwechsel, „Leeren", „Beispielnetz"), wird + die aktuelle Animation sauber abgeschlossen; kein Element geht verloren. + +### Nicht-funktionale Anforderungen + +- [x] Die Animation lebt vollständig im `view`-Paket; `model` und `geometry` + bleiben JavaFX-frei und testbar. +- [x] Die reine Zeit-/Interpolationsmathematik (`Easing.smoothStep`, + `Vec2.lerp`) liegt im `geometry`-Paket und ist per Unit-Test abgedeckt. +- [x] Architektur-Guardrails eingehalten (max. 3 Parameter, max. 500 LOC/Klasse); + `mvn verify` (Checkstyle, PMD, SpotBugs, JaCoCo) läuft grün. + +## Nutzer / Zielgruppe + +Alle, die den Gleisnetz-Editor bedienen und neue Netzelemente anlegen. + +## Ein- und Ausgaben + +- **Eingaben:** Mausklicks der Werkzeuge Knoten / Kante / Signal. +- **Ausgaben:** Kurze Zeichen-Animation (~1,1 s) auf dem Canvas; danach ist das + neue Element Teil des Netzes. + +## Akzeptanzkriterien + +- [x] Nach dem Anlegen jedes Elementtyps ist für kurze Zeit eine Giraffe zu + sehen, die das Element zeichnet, danach ist das Element vorhanden. +- [x] Die Domänenlogik bleibt ohne JavaFX-Abhängigkeit; alle Tests grün. +- [x] `mvn verify` ist erfolgreich. + +## Umsetzung (Kurzüberblick) + +- `geometry/Easing.java` – `smoothStep` für sanftes Ein-/Ausblenden der Bewegung. +- `geometry/Vec2.java` – `lerp` für das Wachsen entlang einer Strecke. +- `view/DrawTarget.java` – beschreibt Geometrie des neuen Elements (Kind + Start/Ende). +- `view/Giraffe.java` – zeichnet die Cartoon-Giraffe (Körper, Flecken, Hals, Kopf). +- `view/GiraffeAnimator.java` – `AnimationTimer`, der pro Frame das Netz neu + zeichnet, das wachsende Element und die Giraffe darüber legt und das Element am + Ende via Callback ins Modell übernimmt. +- `view/NetworkEditorController.java` – löst die Animation beim Anlegen von + Knoten/Kante/Signal aus. + +## Offene Fragen / Annahmen + +- Annahme: Eine feste Animationsdauer (~1,1 s) ist angenehm; bei Bedarf leicht + über `GiraffeAnimator.DURATION_NANOS` anpassbar. +- Die Animation ist rein kosmetisch und wird von JaCoCo (wie `view`/`App`) nicht + auf Coverage geprüft; die zugrunde liegende Mathematik ist getestet. + +## Referenzen + +- [`AGENTS.md`](../AGENTS.md) – Architektur, Guardrails, Definition of Done. diff --git a/src/main/java/com/example/vibeapp/geometry/Easing.java b/src/main/java/com/example/vibeapp/geometry/Easing.java new file mode 100644 index 0000000..3a09f06 --- /dev/null +++ b/src/main/java/com/example/vibeapp/geometry/Easing.java @@ -0,0 +1,22 @@ +package com.example.vibeapp.geometry; + +/** + * Pure easing helpers for time-based animations. UI-free (like the rest of this + * package) so the timing math stays unit-testable without the JavaFX toolkit. + */ +public final class Easing { + + private Easing() { + } + + /** + * Smooth ease-in/ease-out of a normalized progress value. The input is + * clamped to [0,1] and the classic smoothstep curve {@code 3t²-2t³} is + * applied: it returns 0 at 0, 1 at 1, is symmetric around 0.5 and has zero + * slope at both ends, so an animation accelerates and decelerates gently. + */ + public static double smoothStep(double t) { + double c = Math.max(0.0, Math.min(1.0, t)); + return c * c * (3.0 - 2.0 * c); + } +} diff --git a/src/main/java/com/example/vibeapp/geometry/Vec2.java b/src/main/java/com/example/vibeapp/geometry/Vec2.java index 5940004..c271ac2 100644 --- a/src/main/java/com/example/vibeapp/geometry/Vec2.java +++ b/src/main/java/com/example/vibeapp/geometry/Vec2.java @@ -13,4 +13,13 @@ public Vec2 subtract(Vec2 other) { public double length() { return Math.hypot(x, y); } + + /** + * Point fraction {@code t} of the way from this vector toward {@code target} + * ({@code t} = 0 returns this point, {@code t} = 1 returns {@code target}). + * Used to animate an element growing along a straight path. + */ + public Vec2 lerp(Vec2 target, double t) { + return new Vec2(x + (target.x - x) * t, y + (target.y - y) * t); + } } diff --git a/src/main/java/com/example/vibeapp/view/DrawTarget.java b/src/main/java/com/example/vibeapp/view/DrawTarget.java new file mode 100644 index 0000000..6035959 --- /dev/null +++ b/src/main/java/com/example/vibeapp/view/DrawTarget.java @@ -0,0 +1,26 @@ +package com.example.vibeapp.view; + +import java.util.Objects; + +import com.example.vibeapp.geometry.Vec2; + +/** + * Geometry of a freshly created element, handed to the {@link GiraffeAnimator} + * so the giraffe can trace it into existence. + * + *
{@code start} and {@code end} bound the stroke that gets drawn: a + * {@link Kind#NODE} uses the same point for both, a {@link Kind#EDGE} spans its + * two nodes, and a {@link Kind#SIGNAL} runs from its base on the edge to its + * offset marker. + */ +record DrawTarget(Kind kind, Vec2 start, Vec2 end) { + + DrawTarget { + Objects.requireNonNull(kind, "kind must not be null"); + Objects.requireNonNull(start, "start must not be null"); + Objects.requireNonNull(end, "end must not be null"); + } + + /** The kind of element being drawn, which selects how it is animated. */ + enum Kind { NODE, EDGE, SIGNAL } +} diff --git a/src/main/java/com/example/vibeapp/view/Giraffe.java b/src/main/java/com/example/vibeapp/view/Giraffe.java new file mode 100644 index 0000000..d28421f --- /dev/null +++ b/src/main/java/com/example/vibeapp/view/Giraffe.java @@ -0,0 +1,116 @@ +package com.example.vibeapp.view; + +import java.util.Objects; + +import com.example.vibeapp.geometry.Vec2; + +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; + +/** + * Draws a small cartoon giraffe on a canvas — the friendly mascot that appears + * and "draws" newly added elements. Pure rendering: it holds a graphics context + * and paints on request; a bob {@code phase} gives its head a bit of life. The + * giraffe faces right, toward the element it is drawing. + */ +final class Giraffe { + + private static final Color COAT = Color.web("#f2c14e"); + private static final Color COAT_DARK = Color.web("#e0a92e"); + private static final Color SPOT = Color.web("#a86a2c"); + private static final Color HOOF = Color.web("#5b4327"); + private static final Color MANE = Color.web("#b5651d"); + private static final Color EYE = Color.web("#2b2118"); + + private final GraphicsContext gc; + + Giraffe(GraphicsContext gc) { + this.gc = Objects.requireNonNull(gc, "gc must not be null"); + } + + /** + * Draws the giraffe standing on {@code feet} (its bottom-centre point), with + * {@code phase} (radians) driving a gentle head bob so it looks alive while + * it is drawing. The snout ends up near {@code feet} + (23, -48). + */ + void draw(Vec2 feet, double phase) { + double bob = Math.sin(phase) * 2.0; + double bodyCy = feet.y() - 26.0; + drawLegs(feet, bodyCy); + drawTail(feet.x() - 12.0, bodyCy); + drawBody(feet.x(), bodyCy); + drawNeckAndHead(feet.x(), bodyCy, bob); + } + + private void drawLegs(Vec2 feet, double bodyCy) { + double top = bodyCy + 6.0; + double footY = feet.y(); + double[] xs = {feet.x() - 8.0, feet.x() - 3.0, feet.x() + 4.0, feet.x() + 9.0}; + gc.setLineWidth(3.5); + gc.setStroke(COAT); + for (double x : xs) { + gc.strokeLine(x, top, x, footY); + } + gc.setStroke(HOOF); + for (double x : xs) { + gc.strokeLine(x, footY - 2.5, x, footY); + } + } + + private void drawBody(double cx, double cy) { + gc.setFill(COAT); + gc.fillOval(cx - 13.0, cy - 8.0, 26.0, 16.0); + gc.setFill(SPOT); + gc.fillOval(cx - 8.0, cy - 4.0, 4.0, 4.0); + gc.fillOval(cx - 1.0, cy - 1.0, 5.0, 5.0); + gc.fillOval(cx + 5.0, cy - 5.0, 4.0, 4.0); + gc.fillOval(cx + 3.0, cy + 2.0, 3.5, 3.5); + } + + private void drawTail(double x, double cy) { + gc.setStroke(COAT_DARK); + gc.setLineWidth(1.5); + gc.strokeLine(x, cy, x - 5.0, cy + 8.0); + gc.setFill(MANE); + gc.fillOval(x - 7.0, cy + 7.0, 3.5, 4.5); + } + + private void drawNeckAndHead(double cx, double cy, double bob) { + double neckBaseX = cx + 7.0; + double neckBaseY = cy - 6.0; + double headX = cx + 15.0 + bob; + double headY = cy - 24.0 + bob * 0.5; + gc.setStroke(COAT); + gc.setLineWidth(6.0); + gc.strokeLine(neckBaseX, neckBaseY, headX, headY + 4.0); + gc.setStroke(MANE); + gc.setLineWidth(2.0); + gc.strokeLine(neckBaseX - 2.0, neckBaseY, headX - 2.0, headY + 4.0); + drawHead(headX, headY); + } + + private void drawHead(double x, double y) { + // Ossicones (the little horns) with knobbed tips. + gc.setStroke(COAT_DARK); + gc.setLineWidth(1.5); + gc.strokeLine(x - 2.0, y - 2.0, x - 3.0, y - 7.0); + gc.strokeLine(x + 3.0, y - 2.0, x + 3.0, y - 7.0); + gc.setFill(MANE); + gc.fillOval(x - 4.5, y - 9.0, 3.0, 3.0); + gc.fillOval(x + 1.5, y - 9.0, 3.0, 3.0); + // Head and snout (facing right). + gc.setFill(COAT); + gc.fillOval(x - 5.0, y - 3.0, 10.0, 8.0); + gc.fillOval(x + 2.0, y - 1.0, 8.0, 6.0); + // Ear on the far side. + gc.setFill(COAT_DARK); + gc.fillOval(x - 7.0, y - 3.0, 4.0, 2.5); + // Eye, nostril and a friendly smile. + gc.setFill(EYE); + gc.fillOval(x - 1.0, y - 1.0, 2.0, 2.0); + gc.fillOval(x + 8.0, y + 1.5, 1.3, 1.3); + gc.setStroke(EYE); + gc.setLineWidth(0.8); + gc.strokeLine(x + 6.0, y + 3.5, x + 9.0, y + 3.0); + } +} diff --git a/src/main/java/com/example/vibeapp/view/GiraffeAnimator.java b/src/main/java/com/example/vibeapp/view/GiraffeAnimator.java new file mode 100644 index 0000000..e2f723a --- /dev/null +++ b/src/main/java/com/example/vibeapp/view/GiraffeAnimator.java @@ -0,0 +1,176 @@ +package com.example.vibeapp.view; + +import java.util.Objects; + +import com.example.vibeapp.geometry.Easing; +import com.example.vibeapp.geometry.Vec2; + +import javafx.animation.AnimationTimer; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; + +/** + * Plays a short, playful animation whenever a new element is created: a cartoon + * {@link Giraffe} appears and traces the element into existence, and only once + * the stroke is complete is the element committed to the model. + * + *
View-only and owns no domain state. Each frame it lets the caller repaint
+ * the network beneath ({@code baseRedraw}) — which does not yet contain the new
+ * element — then draws the growing preview and the giraffe on top. The commit
+ * runs from {@link #complete()}, so an interrupted animation never loses its
+ * element (see {@link #finishNow()}).
+ */
+final class GiraffeAnimator {
+
+ private static final double DURATION_NANOS = 1_100_000_000.0;
+ private static final double BOB_NANOS = 90_000_000.0;
+ private static final double FADE = 0.15;
+ private static final Color TRACE = Color.web("#f9e2af");
+
+ private final GraphicsContext gc;
+ private final Runnable baseRedraw;
+ private final Giraffe giraffe;
+ private final AnimationTimer timer;
+
+ private DrawTarget target;
+ private Runnable onComplete;
+ private long startNanos = -1L;
+
+ GiraffeAnimator(GraphicsContext gc, Runnable baseRedraw) {
+ this.gc = Objects.requireNonNull(gc, "gc must not be null");
+ this.baseRedraw = Objects.requireNonNull(baseRedraw, "baseRedraw must not be null");
+ this.giraffe = new Giraffe(gc);
+ this.timer = new AnimationTimer() {
+ @Override
+ public void handle(long now) {
+ frame(now);
+ }
+ };
+ }
+
+ /**
+ * Animates the drawing of {@code target}; once the giraffe finishes,
+ * {@code onComplete} runs to commit the element. If another animation is
+ * still running it is finished first so its element is not lost.
+ */
+ void play(DrawTarget target, Runnable onComplete) {
+ Objects.requireNonNull(target, "target must not be null");
+ Objects.requireNonNull(onComplete, "onComplete must not be null");
+ finishNow();
+ this.target = target;
+ this.onComplete = onComplete;
+ this.startNanos = -1L;
+ timer.start();
+ }
+
+ /** If an animation is in progress, end it immediately and commit its element. */
+ void finishNow() {
+ if (target != null) {
+ complete();
+ }
+ }
+
+ private void frame(long now) {
+ if (startNanos < 0L) {
+ startNanos = now;
+ }
+ double elapsed = now - startNanos;
+ double t = Easing.smoothStep(elapsed / DURATION_NANOS);
+ baseRedraw.run();
+ Vec2 tip = drawGrowingElement(t);
+ drawGiraffe(tip, t, elapsed);
+ if (elapsed >= DURATION_NANOS) {
+ complete();
+ }
+ }
+
+ private void complete() {
+ timer.stop();
+ Runnable commit = onComplete;
+ target = null;
+ onComplete = null;
+ startNanos = -1L;
+ if (commit != null) {
+ commit.run();
+ }
+ }
+
+ /** Draws the element grown to progress {@code t} and returns the pen tip. */
+ private Vec2 drawGrowingElement(double t) {
+ return switch (target.kind()) {
+ case NODE -> drawGrowingNode(t);
+ case EDGE -> drawGrowingEdge(t);
+ case SIGNAL -> drawGrowingSignal(t);
+ };
+ }
+
+ private Vec2 drawGrowingNode(double t) {
+ Vec2 c = target.start();
+ double r = NetworkRenderer.NODE_RADIUS * t;
+ gc.setFill(NetworkRenderer.NODE);
+ gc.fillOval(c.x() - r, c.y() - r, 2 * r, 2 * r);
+ double ring = NetworkRenderer.NODE_RADIUS + 5.0 * (1.0 - t);
+ gc.setStroke(TRACE);
+ gc.setLineWidth(2.0);
+ gc.strokeOval(c.x() - ring, c.y() - ring, 2 * ring, 2 * ring);
+ return c;
+ }
+
+ private Vec2 drawGrowingEdge(double t) {
+ Vec2 a = target.start();
+ Vec2 tip = a.lerp(target.end(), t);
+ gc.setStroke(NetworkRenderer.EDGE);
+ gc.setLineWidth(2.5);
+ gc.strokeLine(a.x(), a.y(), tip.x(), tip.y());
+ return tip;
+ }
+
+ private Vec2 drawGrowingSignal(double t) {
+ Vec2 base = target.start();
+ Vec2 tip = base.lerp(target.end(), t);
+ gc.setStroke(NetworkRenderer.SIGNAL);
+ gc.setLineWidth(1.0);
+ gc.strokeLine(base.x(), base.y(), tip.x(), tip.y());
+ double r = NetworkRenderer.SIGNAL_RADIUS * t;
+ gc.setFill(NetworkRenderer.SIGNAL);
+ gc.fillOval(tip.x() - r, tip.y() - r, 2 * r, 2 * r);
+ return tip;
+ }
+
+ private void drawGiraffe(Vec2 tip, double t, double elapsed) {
+ Vec2 feet = giraffeFeet(tip);
+ gc.save();
+ gc.setGlobalAlpha(fade(t));
+ drawPenGuide(feet, tip);
+ giraffe.draw(feet, elapsed / BOB_NANOS);
+ gc.restore();
+ }
+
+ /** Dashed "pen" line from the giraffe's snout to the growing tip, plus a sparkle. */
+ private void drawPenGuide(Vec2 feet, Vec2 tip) {
+ Vec2 snout = new Vec2(feet.x() + 23.0, feet.y() - 48.0);
+ gc.setStroke(TRACE);
+ gc.setLineWidth(1.0);
+ gc.setLineDashes(3.0, 3.0);
+ gc.strokeLine(snout.x(), snout.y(), tip.x(), tip.y());
+ gc.setLineDashes(null);
+ gc.setFill(TRACE);
+ gc.fillOval(tip.x() - 2.0, tip.y() - 2.0, 4.0, 4.0);
+ }
+
+ /** Stands the giraffe just left of and below the tip, kept inside the canvas. */
+ private Vec2 giraffeFeet(Vec2 tip) {
+ double w = gc.getCanvas().getWidth();
+ double h = gc.getCanvas().getHeight();
+ double x = Math.max(22.0, Math.min(w - 16.0, tip.x() - 30.0));
+ double y = Math.max(52.0, Math.min(h - 6.0, tip.y() + 42.0));
+ return new Vec2(x, y);
+ }
+
+ /** Fades the giraffe in over the first slice of the animation and out over the last. */
+ private static double fade(double t) {
+ double in = Math.min(1.0, t / FADE);
+ double out = Math.min(1.0, (1.0 - t) / FADE);
+ return Math.max(0.0, Math.min(in, out));
+ }
+}
diff --git a/src/main/java/com/example/vibeapp/view/NetworkEditorController.java b/src/main/java/com/example/vibeapp/view/NetworkEditorController.java
index c135a9c..7fbc567 100644
--- a/src/main/java/com/example/vibeapp/view/NetworkEditorController.java
+++ b/src/main/java/com/example/vibeapp/view/NetworkEditorController.java
@@ -50,6 +50,7 @@ private enum Tool { SELECT, NODE, EDGE, SIGNAL }
private TrackNetwork network = SampleNetworks.switzerland();
private NetworkRenderer renderer;
+ private GiraffeAnimator animator;
private Tool tool = Tool.SELECT;
private TrackNode draggedNode;
@@ -59,6 +60,14 @@ private enum Tool { SELECT, NODE, EDGE, SIGNAL }
@FXML
private void initialize() {
renderer = new NetworkRenderer(canvas.getGraphicsContext2D());
+ animator = new GiraffeAnimator(canvas.getGraphicsContext2D(), this::redraw);
+ // Stop a running animation timer once the canvas leaves the scene, so it
+ // does not keep firing in the background after the window is closed.
+ canvas.sceneProperty().addListener((obs, oldScene, newScene) -> {
+ if (newScene == null) {
+ animator.finishNow();
+ }
+ });
// A Canvas is not resizable, so as a managed child it would pin the
// holder's minimum size and stop the window from shrinking. Take it out
@@ -99,6 +108,8 @@ private void onToolChanged(Toggle old, Toggle now) {
toolGroup.selectToggle(old);
return;
}
+ // Commit any element still being drawn before switching context.
+ animator.finishNow();
tool = (Tool) now.getUserData();
pendingEdgeNode = null;
highlightedNodeId = null;
@@ -126,6 +137,9 @@ private void onMousePressed(MouseEvent event) {
if (event.getButton() != MouseButton.PRIMARY) {
return;
}
+ // Commit any element still being drawn before hit-testing, so a fast
+ // second click sees the pending element and cannot create a duplicate.
+ animator.finishNow();
double x = event.getX();
double y = event.getY();
switch (tool) {
@@ -152,7 +166,9 @@ private void addNodeOnEmptySpace(double x, double y) {
// Per the editing model, the NODE tool only adds nodes on empty space —
// clicking an existing node must not stack an overlapping duplicate.
if (findNodeAt(x, y).isEmpty()) {
- network.addNode(x, y);
+ Vec2 p = new Vec2(x, y);
+ animateDraw(new DrawTarget(DrawTarget.Kind.NODE, p, p),
+ () -> network.addNode(x, y));
}
}
@@ -166,8 +182,13 @@ private void handleEdgeClick(double x, double y) {
if (pendingEdgeNode == null) {
pendingEdgeNode = node;
} else if (!pendingEdgeNode.id().equals(node.id())) {
- network.addEdge(pendingEdgeNode.id(), node.id());
+ Vec2 a = new Vec2(pendingEdgeNode.x(), pendingEdgeNode.y());
+ Vec2 b = new Vec2(node.x(), node.y());
+ String fromId = pendingEdgeNode.id();
+ String toId = node.id();
pendingEdgeNode = null;
+ animateDraw(new DrawTarget(DrawTarget.Kind.EDGE, a, b),
+ () -> network.addEdge(fromId, toId));
}
}
@@ -194,10 +215,32 @@ private void handleSignalClick(double x, double y) {
}
if (closestEdge != null) {
- network.addSignal(closestEdge.id(), closestParam, selectedSide());
+ animateSignal(closestEdge, closestParam);
}
}
+ private void animateSignal(TrackEdge edge, double param) {
+ TrackNode from = network.node(edge.fromNodeId()).orElseThrow();
+ TrackNode to = network.node(edge.toNodeId()).orElseThrow();
+ Vec2 a = new Vec2(from.x(), from.y());
+ Vec2 b = new Vec2(to.x(), to.y());
+ Side side = selectedSide();
+ Vec2 base = Geometry.pointAt(a, b, param);
+ Vec2 marker = Geometry.offsetPoint(a, b, param, NetworkRenderer.SIGNAL_OFFSET, side);
+ String edgeId = edge.id();
+ animateDraw(new DrawTarget(DrawTarget.Kind.SIGNAL, base, marker),
+ () -> network.addSignal(edgeId, param, side));
+ }
+
+ /** Plays the giraffe draw animation, then commits the new element to the model. */
+ private void animateDraw(DrawTarget target, Runnable commit) {
+ animator.play(target, () -> {
+ commit.run();
+ updateStatus();
+ redraw();
+ });
+ }
+
private Optional