@oh-my-pi/pi-utils 16.0.6 → 16.0.8
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/CHANGELOG.md +10 -0
- package/dist/types/mermaid-ascii.d.ts +1 -1
- package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +41 -0
- package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +89 -0
- package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +12 -0
- package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +66 -0
- package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +48 -0
- package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +43 -0
- package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +56 -0
- package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +65 -0
- package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +27 -0
- package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +17 -0
- package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +34 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +26 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +31 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +59 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +17 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +30 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +55 -0
- package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +206 -0
- package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +51 -0
- package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +2 -0
- package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/class/types.d.ts +102 -0
- package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/er/types.d.ts +76 -0
- package/dist/types/vendor/mermaid-ascii/index.d.ts +1 -0
- package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +9 -0
- package/dist/types/vendor/mermaid-ascii/parser.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +130 -0
- package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +21 -0
- package/dist/types/vendor/mermaid-ascii/types.d.ts +114 -0
- package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +25 -0
- package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +145 -0
- package/package.json +2 -3
- package/src/mermaid-ascii.ts +1 -1
- package/src/vendor/mermaid-ascii/NOTICE +33 -0
- package/src/vendor/mermaid-ascii/ascii/ansi.ts +409 -0
- package/src/vendor/mermaid-ascii/ascii/canvas.ts +476 -0
- package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +699 -0
- package/src/vendor/mermaid-ascii/ascii/converter.ts +271 -0
- package/src/vendor/mermaid-ascii/ascii/draw.ts +1382 -0
- package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +328 -0
- package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +297 -0
- package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +441 -0
- package/src/vendor/mermaid-ascii/ascii/grid.ts +578 -0
- package/src/vendor/mermaid-ascii/ascii/index.ts +187 -0
- package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +78 -0
- package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +215 -0
- package/src/vendor/mermaid-ascii/ascii/sequence.ts +460 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +127 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +101 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +175 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +296 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +114 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +192 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +73 -0
- package/src/vendor/mermaid-ascii/ascii/types.ts +273 -0
- package/src/vendor/mermaid-ascii/ascii/validate.ts +120 -0
- package/src/vendor/mermaid-ascii/ascii/xychart.ts +875 -0
- package/src/vendor/mermaid-ascii/class/parser.ts +290 -0
- package/src/vendor/mermaid-ascii/class/types.ts +121 -0
- package/src/vendor/mermaid-ascii/er/parser.ts +181 -0
- package/src/vendor/mermaid-ascii/er/types.ts +91 -0
- package/src/vendor/mermaid-ascii/index.ts +14 -0
- package/src/vendor/mermaid-ascii/multiline-utils.ts +30 -0
- package/src/vendor/mermaid-ascii/parser.ts +645 -0
- package/src/vendor/mermaid-ascii/sequence/parser.ts +207 -0
- package/src/vendor/mermaid-ascii/sequence/types.ts +146 -0
- package/src/vendor/mermaid-ascii/text-metrics.ts +71 -0
- package/src/vendor/mermaid-ascii/types.ts +164 -0
- package/src/vendor/mermaid-ascii/xychart/colors.ts +140 -0
- package/src/vendor/mermaid-ascii/xychart/parser.ts +115 -0
- package/src/vendor/mermaid-ascii/xychart/types.ts +150 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — ER diagrams
|
|
3
|
+
//
|
|
4
|
+
// Renders erDiagram text to ASCII/Unicode art.
|
|
5
|
+
// Each entity is a 2-section box (header | attributes).
|
|
6
|
+
// Relationships are drawn as lines with crow's foot notation at endpoints.
|
|
7
|
+
//
|
|
8
|
+
// Layout: entities are placed in a grid pattern (multiple rows if needed).
|
|
9
|
+
// Relationship lines use Manhattan routing between entity boxes.
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
import { parseErDiagram } from '../er/parser'
|
|
13
|
+
import type { ErDiagram, ErEntity, ErAttribute, Cardinality } from '../er/types'
|
|
14
|
+
import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types'
|
|
15
|
+
import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas'
|
|
16
|
+
import { drawMultiBox } from './draw'
|
|
17
|
+
import { splitLines } from './multiline-utils'
|
|
18
|
+
import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
|
|
19
|
+
|
|
20
|
+
/** Classify a character from a box drawing as 'border' or 'text'. */
|
|
21
|
+
function classifyBoxChar(ch: string): CharRole {
|
|
22
|
+
if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border'
|
|
23
|
+
return 'text'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Entity box content
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/** Format an attribute line: "PK type name" or "FK type name" etc. */
|
|
31
|
+
function formatAttribute(attr: ErAttribute): string {
|
|
32
|
+
const keyStr = attr.keys.length > 0 ? attr.keys.join(',') + ' ' : ' '
|
|
33
|
+
return `${keyStr}${attr.type} ${attr.name}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build sections for an entity box: [header], [attributes] */
|
|
37
|
+
function buildEntitySections(entity: ErEntity): string[][] {
|
|
38
|
+
// Support multi-line entity names
|
|
39
|
+
const header = splitLines(entity.label)
|
|
40
|
+
const attrs = entity.attributes.map(formatAttribute)
|
|
41
|
+
if (attrs.length === 0) return [header]
|
|
42
|
+
return [header, attrs]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Crow's foot notation
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns the ASCII/Unicode characters for a crow's foot cardinality marker.
|
|
51
|
+
* Markers are drawn adjacent to entity boxes at relationship endpoints.
|
|
52
|
+
*
|
|
53
|
+
* Standard ER notation:
|
|
54
|
+
* one: ─┤├─ perpendicular line (exactly one)
|
|
55
|
+
* zero-one: ─○┤─ circle + perpendicular (zero or one)
|
|
56
|
+
* many: ─<>─ crow's foot (one or more)
|
|
57
|
+
* zero-many: ─○<─ circle + crow's foot (zero or more)
|
|
58
|
+
*
|
|
59
|
+
* @param card - The cardinality type
|
|
60
|
+
* @param useAscii - Use ASCII-only characters
|
|
61
|
+
* @param isRight - True if this marker is on the right side of the relationship
|
|
62
|
+
*/
|
|
63
|
+
function getCrowsFootChars(card: Cardinality, useAscii: boolean, isRight = false): string {
|
|
64
|
+
if (useAscii) {
|
|
65
|
+
switch (card) {
|
|
66
|
+
case 'one': return '|'
|
|
67
|
+
case 'zero-one': return 'o|'
|
|
68
|
+
case 'many': return isRight ? '<' : '>'
|
|
69
|
+
case 'zero-many': return isRight ? 'o<' : '>o'
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
// Use cleaner Unicode characters
|
|
73
|
+
switch (card) {
|
|
74
|
+
case 'one': return '│'
|
|
75
|
+
case 'zero-one': return '○│'
|
|
76
|
+
case 'many': return isRight ? '╟' : '╢'
|
|
77
|
+
case 'zero-many': return isRight ? '○╟' : '╢○'
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Positioned entity
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
interface PlacedEntity {
|
|
87
|
+
entity: ErEntity
|
|
88
|
+
sections: string[][]
|
|
89
|
+
x: number
|
|
90
|
+
y: number
|
|
91
|
+
width: number
|
|
92
|
+
height: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Connected Component Detection
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Find connected components in the ER diagram using DFS.
|
|
101
|
+
* Treats relationships as undirected edges for connectivity.
|
|
102
|
+
*
|
|
103
|
+
* Returns an array of entity ID sets, one per connected component.
|
|
104
|
+
*/
|
|
105
|
+
function findConnectedComponents(diagram: ErDiagram): Set<string>[] {
|
|
106
|
+
const visited = new Set<string>()
|
|
107
|
+
const components: Set<string>[] = []
|
|
108
|
+
|
|
109
|
+
// Build undirected adjacency list from relationships
|
|
110
|
+
const neighbors = new Map<string, Set<string>>()
|
|
111
|
+
for (const ent of diagram.entities) {
|
|
112
|
+
neighbors.set(ent.id, new Set())
|
|
113
|
+
}
|
|
114
|
+
for (const rel of diagram.relationships) {
|
|
115
|
+
neighbors.get(rel.entity1)?.add(rel.entity2)
|
|
116
|
+
neighbors.get(rel.entity2)?.add(rel.entity1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// DFS to find each component
|
|
120
|
+
function dfs(startId: string, component: Set<string>): void {
|
|
121
|
+
const stack = [startId]
|
|
122
|
+
while (stack.length > 0) {
|
|
123
|
+
const nodeId = stack.pop()!
|
|
124
|
+
if (visited.has(nodeId)) continue
|
|
125
|
+
|
|
126
|
+
visited.add(nodeId)
|
|
127
|
+
component.add(nodeId)
|
|
128
|
+
|
|
129
|
+
for (const neighbor of neighbors.get(nodeId) ?? []) {
|
|
130
|
+
if (!visited.has(neighbor)) {
|
|
131
|
+
stack.push(neighbor)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Find all components
|
|
138
|
+
for (const ent of diagram.entities) {
|
|
139
|
+
if (!visited.has(ent.id)) {
|
|
140
|
+
const component = new Set<string>()
|
|
141
|
+
dfs(ent.id, component)
|
|
142
|
+
if (component.size > 0) {
|
|
143
|
+
components.push(component)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return components
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ============================================================================
|
|
152
|
+
// Layout and rendering
|
|
153
|
+
// ============================================================================
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render a Mermaid ER diagram to ASCII/Unicode text.
|
|
157
|
+
*
|
|
158
|
+
* Pipeline: parse → build boxes → component-aware layout → draw boxes → draw relationships → string.
|
|
159
|
+
*/
|
|
160
|
+
export function renderErAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string {
|
|
161
|
+
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))
|
|
162
|
+
const diagram = parseErDiagram(lines)
|
|
163
|
+
|
|
164
|
+
if (diagram.entities.length === 0) return ''
|
|
165
|
+
|
|
166
|
+
const useAscii = config.useAscii
|
|
167
|
+
const hGap = 6 // horizontal gap between entity boxes
|
|
168
|
+
const vGap = 4 // vertical gap between rows (for relationship lines)
|
|
169
|
+
const componentGap = 6 // vertical gap between disconnected components
|
|
170
|
+
|
|
171
|
+
// --- Build entity box dimensions ---
|
|
172
|
+
const entitySections = new Map<string, string[][]>()
|
|
173
|
+
const entityBoxW = new Map<string, number>()
|
|
174
|
+
const entityBoxH = new Map<string, number>()
|
|
175
|
+
const entityById = new Map<string, ErEntity>()
|
|
176
|
+
|
|
177
|
+
for (const ent of diagram.entities) {
|
|
178
|
+
entityById.set(ent.id, ent)
|
|
179
|
+
const sections = buildEntitySections(ent)
|
|
180
|
+
entitySections.set(ent.id, sections)
|
|
181
|
+
|
|
182
|
+
let maxTextW = 0
|
|
183
|
+
for (const section of sections) {
|
|
184
|
+
for (const line of section) maxTextW = Math.max(maxTextW, displayWidth(line))
|
|
185
|
+
}
|
|
186
|
+
const boxW = maxTextW + 4 // 2 border + 2 padding
|
|
187
|
+
|
|
188
|
+
let totalLines = 0
|
|
189
|
+
for (const section of sections) totalLines += Math.max(section.length, 1)
|
|
190
|
+
const boxH = totalLines + (sections.length - 1) + 2
|
|
191
|
+
|
|
192
|
+
entityBoxW.set(ent.id, boxW)
|
|
193
|
+
entityBoxH.set(ent.id, boxH)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Find connected components ---
|
|
197
|
+
const components = findConnectedComponents(diagram)
|
|
198
|
+
|
|
199
|
+
// --- Layout: place each component, then stack components vertically ---
|
|
200
|
+
const placed = new Map<string, PlacedEntity>()
|
|
201
|
+
let currentY = 0
|
|
202
|
+
|
|
203
|
+
for (const component of components) {
|
|
204
|
+
// Get entities in this component (preserve original order for consistency)
|
|
205
|
+
const componentEntities = diagram.entities.filter(e => component.has(e.id))
|
|
206
|
+
|
|
207
|
+
// Layout entities within this component horizontally
|
|
208
|
+
// Use sqrt-based row limit for larger components
|
|
209
|
+
const maxPerRow = Math.max(2, Math.ceil(Math.sqrt(componentEntities.length)))
|
|
210
|
+
|
|
211
|
+
let currentX = 0
|
|
212
|
+
let maxRowH = 0
|
|
213
|
+
let colCount = 0
|
|
214
|
+
const componentStartY = currentY
|
|
215
|
+
|
|
216
|
+
for (const ent of componentEntities) {
|
|
217
|
+
const w = entityBoxW.get(ent.id)!
|
|
218
|
+
const h = entityBoxH.get(ent.id)!
|
|
219
|
+
|
|
220
|
+
if (colCount >= maxPerRow) {
|
|
221
|
+
// Wrap to next row within this component
|
|
222
|
+
currentY += maxRowH + vGap
|
|
223
|
+
currentX = 0
|
|
224
|
+
maxRowH = 0
|
|
225
|
+
colCount = 0
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
placed.set(ent.id, {
|
|
229
|
+
entity: ent,
|
|
230
|
+
sections: entitySections.get(ent.id)!,
|
|
231
|
+
x: currentX,
|
|
232
|
+
y: currentY,
|
|
233
|
+
width: w,
|
|
234
|
+
height: h,
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
currentX += w + hGap
|
|
238
|
+
maxRowH = Math.max(maxRowH, h)
|
|
239
|
+
colCount++
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Move to next component row (add gap between components)
|
|
243
|
+
currentY += maxRowH + componentGap
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Create canvas ---
|
|
247
|
+
let totalW = 0
|
|
248
|
+
let totalH = 0
|
|
249
|
+
for (const p of placed.values()) {
|
|
250
|
+
totalW = Math.max(totalW, p.x + p.width)
|
|
251
|
+
totalH = Math.max(totalH, p.y + p.height)
|
|
252
|
+
}
|
|
253
|
+
totalW += 4
|
|
254
|
+
totalH += 2
|
|
255
|
+
|
|
256
|
+
const canvas = mkCanvas(totalW - 1, totalH - 1)
|
|
257
|
+
const rc = mkRoleCanvas(totalW - 1, totalH - 1)
|
|
258
|
+
|
|
259
|
+
/** Set a character on the canvas and track its role. */
|
|
260
|
+
function setC(x: number, y: number, ch: string, role: CharRole): void {
|
|
261
|
+
if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) {
|
|
262
|
+
canvas[x]![y] = ch
|
|
263
|
+
setRole(rc, x, y, role)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- Draw entity boxes ---
|
|
268
|
+
for (const p of placed.values()) {
|
|
269
|
+
const boxCanvas = drawMultiBox(p.sections, useAscii)
|
|
270
|
+
for (let bx = 0; bx < boxCanvas.length; bx++) {
|
|
271
|
+
for (let by = 0; by < boxCanvas[0]!.length; by++) {
|
|
272
|
+
const ch = boxCanvas[bx]![by]!
|
|
273
|
+
if (ch !== ' ') {
|
|
274
|
+
const cx = p.x + bx
|
|
275
|
+
const cy = p.y + by
|
|
276
|
+
if (cx < totalW && cy < totalH) {
|
|
277
|
+
setC(cx, cy, ch, classifyBoxChar(ch))
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- Draw relationships ---
|
|
285
|
+
const H = useAscii ? '-' : '─'
|
|
286
|
+
const V = useAscii ? '|' : '│'
|
|
287
|
+
const dashH = useAscii ? '.' : '╌'
|
|
288
|
+
const dashV = useAscii ? ':' : '┊'
|
|
289
|
+
|
|
290
|
+
for (const rel of diagram.relationships) {
|
|
291
|
+
const e1 = placed.get(rel.entity1)
|
|
292
|
+
const e2 = placed.get(rel.entity2)
|
|
293
|
+
if (!e1 || !e2) continue
|
|
294
|
+
|
|
295
|
+
const lineH = rel.identifying ? H : dashH
|
|
296
|
+
const lineV = rel.identifying ? V : dashV
|
|
297
|
+
|
|
298
|
+
// Determine connection direction based on relative position.
|
|
299
|
+
// Connect from right side of left entity to left side of right entity (horizontal),
|
|
300
|
+
// or from bottom of upper entity to top of lower entity (vertical).
|
|
301
|
+
const e1CX = e1.x + Math.floor(e1.width / 2)
|
|
302
|
+
const e1CY = e1.y + Math.floor(e1.height / 2)
|
|
303
|
+
const e2CX = e2.x + Math.floor(e2.width / 2)
|
|
304
|
+
const e2CY = e2.y + Math.floor(e2.height / 2)
|
|
305
|
+
|
|
306
|
+
// Check if entities are on the same row (horizontal connection)
|
|
307
|
+
const sameRow = Math.abs(e1CY - e2CY) < Math.max(e1.height, e2.height)
|
|
308
|
+
|
|
309
|
+
if (sameRow) {
|
|
310
|
+
// Horizontal connection: right side of left entity → left side of right entity
|
|
311
|
+
const [left, right] = e1CX < e2CX ? [e1, e2] : [e2, e1]
|
|
312
|
+
const [leftCard, rightCard] = e1CX < e2CX
|
|
313
|
+
? [rel.cardinality1, rel.cardinality2]
|
|
314
|
+
: [rel.cardinality2, rel.cardinality1]
|
|
315
|
+
|
|
316
|
+
const startX = left.x + left.width
|
|
317
|
+
const endX = right.x - 1
|
|
318
|
+
const lineY = left.y + Math.floor(left.height / 2)
|
|
319
|
+
|
|
320
|
+
// Draw horizontal line
|
|
321
|
+
for (let x = startX; x <= endX; x++) {
|
|
322
|
+
setC(x, lineY, lineH, 'line')
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Draw crow's foot markers at endpoints
|
|
326
|
+
// Left marker (at left entity's right edge) - isRight=false
|
|
327
|
+
const leftChars = getCrowsFootChars(leftCard, useAscii, false)
|
|
328
|
+
for (let i = 0; i < leftChars.length; i++) {
|
|
329
|
+
setC(startX + i, lineY, leftChars[i]!, 'arrow')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Right marker (at right entity's left edge) - isRight=true
|
|
333
|
+
const rightChars = getCrowsFootChars(rightCard, useAscii, true)
|
|
334
|
+
for (let i = 0; i < rightChars.length; i++) {
|
|
335
|
+
setC(endX - rightChars.length + 1 + i, lineY, rightChars[i]!, 'arrow')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Relationship label centered in the gap between the two entities, below the line.
|
|
339
|
+
// Clamp label to the gap region [startX, endX] to avoid overwriting box borders.
|
|
340
|
+
// Supports multi-line labels.
|
|
341
|
+
if (rel.label) {
|
|
342
|
+
const lines = splitLines(rel.label)
|
|
343
|
+
const gapMid = Math.floor((startX + endX) / 2)
|
|
344
|
+
|
|
345
|
+
// Place lines below the relationship line (lineY + 1, lineY + 2, ...)
|
|
346
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
347
|
+
const line = lines[lineIdx]!
|
|
348
|
+
const cells = toCells(line)
|
|
349
|
+
const labelStart = Math.max(startX, gapMid - Math.floor(cells.length / 2))
|
|
350
|
+
const labelY = lineY + 1 + lineIdx
|
|
351
|
+
// Ensure canvas is tall enough
|
|
352
|
+
increaseSize(canvas, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1))
|
|
353
|
+
increaseRoleCanvasSize(rc, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1))
|
|
354
|
+
for (let i = 0; i < cells.length; i++) {
|
|
355
|
+
const cell = cells[i]!
|
|
356
|
+
if (cell === WIDE_PAD) continue // written atomically with its lead
|
|
357
|
+
const lx = labelStart + i
|
|
358
|
+
const wide = cells[i + 1] === WIDE_PAD
|
|
359
|
+
// Keep wide-glyph pairs atomic within the gap region
|
|
360
|
+
if (lx < startX || lx + (wide ? 1 : 0) > endX) continue
|
|
361
|
+
setC(lx, labelY, cell, 'text')
|
|
362
|
+
if (wide) setC(lx + 1, labelY, WIDE_PAD, 'text')
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
// Vertical connection: bottom of upper entity → top of lower entity
|
|
368
|
+
const [upper, lower] = e1CY < e2CY ? [e1, e2] : [e2, e1]
|
|
369
|
+
const [upperCard, lowerCard] = e1CY < e2CY
|
|
370
|
+
? [rel.cardinality1, rel.cardinality2]
|
|
371
|
+
: [rel.cardinality2, rel.cardinality1]
|
|
372
|
+
|
|
373
|
+
const startY = upper.y + upper.height
|
|
374
|
+
const endY = lower.y - 1
|
|
375
|
+
const lineX = upper.x + Math.floor(upper.width / 2)
|
|
376
|
+
|
|
377
|
+
// Vertical line
|
|
378
|
+
for (let y = startY; y <= endY; y++) {
|
|
379
|
+
setC(lineX, y, lineV, 'line')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// If horizontal offset needed, add a horizontal segment
|
|
383
|
+
const lowerCX = lower.x + Math.floor(lower.width / 2)
|
|
384
|
+
if (lineX !== lowerCX) {
|
|
385
|
+
const midY = Math.floor((startY + endY) / 2)
|
|
386
|
+
// Horizontal segment at midY
|
|
387
|
+
const lx = Math.min(lineX, lowerCX)
|
|
388
|
+
const rx = Math.max(lineX, lowerCX)
|
|
389
|
+
for (let x = lx; x <= rx; x++) {
|
|
390
|
+
setC(x, midY, lineH, 'line')
|
|
391
|
+
}
|
|
392
|
+
// Vertical from midY to lower entity
|
|
393
|
+
for (let y = midY + 1; y <= endY; y++) {
|
|
394
|
+
setC(lowerCX, y, lineV, 'line')
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Crow's foot markers (vertical direction)
|
|
399
|
+
// Upper marker (at upper entity's bottom edge) - treat as source side (isRight=false)
|
|
400
|
+
const upperChars = getCrowsFootChars(upperCard, useAscii, false)
|
|
401
|
+
for (let i = 0; i < upperChars.length; i++) {
|
|
402
|
+
setC(lineX - Math.floor(upperChars.length / 2) + i, startY, upperChars[i]!, 'arrow')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Lower marker (at lower entity's top edge) - treat as target side (isRight=true)
|
|
406
|
+
const targetX = lineX !== lowerCX ? lowerCX : lineX
|
|
407
|
+
const lowerChars = getCrowsFootChars(lowerCard, useAscii, true)
|
|
408
|
+
for (let i = 0; i < lowerChars.length; i++) {
|
|
409
|
+
setC(targetX - Math.floor(lowerChars.length / 2) + i, endY, lowerChars[i]!, 'arrow')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Relationship label — placed to the right of the vertical line at the midpoint.
|
|
413
|
+
// We expand the canvas as needed since labels can extend beyond the initial bounds.
|
|
414
|
+
// Supports multi-line labels.
|
|
415
|
+
if (rel.label) {
|
|
416
|
+
const lines = splitLines(rel.label)
|
|
417
|
+
const midY = Math.floor((startY + endY) / 2)
|
|
418
|
+
// Center lines vertically around midY
|
|
419
|
+
const startLabelY = midY - Math.floor((lines.length - 1) / 2)
|
|
420
|
+
|
|
421
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
422
|
+
const cells = toCells(lines[lineIdx]!)
|
|
423
|
+
const labelX = lineX + 2
|
|
424
|
+
const y = startLabelY + lineIdx
|
|
425
|
+
if (y >= 0) {
|
|
426
|
+
for (let i = 0; i < cells.length; i++) {
|
|
427
|
+
const lx = labelX + i
|
|
428
|
+
if (lx >= 0) {
|
|
429
|
+
increaseSize(canvas, lx + 1, y + 1)
|
|
430
|
+
increaseRoleCanvasSize(rc, lx + 1, y + 1)
|
|
431
|
+
setC(lx, y, cells[i]!, 'text')
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })
|
|
441
|
+
}
|