@knowvah/dot-engine 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
4
4
  "description": "Faithful TypeScript port of Graphviz (dot, neato, fdp, sfdp, circo, twopi, osage, patchwork) — no C: no native binary, no WASM. Oracle-verified against the C implementation, runs in the browser.",
5
5
  "keywords": [
6
6
  "graphviz",
@@ -100,7 +100,7 @@
100
100
  },
101
101
  "devDependencies": {
102
102
  "@fontsource/jetbrains-mono": "^5.3.0",
103
- "@hpcc-js/wasm-graphviz": "1.28.0",
103
+ "@hpcc-js/wasm-graphviz": "1.29.2",
104
104
  "@knowvah/theme": "0.3.0",
105
105
  "@knowvah/vitepress-plugin-dot": "^3.0.1",
106
106
  "@semantic-release/commit-analyzer": "^13.0.1",
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+ //
3
+ // Which precomputed arrow ops to draw for each bezier of an edge.
4
+ //
5
+ // C draws arrows at render time inside emit_edge_graphics' per-bezier loop:
6
+ // every bezier whose sflag/eflag is set gets its own arrow_gen call. The port
7
+ // computes arrows at clip time (arrowhead-geometry ADR-2) and keeps one op list
8
+ // per edge end (`e.info.tailArrowOps` / `headArrowOps`) — enough while at most
9
+ // one bezier carries each flag, which is every spline except neato's
10
+ // multispline fallback (neatosplines.c:683-701: makeMultiSpline fails partway,
11
+ // then makeSpline appends a second flagged bezier). For that case each bezier
12
+ // also carries its own ops (`Bezier.sArrowOps` / `eArrowOps`, set by
13
+ // clipAndInstall), and these helpers hand the emitters one list per bezier.
14
+ // With at most one flagged bezier per end, the edge-level ops stay the source:
15
+ // dot's swap, flat-edge and compound passes rewrite only those.
16
+ //
17
+ // @see lib/common/emit.c:emit_edge_graphics (2669-2675)
18
+
19
+ import type { Edge } from '../model/edge.js';
20
+ import type { Bezier } from '../model/geom.js';
21
+ import type { ArrowDrawOp } from './arrows-types.js';
22
+
23
+ /** Edge end: `tail` pairs with a bezier's sflag/sp, `head` with eflag/ep. */
24
+ export type ArrowEnd = 'head' | 'tail';
25
+
26
+ const flagOf = (bz: Bezier, end: ArrowEnd): number => (end === 'head' ? bz.eflag : bz.sflag);
27
+ const ownOps = (bz: Bezier, end: ArrowEnd): ArrowDrawOp[] | undefined =>
28
+ (end === 'head' ? bz.eArrowOps : bz.sArrowOps);
29
+ const edgeOps = (e: Edge, end: ArrowEnd): ArrowDrawOp[] | undefined =>
30
+ (end === 'head' ? e.info.headArrowOps : e.info.tailArrowOps);
31
+
32
+ function flaggedBeziers(e: Edge, end: ArrowEnd): Bezier[] {
33
+ const list = (e.info.spl?.list ?? []) as (Bezier | undefined)[];
34
+ return list.filter((bz): bz is Bezier => bz !== undefined && flagOf(bz, end) !== 0);
35
+ }
36
+
37
+ /** Two or more beziers carry `end`'s flag: C draws an arrow for each. */
38
+ function multiFlagged(e: Edge, end: ArrowEnd): boolean {
39
+ return flaggedBeziers(e, end).length > 1;
40
+ }
41
+
42
+ /**
43
+ * The ops C's per-bezier loop draws right after `bz` for `end`, when the edge
44
+ * has several beziers flagged at that end; undefined otherwise (the caller's
45
+ * edge-level path applies).
46
+ */
47
+ export function bezierEndOps(e: Edge, bz: Bezier, end: ArrowEnd): ArrowDrawOp[] | undefined {
48
+ if (flagOf(bz, end) === 0 || !multiFlagged(e, end)) return undefined;
49
+ return ownOps(bz, end) ?? edgeOps(e, end);
50
+ }
51
+
52
+ /** Every arrow to draw for `end`, one op list per arrow, in bezier order. */
53
+ export function endArrowOps(e: Edge, end: ArrowEnd): ArrowDrawOp[][] {
54
+ const lists = multiFlagged(e, end)
55
+ ? flaggedBeziers(e, end).map((bz) => ownOps(bz, end) ?? edgeOps(e, end))
56
+ : [edgeOps(e, end)];
57
+ return lists.filter((ops): ops is ArrowDrawOp[] => ops !== undefined && ops.length > 0);
58
+ }
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * Set of points, compared exactly on their double coordinates.
5
+ *
6
+ * C keeps the points in an ordered cdt dictionary (`Dtoset`) under `cmppair`:
7
+ * x first, then y, plain double `<`/`>`, so -0 and 0 are one key. Here the
8
+ * dictionary is a two-level `Map` keyed on the coordinates themselves. Map
9
+ * keys use SameValueZero, which also makes -0 and 0 one key. A lookup
10
+ * allocates nothing; packing calls it once per cell per candidate position.
11
+ *
12
+ * @see lib/common/pointset.c
13
+ */
14
+
15
+ import type { Point } from '../model/geom.js';
16
+
17
+ /** @see lib/common/pointset.c:cmppair */
18
+ function cmpPair(a: Point, b: Point): number {
19
+ if (a.x > b.x) return 1;
20
+ if (a.x < b.x) return -1;
21
+ if (a.y > b.y) return 1;
22
+ if (a.y < b.y) return -1;
23
+ return 0;
24
+ }
25
+
26
+ /** @see lib/common/pointset.h:PointSet */
27
+ export class PointSet {
28
+ private readonly byX = new Map<number, Map<number, Point>>();
29
+ private n = 0;
30
+
31
+ /**
32
+ * Insert (x, y). A duplicate keeps the point inserted first, as `dtinsert`
33
+ * returns the existing object and the new one is freed.
34
+ * @see lib/common/pointset.c:addPS
35
+ */
36
+ addPS(x: number, y: number): void {
37
+ let col = this.byX.get(x);
38
+ if (col === undefined) {
39
+ col = new Map();
40
+ this.byX.set(x, col);
41
+ }
42
+ if (col.has(y)) return;
43
+ col.set(y, { x, y });
44
+ this.n++;
45
+ }
46
+
47
+ /** @see lib/common/pointset.c:inPS */
48
+ inPS(pt: Point): boolean {
49
+ return this.byX.get(pt.x)?.has(pt.y) ?? false;
50
+ }
51
+
52
+ /** @see lib/common/pointset.c:isInPS */
53
+ isInPS(x: number, y: number): boolean {
54
+ return this.byX.get(x)?.has(y) ?? false;
55
+ }
56
+
57
+ /** @see lib/common/pointset.c:sizeOf */
58
+ sizeOf(): number {
59
+ return this.n;
60
+ }
61
+
62
+ /**
63
+ * The points in dictionary order (`dtflatten` walks the ordered set).
64
+ * @see lib/common/pointset.c:pointsOf
65
+ */
66
+ pointsOf(): Point[] {
67
+ const pts: Point[] = [];
68
+ for (const col of this.byX.values()) {
69
+ for (const p of col.values()) pts.push({ x: p.x, y: p.y });
70
+ }
71
+ return pts.sort(cmpPair);
72
+ }
73
+ }
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { Graph } from '../model/graph.js';
19
- import type { Point, Box } from '../model/geom.js';
19
+ import type { Point, Box, Bezier } from '../model/geom.js';
20
20
  import { ccwrotatepf } from '../model/geom.js';
21
21
  import type { ArrowDrawOp } from './arrows-types.js';
22
22
  import { mapArrowOpPoints } from './arrows-shapes-util.js';
@@ -39,9 +39,12 @@ const IGNORED_EDGE = 6;
39
39
  let Rankdir = 0;
40
40
  let Offset: Point = { x: 0, y: 0 };
41
41
 
42
+ /** The bezier fields map_edge reads/writes. */
43
+ type MappedBezier = Pick<Bezier, 'list' | 'sflag' | 'eflag' | 'sp' | 'ep' | 'sArrowOps' | 'eArrowOps'>;
44
+
42
45
  /** The edge-info shape map_edge reads/writes (spline, labels, arrow ops). */
43
46
  interface EdgeArrowInfo {
44
- spl?: { list: Array<{ list: Point[]; sflag: number; eflag: number; sp: Point; ep: Point }> };
47
+ spl?: { list: MappedBezier[] };
45
48
  label?: unknown;
46
49
  xlabel?: unknown;
47
50
  head_label?: unknown;
@@ -116,17 +119,6 @@ function mapArrowOps(ops: ArrowDrawOp[] | undefined): ArrowDrawOp[] | undefined
116
119
  });
