@openpowershift/logic-diagram-language 0.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/dist/examples.d.ts +2 -0
  4. package/dist/examples.js +469 -0
  5. package/dist/export-image.d.ts +10 -0
  6. package/dist/export-image.js +47 -0
  7. package/dist/index.d.ts +10 -0
  8. package/dist/index.html +13 -0
  9. package/dist/index.js +18 -0
  10. package/dist/parser/ast.d.ts +118 -0
  11. package/dist/parser/ast.js +79 -0
  12. package/dist/parser/index.d.ts +3 -0
  13. package/dist/parser/index.js +2 -0
  14. package/dist/parser/parser.d.ts +2 -0
  15. package/dist/parser/parser.js +635 -0
  16. package/dist/renderer/astar-router.d.ts +16 -0
  17. package/dist/renderer/astar-router.js +532 -0
  18. package/dist/renderer/gates.d.ts +19 -0
  19. package/dist/renderer/gates.js +92 -0
  20. package/dist/renderer/graph.d.ts +31 -0
  21. package/dist/renderer/graph.js +403 -0
  22. package/dist/renderer/layout.d.ts +76 -0
  23. package/dist/renderer/layout.js +3121 -0
  24. package/dist/renderer/math-renderer.d.ts +16 -0
  25. package/dist/renderer/math-renderer.js +119 -0
  26. package/dist/renderer/svg-renderer.d.ts +3 -0
  27. package/dist/renderer/svg-renderer.js +599 -0
  28. package/dist/renderer/wires.d.ts +5 -0
  29. package/dist/renderer/wires.js +12 -0
  30. package/dist/theme/themes.d.ts +58 -0
  31. package/dist/theme/themes.js +104 -0
  32. package/docs/api.adoc +196 -0
  33. package/docs/ldl-for-llms.md +163 -0
  34. package/docs/user-guide.adoc +318 -0
  35. package/package.json +78 -0
  36. package/spec/render.sh +22 -0
  37. package/spec/sections/attributes.adoc +80 -0
  38. package/spec/sections/connections.adoc +39 -0
  39. package/spec/sections/examples.adoc +212 -0
  40. package/spec/sections/expressions.adoc +182 -0
  41. package/spec/sections/file-extension.adoc +5 -0
  42. package/spec/sections/function-blocks.adoc +120 -0
  43. package/spec/sections/grammar.adoc +64 -0
  44. package/spec/sections/hyperlinks.adoc +18 -0
  45. package/spec/sections/introduction.adoc +16 -0
  46. package/spec/sections/layout-rules.adoc +491 -0
  47. package/spec/sections/lexical-conventions.adoc +68 -0
  48. package/spec/sections/objects.adoc +31 -0
  49. package/spec/sections/options.adoc +146 -0
  50. package/spec/sections/ports.adoc +77 -0
  51. package/spec/sections/rendering-contract.adoc +11 -0
  52. package/spec/sections/revision-history.adoc +9 -0
  53. package/spec/sections/styling.adoc +83 -0
  54. package/spec/sections/svg-symbol-specification.adoc +149 -0
  55. package/spec/sections/symbol-definitions.adoc +143 -0
  56. package/spec/sections/templates.adoc +31 -0
  57. package/spec/spec.adoc +49 -0
