@knowvah/dot-engine 2.0.1 → 2.1.0

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.
Files changed (55) hide show
  1. package/dist/async/collect.d.ts.map +1 -1
  2. package/dist/async/render-async.d.ts.map +1 -1
  3. package/dist/common/arith.d.ts +6 -0
  4. package/dist/common/arith.d.ts.map +1 -1
  5. package/dist/common/poly-sizing.d.ts.map +1 -1
  6. package/dist/common/textmeasure-factory.d.ts +2 -1
  7. package/dist/common/textmeasure-factory.d.ts.map +1 -1
  8. package/dist/common/textmeasure.d.ts +1 -1
  9. package/dist/common/textmeasure.d.ts.map +1 -1
  10. package/dist/gvc/device-labels.d.ts +30 -0
  11. package/dist/gvc/device-labels.d.ts.map +1 -0
  12. package/dist/gvc/device-transform.d.ts +22 -0
  13. package/dist/gvc/device-transform.d.ts.map +1 -0
  14. package/dist/gvc/device.d.ts +2 -42
  15. package/dist/gvc/device.d.ts.map +1 -1
  16. package/dist/index.js +98 -79
  17. package/dist/index.js.map +4 -4
  18. package/dist/layout/dot/graph-label.d.ts +10 -0
  19. package/dist/layout/dot/graph-label.d.ts.map +1 -1
  20. package/dist/layout/dot/splines-label.d.ts.map +1 -1
  21. package/dist/layout/fdp/normalize.d.ts.map +1 -1
  22. package/dist/layout/fdp/xlayout.d.ts +1 -1
  23. package/dist/layout/neato/dijkstra.d.ts +0 -32
  24. package/dist/layout/neato/dijkstra.d.ts.map +1 -1
  25. package/dist/layout/neato/fdp-adjust.d.ts +12 -1
  26. package/dist/layout/neato/fdp-adjust.d.ts.map +1 -1
  27. package/dist/layout/neato/index.d.ts +5 -5
  28. package/dist/layout/neato/index.d.ts.map +1 -1
  29. package/dist/layout/sfdp/index.d.ts.map +1 -1
  30. package/dist/render/public.d.ts +5 -0
  31. package/dist/render/public.d.ts.map +1 -1
  32. package/dist/render.js +95 -76
  33. package/dist/render.js.map +4 -4
  34. package/package.json +1 -1
  35. package/src/async/collect.ts +2 -14
  36. package/src/async/render-async.ts +7 -12
  37. package/src/common/arith.ts +9 -0
  38. package/src/common/poly-sizing.ts +2 -1
  39. package/src/common/textmeasure-factory.ts +20 -1
  40. package/src/common/textmeasure.ts +1 -1
  41. package/src/gvc/device-labels.ts +98 -0
  42. package/src/gvc/device-transform.ts +58 -0
  43. package/src/gvc/device.ts +8 -132
  44. package/src/layout/dot/graph-label.ts +6 -1
  45. package/src/layout/dot/splines-label.ts +2 -1
  46. package/src/layout/fdp/normalize.ts +2 -1
  47. package/src/layout/fdp/xlayout.ts +3 -2
  48. package/src/layout/neato/dijkstra.ts +0 -141
  49. package/src/layout/neato/fdp-adjust.ts +25 -3
  50. package/src/layout/neato/index.ts +13 -12
  51. package/src/layout/sfdp/index.ts +2 -1
  52. package/src/render/public.ts +6 -5
  53. package/dist/layout/neato/overlap.d.ts +0 -85
  54. package/dist/layout/neato/overlap.d.ts.map +0 -1
  55. package/src/layout/neato/overlap.ts +0 -158
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
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",
@@ -19,12 +19,12 @@ import type { Edge } from '../model/edge.js';
19
19
  import type { Node } from '../model/node.js';
20
20
  import type { TextVariantFlags } from '../common/textmeasure.js';
21
21
  import { canvasFont } from '../common/css-font.js';
22
- import { DEFAULT_FONTNAME, DEFAULT_FONTSIZE } from '../common/make-label.js';
23
22
  import { isHtmlValue, htmlValueContent } from '../common/html-string.js';
24
23
  import { nodeAttr, readFontAttrs } from '../common/poly-init.js';
25
24
  import { initFontEdgeAttr, initFontLabelEdgeAttr } from '../common/edge-label-init.js';
26
25
  import { parseHtmlLabel } from '../common/htmltable-parse.js';
27
26
  import { isACluster } from '../layout/dot/rank.js';
27
+ import { readFontParams } from '../layout/dot/graph-label.js';
28
28
  import type {
29
29
  HtmlCellContent, HtmlLabel, HtmlTable, HtmlTextItem,
30
30
  } from '../common/htmltable-types.js';
