@schemd/core 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/renderer.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { classicalGateHeight, componentTextAnchors, distributedCoordinate, enumerateComponentPorts, positionIcPin, PORT_HOTSPOT_RADIUS, routeConnections, validateDocumentGeometry } from './layout.js';
2
2
  import { MAX_SCHEMATIC_SVG_OUTPUT_BYTES, utf8ByteLength } from './limits.js';
3
3
  import { assertParsedSchematicDocument } from './parser.js';
4
- import { CLASSICAL_GATE_KINDS, SCHEMD_OUTPUT_MODES, SchematicSyntaxError } from './types.js';
5
- import { renderMathLabelTspans } from './math-label.js';
4
+ import { CLASSICAL_GATE_KINDS, SCHEMD_OUTPUT_MODES, UML_COMPONENT_KINDS, SchematicSyntaxError } from './types.js';
5
+ import { mathLabelTextWidth, renderMathLabelTspans } from './math-label.js';
6
6
  export const MAX_SVG_OUTPUT_BYTES = MAX_SCHEMATIC_SVG_OUTPUT_BYTES;
7
- const STATIC_SVG_STYLES = '.schematic-token{fill:none;stroke:var(--schematic-vector,var(--schematic-vector-fallback,currentColor));stroke-linecap:round;stroke-linejoin:round;stroke-width:var(--schematic-stroke-width,1.65);vector-effect:non-scaling-stroke}.schematic-node-fill{fill:var(--schematic-vector,var(--schematic-vector-fallback,currentColor))}.schematic-designator,.schematic-label,.schematic-gate-symbol,.schematic-quantum-detail,.schematic-pin-label{fill:currentColor}.schematic-surface{fill:var(--schematic-surface,transparent)}.schematic-grid-line{fill:none;stroke:var(--schematic-grid,currentColor);stroke-width:1;opacity:.12;vector-effect:non-scaling-stroke}';
7
+ const STATIC_SVG_STYLES = '.schematic-token{fill:none;stroke:var(--schematic-vector,var(--schematic-vector-fallback,currentColor));stroke-linecap:round;stroke-linejoin:round;stroke-width:var(--schematic-stroke-width,1.65);vector-effect:non-scaling-stroke}.schematic-node-fill{fill:var(--schematic-vector,var(--schematic-vector-fallback,currentColor))}.schematic-designator,.schematic-label,.schematic-gate-symbol,.schematic-quantum-detail,.schematic-pin-label,.schematic-uml-text,.schematic-connection-label{fill:currentColor}.schematic-uml-row{font-size:12px}.schematic-uml-stereotype{font-size:11px}.schematic-connection-label{font-size:11px;paint-order:stroke;stroke:var(--schematic-surface,#fff);stroke-width:4;stroke-linejoin:round}.schematic-surface{fill:var(--schematic-surface,transparent)}.schematic-grid-line{fill:none;stroke:var(--schematic-grid,currentColor);stroke-width:1;opacity:.12;vector-effect:non-scaling-stroke}';
8
8
  const HOOK_SVG_STYLES = '.schematic-port-hotspot{fill:transparent!important;stroke:transparent!important;stroke-width:8;vector-effect:non-scaling-stroke;pointer-events:all;outline:none}.schematic-port-hotspot:focus-visible{fill:var(--schematic-vector,var(--schematic-vector-fallback,currentColor))!important;fill-opacity:.22!important;stroke:var(--schematic-vector,var(--schematic-vector-fallback,currentColor))!important;stroke-width:2}';
9
9
  const INTERACTIVE_SVG_STYLES = '.schematic-component,.schematic-wire{outline:none;transition:opacity .2s ease,filter .2s ease}.schematic-component .schematic-token,.schematic-wire .schematic-token{transition:stroke .2s ease,fill .2s ease,color .2s ease,opacity .2s ease,stroke-width .2s ease}.schematic-glow-layer{opacity:0;pointer-events:none;transition:opacity .2s ease}.schematic-component:hover>.schematic-glow-layer,.schematic-component:focus>.schematic-glow-layer,.schematic-component:focus-within>.schematic-glow-layer,.schematic-component.is-hovered>.schematic-glow-layer,.schematic-component.is-active>.schematic-glow-layer,.schematic-component.is-selected>.schematic-glow-layer,.schematic-wire:hover>.schematic-glow-layer,.schematic-wire:focus>.schematic-glow-layer,.schematic-wire.is-hovered>.schematic-glow-layer,.schematic-wire.is-active>.schematic-glow-layer,.schematic-wire.is-selected>.schematic-glow-layer{opacity:1}.schematic-component.is-degraded,.schematic-wire.is-degraded{opacity:.45}.schematic-component.is-degraded>.schematic-glow-layer,.schematic-wire.is-degraded>.schematic-glow-layer{opacity:0}.schematic-component:hover .schematic-token,.schematic-component:focus .schematic-token,.schematic-component:focus-within .schematic-token,.schematic-component.is-hovered .schematic-token,.schematic-component.is-active .schematic-token,.schematic-component.is-selected .schematic-token,.schematic-wire:hover .schematic-token,.schematic-wire:focus .schematic-token,.schematic-wire.is-hovered .schematic-token,.schematic-wire.is-active .schematic-token,.schematic-wire.is-selected .schematic-token{stroke-width:var(--schematic-interactive-stroke-width,2.25)}.schematic-port-hotspot{fill:var(--schematic-vector,var(--schematic-vector-fallback,currentColor))!important;fill-opacity:0;transition:fill-opacity .2s ease,opacity .2s ease}.schematic-port-hotspot:hover,.schematic-port-hotspot:focus,.schematic-port-hotspot.is-hovered,.schematic-port-hotspot.is-active,.schematic-port-hotspot.is-selected{fill-opacity:.2}.schematic-port-hotspot.is-degraded{opacity:.45}@media(prefers-reduced-motion:reduce){.schematic-component,.schematic-wire,.schematic-token,.schematic-glow-layer,.schematic-port-hotspot{transition:none}}';
