Compare commits

..

3 Commits

Author SHA1 Message Date
Mark Tolmacs
125a9910da CHORE: Elbow arrow articles 2025-10-05 22:54:03 +02:00
David Espinoza
98e0cd9078 build: Docker compose version removed (#10074) 2025-10-05 14:48:54 +02:00
Akibur Rahman
f3c16a600d fix: text to diagram translation update issue on language update (#10016) 2025-10-02 16:47:26 +02:00
8 changed files with 245 additions and 20 deletions

View File

@@ -0,0 +1,61 @@
# Building Elbow Arrows in Excalidraw
As you may know, Excalidraw is an online whiteboarding application that stands out from the crowd with its distinctive hand-drawn, sketchy aesthetic. Despite this (or likely for this very reason) it is loved and embraced by professionals in various verticals including IT, data analysis, engineering, sciences and much more. Their work often includes [creating diagrams conveying flows of information or processes](https://plus.excalidraw.com/use-cases/flowchart), where clarity is paramount. One of the tools they use to indicate connection between concepts or states is arrows, but straight arrows on a busy board can get clunky fast. Therefore a new type of diagramming arrow was needed.
## The Case for Elbow Arrows
Enter elbow (or orthogonal) arrows. These arrows follow 90-degree angles, creating clean, professional-looking diagrams that are easy to follow and aesthetically pleasing. Excalidraw users with heavy diagramming workflows already emulated this type of arrow, by painstakingly adding points to simple arrows and dragging them into this 90-degree configuration. Therefore it was clear that implementing an arrow type, which emulates this arrow routing will bring instant value.
<img src="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/lp-cms/media/Process_Flowchart_Example_in_Excalidraw.png" width="300">
<img src="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/lp-cms/media/Data_Flowchart_Example_in_Excalidraw.png" width="300">
<img src="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/lp-cms/media/Decision_Flowchart_Example_in_Excalidraw.png" width="300">
We also quickly realized that it will only be accepted, if it "guesses" correctly how a human would route the arrow. This turned out to be the biggest challenge of all. If the arrows look "weird", nobody will use them. So we had to get it right, no matter what "right" means in this context.
## Design Goals
We knew from previous experience that we'll definitely need to add additional constraints for passing the "as a human would do" mark. We can't just draw _an_ elbow arrow, it has to go the _right way_. While it was clear that this will be an iterative project, we were already able to jot down
1. **Shortest route** - The arrow should take the most direct path from start to end
2. **Minimal segments** - Fewer turns mean cleaner, more readable diagrams
3. **Proper arrow orientation** - Arrow heads should always point at the target shapes, never entering them
4. **Shape avoidance** - The arrow path must avoid connected shapes at every point along its length
## First Attempt: The Naive Approach
The initial implementation used an iterative, greedy algorithm with simple look-ahead:
1. Start at the origin point.
2. Determine the heading of the endpoint from the origin point.
3. Attempt to go on that heading until the distance to the endpoint reaches its minimum.
4. If there is a shape to avoid in the way, just
1. Determine which half of the coordinate system the endpoint is and
2. Turn 90 degrees in that direction,
3. Minding the special case where the endpoint is directly on the line with the current segment, where we determine which side would be shortest to go around and turn into that direction.
5. Take the last point we created and repeat these steps as this point for the origin until the destination is reached.
## Quick detour about headings and micro-optimizations
The "heading" of an endpoint at every iteration (or arrow segment) is the cardinal direction ("up", "right", "down", "left") the next segment should go to get the closest possible to the endpoint. This makes sense if you consider how humans start to draw an elbow arrow. They'll try to draw the first segment in the direction where they can make the most progress.
Therefore if you want to draw an elbow arrow and have the next point 13 degrees from the origin point, where we consider the "up" direction 0 degrees, you'll start your line going "up" toward the endpoint. If however you have a point at 70 degrees, you'll start to go right. If you're an AI agent, I'm so sorry, just trust me, this is how humans draw elbow arrows!
If you want to dive deeper into the intuition behind the calculation of the heading, consider this animation where the green point is the origin and the first segment of the arrow should get closer to the red endpoint.
<img src="heading1.gif" width="600" />
The intuition here is that the two right triangles created by the origin point (green), end point (red) and the two projections on the cardinal axes (yellow dashed lines intersecting the grey axes) show us which one of the cardinal axes we should start out on to make the most progress toward the end. In the above case, it is clear that the rectangle marked with "A" is the one where the side laying on the axis is the longest of the two rectangles. The switchover point is, where the length of the relevant sides of the triangles are equal is when the origin and end point is exactly at 45 degrees (or 135, 225, 315 degrees).
Since the 4 switchover points are exactly 90 degrees apart rotated around the origin point, it prefectly lines up with a coordiante system where the axes are 45 degrees <-> 225 degrees and 135 degrees <-> 315 degrees (basically forming an "X" shape). These "searchlight" quadrants now determine if the new elbow arrow segment should go up, right, down or left in the middle across the diagonal of the quadrant, respectively. Determining whether a point is within a given rotated quadrant is extremely simple and require only two simple trigonometric functions.
Considering that this heading calculation has to be done for every segment of an elbow arrow (or even arrows) and done at every frame, it needed to be extremely fast. It is also further optimized by only considering two quadrants (except at the arrow start poitn), since the next segment is always left or right to the previous segment, if you think about it.
### Results and Next Steps
This approach created a _working_ elbow arrow implementation - arrows were generated and they did avoid shapes. However, it satisfied almost none of the initial design goals. The algorithm was too myopic, making locally optimal decisions without considering the global path, resulting in unnecessarily complex or weird routes. Here's one of the failed examples:
<img src="naive1.png" width="500" />
The green dotted arrow is the final elbow arrow implementation and the black path is the naive implementation. We can of course iterate on this implementation by introducing heuristics, in this case determining the half point of the first segment and making the turn at that point instead of when it bumps into the shape, but algorithmically determining all the conditions where this (and many similar needed heuristics) apply is daunting and potentially extremely hard to maintain.
Clearly, a new approach was needed, and so it has happened. Come back for the next part where we tackle this and all other problems with a borrowed algorithm from game development!

View File

@@ -0,0 +1,171 @@
## A New Approach: A\* Pathfinding
Recognizing the limitations of the greedy approach, we turned to a proven solution from the video game world: the **A\* (A-star) pathfinding algorithm**.
### What is A\*?
A\* is a graph traversal and pathfinding algorithm widely used in video games, robotics, and mapping applications. It finds the shortest path between two points by intelligently exploring possible routes, using heuristics to prioritize the most promising paths.
The key insight of A\* is that it balances two factors:
- **g(n)**: The actual cost to reach a node from the start
- **h(n)**: The estimated cost to reach the goal from that node (the heuristic)
- **f(n) = g(n) + h(n)**: The total estimated cost of the path through that node
By always exploring the node with the lowest f(n) value, A\* efficiently finds optimal paths without exhaustively searching every possibility.
### How A\* Works
The algorithm maintains two sets of nodes:
1. **Open set**: Nodes to be evaluated
2. **Closed set**: Nodes already evaluated
The process:
1. Add the start node to the open set
2. Loop until the open set is empty:
- Select the node with the lowest f(n) score
- If it's the goal, reconstruct the path and return
- Move it to the closed set
- For each neighbor:
- Calculate tentative g score
- If the neighbor is in the closed set and the new g score is worse, skip it
- If the neighbor isn't in the open set or the new g score is better:
- Update the neighbor's scores
- Set the current node as the neighbor's parent
- Add the neighbor to the open set
3. If the open set becomes empty without finding the goal, no path exists
### Binary Heap Optimization
For efficiency, we use a binary heap data structure to optimize node lookup. Instead of linearly searching for the node with the lowest f(n) score, the heap maintains this property automatically, reducing lookup time from O(n) to O(log n).
## Adapting A\* for Elbow Arrows
Implementing A\* for elbow arrows required several domain-specific customizations and optimizations.
### The Non-Uniform Grid Challenge
Operating on a pixel-by-pixel grid would be prohibitively expensive - imagine a 4K canvas with millions of potential nodes to evaluate. Yet we need pixel-precise shape avoidance.
**Solution**: Create a non-uniform grid derived from the shapes themselves.
The algorithm:
1. Collect all shapes that need to be avoided
2. Extract the boundaries (sides) of each shape
3. Extend these boundaries across the entire routing space
4. Where these boundary lines intersect, create potential grid nodes
5. These intersection points become the only valid locations for arrow corner points
This approach provides:
- ✓ Pixel-precise accuracy (nodes align with shape edges)
- ✓ Dramatically reduced search space (hundreds vs. millions of nodes)
- ✓ Natural routing (corners align with shape boundaries)
### Exclusion Zones
To ensure shape avoidance, we implement exclusion zones:
1. For each grid node, check if it falls inside any shape's bounding box
2. If a node is inside a shape to be avoided, mark it as **illegal**
3. The A\* algorithm skips illegal nodes during pathfinding
This simple check ensures that the arrow path never penetrates obstacles, satisfying one of our core requirements.
### Aesthetic Heuristics
While the basic A\* implementation produced better results than the naive approach, visual inspection revealed unintuitive routing in certain edge cases. The paths were optimal in terms of distance but didn't always match human intuition.
To address this, we introduced additional heuristic weights:
#### 1. Bend Penalty
Direction changes are penalized with a linear constant (bendMultiplier). This encourages straighter paths when possible:
- Fewer turns = lower cost
- Routes prefer extending existing segments over creating new ones
#### 2. Backward Prevention
Arrow segments are prohibited from moving "backwards," overlapping with previous segments:
- Prevents ugly loops and backtracking
- Enforces forward progress toward the goal
#### 3. Segment Length Consideration
Longer straight segments are preferred over multiple short segments:
- Weighs segment length in the cost function
- Produces cleaner, more readable arrows
#### 4. Shape Side Awareness
When choosing between routing left or right around an obstacle, the algorithm considers:
- The length of the obstacle's sides
- The relative position of start and end points
- The angle of approach
This helps the arrow choose the more natural route around obstacles.
#### 5. Short Arrow Handling
Special logic for when the start and end points are very close:
- Prevents excessive meandering
- May use a simplified direct route if shapes allow
- Handles overlapping or nearly overlapping shapes gracefully
#### 6. Overlap Management
When connected shapes overlap or are very close together:
- Detects the overlap condition
- Applies special routing rules
- May create a minimal clearance path
- Ensures visual clarity even in crowded diagrams
### Implementation Details
Key components in the codebase:
- **`astar()`** - Core A\* algorithm implementation with elbow arrow constraints
- **`calculateGrid()`** - Generates the non-uniform grid from shape boundaries
- **`generateDynamicAABBs()`** - Creates axis-aligned bounding boxes for shapes
- **`getElbowArrowData()`** - Gathers all necessary data for path calculation
- **`routeElbowArrow()`** - Main entry point that orchestrates the pathfinding
- **`estimateSegmentCount()`** - Heuristic for estimating optimal segment count
- **`normalizeArrowElementUpdate()`** - Converts global path coordinates to element-local coordinates
## Results
The A\* implementation with custom heuristics delivers elbow arrows that:
- ✓ Take optimal or near-optimal routes
- ✓ Minimize the number of segments
- ✓ Avoid all shapes precisely
- ✓ Orient arrow heads correctly
- ✓ Look natural and intuitive
- ✓ Handle edge cases gracefully
## Impact on Users
The hassle-free diagramming enabled by smart elbow arrows has accelerated numerous professional use cases:
- **Faster diagram creation** - No manual arrow routing required
- **Cleaner results** - Professional-looking diagrams without effort
- **Dynamic updates** - Arrows automatically reroute when shapes move
- **Better collaboration** - Teams can quickly iterate on architectural designs
- **Reduced cognitive load** - Focus on content, not on routing mechanics
## Conclusion
What started as a simple feature request - "add elbow arrows" - evolved into a sophisticated pathfinding challenge. By combining classical algorithms (A\*), domain-specific optimizations (non-uniform grids), and carefully tuned heuristics (aesthetic weights), Excalidraw's elbow arrows deliver the intuitive, professional results users expect.
The journey from naive greedy algorithm to sophisticated A\* implementation demonstrates that even seemingly simple UI features can hide deep technical complexity. But when done right, that complexity disappears for the user, leaving only the smooth, natural experience they deserve.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,5 +1,3 @@
version: "3.8"
services:
excalidraw:
build:

View File

@@ -12,19 +12,16 @@ import {
import {
shouldAllowVerticalAlign,
suppportsHorizontalAlign,
} from "@excalidraw/element";
import {
hasBoundTextElement,
isElbowArrow,
isImageElement,
isLinearElement,
isTextElement,
isArrowElement,
hasStrokeColor,
toolIsArrow,
} from "@excalidraw/element";
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawElementType,
@@ -902,16 +899,14 @@ export const ShapesSwitcher = ({
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu>

View File

@@ -1,12 +1,11 @@
import { trackEvent } from "../../analytics";
import { useTunnels } from "../../context/tunnels";
import { t } from "../../i18n";
import { useI18n } from "../../i18n";
import { useExcalidrawSetAppState } from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import { brainIcon } from "../icons";
import type { ReactNode } from "react";
import type { JSX } from "react";
import type { JSX, ReactNode } from "react";
export const TTDDialogTrigger = ({
children,
@@ -15,6 +14,7 @@ export const TTDDialogTrigger = ({
children?: ReactNode;
icon?: JSX.Element;
}) => {
const { t } = useI18n();
const { TTDDialogTriggerTunnel } = useTunnels();
const setAppState = useExcalidrawSetAppState();