@@ -0,0 +1,599 @@
1
+ import { DEFAULT_OPTIONS } from '../parser/ast.js';
2
+ import { layoutDiagram } from './layout.js';
3
+ import { andGateBody, orGateBody, notGateBody, renderInputPortLabel, renderOutputPortLabel, renderJunctionDot, NOT_TRIANGLE_W, BUBBLE_R, NOT_GATE_H, PORT_SIZE, } from './gates.js';
4
+ import { LIGHT_DIAGRAM } from '../theme/themes.js';
5
+ import { renderWire } from './wires.js';
6
+ import { hasMathContent, splitIntoSegments, renderMath, estimateTextWidth } from './math-renderer.js';
7
+ const INPUT_BAR_OFFSET = 12;
8
+ const INPUT_BAR_STUB = 6;
9
+ function esc(s) {
10
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
+ }
12
+ export function renderDiagram(diagram, portMeta, showLabels = true, showIds = false, options, diagramTheme) {
13
+ const opts = options ?? DEFAULT_OPTIONS;
14
+ const theme = diagramTheme ?? LIGHT_DIAGRAM;
15
+ const layout = layoutDiagram(diagram, portMeta, opts);
16
+ // Style payload: user STYLE blocks (parser already stored them as StyleDecl[]). Concatenated
17
+ // as-is into the rendered SVG's <defs><style>, so #ID selectors from `STYLE ... END STYLE` work
18
+ // restyling of the semantic SVG IDs in Item 10.
19
+ const userCss = diagram.styles.map(s => s.css).join('\n');
20
+ const svgWires = [];
21
+ const svgBodies = [];
22
+ const svgPorts = [];
23
+ const svgJunctions = [];
24
+ const svgIds = [];
25
+ const svgLabels = [];
26
+ // Map internal node ids to their SVG-facing ids for wire data-from/data-to attributes.
27
+ // The SVG id of a node (e.g. "I1", "AB") differs from its internal id (e.g. "in_2", "and_4"),
28
+ // so wire cross-references must use the SVG-facing id to match the DOM.
29
+ const svgIdOf = new Map();
30
+ for (const n of layout.nodes)
31
+ svgIdOf.set(n.id, svgObjectId(n));
32
+ for (let wi = 0; wi < layout.wires.length; wi++) {
33
+ const wire = layout.wires[wi];
34
+ const fromSvgId = svgIdOf.get(wire.fromId) ?? wire.fromId;
35
+ const toSvgId = svgIdOf.get(wire.toId) ?? wire.toId;
36
+ svgWires.push(renderWire(wire.points, fromSvgId, toSvgId, `wire_${wi}`, wire.feedback));
37
+ }
38
+ for (const node of layout.nodes) {
39
+ renderNodeBody(node, svgBodies, layout.options, theme);
40
+ renderNodePorts(node, svgPorts, layout.options, theme);
41
+ renderNodeLabels(node, showLabels, svgLabels, theme);
42
+ renderNodeIds(node, showIds, svgIds, theme);
43
+ }
44
+ for (let ji = 0; ji < layout.junctions.length; ji++) {
45
+ const j = layout.junctions[ji];
46
+ svgJunctions.push(`<g class="ldl-junction-group" id="dot_${ji}" data-ldl-x="${j.x}" data-ldl-y="${j.y}">${renderJunctionDot(j.x, j.y, theme)}</g>`);
47
+ }
48
+ if (showLabels)
49
+ for (let li = 0; li < layout.labels.length; li++) {
50
+ const lbl = layout.labels[li];
51
+ // Net label for a consumed intermediate, drawn above its fan-out junction.
52
+ const cx = lbl.x + lbl.width / 2;
53
+ let ty = lbl.y + 11;
54
+ svgLabels.push(`<g class="ldl-net-label" id="netlabel_${li}">`);
55
+ // Optional leader line from the label to the wire it names (OPTION WIRE_LABEL_LEADER). Drawn only
56
+ // when the label sits clear of its net (a real gap), from the box edge nearest the wire to the
57
+ // wire point. Makes the label→net association explicit wherever the label had to be placed.
58
+ if (opts.wireLabelLeader && lbl.leaderX !== undefined && lbl.leaderY !== undefined) {
59
+ const ex = Math.max(lbl.x, Math.min(lbl.x + lbl.width, lbl.leaderX)); // box edge nearest the wire
60
+ const ey = Math.max(lbl.y, Math.min(lbl.y + lbl.height, lbl.leaderY));
61
+ if (Math.hypot(lbl.leaderX - ex, lbl.leaderY - ey) > 6) {
62
+ svgLabels.push(`<line class="ldl-leader" x1="${ex}" y1="${ey}" x2="${lbl.leaderX}" y2="${lbl.leaderY}" stroke="${theme.descFill}" stroke-width="1" stroke-dasharray="3 2"/>`);
63
+ svgLabels.push(`<circle class="ldl-leader-dot" cx="${lbl.leaderX}" cy="${lbl.leaderY}" r="2" fill="${theme.descFill}"/>`);
64
+ }
65
+ }
66
+ if (lbl.name) {
67
+ svgLabels.push(`<text class="ldl-label ldl-name" x="${cx}" y="${ty}" text-anchor="middle" fill="${theme.nameFill}" font-size="11" font-family="sans-serif" font-weight="600">${esc(lbl.name)}</text>`);
68
+ ty += 12;
69
+ }
70
+ if (lbl.description) {
71
+ svgLabels.push(`<text class="ldl-label ldl-description" x="${cx}" y="${ty}" text-anchor="middle" fill="${theme.descFill}" font-size="9" font-family="sans-serif">${esc(lbl.description)}</text>`);
72
+ }
73
+ svgLabels.push('</g>');
74
+ }
75
+ // Content bounds include port label text, which can extend past the node bounding boxes
76
+ // (long output names overflow to the right, long input names to the left). Without this
77
+ // the viewBox clips them — the cause of right-side truncation in SVG/PDF export.
78
+ let minX = 0, maxX = layout.width, minY = 0, maxY = layout.height;
79
+ for (const node of layout.nodes) {
80
+ if (node.gateType === 'INPUT' && node.outputs[0]) {
81
+ const w = labelExtent(node, showLabels, showIds);
82
+ const p = node.outputs[0];
83
+ if (w > 0)
84
+ minX = Math.min(minX, p.absX - 16 - w);
85
+ minY = Math.min(minY, p.absY - 18);
86
+ maxY = Math.max(maxY, p.absY + 18);
87
+ }
88
+ else if (node.gateType === 'OUTPUT' && node.inputs[0]) {
89
+ const w = labelExtent(node, showLabels, showIds);
90
+ const p = node.inputs[0];
91
+ if (w > 0)
92
+ maxX = Math.max(maxX, p.absX + 16 + w);
93
+ minY = Math.min(minY, p.absY - 18);
94
+ maxY = Math.max(maxY, p.absY + 18);
95
+ }
96
+ }
97
+ if (showLabels)
98
+ for (const lbl of layout.labels) {
99
+ minX = Math.min(minX, lbl.x);
100
+ maxX = Math.max(maxX, lbl.x + lbl.width);
101
+ minY = Math.min(minY, lbl.y);
102
+ maxY = Math.max(maxY, lbl.y + lbl.height);
103
+ }
104
+ // Tight margin around the content bounds (labels are already included in min/maxX/Y above), so the
105
+ // exported/rendered diagram has only a thin border rather than a wide empty frame. Configurable
106
+ // via OPTION MARGIN = <px>; defaults to 8.
107
+ const pad = opts.margin ?? 8;
108
+ const svgW = (maxX - minX) + pad * 2;
109
+ const svgH = (maxY - minY) + pad * 2;
110
+ const tx = pad - minX;
111
+ const ty = pad - minY;
112
+ const labelsClass = showLabels ? 'ldl-show-labels' : '';
113
+ const idsClass = showIds ? 'ldl-show-ids' : '';
114
+ const hideDotsClass = opts.hideJunctions ? 'ldl-hide-dots' : '';
115
+ // Layer visibility via root class — Item 10/11 enables a stylesheet (or the user's STYLE block)
116
+ // to toggle whole layers by writing `svg.ldl-hide-dots .ldl-junction { display: none; }`
117
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" class="ldl-diagram ${labelsClass} ${idsClass} ${hideDotsClass}" style="max-width:100%;max-height:100%;">
118
+ <defs>
119
+ <style>
120
+ .ldl-wire { stroke: ${theme.wire}; fill: none; stroke-width: ${opts.strokeWidth ?? 2.5}; stroke-linecap: round; stroke-linejoin: round; transition: stroke 0.2s; }
121
+ .ldl-wire:hover { stroke: ${theme.wireHover}; stroke-width: ${(opts.strokeWidth ?? 2.5) + 1}; }
122
+ .ldl-symbol { transition: filter 0.15s; }
123
+ .ldl-symbol:hover { filter: brightness(1.3); }
124
+ /* Custom stroke-width (Item 10): user-set OPTION STROKE_WIDTH overrides the body defaults
125
+ via CSS, so a single knob restyles every gate/block body + bubbles + input bars uniformly. */
126
+ .ldl-symbol path, .ldl-symbol rect, .ldl-symbol circle, .ldl-input-bar, .ldl-bar-stub { stroke-width: ${opts.strokeWidth ?? 2.5}; }
127
+ .ldl-input-port text { fill: ${theme.nameFill}; }
128
+ .ldl-output-port text { fill: ${theme.nameOutFill}; }
129
+ .ldl-port { transition: filter 0.15s; }
130
+ .ldl-port:hover { filter: brightness(1.5); }
131
+ .ldl-diagram:not(.ldl-show-labels) .ldl-label { display: none; }
132
+ .ldl-diagram:not(.ldl-show-ids) .ldl-id { display: none; }
133
+ .ldl-diagram.ldl-show-labels .ldl-label { display: block; }
134
+ .ldl-diagram.ldl-show-ids .ldl-id { display: block; }
135
+ .ldl-diagram.ldl-hide-dots .ldl-junction-group { display: none; }
136
+ /* User-supplied STYLE blocks (Item 10): #ID selectors restyle the semantic SVG ids
137
+ emitted on gates/blocks/inputs/outputs/junctions/ports/wires. */
138
+ ${userCss ? ' ' + userCss.split('\n').join('\n ') + '\n' : ''}
139
+ </style>
140
+ </defs>
141
+ <rect x="0" y="0" width="${svgW}" height="${svgH}" fill="${theme.background}" rx="4"/>
142
+ <g class="ldl-layer-root" transform="translate(${tx}, ${ty})">
143
+ <g class="ldl-layer-wires">${svgWires.join('')}</g>
144
+ <g class="ldl-layer-bodies">${svgBodies.join('')}</g>
145
+ <g class="ldl-layer-ports">${svgPorts.join('')}</g>
146
+ <g class="ldl-layer-dots">${svgJunctions.join('')}</g>
147
+ <g class="ldl-layer-objects">${svgIds.join('')}</g>
148
+ <g class="ldl-layer-labels">${svgLabels.join('')}</g>
149
+ </g>
150
+ </svg>`;
151
+ }
152
+ // Widest rendered line of a port label (name/description/id), used to size the viewBox so
153
+ // labels are never clipped. Handles mixed plain + math (TeX) content.
154
+ function textLineWidth(text, fontSize) {
155
+ let w = 0;
156
+ for (const seg of splitIntoSegments(text)) {
157
+ if (seg.type === 'math') {
158
+ const r = renderMath(seg.text, fontSize);
159
+ w += r?.width ?? estimateTextWidth(seg.text, fontSize);
160
+ }
161
+ else {
162
+ w += estimateTextWidth(seg.text, fontSize);
163
+ }
164
+ }
165
+ return w;
166
+ }
167
+ function labelExtent(node, showLabels, showIds) {
168
+ let w = 0;
169
+ if (showLabels) {
170
+ const name = node.name || node.label || '';
171
+ w = Math.max(w, textLineWidth(name, 12));
172
+ if (node.description)
173
+ w = Math.max(w, textLineWidth(node.description, 9));
174
+ }
175
+ if (showIds)
176
+ w = Math.max(w, textLineWidth(node.label || '', 10));
177
+ // Safety margin: names render in a semi-bold weight that the width estimate undershoots,
178
+ // so pad generously to guarantee the label is never clipped.
179
+ return w > 0 ? w * 1.15 + 10 : 0;
180
+ }
181
+ function renderNodeBody(node, bodies, opts, theme) {
182
+ if (opts.inversion === 'BUBBLES' && node.gateType === 'NOT') {
183
+ return;
184
+ }
185
+ switch (node.gateType) {
186
+ case 'INPUT':
187
+ renderInputNodeBody(node, bodies, theme);
188
+ break;
189
+ case 'OUTPUT':
190
+ renderOutputNodeBody(node, bodies, theme);
191
+ break;
192
+ case 'AND':
193
+ renderGateNodeBody(node, 'and', '&', bodies, theme);
194
+ break;
195
+ case 'OR':
196
+ renderGateNodeBody(node, 'or', '\u22651', bodies, theme);
197
+ break;
198
+ case 'NOT':
199
+ renderNotNodeBody(node, bodies, theme);
200
+ break;
201
+ case 'TIMER':
202
+ case 'SR':
203
+ case 'RISING':
204
+ case 'FALLING':
205
+ case 'COMPARE':
206
+ renderBlockNodeBody(node, bodies, theme);
207
+ break;
208
+ case 'FB':
209
+ renderFbNodeBody(node, bodies, theme);
210
+ break;
211
+ default:
212
+ renderGateNodeBody(node, 'and', '&', bodies, theme);
213
+ }
214
+ }
215
+ // Small rising/falling edge step glyphs (a low-high or high-low step), used on TIMER (PU/DO)
216
+ // and the RISING/FALLING blocks.
217
+ function edgeGlyph(x, y, rising, stroke) {
218
+ const d = rising ? `M ${x} ${y + 8} L ${x + 6} ${y + 8} L ${x + 6} ${y} L ${x + 14} ${y}`
219
+ : `M ${x} ${y} L ${x + 6} ${y} L ${x + 6} ${y + 8} L ${x + 14} ${y + 8}`;
220
+ return ` <path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.5"/>`;
221
+ }
222
+ function fmtDuration(v) {
223
+ if (v === undefined || v === '')
224
+ return '0';
225
+ return /[a-z]/i.test(v) ? v : `${v}cyc`;
226
+ }
227
+ // Generic user block: a square box with the name centred inside, labelled input ports on the
228
+ // left and labelled output ports on the right. The description is rendered below (renderNodeLabels).
229
+ function renderFbNodeBody(node, bodies, theme) {
230
+ const x = node.absX, y = node.absY, w = node.width, h = node.height;
231
+ const stroke = theme.stroke, fill = theme.fill;
232
+ const parts = [`<g class="ldl-symbol ldl-block ldl-block-fb" id="${esc(svgObjectId(node))}" data-ldl-id="${esc(node.id)}">`];
233
+ parts.push(` <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="2" fill="${fill}" stroke="${stroke}" stroke-width="2.5"/>`);
234
+ if (node.name) {
235
+ parts.push(` <text x="${x + w / 2}" y="${y + h / 2 + 4}" text-anchor="middle" fill="${stroke}" font-size="11" font-weight="700" font-family="sans-serif">${esc(node.name)}</text>`);
236
+ }
237
+ for (const p of node.inputs) {
238
+ if (p.label)
239
+ parts.push(` <text x="${x + 6}" y="${p.absY + 3.5}" text-anchor="start" fill="${stroke}" font-size="9" font-family="sans-serif">${esc(p.label)}</text>`);
240
+ }
241
+ for (const p of node.outputs) {
242
+ if (p.label)
243
+ parts.push(` <text x="${x + w - 6}" y="${p.absY + 3.5}" text-anchor="end" fill="${stroke}" font-size="9" font-family="sans-serif">${esc(p.label)}</text>`);
244
+ }
245
+ parts.push('</g>');
246
+ bodies.push(parts.join('\n'));
247
+ }
248
+ // SEL-style function blocks: rectangle for TIMER/SR/RISING/FALLING, comparator triangle for
249
+ // COMPARE. Labels mirror SEL documentation (PU/DO, S/R/Q, +/−).
250
+ function renderBlockNodeBody(node, bodies, theme) {
251
+ const x = node.absX, y = node.absY, w = node.width, h = node.height;
252
+ const stroke = theme.stroke, fill = theme.fill;
253
+ const txt = (tx, ty, s, size = 11, anchor = 'middle') => ` <text x="${tx}" y="${ty}" text-anchor="${anchor}" fill="${stroke}" font-size="${size}" font-weight="700" font-family="sans-serif">${esc(s)}</text>`;
254
+ const parts = [`<g class="ldl-symbol ldl-block ldl-block-${(node.blockType ?? '').toLowerCase()}" id="${esc(svgObjectId(node))}" data-ldl-id="${esc(node.id)}">`];
255
+ // Port labels track the actual port Y (ports can be shifted/expanded to straighten wires).
256
+ if (node.blockType === 'COMPARE') {
257
+ parts.push(` <path d="M ${x} ${y} L ${x} ${y + h} L ${x + w} ${y + h / 2} Z" fill="${fill}" stroke="${stroke}" stroke-width="2.5"/>`);
258
+ if (node.inputs[0])
259
+ parts.push(txt(x + 13, node.inputs[0].absY + 5, '+', 14));
260
+ if (node.inputs[1])
261
+ parts.push(txt(x + 13, node.inputs[1].absY + 5, '−', 14));
262
+ }
263
+ else {
264
+ parts.push(` <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="2" fill="${fill}" stroke="${stroke}" stroke-width="2.5"/>`);
265
+ if (node.blockType === 'SR') {
266
+ if (node.inputs[0])
267
+ parts.push(txt(x + 12, node.inputs[0].absY + 4, 'S'));
268
+ if (node.inputs[1])
269
+ parts.push(txt(x + 12, node.inputs[1].absY + 4, 'R'));
270
+ for (const o of node.outputs) {
271
+ const ly = o.absY;
272
+ parts.push(txt(x + w - 12, ly + 4, 'Q'));
273
+ if (o.name === 'NQ')
274
+ parts.push(` <line x1="${x + w - 17}" y1="${ly - 7}" x2="${x + w - 7}" y2="${ly - 7}" stroke="${stroke}" stroke-width="1.5"/>`);
275
+ }
276
+ }
277
+ else if (node.blockType === 'TIMER') {
278
+ // Diagonal ramp from bottom-left to top-right; pickup (PU) in the upper-left, dropout (DO)
279
+ // in the lower-right — the SEL timer convention.
280
+ parts.push(` <line x1="${x}" y1="${y + h}" x2="${x + w}" y2="${y}" stroke="${stroke}" stroke-width="1.5"/>`);
281
+ parts.push(txt(x + w * 0.30, y + h * 0.36, fmtDuration(node.params?.PU), 10));
282
+ parts.push(txt(x + w * 0.70, y + h * 0.78, fmtDuration(node.params?.DO), 10));
283
+ }
284
+ else if (node.blockType === 'RISING' || node.blockType === 'FALLING') {
285
+ parts.push(edgeGlyph(x + w / 2 - 7, y + h / 2 - 4, node.blockType === 'RISING', stroke));
286
+ }
287
+ }
288
+ parts.push('</g>');
289
+ bodies.push(parts.join('\n'));
290
+ }
291
+ function renderNodePorts(node, ports, opts, theme) {
292
+ if (node.gateType === 'INPUT') {
293
+ const port = node.outputs[0];
294
+ if (port) {
295
+ const x = port.absX;
296
+ const y = port.absY;
297
+ if (opts.portStyle === 'SQUARE') {
298
+ const size = PORT_SIZE;
299
+ ports.push(`<rect class="ldl-port ldl-output ldl-port-square" data-port="out" x="${x - size / 2}" y="${y - size / 2}" width="${size}" height="${size}" fill="${theme.portFill}"/>`);
300
+ }
301
+ else {
302
+ ports.push(`<circle class="ldl-port ldl-output ldl-port-circle" data-port="out" cx="${x}" cy="${y}" r="${3}" fill="${theme.portFill}"/>`);
303
+ }
304
+ }
305
+ }
306
+ else if (node.gateType === 'OUTPUT') {
307
+ const port = node.inputs[0];
308
+ if (port) {
309
+ if (port.bubbled) {
310
+ // NOT feeding straight into an output: bubble sits just left of the output port.
311
+ const bubbleCenterX = port.absX + BUBBLE_R;
312
+ ports.push(`<circle class="ldl-bubble ldl-input" data-port="in" cx="${bubbleCenterX}" cy="${port.absY}" r="${BUBBLE_R}" fill="${theme.fill}" stroke="${theme.stroke}" stroke-width="1.5"/>`);
313
+ }
314
+ else {
315
+ ports.push(portMarker(port.absX, port.absY, 'ldl-input', 'in', opts, theme));
316
+ }
317
+ }
318
+ }
319
+ else {
320
+ // Inversion bubbles are drawn ONLY on ports actually marked inverted; every other
321
+ // port gets a normal marker. A bubbled input port was shifted left by BUBBLE_R*2 in
322
+ // layout, so the bubble (centre = port.absX + BUBBLE_R) sits just outside the gate
323
+ // edge with its inner edge meeting the gate (straight edge for AND/NOT, curve for OR).
324
+ for (const port of node.inputs) {
325
+ if (port.bubbled) {
326
+ const bubbleCenterX = port.absX + BUBBLE_R;
327
+ ports.push(`<circle class="ldl-bubble ldl-input" data-port="${esc(port.name)}" cx="${bubbleCenterX}" cy="${port.absY}" r="${BUBBLE_R}" fill="${theme.fill}" stroke="${theme.stroke}" stroke-width="1.5"/>`);
328
+ }
329
+ else {
330
+ ports.push(portMarker(port.absX, port.absY, 'ldl-input', port.name, opts, theme));
331
+ }
332
+ }
333
+ for (const port of node.outputs) {
334
+ if (port.bubbledOutput) {
335
+ const bubbleCenterX = port.absX - BUBBLE_R;
336
+ ports.push(`<circle class="ldl-bubble ldl-output" data-port="${esc(port.name)}" cx="${bubbleCenterX}" cy="${port.absY}" r="${BUBBLE_R}" fill="${theme.fill}" stroke="${theme.stroke}" stroke-width="1.5"/>`);
337
+ }
338
+ else {
339
+ ports.push(portMarker(port.absX, port.absY, 'ldl-output', port.name, opts, theme));
340
+ }
341
+ }
342
+ }
343
+ }
344
+ function portMarker(x, y, dir, name, opts, theme) {
345
+ if (opts.portStyle === 'SQUARE') {
346
+ const size = PORT_SIZE;
347
+ return `<rect class="ldl-port ${dir} ldl-port-square" data-port="${esc(name)}" x="${x - size / 2}" y="${y - size / 2}" width="${size}" height="${size}" fill="${theme.portFill}"/>`;
348
+ }
349
+ return `<circle class="ldl-port ${dir} ldl-port-circle" data-port="${esc(name)}" cx="${x}" cy="${y}" r="${3}" fill="${theme.portFill}"/>`;
350
+ }
351
+ function renderNodeLabels(node, showLabels, labels, theme) {
352
+ if (!showLabels)
353
+ return;
354
+ if (node.gateType === 'INPUT') {
355
+ const port = node.outputs[0];
356
+ if (port) {
357
+ const labelLeft = port.absX - 10;
358
+ const nameHasMath = node.name ? hasMathContent(node.name) : false;
359
+ const descHasMath = node.description ? hasMathContent(node.description) : false;
360
+ if (nameHasMath || descHasMath) {
361
+ labels.push(renderInputMathLabel(labelLeft, port.absY, node.label ?? '', theme, node.name, node.description));
362
+ }
363
+ else {
364
+ labels.push(renderInputPortLabel(labelLeft, port.absY, node.label ?? '', theme, node.name, node.description));
365
+ }
366
+ }
367
+ }
368
+ else if (node.gateType === 'OUTPUT') {
369
+ const port = node.inputs[0];
370
+ if (port) {
371
+ const labelLeft = port.absX + 10;
372
+ const nameHasMath = node.name ? hasMathContent(node.name) : false;
373
+ const descHasMath = node.description ? hasMathContent(node.description) : false;
374
+ if (nameHasMath || descHasMath) {
375
+ labels.push(renderOutputMathLabel(labelLeft, port.absY, node.label ?? '', theme, node.name, node.description));
376
+ }
377
+ else {
378
+ labels.push(renderOutputPortLabel(labelLeft, port.absY, node.label ?? '', theme, node.name, node.description));
379
+ }
380
+ }
381
+ }
382
+ else if (node.blockType && (node.name || node.description)) {
383
+ // SEL function blocks show their instance name above and description below the body. A generic
384
+ // FB block shows its name inside the box (renderFbNodeBody), so only the description goes below.
385
+ const cx = node.absX + node.width / 2;
386
+ if (node.name && node.blockType !== 'FB') {
387
+ labels.push(`<text class="ldl-label ldl-name" x="${cx}" y="${node.absY - 7}" text-anchor="middle" fill="${theme.nameFill}" font-size="12" font-family="sans-serif" font-weight="600">${esc(node.name)}</text>`);
388
+ }
389
+ if (node.description) {
390
+ labels.push(`<text class="ldl-label ldl-description" x="${cx}" y="${node.absY + node.height + 15}" text-anchor="middle" fill="${theme.descFill}" font-size="9" font-family="sans-serif">${esc(node.description)}</text>`);
391
+ }
392
+ }
393
+ else if (!node.blockType && node.gateType !== 'INPUT' && node.gateType !== 'OUTPUT' && (node.name || node.description)) {
394
+ // A named gate (AND#ID(...)) shows its instance name above and description below the body,
395
+ // mirroring the SEL block label placement.
396
+ const cx = node.absX + node.width / 2;
397
+ if (node.name && node.blockType !== 'FB') {
398
+ labels.push(`<text class="ldl-label ldl-name" x="${cx}" y="${node.absY - 7}" text-anchor="middle" fill="${theme.nameFill}" font-size="12" font-family="sans-serif" font-weight="600">${esc(node.name)}</text>`);
399
+ }
400
+ if (node.description) {
401
+ labels.push(`<text class="ldl-label ldl-description" x="${cx}" y="${node.absY + node.height + 15}" text-anchor="middle" fill="${theme.descFill}" font-size="9" font-family="sans-serif">${esc(node.description)}</text>`);
402
+ }
403
+ }
404
+ }
405
+ function renderNodeIds(node, showIds, ids, theme) {
406
+ if (!showIds)
407
+ return;
408
+ if (node.gateType === 'INPUT') {
409
+ const port = node.outputs[0];
410
+ if (port) {
411
+ // Above-left of the port (over the label column, not the wire stub) — stacks above the
412
+ // .Name label when labels are also shown, and is the sole id source for inputs.
413
+ ids.push(`<text class="ldl-id" x="${port.absX - 16}" y="${port.absY - PORT_SIZE / 2 - 4}" text-anchor="end" fill="${theme.idFill}" font-size="10" font-family="sans-serif" font-weight="600">${esc(node.label ?? '')}</text>`);
414
+ }
415
+ }
416
+ else if (node.gateType === 'OUTPUT') {
417
+ const port = node.inputs[0];
418
+ if (port) {
419
+ // Above-right of the port, mirroring the input placement.
420
+ ids.push(`<text class="ldl-id" x="${port.absX + 16}" y="${port.absY - PORT_SIZE / 2 - 4}" text-anchor="start" fill="${theme.idFill}" font-size="10" font-family="sans-serif" font-weight="600">${esc(node.label ?? '')}</text>`);
421
+ }
422
+ }
423
+ else {
424
+ // The gate/block's own instance id, centred above the body — this is the handle a STYLE
425
+ // `#ID` selector targets (e.g. AND#G1 -> "G1"), so revealing IDs must show it. A gate's name
426
+ // label sits at absY-7; stack the id above it so both stay legible when both layers show. A
427
+ // block's name is drawn inside the body, so its id can sit directly above.
428
+ const cx = node.absX + node.width / 2;
429
+ const idY = (!node.blockType && node.name) ? node.absY - 19 : node.absY - 7;
430
+ ids.push(`<text class="ldl-id" x="${cx}" y="${idY}" text-anchor="middle" fill="${theme.idFill}" font-size="10" font-family="sans-serif" font-weight="600">${esc(svgObjectId(node))}</text>`);
431
+ for (const p of node.inputs) {
432
+ ids.push(`<text class="ldl-id" x="${p.absX - PORT_SIZE / 2 - 3}" y="${p.absY + 3}" text-anchor="end" fill="${theme.idFill}" font-size="10" font-family="sans-serif" font-weight="600">${esc(p.name)}</text>`);
433
+ }
434
+ if (node.outputs.length > 0) {
435
+ const p = node.outputs[0];
436
+ ids.push(`<text class="ldl-id" x="${p.absX + PORT_SIZE / 2 + 3}" y="${p.absY + 3}" text-anchor="start" fill="${theme.idFill}" font-size="10" font-family="sans-serif" font-weight="600">out</text>`);
437
+ }
438
+ }
439
+ }
440
+ function svgObjectId(node) {
441
+ // Inputs/outputs are addressed by their name (e.g. I1, O2 — unique per diagram); gates/blocks
442
+ // by their instance label when set (an explicit `#ID`), else by internal id (e.g. and_4).
443
+ // CSS `#ID` selectors in the user's STYLE block thus target the user-facing identifier.
444
+ if (node.gateType === 'INPUT' || node.gateType === 'OUTPUT')
445
+ return node.label ?? node.id;
446
+ return node.label ?? node.id;
447
+ }
448
+ function renderInputNodeBody(node, bodies, theme) {
449
+ const port = node.outputs[0];
450
+ if (!port)
451
+ return;
452
+ const sid = svgObjectId(node);
453
+ const labelRight = node.absX + node.width - 10;
454
+ bodies.push(`<g class="ldl-symbol ldl-io ldl-input" id="${esc(sid)}" data-ldl-id="${esc(node.id)}"><line class="ldl-id-stub ldl-wire" x1="${labelRight}" y1="${port.absY}" x2="${port.absX}" y2="${port.absY}"/></g>`);
455
+ }
456
+ function renderOutputNodeBody(node, bodies, theme) {
457
+ const port = node.inputs[0];
458
+ if (!port)
459
+ return;
460
+ const sid = svgObjectId(node);
461
+ const labelLeft = port.absX + 10;
462
+ bodies.push(`<g class="ldl-symbol ldl-io ldl-output" id="${esc(sid)}" data-ldl-id="${esc(node.id)}"><line class="ldl-id-stub ldl-wire" x1="${port.absX}" y1="${port.absY}" x2="${labelLeft}" y2="${port.absY}"/></g>`);
463
+ }
464
+ function renderGateNodeBody(node, shape, symbol, bodies, theme) {
465
+ const w = node.width;
466
+ const h = node.height;
467
+ const x = node.absX;
468
+ const y = node.absY;
469
+ let bodyPath;
470
+ if (shape === 'or') {
471
+ bodyPath = orGateBody(w, h);
472
+ }
473
+ else {
474
+ bodyPath = andGateBody(w, h);
475
+ }
476
+ const cls = shape === 'or' ? 'ldl-gate-or' : 'ldl-gate-and';
477
+ const fontSize = Math.min(20, Math.max(12, h * 0.22));
478
+ const parts = [];
479
+ parts.push(`<g class="ldl-symbol ${cls}" id="${esc(svgObjectId(node))}" data-ldl-id="${esc(node.id)}">`);
480
+ parts.push(` <path d="${bodyPath}" transform="translate(${x}, ${y})" fill="${theme.fill}" stroke="${theme.stroke}" stroke-width="2.5"/>`);
481
+ parts.push(` <text x="${x + w / 2}" y="${y + h / 2 + fontSize * 0.35}" text-anchor="middle" fill="${theme.stroke}" font-size="${fontSize}" font-weight="700" font-family="sans-serif">${esc(symbol)}</text>`);
482
+ if (node.barsMode && node.inputs.length > 2) {
483
+ const barX = x - INPUT_BAR_OFFSET;
484
+ parts.push(` <line class="ldl-input-bar" x1="${barX}" y1="${y}" x2="${barX}" y2="${y + h}" stroke="${theme.stroke}" stroke-width="2.5"/>`);
485
+ for (let i = 2; i < node.inputs.length; i++) {
486
+ const port = node.inputs[i];
487
+ const stubEnd = x - INPUT_BAR_STUB;
488
+ parts.push(` <line class="ldl-bar-stub" x1="${barX}" y1="${port.absY}" x2="${stubEnd}" y2="${port.absY}" stroke="${theme.wire}" stroke-width="2.5"/>`);
489
+ }
490
+ }
491
+ parts.push('</g>');
492
+ bodies.push(parts.join('\n'));
493
+ }
494
+ function renderNotNodeBody(node, bodies, theme) {
495
+ const x = node.absX;
496
+ const y = node.absY;
497
+ const h = NOT_GATE_H;
498
+ const bodyPath = notGateBody(NOT_TRIANGLE_W, h);
499
+ const parts = [];
500
+ parts.push(`<g class="ldl-symbol ldl-gate-not" id="${esc(svgObjectId(node))}" data-ldl-id="${esc(node.id)}">`);
501
+ parts.push(` <path d="${bodyPath}" transform="translate(${x}, ${y})" fill="${theme.fill}" stroke="${theme.stroke}" stroke-width="2.5"/>`);
502
+ parts.push(` <circle cx="${x + NOT_TRIANGLE_W + BUBBLE_R}" cy="${y + h / 2}" r="${BUBBLE_R}" fill="${theme.fill}" stroke="${theme.stroke}" stroke-width="2.5"/>`);
503
+ parts.push(` <text x="${x + NOT_TRIANGLE_W * 0.38}" y="${y + h / 2 + 4}" text-anchor="middle" fill="${theme.stroke}" font-size="11" font-weight="700" font-family="sans-serif">NOT</text>`);
504
+ if (node.outputs.length > 0) {
505
+ const outPort = node.outputs[0];
506
+ const stubStartX = x + NOT_TRIANGLE_W + BUBBLE_R * 2;
507
+ parts.push(` <line class="ldl-wire" x1="${stubStartX}" y1="${y + h / 2}" x2="${outPort.absX}" y2="${y + h / 2}"/>`);
508
+ }
509
+ parts.push('</g>');
510
+ bodies.push(parts.join('\n'));
511
+ }
512
+ function renderMixedLabelContent(segments, baseX, baseY, anchor, fontSize, fill, isDescription) {
513
+ const parts = [];
514
+ const classSuffix = isDescription ? 'ldl-description' : 'ldl-name';
515
+ const fontWeight = isDescription ? '' : ' font-weight="500"';
516
+ // Small gap so a plain segment and an adjacent math segment never visually touch.
517
+ const segGap = segments.length > 1 ? fontSize * 0.18 : 0;
518
+ // width = glyph/text advance (used for placement); segGap is added only between segments.
519
+ const measured = [];
520
+ for (const seg of segments) {
521
+ if (seg.type === 'plain') {
522
+ measured.push({ type: 'plain', text: seg.text, width: estimateTextWidth(seg.text, fontSize), baselineOffset: 0 });
523
+ }
524
+ else {
525
+ const result = renderMath(seg.text, fontSize);
526
+ measured.push({
527
+ type: 'math',
528
+ text: seg.text,
529
+ width: result?.width ?? estimateTextWidth(seg.text, fontSize),
530
+ svg: result?.svg,
531
+ baselineOffset: result?.baselineOffset ?? 0,
532
+ });
533
+ }
534
+ }
535
+ if (anchor === 'end') {
536
+ const rightEdges = [];
537
+ let currentRight = baseX;
538
+ for (let i = measured.length - 1; i >= 0; i--) {
539
+ rightEdges[i] = currentRight;
540
+ currentRight -= measured[i].width + segGap;
541
+ }
542
+ for (let i = 0; i < measured.length; i++) {
543
+ const seg = measured[i];
544
+ const rightEdge = rightEdges[i];
545
+ if (seg.type === 'plain') {
546
+ parts.push(`<text class="ldl-label ${classSuffix}" x="${rightEdge}" y="${baseY}" text-anchor="end" fill="${fill}" font-size="${fontSize}" font-family="sans-serif"${fontWeight}>${esc(seg.text)}</text>`);
547
+ }
548
+ else if (seg.svg) {
549
+ parts.push(`<g class="ldl-math" transform="translate(${rightEdge - seg.width}, ${baseY - seg.baselineOffset})">${seg.svg}</g>`);
550
+ }
551
+ }
552
+ }
553
+ else {
554
+ let leftX = baseX;
555
+ for (const seg of measured) {
556
+ if (seg.type === 'plain') {
557
+ parts.push(`<text class="ldl-label ${classSuffix}" x="${leftX}" y="${baseY}" text-anchor="start" fill="${fill}" font-size="${fontSize}" font-family="sans-serif"${fontWeight}>${esc(seg.text)}</text>`);
558
+ }
559
+ else if (seg.svg) {
560
+ parts.push(`<g class="ldl-math" transform="translate(${leftX}, ${baseY - seg.baselineOffset})">${seg.svg}</g>`);
561
+ }
562
+ leftX += seg.width + segGap;
563
+ }
564
+ }
565
+ return parts.join('\n');
566
+ }
567
+ function renderInputMathLabel(absX, absY, label, theme, name, description) {
568
+ const displayName = name || label;
569
+ const labelGap = 6;
570
+ const textX = absX - labelGap;
571
+ const nameY = description ? absY - 6 : absY + 4;
572
+ const descY = description ? absY + 12 : 0;
573
+ const parts = [];
574
+ const nameSegments = splitIntoSegments(displayName);
575
+ parts.push(renderMixedLabelContent(nameSegments, textX, nameY, 'end', 12, theme.nameFill, false));
576
+ if (description) {
577
+ const descSegments = splitIntoSegments(description);
578
+ parts.push(renderMixedLabelContent(descSegments, textX, descY, 'end', 9, theme.descFill, true));
579
+ }
580
+ // Id text is emitted by renderNodeIds (gated on showIds); omitting it here avoids the CSS-hidden
581
+ // id leaking into non-browser SVG viewers.
582
+ return parts.join('\n');
583
+ }
584
+ function renderOutputMathLabel(absX, absY, label, theme, name, description) {
585
+ const displayName = name || label;
586
+ const labelGap = 6;
587
+ const textX = absX + labelGap;
588
+ const nameY = description ? absY - 6 : absY + 4;
589
+ const descY = description ? absY + 12 : 0;
590
+ const parts = [];
591
+ const nameSegments = splitIntoSegments(displayName);
592
+ parts.push(renderMixedLabelContent(nameSegments, textX, nameY, 'start', 12, theme.nameOutFill, false));
593
+ if (description) {
594
+ const descSegments = splitIntoSegments(description);
595
+ parts.push(renderMixedLabelContent(descSegments, textX, descY, 'start', 9, theme.descFill, true));
596
+ }
597
+ // Id text is emitted by renderNodeIds (gated on showIds); see note in renderInputMathLabel.
598
+ return parts.join('\n');
599
+ }
@@ -0,0 +1,5 @@
1
+ export interface WirePoint {
2
+ x: number;
3
+ y: number;
4
+ }
5
+ export declare function renderWire(points: WirePoint[], fromId: string, toId: string, svgId?: string, feedback?: boolean): string;
@@ -0,0 +1,12 @@
1
+ // Render a single wire as a <path> with stable id, `data-from`/`data-to` net links, and a
2
+ // `ldl-feedback` class for loop-back wires so CSS styling can distinguish them.
3
+ export function renderWire(points, fromId, toId, svgId, feedback) {
4
+ if (points.length < 2)
5
+ return '';
6
+ const d = points
7
+ .map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`)
8
+ .join(' ');
9
+ const id = svgId ?? `wire_${fromId}_to_${toId}`;
10
+ const fbClass = feedback ? ' ldl-feedback' : '';
11
+ return `<path class="ldl-wire${fbClass}" id="${id}" data-from="${fromId}" data-to="${toId}" d="${d}"/>`;
12
+ }