Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const arrow = scrollArrow({
stroke: '#e7e9ee',
strokeWidth: 2.5,
head: 'end', // "start" | "end" | "both" | "none"
headStyle: 'solid', // "line" (open V, default) | "solid" (filled triangle)
});

// later
Expand Down Expand Up @@ -237,7 +238,7 @@ the runtime arrow mounts in its own overlay and won't clash with your static one

Key options: `start`, `end`, `container`, `roughness`, `stroke`, `strokeWidth`,
`seed`, `startSocket`, `endSocket`, `startSocketOffset`, `endSocketOffset`,
`curvature`, `route`, `head`, `headSize`, `scroll`, `speed`, `easing`,
`curvature`, `route`, `head`, `headSize`, `headStyle`, `scroll`, `speed`, `easing`,
`progress`, `enabled`. Full types ship with the package. `setEnabled(on)` toggles
an arrow (and a group) on/off without teardown.

Expand Down
23 changes: 21 additions & 2 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,14 @@ <h2>Obstacle avoidance</h2>
</div>
</section>

<section class="section">
<h2>Solid arrowheads · filled</h2>
<div class="stage row">
<div class="box" id="s-a">open V</div>
<div class="box brown" id="s-b">filled</div>
</div>
</section>

<section class="section">
<h2>Staggered group · tree reveal</h2>
<div class="stage tree">
Expand Down Expand Up @@ -415,6 +423,17 @@ <h2>Elbow routing · org chart</h2>
labelAt: '22%',
},
],
[
{
start: '#s-b',
end: '#s-a',
roughness: 0.5,
head: 'both',
headStyle: 'solid',
headSize: 18,
label: 'filled',
},
],
];

examples.forEach(([opts], i) => {
Expand Down Expand Up @@ -443,7 +462,7 @@ <h2>Elbow routing · org chart</h2>
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();
Expand All @@ -460,7 +479,7 @@ <h2>Elbow routing · org chart</h2>
};
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);
Expand Down
172 changes: 172 additions & 0 deletions docs/plans/2026-07-06-002-feat-solid-arrowhead-plan.md
Original file line number Diff line number Diff line change
@@ -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 `<path>` 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.
64 changes: 49 additions & 15 deletions src/draw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | undefined, number>();
const groupShown = new Map<number | undefined, number>();
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;
});
}

Expand Down
13 changes: 10 additions & 3 deletions src/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,14 @@ export function headBaseInset(size: number): number {
return size * Math.cos(HEAD_SPREAD);
}

/** Two short strokes forming an arrowhead at `tip`, opening along `dir`. */
export function arrowHeadPath(tip: Point, dir: Point, size: number): string {
/** 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 = HEAD_SPREAD;
const p1 = {
Expand All @@ -394,7 +400,8 @@ 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;
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {
ScrollOptions,
Socket,
ArrowHead,
HeadStyle,
ElementRef,
LabelPosition,
Point,
Expand Down
Loading
Loading