From aac9ac375a0ddcc6e3a8c6fdb294ee79254813a7 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 24 Aug 2026 15:36:44 -0500 Subject: [PATCH] feat: path-aware arrowheads and labels * shared tangent-based arrowhead geometry with optional filled triangles and shaft trimming * labels use path distance and local-normal offsets across rendering, export, selection, and hit testing --- TODO.md | 14 +- crates/inkfinite-core/src/render/mod.rs | 339 ++++++++++-------- fixtures/native/rendering/all-builtins.svg | 2 +- packages/core/src/arrow-geometry.ts | 142 ++++++++ packages/core/src/export.ts | 80 +++-- packages/core/src/geom.ts | 75 +++- packages/core/src/index.ts | 1 + packages/core/src/model.ts | 33 +- packages/core/src/tools/select.ts | 51 +-- packages/core/tests/arrow-geometry.test.ts | 135 ++++--- packages/editor/src/renderer.ts | 126 ++----- .../arrowlabel-controller.svelte.ts | 30 +- .../lib/editor/components/ArrowPopover.svelte | 5 +- 13 files changed, 619 insertions(+), 414 deletions(-) create mode 100644 packages/core/src/arrow-geometry.ts diff --git a/TODO.md b/TODO.md index 7d79f2b..5000660 100644 --- a/TODO.md +++ b/TODO.md @@ -116,17 +116,17 @@ selection, labels, and arrowheads. ### Path-aware arrowheads and labels -- [ ] Orient start and end arrowheads from the tangent of the resolved path +- [x] Orient start and end arrowheads from the tangent of the resolved path rather than the first or last sampled line segment -- [ ] Define arrowhead geometry independently from shaft geometry so additional +- [x] Define arrowhead geometry independently from shaft geometry so additional head styles can be added without changing routing -- [ ] Trim the visible shaft where required so filled arrowheads do not overlap +- [x] Trim the visible shaft where required so filled arrowheads do not overlap the path beneath them -- [ ] Position arrow labels by distance along the resolved path -- [ ] Apply label offset along the local path normal rather than a global axis -- [ ] Keep label placement stable when bindings, bend, or orthogonal routing +- [x] Position arrow labels by distance along the resolved path +- [x] Apply label offset along the local path normal rather than a global axis +- [x] Keep label placement stable when bindings, bend, or orthogonal routing change -- [ ] Add hit-test and visual coverage for arrowheads and labels on straight, +- [x] Add hit-test and visual coverage for arrowheads and labels on straight, curved, and orthogonal routes ## Advanced vector editing diff --git a/crates/inkfinite-core/src/render/mod.rs b/crates/inkfinite-core/src/render/mod.rs index fb5ef3c..c21f517 100644 --- a/crates/inkfinite-core/src/render/mod.rs +++ b/crates/inkfinite-core/src/render/mod.rs @@ -13,6 +13,7 @@ use crate::engine::geometry::world_transform; use crate::engine::geometry::{ Affine, bounds_from_points, intersects, path_bounds, stroke_outline as canonical_stroke_outline, union, }; +use crate::path_metrics::{DEFAULT_PATH_METRIC_TOLERANCE, path_length, trim_path}; use crate::proto::Bounds; use crate::{ AssetId, AssetSource, BuiltinShapeKind, Document, DocumentSnapshot, LayerId, PageId, PathFillRule, PathGeometry, @@ -391,10 +392,7 @@ impl Renderer<'_> { message: error.to_string(), } })?; - let points = path_vertices(&geometry.path); - if points.len() < 2 { - return Ok(()); - } + let shaft = arrow_shaft_geometry(&geometry.path, &props.style); let dash = props .style .dash @@ -407,44 +405,23 @@ impl Renderer<'_> { ) }) .unwrap_or_default(); - writeln!(output, " ", path_data(&geometry.path), escape_xml(&props.style.stroke), number(props.style.width)).expect("writing to a String cannot fail"); + writeln!(output, " ", path_data(&shaft), escape_xml(&props.style.stroke), number(props.style.width)).expect("writing to a String cannot fail"); - if props.style.head_end.unwrap_or(true) { - let tangent = path_endpoint_tangent(&geometry.path, false).unwrap_or_else(|| Vec2 { - x: points[points.len() - 1].x - points[points.len() - 2].x, - y: points[points.len() - 1].y - points[points.len() - 2].y, - }); - arrow_head( - output, - transform, - offset_point(points[points.len() - 1], tangent, -1.0), - points[points.len() - 1], - &props.style, - stroke_opacity, - ); + if props.style.head_end.unwrap_or(true) + && let Some(head) = arrow_head_geometry(&geometry.path, false) + { + arrow_head(output, transform, &head, false, &props.style, stroke_opacity); } - if props.style.head_start.unwrap_or(false) { - let tangent = path_endpoint_tangent(&geometry.path, true) - .unwrap_or_else(|| Vec2 { x: points[1].x - points[0].x, y: points[1].y - points[0].y }); - arrow_head( - output, - transform, - offset_point(points[0], tangent, 1.0), - points[0], - &props.style, - stroke_opacity, - ); + if props.style.head_start.unwrap_or(false) + && let Some(head) = arrow_head_geometry(&geometry.path, true) + { + arrow_head(output, transform, &head, true, &props.style, stroke_opacity); } - if let Some(label) = props.label.filter(|label| !label.text.is_empty()) { - let length = polyline_length(&points); - let distance = match label.align.as_str() { - "start" => label.offset, - "end" => length - label.offset, - _ => length / 2.0 + label.offset, - }; - let at = point_at_distance(&points, distance); + if let Some(label) = props.label.filter(|label| !label.text.is_empty()) + && let Some((at, _distance)) = arrow_label_position(&geometry.path, &label) + { let label_width = deterministic_text_width(&label.text, 14.0) + 8.0; - writeln!(output, " {}", number(at.x - label_width / 2.0), number(at.y - 23.0), number(label_width), number(at.x), number(at.y - 7.0), escape_xml(&label.text)).expect("writing to a String cannot fail"); + writeln!(output, " {}", number(at.x - label_width / 2.0), number(at.y - 9.0), number(label_width), number(at.x), number(at.y), escape_xml(&label.text)).expect("writing to a String cannot fail"); } Ok(()) } @@ -593,6 +570,8 @@ struct ArrowStyle { width: f64, head_start: Option, head_end: Option, + head_start_style: Option, + head_end_style: Option, dash: Option>, } @@ -601,6 +580,8 @@ struct ArrowLabel { text: String, align: String, offset: f64, + #[serde(default)] + distance: Option, } #[derive(Deserialize)] @@ -847,6 +828,43 @@ fn properties Deserialize<'de>>(shape: &ShapeRecord) -> Result Result { + let geometry = + resolve_arrow_geometry_for_shape(document, shape).map_err(|error| SvgRenderError::InvalidShapeProperties { + shape_id: shape.id.clone(), + kind: shape.kind.to_string(), + message: error.to_string(), + })?; + let props: ArrowProps = properties(shape)?; + let mut bounds = path_bounds(&geometry.path); + let mut include_head = |at_start: bool| { + if let Some(head) = arrow_head_geometry(&geometry.path, at_start) { + bounds = union(bounds, bounds_from_points(&[head.tip, head.left, head.right])); + } + }; + if props.style.head_end.unwrap_or(true) { + include_head(false); + } + if props.style.head_start.unwrap_or(false) { + include_head(true); + } + if let Some(label) = props.label.filter(|label| !label.text.is_empty()) + && let Some((point, _distance)) = arrow_label_position(&geometry.path, &label) + { + let half_width = deterministic_text_width(&label.text, 14.0) / 2.0 + 4.0; + bounds = union( + bounds, + bounds_from_points(&[ + Vec2 { x: point.x - half_width, y: point.y - 9.0 }, + Vec2 { x: point.x + half_width, y: point.y - 9.0 }, + Vec2 { x: point.x - half_width, y: point.y + 9.0 }, + Vec2 { x: point.x + half_width, y: point.y + 9.0 }, + ]), + ); + } + Ok(bounds) +} + fn shape_local_bounds(document: &Document, shape: &ShapeRecord) -> Result { let bounds = match BuiltinShapeKind::parse(shape.kind.as_str()) { Some(BuiltinShapeKind::Rectangle | BuiltinShapeKind::Ellipse) => { @@ -861,13 +879,7 @@ fn shape_local_bounds(document: &Document, shape: &ShapeRecord) -> Result resolve_arrow_geometry_for_shape(document, shape) - .map(|geometry| path_bounds(&geometry.path)) - .map_err(|error| SvgRenderError::InvalidShapeProperties { - shape_id: shape.id.clone(), - kind: shape.kind.to_string(), - message: error.to_string(), - })?, + Some(BuiltinShapeKind::Arrow) => arrow_bounds(document, shape)?, Some(BuiltinShapeKind::Text) => { let props: TextProps = properties(shape)?; let width = props @@ -988,135 +1000,146 @@ fn contains_selected_descendant(document: &Document, shape: &ShapeRecord, select }) } -fn path_vertices(geometry: &PathGeometry) -> Vec { - geometry - .subpaths - .iter() - .flat_map(|subpath| subpath.segments.iter()) - .map(|segment| match segment { - PathSegment::Move { to } - | PathSegment::Line { to } - | PathSegment::Quadratic { to, .. } - | PathSegment::Cubic { to, .. } => *to, - }) - .collect() +#[derive(Clone, Copy)] +struct ArrowHeadGeometry { + tip: Vec2, + left: Vec2, + right: Vec2, } -fn path_endpoint_tangent(geometry: &PathGeometry, at_start: bool) -> Option { - let subpath = geometry.subpaths.first()?; - let first = subpath.segments.first()?; - let PathSegment::Move { to: start } = first else { return None }; - if at_start { - let mut current = *start; - for segment in subpath.segments.iter().skip(1) { - let tangent = match segment { - PathSegment::Move { to } => { - current = *to; - continue; - } - PathSegment::Line { to } => subtract(*to, current), - PathSegment::Quadratic { control, to } => { - let control_tangent = subtract(*control, current); - if nonzero(control_tangent) { control_tangent } else { subtract(*to, current) } - } - PathSegment::Cubic { control_1, control_2, to } => { - let control_tangent = subtract(*control_1, current); - if nonzero(control_tangent) { - control_tangent - } else { - let fallback = subtract(*control_2, current); - if nonzero(fallback) { fallback } else { subtract(*to, current) } - } - } - }; - if nonzero(tangent) { - return Some(tangent); - } - if let PathSegment::Line { to } | PathSegment::Quadratic { to, .. } | PathSegment::Cubic { to, .. } = - segment - { - current = *to; - } - } +fn arrow_head_geometry(geometry: &PathGeometry, at_start: bool) -> Option { + let length = path_length(geometry, DEFAULT_PATH_METRIC_TOLERANCE); + if length <= f64::EPSILON { + return None; + } + let location = point_at_distance( + geometry, + if at_start { 0.0 } else { length }, + DEFAULT_PATH_METRIC_TOLERANCE, + )?; + if !nonzero(location.tangent) { + return None; + } + let direction = if at_start { Vec2 { x: -location.tangent.x, y: -location.tangent.y } } else { location.tangent }; + let left_direction = rotate(direction, -std::f64::consts::PI / 6.0); + let right_direction = rotate(direction, std::f64::consts::PI / 6.0); + Some(ArrowHeadGeometry { + tip: location.point, + left: subtract(location.point, scale(left_direction, 15.0)), + right: subtract(location.point, scale(right_direction, 15.0)), + }) +} + +fn arrow_shaft_geometry<'a>(geometry: &'a PathGeometry, style: &ArrowStyle) -> std::borrow::Cow<'a, PathGeometry> { + let start_trim = if style.head_start.unwrap_or(false) && style.head_start_style.as_deref() == Some("triangle") { + 15.0 } else { - let mut current = *start; - let mut tangent = None; - for segment in subpath.segments.iter().skip(1) { - match segment { - PathSegment::Move { to } => current = *to, - PathSegment::Line { to } => { - tangent = Some(subtract(*to, current)); - current = *to; - } - PathSegment::Quadratic { control, to } => { - tangent = Some(if nonzero(subtract(*to, *control)) { - subtract(*to, *control) - } else { - subtract(*to, current) - }); - current = *to; - } - PathSegment::Cubic { control_2, to, .. } => { - tangent = Some(if nonzero(subtract(*to, *control_2)) { - subtract(*to, *control_2) - } else { - subtract(*to, current) - }); - current = *to; - } - } - } - return tangent.filter(|value| nonzero(*value)); + 0.0 + }; + let end_trim = if style.head_end.unwrap_or(true) && style.head_end_style.as_deref() == Some("triangle") { + 15.0 + } else { + 0.0 + }; + let length = path_length(geometry, DEFAULT_PATH_METRIC_TOLERANCE); + if start_trim == 0.0 && end_trim == 0.0 || length <= f64::EPSILON { + return std::borrow::Cow::Borrowed(geometry); } - None + trim_path( + geometry, + start_trim, + (length - end_trim).max(start_trim), + DEFAULT_PATH_METRIC_TOLERANCE, + ) + .map_or_else(|| std::borrow::Cow::Borrowed(geometry), std::borrow::Cow::Owned) } -fn subtract(left: Vec2, right: Vec2) -> Vec2 { - Vec2 { x: left.x - right.x, y: left.y - right.y } +fn arrow_label_position(geometry: &PathGeometry, label: &ArrowLabel) -> Option<(Vec2, f64)> { + let length = path_length(geometry, DEFAULT_PATH_METRIC_TOLERANCE); + if length <= f64::EPSILON { + return None; + } + let distance = label.distance.unwrap_or_else(|| match label.align.as_str() { + "start" => 0.0, + "end" => length, + _ => length / 2.0, + }); + let distance = if distance.is_finite() { + distance.clamp(0.0, length) + } else if distance.is_sign_negative() { + 0.0 + } else { + length + }; + let location = point_at_distance(geometry, distance, DEFAULT_PATH_METRIC_TOLERANCE)?; + let normal = Vec2 { x: -location.tangent.y, y: location.tangent.x }; + Some((add(location.point, scale(normal, label.offset)), location.distance)) } -fn nonzero(value: Vec2) -> bool { - value.x.abs() > f64::EPSILON || value.y.abs() > f64::EPSILON +fn point_at_distance( + geometry: &PathGeometry, distance: f64, tolerance: f64, +) -> Option { + crate::path_metrics::point_at_distance(geometry, distance, tolerance) } -fn offset_point(point: Vec2, direction: Vec2, factor: f64) -> Vec2 { - let length = direction.x.hypot(direction.y); - if length <= f64::EPSILON { - return point; - } - Vec2 { x: point.x + direction.x / length * factor, y: point.y + direction.y / length * factor } +fn arrow_head( + output: &mut String, transform: &str, head: &ArrowHeadGeometry, at_start: bool, style: &ArrowStyle, opacity: &str, +) { + let filled = (at_start && style.head_start_style.as_deref() == Some("triangle")) + || (!at_start && style.head_end_style.as_deref() == Some("triangle")); + let triangle = if filled { + format!( + "M {} {} L {} {} L {} {} Z", + number(head.tip.x), + number(head.tip.y), + number(head.left.x), + number(head.left.y), + number(head.right.x), + number(head.right.y) + ) + } else { + format!( + "M {} {} L {} {} M {} {} L {} {}", + number(head.tip.x), + number(head.tip.y), + number(head.left.x), + number(head.left.y), + number(head.tip.x), + number(head.tip.y), + number(head.right.x), + number(head.right.y) + ) + }; + let filled = triangle.ends_with(" Z"); + writeln!( + output, + " ", + if filled { escape_xml(&style.stroke) } else { "none".into() }, + escape_xml(&style.stroke), + number(style.width) + ) + .expect("writing to a String cannot fail"); } -fn arrow_head(output: &mut String, transform: &str, from: Vec2, at: Vec2, style: &ArrowStyle, opacity: &str) { - let angle = (at.y - from.y).atan2(at.x - from.x); - let length = 15.0; - let spread = std::f64::consts::PI / 6.0; - let left = Vec2 { x: at.x - length * (angle - spread).cos(), y: at.y - length * (angle - spread).sin() }; - let right = Vec2 { x: at.x - length * (angle + spread).cos(), y: at.y - length * (angle + spread).sin() }; - writeln!(output, " ", number(at.x), number(at.y), number(left.x), number(left.y), number(at.x), number(at.y), number(right.x), number(right.y), escape_xml(&style.stroke), number(style.width)).expect("writing to a String cannot fail"); +fn add(left: Vec2, right: Vec2) -> Vec2 { + Vec2 { x: left.x + right.x, y: left.y + right.y } } -fn polyline_length(points: &[Vec2]) -> f64 { - points - .windows(2) - .map(|pair| (pair[1].x - pair[0].x).hypot(pair[1].y - pair[0].y)) - .sum() +fn subtract(left: Vec2, right: Vec2) -> Vec2 { + Vec2 { x: left.x - right.x, y: left.y - right.y } } -fn point_at_distance(points: &[Vec2], target: f64) -> Vec2 { - let mut distance = 0.0; - for pair in points.windows(2) { - let length = (pair[1].x - pair[0].x).hypot(pair[1].y - pair[0].y); - if distance + length >= target && length > 0.0 { - let ratio = (target - distance) / length; - return Vec2 { - x: pair[0].x + (pair[1].x - pair[0].x) * ratio, - y: pair[0].y + (pair[1].y - pair[0].y) * ratio, - }; - } - distance += length; - } - points.last().copied().unwrap_or(Vec2 { x: 0.0, y: 0.0 }) +fn scale(point: Vec2, factor: f64) -> Vec2 { + Vec2 { x: point.x * factor, y: point.y * factor } +} + +fn rotate(point: Vec2, angle: f64) -> Vec2 { + let (sine, cosine) = angle.sin_cos(); + Vec2 { x: point.x * cosine - point.y * sine, y: point.x * sine + point.y * cosine } +} + +fn nonzero(value: Vec2) -> bool { + value.x.abs() > f64::EPSILON || value.y.abs() > f64::EPSILON } fn markdown_lines(source: &str, base_size: f64) -> Vec { diff --git a/fixtures/native/rendering/all-builtins.svg b/fixtures/native/rendering/all-builtins.svg index d8c9cdf..9c20b55 100644 --- a/fixtures/native/rendering/all-builtins.svg +++ b/fixtures/native/rendering/all-builtins.svg @@ -30,7 +30,7 @@ - bound route + bound route diff --git a/packages/core/src/arrow-geometry.ts b/packages/core/src/arrow-geometry.ts new file mode 100644 index 0000000..10f3db6 --- /dev/null +++ b/packages/core/src/arrow-geometry.ts @@ -0,0 +1,142 @@ +import type { ArrowLabel, ArrowStyle, PathGeometry } from './model'; +import type { Vec2 } from './math'; +import { pathLength, pointAtPathDistance, trimPathGeometry } from './path-metrics'; + +/** Arrowhead shapes supported by the connector renderers. */ +export type ArrowHeadStyle = 'open' | 'triangle'; + +/** Geometry needed to render one arrowhead independently of its shaft. */ +export type ArrowHeadGeometry = { + tip: Vec2; + /** Unit direction pointing out from the path endpoint. */ + direction: Vec2; + /** Unit tangent in the path's forward direction. */ + tangent: Vec2; + left: Vec2; + right: Vec2; + length: number; +}; + +/** Position and local frame for an arrow label. */ +export type ArrowLabelPlacement = { + /** Label anchor after applying the normal offset. */ + point: Vec2; + /** Point on the resolved path before applying the normal offset. */ + pathPoint: Vec2; + /** Unit tangent in the path's forward direction. */ + tangent: Vec2; + /** Left-hand unit normal in the path's local coordinate system. */ + normal: Vec2; + /** Distance along the resolved path used for the anchor. */ + distance: number; +}; + +const DEFAULT_ARROWHEAD_LENGTH = 15; +const DEFAULT_ARROWHEAD_SPREAD = Math.PI / 6; +const EPSILON = Number.EPSILON; + +/** + * Resolve one arrowhead from the path endpoint tangent. + * + * `atStart` reverses the path tangent so the head points away from the path. + * The returned points are in the same coordinate system as `path`. + */ +export function arrowHeadGeometry( + path: PathGeometry, + atStart: boolean, + options: { length?: number; spread?: number } = {} +): ArrowHeadGeometry | null { + const length = Math.max(0, options.length ?? DEFAULT_ARROWHEAD_LENGTH); + const spread = options.spread ?? DEFAULT_ARROWHEAD_SPREAD; + const totalLength = pathLength(path); + if (totalLength <= EPSILON || length <= EPSILON) return null; + + const location = pointAtPathDistance(path, atStart ? 0 : totalLength); + if (!location || (location.tangent.x === 0 && location.tangent.y === 0)) return null; + + const tangent = location.tangent; + const direction = atStart ? { x: -tangent.x, y: -tangent.y } : tangent; + const leftDirection = rotate(direction, -spread); + const rightDirection = rotate(direction, spread); + return { + tip: location.point, + direction, + tangent, + left: subtract(location.point, scale(leftDirection, length)), + right: subtract(location.point, scale(rightDirection, length)), + length + }; +} + +/** + * Return the shaft geometry after removing space occupied by filled heads. + * Open heads remain attached to the path endpoint, while triangle heads stop + * at their base so the shaft is not drawn through the filled shape. + */ +export function arrowShaftGeometry(path: PathGeometry, style: ArrowStyle): PathGeometry { + const startHead = style.headStart === true ? (style.headStartStyle ?? 'open') : 'open'; + const endHead = style.headEnd !== false ? (style.headEndStyle ?? 'open') : 'open'; + const totalLength = pathLength(path); + if (totalLength <= EPSILON) return path; + + const startTrim = startHead === 'triangle' ? DEFAULT_ARROWHEAD_LENGTH : 0; + const endTrim = endHead === 'triangle' ? DEFAULT_ARROWHEAD_LENGTH : 0; + if (startTrim === 0 && endTrim === 0) return path; + return trimPathGeometry(path, startTrim, Math.max(startTrim, totalLength - endTrim)) ?? path; +} + +/** + * Resolve an arrow label from path distance and a local normal offset. + * + * `distance` is optional so labels created before explicit path-distance + * storage continue to use their alignment anchor. `offset` is perpendicular + * to the path; it does not change the label's distance along the route. + */ +export function arrowLabelPlacement(path: PathGeometry, label: ArrowLabel): ArrowLabelPlacement | null { + const totalLength = pathLength(path); + if (totalLength <= EPSILON) return null; + + const distance = clamp(label.distance ?? alignmentDistance(label.align, totalLength), 0, totalLength); + const location = pointAtPathDistance(path, distance); + if (!location) return null; + + const tangent = location.tangent; + const normal = { x: -tangent.y, y: tangent.x }; + const offset = Number.isFinite(label.offset) ? label.offset : 0; + return { + point: add(location.point, scale(normal, offset)), + pathPoint: location.point, + tangent, + normal, + distance: location.distance + }; +} + +function alignmentDistance(align: ArrowLabel['align'], totalLength: number): number { + if (align === 'start') return 0; + if (align === 'end') return totalLength; + return totalLength / 2; +} + +function clamp(value: number, minimum: number, maximum: number): number { + if (!Number.isFinite(value)) return value < 0 ? minimum : maximum; + return Math.max(minimum, Math.min(maximum, value)); +} + +function add(left: Vec2, right: Vec2): Vec2 { + return { x: left.x + right.x, y: left.y + right.y }; +} + +function subtract(left: Vec2, right: Vec2): Vec2 { + return { x: left.x - right.x, y: left.y - right.y }; +} + +function scale(point: Vec2, factor: number): Vec2 { + return { x: point.x * factor, y: point.y * factor }; +} + +function rotate(point: Vec2, angle: number): Vec2 { + const cosine = Math.cos(angle); + const sine = Math.sin(angle); + return { x: point.x * cosine - point.y * sine, y: point.x * sine + point.y * cosine }; +} diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts index 50f8818..2f32fde 100644 --- a/packages/core/src/export.ts +++ b/packages/core/src/export.ts @@ -1,13 +1,12 @@ import { arrowGeometryForShape, - arrowPathForShape, localToWorld, pathGeometryBounds, shapeBounds, } from "./geom"; +import { arrowHeadGeometry, arrowLabelPlacement, arrowShaftGeometry } from "./arrow-geometry"; import type { Box2 } from "./math"; import { Box2 as Box2Ops } from "./math"; -import { pathLength, pointAtPathDistance } from "./path-metrics"; import type { ArrowShape, ContainerShape, EllipseShape, LineShape, MarkdownShape, PathGeometry, PathShape, RectShape, ShapeRecord, TextShape } from "./model"; import type { EditorState } from "./reactivity"; import { getSelectedShapes, getShapesOnCurrentPage } from "./reactivity"; @@ -244,31 +243,35 @@ function lineToSVG(shape: LineShape, transform: string): string { function arrowToSVG(shape: ArrowShape, transform: string, state: EditorState): string { const geometry = arrowGeometryForShape(state, shape); - const points = arrowPathForShape(state, shape); - if (!geometry || points.length < 2) return ""; + if (!geometry) return ""; const stroke = escapeXML(shape.props.style.stroke); const width = svgNumber(shape.props.style.width); - const last = points.at(-1)!; - const previous = points.at(-2)!; - const angle = Math.atan2(last.y - previous.y, last.x - previous.x); - const head = (at: { x: number; y: number }, direction: number) => { - const length = 15; - const spread = Math.PI / 6; - const left = { x: at.x - length * Math.cos(direction - spread), y: at.y - length * Math.sin(direction - spread) }; - const right = { x: at.x - length * Math.cos(direction + spread), y: at.y - length * Math.sin(direction + spread) }; - return ``; + const shaft = arrowShaftGeometry(geometry.path, shape.props.style); + const elements = pathGeometryIsPolyline(shaft) + ? pathGeometryToLines(shaft, stroke, width) + : [``]; + + const head = (atStart: boolean) => { + const resolved = arrowHeadGeometry(geometry.path, atStart); + if (!resolved) return; + const headStyle = atStart ? shape.props.style.headStartStyle : shape.props.style.headEndStyle; + const points = `M ${svgNumber(resolved.tip.x)} ${svgNumber(resolved.tip.y)} L ${svgNumber(resolved.left.x)} ${svgNumber(resolved.left.y)} L ${svgNumber(resolved.right.x)} ${svgNumber(resolved.right.y)}`; + elements.push( + headStyle === "triangle" + ? `` + : `` + ); }; - const elements = pathGeometryIsPolyline(geometry.path) - ? pathGeometryToLines(geometry.path, stroke, width) - : [``]; - if (shape.props.style.headEnd !== false) elements.push(head(last, angle)); - if (shape.props.style.headStart) elements.push(head(points[0], angle + Math.PI)); + + if (shape.props.style.headEnd !== false) head(false); + if (shape.props.style.headStart) head(true); + const label = shape.props.label; if (label?.text) { - const length = pathLength(geometry.path); - const distance = label.align === "start" ? label.offset : label.align === "end" ? length - label.offset : length / 2 + label.offset; - const at = pointAtPathDistance(geometry.path, Math.max(0, Math.min(length, distance)))?.point; - if (at) elements.push(`${escapeXML(label.text)}`); + const placement = arrowLabelPlacement(geometry.path, label); + if (placement) { + elements.push(`${escapeXML(label.text)}`); + } } return `${elements.join("")}`; } @@ -409,12 +412,33 @@ function exportBounds(state: EditorState, shape: ShapeRecord): Box2 { const geometry = arrowGeometryForShape(state, shape); if (!geometry) return shapeBounds(shape); const bounds = pathGeometryBounds(geometry.path); - return Box2Ops.fromPoints([ - localToWorld(shape, bounds.min), - localToWorld(shape, { x: bounds.max.x, y: bounds.min.y }), - localToWorld(shape, bounds.max), - localToWorld(shape, { x: bounds.min.x, y: bounds.max.y }) - ]); + const points = [ + bounds.min, + { x: bounds.max.x, y: bounds.min.y }, + bounds.max, + { x: bounds.min.x, y: bounds.max.y } + ]; + if (shape.props.style.headEnd !== false) { + const head = arrowHeadGeometry(geometry.path, false); + if (head) points.push(head.tip, head.left, head.right); + } + if (shape.props.style.headStart) { + const head = arrowHeadGeometry(geometry.path, true); + if (head) points.push(head.tip, head.left, head.right); + } + if (shape.props.label?.text) { + const placement = arrowLabelPlacement(geometry.path, shape.props.label); + if (placement) { + const halfWidth = (shape.props.label.text.length * 7 + 8) / 2; + points.push( + { x: placement.point.x - halfWidth, y: placement.point.y - 9 }, + { x: placement.point.x + halfWidth, y: placement.point.y - 9 }, + { x: placement.point.x - halfWidth, y: placement.point.y + 9 }, + { x: placement.point.x + halfWidth, y: placement.point.y + 9 } + ); + } + } + return Box2Ops.fromPoints(points.map((point) => localToWorld(shape, point))); } function getExportSelection(state: EditorState): ShapeRecord[] { diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index 7da4f12..8b7e7f8 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -1,6 +1,7 @@ import getStroke from 'perfect-freehand'; import type { Box2, Vec2 } from './math'; import { Box2 as Box2Ops, Mat3, Vec2 as Vec2Ops } from './math'; +import { arrowHeadGeometry, arrowLabelPlacement } from './arrow-geometry'; import type { ArrowShape, BindingRecord, @@ -66,13 +67,47 @@ export function localShapeBounds(shape: ShapeRecord): Box2 { case 'line': return Box2Ops.fromPoints([shape.props.a, shape.props.b]); case 'arrow': { + const style = shape.props.style; const routing = shape.props.routing; - if (routing?.kind === 'curved' && !routing.automatic) { - return pathGeometryBounds(arrowPathGeometryFromProps(shape.props)); + const geometry = + routing?.kind === 'curved' && !routing.automatic + ? arrowPathGeometryFromProps(shape.props) + : (shape.resolvedGeometry?.path ?? arrowPathGeometryFromProps(shape.props)); + let bounds = pathGeometryBounds(geometry); + const addPoint = (point: Vec2) => { + bounds = Box2Ops.expandToPoint(bounds, point); + }; + if (style?.headEnd !== false) { + const head = arrowHeadGeometry(geometry, false); + if (head) { + addPoint(head.tip); + addPoint(head.left); + addPoint(head.right); + } + } + if (style?.headStart) { + const head = arrowHeadGeometry(geometry, true); + if (head) { + addPoint(head.tip); + addPoint(head.left); + addPoint(head.right); + } } - return shape.resolvedGeometry - ? pathGeometryBounds(shape.resolvedGeometry.path) - : pathGeometryBounds(arrowPathGeometryFromProps(shape.props)); + if (shape.props.label?.text) { + const placement = arrowLabelPlacement(geometry, shape.props.label); + if (placement) { + const halfWidth = (shape.props.label.text.length * 7 + 8) / 2; + for (const point of [ + { x: placement.point.x - halfWidth, y: placement.point.y - 9 }, + { x: placement.point.x + halfWidth, y: placement.point.y - 9 }, + { x: placement.point.x - halfWidth, y: placement.point.y + 9 }, + { x: placement.point.x + halfWidth, y: placement.point.y + 9 } + ]) { + addPoint(point); + } + } + } + return bounds; } case 'text': return Box2Ops.create(0, 0, shape.props.w ?? shape.props.fontSize * 10, shape.props.fontSize * 1.2); @@ -598,11 +633,39 @@ function hitTestShape(state: EditorState, shape: ShapeRecord, worldPoint: Vec2, case 'line': return pointNearLine(worldPoint, shape, tolerance); case 'arrow': { + const style = shape.props.style; const geometry = arrowGeometryForShape(state, shape); if (!geometry) return false; const worldGeometry = transformPathGeometry(geometry.path, shapeTransform(shape)); const nearest = nearestPointOnPath(worldGeometry, worldPoint, Math.max(0.01, tolerance / 4)); - return nearest !== null && nearest.distanceToPath <= tolerance + shape.props.style.width / 2; + if (nearest !== null && nearest.distanceToPath <= tolerance + (style?.width ?? 2) / 2) return true; + + const localPoint = worldToLocal(worldPoint, shape); + const headHit = (atStart: boolean) => { + const head = arrowHeadGeometry(geometry.path, atStart); + if (!head) return false; + if ((atStart ? style?.headStart : style?.headEnd !== false) === false) return false; + if ( + pointNearSegment(localPoint, head.tip, head.left, tolerance) || + pointNearSegment(localPoint, head.tip, head.right, tolerance) + ) + return true; + const headStyle = atStart ? style?.headStartStyle : style?.headEndStyle; + return headStyle === 'triangle' && pointInPolygon(localPoint, [head.tip, head.left, head.right]); + }; + if (headHit(true) || headHit(false)) return true; + + if (shape.props.label?.text) { + const placement = arrowLabelPlacement(geometry.path, shape.props.label); + if (placement) { + const halfWidth = (shape.props.label.text.length * 7 + 8) / 2 + tolerance; + return ( + Math.abs(localPoint.x - placement.point.x) <= halfWidth && + Math.abs(localPoint.y - placement.point.y) <= 9 + tolerance + ); + } + } + return false; } case 'text': return pointInText(worldPoint, shape); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 84077bd..a873f51 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export * from './actions'; +export * from './arrow-geometry'; export * from './camera'; export * from './cards'; export * from './cursor'; diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index ead7840..43fe4f5 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -128,7 +128,15 @@ export type ArrowEndpoint = { kind: 'free' | 'bound'; bindingId?: string }; /** * Arrow style configuration */ -export type ArrowStyle = { stroke: string; width: number; headStart?: boolean; headEnd?: boolean; dash?: number[] }; +export type ArrowStyle = { + stroke: string; + width: number; + headStart?: boolean; + headEnd?: boolean; + headStartStyle?: 'open' | 'triangle'; + headEndStyle?: 'open' | 'triangle'; + dash?: number[]; +}; /** * Arrow routing configuration @@ -144,9 +152,13 @@ export type ArrowRouting = { }; /** - * Arrow label configuration + * Arrow label configuration. + * + * `distance` is an optional absolute distance along the resolved path. When it + * is omitted, `align` chooses the start, center, or end anchor. `offset` is a + * signed displacement along the path's local normal. */ -export type ArrowLabel = { text: string; align: 'center' | 'start' | 'end'; offset: number }; +export type ArrowLabel = { text: string; align: 'center' | 'start' | 'end'; offset: number; distance?: number }; /** * Arrow properties using modern format @@ -754,6 +766,15 @@ export function validateDoc(document: Document): ValidationResult { errors.push(`Arrow shape '${shapeId}' missing style`); } else if (props.style.width < 0) { errors.push(`Arrow shape '${shapeId}' has negative width in style`); + } else { + for (const [name, value] of [ + ['start head style', props.style.headStartStyle], + ['end head style', props.style.headEndStyle] + ] as const) { + if (value !== undefined && value !== 'open' && value !== 'triangle') { + errors.push(`Arrow shape '${shapeId}' has invalid ${name}`); + } + } } if (props.routing) { if (props.routing.bend !== undefined && !Number.isFinite(props.routing.bend)) { @@ -769,6 +790,12 @@ export function validateDoc(document: Document): ValidationResult { if (!['center', 'start', 'end'].includes(props.label.align)) { errors.push(`Arrow shape '${shapeId}' has invalid label alignment`); } + if (!Number.isFinite(props.label.offset)) { + errors.push(`Arrow shape '${shapeId}' has non-finite label offset`); + } + if (props.label.distance !== undefined && !Number.isFinite(props.label.distance)) { + errors.push(`Arrow shape '${shapeId}' has non-finite label distance`); + } } break; diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts index 3b94beb..09a8daf 100644 --- a/packages/core/src/tools/select.ts +++ b/packages/core/src/tools/select.ts @@ -1,4 +1,5 @@ import type { Action } from '../actions'; +import { arrowLabelPlacement } from '../arrow-geometry'; import { arrowBendForPointer, arrowBendHandleForShape, @@ -13,7 +14,7 @@ import { shapeTransform, worldToLocal } from '../geom'; -import { nearestPointOnPath, pathLength, pointAtPathDistance, transformPathGeometry } from '../path-metrics'; +import { nearestPointOnPath } from '../path-metrics'; import { Box2, clamp, Mat3, type Vec2, Vec2 as Vec2Ops } from '../math'; import { duplicateAndConnectSelection } from '../selection'; import { BindingRecord, createId, ShapeRecord } from '../model'; @@ -858,23 +859,9 @@ export class SelectTool implements Tool { handles.push({ id: 'line-end', position: resolved.b }); if (shape.props.label) { - const polylineLength = pathLength(arrowGeometry.path); - const align = shape.props.label.align ?? 'center'; - const offset = shape.props.label.offset ?? 0; - - let distance: number; - if (align === 'center') { - distance = polylineLength / 2 + offset; - } else if (align === 'start') { - distance = offset; - } else { - distance = polylineLength - offset; - } - - distance = Math.max(0, Math.min(distance, polylineLength)); - const labelPos = pointAtPathDistance(arrowGeometry.path, distance)?.point; - if (labelPos) { - const worldLabelPos = localToWorld(shape, labelPos); + const placement = arrowLabelPlacement(arrowGeometry.path, shape.props.label); + if (placement) { + const worldLabelPos = localToWorld(shape, placement.point); handles.push({ id: 'arrow-label', position: worldLabelPos }); } } @@ -998,24 +985,20 @@ export class SelectTool implements Tool { const geometry = arrowGeometryForShape(state, initial); if (!geometry) return null; - const worldGeometry = transformPathGeometry(geometry.path, shapeTransform(initial)); - const nearest = nearestPointOnPath(worldGeometry, pointer); + const localPointer = worldToLocal(pointer, initial); + const nearest = nearestPointOnPath(geometry.path, localPointer); if (!nearest) return null; - const polylineLength = pathLength(geometry.path); - const closestDistance = nearest.distance; + const normal = { x: -nearest.tangent.y, y: nearest.tangent.x }; + const delta = { x: localPointer.x - nearest.point.x, y: localPointer.y - nearest.point.y }; + const normalOffset = delta.x * normal.x + delta.y * normal.y; - const align = initial.props.label.align ?? 'center'; - let newOffset: number; - - if (align === 'center') { - newOffset = closestDistance - polylineLength / 2; - } else if (align === 'start') { - newOffset = closestDistance; - } else { - newOffset = polylineLength - closestDistance; - } - - return { ...initial, props: { ...initial.props, label: { ...initial.props.label, offset: newOffset } } }; + return { + ...initial, + props: { + ...initial.props, + label: { ...initial.props.label, distance: nearest.distance, offset: normalOffset } + } + }; } private resizeLineShape( diff --git a/packages/core/tests/arrow-geometry.test.ts b/packages/core/tests/arrow-geometry.test.ts index e2b6ff7..0992478 100644 --- a/packages/core/tests/arrow-geometry.test.ts +++ b/packages/core/tests/arrow-geometry.test.ts @@ -1,80 +1,75 @@ import { describe, expect, it } from 'vitest'; -import fixture from '../../../fixtures/native/connector-geometry.json'; import { - arrowBendForPointer, - arrowBendHandleForShape, - arrowGeometryForShape, - EditorState, - PageRecord, - ShapeRecord + arrowHeadGeometry, + arrowLabelPlacement, + arrowShaftGeometry, + pathLength, + type ArrowStyle, + type PathGeometry } from '../src'; -describe('shared connector geometry fixtures', () => { - it('resolves the same native path shape used by interactive geometry', () => { - for (const testCase of fixture.cases) { - const page = PageRecord.create('Connector fixture', `page:${testCase.name}`); - const arrow = ShapeRecord.createArrow( - page.id, - 0, - 0, - { - points: testCase.points, - start: { kind: 'free' }, - end: { kind: 'free' }, - style: { stroke: '#000000', width: 2 }, - routing: - testCase.routing === 'straight' - ? undefined - : { - kind: testCase.routing as 'curved' | 'orthogonal', - ...(testCase.bend === undefined ? {} : { bend: testCase.bend }), - ...(testCase.cornerRadius === undefined - ? {} - : { cornerRadius: testCase.cornerRadius }) - } - }, - `shape:${testCase.name}` - ); - const state = { - ...EditorState.create(), - doc: { - ...EditorState.create().doc, - pages: { [page.id]: { ...page, shapeIds: [arrow.id] } }, - shapes: { [arrow.id]: arrow } - }, - ui: { ...EditorState.create().ui, currentPageId: page.id } - }; - const geometry = arrowGeometryForShape(state, arrow); - expect(geometry).not.toBeNull(); - expect(geometry).toEqual(testCase.expected); +const quadratic: PathGeometry = { + subpaths: [ + { + segments: [ + { type: 'move', to: { x: 0, y: 0 } }, + { type: 'quadratic', control: { x: 0, y: 100 }, to: { x: 100, y: 100 } } + ], + closed: false } - }); + ], + fill_rule: 'nonzero' +}; - it('uses a signed bend with a quadratic zero state at the chord midpoint', () => { - const page = PageRecord.create('Bend fixture', 'page:bend'); - const arrow = ShapeRecord.createArrow(page.id, 0, 0, { - points: [ - { x: 0, y: 0 }, - { x: 100, y: 0 } +const diagonal: PathGeometry = { + subpaths: [ + { + segments: [ + { type: 'move', to: { x: 0, y: 0 } }, + { type: 'line', to: { x: 100, y: 100 } } ], - start: { kind: 'free' }, - end: { kind: 'free' }, - style: { stroke: '#000', width: 2 }, - routing: { kind: 'curved', bend: 0 } - }); - const state = { - ...EditorState.create(), - doc: { - ...EditorState.create().doc, - pages: { [page.id]: { ...page, shapeIds: [arrow.id] } }, - shapes: { [arrow.id]: arrow } - }, - ui: { ...EditorState.create().ui, currentPageId: page.id } - }; + closed: false + } + ], + fill_rule: 'nonzero' +}; + +describe('path-aware arrow geometry', () => { + it('orients heads from curve endpoint tangents', () => { + const start = arrowHeadGeometry(quadratic, true); + const end = arrowHeadGeometry(quadratic, false); + + expect(start?.tangent.y).toBeGreaterThan(0.99); + expect(start?.direction.y).toBeLessThan(-0.99); + expect(end?.tangent.x).toBeGreaterThan(0.99); + expect(end?.direction.x).toBeGreaterThan(0.99); + }); + + it('places labels by path distance and local normal offset', () => { + const placement = arrowLabelPlacement(diagonal, { text: 'route', align: 'center', offset: 10 }); + + expect(placement?.distance).toBeCloseTo(pathLength(diagonal) / 2); + expect(placement?.point.x).toBeCloseTo(50 - 10 / Math.sqrt(2)); + expect(placement?.point.y).toBeCloseTo(50 + 10 / Math.sqrt(2)); + }); + + it('supports an explicit along-path label distance independently of its normal offset', () => { + const placement = arrowLabelPlacement(diagonal, { text: 'route', align: 'center', offset: -8, distance: 25 }); + + expect(placement?.distance).toBe(25); + expect(placement?.point.x).toBeCloseTo((25 + 8) / Math.sqrt(2)); + expect(placement?.point.y).toBeCloseTo((25 - 8) / Math.sqrt(2)); + }); + + it('trims the shaft for filled triangle heads without changing the route', () => { + const style: ArrowStyle = { stroke: '#000', width: 2, headEndStyle: 'triangle' }; + const shaft = arrowShaftGeometry(diagonal, style); + const first = shaft.subpaths[0]?.segments[0]; - const handle = arrowBendHandleForShape(state, arrow); - expect(handle?.position).toEqual({ x: 50, y: 0 }); - expect(arrowBendForPointer(state, arrow, { x: 50, y: 20 })).toBe(20); - expect(arrowBendForPointer(state, arrow, { x: 50, y: -20 })).toBe(-20); + expect(first?.type).toBe('move'); + if (first?.type === 'move') expect(first.to.x).toBe(0); + const last = shaft.subpaths[0]?.segments.at(-1); + expect(last?.type).toBe('line'); + if (last?.type === 'line') expect(last.to.x).toBeLessThan(100); }); }); diff --git a/packages/editor/src/renderer.ts b/packages/editor/src/renderer.ts index 9d67e30..cb90cee 100644 --- a/packages/editor/src/renderer.ts +++ b/packages/editor/src/renderer.ts @@ -1,4 +1,5 @@ import type { + ArrowLabel, ArrowShape, BindingIndex, BindingRecord, @@ -22,9 +23,10 @@ import type { import { arrowBendHandleForShape, arrowGeometryForShape, + arrowHeadGeometry, + arrowLabelPlacement, arrowPathForShape, - pathLength, - pointAtPathDistance, + arrowShaftGeometry, getLayersOnCurrentPage, getStrokeOutline, getShapesOnCurrentPage, @@ -841,68 +843,42 @@ function drawArrow( const shapeAlpha = context.globalAlpha; const geometry = arrowGeometryForShape(state, shape, bindingsBySource); - const points = geometry ? arrowPathForShape(state, shape, bindingsBySource) : []; - if (!geometry || points.length < 2) return; + if (!geometry) return; - drawNativePath(context, geometry.path); + const shaft = arrowShaftGeometry(geometry.path, style); + drawNativePath(context, shaft); context.strokeStyle = style.stroke; context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); context.lineWidth = style.width; - if (style.dash) { - context.setLineDash(style.dash); - } + if (style.dash) context.setLineDash(style.dash); context.stroke(); - if (style.dash) { - context.setLineDash([]); - } + if (style.dash) context.setLineDash([]); - const lastSegment = { from: points[points.length - 2], to: points[points.length - 1] }; - const angle = Math.atan2(lastSegment.to.y - lastSegment.from.y, lastSegment.to.x - lastSegment.from.x); - const arrowLength = 15; - const arrowAngle = Math.PI / 6; - - const drawHead = (at: Vec2, reverse: boolean) => { - const dir = reverse ? angle + Math.PI : angle; + const drawHead = (atStart: boolean) => { + const head = arrowHeadGeometry(geometry.path, atStart); + if (!head) return; + const headStyle = atStart ? style.headStartStyle : style.headEndStyle; context.beginPath(); - context.moveTo(at.x, at.y); - context.lineTo( - at.x - arrowLength * Math.cos(dir - arrowAngle), - at.y - arrowLength * Math.sin(dir - arrowAngle) - ); - context.moveTo(at.x, at.y); - context.lineTo( - at.x - arrowLength * Math.cos(dir + arrowAngle), - at.y - arrowLength * Math.sin(dir + arrowAngle) - ); + context.moveTo(head.tip.x, head.tip.y); + if (headStyle === 'triangle') { + context.lineTo(head.left.x, head.left.y); + context.lineTo(head.right.x, head.right.y); + context.closePath(); + context.fillStyle = style.stroke; + context.fill(); + } else { + context.lineTo(head.left.x, head.left.y); + context.moveTo(head.tip.x, head.tip.y); + context.lineTo(head.right.x, head.right.y); + } context.strokeStyle = style.stroke; context.lineWidth = style.width; context.stroke(); }; - if (style.headEnd !== false) { - drawHead(lastSegment.to, false); - } - - if (style.headStart) { - const firstSegment = { from: points[0], to: points[1] }; - const startAngle = Math.atan2(firstSegment.to.y - firstSegment.from.y, firstSegment.to.x - firstSegment.from.x); - const startDir = startAngle + Math.PI; - context.beginPath(); - context.moveTo(firstSegment.from.x, firstSegment.from.y); - context.lineTo( - firstSegment.from.x - arrowLength * Math.cos(startDir - arrowAngle), - firstSegment.from.y - arrowLength * Math.sin(startDir - arrowAngle) - ); - context.moveTo(firstSegment.from.x, firstSegment.from.y); - context.lineTo( - firstSegment.from.x - arrowLength * Math.cos(startDir + arrowAngle), - firstSegment.from.y - arrowLength * Math.sin(startDir + arrowAngle) - ); - context.strokeStyle = style.stroke; - context.lineWidth = style.width; - context.stroke(); - } + if (style.headEnd !== false) drawHead(false); + if (style.headStart) drawHead(true); const label = shape.props.label; if (label) { @@ -918,43 +894,32 @@ function drawArrowLabel( context: CanvasRenderingContext2D, state: EditorState, geometry: PathGeometry, - label: { text: string; align: string; offset: number } + label: ArrowLabel ) { if (!label.text) return; - let labelPos: Vec2; - const totalLength = pathLength(geometry); - let targetDist: number; - - if (label.align === 'start') { - targetDist = label.offset; - } else if (label.align === 'end') { - targetDist = totalLength - label.offset; - } else { - targetDist = totalLength / 2 + label.offset; - } - - labelPos = pointAtPathDistance(geometry, targetDist)?.point ?? geometry.subpaths[0]?.segments[0]?.to; - if (!labelPos) return; + const placement = arrowLabelPlacement(geometry, label); + if (!placement) return; + const labelPos = placement.point; context.save(); context.font = '14px sans-serif'; context.fillStyle = '#000'; context.textAlign = 'center'; - context.textBaseline = 'bottom'; + context.textBaseline = 'middle'; const metrics = context.measureText(label.text); const padding = 4; const bgWidth = metrics.width + padding * 2; const bgHeight = 18; context.fillStyle = 'rgba(255, 255, 255, 0.9)'; - context.fillRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight - 5, bgWidth, bgHeight); + context.fillRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight / 2, bgWidth, bgHeight); context.strokeStyle = '#ccc'; context.lineWidth = 1 / state.camera.zoom; - context.strokeRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight - 5, bgWidth, bgHeight); + context.strokeRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight / 2, bgWidth, bgHeight); context.fillStyle = '#000'; - context.fillText(label.text, labelPos.x, labelPos.y - 5); + context.fillText(label.text, labelPos.x, labelPos.y); context.restore(); } @@ -1664,24 +1629,11 @@ function getHandlesForShape(state: EditorState, shape: ShapeRecord, bindingsBySo handles.push({ id: 'line-end', position: resolved.b }); if (shape.props.label) { - const polylineLength = pathLength(arrowGeometry.path); - const align = shape.props.label.align ?? 'center'; - const offset = shape.props.label.offset ?? 0; - - let distance: number; - if (align === 'center') { - distance = polylineLength / 2 + offset; - } else if (align === 'start') { - distance = offset; - } else { - distance = polylineLength - offset; + const placement = arrowLabelPlacement(arrowGeometry.path, shape.props.label); + if (placement) { + const worldLabelPos = localToWorld(shape, placement.point); + handles.push({ id: 'arrow-label', position: worldLabelPos }); } - - distance = Math.max(0, Math.min(distance, polylineLength)); - const labelPos = pointAtPathDistance(arrowGeometry.path, distance)?.point; - if (!labelPos) return handles; - const worldLabelPos = localToWorld(shape, labelPos); - handles.push({ id: 'arrow-label', position: worldLabelPos }); } } return handles; diff --git a/packages/ui/src/lib/editor/canvas/controllers/arrowlabel-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/arrowlabel-controller.svelte.ts index 30f55e9..3c607f2 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/arrowlabel-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/arrowlabel-controller.svelte.ts @@ -1,10 +1,10 @@ import { type ArrowShape, arrowGeometryForShape, + arrowLabelPlacement, Camera, EditorState, - pathLength, - pointAtPathDistance, + localToWorld, SnapshotCommand, type Store, type Viewport @@ -42,22 +42,11 @@ export class ArrowLabelEditorController { const geometry = arrowGeometryForShape(state, arrow); if (!geometry) return null; - const polylineLength = pathLength(geometry.path); - const align = arrow.props.label?.align ?? 'center'; - const offset = arrow.props.label?.offset ?? 0; - - let distance: number; - if (align === 'center') { - distance = polylineLength / 2 + offset; - } else if (align === 'start') { - distance = offset; - } else { - distance = polylineLength - offset; - } - - distance = Math.max(0, Math.min(distance, polylineLength)); - const labelPos = pointAtPathDistance(geometry.path, distance)?.point; - if (!labelPos) return null; + const label = arrow.props.label; + if (!label) return null; + const placement = arrowLabelPlacement(geometry.path, label); + if (!placement) return null; + const labelPos = localToWorld(arrow, placement.point); const viewport = this.getViewport(); const screenPos = Camera.worldToScreen(state.camera, labelPos, viewport); @@ -134,7 +123,10 @@ export class ArrowLabelEditorController { ? { text: trimmedValue, align: arrow.props.label?.align ?? 'center', - offset: arrow.props.label?.offset ?? 0 + offset: arrow.props.label?.offset ?? 0, + ...(arrow.props.label?.distance === undefined + ? {} + : { distance: arrow.props.label.distance }) } : undefined } diff --git a/packages/ui/src/lib/editor/components/ArrowPopover.svelte b/packages/ui/src/lib/editor/components/ArrowPopover.svelte index dc5fe2d..5760558 100644 --- a/packages/ui/src/lib/editor/components/ArrowPopover.svelte +++ b/packages/ui/src/lib/editor/components/ArrowPopover.svelte @@ -209,7 +209,10 @@ ? { text, align: arrow.props.label?.align ?? 'center', - offset: arrow.props.label?.offset ?? 0 + offset: arrow.props.label?.offset ?? 0, + ...(arrow.props.label?.distance === undefined + ? {} + : { distance: arrow.props.label.distance }) } : undefined } -- 2.51.2