From 90dc18c54fc35b45407822014c6707039ae1727c Mon Sep 17 00:00:00 2001 From: Marcel Friedrichs Date: Mon, 6 Jul 2026 13:22:18 +0200 Subject: [PATCH] fix(graph): edge endpoints follow symbol shape. close #10563 --- src/chart/graph/adjustEdge.ts | 147 ++++++-- src/chart/graph/nodeShapeHelper.ts | 345 ++++++++++++++++++ test/graph-edge-shape.html | 164 +++++++++ .../spec/series/graph/nodeShapeHelper.test.ts | 116 ++++++ 4 files changed, 750 insertions(+), 22 deletions(-) create mode 100644 src/chart/graph/nodeShapeHelper.ts create mode 100644 test/graph-edge-shape.html create mode 100644 test/ut/spec/series/graph/nodeShapeHelper.test.ts diff --git a/src/chart/graph/adjustEdge.ts b/src/chart/graph/adjustEdge.ts index e64a817715..1a3e815869 100644 --- a/src/chart/graph/adjustEdge.ts +++ b/src/chart/graph/adjustEdge.ts @@ -19,15 +19,62 @@ import * as curveTool from 'zrender/src/core/curve'; import * as vec2 from 'zrender/src/core/vector'; -import {getSymbolSize} from './graphHelper'; -import Graph from '../../data/Graph'; +import { normalizeSymbolSize, normalizeSymbolOffset } from '../../util/symbol'; +import { + getSymbolBoundaryDistance, + getSymbolContainChecker, + symbolRotateToRad +} from './nodeShapeHelper'; +import Graph, { GraphNode } from '../../data/Graph'; -const v1: number[] = []; -const v2: number[] = []; -const v3: number[] = []; const quadraticAt = curveTool.quadraticAt; const v2DistSquare = vec2.distSquare; const mathAbs = Math.abs; + +// Curve sampling resolution used to locate where a quadratic edge crosses a node outline. +const CURVE_SAMPLES = 20; +const CURVE_BISECT_ITERATIONS = 12; + +interface NodeShapeParams { + symbolType: string + // Half already folded into scale by nodeShapeHelper; these are full sizes/offsets in px, + // pre-multiplied by the node global scale. + size: number[] + rotateRad: number + keepAspect: boolean + offset: number[] +} + +/** + * Collect the node's symbol geometry (already scaled by the graph global scale) needed to probe + * its outline. All of these visuals are populated by `visual/symbol` before the view renders. + */ +function getNodeShapeParams(node: GraphNode, globalScale: number): NodeShapeParams { + const rawSize = normalizeSymbolSize(node.getVisual('symbolSize')); + const rawOffset = normalizeSymbolOffset(node.getVisual('symbolOffset'), rawSize); + return { + symbolType: node.getVisual('symbol') || 'circle', + size: [rawSize[0] * globalScale, rawSize[1] * globalScale], + rotateRad: symbolRotateToRad(node.getVisual('symbolRotate')), + keepAspect: node.getVisual('symbolKeepAspect'), + offset: rawOffset ? [rawOffset[0] * globalScale, rawOffset[1] * globalScale] : null + }; +} + +function getNodeBoundaryDistance( + node: GraphNode, globalScale: number, dirX: number, dirY: number +): number { + const p = getNodeShapeParams(node, globalScale); + return getSymbolBoundaryDistance(p.symbolType, p.size, p.rotateRad, p.keepAspect, p.offset, dirX, dirY); +} + +const v1: number[] = []; +const v2: number[] = []; +const v3: number[] = []; +/** + * Legacy circle intersection, kept as a fallback for the rare cases where the generic outline + * search cannot find a crossing (e.g. an unfillable node symbol or a degenerate offset). + */ function intersectCurveCircle( curvePoints: number[][], center: number[], @@ -94,14 +141,75 @@ function intersectCurveCircle( return t; } -// Adjust edge to avoid -export default function adjustEdge(graph: Graph, scale: number) { +/** + * Find the parameter `t` where the quadratic `curvePoints` crosses `node`'s transformed outline. + * The node sits at one end of the curve (`fromStart` ? `t = 0` : `t = 1`), which is inside the + * outline; we walk toward the other end to find the exit crossing, then refine by bisection. + * Falls back to the circle intersection if no clean crossing is found. + */ +function intersectCurveNode( + curvePoints: number[][], + node: GraphNode, + globalScale: number, + fromStart: boolean +): number { + const p0 = curvePoints[0]; + const p1 = curvePoints[1]; + const p2 = curvePoints[2]; + const p = getNodeShapeParams(node, globalScale); + + const cx = fromStart ? p0[0] : p2[0]; + const cy = fromStart ? p0[1] : p2[1]; + const contain = getSymbolContainChecker(p.symbolType, p.size, p.rotateRad, p.keepAspect, p.offset); + + function isInside(t: number): boolean { + const x = quadraticAt(p0[0], p1[0], p2[0], t); + const y = quadraticAt(p0[1], p1[1], p2[1], t); + return contain(x - cx, y - cy); + } + + // Bracket the outline crossing between an inside `t` and an adjacent outside `t`. + let inT = fromStart ? 0 : 1; + let outT = -1; + if (isInside(inT)) { + for (let i = 1; i <= CURVE_SAMPLES; i++) { + const t = fromStart ? i / CURVE_SAMPLES : 1 - i / CURVE_SAMPLES; + if (!isInside(t)) { + outT = t; + break; + } + inT = t; + } + } + + if (outT < 0) { + // Endpoint not inside its own outline, or the whole curve lies inside it. + const radius = Math.max(p.size[0], p.size[1]) / 2; + return intersectCurveCircle(curvePoints, [cx, cy], radius); + } + + let lo = inT; + let hi = outT; + for (let i = 0; i < CURVE_BISECT_ITERATIONS; i++) { + const mid = (lo + hi) / 2; + if (isInside(mid)) { + lo = mid; + } + else { + hi = mid; + } + } + return (lo + hi) / 2; +} + +// Adjust edge endpoints so an edge's end-symbol sits flush against the node outline instead of +// being hidden underneath it, accounting for the node symbol's shape and transform. +export default function adjustEdge(graph: Graph, globalScale: number) { const tmp0: number[] = []; const quadraticSubdivide = curveTool.quadraticSubdivide; const pts: number[][] = [[], [], []]; const pts2: number[][] = [[], []]; const v: number[] = []; - scale /= 2; graph.eachEdge(function (edge, idx) { const linePoints = edge.getLayout(); @@ -124,9 +232,7 @@ export default function adjustEdge(graph: Graph, scale: number) { vec2.copy(pts[1], originalPoints[2]); vec2.copy(pts[2], originalPoints[1]); if (fromSymbol && fromSymbol !== 'none') { - const symbolSize = getSymbolSize(edge.node1); - - const t = intersectCurveCircle(pts, originalPoints[0], symbolSize * scale); + const t = intersectCurveNode(pts, edge.node1, globalScale, true); // Subdivide and get the second quadraticSubdivide(pts[0][0], pts[1][0], pts[2][0], t, tmp0); pts[0][0] = tmp0[3]; @@ -136,9 +242,7 @@ export default function adjustEdge(graph: Graph, scale: number) { pts[1][1] = tmp0[4]; } if (toSymbol && toSymbol !== 'none') { - const symbolSize = getSymbolSize(edge.node2); - - const t = intersectCurveCircle(pts, originalPoints[1], symbolSize * scale); + const t = intersectCurveNode(pts, edge.node2, globalScale, false); // Subdivide and get the first quadraticSubdivide(pts[0][0], pts[1][0], pts[2][0], t, tmp0); pts[1][0] = tmp0[1]; @@ -160,18 +264,17 @@ export default function adjustEdge(graph: Graph, scale: number) { vec2.sub(v, pts2[1], pts2[0]); vec2.normalize(v, v); if (fromSymbol && fromSymbol !== 'none') { - - const symbolSize = getSymbolSize(edge.node1); - - vec2.scaleAndAdd(pts2[0], pts2[0], v, symbolSize * scale); + // Approach direction at node1 points toward node2. + const dist = getNodeBoundaryDistance(edge.node1, globalScale, v[0], v[1]); + vec2.scaleAndAdd(pts2[0], pts2[0], v, dist); } if (toSymbol && toSymbol !== 'none') { - const symbolSize = getSymbolSize(edge.node2); - - vec2.scaleAndAdd(pts2[1], pts2[1], v, -symbolSize * scale); + // Approach direction at node2 points toward node1. + const dist = getNodeBoundaryDistance(edge.node2, globalScale, -v[0], -v[1]); + vec2.scaleAndAdd(pts2[1], pts2[1], v, -dist); } vec2.copy(linePoints[0], pts2[0]); vec2.copy(linePoints[1], pts2[1]); } }); -} \ No newline at end of file +} diff --git a/src/chart/graph/nodeShapeHelper.ts b/src/chart/graph/nodeShapeHelper.ts new file mode 100644 index 0000000000..8c140023ee --- /dev/null +++ b/src/chart/graph/nodeShapeHelper.ts @@ -0,0 +1,345 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +/** + * Shape-aware geometry helpers for graph edge endpoints. + * + * The graph series offsets an edge's end-symbol away from the node center so it is not hidden + * under the node. Historically that offset was a plain circle radius (`symbolSize / 2`), which is + * wrong for any non-circular / non-uniformly-scaled / rotated / SVG node: the true distance from + * the center to the node outline depends on the approach angle. + * + * These helpers compute that distance against the node's *actual* transformed outline. Rather than + * re-deriving the rotation/scale math by hand, they reuse zrender's own transform pipeline: a + * unit-size symbol `Path` (built exactly as `chart/helper/Symbol` builds node symbols) is given + * the node's transform and probed with `Path#contain`, so the boundary always matches what is + * actually drawn. + * + * All coordinates here are **relative to the node center** (the ray / probe origin), which makes + * the returned boundary distance directly usable by the caller with no back-transform. + */ + +import { createSymbol, ECSymbol } from '../../util/symbol'; +import { applyTransform } from 'zrender/src/core/vector'; + +type Vec2 = [number, number] | number[]; + +// Cache one unit-size proxy per distinct geometry. The full `path://` / `image://` string is part +// of `symbolType`, so distinct SVG/image symbols get distinct cache entries. +const proxyCache: Record = {}; + +const RAD = Math.PI / 180; +// Coarse ray samples used to locate the outermost inside→outside transition (robust for concave +// shapes such as `arrow` / `pin` / star SVGs), then refined by bisection. +const COARSE_SAMPLES = 16; +const BISECT_ITERATIONS = 12; +const EXTENT_SAFETY = 1.05; + +const _corner: number[] = []; + +function isCircleType(symbolType: string): boolean { + return symbolType === 'circle'; +} + +function hasOffset(offset: Vec2): boolean { + return !!offset && !!(offset[0] || offset[1]); +} + +// `emptyCircle`, `emptyRect`, ... share the outline of their non-empty base shape. +function baseSymbolType(symbolType: string): string { + return symbolType.indexOf('empty') === 0 + ? symbolType.charAt(5).toLowerCase() + symbolType.slice(6) + : symbolType; +} + +// Local unit-space vertices of the built-in convex polygon symbols (built at [-1, 1] on each axis). +const CONVEX_POLY_VERTS: Record = { + triangle: [[0, -1], [1, 1], [-1, 1]], + diamond: [[0, -1], [1, 0], [0, 1], [-1, 0]], + rect: [[-1, -1], [1, -1], [1, 1], [-1, 1]], + square: [[-1, -1], [1, -1], [1, 1], [-1, 1]] +}; + +/** + * Ray-exit parameter for a convex polygon whose interior contains the origin: the smallest `t > 0` + * at which `t * (ux, uy)` crosses an edge. Returns `Infinity` if no edge is crossed. + */ +function convexPolyRayExit(verts: number[][], ux: number, uy: number): number { + let best = Infinity; + const n = verts.length; + for (let i = 0; i < n; i++) { + const a = verts[i]; + const b = verts[(i + 1) % n]; + const ex = b[0] - a[0]; + const ey = b[1] - a[1]; + // Solve t*(ux,uy) = a + s*(ex,ey) for (t, s) via Cramer's rule. + const det = ex * uy - ux * ey; + if (Math.abs(det) < 1e-12) { + continue; + } + const t = (ex * a[1] - a[0] * ey) / det; + const s = (ux * a[1] - uy * a[0]) / det; + if (t > 1e-9 && s >= -1e-6 && s <= 1 + 1e-6 && t < best) { + best = t; + } + } + return best; +} + +/** + * Ray-exit parameter for the unit `roundRect` (box [-1, 1] with corner radius 0.5). Exits on a flat + * edge unless the ray heads into a corner, where it meets the quarter-circle arc. + */ +function roundRectRayExit(ux: number, uy: number): number { + const tBox = 1 / Math.max(Math.abs(ux), Math.abs(uy)); + const qx = tBox * ux; + const qy = tBox * uy; + if (Math.abs(qx) <= 0.5 || Math.abs(qy) <= 0.5) { + return tBox; + } + // Farther intersection with the corner arc centered at (+/-0.5, +/-0.5), radius 0.5. + const cx = qx > 0 ? 0.5 : -0.5; + const cy = qy > 0 ? 0.5 : -0.5; + const a = ux * ux + uy * uy; + const b = -2 * (ux * cx + uy * cy); + const c = cx * cx + cy * cy - 0.25; + const disc = b * b - 4 * a * c; + if (disc < 0) { + return tBox; + } + return (-b + Math.sqrt(disc)) / (2 * a); +} + +/** + * Exact center→outline distance for shapes with a closed-form ray intersection, or `null` when no + * analytic formula applies (concave / path / image / line symbols → generic ray-march). + * + * The rendered transform is `g = R'*S*p` with `S = diag(halfW, halfH)` and + * `R' = [[cos, sin], [-sin, cos]]` (see zrender `Transformable.getLocalTransform`). A center ray + * `g(t) = t*dir` maps to the local ray `t*u` with `u = S^-1 * R'^-1 * dir`, and since `R'*S*u = dir` + * is a unit vector, the global distance equals the local exit parameter `t`. + */ +function getAnalyticBoundaryDistance( + baseType: string, + halfW: number, + halfH: number, + rotateRad: number, + dirX: number, + dirY: number +): number { + const ct = Math.cos(rotateRad); + const st = Math.sin(rotateRad); + // u = S^-1 * R'^-1 * dir + const ux = (dirX * ct - dirY * st) / halfW; + const uy = (dirX * st + dirY * ct) / halfH; + + switch (baseType) { + case 'circle': + // Ellipse: |t*u| = 1. + return 1 / Math.sqrt(ux * ux + uy * uy); + case 'rect': + case 'square': + // Box: max(|t*ux|, |t*uy|) = 1. + return 1 / Math.max(Math.abs(ux), Math.abs(uy)); + case 'diamond': + // |t*ux| + |t*uy| = 1. + return 1 / (Math.abs(ux) + Math.abs(uy)); + case 'triangle': + return convexPolyRayExit(CONVEX_POLY_VERTS.triangle, ux, uy); + case 'roundRect': + return roundRectRayExit(ux, uy); + default: + // pin / arrow (center is not the geometric center), path / image / line / none. + return null; + } +} + +function getProxy(symbolType: string, keepAspect: boolean): ECSymbol { + const key = symbolType + '|' + (keepAspect ? 1 : 0); + let proxy = proxyCache[key]; + if (!proxy) { + // Build at unit size centered on the origin, mirroring `Symbol#_createSymbol`. + proxy = createSymbol(symbolType, -1, -1, 2, 2, null, keepAspect); + // `Path#contain` tests fill containment, so the proxy must report a fill. `setColor` + // is a no-op on images, whose `contain` is a bounding-rect test and needs no fill. + proxy.setColor && proxy.setColor('#000'); + proxyCache[key] = proxy; + } + return proxy; +} + +/** + * (Re)configure the shared proxy for a node's symbol, in center-relative space, and refresh its + * transform matrix. `size` and `offset` must already include the node global scale. + * + * The returned proxy is shared and mutable: use it synchronously and finish before configuring a + * proxy of the same symbol type again. + */ +function configureSymbolProxy( + symbolType: string, + size: Vec2, + rotateRad: number, + keepAspect: boolean, + offset: Vec2 +): ECSymbol { + const proxy = getProxy(symbolType, keepAspect); + proxy.x = offset ? offset[0] : 0; + proxy.y = offset ? offset[1] : 0; + // Unit symbol spans [-1, 1] on each axis, so half-size is the scale factor. + proxy.scaleX = size[0] / 2; + proxy.scaleY = size[1] / 2; + proxy.rotation = rotateRad || 0; + proxy.originX = 0; + proxy.originY = 0; + proxy.updateTransform(); + return proxy; +} + +/** + * Upper bound for the distance from the center to the outline: the farthest transformed corner of + * the proxy's local bounding rect (with a small safety margin). + */ +function computeMaxExtent(proxy: ECSymbol, size: Vec2): number { + const m = proxy.transform; + const rect = proxy.getBoundingRect(); + if (!m) { + // Identity transform (near-unit scale): local ~= global, outline within radius √2. + return Math.max(size[0], size[1]) / 2 * Math.SQRT2 * EXTENT_SAFETY + 1; + } + const x0 = rect.x; + const y0 = rect.y; + const x1 = rect.x + rect.width; + const y1 = rect.y + rect.height; + let maxDistSq = 0; + for (let i = 0; i < 4; i++) { + _corner[0] = i & 1 ? x1 : x0; + _corner[1] = i & 2 ? y1 : y0; + applyTransform(_corner, _corner, m); + const distSq = _corner[0] * _corner[0] + _corner[1] * _corner[1]; + if (distSq > maxDistSq) { + maxDistSq = distSq; + } + } + return Math.sqrt(maxDistSq) * EXTENT_SAFETY; +} + +/** + * Distance from the node center to its transformed symbol outline along the unit direction + * `(dirX, dirY)`. Placing the endpoint at `center + dist * dir` makes it flush with the node + * outline at that approach angle. + */ +export function getSymbolBoundaryDistance( + symbolType: string, + size: Vec2, + rotateRad: number, + keepAspect: boolean, + offset: Vec2, + dirX: number, + dirY: number +): number { + const halfW = size[0] / 2; + const halfH = size[1] / 2; + if (!(halfW > 0) || !(halfH > 0)) { + return 0; + } + + const noOffset = !hasOffset(offset); + const baseType = baseSymbolType(symbolType); + + // Fast path: a symmetric circle is angle-independent — covers the default node. + if (noOffset && isCircleType(baseType) && halfW === halfH) { + return halfW; + } + + // Exact analytic outline for shapes with a closed-form ray intersection (no offset). + if (noOffset) { + const analytic = getAnalyticBoundaryDistance(baseType, halfW, halfH, rotateRad, dirX, dirY); + if (analytic != null && isFinite(analytic) && analytic > 0) { + return analytic; + } + } + + // Generic fallback: ray-march the transformed outline. Handles concave / `path://` shapes, + // raster and SVG images (bounding box), and offset shapes. + const fallback = Math.max(halfW, halfH); + const proxy = configureSymbolProxy(symbolType, size, rotateRad, keepAspect, offset); + const tMax = computeMaxExtent(proxy, size); + if (!(tMax > 0)) { + return fallback; + } + + const step = tMax / COARSE_SAMPLES; + let lastInside = NaN; + for (let i = 0; i <= COARSE_SAMPLES; i++) { + const t = step * i; + if (proxy.contain(t * dirX, t * dirY)) { + lastInside = t; + } + } + + // No sample inside: unfillable (`line` / `none`) or the offset moved the shape off the ray. + if (isNaN(lastInside)) { + return fallback; + } + // Outline extends beyond the search bound (should not happen given the safety margin). + if (lastInside >= tMax) { + return lastInside; + } + + // Refine the outermost boundary between the last inside sample and the next (outside) one. + let lo = lastInside; + let hi = lastInside + step; + for (let i = 0; i < BISECT_ITERATIONS; i++) { + const mid = (lo + hi) / 2; + if (proxy.contain(mid * dirX, mid * dirY)) { + lo = mid; + } + else { + hi = mid; + } + } + return (lo + hi) / 2; +} + +/** + * Predicate testing whether a point (relative to the node center) lies inside the node's + * transformed symbol. Used to clip curved edges to the outline. + * + * The returned function borrows the shared proxy for its symbol type, so use it synchronously and + * finish before requesting another checker for the same symbol type. + */ +export function getSymbolContainChecker( + symbolType: string, + size: Vec2, + rotateRad: number, + keepAspect: boolean, + offset: Vec2 +): (relX: number, relY: number) => boolean { + const proxy = configureSymbolProxy(symbolType, size, rotateRad, keepAspect, offset); + return function (relX: number, relY: number): boolean { + return proxy.contain(relX, relY); + }; +} + +/** + * Convert an option `symbolRotate` (degrees) to radians, matching `chart/helper/Symbol`. + */ +export function symbolRotateToRad(symbolRotate: number): number { + return (symbolRotate || 0) * RAD || 0; +} diff --git a/test/graph-edge-shape.html b/test/graph-edge-shape.html new file mode 100644 index 0000000000..218abde65c --- /dev/null +++ b/test/graph-edge-shape.html @@ -0,0 +1,164 @@ + + + + + + + + + + +