117
120
  }
118
121
 
119
- /** The edge-info shape map_edge reads/writes (spline, labels, arrow ops). */
120
- interface EdgeArrowInfo {
121
- spl?: { list: Array<{ list: Point[]; sflag: number; eflag: number; sp: Point; ep: Point }> };
122
- label?: unknown;
123
- xlabel?: unknown;
124
- head_label?: unknown;
125
- tail_label?: unknown;
126
- headArrowOps?: ArrowDrawOp[];
127
- tailArrowOps?: ArrowDrawOp[];
128
- }
129
-
130
122
  /**
131
123
  * Map spline control points, label positions, and pre-computed arrow draw-ops
132
124
  * for one edge. The C port regenerates arrowheads at render time from the
@@ -145,6 +137,10 @@ function mapEdge(e: { info: EdgeArrowInfo }): void {
145
137
  // Rotate pre-computed arrowhead draw-ops (TS-only: C computes at render time).
146
138
  e.info.headArrowOps = mapArrowOps(e.info.headArrowOps);
147
139
  e.info.tailArrowOps = mapArrowOps(e.info.tailArrowOps);
140
+ for (const bz of spl.list) {
141
+ if (bz.sArrowOps) bz.sArrowOps = mapArrowOps(bz.sArrowOps);
142
+ if (bz.eArrowOps) bz.eArrowOps = mapArrowOps(bz.eArrowOps);
143
+ }
148
144
  }
149
145
 
150
146
  // ---------------------------------------------------------------------------
@@ -258,20 +258,20 @@ class SplineClipHelper {
258
258
  if (sflag || eflag) {
259
259
  SplineClipHelper.arrowOrthoClip(
260
260
  orig, ps, bounds.start, bounds.end, newspl, sflag, eflag);
261
- if (sflag) SplineClipHelper.stashArrow(orig, newspl.sp, ps[bounds.start]!, true, startEnd);
262
- if (eflag) SplineClipHelper.stashArrow(orig, newspl.ep, ps[bounds.end + 3]!, false, endEnd);
261
+ if (sflag) SplineClipHelper.stashArrow(orig, newspl, newspl.sp, ps[bounds.start]!, true, startEnd);
262
+ if (eflag) SplineClipHelper.stashArrow(orig, newspl, newspl.ep, ps[bounds.end + 3]!, false, endEnd);
263
263
  }
264
264
  return;
265
265
  }
266
266
  if (sflag) {
267
267
  bounds.start = SplineClipHelper.arrowStartClip(
268
268
  orig, ps, bounds.start, bounds.end, newspl, sflag, startEnd);
269
- SplineClipHelper.stashArrow(orig, newspl.sp, ps[bounds.start]!, true, startEnd);
269
+ SplineClipHelper.stashArrow(orig, newspl, newspl.sp, ps[bounds.start]!, true, startEnd);
270
270
  }
271
271
  if (eflag) {
272
272
  bounds.end = SplineClipHelper.arrowEndClip(
273
273
  orig, ps, bounds.start, bounds.end, newspl, eflag, endEnd);
274
- SplineClipHelper.stashArrow(orig, newspl.ep, ps[bounds.end + 3]!, false, endEnd);
274
+ SplineClipHelper.stashArrow(orig, newspl, newspl.ep, ps[bounds.end + 3]!, false, endEnd);
275
275
  }
276
276
  }
277
277
 
@@ -336,15 +336,19 @@ class SplineClipHelper {
336
336
  }
337
337
  }
338
338
 
339
- /** Record an arrow polygon on the edge for svgArrowPolygons. */
339
+ /**
340
+ * Record an arrow on the edge end and on the bezier being installed. The
341
+ * edge slot holds the latest arrow; the bezier keeps its own, since C draws
342
+ * one per flagged bezier (see common/bezier-arrows.ts).
343
+ */
340
344
  static stashArrow(
341
- e: Edge, tip: Point, base: Point, isTail: boolean, attrEnd?: 'head' | 'tail',
345
+ e: Edge, bz: Bezier, tip: Point, base: Point, isTail: boolean, attrEnd?: 'head' | 'tail',
342
346
  ): void {
343
347
  const dir = { x: base.x - tip.x, y: base.y - tip.y };
344
348
  const end = attrEnd ?? (isTail ? 'tail' : 'head');
345
349
  const ops = arrowDrawOpsForEnd(e, end, tip, dir, SplineClipHelper.renderPenwidth(e));
346
- if (isTail) e.info.tailArrowOps = ops;
347
- else e.info.headArrowOps = ops;
350
+ if (isTail) { e.info.tailArrowOps = ops; bz.sArrowOps = ops; }
351
+ else { e.info.headArrowOps = ops; bz.eArrowOps = ops; }
348
352
  }
