@@ -415,6 +423,17 @@
Elbow routing · org chart
labelAt: '22%',
},
],
+ [
+ {
+ start: '#s-b',
+ end: '#s-a',
+ roughness: 0.5,
+ head: 'both',
+ headStyle: 'solid',
+ headSize: 18,
+ label: 'filled',
+ },
+ ],
];
examples.forEach(([opts], i) => {
@@ -443,7 +462,7 @@
Elbow routing · org chart
scroll: { target: '.tree' },
});
- const treeSection = document.querySelectorAll('.section')[5];
+ const treeSection = document.querySelectorAll('.section')[6];
const groupCard = document.createElement('div');
groupCard.className = 'code';
groupCard.innerHTML = renderGroupCall();
@@ -460,7 +479,7 @@
Elbow routing · org chart
};
scrollArrow({ ...base, ...rmOpts });
- const rmSection = document.querySelectorAll('.section')[6];
+ const rmSection = document.querySelectorAll('.section')[7];
const rmCard = document.createElement('div');
rmCard.className = 'code';
rmCard.innerHTML = renderCall(rmOpts);
diff --git a/docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md b/docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md
new file mode 100644
index 0000000..d857ad2
--- /dev/null
+++ b/docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md
@@ -0,0 +1,131 @@
+---
+title: 'fix: Stop shaft overlapping the arrowhead'
+date: 2026-07-06
+artifact_contract: ce-unified-plan/v1
+artifact_readiness: implementation-ready
+execution: code
+product_contract_source: ce-plan-bootstrap
+origin: 'GitHub issue #59'
+---
+
+# fix: Stop shaft overlapping the arrowhead
+
+## Summary
+
+The shaft path and the arrowhead share the same endpoint, so the line (plus rough.js wobble) visibly crosses the arrowhead's V instead of meeting it at the base. Fix: when a head is drawn at an end, shorten the shaft by the head's base depth — pulling the endpoint back along the arrival tangent (curve route) or trimming the first/last leg (elbow route) — while keeping the arrowhead itself anchored at the true socket point. Both ends, with clamps so very short arrows never invert.
+
+**Branch/PR convention:** branch from `origin/staging`; PR targets `staging`.
+
+---
+
+## Problem Frame
+
+In `src/scroll-arrow.ts` `render()`, `buildPath(local, …)` / `buildElbowPath(local)` end exactly at `local.end`, and the end head is `arrowHeadPath(local.end, dir, size)` — tip at the same point. The head's base sits `size * cos(spread)` behind the tip (`spread = π/7` in `src/geometry.ts` `arrowHeadPath`), so the shaft runs ~0.9 × headSize past where it should stop. Same at the start when `head` is `'start'` or `'both'`.
+
+## Requirements
+
+- **R1**: When `head` includes `'end'`, the shaft terminates at the arrowhead's base (tip minus base depth along the aim direction), not at the tip.
+- **R2**: Same treatment for the start point when `head` includes `'start'`.
+- **R3**: The arrowhead stays anchored at the true socket point — the tip still touches the target edge.
+- **R4**: `head: 'none'` (and the non-headed end of `'start'`/`'end'`) renders exactly as today.
+- **R5**: Both `route: 'curve'` (default, incl. obstacle avoidance) and `route: 'elbow'` get the inset.
+- **R6**: Very short arrows degrade gracefully — insets never invert or collapse the shaft.
+- **R7**: Draw-on animation ordering unchanged: head strokes still begin after the (now slightly shorter) line finishes.
+
+---
+
+## Key Technical Decisions
+
+- **KTD1 — Inset lives in geometry as a pure function (curve route).** Add `insetEndpoints(ep, startInset, endInset): Endpoints` to `src/geometry.ts`. It moves `start`/`end` back along `startTangent(ep)`/`endTangent(ep)` (i.e. `p - tangent * inset`; note `endTangent` is `-endNormal` for side sockets, so this equals `end + endNormal * inset` — same formula, two spellings) and preserves the normals so `cubicControls` still bows the curve off the socket edges. Pure and unit-testable; `render()` just calls it.
+- **KTD2 — Base depth derived from the shared spread constant.** Inset = `headSize * cos(π/7)` (~0.9 × headSize). Hoist the `spread` constant in `geometry.ts` so `arrowHeadPath` and the inset computation share one source of truth; no new public option (issue allows a fixed fraction).
+- **KTD3 — Short-arrow clamp (curve route).** If `startInset + endInset` exceeds half the start→end chord length, scale both proportionally so the shaft keeps at least half the chord. Prevents inverted/degenerate paths for tiny arrows (R6). The chord comparison is exact only for facing-collinear sockets (elsewhere it is strictly conservative — it may shrink insets more than needed, which is safe); test it in that configuration.
+- **KTD4 — Curve route gets inset endpoints; elbow route trims its legs.** For the curve route, `routeBellies` and `buildPath` receive the inset endpoints, so obstacle detection runs against the curve actually rendered. The elbow route must NOT receive inset endpoints — `buildElbowPath` recomputes corners from its endpoints, so a moved endpoint shifts the mid-rail by inset/2 and can invert the final leg entirely (e.g. start `(0,0)` right-socket → end `(200,5)` top-socket: inset end `(200,-7.6)` makes the last leg travel away from the target while the head points at it). Instead, build the elbow from the true endpoints and trim the first/last leg along its own axis by the inset, clamping each trim to that leg's length. This per-leg trim also handles center-socket elbows correctly (the trim follows the actual leg direction, not a tangent guess).
+- **KTD5 — Head aim vs shaft arrival for center sockets (curve route).** Heads are aimed with tangents computed from the original `local` endpoints. For side sockets the tangents are the socket normals and are unaffected by insetting. For a zero-normal (center) socket the tangent falls back to the chord direction, so insetting the far endpoint rotates the shaft's true arrival by ~`atan(inset/chord)` relative to the head aim — a few degrees at typical sizes. Accepted: center sockets are already a fallback path.
+
+## High-Level Technical Design
+
+```
+render():
+ local = shifted endpoints (true socket points)
+ si/ei = per-end insets from opts.head (headSize * cos(π/7), or 0)
+ dirs = endTangent(local)/startTangent(local)
+ d = route === 'elbow'
+ ? buildElbowPath(local, si, ei) // true endpoints; first/last leg trimmed
+ : buildPath(insetEndpoints(local, si, ei), curvature, ...routeBellies(...))
+ heads = arrowHeadPath(local.end, dirs.end, size) // still at true tip
+ arrowHeadPath(local.start, dirs.start, size)
+```
+
+Geometry of the end inset (side socket, curve route): travel arrives along `-endNormal`, so the shaft's new endpoint is `end + endNormal * inset` — back along the arrival path, exactly at the head's base midpoint. Elbow route: corners stay computed from true endpoints; only the first/last straight leg is shortened by the trim.
+
+---
+
+## Implementation Units
+
+### U1. Geometry: `insetEndpoints`, elbow leg trims, shared spread constant
+
+**Goal:** Pure inset/trim primitives with clamping.
+**Requirements:** R1, R2, R5, R6 (geometry half), KTD1–KTD4.
+**Dependencies:** none.
+**Files:** `src/geometry.ts`, `test/geometry.test.ts`.
+**Approach:** Hoist `spread = Math.PI / 7` to a module constant used by `arrowHeadPath`; export `headBaseInset(size)` (or equivalent) returning `size * Math.cos(spread)`. Add `insetEndpoints(ep, startInset, endInset)` (curve route) that computes tangents via existing `startTangent`/`endTangent`, applies the proportional clamp from KTD3, and returns a new `Endpoints` with moved points and original normals. Extend `buildElbowPath(ep, startTrim = 0, endTrim = 0)` (elbow route) to shorten the first/last straight leg along its own axis after corners are computed from the true endpoints, clamping each trim to its leg's length (KTD4). Follow existing pure-function style in `geometry.ts`.
+**Patterns to follow:** `routeBellies` / `samplePath` doc-comment style; existing `Endpoints` shape; `buildElbowPath` branch structure.
+**Test scenarios:**
+
+- End inset, side socket: `insetEndpoints(ep, 0, k)` moves `end` by `+endNormal * k`, leaves `start` and normals untouched.
+- Start inset, side socket: `start` moves by `+startNormal * k`.
+- Both insets applied independently.
+- Zero insets/trims: output identical to today (identity for `head: 'none'`).
+- Center sockets (zero normals): inset falls back along the chord direction (matches `endTangent` fallback).
+- Short chord clamp (facing-collinear sockets, where the comparison is exact): chord length 10, insets 12+12 → both scale so shaft length stays ≥ half the chord; endpoints never cross.
+- Elbow end trim: corners unchanged (mid-rail/corner coordinates identical to untrimmed output); only the final leg's endpoint moves back along that leg by the trim.
+- Elbow inversion guard: start `(0,0)` right-socket → end `(200,5)` top-socket with trim 12.6 (final leg length 5) — trim clamps to the leg length; the final leg never extends past its corner.
+- Elbow same-axis sockets: mid-rail `midY`/`midX` identical with and without trims.
+ **Verification:** `vitest` geometry suite green; new cases cover each scenario.
+
+### U2. Wire inset into `render()` for both routes
+
+**Goal:** Shaft stops at the head base in the rendered SVG; heads stay at true sockets.
+**Requirements:** R1–R5, R7, KTD4.
+**Dependencies:** U1.
+**Files:** `src/scroll-arrow.ts`, `test/scroll-arrow.test.ts`.
+**Approach:** In `render()`, after computing `local`, derive per-end insets from `this.opts.head` and `headSize`. Curve route: build `shaft = insetEndpoints(local, si, ei)` and pass `shaft` to `routeBellies` + `buildPath`. Elbow route: pass the true `local` plus the insets as leg trims — `buildElbowPath(local, si, ei)`. Keep `arrowHeadPath(local.end, endTangent(local), size)` and the start-head call on `local` unchanged. `lineD` (label measurement) naturally uses the shortened path. No changes to `dashOffsets` / segment ordering.
+**Patterns to follow:** existing `render()` structure; `shift()` local-coords idiom.
+**Test scenarios:**
+
+- Covers R1: `head: 'end'` — the line path's `d` terminates at the socket point pulled back by `headSize * cos(π/7)` along the end normal; head path still contains the true tip coordinates.
+- Covers R2: `head: 'both'` — start of line `d` inset along the start normal; both heads at true sockets.
+- Covers R4: `head: 'none'` — line `d` endpoints equal the socket points exactly (byte-compare against pre-change expectation).
+- Covers R5: `route: 'elbow'` with `head: 'end'` — final `L` coordinate pulled back along the final leg's axis; corner coordinates unchanged from the untrimmed elbow.
+- Covers R7: segments still ordered line-then-head; existing `draw.test.ts` ordering tests pass unchanged.
+- Label still renders on a labelled arrow (shortened `lineD` doesn't break `renderLabel`).
+ **Verification:** full `vitest` suite green; visual spot-check in demo page shows shaft meeting head base cleanly.
+
+---
+
+## Scope Boundaries
+
+**In scope:** shaft/head junction geometry for curve and elbow routes, both ends.
+
+**Out of scope (true non-goals):** rough.js wobble/overshoot tuning; changing arrowhead shape or spread.
+
+### Deferred to Follow-Up Work
+
+- Solid/filled arrowhead type — tracked as issue #60.
+- Head-aim vs shaft-arrival divergence for center-socket curve arrows (KTD5) — accepted, revisit only if visible in practice.
+
+## Risks & Dependencies
+
+- **Snapshot-ish tests:** existing tests asserting exact path `d` strings for headed arrows will need updating — expected, not a regression.
+- **rough.js overshoot:** wobble can still poke a stroke slightly past the shaft end; issue accepts this (the fix removes the systematic full-tip overlap).
+
+## Verification Contract
+
+- `npx vitest run` green (geometry, scroll-arrow, draw, reduced-motion suites).
+- Lint/typecheck clean (`eslint`, `tsc`).
+
+## Definition of Done
+
+- R1–R7 hold, demonstrated by the U1/U2 test scenarios.
+- All existing tests pass (updated only where they encoded the old overlapping endpoint).
+- No public API change.
diff --git a/docs/plans/2026-07-06-002-feat-solid-arrowhead-plan.md b/docs/plans/2026-07-06-002-feat-solid-arrowhead-plan.md
new file mode 100644
index 0000000..09146a3
--- /dev/null
+++ b/docs/plans/2026-07-06-002-feat-solid-arrowhead-plan.md
@@ -0,0 +1,172 @@
+---
+title: 'feat: Solid (filled) arrowhead style + start-head render coverage'
+date: 2026-07-06
+artifact_contract: ce-unified-plan/v1
+artifact_readiness: implementation-ready
+execution: code
+product_contract_source: ce-plan-bootstrap
+origin: 'GitHub issues #60 and #61'
+---
+
+# feat: Solid (filled) arrowhead style + start-head render coverage
+
+## Summary
+
+Two issues, one branch/PR:
+
+- **#60** — add `headStyle?: 'line' | 'solid'` (default `'line'`, non-breaking). `'solid'` closes the head path (`… L p2 Z`) and fills it with the stroke color via rough.js `fill` + `fillStyle: 'solid'`, keeping the hand-drawn look. Applies to start and end heads. Filled heads can't dash-reveal, so they fade in during their slot of the draw-on sequence.
+- **#61** — render-level tests for `head: 'start'` alone, which today is only exercised via `'both'`; a bug isolated to the `head === 'start'` disjunct in `src/scroll-arrow.ts` would not be caught.
+
+**Branch/PR convention:** branch from `origin/staging`; PR targets `staging`; closes #60 and #61.
+
+---
+
+## Assumptions
+
+- Single branch and single PR covering both issues (they share `test/scroll-arrow.test.ts`; #61's coverage also protects the render path #60 modifies). The request said "PR for both" — read as one PR resolving both.
+- Fade-in (opacity ramp) is the accepted draw-on treatment for solid heads; no scale animation (issue offers "fade/scale" — fade is simpler and sufficient).
+- No demo-page changes; deferred (see Scope Boundaries).
+
+---
+
+## Problem Frame
+
+`arrowHeadPath()` in `src/geometry.ts` always emits an open V (`M p1 L tip L p2`) and `mapRoughness()` hardcodes `fill: 'none'`. `appendDrawable()` in `src/scroll-arrow.ts` additionally force-sets `fill="none"` on every rough.js output path, and the draw-on animation (`src/draw.ts` `dashOffsets`) reveals every segment by `stroke-dashoffset` — a filled polygon has no stroke length to reveal. So a solid head needs: a closed geometry path, fill-carrying rough options for head drawables only, an `appendDrawable` that preserves rough.js fill paths, and an opacity-based reveal for fill segments.
+
+Test gap (#61): `test/scroll-arrow.test.ts` exercises `head: 'none' | 'end' | 'both'` at render level; `hasStartHead` is only ever true co-occurring with `hasEndHead`.
+
+## Requirements
+
+- **R1**: `headStyle: 'solid'` renders each drawn head as a closed triangle filled with the stroke color, hand-drawn look preserved (rough.js `fillStyle: 'solid'`).
+- **R2**: Default (`headStyle` omitted or `'line'`) output is byte-identical to today — no breaking change.
+- **R3**: Solid style applies to whichever heads `head` selects (`'start'`, `'end'`, `'both'`).
+- **R4**: Draw-on animation: solid heads appear only after the line completes, fading in over their slot; sequencing with multiple heads stays sensible. Reduced motion / `progress: 1` shows them fully.
+- **R5**: Heads stay anchored at the true socket points; shaft inset behavior from #59 is unchanged (base inset math is identical for both styles).
+- **R6** (#61): Render tests exercise `head: 'start'` alone — curve inset, start-head anchoring, and elbow startTrim wiring.
+
+---
+
+## Key Technical Decisions
+
+- **KTD1 — Closed head path via a flag on the existing helper.** Extend `arrowHeadPath(tip, dir, size, closed = false)` to append `Z` when closed, rather than a parallel function — the three points are identical.
+- **KTD2 — Fill options built per-head at render time.** `mapRoughness` stays as-is (`fill: 'none'` is right for the line). In `render()`, solid heads pass `{ ...roughOpts, fill: this.stroke, fillStyle: 'solid' }` to `rc.path`. No change to the roughness mapping contract.
+- **KTD3 — Delete the redundant fill clobber in `appendDrawable`.** rough.js already emits `fill="none"` on every stroke path itself (roughjs `svg.js` `path` case), so the `el.setAttribute('fill', 'none')` line in `appendDrawable` is redundant today. Delete it — byte-identical for line/line-style output, and it automatically preserves the fill `
` rough.js emits for solid heads.
+- **KTD4 — Fill segments reveal by opacity, tied to their head's stroke slot.** rough.js emits the fill path _before_ the stroke path(s) for filled shapes, so raw segment order cannot drive sequential reveal. Extend the segment model with a fill marker (e.g. `kind: 'head'` + `fill: true`) and group segments per head: a solid head's fill segment contributes **zero** length to the total draw budget, and its `opacity` equals its own head's stroke-segment reveal fraction (0 until that head's outline starts, 1 when it completes). Stroke segments keep dash-offset reveal unchanged; initial state for fill segments is opacity 0 (no dasharray).
+- **KTD5 — Reveal is concurrent per head: outline dash-draws while the fill fades over the same slot.** This is the committed visual (the issue's "fade it in once the line completes"). Timing vs line-style heads differs slightly regardless — a closed triangle's outline is longer than the open V — and the fill adds no further length by KTD4; accepted.
+
+## High-Level Technical Design
+
+```
+render():
+ headOpts = headStyle === 'solid'
+ ? { ...roughOpts, fill: stroke, fillStyle: 'solid' }
+ : roughOpts
+ headD = arrowHeadPath(tip, dir, size, headStyle === 'solid') // + Z when solid
+ appendDrawable(rc.path(headD, headOpts), 'head') // keeps rough fill paths
+
+applyProgress():
+ fractions = per-segment revealed fraction (line shares leading edge; heads sequential;
+ fill segments carry zero length and inherit their head's stroke fraction)
+ stroke segment -> strokeDashoffset = len * (1 - fraction)
+ fill segment -> style.opacity = fraction of its head group's stroke reveal
+```
+
+---
+
+## Implementation Units
+
+### U1. Render tests for head:'start' alone (#61)
+
+**Goal:** Close the `head: 'start'` render-level coverage gap before touching the head code path.
+**Requirements:** R6.
+**Dependencies:** none.
+**Files:** `test/scroll-arrow.test.ts`.
+**Approach:** Mirror the existing #59 suite cases using its `anchors()`/`pathDs` helpers and SVG stubs.
+**Patterns to follow:** `ScrollArrow shaft/head junction (#59)` describe block.
+**Test scenarios:**
+
+- Curve inset: line `d` for `{seed: 7, head: 'none'}` differs from `{seed: 7, head: 'start'}` (start inset along the start normal).
+- Anchoring: with `head: 'start'`, the head path still contains the true start-socket coordinates (`anchors()` start socket resolves to `(100, 20)` — assert like the existing R3 regex).
+- Elbow startTrim: `{route: 'elbow', head: 'start'}` line `d` differs from `{route: 'elbow', head: 'none'}`.
+ **Verification:** `npm test` green; deleting the `head === 'start'` disjunct at `src/scroll-arrow.ts` `hasStartHead` would fail these tests.
+
+### U2. Closed head path in geometry
+
+**Goal:** `arrowHeadPath` can emit a closed triangle.
+**Requirements:** R1 (geometry half).
+**Dependencies:** none.
+**Files:** `src/geometry.ts`, `test/geometry.test.ts`.
+**Approach:** KTD1 — optional `closed` param appends ` Z`.
+**Test scenarios:**
+
+- `closed: true` output ends with `Z` and shares the same three points as the open form.
+- Default/omitted stays exactly the current string (R2 guard at geometry level).
+ **Verification:** geometry tests green.
+
+### U3. `headStyle` option and solid-head rendering
+
+**Goal:** Public `headStyle` option; solid heads render filled.
+**Requirements:** R1, R2, R3, R5.
+**Dependencies:** U2.
+**Files:** `src/types.ts`, `src/scroll-arrow.ts`, `test/scroll-arrow.test.ts`.
+**Approach:** Add `HeadStyle` type + `headStyle?: 'line' | 'solid'` to `ScrollArrowOptions` (doc comment per existing style). In `render()`, per KTD2 build head-specific rough options and pass `closed` to `arrowHeadPath`. Fix `appendDrawable` per KTD3.
+**Test scenarios:**
+
+- `headStyle: 'solid', head: 'end'`: at least one head path carries a non-`none` fill equal to the stroke color, and its `d` passes through the true socket coordinates (same regex style as the existing R3 test). Do not assert `Z` in the rendered `d` — rough.js re-emits paths through `opsToPath`, which only produces M/L/C commands; the closing `Z` is observable only at the geometry level (U2).
+- Default omitted vs explicit `'line'`: rendered `d` strings and fill attributes identical to a render with no `headStyle` (R2).
+- `head: 'both', headStyle: 'solid'`: both heads filled.
+- Line paths always keep `fill="none"` regardless of `headStyle`.
+ **Verification:** `npm test`, `npm run typecheck`, `npm run lint` green.
+
+### U4. Draw-on reveal for filled heads
+
+**Goal:** Solid heads fade in during their slot instead of dash-revealing.
+**Requirements:** R4.
+**Dependencies:** U3.
+**Files:** `src/draw.ts`, `src/scroll-arrow.ts`, `test/draw.test.ts`.
+**Approach:** KTD4/KTD5 — mark fill segments and group them with their head's stroke segments; compute per-segment revealed fraction in `draw.ts` (pure, unit-testable) with fill segments contributing zero length and inheriting their head group's stroke fraction; `applyProgress` maps fraction → dashoffset for stroke segments, → opacity for fill segments; initial state sets fill-segment opacity 0 and skips dasharray on them.
+**Test scenarios (pure, in `test/draw.test.ts`):**
+
+- Fill segment fraction is 0 while the line is still drawing.
+- Fill segment fraction equals its head group's stroke fraction as that head reveals (concurrent outline + fade), reaching 1 when the head completes.
+- Fill segment length does not shift the total draw budget (line completes at the same eased progress with and without a fill segment of the same head).
+- Fraction is 1 at eased progress 1 (reduced-motion/static case).
+- Stroke segments' offsets unchanged from current `dashOffsets` behavior for the same inputs (R2 guard on animation).
+ **Verification:** `npm test` green; manual demo check deferred to browser-test step of the pipeline.
+
+### U5. Document the option
+
+**Goal:** README documents `headStyle`.
+**Requirements:** R1 (discoverability).
+**Dependencies:** U3.
+**Files:** `README.md`.
+**Approach:** Add `headStyle` next to the existing `head`/`headSize` entries (options block near line 40 and the options list near line 240).
+**Test expectation:** none — docs only.
+**Verification:** `npm run format:check` green.
+
+---
+
+## Scope Boundaries
+
+### Deferred to Follow-Up Work
+
+- Demo-page toggle showcasing `headStyle: 'solid'`.
+- Group-level (`ScrollArrowGroupOptions`) needs nothing: per-arrow options already pass through.
+
+### Non-goals
+
+- Other head shapes (dot, bar, diamond).
+- Per-end head styles (`startHeadStyle`/`endHeadStyle`) — one knob for both ends, per issue.
+- Scale-in animation for solid heads.
+
+---
+
+## Verification Contract
+
+- `npm test`, `npm run typecheck`, `npm run lint`, `npm run format:check` all green.
+- R2 regression guard: renders without `headStyle` produce identical DOM output to pre-change (covered by seed-deterministic `d`-string tests in U2/U3/U4).
+
+## Definition of Done
+
+- All five units landed; issues #60 and #61 requirements covered by passing tests.
+- PR open against `staging` referencing both issues.
diff --git a/package.json b/package.json
index 02df9f8..cc6105b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "scroll-arrows",
- "version": "0.5.2",
+ "version": "0.6.0",
"description": "Hand-drawn arrows that draw themselves between two elements as you scroll. Roughness goes from clean straight lines to scratchy and curvy.",
"type": "module",
"sideEffects": false,
diff --git a/src/draw.ts b/src/draw.ts
index d448d87..c804c6d 100644
--- a/src/draw.ts
+++ b/src/draw.ts
@@ -38,39 +38,73 @@ export function resolveLabelAt(
export interface DrawSegment {
len: number;
kind: SegmentKind;
+ /** A solid head's fill path: reveals by opacity and contributes no length. */
+ fill?: boolean;
+ /** Reveal group tying a fill path to its own head's stroke paths. */
+ group?: number;
}
-/**
- * Given the line/head segments and an eased progress (0..1), return the
- * `stroke-dashoffset` each segment should have.
- *
- * Line sub-strokes (rough.js emits 1-2 overlapping ones) share a single
- * leading edge so they grow together as one pen tip. Arrowhead strokes only
- * begin once the line is fully drawn, then reveal sequentially.
- */
-function lengths(segs: DrawSegment[]): { lineLen: number; headLen: number } {
+function lengths(segs: DrawSegment[]): {
+ lineLen: number;
+ headLen: number;
+ hasFill: boolean;
+} {
let lineLen = 0;
let headLen = 0;
+ let hasFill = false;
for (const s of segs) {
+ if (s.fill) {
+ hasFill = true; // fades with its group; no length of its own
+ continue;
+ }
if (s.kind === 'line') lineLen = Math.max(lineLen, s.len);
else headLen += s.len;
}
- return { lineLen, headLen };
+ return { lineLen, headLen, hasFill };
}
-export function dashOffsets(segs: DrawSegment[], eased: number): number[] {
- const { lineLen, headLen } = lengths(segs);
+/**
+ * Revealed fraction (0..1) of each segment (a stroke's dash offset is
+ * `len * (1 - fraction)`). Line strokes share one leading edge so they grow
+ * together as one pen tip; head strokes reveal sequentially once the line is
+ * done. A solid head's fill path contributes no length to the budget — its
+ * fraction mirrors its own head group's stroke reveal, so the fill fades in
+ * while the outline draws (#60).
+ */
+export function segmentFractions(segs: DrawSegment[], eased: number): number[] {
+ const { lineLen, headLen, hasFill } = lengths(segs);
const total = lineLen + headLen || 1;
const drawn = clamp01(eased) * total;
const lp = lineLen > 0 ? clamp01(drawn / lineLen) : 1;
let headDrawn = Math.max(0, drawn - lineLen);
- return segs.map((seg) => {
- if (seg.kind === 'line') return seg.len * (1 - lp);
+ // Fast path for the common line-style case: no fill segments, so no group
+ // bookkeeping — this runs per animation frame while scrolling.
+ if (!hasFill) {
+ return segs.map((seg) => {
+ if (seg.kind === 'line') return lp;
+ const show = Math.max(0, Math.min(seg.len, headDrawn));
+ headDrawn -= seg.len;
+ return seg.len > 0 ? show / seg.len : 1;
+ });
+ }
+
+ const groupLen = new Map();
+ const groupShown = new Map();
+ const fractions = segs.map((seg) => {
+ if (seg.fill) return -1; // resolved from its group's strokes below
+ if (seg.kind === 'line') return lp;
const show = Math.max(0, Math.min(seg.len, headDrawn));
headDrawn -= seg.len;
- return seg.len - show;
+ groupLen.set(seg.group, (groupLen.get(seg.group) ?? 0) + seg.len);
+ groupShown.set(seg.group, (groupShown.get(seg.group) ?? 0) + show);
+ return seg.len > 0 ? show / seg.len : 1;
+ });
+ return fractions.map((f, i) => {
+ if (f >= 0) return f;
+ const len = groupLen.get(segs[i]!.group) ?? 0;
+ return len > 0 ? clamp01((groupShown.get(segs[i]!.group) ?? 0) / len) : lp;
});
}
diff --git a/src/geometry.ts b/src/geometry.ts
index 4419448..ac13ec2 100644
--- a/src/geometry.ts
+++ b/src/geometry.ts
@@ -210,8 +210,18 @@ export function samplePath(
* bracket); perpendicular sockets get a single L-corner. Center sockets (no
* normal) fall back to the dominant delta axis. rough.js then sketches the
* straight segments for the hand-drawn look.
+ *
+ * `startTrim`/`endTrim` shorten the first/last leg along its own axis (issue
+ * #59: stop the shaft at an arrowhead's base). Corners are computed from the
+ * true endpoints first, so trimming never moves the mid-rail; each trim is
+ * clamped to its leg's length so the leg stops at its corner instead of
+ * inverting past it.
*/
-export function buildElbowPath(ep: Endpoints): string {
+export function buildElbowPath(
+ ep: Endpoints,
+ startTrim = 0,
+ endTrim = 0,
+): string {
const { start: s, end: e, startNormal: sn, endNormal: en } = ep;
const dx = e.x - s.x;
const dy = e.y - s.y;
@@ -236,6 +246,9 @@ export function buildElbowPath(ep: Endpoints): string {
pts = [s, { x: e.x, y: s.y }, e];
}
+ if (startTrim > 0) trimLeg(pts, 0, 1, startTrim);
+ if (endTrim > 0) trimLeg(pts, pts.length - 1, pts.length - 2, endTrim);
+
return (
`M ${r(pts[0]!.x)} ${r(pts[0]!.y)}` +
pts
@@ -245,6 +258,16 @@ export function buildElbowPath(ep: Endpoints): string {
);
}
+/** Move pts[i] toward pts[j] by `by`, clamped to that leg's length. */
+function trimLeg(pts: Point[], i: number, j: number, by: number): void {
+ const a = pts[i]!;
+ const b = pts[j]!;
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
+ if (len === 0) return;
+ const k = Math.min(by, len) / len;
+ pts[i] = { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k };
+}
+
/** Axis-aligned box, in the same coordinate space as the endpoints. */
export interface Box {
left: number;
@@ -350,10 +373,25 @@ export function routeBellies(
return { b1, b2 };
}
-/** Two short strokes forming an arrowhead at `tip`, opening along `dir`. */
-export function arrowHeadPath(tip: Point, dir: Point, size: number): string {
+/** Half-angle of the arrowhead's V. Shared with headBaseInset so the shaft
+ * inset lands exactly on the head's base. */
+const HEAD_SPREAD = Math.PI / 7;
+
+/** Depth of the arrowhead's base behind its tip, along the aim direction. */
+export function headBaseInset(size: number): number {
+ return size * Math.cos(HEAD_SPREAD);
+}
+
+/** Two short strokes forming an arrowhead at `tip`, opening along `dir`.
+ * `closed` appends `Z` for a fillable triangle (solid head style). */
+export function arrowHeadPath(
+ tip: Point,
+ dir: Point,
+ size: number,
+ closed = false,
+): string {
const a = Math.atan2(dir.y, dir.x);
- const spread = Math.PI / 7;
+ const spread = HEAD_SPREAD;
const p1 = {
x: tip.x - size * Math.cos(a - spread),
y: tip.y - size * Math.sin(a - spread),
@@ -362,7 +400,40 @@ export function arrowHeadPath(tip: Point, dir: Point, size: number): string {
x: tip.x - size * Math.cos(a + spread),
y: tip.y - size * Math.sin(a + spread),
};
- return `M ${r(p1.x)} ${r(p1.y)} L ${r(tip.x)} ${r(tip.y)} L ${r(p2.x)} ${r(p2.y)}`;
+ const d = `M ${r(p1.x)} ${r(p1.y)} L ${r(tip.x)} ${r(tip.y)} L ${r(p2.x)} ${r(p2.y)}`;
+ return closed ? `${d} Z` : d;
+}
+
+/**
+ * Pull the endpoints back along their tangents so the shaft terminates at an
+ * arrowhead's base instead of running into its tip (issue #59). The moved
+ * point is `p - tangent * inset` — for a side socket that is `p + normal *
+ * inset`, back along the arrival/departure path. Normals are preserved so
+ * cubicControls still bows the curve off the socket edges. When the summed
+ * insets would eat more than half the chord (tiny arrows), both scale down
+ * proportionally so the shaft keeps at least half the chord and the endpoints
+ * never cross. Curve route only — the elbow route trims its legs instead
+ * (see buildElbowPath).
+ */
+export function insetEndpoints(
+ ep: Endpoints,
+ startInset: number,
+ endInset: number,
+): Endpoints {
+ const total = startInset + endInset;
+ if (total <= 0) return ep;
+ const chord = Math.hypot(ep.end.x - ep.start.x, ep.end.y - ep.start.y);
+ const scale = Math.min(1, chord / 2 / total);
+ const st = startTangent(ep);
+ const et = endTangent(ep);
+ const si = startInset * scale;
+ const ei = endInset * scale;
+ return {
+ start: { x: ep.start.x - st.x * si, y: ep.start.y - st.y * si },
+ end: { x: ep.end.x - et.x * ei, y: ep.end.y - et.y * ei },
+ startNormal: ep.startNormal,
+ endNormal: ep.endNormal,
+ };
}
/** Tangent direction of the cubic at its end, for aiming the arrowhead. */
diff --git a/src/index.ts b/src/index.ts
index d1e3e48..8dceed8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,6 +6,7 @@ export type {
ScrollOptions,
Socket,
ArrowHead,
+ HeadStyle,
ElementRef,
LabelPosition,
Point,
diff --git a/src/scroll-arrow.ts b/src/scroll-arrow.ts
index 57a6e85..8a95741 100644
--- a/src/scroll-arrow.ts
+++ b/src/scroll-arrow.ts
@@ -8,6 +8,8 @@ import {
buildPath,
buildElbowPath,
arrowHeadPath,
+ headBaseInset,
+ insetEndpoints,
endTangent,
startTangent,
unitNormal,
@@ -26,10 +28,11 @@ import {
import { getOverlay, overlayOrigin, createGroup, createSvgEl } from './overlay';
import { mapRoughness, deriveSeed } from './roughness';
import {
- dashOffsets,
+ segmentFractions,
lineProgress,
labelOpacity,
resolveLabelAt,
+ type DrawSegment,
} from './draw';
interface ResolvedRefs {
@@ -65,11 +68,9 @@ export class ScrollArrow {
* Drawable segments. Line strokes (rough.js emits 1-2 overlapping ones) share
* a leading edge so they grow as a single pen tip; heads draw after the line.
*/
- private segments: {
- el: SVGPathElement;
- len: number;
- kind: 'line' | 'head';
- }[] = [];
+ private segments: (DrawSegment & { el: SVGPathElement })[] = [];
+ /** Counter handing each appendDrawable call its reveal group id. */
+ private groupIds = 0;
/** Representative line stroke + label nodes, when a label is set. */
private lineEl: SVGPathElement | null = null;
/**
@@ -249,12 +250,24 @@ export class ScrollArrow {
this.opts.anchorEnds ?? true,
);
+ // Stop the shaft at each drawn head's base instead of its tip (#59). The
+ // heads themselves stay anchored at the true socket points below.
+ const head = this.opts.head;
+ const size = this.opts.headSize;
+ const hasStartHead = head === 'start' || head === 'both';
+ const hasEndHead = head === 'end' || head === 'both';
+ const inset = headBaseInset(size);
+ const startInset = hasStartHead ? inset : 0;
+ const endInset = hasEndHead ? inset : 0;
+
let d: string;
if (this.opts.route === 'elbow') {
// Orthogonal connector: ignores obstacle avoidance and curvature.
- d = buildElbowPath(local);
+ d = buildElbowPath(local, startInset, endInset);
} else {
- // Route around any obstacles, then build the curve.
+ // Route around any obstacles, then build the curve — both against the
+ // inset endpoints, so detection matches the rendered shaft.
+ const shaft = insetEndpoints(local, startInset, endInset);
const obstacles: Box[] = this.resolveAvoid().map((el) => {
const dr = docRect(el);
return {
@@ -265,30 +278,34 @@ export class ScrollArrow {
};
});
const { b1, b2 } = routeBellies(
- local,
+ shaft,
curvature,
obstacles,
this.opts.avoidPadding ?? 14,
);
- d = buildPath(local, curvature, b1, b2);
+ d = buildPath(shaft, curvature, b1, b2);
}
this.lineD = d;
this.appendDrawable(this.rc.path(d, roughOpts), 'line');
- // Arrowheads.
- const head = this.opts.head;
- const size = this.opts.headSize;
- if (head === 'end' || head === 'both') {
+ // Arrowheads, anchored at the true socket points. Solid style closes the
+ // triangle and fills it with the stroke color (#60); rough.js emits the
+ // fill path alongside the outline stroke(s).
+ const solid = this.opts.headStyle === 'solid';
+ const headOpts = solid
+ ? { ...roughOpts, fill: this.stroke, fillStyle: 'solid' }
+ : roughOpts;
+ if (hasEndHead) {
const dir = endTangent(local);
this.appendDrawable(
- this.rc.path(arrowHeadPath(local.end, dir, size), roughOpts),
+ this.rc.path(arrowHeadPath(local.end, dir, size, solid), headOpts),
'head',
);
}
- if (head === 'start' || head === 'both') {
+ if (hasStartHead) {
const dir = startTangent(local);
this.appendDrawable(
- this.rc.path(arrowHeadPath(local.start, dir, size), roughOpts),
+ this.rc.path(arrowHeadPath(local.start, dir, size, solid), headOpts),
'head',
);
}
@@ -299,8 +316,13 @@ export class ScrollArrow {
let longest = 0;
for (const seg of this.segments) {
seg.len = seg.el.getTotalLength();
- seg.el.style.strokeDasharray = String(seg.len);
- seg.el.style.strokeDashoffset = String(seg.len);
+ if (seg.fill) {
+ // A filled head can't dash-reveal; it fades in via opacity instead.
+ seg.el.style.opacity = '0';
+ } else {
+ seg.el.style.strokeDasharray = String(seg.len);
+ seg.el.style.strokeDashoffset = String(seg.len);
+ }
if (seg.kind === 'line' && seg.len >= longest) {
longest = seg.len;
this.lineEl = seg.el;
@@ -374,14 +396,19 @@ export class ScrollArrow {
this.labelEl = label;
}
- /** roughjs returns a of one or more ; collect them in order. */
+ /**
+ * roughjs returns a of one or more ; collect them in order.
+ * Stroke paths already carry fill="none" from rough.js; a solid head's fill
+ * path carries the fill color and is marked so it reveals by opacity.
+ */
private appendDrawable(g: SVGGElement, kind: 'line' | 'head'): void {
+ const group = this.groupIds++;
const paths = g.querySelectorAll('path');
paths.forEach((p) => {
const el = p as SVGPathElement;
- el.setAttribute('fill', 'none');
+ const fill = el.getAttribute('fill') !== 'none' || undefined;
this.group.appendChild(el);
- this.segments.push({ el, len: 0, kind });
+ this.segments.push({ el, len: 0, kind, fill, group });
});
}
@@ -392,9 +419,11 @@ export class ScrollArrow {
*/
private applyProgress(): void {
const eased = this.opts.easing(clamp01(this.progress));
- const offsets = dashOffsets(this.segments, eased);
+ const fractions = segmentFractions(this.segments, eased);
this.segments.forEach((seg, i) => {
- seg.el.style.strokeDashoffset = String(offsets[i]);
+ const f = fractions[i]!;
+ if (seg.fill) seg.el.style.opacity = String(f);
+ else seg.el.style.strokeDashoffset = String(seg.len * (1 - f));
});
if (this.labelEl) {
diff --git a/src/types.ts b/src/types.ts
index 992fbdb..b72739c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -5,6 +5,13 @@ export type Socket = 'auto' | 'top' | 'bottom' | 'left' | 'right' | 'center';
export type ArrowHead = 'start' | 'end' | 'both' | 'none';
+/**
+ * Arrowhead rendering style. `'line'` (default) draws the head as two open
+ * strokes (a V); `'solid'` closes the head into a triangle filled with the
+ * stroke color, keeping the hand-drawn look.
+ */
+export type HeadStyle = 'line' | 'solid';
+
/**
* Line routing style. `curved` (default) is the smooth bezier with single-bend
* obstacle avoidance. `elbow` draws right-angle connectors (tree/org-chart
@@ -105,6 +112,12 @@ export interface ScrollArrowOptions {
head?: ArrowHead;
/** Arrowhead length in px. Default 14. */
headSize?: number;
+ /**
+ * Arrowhead style: `"line"` (default) for an open V, `"solid"` for a filled
+ * triangle in the stroke color. Applies to whichever ends `head` selects.
+ * Solid heads fade in as their outline draws instead of dash-revealing.
+ */
+ headStyle?: HeadStyle;
/** Text to place along the line. Omit for no label. */
label?: string;
diff --git a/test/draw.test.ts b/test/draw.test.ts
index a9f9af1..abb3e1b 100644
--- a/test/draw.test.ts
+++ b/test/draw.test.ts
@@ -1,12 +1,16 @@
import { describe, it, expect } from 'vitest';
import {
- dashOffsets,
+ segmentFractions,
lineProgress,
labelOpacity,
resolveLabelAt,
type DrawSegment,
} from '../src/draw';
+/** The dash offset each stroke segment gets in applyProgress: len * (1 - fraction). */
+const dashOffsets = (segs: DrawSegment[], eased: number): number[] =>
+ segmentFractions(segs, eased).map((f, i) => segs[i]!.len * (1 - f));
+
const line2: DrawSegment[] = [
{ len: 100, kind: 'line' },
{ len: 100, kind: 'line' },
@@ -67,6 +71,61 @@ describe('dashOffsets', () => {
});
});
+describe('segmentFractions with a solid head fill (#60)', () => {
+ // rough.js emits the fill path BEFORE the outline stroke(s) of a head.
+ const solidHead: DrawSegment[] = [
+ { len: 100, kind: 'line', group: 0 },
+ { len: 30, kind: 'head', fill: true, group: 1 },
+ { len: 20, kind: 'head', group: 1 },
+ ];
+
+ it('keeps the fill hidden while the line is still drawing', () => {
+ const [, fill] = segmentFractions(solidHead, 0.5);
+ expect(fill).toBe(0);
+ });
+
+ it("fades the fill with its own head group's stroke fraction", () => {
+ // Budget excludes the fill: total = 100 + 20. At drawn = 110 the head
+ // outline is halfway — the fill's opacity must ride along at 0.5.
+ const [line, fill, stroke] = segmentFractions(solidHead, 110 / 120);
+ expect(line).toBe(1);
+ expect(stroke).toBeCloseTo(0.5);
+ expect(fill).toBeCloseTo(0.5);
+ });
+
+ it('contributes no length to the draw budget', () => {
+ const noFill: DrawSegment[] = [
+ { len: 100, kind: 'line', group: 0 },
+ { len: 20, kind: 'head', group: 1 },
+ ];
+ // The line completes at the same eased progress with and without the
+ // fill segment, and mid-draw pacing matches exactly.
+ expect(segmentFractions(solidHead, 100 / 120)[0]).toBe(1);
+ expect(segmentFractions(noFill, 100 / 120)[0]).toBe(1);
+ expect(segmentFractions(solidHead, 0.5)[0]).toBeCloseTo(
+ segmentFractions(noFill, 0.5)[0]!,
+ );
+ });
+
+ it('shows everything at progress 1 (reduced motion / static)', () => {
+ expect(segmentFractions(solidHead, 1)).toEqual([1, 1, 1]);
+ });
+
+ it('each solid head fades with its own slot for head: both', () => {
+ const twoSolidHeads: DrawSegment[] = [
+ { len: 100, kind: 'line', group: 0 },
+ { len: 15, kind: 'head', fill: true, group: 1 },
+ { len: 10, kind: 'head', group: 1 },
+ { len: 15, kind: 'head', fill: true, group: 2 },
+ { len: 10, kind: 'head', group: 2 },
+ ];
+ // total = 100 + 10 + 10; drawn = 115 -> first head done, second halfway.
+ const [, fill1, , fill2] = segmentFractions(twoSolidHeads, 115 / 120);
+ expect(fill1).toBe(1);
+ expect(fill2).toBeCloseTo(0.5);
+ });
+});
+
describe('lineProgress', () => {
it('reaches 1 before overall progress (line finishes before head)', () => {
// line 100, head 20 -> line done at eased 100/120
diff --git a/test/geometry.test.ts b/test/geometry.test.ts
index 588ef20..450978e 100644
--- a/test/geometry.test.ts
+++ b/test/geometry.test.ts
@@ -10,6 +10,9 @@ import {
routeBellies,
samplePath,
isDegenerateRect,
+ insetEndpoints,
+ headBaseInset,
+ type Endpoints,
type DocRect,
type Box,
} from '../src/geometry';
@@ -188,6 +191,17 @@ describe('arrowHeadPath', () => {
const d = arrowHeadPath({ x: 100, y: 0 }, { x: 1, y: 0 }, 14);
expect(d).toMatch(/^M .+ L 100 0 L .+$/);
});
+
+ it('stays open by default (no Z, exact current shape)', () => {
+ const d = arrowHeadPath({ x: 100, y: 0 }, { x: 1, y: 0 }, 14);
+ expect(d).not.toContain('Z');
+ });
+
+ it('closes the triangle when closed=true, same three points', () => {
+ const open = arrowHeadPath({ x: 100, y: 0 }, { x: 1, y: 0 }, 14);
+ const closed = arrowHeadPath({ x: 100, y: 0 }, { x: 1, y: 0 }, 14, true);
+ expect(closed).toBe(`${open} Z`);
+ });
});
describe('tangents', () => {
@@ -405,3 +419,146 @@ function controlX(d: string): number {
const cIdx = parts.indexOf('C');
return Number(parts[cIdx + 1]);
}
+
+describe('headBaseInset', () => {
+ it('is the head base depth: size * cos(spread)', () => {
+ expect(headBaseInset(14)).toBeCloseTo(14 * Math.cos(Math.PI / 7), 6);
+ });
+});
+
+describe('insetEndpoints', () => {
+ const facing = (): Endpoints => ({
+ start: { x: 100, y: 50 },
+ end: { x: 300, y: 50 },
+ startNormal: { x: 1, y: 0 },
+ endNormal: { x: -1, y: 0 },
+ });
+
+ it('moves the end back along the arrival path (+endNormal)', () => {
+ const out = insetEndpoints(facing(), 0, 10);
+ expect(out.end).toEqual({ x: 290, y: 50 });
+ expect(out.start).toEqual({ x: 100, y: 50 });
+ expect(out.startNormal).toEqual({ x: 1, y: 0 });
+ expect(out.endNormal).toEqual({ x: -1, y: 0 });
+ });
+
+ it('moves the start forward along the departure path (+startNormal)', () => {
+ const out = insetEndpoints(facing(), 10, 0);
+ expect(out.start).toEqual({ x: 110, y: 50 });
+ expect(out.end).toEqual({ x: 300, y: 50 });
+ });
+
+ it('applies both insets independently', () => {
+ const out = insetEndpoints(facing(), 5, 10);
+ expect(out.start).toEqual({ x: 105, y: 50 });
+ expect(out.end).toEqual({ x: 290, y: 50 });
+ });
+
+ it('is the identity for zero insets (head: none)', () => {
+ const ep = facing();
+ const out = insetEndpoints(ep, 0, 0);
+ expect(out.start).toEqual(ep.start);
+ expect(out.end).toEqual(ep.end);
+ });
+
+ it('falls back along the chord for center sockets (zero normals)', () => {
+ const ep: Endpoints = {
+ start: { x: 0, y: 0 },
+ end: { x: 100, y: 0 },
+ startNormal: { x: 0, y: 0 },
+ endNormal: { x: 0, y: 0 },
+ };
+ const out = insetEndpoints(ep, 10, 10);
+ expect(out.start.x).toBeCloseTo(10, 6);
+ expect(out.end.x).toBeCloseTo(90, 6);
+ });
+
+ it('clamps on a short facing-collinear chord so the shaft keeps half of it', () => {
+ // Chord 10, insets 12+12: scale to 5 total; endpoints never cross.
+ const ep: Endpoints = {
+ start: { x: 0, y: 0 },
+ end: { x: 10, y: 0 },
+ startNormal: { x: 1, y: 0 },
+ endNormal: { x: -1, y: 0 },
+ };
+ const out = insetEndpoints(ep, 12, 12);
+ expect(out.start.x).toBeCloseTo(2.5, 6);
+ expect(out.end.x).toBeCloseTo(7.5, 6);
+ expect(out.end.x - out.start.x).toBeCloseTo(5, 6); // ≥ half the chord
+ });
+});
+
+describe('buildElbowPath leg trims', () => {
+ const lastPoint = (d: string): { x: number; y: number } => {
+ const parts = d.trim().split(/\s+/);
+ return {
+ x: Number(parts[parts.length - 2]),
+ y: Number(parts[parts.length - 1]),
+ };
+ };
+
+ it('trims only the final leg; corners stay put', () => {
+ // Horizontal exit, vertical arrival: corner over the end.
+ const ep: Endpoints = {
+ start: { x: 0, y: 0 },
+ end: { x: 200, y: 50 },
+ startNormal: { x: 1, y: 0 },
+ endNormal: { x: 0, y: -1 },
+ };
+ const plain = buildElbowPath(ep);
+ const trimmed = buildElbowPath(ep, 0, 10);
+ expect(plain).toContain('L 200 0'); // corner
+ expect(trimmed).toContain('L 200 0'); // corner unchanged
+ expect(lastPoint(trimmed)).toEqual({ x: 200, y: 40 });
+ });
+
+ it('clamps the trim to the leg length so the leg never inverts past its corner', () => {
+ // Final leg is only 5px (end nearly level with the start rail).
+ const ep: Endpoints = {
+ start: { x: 0, y: 0 },
+ end: { x: 200, y: 5 },
+ startNormal: { x: 1, y: 0 },
+ endNormal: { x: 0, y: -1 },
+ };
+ const trimmed = buildElbowPath(ep, 0, headBaseInset(14));
+ expect(lastPoint(trimmed)).toEqual({ x: 200, y: 0 }); // stops at the corner
+ });
+
+ it('trims the first leg toward its corner', () => {
+ const ep: Endpoints = {
+ start: { x: 0, y: 0 },
+ end: { x: 200, y: 50 },
+ startNormal: { x: 1, y: 0 },
+ endNormal: { x: 0, y: -1 },
+ };
+ const trimmed = buildElbowPath(ep, 10, 0);
+ expect(trimmed.startsWith('M 10 0')).toBe(true);
+ });
+
+ it('leaves the mid-rail of a same-axis elbow untouched', () => {
+ const ep: Endpoints = {
+ start: { x: 0, y: 0 },
+ end: { x: 200, y: 300 },
+ startNormal: { x: 0, y: 1 },
+ endNormal: { x: 0, y: -1 },
+ };
+ const plain = buildElbowPath(ep);
+ const trimmed = buildElbowPath(ep, 10, 10);
+ expect(plain).toContain('L 0 150');
+ expect(plain).toContain('L 200 150');
+ expect(trimmed).toContain('L 0 150'); // mid-rail identical
+ expect(trimmed).toContain('L 200 150');
+ expect(trimmed.startsWith('M 0 10')).toBe(true);
+ expect(lastPoint(trimmed)).toEqual({ x: 200, y: 290 });
+ });
+
+ it('is unchanged for zero trims', () => {
+ const ep: Endpoints = {
+ start: { x: 0, y: 0 },
+ end: { x: 200, y: 300 },
+ startNormal: { x: 0, y: 1 },
+ endNormal: { x: 0, y: -1 },
+ };
+ expect(buildElbowPath(ep, 0, 0)).toBe(buildElbowPath(ep));
+ });
+});
diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts
index 8a9b409..c8dbcad 100644
--- a/test/scroll-arrow.test.ts
+++ b/test/scroll-arrow.test.ts
@@ -24,6 +24,31 @@ function boxed(rect: Partial): HTMLElement {
return el;
}
+/** Render an arrow, return every path's { d, fill } (line stroke(s) first,
+ * then heads), then clean up. */
+function paths(
+ opts: ConstructorParameters[0],
+): { d: string; fill: string }[] {
+ new ScrollArrow(opts);
+ const out = [...document.querySelectorAll('svg path')].map((p) => ({
+ d: p.getAttribute('d') ?? '',
+ fill: p.getAttribute('fill') ?? '',
+ }));
+ document.querySelectorAll('svg').forEach((s) => s.remove());
+ return out;
+}
+
+/** Render an arrow, return every path's `d` (line stroke(s) first, then heads). */
+function pathDs(opts: ConstructorParameters[0]): string[] {
+ return paths(opts).map((p) => p.d);
+}
+
+/** Standard side-by-side anchors: start socket (100, 20), end socket (300, 20). */
+const anchors = () => ({
+ start: boxed({ left: 0, top: 0, width: 100, height: 40 }),
+ end: boxed({ left: 300, top: 0, width: 100, height: 40 }),
+});
+
beforeEach(() => {
ioInstances = [];
// @ts-expect-error -- minimal stub for jsdom
@@ -129,12 +154,8 @@ describe('ScrollArrow avoid routing', () => {
.getTotalLength;
});
- function lineD(opts: ConstructorParameters[0]): string {
- new ScrollArrow(opts);
- const d = document.querySelector('svg path')?.getAttribute('d') ?? '';
- document.querySelectorAll('svg').forEach((s) => s.remove());
- return d;
- }
+ const lineD = (opts: ConstructorParameters[0]): string =>
+ pathDs(opts)[0] ?? '';
it('bends the curve when an avoided obstacle blocks it', () => {
const start = boxed({ left: 0, top: 0, width: 100, height: 40 });
@@ -166,3 +187,224 @@ describe('ScrollArrow avoid routing', () => {
expect(routed).toBe(plain);
});
});
+
+describe('ScrollArrow shaft/head junction (#59)', () => {
+ // Same jsdom SVG-geometry stub as the avoid-routing suite above, plus the
+ // label-measurement calls (getPointAtLength/getBBox). rough.js is
+ // deterministic for a fixed seed, so DOM `d` strings witness geometry.
+ type SvgStubs = {
+ getTotalLength?: unknown;
+ getPointAtLength?: unknown;
+ getBBox?: unknown;
+ };
+ beforeEach(() => {
+ const proto = SVGElement.prototype as SvgStubs;
+ proto.getTotalLength = () => 100;
+ proto.getPointAtLength = (l: number) => ({ x: l, y: 20 });
+ proto.getBBox = () => ({ x: 0, y: 0, width: 20, height: 10 });
+ });
+ afterEach(() => {
+ const proto = SVGElement.prototype as SvgStubs;
+ delete proto.getTotalLength;
+ delete proto.getPointAtLength;
+ delete proto.getBBox;
+ });
+
+ it('pulls the shaft back from the tip when an end head is drawn (R1)', () => {
+ const a = anchors();
+ const noHead = pathDs({ ...a, seed: 7, head: 'none' })[0];
+ const withHead = pathDs({ ...a, seed: 7, head: 'end' })[0];
+ expect(noHead).toBeTruthy();
+ expect(withHead).toBeTruthy();
+ expect(withHead).not.toBe(noHead);
+ });
+
+ it('leaves the shaft untouched when no head is drawn (R4)', () => {
+ // headSize can only reach the line through the inset; with head:'none'
+ // the shaft must not depend on it.
+ const a = anchors();
+ const small = pathDs({ ...a, seed: 7, head: 'none', headSize: 14 })[0];
+ const large = pathDs({ ...a, seed: 7, head: 'none', headSize: 28 })[0];
+ expect(small).toBe(large);
+ });
+
+ it('scales the shaft inset with headSize', () => {
+ const a = anchors();
+ const small = pathDs({ ...a, seed: 7, head: 'end', headSize: 14 })[0];
+ const large = pathDs({ ...a, seed: 7, head: 'end', headSize: 28 })[0];
+ expect(small).not.toBe(large);
+ });
+
+ it('insets the start too for head: both (R2)', () => {
+ const a = anchors();
+ const endOnly = pathDs({ ...a, seed: 7, head: 'end' })[0];
+ const both = pathDs({ ...a, seed: 7, head: 'both' })[0];
+ expect(both).not.toBe(endOnly);
+ });
+
+ it('keeps the arrowhead anchored at the true socket point (R3)', () => {
+ // anchorEnds (preserveVertices) keeps exact vertices, so the head path
+ // must still pass through the true end socket (300, 20) even though the
+ // shaft now stops short of it.
+ const a = anchors();
+ const ds = pathDs({ ...a, seed: 7, head: 'end' });
+ const headD = ds[ds.length - 1]!;
+ expect(headD).toMatch(/300[ ,]+20/);
+ });
+
+ it('insets the shaft when only a start head is drawn (#61)', () => {
+ const a = anchors();
+ const noHead = pathDs({ ...a, seed: 7, head: 'none' })[0];
+ const withHead = pathDs({ ...a, seed: 7, head: 'start' })[0];
+ expect(noHead).toBeTruthy();
+ expect(withHead).toBeTruthy();
+ expect(withHead).not.toBe(noHead);
+ });
+
+ it('keeps the start head anchored at the true start socket (#61)', () => {
+ // Mirror of R3: with anchorEnds the start head must still pass through the
+ // true start socket (100, 20) even though the shaft now starts short of it.
+ const a = anchors();
+ const ds = pathDs({ ...a, seed: 7, head: 'start' });
+ const headD = ds[ds.length - 1]!;
+ expect(headD).toMatch(/100[ ,]+20/);
+ });
+
+ it("trims the elbow's start leg for head: 'start' (#61)", () => {
+ const a = anchors();
+ const noHead = pathDs({ ...a, seed: 7, route: 'elbow', head: 'none' })[0];
+ const withHead = pathDs({
+ ...a,
+ seed: 7,
+ route: 'elbow',
+ head: 'start',
+ })[0];
+ expect(withHead).not.toBe(noHead);
+ });
+
+ it('trims the elbow shaft when an end head is drawn (R5)', () => {
+ const a = anchors();
+ const noHead = pathDs({ ...a, seed: 7, route: 'elbow', head: 'none' })[0];
+ const withHead = pathDs({ ...a, seed: 7, route: 'elbow', head: 'end' })[0];
+ expect(withHead).not.toBe(noHead);
+ });
+
+ it('still renders a label on a headed arrow', () => {
+ const a = anchors();
+ new ScrollArrow({ ...a, seed: 7, head: 'both', label: 'hi' });
+ expect(document.querySelector('svg text')?.textContent).toBe('hi');
+ document.querySelectorAll('svg').forEach((s) => s.remove());
+ });
+});
+
+describe("ScrollArrow headStyle: 'solid' (#60)", () => {
+ // Same jsdom SVG-geometry stubs as the #59 suite above.
+ type SvgStubs = { getTotalLength?: unknown };
+ beforeEach(() => {
+ (SVGElement.prototype as SvgStubs).getTotalLength = () => 100;
+ });
+ afterEach(() => {
+ delete (SVGElement.prototype as SvgStubs).getTotalLength;
+ });
+
+ it('fills the end head with the stroke color', () => {
+ const a = anchors();
+ const ps = paths({
+ ...a,
+ seed: 7,
+ head: 'end',
+ headStyle: 'solid',
+ stroke: '#123456',
+ });
+ const fills = ps.filter((p) => p.fill !== 'none');
+ expect(fills).toHaveLength(1);
+ expect(fills[0]!.fill).toBe('#123456');
+ });
+
+ it('keeps the solid head anchored at the true socket point', () => {
+ // The head's outline stroke still passes through the end socket (300, 20).
+ const a = anchors();
+ const ps = paths({ ...a, seed: 7, head: 'end', headStyle: 'solid' });
+ const headStroke = ps[ps.length - 1]!;
+ expect(headStroke.fill).toBe('none');
+ expect(headStroke.d).toMatch(/300[ ,]+20/);
+ });
+
+ it("renders identically for omitted vs explicit 'line' (R2)", () => {
+ const a = anchors();
+ const dflt = paths({ ...a, seed: 7, head: 'end' });
+ const line = paths({ ...a, seed: 7, head: 'end', headStyle: 'line' });
+ expect(line).toEqual(dflt);
+ expect(dflt.every((p) => p.fill === 'none')).toBe(true);
+ });
+
+ it("fills both heads for head: 'both'", () => {
+ const a = anchors();
+ const ps = paths({
+ ...a,
+ seed: 7,
+ head: 'both',
+ headStyle: 'solid',
+ stroke: '#123456',
+ });
+ expect(ps.filter((p) => p.fill === '#123456')).toHaveLength(2);
+ });
+
+ it('fades the fill in: hidden at progress 0, shown fully drawn', () => {
+ const a = anchors();
+ const arrow = new ScrollArrow({
+ ...a,
+ seed: 7,
+ head: 'end',
+ headStyle: 'solid',
+ stroke: '#123456',
+ scroll: false,
+ });
+ const fillEl = [...document.querySelectorAll('svg path')].find(
+ (p) => p.getAttribute('fill') === '#123456',
+ ) as SVGPathElement;
+ expect(fillEl.style.opacity).toBe('0');
+ arrow.setProgress(1);
+ expect(fillEl.style.opacity).toBe('1');
+ document.querySelectorAll('svg').forEach((s) => s.remove());
+ });
+
+ it("fades each solid head independently for head: 'both'", () => {
+ // Proves appendDrawable's real group ids drive per-head fades: at an
+ // intermediate progress the end head's fill is fully in while the start
+ // head's is still fading (heads reveal sequentially after the line).
+ const a = anchors();
+ const arrow = new ScrollArrow({
+ ...a,
+ seed: 7,
+ head: 'both',
+ headStyle: 'solid',
+ stroke: '#123456',
+ scroll: false,
+ });
+ arrow.setProgress(0.6);
+ const opacities = [...document.querySelectorAll('svg path')]
+ .filter((p) => p.getAttribute('fill') === '#123456')
+ .map((p) => Number((p as SVGPathElement).style.opacity));
+ expect(opacities).toHaveLength(2);
+ const hi = Math.max(...opacities);
+ const lo = Math.min(...opacities);
+ expect(hi).toBe(1);
+ expect(lo).toBeGreaterThan(0);
+ expect(lo).toBeLessThan(1);
+ document.querySelectorAll('svg').forEach((s) => s.remove());
+ });
+
+ it("line paths keep fill='none' regardless of headStyle", () => {
+ const a = anchors();
+ const ps = paths({
+ ...a,
+ seed: 7,
+ head: 'end',
+ headStyle: 'solid',
+ stroke: '#123456',
+ });
+ // First path(s) are the line strokes; none of them may pick up the fill.
+ expect(ps[0]!.fill).toBe('none');
+ });
+});