@@ -160,21 +160,9 @@ function collectEdge(acc: Acc, e: Edge): void {
160
160
  addLabel(acc, e.attrs.get('taillabel'), lfi);
161
161
  }
162
162
 
163
- /**
164
- * Root/cluster label font. A subgraph reads through its parse-time defaults
165
- * snapshot, as doGraphLabel does. @see src/layout/dot/graph-label.ts:readFontParams
166
- */
167
- function graphLabelFont(sg: Graph): LabelFont {
168
- const get = (k: string): string | undefined => sg.attrs.get(k) ?? sg.graphDefaultsSnapshot?.get(k);
169
- return {
170
- fontname: get('fontname') ?? DEFAULT_FONTNAME,
171
- fontsize: parseFloat(get('fontsize') ?? '') || DEFAULT_FONTSIZE,
172
- };
173
- }
174
-
175
163
  function collectGraphLabels(acc: Acc, sg: Graph, root: Graph): void {
176
164
  if (isACluster(sg)) {
177
- addLabel(acc, sg.attrs.get('label') ?? sg.graphDefaultsSnapshot?.get('label'), graphLabelFont(sg));
165
+ addLabel(acc, sg.attrs.get('label') ?? sg.graphDefaultsSnapshot?.get('label'), readFontParams(sg));
178
166
  }
179
167
  for (const child of sg.subgraphs.values()) collectGraphLabels(acc, child, root);
180
168
  }
@@ -16,6 +16,7 @@ import { parse } from '../parser/index.js';
16
16
  import { invalidArgType, rethrowAtBoundary } from '../errors.js';
17
17
  import type { EngineName } from '../gvc/context.js';
18
18
  import { createDefaultContext } from '../gvc/default-context.js';
19
+ import { checkRenderArgs } from '../render/public.js';
19
20
  import { render as deviceRender } from '../gvc/device.js';
20
21
  import type { ImageSizer } from '../common/htmltable-types.js';
21
22
  import type { ImageResolver } from '../gvc/image-resolver.js';
@@ -62,20 +63,14 @@ export interface AsyncSvgResult {
62
63
  fontIssues: FontIssue[];
63
64
  }
64
65
 
65
- /** Reject a bad `g` / `format` / `opts` before any work starts (mirrors `render`). */
66
- function checkRenderArgs(g: unknown, format: unknown, opts: unknown): void {
67
- if (typeof g !== 'object' || g === null) throw invalidArgType('g', 'object', g);
68
- if (typeof format !== 'string') throw invalidArgType('format', 'string', format);
69
- if (opts !== undefined && (typeof opts !== 'object' || opts === null)) {
70
- throw invalidArgType('opts', 'object or undefined', opts);
71
- }
72
- }
73
-
74
- /** `document.fonts` when a document exposes one (absent in Node and Workers). */
66
+ /**
67
+ * `document.fonts` in a page, else `self.fonts` (WorkerGlobalScope.fonts) in a
68
+ * Worker; undefined in Node. @see plans/async-api/DESIGN.md D5
69
+ */
75
70
  function defaultFontSet(): FontSetLike | undefined {
76
71
  const doc: unknown = Reflect.get(globalThis, 'document');
77
- if (typeof doc !== 'object' || doc === null) return undefined;
78
- const fonts: unknown = Reflect.get(doc, 'fonts');
72
+ const owner: unknown = typeof doc === 'object' && doc !== null ? doc : globalThis;
73
+ const fonts: unknown = Reflect.get(owner as object, 'fonts');
79
74
  if (typeof fonts !== 'object' || fonts === null) return undefined;
80
75
  return fonts as FontSetLike; // structural: FontFaceSet.load
81
76
  }
@@ -37,3 +37,12 @@
37
37
  export function cround(v: number): number {
38
38
  return v >= 0 ? Math.floor(v + 0.5) : Math.ceil(v - 0.5);
39
39
  }
40
+
41
+ /**
42
+ * Degrees to radians in C's operation order, `(deg)/180.0 * M_PI`.
43
+ * `deg * Math.PI / 180` rounds differently for some angles (e.g. 33°).
44
+ * @see lib/common/arith.h:49 (RADIANS)
45
+ */
46
+ export function RADIANS(deg: number): number {
47
+ return (deg / 180.0) * Math.PI;
48
+ }
@@ -22,6 +22,7 @@ import type { Point } from '../model/geom.js';
22
22
  import { STAR, CYLINDER } from './shapeData.js';
23
23
  import { starVertices } from './poly-vertices.js';
24
24
  import { fma } from './fma.js';
25
+ import { RADIANS } from './arith.js';
25
26
 