349
353
 
350
354
  static clipSide(n: Node, box: Box | null, ps: Point[], pn: number, tail: boolean): number {
@@ -290,6 +290,10 @@ export function swapBezier(b: Bezier): void {
290
290
  }
291
291
  const sf = b.sflag; b.sflag = b.eflag; b.eflag = sf;
292
292
  const sp = b.sp; b.sp = b.ep; b.ep = sp;
293
+ // TS-only per-bezier arrow ops stay paired with their flag.
294
+ if (b.sArrowOps !== undefined || b.eArrowOps !== undefined) {
295
+ const so = b.sArrowOps; b.sArrowOps = b.eArrowOps; b.eArrowOps = so;
296
+ }
293
297
  }
294
298
 
295
299
  /** Reverse the list of beziers in a spline and swap each bezier. */
@@ -313,6 +313,10 @@ export function shiftEdgePoints(e: Edge, dx: number, dy: number): void {
313
313
  ops?.map((op) => mapArrowOpPoints(op, (p) => ({ x: p.x + dx, y: p.y + dy })));
314
314
  e.info.headArrowOps = shiftOps(e.info.headArrowOps);
315
315
  e.info.tailArrowOps = shiftOps(e.info.tailArrowOps);
316
+ for (const bz of spl.list) {
317
+ if (bz.sArrowOps) bz.sArrowOps = shiftOps(bz.sArrowOps);
318
+ if (bz.eArrowOps) bz.eArrowOps = shiftOps(bz.eArrowOps);
319
+ }
316
320
  }