+ Edge arrowheads should sit flush against each central node's outline at every + approach angle — no gap, no overlap. Try roaming/zooming; use the browser to inspect. +
Note: image:// nodes (raster or SVG data-URIs) have no vector + outline, so endpoints clip to the image's bounding box. Use path:// + (e.g. the whale) for true outline following. +

+
+ + + diff --git a/test/ut/spec/series/graph/nodeShapeHelper.test.ts b/test/ut/spec/series/graph/nodeShapeHelper.test.ts new file mode 100644 index 0000000000..6def046350 --- /dev/null +++ b/test/ut/spec/series/graph/nodeShapeHelper.test.ts @@ -0,0 +1,116 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { getSymbolBoundaryDistance } from '@/src/chart/graph/nodeShapeHelper'; + +// Convenience: distance from center to outline for a symbol probed along a unit direction. +function dist( + symbolType: string, + size: number[], + rotateDeg: number, + dirX: number, + dirY: number +): number { + const len = Math.sqrt(dirX * dirX + dirY * dirY); + return getSymbolBoundaryDistance( + symbolType, size, rotateDeg * Math.PI / 180, false, null, dirX / len, dirY / len + ); +} + +const SQRT1_2 = Math.SQRT1_2; + +describe('chart/graph/nodeShapeHelper', function () { + + describe('getSymbolBoundaryDistance', function () { + + it('circle is angle-independent (fast path)', function () { + for (let deg = 0; deg < 360; deg += 23) { + const rad = deg * Math.PI / 180; + expect(dist('circle', [20, 20], 0, Math.cos(rad), Math.sin(rad))).toBeCloseTo(10, 5); + } + }); + + it('axis-aligned rect uses half-extents on the axes', function () { + expect(dist('rect', [40, 20], 0, 1, 0)).toBeCloseTo(20, 1); + expect(dist('rect', [40, 20], 0, -1, 0)).toBeCloseTo(20, 1); + expect(dist('rect', [40, 20], 0, 0, 1)).toBeCloseTo(10, 1); + expect(dist('rect', [40, 20], 0, 0, -1)).toBeCloseTo(10, 1); + }); + + it('rect diagonal follows the box (min of axis crossings)', function () { + // (0.6, 0.8): x-limit 20/0.6 = 33.3, y-limit 10/0.8 = 12.5 -> exits at y = 10. + expect(dist('rect', [40, 20], 0, 0.6, 0.8)).toBeCloseTo(12.5, 1); + // 45deg on a 40x40 square exits at the corner: sqrt(20^2 + 20^2). + expect(dist('rect', [40, 40], 0, SQRT1_2, SQRT1_2)).toBeCloseTo(Math.sqrt(800), 1); + }); + + it('diamond is smaller on the diagonal than on the axes', function () { + // |x| + |y| = 10 scaled shape. + expect(dist('diamond', [20, 20], 0, 1, 0)).toBeCloseTo(10, 1); + expect(dist('diamond', [20, 20], 0, 0, 1)).toBeCloseTo(10, 1); + // Diagonal: t*(|dx| + |dy|) = 10 -> 10 / sqrt(2). + expect(dist('diamond', [20, 20], 0, SQRT1_2, SQRT1_2)).toBeCloseTo(10 * SQRT1_2, 1); + }); + + it('rotation swaps the rect axes (90 degrees)', function () { + // Rotating a 40x20 rect by 90deg puts its short axis horizontal. + expect(dist('rect', [40, 20], 90, 1, 0)).toBeCloseTo(10, 1); + expect(dist('rect', [40, 20], 90, 0, 1)).toBeCloseTo(20, 1); + }); + + it('non-uniform circle is treated as an ellipse', function () { + // Ellipse x^2/20^2 + y^2/10^2 = 1 (fast path skipped: half-extents differ). + expect(dist('circle', [40, 20], 0, 1, 0)).toBeCloseTo(20, 1); + expect(dist('circle', [40, 20], 0, 0, 1)).toBeCloseTo(10, 1); + // Diagonal: t^2/2 * (1/400 + 1/100) = 1 -> t = sqrt(160). + expect(dist('circle', [40, 20], 0, SQRT1_2, SQRT1_2)).toBeCloseTo(Math.sqrt(160), 1); + }); + + it('triangle exits at apex, base and slanted sides', function () { + // verts (0,-1),(1,1),(-1,1) scaled by 20. + expect(dist('triangle', [40, 40], 0, 0, -1)).toBeCloseTo(20, 3); // apex + expect(dist('triangle', [40, 40], 0, 0, 1)).toBeCloseTo(20, 3); // base + expect(dist('triangle', [40, 40], 0, 1, 0)).toBeCloseTo(10, 3); // right side at y=0 + }); + + it('roundRect exits flat edges and corner arcs', function () { + // Half-extent 20, corner radius 10. + expect(dist('roundRect', [40, 40], 0, 1, 0)).toBeCloseTo(20, 3); // flat edge + expect(dist('roundRect', [40, 40], 0, 0, 1)).toBeCloseTo(20, 3); // flat edge + // Diagonal meets the corner arc (closer than the sharp corner at sqrt(800) ~ 28.28). + expect(dist('roundRect', [40, 40], 0, SQRT1_2, SQRT1_2)).toBeCloseTo(24.142, 2); + }); + + it('empty variants share the base outline', function () { + expect(dist('emptyRect', [40, 20], 0, 1, 0)).toBeCloseTo(20, 1); + expect(dist('emptyCircle', [20, 20], 0, 1, 0)).toBeCloseTo(10, 3); + }); + + it('scales with symbol size', function () { + expect(dist('rect', [80, 40], 0, 1, 0)).toBeCloseTo(40, 1); + expect(dist('rect', [80, 40], 0, 0, 1)).toBeCloseTo(20, 1); + }); + + it('degenerate sizes do not throw', function () { + expect(dist('rect', [0, 0], 0, 1, 0)).toBe(0); + }); + + }); + +});