10
10
  function validXmlCodePoint(codePoint) {
@@ -90,7 +90,7 @@ function svgNumber(value) {
90
90
  return String(Number(value.toFixed(3)));
91
91
  }
92
92
  function fittedTextLength(value, maximum, advance) {
93
- return Math.max(1, Math.min(Array.from(value).length * advance, maximum));
93
+ return Math.max(1, Math.min(Math.ceil(mathLabelTextWidth(value, advance)), maximum));
94
94
  }
95
95
  function stableHash(value) {
96
96
  let hash = 2166136261;
@@ -103,6 +103,9 @@ function stableHash(value) {
103
103
  function isClassicalGate(component) {
104
104
  return CLASSICAL_GATE_KINDS.includes(component.kind);
105
105
  }
106
+ function isUmlComponent(component) {
107
+ return UML_COMPONENT_KINDS.includes(component.kind);
108
+ }
106
109
  function colorAttributes(color, extraClass = '') {
107
110
  const suffix = extraClass === '' ? '' : ` ${extraClass}`;
108
111
  const fill = extraClass.split(' ').includes('schematic-node-fill') ? ' fill="currentColor"' : '';
@@ -183,7 +186,7 @@ function iecGate(component, paint) {
183
186
  xor: '=1',
184
187
  not: '1'
185
188
  };
186
- return `<rect ${paint} x="-32" y="${-height / 2}" width="64" height="${height}" rx="2" /><text class="schematic-gate-symbol" fill="currentColor" stroke="none" x="0" y="4">${symbol[component.kind]}</text>${inversionBubbles(component, paint)}${gateStubs(component, paint)}`;
189
+ return `<rect ${paint} x="-32" y="${-height / 2}" width="64" height="${height}" rx="2" /><text class="schematic-gate-symbol" fill="currentColor" stroke="none" x="0" y="4" text-anchor="middle" font-size="14">${symbol[component.kind]}</text>${inversionBubbles(component, paint)}${gateStubs(component, paint)}`;
187
190
  }
188
191
  function ieeeGate(component, paint) {
189
192
  const height = classicalGateHeight(component);
@@ -205,9 +208,9 @@ function ieeeGate(component, paint) {
205
208
  function quantumText(component) {
206
209
  const details = [component.parameter, component.phase, component.matrix].filter((value) => value !== undefined && value !== '');
207
210
  const rows = [component.label, ...details];
208
- const start = -((rows.length - 1) * 10) / 2 + 3;
211
+ const start = -((rows.length - 1) * 12) / 2 + 4;
209
212
  return rows
210
- .map((value, index) => `<text class="${index === 0 ? 'schematic-gate-symbol' : 'schematic-quantum-detail'}" fill="currentColor" stroke="none" x="0" y="${start + index * 10}" textLength="${fittedTextLength(value, 56, index === 0 ? 7 : 5)}" lengthAdjust="spacingAndGlyphs">${renderMathLabelTspans(value)}</text>`)
213
+ .map((value, index) => `<text class="${index === 0 ? 'schematic-gate-symbol' : 'schematic-quantum-detail'}" fill="currentColor" stroke="none" x="0" y="${start + index * 12}" text-anchor="middle" font-size="${index === 0 ? 14 : 10}" textLength="${fittedTextLength(value, 56, index === 0 ? 7 : 5)}" lengthAdjust="spacingAndGlyphs">${renderMathLabelTspans(value)}</text>`)
211
214
  .join('');
212
215
  }
213
216
  function diodeShape(component, paint) {
@@ -261,29 +264,94 @@ function icPinMarkup(component, side, paint) {
261
264
  const horizontalTextLength = fittedTextLength(pin, Math.max(8, component.bodyWidth / 2 - 10), 4);
262
265
  const verticalTextLength = fittedTextLength(pin, Math.max(8, component.bodyHeight / 2 - 10), 4);
263
266
  if (side === 'left') {
264
- return `<path ${paint} d="M ${x} ${y} H ${-component.bodyWidth / 2}" /><text class="schematic-pin-label" fill="currentColor" stroke="none" x="${-component.bodyWidth / 2 + 5}" y="${y + 3}" text-anchor="start" textLength="${horizontalTextLength}" lengthAdjust="spacingAndGlyphs">${escapeXml(pin)}</text>`;
267
+ return `<path ${paint} d="M ${x} ${y} H ${-component.bodyWidth / 2}" /><text class="schematic-pin-label" fill="currentColor" stroke="none" x="${-component.bodyWidth / 2 + 5}" y="${y + 3}" text-anchor="start" font-size="10" textLength="${horizontalTextLength}" lengthAdjust="spacingAndGlyphs">${escapeXml(pin)}</text>`;
265
268
  }
266
269
  if (side === 'right') {
267
- return `<path ${paint} d="M ${component.bodyWidth / 2} ${y} H ${x}" /><text class="schematic-pin-label" fill="currentColor" stroke="none" x="${component.bodyWidth / 2 - 5}" y="${y + 3}" text-anchor="end" textLength="${horizontalTextLength}" lengthAdjust="spacingAndGlyphs">${escapeXml(pin)}</text>`;
270
+ return `<path ${paint} d="M ${component.bodyWidth / 2} ${y} H ${x}" /><text class="schematic-pin-label" fill="currentColor" stroke="none" x="${component.bodyWidth / 2 - 5}" y="${y + 3}" text-anchor="end" font-size="10" textLength="${horizontalTextLength}" lengthAdjust="spacingAndGlyphs">${escapeXml(pin)}</text>`;
268
271
  }
269
272
  if (side === 'top') {
270
273
  const labelY = -component.bodyHeight / 2 + 5;
271
- return `<path ${paint} d="M ${x} ${y} V ${-component.bodyHeight / 2}" /><text class="schematic-pin-label" fill="currentColor" stroke="none" x="${x}" y="${labelY}" text-anchor="start" textLength="${verticalTextLength}" lengthAdjust="spacingAndGlyphs" transform="rotate(90 ${x} ${labelY})">${escapeXml(pin)}</text>`;
274
+ return `<path ${paint} d="M ${x} ${y} V ${-component.bodyHeight / 2}" /><text class="schematic-pin-label" fill="currentColor" stroke="none" x="${x}" y="${labelY}" text-anchor="start" font-size="10" textLength="${verticalTextLength}" lengthAdjust="spacingAndGlyphs" transform="rotate(90 ${x} ${labelY})">${escapeXml(pin)}</text>`;
272
275
  }
273
276
  const labelY = component.bodyHeight / 2 - 5;
274
- return `<path ${paint} d="M ${x} ${component.bodyHeight / 2} V ${y}" /><text class="schematic-pin-label" fill="currentColor" stroke="none" x="${x}" y="${labelY}" text-anchor="start" textLength="${verticalTextLength}" lengthAdjust="spacingAndGlyphs" transform="rotate(-90 ${x} ${labelY})">${escapeXml(pin)}</text>`;
277
+ return `<path ${paint} d="M ${x} ${component.bodyHeight / 2} V ${y}" /><text class="schematic-pin-label" fill="currentColor" stroke="none" x="${x}" y="${labelY}" text-anchor="start" font-size="10" textLength="${verticalTextLength}" lengthAdjust="spacingAndGlyphs" transform="rotate(-90 ${x} ${labelY})">${escapeXml(pin)}</text>`;
275
278
  })
276
279
  .join('');
277
280
  }
278
281
  function integratedCircuitShape(component, paint) {
279
282
  const left = -component.bodyWidth / 2;
280
283
  const top = -component.bodyHeight / 2;
281
- return `<rect ${paint} x="${left}" y="${top}" width="${component.bodyWidth}" height="${component.bodyHeight}" rx="3" />${icPinMarkup(component, 'left', paint)}${icPinMarkup(component, 'right', paint)}${icPinMarkup(component, 'top', paint)}${icPinMarkup(component, 'bottom', paint)}<text class="schematic-gate-symbol" fill="currentColor" stroke="none" x="0" y="4" textLength="${fittedTextLength(component.label, component.bodyWidth - 12, 7)}" lengthAdjust="spacingAndGlyphs">${renderMathLabelTspans(component.label)}</text>`;
284
+ return `<rect ${paint} x="${left}" y="${top}" width="${component.bodyWidth}" height="${component.bodyHeight}" rx="3" />${icPinMarkup(component, 'left', paint)}${icPinMarkup(component, 'right', paint)}${icPinMarkup(component, 'top', paint)}${icPinMarkup(component, 'bottom', paint)}<text class="schematic-gate-symbol" fill="currentColor" stroke="none" x="0" y="4" text-anchor="middle" font-size="12" textLength="${fittedTextLength(component.label, component.bodyWidth - 12, 7)}" lengthAdjust="spacingAndGlyphs">${renderMathLabelTspans(component.label)}</text>`;
285
+ }
286
+ function umlRow(value, x, y) {
287
+ return `<text class="schematic-uml-text schematic-uml-row" fill="currentColor" stroke="none" x="${svgNumber(x)}" y="${svgNumber(y)}" font-size="12">${escapeXml(value)}</text>`;
288
+ }
289
+ function umlClassShape(component, paint) {
290
+ const left = -component.bodyWidth / 2;
291
+ const top = -component.bodyHeight / 2;
292
+ const headerHeight = 36 + (component.stereotype === undefined ? 0 : 14);
293
+ const attributeHeight = Math.max(24, component.attributes.length * 16 + 8);
294
+ const attributeSeparator = top + headerHeight;
295
+ const operationSeparator = attributeSeparator + attributeHeight;
296
+ const stereotype = component.stereotype === undefined
297
+ ? ''
298
+ : `<text class="schematic-uml-text schematic-uml-stereotype" fill="currentColor" stroke="none" x="0" y="${svgNumber(top + 13)}" text-anchor="middle" font-size="11">«${escapeXml(component.stereotype)}»</text>`;
299
+ const nameY = top + (component.stereotype === undefined ? 23 : 32);
300
+ const attributes = component.attributes
301
+ .map((row, index) => umlRow(row, left + 8, attributeSeparator + 17 + index * 16))
302
+ .join('');
303
+ const operations = component.operations
304
+ .map((row, index) => umlRow(row, left + 8, operationSeparator + 17 + index * 16))
305
+ .join('');
306
+ return `<rect ${paint} x="${svgNumber(left)}" y="${svgNumber(top)}" width="${svgNumber(component.bodyWidth)}" height="${svgNumber(component.bodyHeight)}" rx="2" /><path ${paint} d="M ${svgNumber(left)} ${svgNumber(attributeSeparator)} H ${svgNumber(-left)} M ${svgNumber(left)} ${svgNumber(operationSeparator)} H ${svgNumber(-left)}" />${stereotype}<text class="schematic-uml-text" fill="currentColor" stroke="none" x="0" y="${svgNumber(nameY)}" text-anchor="middle" font-size="13" font-weight="600">${renderMathLabelTspans(component.label)}</text>${attributes}${operations}`;
307
+ }
308
+ function umlStateShape(component, paint) {
309
+ const left = -component.bodyWidth / 2;
310
+ const top = -component.bodyHeight / 2;
311
+ const separator = top + 32;
312
+ const rows = component.details
313
+ .map((row, index) => umlRow(row, left + 8, separator + 17 + index * 16))
314
+ .join('');
315
+ return `<rect ${paint} x="${svgNumber(left)}" y="${svgNumber(top)}" width="${svgNumber(component.bodyWidth)}" height="${svgNumber(component.bodyHeight)}" rx="10" />${component.details.length === 0 ? '' : `<path ${paint} d="M ${svgNumber(left)} ${svgNumber(separator)} H ${svgNumber(-left)}" />`}<text class="schematic-uml-text" fill="currentColor" stroke="none" x="0" y="${svgNumber(top + 21)}" text-anchor="middle" font-size="13" font-weight="600">${renderMathLabelTspans(component.label)}</text>${rows}`;
316
+ }
317
+ function umlComponentShape(component, paint, nodePaint) {
318
+ if (component.kind === 'class')
319
+ return umlClassShape(component, paint);
320
+ if (component.kind === 'state')
321
+ return umlStateShape(component, paint);
322
+ switch (component.kind) {
323
+ case 'actor':
324
+ return `<circle ${paint} cx="0" cy="-28" r="9" /><path ${paint} d="M 0 -19 V 12 M -20 -5 H 20 M 0 12 L -17 37 M 0 12 L 17 37" /><text class="schematic-uml-text" fill="currentColor" stroke="none" x="0" y="49" text-anchor="middle" font-size="12">${renderMathLabelTspans(component.label)}</text>`;
325
+ case 'usecase':
326
+ return `<ellipse ${paint} cx="0" cy="0" rx="${svgNumber(component.bodyWidth / 2)}" ry="${svgNumber(component.bodyHeight / 2)}" /><text class="schematic-uml-text" fill="currentColor" stroke="none" x="0" y="4" text-anchor="middle" font-size="13">${renderMathLabelTspans(component.label)}</text>`;
327
+ case 'lifeline': {
328
+ const top = -component.bodyHeight / 2;
329
+ const left = -component.bodyWidth / 2;
330
+ return `<rect ${paint} x="${svgNumber(left)}" y="${svgNumber(top)}" width="${svgNumber(component.bodyWidth)}" height="32" rx="2" /><text class="schematic-uml-text" fill="currentColor" stroke="none" x="0" y="${svgNumber(top + 21)}" text-anchor="middle" font-size="12">${renderMathLabelTspans(component.label)}</text><path ${paint} stroke-dasharray="6 5" d="M 0 ${svgNumber(top + 32)} V ${svgNumber(component.bodyHeight / 2)}" />`;
331
+ }
332
+ case 'note': {
333
+ const left = -component.bodyWidth / 2;
334
+ const top = -component.bodyHeight / 2;
335
+ const right = component.bodyWidth / 2;
336
+ return `<path ${paint} d="M ${svgNumber(left)} ${svgNumber(top)} H ${svgNumber(right - 14)} L ${svgNumber(right)} ${svgNumber(top + 14)} V ${svgNumber(-top)} H ${svgNumber(left)} Z M ${svgNumber(right - 14)} ${svgNumber(top)} V ${svgNumber(top + 14)} H ${svgNumber(right)}" /><text class="schematic-uml-text" fill="currentColor" stroke="none" x="0" y="4" text-anchor="middle" font-size="12">${renderMathLabelTspans(component.label)}</text>`;
337
+ }
338
+ case 'package': {
339
+ const left = -component.bodyWidth / 2;
340
+ const top = -component.bodyHeight / 2;
341
+ return `<path ${paint} d="M ${svgNumber(left)} ${svgNumber(top + 12)} V ${svgNumber(-top)} H ${svgNumber(-left)} V ${svgNumber(top)} H ${svgNumber(left + Math.min(54, component.bodyWidth / 2))} L ${svgNumber(left + Math.min(64, component.bodyWidth / 2 + 10))} ${svgNumber(top + 12)} Z" /><text class="schematic-uml-text" fill="currentColor" stroke="none" x="0" y="4" text-anchor="middle" font-size="13">${renderMathLabelTspans(component.label)}</text>`;
342
+ }
343
+ case 'initial':
344
+ return `<circle ${nodePaint} cx="0" cy="0" r="10" />`;
345
+ case 'final':
346
+ return `<circle ${paint} cx="0" cy="0" r="11" /><circle ${nodePaint} cx="0" cy="0" r="6" />`;
347
+ }
282
348
  }
283
349
  function componentShape(component, paint = colorAttributes(component.color), nodePaint = colorAttributes(component.color, 'schematic-node-fill')) {
284
350
  if (isClassicalGate(component)) {
285
351
  return component.standard === 'iec' ? iecGate(component, paint) : ieeeGate(component, paint);
286
352
  }
353
+ if (isUmlComponent(component))
354
+ return umlComponentShape(component, paint, nodePaint);
287
355
  switch (component.kind) {
288
356
  case 'resistor':
289
357
  return `<path ${paint} d="M -42 0 H -26 L -20 -12 L -10 12 L 0 -12 L 10 12 L 20 -12 L 26 0 H 42" />`;
@@ -310,7 +378,10 @@ function componentShape(component, paint = colorAttributes(component.color), nod
310
378
  }
311
379
  }
312
380
  function reusableSymbolKey(component) {
313
- if (isClassicalGate(component) || component.kind === 'qgate' || component.kind === 'ic') {
381
+ if (isClassicalGate(component) ||
382
+ isUmlComponent(component) ||
383
+ component.kind === 'qgate' ||
384
+ component.kind === 'ic') {
314
385
  return undefined;
315
386
  }
316
387
  if (component.kind === 'diode')
@@ -332,16 +403,56 @@ function connectionMarkup(connection, routed, index, idPrefix, glowId) {
332
403
  const target = escapeXml(`${connection.to.componentId}.${connection.to.port}`);
333
404
  const traceId = `${idPrefix}-wire-${index}-vector`;
334
405
  const dataAttributes = ` data-wire-source="${source}" data-wire-target="${target}"`;
335
- const accessibility = ` tabindex="0" role="group" aria-label="Signal from ${source} to ${target}"`;
406
+ const relation = connection.relation ?? 'signal';
407
+ const accessibility = ` tabindex="0" role="group" aria-label="${escapeXml(relation)} from ${source} to ${target}"`;
336
408
  const vectorId = ` id="${traceId}"`;
337
409
  const markerAttributes = connectionMarkerAttributes(connection, idPrefix);
338
410
  const glow = `<use class="schematic-glow-layer" href="#${traceId}" filter="url(#${glowId})" aria-hidden="true" pointer-events="none" />`;
339
- return `<g class="schematic-wire"${dataAttributes}${accessibility}><path${vectorId} ${colorAttributes(connection.color, 'schematic-trace')} d="${routed.d}"${markerAttributes} />${glow}<circle ${colorAttributes(connection.color, 'schematic-node-fill')} cx="${svgNumber(end.x)}" cy="${svgNumber(end.y)}" r="3" /></g>`;
411
+ const endpoint = relation === 'signal' && connection.markerEnd === 'none'
412
+ ? `<circle ${colorAttributes(connection.color, 'schematic-node-fill')} cx="${svgNumber(end.x)}" cy="${svgNumber(end.y)}" r="3" />`
413
+ : '';
414
+ return `<g class="schematic-wire"${dataAttributes}${accessibility}><path${vectorId} ${colorAttributes(connection.color, 'schematic-trace')} d="${routed.d}"${markerAttributes} />${glow}${endpoint}${connectionLabelMarkup(connection, routed)}</g>`;
340
415
  }
341
416
  function connectionMarkerAttributes(connection, idPrefix) {
342
417
  const start = connection.markerStart;
343
418
  const end = connection.markerEnd;
344
- return `${start === 'none' ? '' : ` marker-start="url(#${idPrefix}-marker-${start})"`}${end === 'none' ? '' : ` marker-end="url(#${idPrefix}-marker-${end})"`}`;
419
+ return `${start === 'none' ? '' : ` marker-start="url(#${idPrefix}-marker-${start})"`}${end === 'none' ? '' : ` marker-end="url(#${idPrefix}-marker-${end})"`}${connection.dashed === true ? ' stroke-dasharray="7 5"' : ''}`;
420
+ }
421
+ function connectionLabelPoint(route) {
422
+ if (route.curve === 'bezier' && route.points.length >= 4) {
423
+ const a = route.points[0];
424
+ const b = route.points[1];
425
+ const c = route.points[2];
426
+ const d = route.points[3];
427
+ return {
428
+ x: (a.x + 3 * b.x + 3 * c.x + d.x) / 8,
429
+ y: (a.y + 3 * b.y + 3 * c.y + d.y) / 8
430
+ };
431
+ }
432
+ let total = 0;
433
+ for (let index = 1; index < route.points.length; index += 1) {
434
+ total +=
435
+ Math.abs(route.points[index].x - route.points[index - 1].x) +
436
+ Math.abs(route.points[index].y - route.points[index - 1].y);
437
+ }
438
+ let remaining = total / 2;
439
+ for (let index = 1; index < route.points.length; index += 1) {
440
+ const start = route.points[index - 1];
441
+ const end = route.points[index];
442
+ const length = Math.abs(end.x - start.x) + Math.abs(end.y - start.y);
443
+ if (remaining <= length) {
444
+ const ratio = length === 0 ? 0 : remaining / length;
445
+ return { x: start.x + (end.x - start.x) * ratio, y: start.y + (end.y - start.y) * ratio };
446
+ }
447
+ remaining -= length;
448
+ }
449
+ return route.points.at(-1);
450
+ }
451
+ function connectionLabelMarkup(connection, route) {
452
+ if (connection.label === undefined)
453
+ return '';
454
+ const point = connectionLabelPoint(route);
455
+ return `<text class="schematic-connection-label" fill="currentColor" stroke="none" x="${svgNumber(point.x)}" y="${svgNumber(point.y - 7)}" text-anchor="middle" font-size="11">${renderMathLabelTspans(connection.label)}</text>`;
345
456
  }
346
457
  function colorKey(color) {
347
458
  return `${color.kind}:${color.value}`;
@@ -353,23 +464,29 @@ function compactConnectionMarkup(connections, routes, idPrefix, embedVisuals, gl
353
464
  const key = `${colorKey(connection.color)}|${markerAttributes}`;
354
465
  let batch = batches.get(key);
355
466
  if (batch === undefined) {
356
- batch = { color: connection.color, markerAttributes, traces: [], endpoints: [] };
467
+ batch = { color: connection.color, markerAttributes, traces: [], endpoints: [], labels: [] };
357
468
  batches.set(key, batch);
358
469
  }
359
470
  const routed = routes[index];
360
471
  const end = routed.points.at(-1);
361
472
  batch.traces.push(routed.d);
362
- batch.endpoints.push(`M ${svgNumber(end.x - 3)} ${svgNumber(end.y)} a 3 3 0 1 0 6 0 a 3 3 0 1 0 -6 0`);
473
+ if ((connection.relation ?? 'signal') === 'signal' && connection.markerEnd === 'none') {
474
+ batch.endpoints.push(`M ${svgNumber(end.x - 3)} ${svgNumber(end.y)} a 3 3 0 1 0 6 0 a 3 3 0 1 0 -6 0`);
475
+ }
476
+ const label = connectionLabelMarkup(connection, routed);
477
+ if (label !== '')
478
+ batch.labels.push(label);
363
479
  }
364
480
  return Array.from(batches.values(), (batch, index) => {
365
481
  const tracePaint = colorAttributes(batch.color, 'schematic-trace');
366
482
  const nodePaint = colorAttributes(batch.color, 'schematic-node-fill');
367
483
  const traceId = `${idPrefix}-wire-batch-${index}-vector`;
368
484
  const trace = `<path${embedVisuals ? ` id="${traceId}"` : ''} ${tracePaint} d="${batch.traces.join(' ')}"${batch.markerAttributes} />`;
369
- const endpoints = `<path ${nodePaint} d="${batch.endpoints.join(' ')}" />`;
485
+ const endpoints = batch.endpoints.length === 0 ? '' : `<path ${nodePaint} d="${batch.endpoints.join(' ')}" />`;
486
+ const labels = batch.labels.join('');
370
487
  if (!embedVisuals)
371
- return trace + endpoints;
372
- return `<g class="schematic-wire" tabindex="0" role="group" aria-label="${batch.traces.length} grouped signal trace${batch.traces.length === 1 ? '' : 's'}">${trace}<use class="schematic-glow-layer" href="#${traceId}" filter="url(#${glowId})" aria-hidden="true" pointer-events="none" />${endpoints}</g>`;
488
+ return trace + endpoints + labels;
489
+ return `<g class="schematic-wire" tabindex="0" role="group" aria-label="${batch.traces.length} grouped connection${batch.traces.length === 1 ? '' : 's'}">${trace}<use class="schematic-glow-layer" href="#${traceId}" filter="url(#${glowId})" aria-hidden="true" pointer-events="none" />${endpoints}${labels}</g>`;
373
490
  }).join('');
374
491
  }
375
492
  function componentMetadata(component) {
@@ -424,12 +541,17 @@ function componentMarkup(component, index, idPrefix, glowId, mode, symbolId) {
424
541
  const vector = symbolId === undefined
425
542
  ? `<g${vectorIdAttribute} class="schematic-component-vector">${componentShape(component)}</g>`
426
543
  : `<use${vectorIdAttribute} ${colorAttributes(component.color, 'schematic-component-vector')} href="#${symbolId}" />`;
427
- return `<g class="schematic-component"${dataAttributes} transform="translate(${svgNumber(component.x)} ${svgNumber(component.y)})"${accessibility}>${vector}${glow}<text class="schematic-designator" fill="currentColor" stroke="none" x="0" y="${anchors.designatorY}" text-anchor="middle" textLength="${anchors.designatorWidth}" lengthAdjust="spacingAndGlyphs">${id}</text><text class="schematic-label" fill="currentColor" stroke="none" x="0" y="${anchors.labelY}" text-anchor="middle" textLength="${anchors.labelWidth}" lengthAdjust="spacingAndGlyphs">${renderedLabel}</text>${hotspots}</g>`;
544
+ const externalLabels = isUmlComponent(component)
545
+ ? ''
546
+ : `<text class="schematic-designator" fill="currentColor" stroke="none" x="0" y="${anchors.designatorY}" text-anchor="middle" font-size="12" textLength="${anchors.designatorWidth}" lengthAdjust="spacingAndGlyphs">${id}</text><text class="schematic-label" fill="currentColor" stroke="none" x="0" y="${anchors.labelY}" text-anchor="middle" font-size="12" textLength="${anchors.labelWidth}" lengthAdjust="spacingAndGlyphs">${renderedLabel}</text>`;
547
+ return `<g class="schematic-component"${dataAttributes} transform="translate(${svgNumber(component.x)} ${svgNumber(component.y)})"${accessibility}>${vector}${glow}${externalLabels}${hotspots}</g>`;
428
548
  }
429
549
  export function renderSchematic(document, options) {
430
550
  assertParsedSchematicDocument(document);
431
551
  const normalized = normalizeCompileOptions(options);
432
- validateDocumentGeometry(document, normalized);
552
+ const components = new Map(document.components.map((component) => [component.id, component]));
553
+ const routedConnections = routeConnections(document.connections, components, normalized.bounds);
554
+ validateDocumentGeometry(document, normalized, routedConnections);
433
555
  const signature = `${normalized.bounds.width}x${normalized.bounds.height}:${normalized.title}:${JSON.stringify(document)}`;
434
556
  const candidatePrefix = (normalized.idPrefix ?? `schematic-${stableHash(signature)}`).replace(/[^A-Za-z0-9_-]/g, '-');
435
557
  const safePrefix = /[A-Za-z0-9]/.test(candidatePrefix)
@@ -441,8 +563,6 @@ export function renderSchematic(document, options) {
441
563
  const glowId = `${safePrefix}-schematic-glow-filter`;
442
564
  const styles = normalized.mode === 'embedded-css' || normalized.mode === 'full';
443
565
  const hooks = normalized.mode === 'full';
444
- const components = new Map(document.components.map((component) => [component.id, component]));
445
- const routedConnections = routeConnections(document.connections, components);
446
566
  const reusableSymbols = new Map();
447
567
  for (const component of document.components) {
448
568
  const key = reusableSymbolKey(component);
@@ -452,7 +572,7 @@ export function renderSchematic(document, options) {
452
572
  }
453
573
  const title = escapeXml(normalized.title);
454
574
  const componentCount = document.components.length;
455
- const description = `${componentCount} component${componentCount === 1 ? '' : 's'} and ${document.connections.length} signal connection${document.connections.length === 1 ? '' : 's'}.`;
575
+ const description = `${componentCount} component${componentCount === 1 ? '' : 's'} and ${document.connections.length} connection${document.connections.length === 1 ? '' : 's'}.`;
456
576
  const writer = new BoundedSvgWriter();
457
577
  const hookStyles = hooks ? HOOK_SVG_STYLES : '';
458
578
  const interactionStyles = styles ? INTERACTIVE_SVG_STYLES : '';
@@ -461,7 +581,7 @@ export function renderSchematic(document, options) {
461
581
  : '';
462
582
  const symbolDefinitions = Array.from(reusableSymbols.values(), ({ id, component }) => reusableSymbolDefinition(component, id)).join('');
463
583
  const usedMarkers = new Set(document.connections.flatMap((connection) => [connection.markerStart, connection.markerEnd]));
464
- const markerDefinitions = `${usedMarkers.has('arrow') ? `<marker id="${safePrefix}-marker-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0 8 4 0 8Z" fill="context-stroke" /></marker>` : ''}${usedMarkers.has('dot') ? `<marker id="${safePrefix}-marker-dot" viewBox="0 0 8 8" refX="4" refY="4" markerWidth="5" markerHeight="5"><circle cx="4" cy="4" r="3" fill="context-stroke" /></marker>` : ''}`;
584
+ const markerDefinitions = `${usedMarkers.has('arrow') ? `<marker id="${safePrefix}-marker-arrow" viewBox="0 0 8 8" refX="8" refY="4" markerWidth="8" markerHeight="8" markerUnits="userSpaceOnUse" orient="auto-start-reverse" overflow="visible"><path d="M0 0 8 4 0 8Z" fill="context-stroke" /></marker>` : ''}${usedMarkers.has('open-arrow') ? `<marker id="${safePrefix}-marker-open-arrow" viewBox="0 0 9 10" refX="9" refY="5" markerWidth="9" markerHeight="10" markerUnits="userSpaceOnUse" orient="auto-start-reverse" overflow="visible"><path d="M0 0 9 5 0 10" fill="none" stroke="context-stroke" stroke-width="1.5" /></marker>` : ''}${usedMarkers.has('dot') ? `<marker id="${safePrefix}-marker-dot" viewBox="0 0 8 8" refX="4" refY="4" markerWidth="8" markerHeight="8" markerUnits="userSpaceOnUse"><circle cx="4" cy="4" r="3" fill="context-stroke" /></marker>` : ''}${usedMarkers.has('triangle') ? `<marker id="${safePrefix}-marker-triangle" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="12" markerHeight="12" markerUnits="userSpaceOnUse" orient="auto-start-reverse" overflow="visible"><path d="M0 1 11 6 0 11Z" fill="var(--schematic-surface,#fff)" stroke="context-stroke" stroke-width="1.5" /></marker>` : ''}${usedMarkers.has('diamond') ? `<marker id="${safePrefix}-marker-diamond" viewBox="0 0 13 12" refX="12" refY="6" markerWidth="13" markerHeight="12" markerUnits="userSpaceOnUse" orient="auto-start-reverse" overflow="visible"><path d="M0 6 6 1 12 6 6 11Z" fill="var(--schematic-surface,#fff)" stroke="context-stroke" stroke-width="1.5" /></marker>` : ''}${usedMarkers.has('diamond-filled') ? `<marker id="${safePrefix}-marker-diamond-filled" viewBox="0 0 13 12" refX="12" refY="6" markerWidth="13" markerHeight="12" markerUnits="userSpaceOnUse" orient="auto-start-reverse" overflow="visible"><path d="M0 6 6 1 12 6 6 11Z" fill="context-stroke" stroke="context-stroke" /></marker>` : ''}`;
465
585
  const embeddedDefinitions = styles
466
586
  ? `<style>${STATIC_SVG_STYLES}${hookStyles}${interactionStyles}</style><pattern id="${gridId}" width="20" height="20" patternUnits="userSpaceOnUse"><path class="schematic-grid-line" d="M20 0H0V20" /></pattern>${glowFilter}`
467
587
  : '';
package/dist/types.d.ts CHANGED
@@ -35,8 +35,12 @@ export type ClassicalGateKind = (typeof CLASSICAL_GATE_KINDS)[number];
35
35
  export declare const QUANTUM_GATE_KINDS: readonly ["hadamard", "cnot", "qgate"];
36
36
  /** A supported native or polymorphic quantum gate keyword. */
37
37
  export type QuantumGateKind = (typeof QUANTUM_GATE_KINDS)[number];
38
+ /** First-class UML node keywords spanning structural and behavioral diagrams. */
39
+ export declare const UML_COMPONENT_KINDS: readonly ["class", "actor", "usecase", "state", "lifeline", "note", "package", "initial", "final"];
40
+ /** A supported UML node keyword. */
41
+ export type UmlComponentKind = (typeof UML_COMPONENT_KINDS)[number];
38
42
  /** Complete, collision-free component keyword registry. */
39
- export declare const COMPONENT_KINDS: readonly ["resistor", "capacitor", "inductor", "diode", "transistor", "port", "ground", "nand", "nor", "xor", "and", "or", "not", "hadamard", "cnot", "qgate", "ic"];
43
+ export declare const COMPONENT_KINDS: readonly ["resistor", "capacitor", "inductor", "diode", "transistor", "port", "ground", "nand", "nor", "xor", "and", "or", "not", "hadamard", "cnot", "qgate", "class", "actor", "usecase", "state", "lifeline", "note", "package", "initial", "final", "ic"];
40
44
  /** Any component keyword that can begin a declaration. */
41
45
  export type ComponentKind = (typeof COMPONENT_KINDS)[number];
42
46
  /** Built-in semantic color tokens that hosts can theme through CSS. */
@@ -169,8 +173,53 @@ export interface IcComponent extends ComponentBase {
169
173
  }
170
174
  /** Descriptive alias for a parsed integrated-circuit block. */
171
175
  export type IntegratedCircuitComponent = IcComponent;
176
+ /** Parsed UML class with independently sized attribute and operation compartments. */
177
+ export interface UmlClassComponent extends ComponentBase {
178
+ /** UML class node discriminant. */
179
+ kind: 'class';
180
+ /** Optional stereotype displayed above the class name. */
181
+ stereotype?: string;
182
+ /** Attribute declarations in source order. */
183
+ attributes: readonly string[];
184
+ /** Operation declarations in source order. */
185
+ operations: readonly string[];
186
+ /** Deterministic box width derived from all visible rows. */
187
+ bodyWidth: number;
188
+ /** Deterministic three-compartment box height. */
189
+ bodyHeight: number;
190
+ }
191
+ /** Parsed UML state with optional behavior rows. */
192
+ export interface UmlStateComponent extends ComponentBase {
193
+ kind: 'state';
194
+ /** Entry, do, exit, or author-defined behavior rows. */
195
+ details: readonly string[];
196
+ bodyWidth: number;
197
+ bodyHeight: number;
198
+ }
199
+ /** Parsed UML ellipse, note, package, or sequence lifeline. */
200
+ export interface UmlSizedComponent extends ComponentBase {
201
+ kind: 'usecase' | 'lifeline' | 'note' | 'package';
202
+ bodyWidth: number;
203
+ bodyHeight: number;
204
+ }
205
+ /** Parsed UML actor figure. */
206
+ export interface UmlActorComponent extends ComponentBase {
207
+ kind: 'actor';
208
+ }
209
+ /** Parsed UML initial or final pseudostate. */
210
+ export interface UmlPseudostateComponent extends ComponentBase {
211
+ kind: 'initial' | 'final';
212
+ }
213
+ /** Every UML component accepted by the compiler. */
214
+ export type UmlComponent = UmlClassComponent | UmlStateComponent | UmlSizedComponent | UmlActorComponent | UmlPseudostateComponent;
172
215
  /** Discriminated union of every component node accepted by the renderer. */
173
- export type SchematicComponent = PassiveComponent | DiodeComponent | TransistorComponent | PortComponent | GroundComponent | ClassicalGateComponent | QuantumGateComponent | IcComponent;
216
+ export type SchematicComponent = PassiveComponent | DiodeComponent | TransistorComponent | PortComponent | GroundComponent | ClassicalGateComponent | QuantumGateComponent | IcComponent | UmlComponent;
217
+ /** UML relationship semantics used to derive line style and endpoint markers. */
218
+ export declare const UML_RELATION_KINDS: readonly ["association", "dependency", "generalization", "realization", "aggregation", "composition", "message", "transition", "include", "extend"];
219
+ /** A supported UML relationship. */
220
+ export type UmlRelationKind = (typeof UML_RELATION_KINDS)[number];
221
+ /** Electrical signal or a first-class UML relationship. */
222
+ export type SchematicRelationKind = 'signal' | UmlRelationKind;
174
223
  /** Address of one component terminal in a connection declaration. */
175
224
  export interface SchematicEndpoint {
176
225
  /** Document-local component identifier. */
@@ -192,6 +241,12 @@ export interface SchematicConnection {
192
241
  markerStart: SchematicSignalMarker;
193
242
  /** Optional marker drawn at the destination terminal. */
194
243
  markerEnd: SchematicSignalMarker;
244
+ /** Electrical or UML relationship semantics. */
245
+ relation?: SchematicRelationKind;
246
+ /** Optional text centered beside the routed connector. */
247
+ label?: string;
248
+ /** Whether the trace uses the UML dependency dash pattern. */
249
+ dashed?: boolean;
195
250
  /** One-based source line used for routing diagnostics. */
196
251
  line: number;
197
252
  }
@@ -207,7 +262,7 @@ export declare const SCHEMD_OUTPUT_MODES: readonly ["default", "embedded-css", "
207
262
  /** Static, CSS-enhanced, or fully attributed SVG output mode. */
208
263
  export type SchemdOutputMode = (typeof SCHEMD_OUTPUT_MODES)[number];
209
264
  /** Marker primitives that can terminate or originate a signal trace. */
210
- export declare const SCHEMATIC_SIGNAL_MARKERS: readonly ["none", "arrow", "dot"];
265
+ export declare const SCHEMATIC_SIGNAL_MARKERS: readonly ["none", "arrow", "open-arrow", "dot", "triangle", "diamond", "diamond-filled"];
211
266
  /** A validated connection marker selection. */
212
267
  export type SchematicSignalMarker = (typeof SCHEMATIC_SIGNAL_MARKERS)[number];
213
268
  /** Per-render options extending the fence's intrinsic layout contract. */
package/dist/types.js CHANGED
@@ -5,16 +5,48 @@ export const TRANSISTOR_TYPES = ['npn', 'pnp', 'nmos', 'pmos'];
5
5
  export const GROUND_STYLES = ['chassis', 'earth', 'signal'];
6
6
  export const CLASSICAL_GATE_KINDS = ['nand', 'nor', 'xor', 'and', 'or', 'not'];
7
7
  export const QUANTUM_GATE_KINDS = ['hadamard', 'cnot', 'qgate'];
8
+ export const UML_COMPONENT_KINDS = [
9
+ 'class',
10
+ 'actor',
11
+ 'usecase',
12
+ 'state',
13
+ 'lifeline',
14
+ 'note',
15
+ 'package',
16
+ 'initial',
17
+ 'final'
18
+ ];
8
19
  export const COMPONENT_KINDS = [
9
20
  ...PASSIVE_KINDS,
10
21
  ...ANALOG_KINDS,
11
22
  ...CLASSICAL_GATE_KINDS,
12
23
  ...QUANTUM_GATE_KINDS,
24
+ ...UML_COMPONENT_KINDS,
13
25
  'ic'
14
26
  ];
15
27
  export const SEMANTIC_COLORS = ['amber', 'blue', 'cyan', 'purple', 'slate', 'emerald'];
28
+ export const UML_RELATION_KINDS = [
29
+ 'association',
30
+ 'dependency',
31
+ 'generalization',
32
+ 'realization',
33
+ 'aggregation',
34
+ 'composition',
35
+ 'message',
36
+ 'transition',
37
+ 'include',
38
+ 'extend'
39
+ ];
16
40
  export const SCHEMD_OUTPUT_MODES = ['default', 'embedded-css', 'full'];
17
- export const SCHEMATIC_SIGNAL_MARKERS = ['none', 'arrow', 'dot'];
41
+ export const SCHEMATIC_SIGNAL_MARKERS = [
42
+ 'none',
43
+ 'arrow',
44
+ 'open-arrow',
45
+ 'dot',
46
+ 'triangle',
47
+ 'diamond',
48
+ 'diamond-filled'
49
+ ];
18
50
  export class SchematicSyntaxError extends Error {
19
51
  line;
20
52
  constructor(message, line) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schemd/core",
3
- "version": "0.1.1",
4
- "description": "Strict, bounded, server-side engineering schematic DSL compiler for Marked.",
3
+ "version": "0.1.2",
4
+ "description": "Zero-dependency text-to-SVG compiler for schematics and UML.",
5
5
  "homepage": "https://johnowolabiidogun.dev/tools/schemd/docs/overview",
6
6
  "bugs": {
7
7
  "url": "https://github.com/Sirneij/schemd/issues"
@@ -23,6 +23,30 @@
23
23
  ".": {
24
24
  "types": "./dist/index.d.ts",
25
25
  "import": "./dist/index.js"
26
+ },
27
+ "./parser": {
28
+ "types": "./dist/parser.d.ts",
29
+ "import": "./dist/parser.js"
30
+ },
31
+ "./renderer": {
32
+ "types": "./dist/renderer.d.ts",
33
+ "import": "./dist/renderer.js"
34
+ },
35
+ "./layout": {
36
+ "types": "./dist/layout.d.ts",
37
+ "import": "./dist/layout.js"
38
+ },
39
+ "./math-label": {
40
+ "types": "./dist/math-label.d.ts",
41
+ "import": "./dist/math-label.js"
42
+ },
43
+ "./marked": {
44
+ "types": "./dist/marked-extension.d.ts",
45
+ "import": "./dist/marked-extension.js"
46
+ },
47
+ "./types": {
48
+ "types": "./dist/types.d.ts",
49
+ "import": "./dist/types.js"
26
50
  }
27
51
  },
28
52
  "scripts": {
@@ -35,6 +59,11 @@
35
59
  "peerDependencies": {
36
60
  "marked": ">=17 <19"
37
61
  },
62
+ "peerDependenciesMeta": {
63
+ "marked": {
64
+ "optional": true
65
+ }
66
+ },
38
67
  "devDependencies": {
39
68
  "@vitest/coverage-v8": "^4.1.10",
40
69
  "marked": "^18.0.6",
@@ -50,7 +79,8 @@
50
79
  "schematic",
51
80
  "svg",
52
81
  "compiler",
53
- "engineering"
82
+ "engineering",
83
+ "uml"
54
84
  ],
55
85
  "license": "MIT"
56
86
  }