26
27
  /** Whitespace in points around labels / between peripheries. @see lib/common/const.h:GAP */
27
28
  export const GAP = 4;
@@ -277,7 +278,7 @@ export interface PolyGeom {
277
278
  /** Distort, skew, orient, and scale one unit vertex. @see shapes.c:poly_init */
278
279
  function transformUnitVertex(R: Point, g: PolyGeom, c: { skewdist: number; gdistortion: number; gskew: number }, bb: Point): Point {
279
280
  const D = { x: R.x * (c.skewdist + R.y * c.gdistortion) + R.y * c.gskew, y: R.y };
280
- const alpha = (g.orientation * Math.PI) / 180 + Math.atan2(D.y, D.x);
281
+ const alpha = RADIANS(g.orientation) + Math.atan2(D.y, D.x);
281
282
  // C uses libm hypot(D.x, D.y). V8's Math.hypot is a scaled algorithm that
282
283
  // returns a result 1 ULP off from libm hypot for in-range polygon vertices
283
284
  // (e.g. hypot(0.35355…, 0.35355…): libm = 0.5 exactly, Math.hypot = 0.5+1ULP),
@@ -6,7 +6,8 @@
6
6
  * 1. explicit override via setTextMeasurer (tests, host-faithful Node opt-in)
7
7
  * 2. GV_TEXT_MEASURER=estimate|lut — test/CI hook to force a specific measurer
8
8
  * 3. browser (document present) — CanvasTextMeasurer over the page canvas
9
- * (host-faithful: the same font the browser renders the SVG with)
9
+ * (host-faithful: the same font the browser renders the SVG with);
10
+ * Worker (no document) — the same over an OffscreenCanvas
10
11
  * 4. Node default — EstimateTextMeasurer (deterministic; matches
11
12
  * graphviz's own headless estimate_textspan_size)
12
13
  *
@@ -77,6 +78,21 @@ function adviseHostFaithful(): void {
77
78
  );
78
79
  }
79
80
 
81
+ /**
82
+ * A CanvasTextMeasurer over an OffscreenCanvas 2d context, for Workers (no
83
+ * `document`); undefined where OffscreenCanvas is missing or unusable (Node).
84
+ * @see plans/async-api/DESIGN.md D5
85
+ */
86
+ function offscreenMeasurer(): CanvasTextMeasurer | undefined {
87
+ if (typeof OffscreenCanvas === 'undefined') return undefined;
88
+ try {
89
+ const ctx2d = new OffscreenCanvas(0, 0).getContext('2d');
90
+ return ctx2d ? new CanvasTextMeasurer(ctx2d) : undefined;
91
+ } catch {
92
+ return undefined; // OffscreenCanvas present but no 2d context
93
+ }
94
+ }
95
+
80
96
  /** Resolve the text measurer for this render (see resolution order above). */
81
97
  export function createMeasurer(): TextMeasurer {
82
98
  if (override) return override;
@@ -88,6 +104,9 @@ export function createMeasurer(): TextMeasurer {
88
104
  const ctx2d = document.createElement('canvas').getContext('2d');
89
105
  if (ctx2d) return new CanvasTextMeasurer(ctx2d);
90
106
  } catch { /* canvas unavailable */ }
107
+ } else {
108
+ const offscreen = offscreenMeasurer(); // Worker: no document, same fonts API
109
+ if (offscreen) return offscreen;
91
110
  }
92
111
  adviseHostFaithful(); // Node, no host canvas → built-in deterministic metrics
93
112
  // Node default = EstimateTextMeasurer: the deterministic reference that matches
@@ -285,7 +285,7 @@ export class CanvasTextMeasurer implements TextMeasurer {
285
285
  */
286
286
  private readonly fonts = new Map<string, string>();
287
287
 
288
- constructor(private readonly ctx: CanvasRenderingContext2D) {}
288
+ constructor(private readonly ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) {}
289
289
 
290
290
  measure(
291
291
  text: string,
@@ -0,0 +1,98 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * Label emission shared by edge labels, node xlabels and cluster/graph
5
+ * labels (split from device.ts for the file-size cap; re-exported there).
6
+ *
7
+ * @see lib/common/labels.c:emit_label
8
+ * @see lib/common/emit.c:emit_label
9
+ */
10
+
11
+ import type { Graph } from '../model/graph.js';
12
+ import type { Node } from '../model/node.js';
13
+ import type { RendererPlugin } from './context.js';
14
+ import type { RenderJob } from './job.js';
15
+ import type { TextlabelT } from '../common/types.js';
16
+ import type { TextSpan } from '../common/emit-types.js';
17
+ import type { PlacedHtml } from '../common/htmltable-pos.js';
18
+ import { gvrenderTextspan, withLabelEmitState } from './textspan-emit.js';
19
+ import { emitHtmlLabel } from '../common/htmltable-emit.js';
20
+
21
+ /** valign codes stored on textlabel_t. @see lib/common/types.h:textlabel_t.valign */
22
+ const VALIGN_TOP = 't'.charCodeAt(0);
23
+ const VALIGN_BOTTOM = 'b'.charCodeAt(0);
24
+
25
+ /** First-span baseline y per valign. @see lib/common/labels.c:emit_label (240-251) */
26
+ function labelFirstSpanY(lp: TextlabelT): number {
27
+ if (lp.valign === VALIGN_TOP) return lp.pos.y + lp.space.y / 2.0 - lp.fontsize;
28
+ if (lp.valign === VALIGN_BOTTOM) {
29
+ return lp.pos.y - lp.space.y / 2.0 + lp.dimen.y - lp.fontsize;
30
+ }
31
+ return lp.pos.y + lp.dimen.y / 2.0 - lp.fontsize;
32
+ }
33
+
34
+ /** Span x position per justification. @see lib/common/labels.c:emit_label (254-266) */
35
+ function labelSpanX(lp: TextlabelT, just: 'l' | 'n' | 'r'): number {
36
+ if (just === 'l') return lp.pos.x - lp.space.x / 2.0;
37
+ if (just === 'r') return lp.pos.x + lp.space.x / 2.0;
38
+ return lp.pos.x;
39
+ }
40
+
41
+ /**
42
+ * Emit one label's text spans if present and placed.
43
+ * Shared by edge-label, node-xlabel, and graph-label slots.
44
+ * URL/anchor/map machinery and E_decorate attachment (emit.c:emit_attachment)
45
+ * are not ported, matching the live path's AD-2 scope.
46
+ * @see lib/common/emit.c:emit_label
47
+ * @see lib/common/labels.c:emit_label
48
+ */
49
+ export function renderOneLabel(
50
+ lp: TextlabelT | undefined,
51
+ renderer: RendererPlugin,
52
+ job: RenderJob,
53
+ requireSet = true,
54
+ ): void {
55
+ // C gates `->set` at the xlabel/edge-label CALL SITES (emit.c:1829,
56
+ // emit_edge_label:2891), but draws root-graph and cluster labels on
57
+ // existence alone (emit.c:3656, 3920) — an unplaced label (e.g. fdp's
58
+ // non-comparable-clusters abort skips gv_postprocess) still renders at its
59
+ // default pos. Callers mirroring the existence-only sites pass false.
60
+ if (!lp) return;
61
+ if (requireSet && !lp.set) return;
62
+ // HTML branch: @see lib/common/labels.c:emit_label (226-230)
63
+ // C routes to emit_html_label(job, lp->u.html, lp) using lp->pos as anchor.
64
+ if (lp.html) {
65
+ if (lp.u.kind === 'html') {
66
+ // Route the whole HTML label (table box/fill polygons + text) into the
67
+ // object's LABEL emit-state so its ops land in _ldraw_, not _draw_.
68
+ const html = lp.u.html as PlacedHtml;
69
+ const pos = lp.pos;
70
+ withLabelEmitState(job, () => emitHtmlLabel(html, pos, renderer, job));
71
+ }
72
+ return;
73
+ }
74
+ if (lp.u.kind !== 'txt' || lp.u.nspans < 1) return;
75
+ let y = labelFirstSpanY(lp);
76
+ for (let i = 0; i < lp.u.nspans; i++) {
77
+ const span = lp.u.span[i] as TextSpan | undefined;
78
+ if (!span) break;
79
+ // Emit only visible spans; the baseline still advances below so blank
80
+ // lines reserve vertical space. @see gvrender_textspan (gvrender.c:419).
81
+ gvrenderTextspan(renderer, { x: labelSpanX(lp, span.just), y }, span, job);
82
+ y -= span.size.y; // UL position for next span (unconditional)
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Emit node external label (ND_xlabel) if placed.
88
+ * Must run inside the node group, after codefn (shape draw), matching C order.
89
+ * @see lib/common/emit.c:emit_node (1829-1830)
90
+ */
91
+ export function renderNodeXLabel(n: Node, renderer: RendererPlugin, job: RenderJob): void {
92
+ renderOneLabel(n.info.xlabel as TextlabelT | undefined, renderer, job);
93
+ }
94
+
95
+ /** Cluster labels go through the single emit_label port. @see labels.c:emit_label */
96
+ export function renderClusterLabel(sg: Graph, renderer: RendererPlugin, job: RenderJob): void {
97
+ renderOneLabel(sg.info.label as TextlabelT | undefined, renderer, job, false);
98
+ }
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * Graph-to-device point transform (split from device.ts for the file-size
5
+ * cap; re-exported there).
6
+ *
7
+ * @see lib/gvc/gvrender.c:gvrender_ptf
8
+ */
9
+
10
+ import type { Point } from '../model/geom.js';
11
+ import { type RenderJob, GVRENDER_DOES_TRANSFORM } from './job.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // transformPoint — @see lib/gvc/gvrender.c:gvrender_ptf
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Transform a point from graph to device coordinates.
19
+ * Short-circuits when GVRENDER_DOES_TRANSFORM is set (renderer owns mapping).
20
+ *
21
+ * @see lib/gvc/gvrender.c:gvrender_ptf
22
+ */
23
+ export function transformPoint(p: Point, job: RenderJob): Point {
24
+ if ((job.flags & GVRENDER_DOES_TRANSFORM) !== 0) {
25
+ return p;
26
+ }
27
+ const sx = job.zoom * job.devscale.x;
28
+ const sy = job.zoom * job.devscale.y;
29
+ const tx = job.translation.x;
30
+ const ty = job.translation.y;
31
+ // ADR-2: SVG landscape rotation lives entirely in the graph `<g>` group
32
+ // transform (svg_begin_page emits rotate(-job->rotation)); inner coords stay
33
+ // in the unrotated frame. The ptf rotation branch (applyRotation) is the
34
+ // raster/imagemap path and must NOT fire here, else job.rotation=90 would
35
+ // double-rotate every SVG coordinate. applyRotation stays exported as the
36
+ // faithful gvrender_ptf port (currently dead). @see ADR-2; gvrender.c:gvrender_ptf
37
+ return applyScale(p, tx, ty, sx, sy);
38
+ }
39
+
40
+ /** Rotation branch: out.x = -(p.y+ty)*sx, out.y = (p.x+tx)*sy @see gvrender_ptf */
41
+ export function applyRotation(p: Point, tx: number, ty: number, sx: number, sy: number): Point {
42
+ const x = -(p.y + ty) * sx;
43
+ const y = (p.x + tx) * sy;
44
+ return buildPoint(x, y);
45
+ }
46
+
47
+ /** No-rotation branch: out.x = (p.x+tx)*sx, out.y = (p.y+ty)*sy @see gvrender_ptf */
48
+ export function applyScale(p: Point, tx: number, ty: number, sx: number, sy: number): Point {
49
+ const x = (p.x + tx) * sx;
50
+ const y = (p.y + ty) * sy;
51
+ return buildPoint(x, y);
52
+ }
53
+
54
+ /** Construct a Point value. Extracted to avoid inline object literals in return position. */
55
+ export function buildPoint(x: number, y: number): Point {
56
+ const pt: Point = { x, y };
57
+ return pt;
58
+ }
package/src/gvc/device.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // SPDX-License-Identifier: EPL-2.0
2
2
 
3
3
  /**
4
- * Device render loop and coordinate transform.
4
+ * Device render loop (point transform: device-transform.ts; label emission:
5
+ * device-labels.ts).
5
6
  *
6
7
  * Ports gvrender_ptf, gvrender_begin_graph, and gvrender_end_graph from
7
8
  * lib/gvc/gvrender.c. render() is the top-level entry point; it lives here
@@ -11,7 +12,7 @@
11
12
  * @see lib/gvc/gvdevice.c
12
13
  */
13
14
 
14
- import type { Point, Box } from '../model/geom.js';
15
+ import type { Box } from '../model/geom.js';
15
16
  import { boxOverlap, POINTS_PER_INCH } from '../model/geom.js';
16
17
  import { lateDouble } from '../common/nodeinit.js';
17
18
  import { parseDrawingSize, initJobViewportZoom, parseLandscape, parseGraphPad, parseGraphMargin } from './viewport.js';
@@ -21,22 +22,18 @@ import type { Edge } from '../model/edge.js';
21
22
  import type { RendererPlugin } from './context.js';
22
23
  import { GvcContext, PenType } from './context.js';
23
24
  import { invalidArgType } from '../errors.js';
24
- import { gvrenderTextspan, withLabelEmitState } from './textspan-emit.js';
25
25
  import { resolveEdgeAnchor, resolveObjAnchor, beginAnchorIf } from './anchor.js';
26
26
  import type { ShapeDesc, TextlabelT } from '../common/types.js';
27
- import type { TextSpan } from '../common/emit-types.js';
28
27
  import { type LayerInfo, parseLayers } from '../common/layers.js';
29
- import { RenderJob, GVRENDER_DOES_TRANSFORM, EMIT_CLUSTERS_LAST, createObjState, ObjType, EmitState } from './job.js';
28
+ import { RenderJob, EMIT_CLUSTERS_LAST, createObjState, ObjType, EmitState } from './job.js';
30
29
  import { walkNodesAndEdges } from './emit-walk.js';
31
30
  import { polyInit } from '../common/poly-init.js';
32
- import { emitHtmlLabel } from '../common/htmltable-emit.js';
33
31
  import {
34
32
  setHtmlAnchorObj,
35
33
  setHtmlObjImgscale,
36
34
  resetHtmlAnchorIds,
37
35
  } from '../common/htmltable-emit-rules.js';
38
36
  import { nodeAttr } from '../common/poly-init.js';
39
- import type { PlacedHtml } from '../common/htmltable-pos.js';
40
37
  import {
41
38
  parseStyleFlags,
42
39
  resolvePenColor,
@@ -49,10 +46,10 @@ import { isPointNode } from '../common/poly-gencode.js';
49
46
  import { resolveRenderColor, withColorScheme } from '../render/color-resolve.js';
50
47
  import { emitRoundedBezier } from '../common/poly-shapes.js';
51
48
  import { applyClusterObjState, clusterStyle, clusterPeripheries } from './device-cluster.js';
52
- /** Cluster labels go through the single emit_label port. @see labels.c:emit_label */
53
- export function renderClusterLabel(sg: Graph, renderer: RendererPlugin, job: RenderJob): void {
54
- renderOneLabel(sg.info.label as TextlabelT | undefined, renderer, job, false);
55
- }
49
+ import { transformPoint } from './device-transform.js';
50
+ import { renderOneLabel, renderNodeXLabel, renderClusterLabel } from './device-labels.js';
51
+ export { transformPoint, applyRotation, applyScale, buildPoint } from './device-transform.js';
52
+ export { renderOneLabel, renderNodeXLabel, renderClusterLabel } from './device-labels.js';
56
53
  import { svgNodeId, svgEdgeId, svgClusterId, svgGraphId } from '../render/svg-id.js';
57
54
 
58
55
  // ---------------------------------------------------------------------------
@@ -72,53 +69,6 @@ declare module './job.js' {
72
69
  }
73
70
  }
74
71
 
75
- // ---------------------------------------------------------------------------
76
- // transformPoint — @see lib/gvc/gvrender.c:gvrender_ptf
77
- // ---------------------------------------------------------------------------
78
-
79
- /**
80
- * Transform a point from graph to device coordinates.
81
- * Short-circuits when GVRENDER_DOES_TRANSFORM is set (renderer owns mapping).
82
- *
83
- * @see lib/gvc/gvrender.c:gvrender_ptf
84
- */
85
- export function transformPoint(p: Point, job: RenderJob): Point {
86
- if ((job.flags & GVRENDER_DOES_TRANSFORM) !== 0) {
87
- return p;
88
- }
89
- const sx = job.zoom * job.devscale.x;
90
- const sy = job.zoom * job.devscale.y;
91
- const tx = job.translation.x;
92
- const ty = job.translation.y;
93
- // ADR-2: SVG landscape rotation lives entirely in the graph `<g>` group
94
- // transform (svg_begin_page emits rotate(-job->rotation)); inner coords stay
95
- // in the unrotated frame. The ptf rotation branch (applyRotation) is the
96
- // raster/imagemap path and must NOT fire here, else job.rotation=90 would
97
- // double-rotate every SVG coordinate. applyRotation stays exported as the
98
- // faithful gvrender_ptf port (currently dead). @see ADR-2; gvrender.c:gvrender_ptf
99
- return applyScale(p, tx, ty, sx, sy);
100
- }
101
-
102
- /** Rotation branch: out.x = -(p.y+ty)*sx, out.y = (p.x+tx)*sy @see gvrender_ptf */
103
- export function applyRotation(p: Point, tx: number, ty: number, sx: number, sy: number): Point {
104
- const x = -(p.y + ty) * sx;
105
- const y = (p.x + tx) * sy;
106
- return buildPoint(x, y);
107
- }
108
-
109
- /** No-rotation branch: out.x = (p.x+tx)*sx, out.y = (p.y+ty)*sy @see gvrender_ptf */
110
- export function applyScale(p: Point, tx: number, ty: number, sx: number, sy: number): Point {
111
- const x = (p.x + tx) * sx;
112
- const y = (p.y + ty) * sy;
113
- return buildPoint(x, y);
114
- }
115
-
116
- /** Construct a Point value. Extracted to avoid inline object literals in return position. */
117
- export function buildPoint(x: number, y: number): Point {
118
- const pt: Point = { x, y };
119
- return pt;
120
- }
121
-
122
72
  // ---------------------------------------------------------------------------
123
73
  // Node/Edge walk helpers
124
74
  // ---------------------------------------------------------------------------
@@ -282,80 +232,6 @@ export function renderEdge(e: Edge, renderer: RendererPlugin, job: RenderJob): v
282
232
  }
283
233
  }
284
234
 
285
- /** valign codes stored on textlabel_t. @see lib/common/types.h:textlabel_t.valign */
286
- const VALIGN_TOP = 't'.charCodeAt(0);
287
- const VALIGN_BOTTOM = 'b'.charCodeAt(0);
288
-
289
- /** First-span baseline y per valign. @see lib/common/labels.c:emit_label (240-251) */
290
- function labelFirstSpanY(lp: TextlabelT): number {
291
- if (lp.valign === VALIGN_TOP) return lp.pos.y + lp.space.y / 2.0 - lp.fontsize;
292
- if (lp.valign === VALIGN_BOTTOM) {
293
- return lp.pos.y - lp.space.y / 2.0 + lp.dimen.y - lp.fontsize;
294
- }
295
- return lp.pos.y + lp.dimen.y / 2.0 - lp.fontsize;
296
- }
297
-
298
- /** Span x position per justification. @see lib/common/labels.c:emit_label (254-266) */
299
- function labelSpanX(lp: TextlabelT, just: 'l' | 'n' | 'r'): number {
300
- if (just === 'l') return lp.pos.x - lp.space.x / 2.0;
301
- if (just === 'r') return lp.pos.x + lp.space.x / 2.0;
302
- return lp.pos.x;
303
- }
304
-
305
- /**
306
- * Emit one label's text spans if present and placed.
307
- * Shared by edge-label, node-xlabel, and graph-label slots.
308
- * URL/anchor/map machinery and E_decorate attachment (emit.c:emit_attachment)
309
- * are not ported, matching the live path's AD-2 scope.
310
- * @see lib/common/emit.c:emit_label
311
- * @see lib/common/labels.c:emit_label
312
- */
313
- export function renderOneLabel(
314
- lp: TextlabelT | undefined,
315
- renderer: RendererPlugin,
316
- job: RenderJob,
317
- requireSet = true,
318
- ): void {
319
- // C gates `->set` at the xlabel/edge-label CALL SITES (emit.c:1829,
320
- // emit_edge_label:2891), but draws root-graph and cluster labels on
321
- // existence alone (emit.c:3656, 3920) — an unplaced label (e.g. fdp's
322
- // non-comparable-clusters abort skips gv_postprocess) still renders at its
323
- // default pos. Callers mirroring the existence-only sites pass false.
324
- if (!lp) return;
325
- if (requireSet && !lp.set) return;
326
- // HTML branch: @see lib/common/labels.c:emit_label (226-230)
327
- // C routes to emit_html_label(job, lp->u.html, lp) using lp->pos as anchor.
328
- if (lp.html) {
329
- if (lp.u.kind === 'html') {
330
- // Route the whole HTML label (table box/fill polygons + text) into the
331
- // object's LABEL emit-state so its ops land in _ldraw_, not _draw_.
332
- const html = lp.u.html as PlacedHtml;
333
- const pos = lp.pos;
334
- withLabelEmitState(job, () => emitHtmlLabel(html, pos, renderer, job));
335
- }
336
- return;
337
- }
338
- if (lp.u.kind !== 'txt' || lp.u.nspans < 1) return;
339
- let y = labelFirstSpanY(lp);
340
- for (let i = 0; i < lp.u.nspans; i++) {
341
- const span = lp.u.span[i] as TextSpan | undefined;
342
- if (!span) break;
343
- // Emit only visible spans; the baseline still advances below so blank
344
- // lines reserve vertical space. @see gvrender_textspan (gvrender.c:419).
345
- gvrenderTextspan(renderer, { x: labelSpanX(lp, span.just), y }, span, job);
346
- y -= span.size.y; // UL position for next span (unconditional)
347
- }
348
- }
349
-
350
- /**
351
- * Emit node external label (ND_xlabel) if placed.
352
- * Must run inside the node group, after codefn (shape draw), matching C order.
353
- * @see lib/common/emit.c:emit_node (1829-1830)
354
- */
355
- export function renderNodeXLabel(n: Node, renderer: RendererPlugin, job: RenderJob): void {
356
- renderOneLabel(n.info.xlabel as TextlabelT | undefined, renderer, job);
357
- }
358
-
359
235
  /**
360
236
  * Emit root-graph label (GD_label) if present and placed.
361
237
  * Must run before clusters and nodes (emit_page order: background, then label,
@@ -60,7 +60,12 @@ function graphAttrInherited(sg: Graph, key: string): string | undefined {
60
60
  return sg.attrs.get(key) ?? sg.graphDefaultsSnapshot?.get(key);
61
61
  }
62
62
 
63
- function readFontParams(sg: Graph): { fontsize: number; fontname: string; fontcolor: string } {
63
+ /**
64
+ * Graph/cluster label font, read through graphAttrInherited.
65
+ * @internal also used by src/async/collect.ts to request the same face.
66
+ * @see lib/common/input.c:do_graph_label
67
+ */
68
+ export function readFontParams(sg: Graph): { fontsize: number; fontname: string; fontcolor: string } {
64
69
  return {
65
70
  fontsize: parseFloat(graphAttrInherited(sg, 'fontsize') ?? '') || DEFAULT_FONTSIZE,
66
71
  fontname: graphAttrInherited(sg, 'fontname') ?? DEFAULT_FONTNAME,
@@ -16,6 +16,7 @@ import { VIRTUAL } from './fastgr.js';
16
16
  import { IGNORED } from './rank.js';
17
17
  import { lateDouble } from '../../common/nodeinit.js';
18
18
  import { nodesInSeq } from './decomp.js';
19
+ import { RADIANS } from '../../common/arith.js';
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // TextLabel interface (minimal — full type deferred to Batch 5b)
@@ -286,7 +287,7 @@ function writePortlabelPos(
286
287
  distanceStr: string,
287
288
  ): void {
288
289
  const angleDeg = lateDouble(angleStr, PORT_LABEL_ANGLE, -180.0);
289
- const angle = Math.atan2(pf.y - pe.y, pf.x - pe.x) + (Math.PI / 180) * angleDeg;
290
+ const angle = Math.atan2(pf.y - pe.y, pf.x - pe.x) + RADIANS(angleDeg);
290
291
  const distMult = lateDouble(distanceStr || undefined, 1.0, 0.0);
291
292
  const dist = PORT_LABEL_DISTANCE * distMult;
292
293
  l.pos.x = pe.x + dist * Math.cos(angle);
@@ -14,6 +14,7 @@
14
14
  import type { Graph } from '../../model/graph.js';
15
15
  import type { Node } from '../../model/node.js';
16
16
  import { aggetGraph } from './fdp-model.js';
17
+ import { RADIANS } from '../../common/arith.js';
17
18
 
18
19
  /** mapbool for the "normalize" attr. @see lib/common/utils.c:mapbool */
19
20
  function mapbool(s: string): boolean {
@@ -34,7 +35,7 @@ function angleSet(g: Graph): number | null {
34
35
  if (a === undefined || a === '') return null;
35
36
  const ang = parseAngle(a);
36
37
  if (ang === null) return null;
37
- return (wrapDegrees(ang) / 180.0) * Math.PI; // RADIANS(a) = a/180.0*M_PI
38
+ return RADIANS(wrapDegrees(ang));
38
39
  }
39
40
 
40
41
  /** strtod (leading whitespace, sign) with the mapbool fallback of angleSet. */
@@ -21,7 +21,7 @@ import { RenderError } from '../../errors.js';
21
21
  import type { Graph } from '../../model/graph.js';
22
22
  import type { Node } from '../../model/node.js';
23
23
  import { sepFactor, type ExpandT } from '../neato/sep-factor.js';
24
- import { fdpAdjust, overlapPrismTries } from '../neato/fdp-adjust.js';
24
+ import { fdpAdjust, overlapPrismTries, warnUnrecognizedOverlap } from '../neato/fdp-adjust.js';
25
25
  import { scAdjust } from '../neato/sc-adjust.js';
26
26
  import {
27
27
  type XParams,
@@ -295,7 +295,7 @@ function parseOverlapTries(ovlp: string): { tries: number; rest: string } {
295
295
  /**
296
296
  * Overlap-removal dispatch on the overlap attribute:
297
297
  * ""/null → default "9:prism"; "n:mode" → n x_layout tries, then
298
- * removeOverlap with mode; "true" → keep overlaps.
298
+ * removeOverlapAs with mode; "true" → keep overlaps.
299
299
  * @see lib/fdpgen/xlayout.c:fdp_xLayout
300
300
  */
301
301
  export function fdpXLayout(g: Graph, xpms: XParams): void {
@@ -321,6 +321,7 @@ export function fdpXLayout(g: Graph, xpms: XParams): void {
321
321
  */
322
322
  function removeOverlapAs(g: Graph, flag: string): void {
323
323
  if (g.nodes.size < 2) return; // removeOverlapWith: <2 nodes short-circuits
324
+ warnUnrecognizedOverlap(flag); // getAdjustMode
324
325
  normalizeG(g); // removeOverlapWith runs normalize + simpleScale first
325
326
  simpleScale(g);
326
327
  const ntry = overlapPrismTries(flag);