317
321
 
318
322
  export function shiftOneGraph(g: Graph, dx: number, dy: number): void {
@@ -21,6 +21,7 @@ import { PackMode } from './types.js';
21
21
  import { cround } from '../../common/arith.js';
22
22
  import { d2i } from '../../util/math.js';
23
23
  import { gvQsort } from '../../util/bsd-qsort.js';
24
+ import { PointSet } from '../../common/pointset.js';
24
25
 
25
26
  /** Max. avg. polyomino size. @see lib/pack/pack.c:C */
26
27
  const C_AVG = 100;
@@ -40,23 +41,6 @@ function cell(p: Point, s: number): Point {
40
41
  return { x: cval(p.x, s), y: cval(p.y, s) };
41
42
  }
42
43
 
43
- /** Integer point set. @see lib/common/pointset.c */
44
- class PointSet {
45
- private readonly m = new Map<string, Point>();
46
- add(x: number, y: number): void {
47
- this.m.set(`${x},${y}`, { x, y });
48
- }
49
- has(p: Point): boolean {
50
- return this.m.has(`${p.x},${p.y}`);
51
- }
52
- points(): Point[] {
53
- return [...this.m.values()];
54
- }
55
- get size(): number {
56
- return this.m.size;
57
- }
58
- }
59
-
60
44
  /** Polyomino info for one graph. @see lib/pack/pack.c:ginfo */
61
45
  interface GInfo {
62
46
  perim: number;
@@ -96,7 +80,7 @@ interface LineState {
96
80
  function lineXDominant(st: LineState, ps: PointSet): void {
97
81
  let d = st.ay - (st.ax >> 1);
98
82
  for (;;) {
99
- ps.add(st.x, st.y);
83
+ ps.addPS(st.x, st.y);
100
84
  if (st.x === st.x2) return;
101
85
  if (d >= 0) { st.y += st.sy; d -= st.ax; }
102
86
  st.x += st.sx;
@@ -108,7 +92,7 @@ function lineXDominant(st: LineState, ps: PointSet): void {
108
92
  function lineYDominant(st: LineState, ps: PointSet): void {
109
93
  let d = st.ax - (st.ay >> 1);
110
94
  for (;;) {
111
- ps.add(st.x, st.y);
95
+ ps.addPS(st.x, st.y);
112
96
  if (st.y === st.y2) return;
113
97
  if (d >= 0) { st.x += st.sx; d -= st.ay; }
114
98
  st.y += st.sy;
@@ -136,7 +120,7 @@ function fillLine(p: Point, q: Point, ps: PointSet): void {
136
120
  /** Mark all cells in the inclusive cell-rect [LL, UR]. */
137
121
  function fillRect(LL: Point, UR: Point, ps: PointSet): void {
138
122
  for (let x = LL.x; x <= UR.x; x++) {
139
- for (let y = LL.y; y <= UR.y; y++) ps.add(x, y);
123
+ for (let y = LL.y; y <= UR.y; y++) ps.addPS(x, y);
140
124
  }
141
125
  }
142
126
 
@@ -208,7 +192,7 @@ function genBox(bb0: Box, ssize: number, margin: number, center: Point): GInfo {
208
192
  const UR = roundedCell({ x: center.x + bbW + margin, y: center.y + bbH + margin }, ssize);
209
193
  fillRect(LL, UR, ps);
210
194
  return {
211
- cells: ps.points(),
195
+ cells: ps.pointsOf(),
212
196
  perim: grid(bb0.ur.x - bb0.ll.x + 2 * margin, ssize)
213
197
  + grid(bb0.ur.y - bb0.ll.y + 2 * margin, ssize),
214
198
  index: 0,
@@ -266,7 +250,7 @@ function genPoly(
266
250
  };
267
251
  for (const n of g.nodes.values()) coverNode(n, ctx);
268
252
  return {
269
- cells: ps.points(),
253
+ cells: ps.pointsOf(),
270
254
  perim: grid(bb.ur.x - bb.ll.x + 2 * pinfo.margin, ssize)
271
255
  + grid(bb.ur.y - bb.ll.y + 2 * pinfo.margin, ssize),
272
256
  index: 0,
@@ -289,7 +273,7 @@ interface PlaceCtx {
289
273
 
290
274
  function fits(x: number, y: number, info: GInfo, ctx: PlaceCtx): boolean {
291
275
  for (const c of info.cells) {
292
- if (ctx.ps.has({ x: c.x + x, y: c.y + y })) return false;
276
+ if (ctx.ps.isInPS(c.x + x, c.y + y)) return false;
293
277
  }
294
278
  const bb = ctx.bbs[info.index]!;
295
279
  const LL = { x: cround(bb.ll.x), y: cround(bb.ll.y) };
@@ -297,30 +281,44 @@ function fits(x: number, y: number, info: GInfo, ctx: PlaceCtx): boolean {
297
281
  // is widened to double (objdump of libgvc `_fits`). Matters only once
298
282
  // computeStep has saturated at INT_MAX.
299
283
  ctx.places[info.index] = { x: Math.imul(ctx.step, x) - LL.x, y: Math.imul(ctx.step, y) - LL.y };
300
- for (const c of info.cells) ctx.ps.add(c.x + x, c.y + y);
284
+ for (const c of info.cells) ctx.ps.addPS(c.x + x, c.y + y);
301
285
  return true;
302
286
  }
303
287
 
304
- /** Ring candidates for wide graphs (W >= H). @see pack.c:placeGraph */
305
- function* ringWide(bnd: number): Generator<[number, number]> {
306
- let x = 0;
307
- let y = -bnd;
308
- for (; x < bnd; x++) yield [x, y];
309
- for (; y < bnd; y++) yield [x, y];
310
- for (; x > -bnd; x--) yield [x, y];
311
- for (; y > -bnd; y--) yield [x, y];
312
- for (; x < 0; x++) yield [x, y];
288
+ /** Ring cursor, shared by the legs of one ring as C shares its x and y. */
289
+ interface Cursor {
290
+ x: number;
291
+ y: number;
292
+ }
293
+
294
+ /**
295
+ * One straight leg of a ring: step (dx, dy) from the cursor until the moving
296
+ * coordinate reaches `end`, trying each cell. Every leg starts on the
297
+ * `end` side's integer lattice with a unit step, so `!== end` stops where C's
298
+ * `<`/`>` bound does.
299
+ * @see pack.c:placeGraph (one of the five loops per ring)
300
+ */
301
+ function leg(c: Cursor, dx: number, dy: number, end: number, info: GInfo, ctx: PlaceCtx): boolean {
302
+ for (; (dx !== 0 ? c.x : c.y) !== end; c.x += dx, c.y += dy) {
303
+ if (fits(c.x, c.y, info, ctx)) return true;
304
+ }
305
+ return false;
313
306
  }
314
307
 
315
- /** Ring candidates for tall graphs (W < H). @see pack.c:placeGraph */
316
- function* ringTall(bnd: number): Generator<[number, number]> {
317
- let y = 0;
318
- let x = -bnd;
319
- for (; y > -bnd; y--) yield [x, y];
320
- for (; x < bnd; x++) yield [x, y];
321
- for (; y < bnd; y++) yield [x, y];
322
- for (; x > -bnd; x--) yield [x, y];
323
- for (; y > 0; y--) yield [x, y];
308
+ /** Ring `bnd` for wide graphs (W >= H). @see pack.c:placeGraph */
309
+ function ringWide(bnd: number, info: GInfo, ctx: PlaceCtx): boolean {
310
+ const c = { x: 0, y: -bnd };
311
+ return leg(c, 1, 0, bnd, info, ctx) || leg(c, 0, 1, bnd, info, ctx)
312
+ || leg(c, -1, 0, -bnd, info, ctx) || leg(c, 0, -1, -bnd, info, ctx)
313
+ || leg(c, 1, 0, 0, info, ctx);
314
+ }
315
+
316
+ /** Ring `bnd` for tall graphs (W < H). @see pack.c:placeGraph */
317
+ function ringTall(bnd: number, info: GInfo, ctx: PlaceCtx): boolean {
318
+ const c = { x: -bnd, y: 0 };
319
+ return leg(c, 0, -1, -bnd, info, ctx) || leg(c, 1, 0, bnd, info, ctx)
320
+ || leg(c, 0, 1, bnd, info, ctx) || leg(c, -1, 0, -bnd, info, ctx)
321
+ || leg(c, 0, -1, 0, info, ctx);
324
322
  }
325
323
 
326
324
  /**
@@ -331,7 +329,7 @@ function* ringTall(bnd: number): Generator<[number, number]> {
331
329
  */
332
330
  function placeFixed(info: GInfo, ctx: PlaceCtx, center: Point): void {
333
331
  ctx.places[info.index] = { x: -center.x, y: -center.y };
334
- for (const c of info.cells) ctx.ps.add(c.x, c.y);
332
+ for (const c of info.cells) ctx.ps.addPS(c.x, c.y);
335
333
  }
336
334
 
337
335
  /**
@@ -349,10 +347,7 @@ function placeGraph(i: number, info: GInfo, ctx: PlaceCtx): void {
349
347
  if (fits(0, 0, info, ctx)) return;
350
348
  const wide = Math.ceil(bb.ur.x - bb.ll.x) >= Math.ceil(bb.ur.y - bb.ll.y);
351
349
  for (let bnd = 1; ; bnd++) {
352
- const ring = wide ? ringWide(bnd) : ringTall(bnd);
353
- for (const [x, y] of ring) {
354
- if (fits(x, y, info, ctx)) return;
355
- }
350
+ if (wide ? ringWide(bnd, info, ctx) : ringTall(bnd, info, ctx)) return;
356
351
  }
357
352
  }
358
353
 
package/src/model/geom.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  * @see lib/common/const.h
9
9
  */
10
10
 
11
+ import type { ArrowDrawOp } from '../common/arrows-types.js';
12
+
11
13
  // ---------------------------------------------------------------------------
12
14
  // Points
13
15
  // ---------------------------------------------------------------------------
@@ -56,6 +58,14 @@ export type Bezier = {
56
58
  sp: Point;
57
59
  /** @see lib/common/types.h: pointf ep */
58
60
  ep: Point;
61
+ /**
62
+ * TS-only: the arrow drawn at `sp` (paired with `sflag`). C regenerates it
63
+ * per bezier at render time (emit.c arrow_gen); the port computes it at clip
64
+ * time, so it is stored per bezier for splines with several flagged beziers.
65
+ */
66
+ sArrowOps?: ArrowDrawOp[];
67
+ /** TS-only: the arrow drawn at `ep` (paired with `eflag`); see `sArrowOps`. */
68
+ eArrowOps?: ArrowDrawOp[];
59
69
  };
60
70
 
61
71
  /**
package/src/render/dot.ts CHANGED
@@ -32,6 +32,7 @@ import { PenType, FillType } from '../gvc/context.js';
32
32
  import type { RenderJob } from '../gvc/job.js';
33
33
  import { EmitState } from '../gvc/job.js';
34
34
  import { renderEdgeLabels } from '../gvc/edge-labels.js';
35
+ import { endArrowOps } from '../common/bezier-arrows.js';
35
36
  export * from './dot/xdot-ops.js';
36
37
  export * from './dot/attrs.js';
37
38
  import {
@@ -226,6 +227,11 @@ export class XdotRenderer extends EdgeDrawBase implements RendererPlugin {
226
227
  headArrowColor = c.headColor;
227
228
  } else {
228
229
  this.emitPlainSpline(e, spl.list, edraw, job);
230
+ // The plain branch draws an arrow per flagged bezier (emit.c:2669-2675);
231
+ // the multicolor/tapered branches above draw one per end.
232
+ for (const ops of endArrowOps(e, 'tail')) this.emitArrows(this.bufs[EmitState.TDraw]!, ops, job, EmitState.TDraw);
233
+ for (const ops of endArrowOps(e, 'head')) this.emitArrows(this.bufs[EmitState.HDraw]!, ops, job, EmitState.HDraw);
234
+ return;
229
235
  }
230
236
  // Arrows: y-up ops already computed for the shared render path. C sets the
231
237
  // default line style ("solid") + penwidth before each arrow primitive.
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+ //
3
+ // SVG arrowheads for one edge, drawn inside svgEdgePath's per-bezier loop the
4
+ // way emit_edge_graphics calls arrow_gen after each bezier.
5
+ //
6
+ // @see lib/common/emit.c:emit_edge_graphics (2668-2676)
7
+
8
+ import type { Edge } from '../model/edge.js';
9
+ import type { Bezier } from '../model/geom.js';
10
+ import type { RenderJob } from '../gvc/job.js';
11
+ import { bezierEndOps } from '../common/bezier-arrows.js';
12
+ import { emitArrowOps } from './svg-arrow-ops.js';
13
+
14
+ /** The edge pen an arrow is drawn with. */
15
+ export interface ArrowPaint { job: RenderJob; penColor: string; opacity: string | null; pw: number }
16
+
17
+ /** Which ends have drawn their edge-level arrow. */
18
+ export interface ArrowsDone { tailDone: boolean; headDone: boolean }
19
+
20
+ /**
21
+ * One end's arrow after bezier `bz`. With several beziers flagged at that end
22
+ * each draws its own arrow (bezierEndOps); otherwise the edge-level ops are
23
+ * drawn once, at the first flagged bezier.
24
+ */
25
+ export function emitBezierEndArrow(
26
+ e: Edge, bz: Bezier, end: 'head' | 'tail', st: ArrowsDone, p: ArrowPaint,
27
+ ): void {
28
+ const tail = end === 'tail';
29
+ const flag = tail ? bz.sflag : bz.eflag;
30
+ const done = tail ? st.tailDone : st.headDone;
31
+ const edgeOps = tail ? e.info.tailArrowOps : e.info.headArrowOps;
32
+ const ops = bezierEndOps(e, bz, end)
33
+ ?? (flag !== 0 && !done && edgeOps?.length ? edgeOps : undefined);
34
+ if (ops === undefined) return;
35
+ emitArrowOps(ops, p.penColor, p.job, p.pw, p.opacity);
36
+ if (tail) st.tailDone = true;
37
+ else st.headDone = true;
38
+ }
39
+
40
+ /**
41
+ * Arrow ops whose bezier carried no s/eflag (a port-model seam: ops are stored
42
+ * per edge end, flags per bezier). C cannot reach this state — an arrow is only
43
+ * generated where a flag is set — so this emits at the old position (group
44
+ * end) purely to keep behavior identical if a router ever stashes ops without
45
+ * setting the flag.
46
+ */
47
+ export function emitRemainingArrows(e: Edge, st: ArrowsDone, p: ArrowPaint): void {
48
+ if (!st.tailDone && e.info.tailArrowOps?.length) {
49
+ emitArrowOps(e.info.tailArrowOps, p.penColor, p.job, p.pw, p.opacity);
50
+ }
51
+ if (!st.headDone && e.info.headArrowOps?.length) {
52
+ emitArrowOps(e.info.headArrowOps, p.penColor, p.job, p.pw, p.opacity);
53
+ }
54
+ }
@@ -66,6 +66,8 @@ import { escapeXml, escapeXmlText, escapeXmlTitle } from './xml-escape.js';
66
66
  export { escapeXml, escapeXmlText, escapeXmlTitle };
67
67
  import { svgNodeId, svgEdgeId, svgNodeClass, svgEdgeClass } from './svg-id.js';
68
68
  import { orthoRoundedPolylines } from './svg-edge-ortho-radius.js';
69
+ import { emitBezierEndArrow, emitRemainingArrows } from './svg-edge-arrows.js';
70
+ import type { ArrowPaint } from './svg-edge-arrows.js';
69
71
 
70
72
  // ---------------------------------------------------------------------------
71
73
  // Color helpers
@@ -442,6 +444,11 @@ export function svgEdgePath(e: Edge, job: RenderJob): void {
442
444
  if (spl === undefined || spl.size === 0) return;
443
445
  const obj = job.obj;
444
446
  const stroke = obj !== null ? paintStr(obj, false) : 'black';
447
+ const paint: ArrowPaint = {
448
+ job, penColor: stroke,
449
+ opacity: obj !== null ? colorOpacity(obj.penColor) : null,
450
+ pw: obj !== null ? obj.penWidth : 1.0,
451
+ };
445
452
  const st = { tailDone: false, headDone: false };
446
453
  for (let si = 0; si < spl.size; si++) {
447
454
  const bz = spl.list[si] as Bezier | undefined;
@@ -452,51 +459,10 @@ export function svgEdgePath(e: Edge, job: RenderJob): void {
452
459
  // for a multi-bezier concentrate spline the arrow interleaves BETWEEN
453
460
  // the paths (b69 g[84]: [path, arrow-polygon, path]).
454
461
  // @see lib/common/emit.c:2668-2676 (arrow_gen after gvrender_beziercurve)
455
- emitBezierArrows(e, bz, job, st);
456
- }
457
- emitRemainingArrows(e, job, st);
458
- }
459
-
460
- /** Per-bezier arrow emission (C's in-loop arrow_gen calls). */
461
- function emitBezierArrows(
462
- e: Edge, bz: Bezier, job: RenderJob,
463
- st: { tailDone: boolean; headDone: boolean },
464
- ): void {
465
- const obj = job.obj;
466
- const penColor = obj !== null ? paintStr(obj, false) : 'black';
467
- const opacity = obj !== null ? colorOpacity(obj.penColor) : null;
468
- const pw = obj !== null ? obj.penWidth : 1.0;
469
- if (bz.sflag !== 0 && !st.tailDone && e.info.tailArrowOps?.length) {
470
- emitArrowOps(e.info.tailArrowOps, penColor, job, pw, opacity);
471
- st.tailDone = true;
472
- }
473
- if (bz.eflag !== 0 && !st.headDone && e.info.headArrowOps?.length) {
474
- emitArrowOps(e.info.headArrowOps, penColor, job, pw, opacity);
475
- st.headDone = true;
476
- }
477
- }
478
-
479
- /**
480
- * Arrow ops whose bezier carried no s/eflag (a port-model seam: ops are stored
481
- * per edge end, flags per bezier). C cannot reach this state — an arrow is only
482
- * generated where a flag is set — so this emits at the old position (group
483
- * end) purely to keep behavior identical if a router ever stashes ops without
484
- * setting the flag.
485
- */
486
- function emitRemainingArrows(
487
- e: Edge, job: RenderJob,
488
- st: { tailDone: boolean; headDone: boolean },
489
- ): void {
490
- const obj = job.obj;
491
- const penColor = obj !== null ? paintStr(obj, false) : 'black';
492
- const opacity = obj !== null ? colorOpacity(obj.penColor) : null;
493
- const pw = obj !== null ? obj.penWidth : 1.0;
494
- if (!st.tailDone && e.info.tailArrowOps?.length) {
495
- emitArrowOps(e.info.tailArrowOps, penColor, job, pw, opacity);
496
- }
497
- if (!st.headDone && e.info.headArrowOps?.length) {
498
- emitArrowOps(e.info.headArrowOps, penColor, job, pw, opacity);
462
+ emitBezierEndArrow(e, bz, 'tail', st, paint);
463
+ emitBezierEndArrow(e, bz, 'head', st, paint);
499
464
  }
465
+ emitRemainingArrows(e, st, paint);
500
466
  }
501
467
 
502
468
  /** Emit one bezier as a `<path d="M…C…">`. Extracted from svgEdgePath so the