@zombie-mermaid/ascii-renderer 2.2.1
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/LICENSE +22 -0
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +146 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.js +5392 -0
- package/dist/index.js.map +1 -0
- package/package.json +36 -0
- package/src/__tests__/ascii-arrowhead-direction-1083.test.ts +51 -0
- package/src/__tests__/ascii-canvas-first-claim-wins-1093.test.ts +89 -0
- package/src/__tests__/ascii-canvas-size-offset-1093.test.ts +95 -0
- package/src/__tests__/ascii-canvas-write.test.ts +132 -0
- package/src/__tests__/ascii-chain-edge-overlap-1067.test.ts +100 -0
- package/src/__tests__/ascii-charset-border-junctions.test.ts +63 -0
- package/src/__tests__/ascii-cjk-width.test.ts +150 -0
- package/src/__tests__/ascii-class-box-occupancy.test.ts +462 -0
- package/src/__tests__/ascii-class-column-width-488-489.test.ts +639 -0
- package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
- package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +114 -0
- package/src/__tests__/ascii-class-diagram-compartments.test.ts +70 -0
- package/src/__tests__/ascii-class-label-box-collision.test.ts +60 -0
- package/src/__tests__/ascii-class-label-row-collision-531.test.ts +135 -0
- package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +57 -0
- package/src/__tests__/ascii-class-padding.test.ts +104 -0
- package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
- package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
- package/src/__tests__/ascii-class-reciprocal-relationships-448.test.ts +169 -0
- package/src/__tests__/ascii-combining-mark-width.test.ts +70 -0
- package/src/__tests__/ascii-coords-overlay.test.ts +64 -0
- package/src/__tests__/ascii-decision-lr-box-start.test.ts +106 -0
- package/src/__tests__/ascii-display-width-unit.test.ts +154 -0
- package/src/__tests__/ascii-draw-arrows-coverage.test.ts +224 -0
- package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +105 -0
- package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +72 -0
- package/src/__tests__/ascii-edge-ending-glyphs.test.ts +206 -0
- package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +95 -0
- package/src/__tests__/ascii-edge-routing-fixes.test.ts +178 -0
- package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +103 -0
- package/src/__tests__/ascii-edge-style-consistency-1067.test.ts +89 -0
- package/src/__tests__/ascii-edge-styles.test.ts +149 -0
- package/src/__tests__/ascii-emoji-cluster-width.test.ts +101 -0
- package/src/__tests__/ascii-er-box-occupancy.test.ts +251 -0
- package/src/__tests__/ascii-er-cardinality.test.ts +40 -0
- package/src/__tests__/ascii-er-corner-glyphs.test.ts +249 -0
- package/src/__tests__/ascii-er-jog-stray-line.test.ts +111 -0
- package/src/__tests__/ascii-er-label-padding.test.ts +118 -0
- package/src/__tests__/ascii-er-padding.test.ts +114 -0
- package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +458 -0
- package/src/__tests__/ascii-er-relationship-overwrite.test.ts +325 -0
- package/src/__tests__/ascii-er-stray-connectors.test.ts +344 -0
- package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +98 -0
- package/src/__tests__/ascii-er-vertical-one-marker.test.ts +110 -0
- package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +168 -0
- package/src/__tests__/ascii-lane-search.test.ts +207 -0
- package/src/__tests__/ascii-multibox-cjk-width.test.ts +184 -0
- package/src/__tests__/ascii-multiline.test.ts +288 -0
- package/src/__tests__/ascii-padding-edge-cases.test.ts +154 -0
- package/src/__tests__/ascii-pathfinder-route-edge.test.ts +184 -0
- package/src/__tests__/ascii-sequence-alt-else-label.test.ts +236 -0
- package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +217 -0
- package/src/__tests__/ascii-sequence-box-group.test.ts +159 -0
- package/src/__tests__/ascii-sequence-cjk-width.test.ts +235 -0
- package/src/__tests__/ascii-sequence-create-destroy.test.ts +114 -0
- package/src/__tests__/ascii-sequence-form-invariants.test.ts +432 -0
- package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +219 -0
- package/src/__tests__/ascii-sequence-notes.test.ts +61 -0
- package/src/__tests__/ascii-sequence-padding.test.ts +119 -0
- package/src/__tests__/ascii-sequence-self-arrow.test.ts +206 -0
- package/src/__tests__/ascii-shape-diamond.test.ts +38 -0
- package/src/__tests__/ascii-shape-rectangle.test.ts +258 -0
- package/src/__tests__/ascii-shape-rounded.test.ts +36 -0
- package/src/__tests__/ascii-shapes-circle.test.ts +41 -0
- package/src/__tests__/ascii-shapes-hexagon.test.ts +42 -0
- package/src/__tests__/ascii-shapes-special.test.ts +344 -0
- package/src/__tests__/ascii-shapes-stadium.test.ts +217 -0
- package/src/__tests__/ascii-shapes-state.test.ts +224 -0
- package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +130 -0
- package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +90 -0
- package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +152 -0
- package/src/__tests__/ascii-subgraph-title-padding.test.ts +77 -0
- package/src/__tests__/ascii-territory-unit.test.ts +219 -0
- package/src/__tests__/ascii-validate.test.ts +220 -0
- package/src/__tests__/ascii.test.ts +325 -0
- package/src/__tests__/class-arrow-directions.test.ts +505 -0
- package/src/__tests__/draw-lines.test.ts +93 -0
- package/src/__tests__/edge-cell-styles.test.ts +278 -0
- package/src/__tests__/grid-occupancy.test.ts +240 -0
- package/src/__tests__/helpers/ascii-form.ts +142 -0
- package/src/__tests__/helpers/terminal-display-width.ts +74 -0
- package/src/__tests__/pathfinder.test.ts +239 -0
- package/src/__tests__/testdata/ascii/ampersand_lhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_lhs_and_rhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_rhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_td_fanin.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_td_fanout.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_without_edge.txt +18 -0
- package/src/__tests__/testdata/ascii/back_reference_from_child.txt +10 -0
- package/src/__tests__/testdata/ascii/backlink_from_bottom.txt +22 -0
- package/src/__tests__/testdata/ascii/backlink_from_top.txt +22 -0
- package/src/__tests__/testdata/ascii/backlink_with_short_y_padding.txt +20 -0
- package/src/__tests__/testdata/ascii/cls_all_relationships.txt +19 -0
- package/src/__tests__/testdata/ascii/cls_annotation.txt +29 -0
- package/src/__tests__/testdata/ascii/cls_association.txt +14 -0
- package/src/__tests__/testdata/ascii/cls_basic.txt +15 -0
- package/src/__tests__/testdata/ascii/cls_dependency.txt +14 -0
- package/src/__tests__/testdata/ascii/cls_inheritance.txt +20 -0
- package/src/__tests__/testdata/ascii/cls_methods.txt +21 -0
- package/src/__tests__/testdata/ascii/comments.txt +23 -0
- package/src/__tests__/testdata/ascii/custom_padding.txt +10 -0
- package/src/__tests__/testdata/ascii/duplicate_labels.txt +19 -0
- package/src/__tests__/testdata/ascii/er_attributes.txt +21 -0
- package/src/__tests__/testdata/ascii/er_basic.txt +8 -0
- package/src/__tests__/testdata/ascii/er_identifying.txt +18 -0
- package/src/__tests__/testdata/ascii/flowchart_tb_simple.txt +29 -0
- package/src/__tests__/testdata/ascii/graph_bt_direction.txt +28 -0
- package/src/__tests__/testdata/ascii/graph_tb_direction.txt +26 -0
- package/src/__tests__/testdata/ascii/nested_subgraphs_with_labels.txt +36 -0
- package/src/__tests__/testdata/ascii/preserve_order_of_definition.txt +23 -0
- package/src/__tests__/testdata/ascii/self_reference.txt +10 -0
- package/src/__tests__/testdata/ascii/self_reference_with_edge.txt +10 -0
- package/src/__tests__/testdata/ascii/seq_basic.txt +17 -0
- package/src/__tests__/testdata/ascii/seq_multiple_messages.txt +25 -0
- package/src/__tests__/testdata/ascii/seq_self_message.txt +18 -0
- package/src/__tests__/testdata/ascii/single_node.txt +8 -0
- package/src/__tests__/testdata/ascii/single_node_longer_name.txt +8 -0
- package/src/__tests__/testdata/ascii/subgraph_complex_mixed.txt +38 -0
- package/src/__tests__/testdata/ascii/subgraph_complex_nested.txt +49 -0
- package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +47 -0
- package/src/__tests__/testdata/ascii/subgraph_empty.txt +10 -0
- package/src/__tests__/testdata/ascii/subgraph_mixed_nodes.txt +20 -0
- package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +48 -0
- package/src/__tests__/testdata/ascii/subgraph_multiple_edges.txt +32 -0
- package/src/__tests__/testdata/ascii/subgraph_multiple_nodes.txt +16 -0
- package/src/__tests__/testdata/ascii/subgraph_nested.txt +24 -0
- package/src/__tests__/testdata/ascii/subgraph_nested_with_external.txt +30 -0
- package/src/__tests__/testdata/ascii/subgraph_node_outside_lr.txt +17 -0
- package/src/__tests__/testdata/ascii/subgraph_single_node.txt +16 -0
- package/src/__tests__/testdata/ascii/subgraph_td_direction.txt +26 -0
- package/src/__tests__/testdata/ascii/subgraph_td_multiple.txt +44 -0
- package/src/__tests__/testdata/ascii/subgraph_td_multiple_paddingy.txt +42 -0
- package/src/__tests__/testdata/ascii/subgraph_three_levels_nested.txt +32 -0
- package/src/__tests__/testdata/ascii/subgraph_three_separate.txt +24 -0
- package/src/__tests__/testdata/ascii/subgraph_two_separate.txt +20 -0
- package/src/__tests__/testdata/ascii/subgraph_with_labels.txt +20 -0
- package/src/__tests__/testdata/ascii/three_nodes.txt +9 -0
- package/src/__tests__/testdata/ascii/three_nodes_single_line.txt +8 -0
- package/src/__tests__/testdata/ascii/two_layer_single_graph.txt +19 -0
- package/src/__tests__/testdata/ascii/two_layer_single_graph_longer_names.txt +19 -0
- package/src/__tests__/testdata/ascii/two_nodes_linked.txt +8 -0
- package/src/__tests__/testdata/ascii/two_nodes_longer_names.txt +8 -0
- package/src/__tests__/testdata/ascii/two_root_nodes.txt +19 -0
- package/src/__tests__/testdata/ascii/two_root_nodes_longer_names.txt +19 -0
- package/src/__tests__/testdata/ascii/two_single_root_nodes.txt +19 -0
- package/src/__tests__/testdata/unicode/ampersand_lhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_lhs_and_rhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_rhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_without_edge.txt +18 -0
- package/src/__tests__/testdata/unicode/back_reference_from_child.txt +10 -0
- package/src/__tests__/testdata/unicode/backlink_from_bottom.txt +22 -0
- package/src/__tests__/testdata/unicode/backlink_from_top.txt +22 -0
- package/src/__tests__/testdata/unicode/cls_all_relationships.txt +19 -0
- package/src/__tests__/testdata/unicode/cls_annotation.txt +29 -0
- package/src/__tests__/testdata/unicode/cls_association.txt +14 -0
- package/src/__tests__/testdata/unicode/cls_basic.txt +15 -0
- package/src/__tests__/testdata/unicode/cls_dependency.txt +14 -0
- package/src/__tests__/testdata/unicode/cls_inheritance.txt +20 -0
- package/src/__tests__/testdata/unicode/cls_methods.txt +21 -0
- package/src/__tests__/testdata/unicode/comments.txt +23 -0
- package/src/__tests__/testdata/unicode/duplicate_labels.txt +19 -0
- package/src/__tests__/testdata/unicode/er_attributes.txt +21 -0
- package/src/__tests__/testdata/unicode/er_basic.txt +8 -0
- package/src/__tests__/testdata/unicode/er_identifying.txt +18 -0
- package/src/__tests__/testdata/unicode/graph_bt_direction.txt +28 -0
- package/src/__tests__/testdata/unicode/preserve_order_of_definition.txt +23 -0
- package/src/__tests__/testdata/unicode/self_reference.txt +10 -0
- package/src/__tests__/testdata/unicode/self_reference_with_edge.txt +10 -0
- package/src/__tests__/testdata/unicode/seq_basic.txt +17 -0
- package/src/__tests__/testdata/unicode/seq_multiple_messages.txt +25 -0
- package/src/__tests__/testdata/unicode/seq_self_message.txt +18 -0
- package/src/__tests__/testdata/unicode/single_node.txt +8 -0
- package/src/__tests__/testdata/unicode/single_node_longer_name.txt +8 -0
- package/src/__tests__/testdata/unicode/three_nodes.txt +9 -0
- package/src/__tests__/testdata/unicode/three_nodes_single_line.txt +8 -0
- package/src/__tests__/testdata/unicode/two_layer_single_graph.txt +19 -0
- package/src/__tests__/testdata/unicode/two_layer_single_graph_longer_names.txt +19 -0
- package/src/__tests__/testdata/unicode/two_nodes_linked.txt +8 -0
- package/src/__tests__/testdata/unicode/two_nodes_longer_names.txt +8 -0
- package/src/__tests__/testdata/unicode/two_root_nodes.txt +19 -0
- package/src/__tests__/testdata/unicode/two_root_nodes_longer_names.txt +19 -0
- package/src/__tests__/testdata/unicode/two_single_root_nodes.txt +19 -0
- package/src/__tests__/xychart-ascii.test.ts +376 -0
- package/src/ansi.ts +490 -0
- package/src/canvas.ts +757 -0
- package/src/class-diagram.ts +2001 -0
- package/src/converter.ts +446 -0
- package/src/coords.ts +58 -0
- package/src/display-width.ts +151 -0
- package/src/draw-arrows.ts +593 -0
- package/src/draw-boxes.ts +267 -0
- package/src/draw-bundles.ts +611 -0
- package/src/draw-lines.ts +174 -0
- package/src/draw-subgraphs.ts +108 -0
- package/src/draw.ts +350 -0
- package/src/edge-bundling.ts +435 -0
- package/src/edge-cell-styles.ts +209 -0
- package/src/edge-routing.ts +1070 -0
- package/src/er-diagram.ts +1488 -0
- package/src/flowchart.ts +94 -0
- package/src/grid-occupancy.ts +234 -0
- package/src/grid.ts +1309 -0
- package/src/hyperlinks.ts +248 -0
- package/src/index.ts +163 -0
- package/src/lane-search.ts +68 -0
- package/src/multiline-utils.ts +82 -0
- package/src/pathfinder.ts +448 -0
- package/src/registry.ts +88 -0
- package/src/sequence.ts +1318 -0
- package/src/shapes/circle.ts +31 -0
- package/src/shapes/corners.ts +273 -0
- package/src/shapes/diamond.ts +31 -0
- package/src/shapes/hexagon.ts +35 -0
- package/src/shapes/index.ts +123 -0
- package/src/shapes/rectangle.ts +199 -0
- package/src/shapes/rounded.ts +31 -0
- package/src/shapes/special.ts +360 -0
- package/src/shapes/stadium.ts +122 -0
- package/src/shapes/state.ts +204 -0
- package/src/shapes/types.ts +78 -0
- package/src/territory.ts +136 -0
- package/src/types.ts +454 -0
- package/src/validate.ts +189 -0
- package/src/xychart.ts +1085 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — OSC 8 terminal hyperlinks (opt-in)
|
|
3
|
+
//
|
|
4
|
+
// Modern terminals (iTerm2, WezTerm, kitty, Windows Terminal, VTE-based
|
|
5
|
+
// terminals such as GNOME Terminal) turn a run of text wrapped in an OSC 8
|
|
6
|
+
// escape pair into a clickable link:
|
|
7
|
+
//
|
|
8
|
+
// ESC ] 8 ; ; https://example.com ESC \ Docs ESC ] 8 ; ; ESC \
|
|
9
|
+
//
|
|
10
|
+
// See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
|
|
11
|
+
// for the de-facto spec. The escape sequences are zero-width, so they must
|
|
12
|
+
// never take part in layout, column-width math, or box drawing — this module
|
|
13
|
+
// records *which canvas cells* carry a link in a `LinkCanvas` (parallel to
|
|
14
|
+
// `RoleCanvas`, the same column-major shape) at draw time, and the
|
|
15
|
+
// canvas-to-string conversion (`canvasToString` / `colorizeLine`) inserts
|
|
16
|
+
// the escape pairs around each run of linked cells as it emits a row.
|
|
17
|
+
// Marking cells rather than searching rendered text for the label means a
|
|
18
|
+
// label that repeats, or appears inside another label, still links only the
|
|
19
|
+
// node that actually declared the `click`.
|
|
20
|
+
//
|
|
21
|
+
// This is what lets a `click A "https://..."` directive survive into
|
|
22
|
+
// terminal output — see docs/decisions/no-script-interactivity.md for where
|
|
23
|
+
// that sits in the interactivity tiers. Off by default
|
|
24
|
+
// (`AsciiRenderOptions.hyperlinks`): not every terminal or pager handles
|
|
25
|
+
// OSC 8 gracefully, and HTML color mode (the demo site's terminal mockup)
|
|
26
|
+
// never emits it at all.
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
import type { Canvas, DrawingCoord, AsciiGraph } from './types.ts'
|
|
30
|
+
import type { NodeInteraction } from '@zombie-mermaid/core'
|
|
31
|
+
import { safeHref } from '@zombie-mermaid/core'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* [maxX, maxY] of a canvas — the same arithmetic as canvas.ts's
|
|
35
|
+
* `getCanvasSize`, inlined so this module (imported by canvas.ts for
|
|
36
|
+
* `joinWithLinks`) doesn't import canvas.ts back.
|
|
37
|
+
*/
|
|
38
|
+
function canvasBounds(canvas: Canvas): [number, number] {
|
|
39
|
+
return [canvas.length - 1, (canvas[0]?.length ?? 1) - 1]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Link canvas — parallel to Canvas, records the href each cell is part of.
|
|
44
|
+
* Same column-major structure: linkCanvas[x][y] gives the href at (x, y).
|
|
45
|
+
* null means the cell is not part of any link.
|
|
46
|
+
*/
|
|
47
|
+
export type LinkCanvas = (string | null)[][]
|
|
48
|
+
|
|
49
|
+
/** OSC 8 close sequence (an empty URI ends the current link). */
|
|
50
|
+
export const OSC8_CLOSE = '\x1b]8;;\x1b\\'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* OSC 8 open sequence for `href`.
|
|
54
|
+
*
|
|
55
|
+
* The URI portion is limited to printable ASCII (0x21–0x7E) by the spec
|
|
56
|
+
* linked above — anything else is percent-encoded here so a URL with a
|
|
57
|
+
* non-ASCII path or a literal space can't break the sequence or leak a
|
|
58
|
+
* control character into the terminal stream. `encodeURIComponent` on one
|
|
59
|
+
* code point yields its UTF-8 percent-encoding; the `u` flag keeps a
|
|
60
|
+
* surrogate pair together so it encodes as one code point rather than two
|
|
61
|
+
* lone surrogates (which `encodeURIComponent` would throw on).
|
|
62
|
+
*/
|
|
63
|
+
export function osc8Open(href: string): string {
|
|
64
|
+
const uri = href.replace(/[^\x21-\x7e]/gu, (ch) => encodeURIComponent(ch))
|
|
65
|
+
return `\x1b]8;;${uri}\x1b\\`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Matches one OSC 8 sequence — open or close, ST- or BEL-terminated. The
|
|
70
|
+
* URI can never contain ESC or BEL (`osc8Open` percent-encodes anything
|
|
71
|
+
* outside printable ASCII), so scanning to the first terminator is exact.
|
|
72
|
+
*/
|
|
73
|
+
export const OSC8_SEQUENCE = /\x1b\]8;[^\x07\x1b]*(?:\x1b\\|\x07)/g
|
|
74
|
+
|
|
75
|
+
/** Remove every OSC 8 sequence, leaving the visible text (and any SGR color codes) intact. */
|
|
76
|
+
export function stripOsc8(text: string): string {
|
|
77
|
+
return text.replace(OSC8_SEQUENCE, '')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// Link canvas creation and marking
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a blank link canvas filled with nulls.
|
|
86
|
+
* Dimensions are inclusive, matching `mkCanvas`: mkLinkCanvas(3, 2) covers
|
|
87
|
+
* indices 0..3 by 0..2.
|
|
88
|
+
*/
|
|
89
|
+
export function mkLinkCanvas(x: number, y: number): LinkCanvas {
|
|
90
|
+
const linkCanvas: LinkCanvas = []
|
|
91
|
+
for (let i = 0; i <= x; i++) {
|
|
92
|
+
const col: (string | null)[] = []
|
|
93
|
+
for (let j = 0; j <= y; j++) {
|
|
94
|
+
col.push(null)
|
|
95
|
+
}
|
|
96
|
+
linkCanvas.push(col)
|
|
97
|
+
}
|
|
98
|
+
return linkCanvas
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Bounds-checked write of an href to one cell. Out-of-range coordinates
|
|
103
|
+
* are a silent no-op, the same contract as `write()` in canvas.ts — the
|
|
104
|
+
* link canvas is created at the finished canvas's size, so a cell outside
|
|
105
|
+
* it can't be printed anyway.
|
|
106
|
+
*/
|
|
107
|
+
export function setLink(
|
|
108
|
+
linkCanvas: LinkCanvas,
|
|
109
|
+
x: number,
|
|
110
|
+
y: number,
|
|
111
|
+
href: string,
|
|
112
|
+
): void {
|
|
113
|
+
const col = linkCanvas[x]
|
|
114
|
+
if (col === undefined || y < 0 || y >= col.length) return
|
|
115
|
+
col[y] = href
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Flip the link canvas vertically to match `flipCanvasVertically` (used for
|
|
120
|
+
* BT direction). Mutates in place and returns it.
|
|
121
|
+
*/
|
|
122
|
+
export function flipLinkCanvasVertically(linkCanvas: LinkCanvas): LinkCanvas {
|
|
123
|
+
for (const col of linkCanvas) {
|
|
124
|
+
col.reverse()
|
|
125
|
+
}
|
|
126
|
+
return linkCanvas
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Mark the label text inside a drawn box as linked to `href`.
|
|
131
|
+
*
|
|
132
|
+
* `box` is a standalone box canvas (from `drawNode`/`drawMultiBox`) whose
|
|
133
|
+
* border occupies its outermost row and column; `offset` is where its
|
|
134
|
+
* top-left corner lands on the main canvas. For each interior row (or only
|
|
135
|
+
* `rows.from..rows.to`, inclusive, when given — class diagrams pass just
|
|
136
|
+
* the header's class-name rows), the linked span runs from the first to the
|
|
137
|
+
* last non-space cell, so a multi-word label ("Web Server") is one link
|
|
138
|
+
* rather than two, and each line of a multi-line label becomes its own
|
|
139
|
+
* span. A row with no text is left unmarked.
|
|
140
|
+
*
|
|
141
|
+
* Working from the box's own geometry (rather than scanning the merged
|
|
142
|
+
* canvas for the label's characters) is what keeps this exact for labels
|
|
143
|
+
* that repeat across nodes, contain box-drawing-like punctuation, or hold
|
|
144
|
+
* wide characters whose placeholder cell (`WIDE_CHAR_PLACEHOLDER`) is an
|
|
145
|
+
* empty string rather than a space.
|
|
146
|
+
*/
|
|
147
|
+
export function markBoxLabelLinks(
|
|
148
|
+
linkCanvas: LinkCanvas,
|
|
149
|
+
box: Canvas,
|
|
150
|
+
offset: DrawingCoord,
|
|
151
|
+
href: string,
|
|
152
|
+
rows?: { from: number; to: number },
|
|
153
|
+
): void {
|
|
154
|
+
const [maxX, maxY] = canvasBounds(box)
|
|
155
|
+
const firstRow = Math.max(1, rows?.from ?? 1)
|
|
156
|
+
const lastRow = Math.min(maxY - 1, rows?.to ?? maxY - 1)
|
|
157
|
+
|
|
158
|
+
for (let y = firstRow; y <= lastRow; y++) {
|
|
159
|
+
let first = -1
|
|
160
|
+
let last = -1
|
|
161
|
+
for (let x = 1; x <= maxX - 1; x++) {
|
|
162
|
+
if (box[x]?.[y] !== ' ') {
|
|
163
|
+
if (first === -1) first = x
|
|
164
|
+
last = x
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (first === -1) continue
|
|
168
|
+
for (let x = first; x <= last; x++) {
|
|
169
|
+
setLink(linkCanvas, x + offset.x, y + offset.y, href)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build the link canvas for a drawn flowchart/state graph: every node whose
|
|
176
|
+
* `click` declared a safe href (see `safeHref` — `javascript:`/`data:` and
|
|
177
|
+
* the like are dropped there, never here) gets its label cells marked. A
|
|
178
|
+
* `call`/`callback`-only interaction has no href and marks nothing.
|
|
179
|
+
*
|
|
180
|
+
* Must run after `drawGraph` (so every node has its `drawing` and
|
|
181
|
+
* `drawingCoord`) and before any BT flip of the canvas — flip this canvas
|
|
182
|
+
* alongside it with `flipLinkCanvasVertically`.
|
|
183
|
+
*/
|
|
184
|
+
export function buildNodeLinkCanvas(
|
|
185
|
+
graph: AsciiGraph,
|
|
186
|
+
interactions: ReadonlyMap<string, NodeInteraction>,
|
|
187
|
+
): LinkCanvas {
|
|
188
|
+
const [maxX, maxY] = canvasBounds(graph.canvas)
|
|
189
|
+
const linkCanvas = mkLinkCanvas(maxX, maxY)
|
|
190
|
+
|
|
191
|
+
for (const node of graph.nodes) {
|
|
192
|
+
if (!node.drawing || !node.drawingCoord) continue
|
|
193
|
+
const href = safeHref(interactions.get(node.name)?.href)
|
|
194
|
+
if (href === undefined) continue
|
|
195
|
+
markBoxLabelLinks(linkCanvas, node.drawing, node.drawingCoord, href)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return linkCanvas
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ============================================================================
|
|
202
|
+
// Emitting link runs while serializing a row
|
|
203
|
+
// ============================================================================
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Tracks the link state across one row's cells and hands back the escape
|
|
207
|
+
* text to insert before each cell: a close when leaving a linked run, an
|
|
208
|
+
* open when entering one (both when two different links touch), and
|
|
209
|
+
* nothing while the state is unchanged. `finish()` closes a run still open
|
|
210
|
+
* at the end of the row.
|
|
211
|
+
*
|
|
212
|
+
* The markers are pure insertions into the character stream — the SGR
|
|
213
|
+
* color grouping in `colorizeLine` is left exactly as it would be without
|
|
214
|
+
* links — so stripping every OSC 8 sequence from hyperlinked output yields
|
|
215
|
+
* the non-hyperlinked output byte for byte.
|
|
216
|
+
*/
|
|
217
|
+
export class LinkRunTracker {
|
|
218
|
+
private current: string | null = null
|
|
219
|
+
|
|
220
|
+
/** Escape text to emit before a cell carrying `link` (null = no link). */
|
|
221
|
+
advance(link: string | null): string {
|
|
222
|
+
if (link === this.current) return ''
|
|
223
|
+
let out = this.current !== null ? OSC8_CLOSE : ''
|
|
224
|
+
if (link !== null) out += osc8Open(link)
|
|
225
|
+
this.current = link
|
|
226
|
+
return out
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Escape text to emit after the row's last cell. */
|
|
230
|
+
finish(): string {
|
|
231
|
+
const out = this.current !== null ? OSC8_CLOSE : ''
|
|
232
|
+
this.current = null
|
|
233
|
+
return out
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Join a row's cells into plain (uncolored) text with OSC 8 pairs around each linked run. */
|
|
238
|
+
export function joinWithLinks(
|
|
239
|
+
chars: readonly string[],
|
|
240
|
+
links: readonly (string | null)[],
|
|
241
|
+
): string {
|
|
242
|
+
const tracker = new LinkRunTracker()
|
|
243
|
+
let line = ''
|
|
244
|
+
for (const [i, char] of chars.entries()) {
|
|
245
|
+
line += tracker.advance(links[i] ?? null) + char
|
|
246
|
+
}
|
|
247
|
+
return line + tracker.finish()
|
|
248
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// zombie-mermaid — ASCII renderer public API
|
|
3
|
+
//
|
|
4
|
+
// Renders Mermaid diagrams to ASCII or Unicode box-drawing art.
|
|
5
|
+
// No external dependencies — pure TypeScript.
|
|
6
|
+
//
|
|
7
|
+
// Supported diagram types:
|
|
8
|
+
// - Flowcharts (graph TD / flowchart LR) — grid-based layout with A* pathfinding
|
|
9
|
+
// - State diagrams (stateDiagram-v2) — same pipeline as flowcharts
|
|
10
|
+
// - Sequence diagrams (sequenceDiagram) — column-based timeline layout
|
|
11
|
+
// - Class diagrams (classDiagram) — level-based UML layout
|
|
12
|
+
// - ER diagrams (erDiagram) — grid layout with crow's foot notation
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// import { renderMermaidASCII } from 'zombie-mermaid'
|
|
16
|
+
// const ascii = renderMermaidASCII('graph LR\n A --> B')
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
import { detectDiagramType } from '@zombie-mermaid/core'
|
|
20
|
+
import type { Direction, DiagramType } from '@zombie-mermaid/core'
|
|
21
|
+
import { asciiRegistry } from './registry.ts'
|
|
22
|
+
import { addCoordsOverlay } from './coords.ts'
|
|
23
|
+
import {
|
|
24
|
+
detectColorMode,
|
|
25
|
+
DEFAULT_ASCII_THEME,
|
|
26
|
+
diagramColorsToAsciiTheme,
|
|
27
|
+
} from './ansi.ts'
|
|
28
|
+
import type { AsciiConfig, AsciiTheme, ColorMode } from './types.ts'
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_PADDING_X,
|
|
31
|
+
DEFAULT_PADDING_Y,
|
|
32
|
+
DEFAULT_BOX_BORDER_PADDING,
|
|
33
|
+
} from './types.ts'
|
|
34
|
+
|
|
35
|
+
// Re-export types for external use
|
|
36
|
+
export type { AsciiTheme, ColorMode }
|
|
37
|
+
export { DEFAULT_ASCII_THEME, detectColorMode, diagramColorsToAsciiTheme }
|
|
38
|
+
|
|
39
|
+
export interface AsciiRenderOptions {
|
|
40
|
+
/** true = ASCII chars (+,-,|,>), false = Unicode box-drawing (┌,─,│,►). Default: false */
|
|
41
|
+
useAscii?: boolean
|
|
42
|
+
/** Horizontal spacing between nodes. Default: 5 */
|
|
43
|
+
paddingX?: number
|
|
44
|
+
/** Vertical spacing between nodes. Default: 5 */
|
|
45
|
+
paddingY?: number
|
|
46
|
+
/** Padding inside node boxes. Default: 1 */
|
|
47
|
+
boxBorderPadding?: number
|
|
48
|
+
/**
|
|
49
|
+
* Color mode for output.
|
|
50
|
+
* - 'none': No colors (plain text)
|
|
51
|
+
* - 'auto': Auto-detect (terminal ANSI capabilities, or HTML in browsers)
|
|
52
|
+
* - 'ansi16': 16-color ANSI
|
|
53
|
+
* - 'ansi256': 256-color xterm
|
|
54
|
+
* - 'truecolor': 24-bit RGB
|
|
55
|
+
* - 'html': HTML <span> tags with inline color styles (for browser rendering)
|
|
56
|
+
* Default: 'auto'
|
|
57
|
+
*/
|
|
58
|
+
colorMode?: ColorMode | 'auto'
|
|
59
|
+
/** Theme colors for ASCII output. Uses default theme if not provided. */
|
|
60
|
+
theme?: Partial<AsciiTheme>
|
|
61
|
+
/**
|
|
62
|
+
* Overlay spreadsheet-style row/column indices on the rendered output, to
|
|
63
|
+
* help debug layout spacing. Off by default.
|
|
64
|
+
*/
|
|
65
|
+
showCoords?: boolean
|
|
66
|
+
/**
|
|
67
|
+
* Force the diagram's layout direction, overriding the one its source
|
|
68
|
+
* declares (a flowchart's `graph LR` header, or a state diagram's
|
|
69
|
+
* top-level `direction LR` line). Applied after parsing and before
|
|
70
|
+
* layout — the source text is never rewritten. Replaces only the
|
|
71
|
+
* top-level direction: a nested subgraph's or composite state's own
|
|
72
|
+
* `direction` line still applies on top of it, exactly as it does on top
|
|
73
|
+
* of the diagram's own header.
|
|
74
|
+
*
|
|
75
|
+
* Flowchart and state diagrams only in ASCII output. The ASCII ER layout
|
|
76
|
+
* has no direction concept (it already ignores a source `direction`
|
|
77
|
+
* line), and sequence/class/XY-chart diagrams have none to override, so
|
|
78
|
+
* all of those ignore this option (no error; output is identical with or
|
|
79
|
+
* without it). Same semantics as `RenderOptions.direction` for SVG. See
|
|
80
|
+
* issue #276.
|
|
81
|
+
*/
|
|
82
|
+
direction?: Direction
|
|
83
|
+
/**
|
|
84
|
+
* Emit OSC 8 terminal hyperlinks: each node whose `click` directive
|
|
85
|
+
* declared an http/https/mailto/relative href gets its label wrapped in
|
|
86
|
+
* an `ESC ] 8 ; ; <url> ESC \` … `ESC ] 8 ; ; ESC \` pair, which
|
|
87
|
+
* terminals that support OSC 8 (iTerm2, WezTerm, kitty, Windows
|
|
88
|
+
* Terminal, VTE-based terminals) render as a clickable link. The
|
|
89
|
+
* sequences are zero-width and never affect layout; `click ... call fn()`
|
|
90
|
+
* bindings emit nothing. Off by default — not every terminal or pager
|
|
91
|
+
* handles OSC 8 gracefully (`less` needs `-R`), and no capability
|
|
92
|
+
* detection is attempted here; the caller decides. Ignored when
|
|
93
|
+
* `colorMode` is 'html'.
|
|
94
|
+
*/
|
|
95
|
+
hyperlinks?: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Render Mermaid diagram text to an ASCII/Unicode string.
|
|
100
|
+
*
|
|
101
|
+
* Synchronous — no async layout engine needed (unlike the SVG renderer).
|
|
102
|
+
* Auto-detects diagram type from the header line and dispatches to
|
|
103
|
+
* the appropriate renderer.
|
|
104
|
+
*
|
|
105
|
+
* @param text - Mermaid source text (any supported diagram type)
|
|
106
|
+
* @param options - Rendering options
|
|
107
|
+
* @returns Multi-line ASCII/Unicode string
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```ts
|
|
111
|
+
* const result = renderMermaidASCII(`
|
|
112
|
+
* graph LR
|
|
113
|
+
* A --> B --> C
|
|
114
|
+
* `, { useAscii: true })
|
|
115
|
+
*
|
|
116
|
+
* // Output:
|
|
117
|
+
* // +---+ +---+ +---+
|
|
118
|
+
* // | | | | | |
|
|
119
|
+
* // | A |---->| B |---->| C |
|
|
120
|
+
* // | | | | | |
|
|
121
|
+
* // +---+ +---+ +---+
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export function renderMermaidASCII(
|
|
125
|
+
text: string,
|
|
126
|
+
options: AsciiRenderOptions = {},
|
|
127
|
+
): string {
|
|
128
|
+
const config: AsciiConfig = {
|
|
129
|
+
useAscii: options.useAscii ?? false,
|
|
130
|
+
paddingX: options.paddingX ?? DEFAULT_PADDING_X,
|
|
131
|
+
paddingY: options.paddingY ?? DEFAULT_PADDING_Y,
|
|
132
|
+
boxBorderPadding: options.boxBorderPadding ?? DEFAULT_BOX_BORDER_PADDING,
|
|
133
|
+
graphDirection: 'TD', // default; renderFlowchartAscii overrides this for flowcharts/state diagrams
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Resolve color mode ('auto' or unset → detect environment, otherwise use specified mode)
|
|
137
|
+
const colorMode: ColorMode =
|
|
138
|
+
options.colorMode === 'auto' || options.colorMode === undefined
|
|
139
|
+
? detectColorMode()
|
|
140
|
+
: options.colorMode
|
|
141
|
+
|
|
142
|
+
// Merge user theme with defaults
|
|
143
|
+
const theme: AsciiTheme = { ...DEFAULT_ASCII_THEME, ...options.theme }
|
|
144
|
+
|
|
145
|
+
const diagramType: DiagramType = detectDiagramType(text)
|
|
146
|
+
|
|
147
|
+
// Registry dispatch (see src/ascii/registry.ts — issue #533 / #745):
|
|
148
|
+
// every diagram type, including 'flowchart', is registered there and
|
|
149
|
+
// dispatches to the exact same renderer calls each type's original
|
|
150
|
+
// switch case (or, for 'flowchart', inline sequence) used to make.
|
|
151
|
+
// `extras` carries every ASCII-only per-type option: `hyperlinks` (read
|
|
152
|
+
// by 'class' and 'flowchart') and `direction` (read by 'flowchart'
|
|
153
|
+
// only); both are ignored by every other registered type.
|
|
154
|
+
const result = asciiRegistry[diagramType](text, config, colorMode, theme, {
|
|
155
|
+
hyperlinks: options.hyperlinks ?? false,
|
|
156
|
+
direction: options.direction,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
return options.showCoords ? addCoordsOverlay(result) : result
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @deprecated Use `renderMermaidASCII` */
|
|
163
|
+
export const renderMermaidAscii = renderMermaidASCII
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderers — shared "nearest free lane" search
|
|
3
|
+
//
|
|
4
|
+
// `er-diagram.ts` (`chooseFreeRow`) and `class-diagram.ts`
|
|
5
|
+
// (`findClearColumn`) independently grew the same search: start at a
|
|
6
|
+
// preferred row/column, and if that one is occupied, walk outward one step
|
|
7
|
+
// at a time — forward first, then backward — until a candidate passes the
|
|
8
|
+
// caller's occupancy check.
|
|
9
|
+
//
|
|
10
|
+
// Only the *search order* is shared here. The occupancy check itself stays
|
|
11
|
+
// with each renderer and arrives as a callback, because the two renderers
|
|
12
|
+
// answer "is this lane usable?" from genuinely different sources: ER scans
|
|
13
|
+
// the rendered canvas itself, while class-diagram tests against the placed
|
|
14
|
+
// box rectangles. Unifying that *storage* was scoped out as unsafe — see
|
|
15
|
+
// `docs/decisions/ascii-occupancy-unification-532.md` (issue #532) — so this
|
|
16
|
+
// module deliberately extracts the algorithm and nothing else.
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Caller-supplied occupancy check: whether the whole region a candidate
|
|
21
|
+
* lane would occupy is usable. Called at most once per candidate, and only
|
|
22
|
+
* for candidates already inside `[min, max]` (plus `preferred`, see below),
|
|
23
|
+
* so an expensive check isn't run on lanes that would be rejected anyway.
|
|
24
|
+
*/
|
|
25
|
+
export type LaneFree = (candidate: number) => boolean
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find the nearest usable lane (a row or a column) to `preferred`, scanning
|
|
29
|
+
* outward in alternating directions and stopping at the first candidate
|
|
30
|
+
* `isFree` accepts.
|
|
31
|
+
*
|
|
32
|
+
* Search order: `preferred`, then `preferred + 1`, `preferred - 1`,
|
|
33
|
+
* `preferred + 2`, `preferred - 2`, … — the forward direction is always
|
|
34
|
+
* tried first at each distance, which is what both original call sites did
|
|
35
|
+
* (ER scanned below-then-above; class-diagram right-then-left).
|
|
36
|
+
*
|
|
37
|
+
* `min`/`max` are *inclusive* bounds on the outward candidates only.
|
|
38
|
+
* `preferred` itself is tested unconditionally, even when it falls outside
|
|
39
|
+
* them: both call sites relied on that. ER's bounds are the open interval
|
|
40
|
+
* between two entity borders, so a gap too narrow to hold any candidate row
|
|
41
|
+
* still lets the plain midpoint through; class-diagram's `min` of 0 keeps
|
|
42
|
+
* the search on-canvas while `preferred` is a box's own centre column.
|
|
43
|
+
*
|
|
44
|
+
* Returns `undefined` when no candidate in range is free — including when
|
|
45
|
+
* `min > max` leaves no candidates at all. Each caller supplies its own
|
|
46
|
+
* fallback for that case (they differ), rather than this function inventing
|
|
47
|
+
* one.
|
|
48
|
+
*/
|
|
49
|
+
export function findFreeLane(
|
|
50
|
+
preferred: number,
|
|
51
|
+
min: number,
|
|
52
|
+
max: number,
|
|
53
|
+
isFree: LaneFree,
|
|
54
|
+
): number | undefined {
|
|
55
|
+
if (isFree(preferred)) return preferred
|
|
56
|
+
|
|
57
|
+
// Far enough to reach whichever bound is further from `preferred`; beyond
|
|
58
|
+
// that every remaining candidate is out of range in both directions.
|
|
59
|
+
const maxOffset = Math.max(preferred - min, max - preferred)
|
|
60
|
+
for (let offset = 1; offset <= maxOffset; offset++) {
|
|
61
|
+
const forward = preferred + offset
|
|
62
|
+
if (forward <= max && isFree(forward)) return forward
|
|
63
|
+
const backward = preferred - offset
|
|
64
|
+
if (backward >= min && isFree(backward)) return backward
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — multi-line text utilities
|
|
3
|
+
//
|
|
4
|
+
// Shared utilities for handling multi-line labels (containing \n from <br> tags)
|
|
5
|
+
// in ASCII/Unicode rendering. Provides consistent text splitting, sizing, and
|
|
6
|
+
// centered rendering across all diagram types.
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
import type { Canvas } from './types.ts'
|
|
10
|
+
import { drawText } from './canvas.ts'
|
|
11
|
+
import { displayWidth } from './display-width.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split a label into lines.
|
|
15
|
+
* Labels are already normalized by parsers (br tags → \n).
|
|
16
|
+
*/
|
|
17
|
+
export function splitLines(label: string): string[] {
|
|
18
|
+
return label.split('\n')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the maximum line width (in terminal display columns) for sizing
|
|
23
|
+
* calculations. Used to determine column widths for multi-line labels.
|
|
24
|
+
*
|
|
25
|
+
* Uses `displayWidth` rather than `.length` so CJK/kana/hangul/fullwidth-form
|
|
26
|
+
* and emoji characters — which render as two terminal columns each — are
|
|
27
|
+
* accounted for correctly. Using code-unit `.length` here would produce
|
|
28
|
+
* boxes too narrow for wide-character labels.
|
|
29
|
+
*/
|
|
30
|
+
export function maxLineWidth(label: string): number {
|
|
31
|
+
const lines = splitLines(label)
|
|
32
|
+
return Math.max(...lines.map((l) => displayWidth(l)), 0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the number of lines for height calculations.
|
|
37
|
+
* Used to determine row heights for multi-line labels.
|
|
38
|
+
*/
|
|
39
|
+
export function lineCount(label: string): number {
|
|
40
|
+
return splitLines(label).length
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Draw multi-line text centered at (cx, cy).
|
|
45
|
+
* Expands vertically from the center point.
|
|
46
|
+
* Each line is horizontally centered independently.
|
|
47
|
+
*/
|
|
48
|
+
export function drawMultilineTextCentered(
|
|
49
|
+
canvas: Canvas,
|
|
50
|
+
label: string,
|
|
51
|
+
cx: number,
|
|
52
|
+
cy: number,
|
|
53
|
+
): void {
|
|
54
|
+
const lines = splitLines(label)
|
|
55
|
+
const totalHeight = lines.length
|
|
56
|
+
// Center vertically: start y positions lines evenly around cy
|
|
57
|
+
const startY = cy - Math.floor((totalHeight - 1) / 2)
|
|
58
|
+
|
|
59
|
+
for (const [i, line] of lines.entries()) {
|
|
60
|
+
// Center each line horizontally (in display columns, not code units)
|
|
61
|
+
const startX = cx - Math.floor(displayWidth(line) / 2)
|
|
62
|
+
// Force overwrite for node labels (they take priority)
|
|
63
|
+
drawText(canvas, { x: startX, y: startY + i }, line, true)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Draw multi-line text left-aligned starting at (x, y).
|
|
69
|
+
* Each subsequent line is placed one row below.
|
|
70
|
+
*/
|
|
71
|
+
export function drawMultilineTextLeft(
|
|
72
|
+
canvas: Canvas,
|
|
73
|
+
label: string,
|
|
74
|
+
x: number,
|
|
75
|
+
y: number,
|
|
76
|
+
): void {
|
|
77
|
+
const lines = splitLines(label)
|
|
78
|
+
for (const [i, line] of lines.entries()) {
|
|
79
|
+
// Force overwrite for node labels (they take priority)
|
|
80
|
+
drawText(canvas, { x, y: y + i }, line, true)
|
|
81
|
+
}
|
|
82
|
+
}
|