Building a Live Measure Tool on an SVG Canvas
TLDR: The measure tool is an ephemeral SVG overlay that redraws on every mousemove. Point placement is snap-aware. Wire clicks trigger getTotalLength() for path length. Angle classification (acute/right/obtuse/straight) uses the dot product.
Repo: tools/schema-editor
The Problem
Engineers building electrical and mechanical schematics need to verify distances. A resistor spacing, a wire run length, an angle between connectors. The existing canvas had zoom and pan but no measurement capability.
The tool needed to be live: draw as you move, snap to existing geometry, handle both straight distances and curved paths.
The Overlay Approach
The measure tool renders into an ephemeral SVG <g> element inserted above all other canvas content:
this._measureOverlay = document.createElementNS('http://www.w3.org/2000/svg', 'g');
this._measureOverlay.setAttribute('id', 'measure-overlay');
svgRoot.appendChild(this._measureOverlay);
All measure elements (line, endpoints, labels, angle arc) are children of this group. On each mousemove, the group's contents are replaced entirely:
function _updateMeasureOverlay(fromPt, toPt) {
overlay.innerHTML = '';
// build and append SVG elements
}
innerHTML = '' followed by fresh element creation is faster than trying to update individual element attributes across a variable number of children. The measure overlay has at most 8 to 10 elements. At 60fps, this is not a performance concern.
Snap-Aware Point Placement
Clicking to place a measure point goes through the same snap pipeline as other canvas interactions:
function _placeMeasurePoint(e) {
const worldPt = screenToWorld(e.clientX, e.clientY);
const snapped = smartSnap(worldPt, { pinSnap: true, gridSnap: true });
measurePoints.push(snapped.point);
if (measurePoints.length === 2) _finalizeMeasure();
}
smartSnap returns the nearest snappable target within the snap radius. If the click is near a component pin, the measure anchors to the exact pin world position. If it is near the grid, it snaps to the nearest grid intersection.
This ensures measurements between two pins are exact, not approximate pixel positions.
Wire Auto-Measure
Clicking a wire <path> element triggers an automatic full-length measurement without requiring two manual point placements:
function _handleWireClick(wireEl) {
const length = wireEl.getTotalLength();
const labelPt = wireEl.getPointAtLength(length / 2);
_renderLengthLabel(labelPt, length, 'path');
}
getTotalLength() is a native SVG method on SVGGeometryElement. It returns the exact path length in SVG user units, correctly accounting for curves, multi-segment paths, and Manhattan routing bends.
getPointAtLength(length / 2) gives the midpoint along the path where the label should appear. For a straight wire this is the geometric midpoint. For a bent Manhattan-routed wire it follows the path, placing the label on the longest segment.
Distance and Angle Labels
For a two-point manual measure:
function _renderMeasure(from, to) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.hypot(dx, dy);
// label at midpoint, offset perpendicular to line const mid = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; const norm = { x: -dy / dist, y: dx / dist }; const labelPt = { x: mid.x + norm.x 14, y: mid.y + norm.y 14 };
_appendText(overlay, ${dist.toFixed(1)}, labelPt); _appendText(overlay, Δx: ${Math.abs(dx).toFixed(1)}, { x: mid.x, y: from.y - 8 }); _appendText(overlay, Δy: ${Math.abs(dy).toFixed(1)}, { x: to.x + 8, y: mid.y }); }
The perpendicular offset for the main label is 14 SVG units. In world space this stays constant regardless of zoom because the label is in SVG coordinates, not screen coordinates.
Angle Classification
When three points are placed (vertex between two rays), the angle is computed and classified:
function _classifyAngle(a, vertex, b) {
const va = { x: a.x - vertex.x, y: a.y - vertex.y };
const vb = { x: b.x - vertex.x, y: b.y - vertex.y };
const dot = va.x vb.x + va.y vb.y;
const cross = va.x vb.y - va.y vb.x;
const angle = Math.atan2(Math.abs(cross), dot) * (180 / Math.PI);
if (Math.abs(angle - 90) < 1) return 'right'; if (angle < 90) return 'acute'; if (angle < 180) return 'obtuse'; return 'straight'; }
atan2(|cross|, dot) always returns the angle between 0 and 180 degrees. The cross product magnitude gives the sine of the angle and the dot product gives the cosine. Using both avoids the ambiguity of acos(dot) alone (which is undefined for near-zero magnitudes) and produces a stable classification.
The angle arc is drawn as an SVG <path> arc segment from one ray direction to the other around the vertex:
const r = 20; // arc radius in world units
const p1 = { x: vertex.x + Math.cos(angle1) r, y: vertex.y + Math.sin(angle1) r };
const p2 = { x: vertex.x + Math.cos(angle2) r, y: vertex.y + Math.sin(angle2) r };
const largeArc = angleDeg > 180 ? 1 : 0;
const d = M ${p1.x} ${p1.y} A ${r} ${r} 0 ${largeArc} 1 ${p2.x} ${p2.y};
Active State and Cleanup
#measureBtn has an active CSS class while the tool is active. Pressing Escape or switching to any other tool calls _exitMeasureMode:
function _exitMeasureMode() {
measurePoints = [];
overlay.innerHTML = '';
measureBtn.classList.remove('active');
}
The overlay group stays in the SVG. Its children are cleared. This avoids the cost of removing and re-inserting the group element when the tool is reactivated.
Tradeoffs
overlay.innerHTML = '' is not the fastest DOM operation. For a simple overlay with fewer than 10 elements, the overhead is negligible. For a measure tool that renders hundreds of segment labels simultaneously (e.g., measuring a wiring harness), a keyed update approach would reduce DOM churn.
getTotalLength() is synchronous and accurate. It is a browser-native computation and does not require any JavaScript path math. The only limitation is that it operates on the path as currently rendered in the SVG. If the wire's d attribute has not been updated after a component move, the length is stale until the next render pass.
Angle labels overlap at small angles. When the two rays are nearly collinear, the Δx and Δy labels can overlap the distance label. A minimum angle threshold for showing individual axis labels (e.g., hide Δx label when |dy/dist| < 0.1) would improve readability at small angles.