agentic-mermaid 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/CHANGELOG.md +144 -4
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +25 -32
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/agent-core.d.ts +463 -0
- package/dist/agent-core.js +397 -0
- package/dist/agent.d.ts +73 -1868
- package/dist/agent.js +336 -37325
- package/dist/agentic-mermaid-mcp.js +7 -38399
- package/dist/am.js +33632 -40445
- package/dist/chunk-4QECACIA.js +4702 -0
- package/dist/chunk-CDPKJ2BQ.js +37 -0
- package/dist/chunk-QKDLMOJJ.js +3487 -0
- package/dist/chunk-WI35L7IR.js +615 -0
- package/dist/chunk-WN3YDOBG.js +149 -0
- package/dist/chunk-ZYMMKQ3I.js +62637 -0
- package/dist/index-D54dqSdC.d.ts +6777 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +310 -33014
- package/dist/mcp-cli-SBQSTJFC.js +17 -0
- package/docs/README.md +14 -6
- package/docs/schemas/style-spec.schema.json +1221 -30
- package/examples/styles/catalog.json +54 -0
- package/examples/styles/cloudflare-workers-inspired-prototype.mmd +9 -0
- package/examples/styles/cloudflare-workers-inspired-prototype.style.json +110 -0
- package/examples/styles/cupertino-prototype.mmd +10 -0
- package/examples/styles/cupertino-prototype.style.json +63 -0
- package/examples/styles/mid-century-report.style.json +2 -2
- package/examples/styles/star-chart-atlas.style.json +2 -1
- package/examples/styles/transit-route-map.style.json +2 -2
- package/examples/styles/vercel-inspired-prototype.mmd +6 -0
- package/examples/styles/vercel-inspired-prototype.style.json +87 -0
- package/package.json +68 -43
- package/server.json +2 -2
- package/skills/agentic-mermaid-diagram-workflow/SKILL.md +17 -36
- package/skills/agentic-mermaid-diagram-workflow/references/cli.md +4 -5
- package/skills/agentic-mermaid-diagram-workflow/references/code-mode.md +15 -31
- package/skills/agentic-mermaid-diagram-workflow/references/sequence.md +10 -14
- package/skills/agentic-mermaid-live-editor/SKILL.md +100 -206
- package/AGENT_NATIVE.md +0 -607
- package/Instructions_for_agents.md +0 -89
- package/TODO.md +0 -684
- package/assets/hero.png +0 -0
- package/dist/index-wjRjbsP-.d.ts +0 -1102
- package/docs/agent-api-cookbook.md +0 -308
- package/docs/agent-mutation-policy.md +0 -104
- package/docs/agent-workflow-examples.md +0 -46
- package/docs/api.md +0 -211
- package/docs/ascii.md +0 -103
- package/docs/assets/improvements/box-start-connector.png +0 -0
- package/docs/assets/improvements/comment-dropped-lint.png +0 -0
- package/docs/assets/improvements/er-content-loss.png +0 -0
- package/docs/assets/improvements/fan-in-trunks.png +0 -0
- package/docs/assets/improvements/gantt-family.png +0 -0
- package/docs/assets/improvements/gantt-milestone-clamp.png +0 -0
- package/docs/assets/improvements/gantt-verify-seam.png +0 -0
- package/docs/assets/improvements/subgraph-container-edges.png +0 -0
- package/docs/assets/improvements/wrapper-frontmatter.png +0 -0
- package/docs/assets/improvements/wrapper-init-directive.png +0 -0
- package/docs/assets/improvements/wrapper-leading-comment.png +0 -0
- package/docs/assets/style-cookbook/mid-century-report.png +0 -0
- package/docs/assets/style-cookbook/star-chart-atlas.png +0 -0
- package/docs/assets/style-cookbook/transit-route-map.png +0 -0
- package/docs/comparison.md +0 -94
- package/docs/config.md +0 -105
- package/docs/contributing/adding-diagram-types.md +0 -144
- package/docs/contributing/diagram-family-citizenship.matrix.json +0 -2127
- package/docs/contributing/diagram-family-citizenship.md +0 -82
- package/docs/contributing/harvesting-upstream-tests.md +0 -126
- package/docs/contributing/lessons-learned.md +0 -276
- package/docs/contributing/releasing.md +0 -82
- package/docs/contributing/visual-review-evidence.md +0 -53
- package/docs/custom-style-cookbook.md +0 -154
- package/docs/design/families/README.md +0 -26
- package/docs/design/families/architecture-beta.md +0 -48
- package/docs/design/families/flowchart-parser-conformance.md +0 -22
- package/docs/design/families/gantt-research.md +0 -137
- package/docs/design/families/gantt.md +0 -329
- package/docs/design/families/journey-agentic-cards-before.png +0 -0
- package/docs/design/families/journey-experience-curve-implementation.png +0 -0
- package/docs/design/families/journey-mermaid-classic-implementation.png +0 -0
- package/docs/design/families/journey-mermaid-classic-prototype.png +0 -0
- package/docs/design/families/journey-mermaid-classic-prototype.svg +0 -163
- package/docs/design/families/journey-mermaid-classic-prototype.svg.png +0 -0
- package/docs/design/families/journey-migration-parity.md +0 -439
- package/docs/design/families/journey-section-overlap-after.png +0 -0
- package/docs/design/families/journey-section-overlap-before.png +0 -0
- package/docs/design/families/journey-usage-research.md +0 -423
- package/docs/design/families/journey.md +0 -153
- package/docs/design/families/xychart.md +0 -85
- package/docs/design/system/README.md +0 -45
- package/docs/design/system/abstraction-audit.md +0 -224
- package/docs/design/system/abstraction-recommendations.md +0 -655
- package/docs/design/system/architecture.mmd +0 -29
- package/docs/design/system/architecture.svg +0 -137
- package/docs/design/system/layout-guarantees-and-robustness.md +0 -351
- package/docs/design/system/layout-rubric.md +0 -138
- package/docs/design/system/route-contracts.md +0 -848
- package/docs/design/system/source-preservation-ladder.md +0 -52
- package/docs/design/system/styles-rollout.md +0 -216
- package/docs/design/system/ugly-layouts.md +0 -119
- package/docs/diagram-families.md +0 -193
- package/docs/features.md +0 -147
- package/docs/fork-differences.md +0 -130
- package/docs/getting-started.md +0 -175
- package/docs/issue-derived-test-cases.md +0 -156
- package/docs/layout-characterization/README.md +0 -245
- package/docs/layout-characterization/contact-sheet-families.md +0 -498
- package/docs/layout-characterization/contact-sheet.md +0 -638
- package/docs/layout-characterization/properties.md +0 -281
- package/docs/layout-characterization/visual-quality.md +0 -160
- package/docs/layout-characterization/visual-snapshots/architecture.svg +0 -79
- package/docs/layout-characterization/visual-snapshots/class.svg +0 -60
- package/docs/layout-characterization/visual-snapshots/er.svg +0 -61
- package/docs/layout-characterization/visual-snapshots/flowchart.svg +0 -58
- package/docs/layout-characterization/visual-snapshots/gantt.svg +0 -52
- package/docs/layout-characterization/visual-snapshots/journey.svg +0 -98
- package/docs/layout-characterization/visual-snapshots/pie.svg +0 -36
- package/docs/layout-characterization/visual-snapshots/quadrant.svg +0 -47
- package/docs/layout-characterization/visual-snapshots/sequence.svg +0 -59
- package/docs/layout-characterization/visual-snapshots/state.svg +0 -52
- package/docs/layout-characterization/visual-snapshots/timeline.svg +0 -64
- package/docs/layout-characterization/visual-snapshots/xychart.svg +0 -97
- package/docs/mcp-code-mode-rationale.md +0 -101
- package/docs/mcp-http-transport.md +0 -223
- package/docs/mermaid-layout-complaints.md +0 -837
- package/docs/mutation-testing.md +0 -206
- package/docs/project/divergences.md +0 -98
- package/docs/project/dogfooding-docs-strategy.md +0 -66
- package/docs/project/hosted-mcp-cloudflare-plan.md +0 -338
- package/docs/project/lessons-learned.md +0 -1019
- package/docs/project/remove-role-styling-plan.md +0 -176
- package/docs/quality.md +0 -215
- package/docs/react.md +0 -97
- package/docs/style-authoring.md +0 -133
- package/docs/testing-strategy.md +0 -335
- package/docs/theming.md +0 -162
- package/examples/agent-improve-auth-flow.ts +0 -206
- package/examples/agent-loop.ts +0 -53
- package/examples/mcp-vs-cli-complex-diagrams.ts +0 -133
- package/llms.txt +0 -104
- package/skill-evals/README.md +0 -77
- package/skill-evals/fixtures/architecture-cache/input.mmd +0 -5
- package/skill-evals/fixtures/class-payment/input.mmd +0 -4
- package/skill-evals/fixtures/er-orders/input.mmd +0 -10
- package/skill-evals/fixtures/flowchart-cache/input.mmd +0 -3
- package/skill-evals/fixtures/gantt-release/input.mmd +0 -7
- package/skill-evals/fixtures/journey-checkout/input.mmd +0 -6
- package/skill-evals/fixtures/pie-exports/input.mmd +0 -5
- package/skill-evals/fixtures/quadrant-priorities/input.mmd +0 -11
- package/skill-evals/fixtures/sequence-retry/input.mmd +0 -8
- package/skill-evals/fixtures/state-lifecycle/input.mmd +0 -5
- package/skill-evals/fixtures/timeline-release/input.mmd +0 -6
- package/skill-evals/fixtures/xychart-growth/input.mmd +0 -5
- package/skill-evals/shared-benchmark.json +0 -510
- package/src/agent/analyze.ts +0 -157
- package/src/agent/apply.ts +0 -146
- package/src/agent/architecture-body.ts +0 -458
- package/src/agent/certificates.ts +0 -110
- package/src/agent/class-body.ts +0 -319
- package/src/agent/core.ts +0 -207
- package/src/agent/create.ts +0 -106
- package/src/agent/describe.ts +0 -368
- package/src/agent/er-body.ts +0 -224
- package/src/agent/facts.ts +0 -360
- package/src/agent/families-builtin.ts +0 -920
- package/src/agent/families.ts +0 -232
- package/src/agent/family-layouts.ts +0 -832
- package/src/agent/flowchart-body.ts +0 -372
- package/src/agent/flowchart-unsupported.ts +0 -243
- package/src/agent/font-coverage.ts +0 -288
- package/src/agent/gantt-body.ts +0 -495
- package/src/agent/index.ts +0 -10
- package/src/agent/journey-body.ts +0 -374
- package/src/agent/label-metrics.ts +0 -28
- package/src/agent/layout-to-rendered.ts +0 -63
- package/src/agent/mutate.ts +0 -97
- package/src/agent/mutation-ops.ts +0 -51
- package/src/agent/op-schema.ts +0 -443
- package/src/agent/parse.ts +0 -223
- package/src/agent/pie-body.ts +0 -270
- package/src/agent/png.ts +0 -171
- package/src/agent/quadrant-body.ts +0 -336
- package/src/agent/quality.ts +0 -363
- package/src/agent/readability-audit.ts +0 -92
- package/src/agent/rendered-route-audit.ts +0 -61
- package/src/agent/sequence-body.ts +0 -345
- package/src/agent/serialize.ts +0 -214
- package/src/agent/state-body.ts +0 -589
- package/src/agent/structural-count.ts +0 -150
- package/src/agent/timeline-body.ts +0 -280
- package/src/agent/trace-log.ts +0 -43
- package/src/agent/types.ts +0 -995
- package/src/agent/verify.ts +0 -603
- package/src/agent/xychart-body.ts +0 -472
- package/src/architecture/config.ts +0 -282
- package/src/architecture/layout.ts +0 -496
- package/src/architecture/parser.ts +0 -375
- package/src/architecture/renderer.ts +0 -611
- package/src/architecture/types.ts +0 -113
- package/src/ascii/ansi.ts +0 -423
- package/src/ascii/architecture.ts +0 -210
- package/src/ascii/canvas.ts +0 -443
- package/src/ascii/class-diagram.ts +0 -697
- package/src/ascii/converter.ts +0 -475
- package/src/ascii/draw.ts +0 -1693
- package/src/ascii/edge-bundling.ts +0 -328
- package/src/ascii/edge-routing.ts +0 -330
- package/src/ascii/er-diagram.ts +0 -439
- package/src/ascii/gantt.ts +0 -276
- package/src/ascii/grid.ts +0 -747
- package/src/ascii/index.ts +0 -196
- package/src/ascii/journey.ts +0 -169
- package/src/ascii/meta.ts +0 -244
- package/src/ascii/multiline-utils.ts +0 -80
- package/src/ascii/pathfinder.ts +0 -301
- package/src/ascii/pie.ts +0 -67
- package/src/ascii/quadrant.ts +0 -158
- package/src/ascii/reverse.ts +0 -216
- package/src/ascii/sequence.ts +0 -499
- package/src/ascii/shapes/circle.ts +0 -27
- package/src/ascii/shapes/corners.ts +0 -143
- package/src/ascii/shapes/diamond.ts +0 -27
- package/src/ascii/shapes/hexagon.ts +0 -27
- package/src/ascii/shapes/index.ts +0 -106
- package/src/ascii/shapes/rectangle.ts +0 -168
- package/src/ascii/shapes/rounded.ts +0 -27
- package/src/ascii/shapes/special.ts +0 -332
- package/src/ascii/shapes/stadium.ts +0 -110
- package/src/ascii/shapes/state.ts +0 -192
- package/src/ascii/shapes/types.ts +0 -73
- package/src/ascii/timeline.ts +0 -141
- package/src/ascii/types.ts +0 -312
- package/src/ascii/validate.ts +0 -120
- package/src/ascii/width.ts +0 -81
- package/src/ascii/wrap.ts +0 -131
- package/src/ascii/xychart.ts +0 -919
- package/src/browser.ts +0 -29
- package/src/class/layout.ts +0 -240
- package/src/class/parser.ts +0 -332
- package/src/class/renderer.ts +0 -638
- package/src/class/types.ts +0 -129
- package/src/cli/agent-instructions.ts +0 -1
- package/src/cli/am-bin.ts +0 -6
- package/src/cli/exit-codes.ts +0 -18
- package/src/cli/index.ts +0 -1132
- package/src/cli/init-agent.ts +0 -147
- package/src/cli/run-entrypoint.ts +0 -10
- package/src/color-resolver.ts +0 -152
- package/src/elk-instance.ts +0 -114
- package/src/er/layout.ts +0 -187
- package/src/er/parser.ts +0 -226
- package/src/er/renderer.ts +0 -792
- package/src/er/types.ts +0 -99
- package/src/family-rubric.ts +0 -425
- package/src/font-manifest.ts +0 -31
- package/src/gantt/layout.ts +0 -365
- package/src/gantt/parser.ts +0 -282
- package/src/gantt/pipeline.ts +0 -27
- package/src/gantt/renderer.ts +0 -414
- package/src/gantt/schedule.ts +0 -513
- package/src/gantt/types.ts +0 -255
- package/src/index.ts +0 -368
- package/src/journey/layout.ts +0 -554
- package/src/journey/parse-core.ts +0 -251
- package/src/journey/parser.ts +0 -101
- package/src/journey/renderer.ts +0 -917
- package/src/journey/types.ts +0 -144
- package/src/layout/geometry.ts +0 -182
- package/src/layout/pass.ts +0 -142
- package/src/layout/passes/index.ts +0 -3916
- package/src/layout-engine.ts +0 -1565
- package/src/layout-rubric.ts +0 -659
- package/src/mcp/artifacts.ts +0 -137
- package/src/mcp/dynamic-harness.ts +0 -31
- package/src/mcp/dynamic-modules.d.ts +0 -7
- package/src/mcp/facade.ts +0 -608
- package/src/mcp/harness-runtime.ts +0 -203
- package/src/mcp/hosted-server.ts +0 -468
- package/src/mcp/mcp-bin.ts +0 -3
- package/src/mcp/mcp-cli.ts +0 -82
- package/src/mcp/protocol.ts +0 -13
- package/src/mcp/sandbox.ts +0 -195
- package/src/mcp/sdk-decl.ts +0 -391
- package/src/mcp/server.ts +0 -362
- package/src/mcp/tool-surface.ts +0 -146
- package/src/mermaid-source.ts +0 -655
- package/src/multiline-utils.ts +0 -228
- package/src/parser.ts +0 -1031
- package/src/pie/layout.ts +0 -168
- package/src/pie/parser.ts +0 -136
- package/src/pie/renderer.ts +0 -234
- package/src/pie/types.ts +0 -82
- package/src/quadrant/layout.ts +0 -191
- package/src/quadrant/parser.ts +0 -183
- package/src/quadrant/renderer.ts +0 -322
- package/src/quadrant/types.ts +0 -113
- package/src/render-family-hooks.ts +0 -244
- package/src/renderer.ts +0 -1099
- package/src/route-contracts.ts +0 -2723
- package/src/scene/backend.ts +0 -76
- package/src/scene/fidelity.ts +0 -183
- package/src/scene/hybrid-backend.ts +0 -174
- package/src/scene/ir.ts +0 -181
- package/src/scene/marks.ts +0 -127
- package/src/scene/rough-backend.ts +0 -378
- package/src/scene/seed.ts +0 -46
- package/src/scene/style-registry.ts +0 -556
- package/src/sequence/layout.ts +0 -456
- package/src/sequence/parser.ts +0 -255
- package/src/sequence/renderer.ts +0 -604
- package/src/sequence/types.ts +0 -154
- package/src/shape-clipping.ts +0 -351
- package/src/shared/accessibility-directives.ts +0 -22
- package/src/shared/batched.ts +0 -61
- package/src/shared/color-math.ts +0 -93
- package/src/shared/svg-a11y.ts +0 -29
- package/src/shared/syntax-error.ts +0 -17
- package/src/shared/unicode-ranges.ts +0 -135
- package/src/styles.ts +0 -318
- package/src/svg-paths.ts +0 -28
- package/src/text-metrics.ts +0 -248
- package/src/theme.ts +0 -710
- package/src/timeline/layout.ts +0 -381
- package/src/timeline/parser.ts +0 -215
- package/src/timeline/renderer.ts +0 -538
- package/src/timeline/types.ts +0 -105
- package/src/types.ts +0 -462
- package/src/website-worker-modules.d.ts +0 -18
- package/src/xychart/axis-utils.ts +0 -109
- package/src/xychart/colors.ts +0 -124
- package/src/xychart/config.ts +0 -88
- package/src/xychart/layout.ts +0 -656
- package/src/xychart/parser.ts +0 -504
- package/src/xychart/renderer.ts +0 -640
- package/src/xychart/types.ts +0 -282
package/src/renderer.ts
DELETED
|
@@ -1,1099 +0,0 @@
|
|
|
1
|
-
import type { PositionedGraph, PositionedNode, PositionedEdge, PositionedGroup, Point, EdgeMarker, RenderContext } from './types.ts'
|
|
2
|
-
import { svgOpenTag, buildStyleBlock, buildShadowDefs } from './theme.ts'
|
|
3
|
-
import { STROKE_WIDTHS, ARROW_HEAD, FLOWCHART_DOTTED_DASH, applyTextTransform, resolveRenderStyle } from './styles.ts'
|
|
4
|
-
import type { ResolvedRenderStyle } from './styles.ts'
|
|
5
|
-
import { measureMultilineText } from './text-metrics.ts'
|
|
6
|
-
import { renderMultilineText, renderMultilineTextWithBackground, escapeAttr, escapeXml } from './multiline-utils.ts'
|
|
7
|
-
import { topRoundedRectPath } from './svg-paths.ts'
|
|
8
|
-
import { resolveInlineNodeTextColor } from './color-resolver.ts'
|
|
9
|
-
import type { Geometry, MarkerRef, SceneDoc, SceneNode, SemanticChannels } from './scene/ir.ts'
|
|
10
|
-
import * as marks from './scene/marks.ts'
|
|
11
|
-
import { DefaultBackend } from './scene/backend.ts'
|
|
12
|
-
|
|
13
|
-
// ============================================================================
|
|
14
|
-
// SVG renderer — converts a PositionedGraph into an SVG string.
|
|
15
|
-
//
|
|
16
|
-
// The graph is first lowered to a SceneGraph (SPEC §3.1): every visual mark
|
|
17
|
-
// becomes a scene node carrying semantic fields (role, geometry, paint,
|
|
18
|
-
// channels, stable id) plus its exact crisp serialization, built here from
|
|
19
|
-
// the same inputs. renderSvg() is DefaultBackend serialization of that scene,
|
|
20
|
-
// so the default path stays byte-identical to the historical string renderer
|
|
21
|
-
// (corpus-gated by svg-equivalence.test.ts); styled backends redraw the same
|
|
22
|
-
// scene without re-parsing SVG.
|
|
23
|
-
//
|
|
24
|
-
// Renders back-to-front: groups → edges → arrow heads → edge labels → nodes.
|
|
25
|
-
//
|
|
26
|
-
// All colors are referenced via CSS custom properties (var(--_xxx)) defined
|
|
27
|
-
// in the <style> block. The caller provides bg/fg (+ optional enrichment
|
|
28
|
-
// colors) via DiagramColors, which are set as inline CSS variables on the
|
|
29
|
-
// <svg> tag. See src/theme.ts for the full variable system.
|
|
30
|
-
//
|
|
31
|
-
// Style spec:
|
|
32
|
-
// - All corners rx=0 ry=0 (sharp)
|
|
33
|
-
// - Stroke widths: outer box 1px, inner box 0.75px, connectors 0.75px
|
|
34
|
-
// - Arrow heads: filled triangles, 8px wide × 4.8px tall
|
|
35
|
-
// - Dashed edges: stroke-dasharray="4 4"
|
|
36
|
-
// - Font: Inter with weight per element type
|
|
37
|
-
// ============================================================================
|
|
38
|
-
|
|
39
|
-
/** A shape emission: semantic geometry + the exact crisp serialization. */
|
|
40
|
-
interface ShapePiece {
|
|
41
|
-
geometry: Geometry
|
|
42
|
-
crisp: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Render a positioned graph as an SVG string.
|
|
47
|
-
*
|
|
48
|
-
* @param colors - DiagramColors with bg/fg and optional enrichment variables.
|
|
49
|
-
* These are set as CSS custom properties on the <svg> tag.
|
|
50
|
-
* All element colors reference derived --_xxx variables.
|
|
51
|
-
* @param transparent - If true, renders with transparent background.
|
|
52
|
-
*/
|
|
53
|
-
export function renderSvg(
|
|
54
|
-
ctx: RenderContext<PositionedGraph>,
|
|
55
|
-
): string {
|
|
56
|
-
return DefaultBackend.render(lowerGraphScene(ctx), { seed: 0 })
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Lower a positioned graph to the SceneGraph IR. Mark order matches the
|
|
61
|
-
* historical parts[] order exactly; DefaultBackend joins crisps with '\n'.
|
|
62
|
-
*/
|
|
63
|
-
export function lowerGraphScene(
|
|
64
|
-
ctx: RenderContext<PositionedGraph>,
|
|
65
|
-
): SceneDoc {
|
|
66
|
-
const { positioned: graph, colors, options } = ctx
|
|
67
|
-
const font = colors.font ?? 'Inter'
|
|
68
|
-
const transparent = options.transparent ?? false
|
|
69
|
-
const parts: SceneNode[] = []
|
|
70
|
-
const style = resolveRenderStyle(options)
|
|
71
|
-
|
|
72
|
-
// SVG root with CSS variables + style block + defs
|
|
73
|
-
parts.push(marks.prelude(
|
|
74
|
-
{
|
|
75
|
-
id: 'prelude',
|
|
76
|
-
width: graph.width,
|
|
77
|
-
height: graph.height,
|
|
78
|
-
colors,
|
|
79
|
-
transparent,
|
|
80
|
-
font,
|
|
81
|
-
hasMonoFont: false,
|
|
82
|
-
},
|
|
83
|
-
svgOpenTag(graph.width, graph.height, colors, transparent) + '\n' +
|
|
84
|
-
buildStyleBlock(font, false, colors.shadow, colors.embedFontImport),
|
|
85
|
-
))
|
|
86
|
-
const defsParts: string[] = []
|
|
87
|
-
defsParts.push('<defs>')
|
|
88
|
-
defsParts.push(arrowMarkerDefs())
|
|
89
|
-
const shadowDefs = buildShadowDefs(colors)
|
|
90
|
-
if (shadowDefs) defsParts.push(shadowDefs)
|
|
91
|
-
// Per-color arrow markers for edges with custom stroke via linkStyle
|
|
92
|
-
const customStrokeColors = new Set<string>()
|
|
93
|
-
if (style.edgeStrokeColor) customStrokeColors.add(style.edgeStrokeColor)
|
|
94
|
-
let needsCircle = false
|
|
95
|
-
let needsCross = false
|
|
96
|
-
for (const edge of graph.edges) {
|
|
97
|
-
if (edge.inlineStyle?.stroke) customStrokeColors.add(edge.inlineStyle.stroke)
|
|
98
|
-
if (edge.startMarker === 'circle' || edge.endMarker === 'circle') needsCircle = true
|
|
99
|
-
if (edge.startMarker === 'cross' || edge.endMarker === 'cross') needsCross = true
|
|
100
|
-
}
|
|
101
|
-
if (needsCircle) defsParts.push(circleMarkerDefs())
|
|
102
|
-
if (needsCross) defsParts.push(crossMarkerDefs())
|
|
103
|
-
for (const color of customStrokeColors) {
|
|
104
|
-
defsParts.push(arrowMarkerDefsForColor(color))
|
|
105
|
-
if (needsCircle) defsParts.push(circleMarkerDefs(color))
|
|
106
|
-
if (needsCross) defsParts.push(crossMarkerDefs(color))
|
|
107
|
-
}
|
|
108
|
-
defsParts.push('</defs>')
|
|
109
|
-
parts.push(marks.raw({ id: 'defs', role: 'defs' }, defsParts.join('\n')))
|
|
110
|
-
|
|
111
|
-
// 1. Subgraph backgrounds (group rectangles with header bands)
|
|
112
|
-
for (const group of graph.groups) {
|
|
113
|
-
parts.push(renderGroup(group, font, style))
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 2. Edges (polylines — rendered behind nodes)
|
|
117
|
-
// Each edge is a <polyline> with semantic data-* attributes
|
|
118
|
-
const edgeOccurrence = new Map<string, number>()
|
|
119
|
-
for (const edge of graph.edges) {
|
|
120
|
-
const pairKey = `${edge.source}->${edge.target}`
|
|
121
|
-
const k = edgeOccurrence.get(pairKey) ?? 0
|
|
122
|
-
edgeOccurrence.set(pairKey, k + 1)
|
|
123
|
-
parts.push(renderEdge(edge, style, `edge:${pairKey}#${k}`))
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// 3. Edge labels (positioned at midpoint of edge)
|
|
127
|
-
// Each label is wrapped in <g class="edge-label">
|
|
128
|
-
const labelOccurrence = new Map<string, number>()
|
|
129
|
-
for (const edge of graph.edges) {
|
|
130
|
-
if (edge.label) {
|
|
131
|
-
const pairKey = `${edge.source}->${edge.target}`
|
|
132
|
-
const k = labelOccurrence.get(pairKey) ?? 0
|
|
133
|
-
labelOccurrence.set(pairKey, k + 1)
|
|
134
|
-
parts.push(renderEdgeLabel(edge, font, style, `edge-label:${pairKey}#${k}`))
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// 4. Nodes (shape + label wrapped in <g class="node">)
|
|
139
|
-
for (const node of graph.nodes) {
|
|
140
|
-
parts.push(renderNode(node, font, style))
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
parts.push(marks.raw({ id: 'svg-close', role: 'chrome' }, '</svg>'))
|
|
144
|
-
|
|
145
|
-
return { family: 'flowchart', width: graph.width, height: graph.height, colors, parts }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ============================================================================
|
|
149
|
-
// Arrow marker definitions
|
|
150
|
-
// ============================================================================
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Reusable arrow head markers — both forward (end) and reverse (start) variants.
|
|
154
|
-
* The reverse marker uses orient="auto-start-reverse" to flip automatically.
|
|
155
|
-
* Arrow color uses var(--_arrow) CSS variable.
|
|
156
|
-
*/
|
|
157
|
-
function arrowMarkerDefs(): string {
|
|
158
|
-
const w = ARROW_HEAD.width
|
|
159
|
-
const h = ARROW_HEAD.height
|
|
160
|
-
// Arrow polygons have both fill and a thin stroke for better definition at small sizes
|
|
161
|
-
const arrowStyle = 'fill="var(--_arrow)" stroke="var(--_arrow)" stroke-width="0.75" stroke-linejoin="round"'
|
|
162
|
-
// Pull arrowhead back slightly (refX = w - 1) to prevent clipping at node boundaries
|
|
163
|
-
const refX = w - 1
|
|
164
|
-
return (
|
|
165
|
-
// Forward arrow (marker-end) — orient="auto" ensures arrow points along line direction
|
|
166
|
-
` <marker id="arrowhead" markerWidth="${w}" markerHeight="${h}" refX="${refX}" refY="${h / 2}" orient="auto">` +
|
|
167
|
-
`\n <polygon points="0 0, ${w} ${h / 2}, 0 ${h}" ${arrowStyle} />` +
|
|
168
|
-
`\n </marker>` +
|
|
169
|
-
// Reverse arrow (marker-start) uses the same geometry as marker-end;
|
|
170
|
-
// auto-start-reverse handles orientation without a hand-flipped polygon.
|
|
171
|
-
`\n <marker id="arrowhead-start" markerWidth="${w}" markerHeight="${h}" refX="${refX}" refY="${h / 2}" orient="auto-start-reverse">` +
|
|
172
|
-
`\n <polygon points="0 0, ${w} ${h / 2}, 0 ${h}" ${arrowStyle} />` +
|
|
173
|
-
`\n </marker>`
|
|
174
|
-
)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Generate arrow markers tinted to a specific color (for linkStyle stroke overrides).
|
|
179
|
-
* IDs are suffixed with a sanitized color string to avoid collisions.
|
|
180
|
-
*/
|
|
181
|
-
function arrowMarkerDefsForColor(color: string): string {
|
|
182
|
-
const w = ARROW_HEAD.width
|
|
183
|
-
const h = ARROW_HEAD.height
|
|
184
|
-
const escaped = escapeAttr(color)
|
|
185
|
-
const arrowStyle = `fill="${escaped}" stroke="${escaped}" stroke-width="0.75" stroke-linejoin="round"`
|
|
186
|
-
const refX = w - 1
|
|
187
|
-
const suffix = markerSuffix(color)
|
|
188
|
-
return (
|
|
189
|
-
` <marker id="arrowhead-${suffix}" markerWidth="${w}" markerHeight="${h}" refX="${refX}" refY="${h / 2}" orient="auto">` +
|
|
190
|
-
`\n <polygon points="0 0, ${w} ${h / 2}, 0 ${h}" ${arrowStyle} />` +
|
|
191
|
-
`\n </marker>` +
|
|
192
|
-
`\n <marker id="arrowhead-start-${suffix}" markerWidth="${w}" markerHeight="${h}" refX="${refX}" refY="${h / 2}" orient="auto-start-reverse">` +
|
|
193
|
-
`\n <polygon points="0 0, ${w} ${h / 2}, 0 ${h}" ${arrowStyle} />` +
|
|
194
|
-
`\n </marker>`
|
|
195
|
-
)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function circleMarkerDefs(color?: string): string {
|
|
199
|
-
const size = ARROW_HEAD.width
|
|
200
|
-
const suffix = color ? `-${markerSuffix(color)}` : ''
|
|
201
|
-
const stroke = color ? escapeAttr(color) : 'var(--_arrow)'
|
|
202
|
-
const r = size / 2 - 0.75
|
|
203
|
-
return (
|
|
204
|
-
` <marker id="circlehead${suffix}" markerWidth="${size}" markerHeight="${size}" refX="${size - 0.5}" refY="${size / 2}" orient="auto">` +
|
|
205
|
-
`\n <circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${stroke}" stroke-width="1" />` +
|
|
206
|
-
`\n </marker>` +
|
|
207
|
-
`\n <marker id="circlehead-start${suffix}" markerWidth="${size}" markerHeight="${size}" refX="0.5" refY="${size / 2}" orient="auto-start-reverse">` +
|
|
208
|
-
`\n <circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${stroke}" stroke-width="1" />` +
|
|
209
|
-
`\n </marker>`
|
|
210
|
-
)
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function crossMarkerDefs(color?: string): string {
|
|
214
|
-
const size = ARROW_HEAD.width
|
|
215
|
-
const suffix = color ? `-${markerSuffix(color)}` : ''
|
|
216
|
-
const stroke = color ? escapeAttr(color) : 'var(--_arrow)'
|
|
217
|
-
const pad = 1.25
|
|
218
|
-
const a = pad
|
|
219
|
-
const b = size - pad
|
|
220
|
-
const style = `stroke="${stroke}" stroke-width="1.25" stroke-linecap="round"`
|
|
221
|
-
return (
|
|
222
|
-
` <marker id="crosshead${suffix}" markerWidth="${size}" markerHeight="${size}" refX="${size / 2}" refY="${size / 2}" orient="auto">` +
|
|
223
|
-
`\n <line x1="${a}" y1="${a}" x2="${b}" y2="${b}" ${style} />` +
|
|
224
|
-
`\n <line x1="${a}" y1="${b}" x2="${b}" y2="${a}" ${style} />` +
|
|
225
|
-
`\n </marker>` +
|
|
226
|
-
`\n <marker id="crosshead-start${suffix}" markerWidth="${size}" markerHeight="${size}" refX="${size / 2}" refY="${size / 2}" orient="auto-start-reverse">` +
|
|
227
|
-
`\n <line x1="${a}" y1="${a}" x2="${b}" y2="${b}" ${style} />` +
|
|
228
|
-
`\n <line x1="${a}" y1="${b}" x2="${b}" y2="${a}" ${style} />` +
|
|
229
|
-
`\n </marker>`
|
|
230
|
-
)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function markerIdPrefix(marker: EdgeMarker): string {
|
|
234
|
-
if (marker === 'circle') return 'circlehead'
|
|
235
|
-
if (marker === 'cross') return 'crosshead'
|
|
236
|
-
return 'arrowhead'
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function markerShape(marker: EdgeMarker): MarkerRef['shape'] {
|
|
240
|
-
if (marker === 'circle') return 'circle'
|
|
241
|
-
if (marker === 'cross') return 'cross'
|
|
242
|
-
return 'arrow'
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** Sanitize a color value into a collision-free SVG ID suffix.
|
|
246
|
-
* Non-alphanumeric chars are hex-encoded so distinct inputs never collapse
|
|
247
|
-
* (e.g. "var(--line-1)" → "var28--line2d129", "var(--line1)" → "var28--line129"). */
|
|
248
|
-
function markerSuffix(color: string): string {
|
|
249
|
-
return color.replace(/[^a-zA-Z0-9]/g, (ch) => ch.charCodeAt(0).toString(16))
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// ============================================================================
|
|
253
|
-
// Group rendering (subgraph backgrounds)
|
|
254
|
-
// ============================================================================
|
|
255
|
-
|
|
256
|
-
function renderGroup(group: PositionedGroup, font: string, style: ResolvedRenderStyle, parentId?: string): SceneNode {
|
|
257
|
-
const headerHeight = style.groupHeaderFontSize + 16
|
|
258
|
-
const children: Array<{ node: SceneNode; indent: number }> = []
|
|
259
|
-
|
|
260
|
-
// Opening <g> with semantic attributes for subgraph identification
|
|
261
|
-
// data-id: original Mermaid subgraph ID
|
|
262
|
-
// data-label: display label (may differ from ID)
|
|
263
|
-
const open =
|
|
264
|
-
`<g class="subgraph" data-id="${escapeAttr(group.id)}" data-region="subgraph" data-label="${escapeAttr(group.label)}"${parentId ? ` data-parent-id="${escapeAttr(parentId)}"` : ''}>`
|
|
265
|
-
|
|
266
|
-
// Outer rectangle
|
|
267
|
-
const rectFill = style.groupFillColor ?? 'var(--_group-fill)'
|
|
268
|
-
const rectStroke = style.groupBorderColor ?? 'var(--_node-stroke)'
|
|
269
|
-
children.push({
|
|
270
|
-
indent: 2,
|
|
271
|
-
node: marks.shape({
|
|
272
|
-
id: `group-rect:${group.id}`,
|
|
273
|
-
role: 'group',
|
|
274
|
-
geometry: { kind: 'rect', x: group.x, y: group.y, width: group.width, height: group.height, rx: style.groupCornerRadius, ry: style.groupCornerRadius },
|
|
275
|
-
paint: { fill: rectFill, stroke: rectStroke, strokeWidth: String(style.groupLineWidth) },
|
|
276
|
-
},
|
|
277
|
-
`<rect x="${group.x}" y="${group.y}" width="${group.width}" height="${group.height}" ` +
|
|
278
|
-
`rx="${style.groupCornerRadius}" ry="${style.groupCornerRadius}" fill="${escapeAttr(rectFill)}" stroke="${escapeAttr(rectStroke)}" stroke-width="${style.groupLineWidth}" />`),
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
// Header band
|
|
282
|
-
const headerFill = style.groupHeaderFillColor ?? 'var(--_group-hdr)'
|
|
283
|
-
const headerPath = topRoundedRectPath(group.x, group.y, group.width, headerHeight, style.groupCornerRadius)
|
|
284
|
-
children.push({
|
|
285
|
-
indent: 2,
|
|
286
|
-
node: marks.shape({
|
|
287
|
-
id: `group-header:${group.id}`,
|
|
288
|
-
role: 'group-header',
|
|
289
|
-
geometry: { kind: 'path', d: headerPath },
|
|
290
|
-
paint: { fill: headerFill, stroke: rectStroke, strokeWidth: String(style.groupLineWidth) },
|
|
291
|
-
},
|
|
292
|
-
`<path d="${headerPath}" ` +
|
|
293
|
-
`fill="${escapeAttr(headerFill)}" stroke="${escapeAttr(rectStroke)}" stroke-width="${style.groupLineWidth}" />`),
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
// Header label (supports multi-line via <br> tags)
|
|
297
|
-
const headerText = applyTextTransform(group.label, style.groupTextTransform)
|
|
298
|
-
const headerTextColor = style.groupTextColor ?? 'var(--_text-sec)'
|
|
299
|
-
children.push({
|
|
300
|
-
indent: 2,
|
|
301
|
-
node: marks.text({
|
|
302
|
-
id: `group-label:${group.id}`,
|
|
303
|
-
role: 'group-header',
|
|
304
|
-
text: headerText,
|
|
305
|
-
x: group.x + style.groupLabelPaddingX,
|
|
306
|
-
y: group.y + headerHeight / 2,
|
|
307
|
-
fontSize: style.groupHeaderFontSize,
|
|
308
|
-
anchor: 'start',
|
|
309
|
-
paint: { fill: headerTextColor },
|
|
310
|
-
}, renderMultilineText(
|
|
311
|
-
headerText,
|
|
312
|
-
group.x + style.groupLabelPaddingX,
|
|
313
|
-
group.y + headerHeight / 2,
|
|
314
|
-
style.groupHeaderFontSize,
|
|
315
|
-
`font-size="${style.groupHeaderFontSize}" font-weight="${style.groupHeaderFontWeight}"${style.groupFont ? ` font-family="${escapeAttr(style.groupFont)}"` : ''}${style.groupLetterSpacing !== 0 ? ` letter-spacing="${style.groupLetterSpacing}"` : ''} fill="${escapeAttr(headerTextColor)}"`
|
|
316
|
-
)),
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
// Render nested groups recursively (inside this group)
|
|
320
|
-
for (const child of group.children) {
|
|
321
|
-
children.push({ indent: 0, node: renderGroup(child, font, style, group.id) })
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
return marks.group({
|
|
325
|
-
id: `group:${group.id}`,
|
|
326
|
-
role: 'group',
|
|
327
|
-
open,
|
|
328
|
-
close: '</g>',
|
|
329
|
-
children,
|
|
330
|
-
})
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// ============================================================================
|
|
334
|
-
// Edge rendering
|
|
335
|
-
// ============================================================================
|
|
336
|
-
|
|
337
|
-
function renderEdge(edge: PositionedEdge, style: ResolvedRenderStyle, sceneId: string): SceneNode {
|
|
338
|
-
const lineStyle = edge.style === 'dotted' ? 'dotted'
|
|
339
|
-
: edge.style === 'thick' ? 'thick'
|
|
340
|
-
: edge.style === 'invisible' ? 'invisible'
|
|
341
|
-
: 'solid'
|
|
342
|
-
// Invisible links (~~~) shape the layout but draw no stroke or markers.
|
|
343
|
-
if (edge.points.length < 2 || edge.style === 'invisible') {
|
|
344
|
-
return marks.connector({
|
|
345
|
-
id: sceneId,
|
|
346
|
-
role: 'edge',
|
|
347
|
-
geometry: { kind: 'polyline', points: edge.points },
|
|
348
|
-
lineStyle: 'invisible',
|
|
349
|
-
paint: {},
|
|
350
|
-
}, '')
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const pathData = style.edgeBendRadius > 0 ? pointsToPathD(edge.points, style.edgeBendRadius) : pointsToPolylinePath(edge.points)
|
|
354
|
-
const dashArray = edge.style === 'dotted' ? ` stroke-dasharray="${FLOWCHART_DOTTED_DASH.dash} ${FLOWCHART_DOTTED_DASH.gap}"` : ''
|
|
355
|
-
const baseStrokeWidth = edge.style === 'thick' ? style.lineWidth * 2 : style.lineWidth
|
|
356
|
-
const markerColor = edge.inlineStyle?.stroke ?? style.edgeStrokeColor
|
|
357
|
-
const strokeColor = escapeAttr(markerColor ?? 'var(--_line)')
|
|
358
|
-
const strokeWidth = escapeAttr(edge.inlineStyle?.['stroke-width'] ?? String(baseStrokeWidth))
|
|
359
|
-
|
|
360
|
-
// Build marker attributes based on arrow direction flags
|
|
361
|
-
// Use color-specific markers when edge has a custom stroke from linkStyle
|
|
362
|
-
const suffix = markerColor ? `-${markerSuffix(markerColor)}` : ''
|
|
363
|
-
let markers = ''
|
|
364
|
-
let endMarker: MarkerRef | undefined
|
|
365
|
-
let startMarker: MarkerRef | undefined
|
|
366
|
-
if (edge.hasArrowEnd) {
|
|
367
|
-
const prefix = markerIdPrefix(edge.endMarker ?? 'arrow')
|
|
368
|
-
endMarker = { id: `${prefix}${suffix}`, shape: markerShape(edge.endMarker ?? 'arrow') }
|
|
369
|
-
markers += ` marker-end="url(#${prefix}${suffix})"`
|
|
370
|
-
}
|
|
371
|
-
if (edge.hasArrowStart) {
|
|
372
|
-
const prefix = markerIdPrefix(edge.startMarker ?? 'arrow')
|
|
373
|
-
startMarker = { id: `${prefix}-start${suffix}`, shape: markerShape(edge.startMarker ?? 'arrow') }
|
|
374
|
-
markers += ` marker-start="url(#${prefix}-start${suffix})"`
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Semantic data attributes for edge identification and inspection:
|
|
378
|
-
// - class="edge": CSS targeting and type identification
|
|
379
|
-
// - data-from/data-to: source and target node IDs
|
|
380
|
-
// - data-style: edge style (solid, dotted, thick)
|
|
381
|
-
// - data-arrow-start/end: arrow presence flags
|
|
382
|
-
// - data-label: edge label if present (for quick lookup without traversing DOM)
|
|
383
|
-
const dataAttrs = [
|
|
384
|
-
'class="edge"',
|
|
385
|
-
`data-from="${escapeAttr(edge.source)}"`,
|
|
386
|
-
`data-to="${escapeAttr(edge.target)}"`,
|
|
387
|
-
`data-style="${edge.style}"`,
|
|
388
|
-
`data-arrow-start="${edge.hasArrowStart}"`,
|
|
389
|
-
`data-arrow-end="${edge.hasArrowEnd}"`,
|
|
390
|
-
]
|
|
391
|
-
if (edge.hasArrowStart) dataAttrs.push(`data-marker-start="${edge.startMarker ?? 'arrow'}"`)
|
|
392
|
-
if (edge.hasArrowEnd) dataAttrs.push(`data-marker-end="${edge.endMarker ?? 'arrow'}"`)
|
|
393
|
-
if (edge.label) {
|
|
394
|
-
dataAttrs.push(`data-label="${escapeAttr(edge.label)}"`)
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const paint = {
|
|
398
|
-
stroke: markerColor ?? 'var(--_line)',
|
|
399
|
-
strokeWidth: edge.inlineStyle?.['stroke-width'] ?? String(baseStrokeWidth),
|
|
400
|
-
...(edge.style === 'dotted' ? { strokeDasharray: `${FLOWCHART_DOTTED_DASH.dash} ${FLOWCHART_DOTTED_DASH.gap}` } : {}),
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (style.edgeBendRadius > 0) {
|
|
404
|
-
return marks.connector({
|
|
405
|
-
id: sceneId,
|
|
406
|
-
role: 'edge',
|
|
407
|
-
geometry: { kind: 'path', d: pathData, points: edge.points },
|
|
408
|
-
lineStyle,
|
|
409
|
-
paint,
|
|
410
|
-
startMarker,
|
|
411
|
-
endMarker,
|
|
412
|
-
},
|
|
413
|
-
`<path ${dataAttrs.join(' ')} d="${pathData}" fill="none" stroke="${strokeColor}" ` +
|
|
414
|
-
`stroke-width="${strokeWidth}"${dashArray}${markers} />`)
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
return marks.connector({
|
|
418
|
-
id: sceneId,
|
|
419
|
-
role: 'edge',
|
|
420
|
-
geometry: { kind: 'polyline', points: edge.points },
|
|
421
|
-
lineStyle,
|
|
422
|
-
paint,
|
|
423
|
-
startMarker,
|
|
424
|
-
endMarker,
|
|
425
|
-
},
|
|
426
|
-
`<polyline ${dataAttrs.join(' ')} points="${pathData}" fill="none" stroke="${strokeColor}" ` +
|
|
427
|
-
`stroke-width="${strokeWidth}"${dashArray}${markers} />`)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/** Convert points to SVG polyline points attribute: "x1,y1 x2,y2 ..." */
|
|
431
|
-
function pointsToPolylinePath(points: Point[]): string {
|
|
432
|
-
return points.map(p => `${p.x},${p.y}`).join(' ')
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function pointsToPathD(points: Point[], radius: number): string {
|
|
436
|
-
if (points.length === 0) return ''
|
|
437
|
-
if (points.length === 1) return `M${points[0]!.x},${points[0]!.y}`
|
|
438
|
-
if (radius <= 0 || points.length < 3) {
|
|
439
|
-
return `M${points.map(p => `${p.x},${p.y}`).join(' L')}`
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const parts: string[] = [`M${points[0]!.x},${points[0]!.y}`]
|
|
443
|
-
|
|
444
|
-
for (let i = 1; i < points.length - 1; i++) {
|
|
445
|
-
const prev = points[i - 1]!
|
|
446
|
-
const curr = points[i]!
|
|
447
|
-
const next = points[i + 1]!
|
|
448
|
-
const prevLen = dist(prev, curr)
|
|
449
|
-
const nextLen = dist(curr, next)
|
|
450
|
-
const r = Math.min(radius, prevLen / 2, nextLen / 2)
|
|
451
|
-
|
|
452
|
-
if (r <= 0) {
|
|
453
|
-
parts.push(`L${curr.x},${curr.y}`)
|
|
454
|
-
continue
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const before = pointAlong(curr, prev, r)
|
|
458
|
-
const after = pointAlong(curr, next, r)
|
|
459
|
-
parts.push(`L${before.x},${before.y}`)
|
|
460
|
-
parts.push(`Q${curr.x},${curr.y} ${after.x},${after.y}`)
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const last = points[points.length - 1]!
|
|
464
|
-
parts.push(`L${last.x},${last.y}`)
|
|
465
|
-
return parts.join(' ')
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function pointAlong(from: Point, to: Point, distance: number): Point {
|
|
469
|
-
const total = dist(from, to)
|
|
470
|
-
if (total === 0) return { ...from }
|
|
471
|
-
const t = distance / total
|
|
472
|
-
return {
|
|
473
|
-
x: roundCoord(from.x + (to.x - from.x) * t),
|
|
474
|
-
y: roundCoord(from.y + (to.y - from.y) * t),
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Round a coordinate to 3 decimal places (sub-pixel precision).
|
|
480
|
-
* Exported for the compact-SVG post-processor and any external renderer that
|
|
481
|
-
* wants to emit identical wire bytes to the built-in path.
|
|
482
|
-
* (Renamed from `roundPathCoord` per the Loop 7 audit consolidation note.)
|
|
483
|
-
*/
|
|
484
|
-
export function roundCoord(value: number): number {
|
|
485
|
-
return Math.round(value * 1000) / 1000
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Post-process an SVG string into compact form:
|
|
490
|
-
* - Numbers with 3+ fractional digits get rounded via `roundCoord` (so
|
|
491
|
-
* e.g. "123.456789" → "123.457", "100.0001" → "100"). This shrinks ELK-
|
|
492
|
-
* produced layouts where floats hit 13-digit precision.
|
|
493
|
-
* - Newlines between elements collapse to nothing — EXCEPT inside <style>
|
|
494
|
-
* blocks, where CSS line breaks are load-bearing (some CSS parsers tolerate
|
|
495
|
-
* everything but conservatively we leave the style block formatted).
|
|
496
|
-
* - `data-*` attributes and `class=` attributes survive — they're agent
|
|
497
|
-
* inspection hooks (Loop 7 audit). The number-rounding regex only
|
|
498
|
-
* touches floating-point literals, not identifier strings.
|
|
499
|
-
*
|
|
500
|
-
* Determinism: pure function of the input string. Safe to apply to any SVG
|
|
501
|
-
* the renderer produces; idempotent on already-compact input.
|
|
502
|
-
*/
|
|
503
|
-
export function compactSvg(svg: string): string {
|
|
504
|
-
// Split into segments alternating non-style and style; only collapse
|
|
505
|
-
// whitespace in non-style segments. Style segments keep their formatting
|
|
506
|
-
// so we don't accidentally break CSS rules that span lines.
|
|
507
|
-
const STYLE_BLOCK_RE = /<style\b[\s\S]*?<\/style>/gi
|
|
508
|
-
const segments: { kind: 'plain' | 'style'; text: string }[] = []
|
|
509
|
-
let last = 0
|
|
510
|
-
let m: RegExpExecArray | null
|
|
511
|
-
while ((m = STYLE_BLOCK_RE.exec(svg)) !== null) {
|
|
512
|
-
if (m.index > last) segments.push({ kind: 'plain', text: svg.slice(last, m.index) })
|
|
513
|
-
segments.push({ kind: 'style', text: m[0] })
|
|
514
|
-
last = m.index + m[0].length
|
|
515
|
-
}
|
|
516
|
-
if (last < svg.length) segments.push({ kind: 'plain', text: svg.slice(last) })
|
|
517
|
-
|
|
518
|
-
return segments.map(seg => {
|
|
519
|
-
if (seg.kind === 'style') return seg.text
|
|
520
|
-
// Round numeric literals with 3+ fractional digits.
|
|
521
|
-
//
|
|
522
|
-
// The lookbehind allows SVG path command letters (MLHVCSQTAZ + lowercase)
|
|
523
|
-
// and structural punctuation as valid prefixes — they're how the renderer
|
|
524
|
-
// emits coords (`M78.6115`, `L 202.94`, `cx="50.5"`). It rejects word/
|
|
525
|
-
// dash chars otherwise so we don't touch identifier-like literals
|
|
526
|
-
// (e.g. `data-version="1.0.1234"`).
|
|
527
|
-
let out = seg.text.replace(/(?<=[MLHVCSQTAZmlhvcsqtaz\s,"'(=])(\d+\.\d{3,})/g, (_, num) => {
|
|
528
|
-
const n = parseFloat(num)
|
|
529
|
-
return String(roundCoord(n))
|
|
530
|
-
})
|
|
531
|
-
// Also catch the very first character of the string segment (no prefix).
|
|
532
|
-
out = out.replace(/^(\d+\.\d{3,})/, (_, num) => String(roundCoord(parseFloat(num))))
|
|
533
|
-
// Collapse newlines (the indentation `\n ` produced by string templates).
|
|
534
|
-
// Tabs and runs of spaces inside attribute values are not affected because
|
|
535
|
-
// attribute values are quoted and don't contain newlines in this renderer.
|
|
536
|
-
out = out.replace(/\n\s*/g, '')
|
|
537
|
-
return out
|
|
538
|
-
}).join('')
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/**
|
|
542
|
-
* #7540: namespace every SVG def id and its `url(#…)` references with a prefix,
|
|
543
|
-
* so multiple diagrams rendered onto one HTML page don't collide on shared def
|
|
544
|
-
* ids (`arrowhead`, `bm-shadow`, etc.). Node/subgraph groups use `data-id`, not
|
|
545
|
-
* `id`, so this only touches defs/markers/filters — exactly the colliding set.
|
|
546
|
-
*
|
|
547
|
-
* Pure string rewrite, deterministic. Idempotent only if the prefix isn't
|
|
548
|
-
* already applied (callers pass a fresh prefix per diagram). Skips the xmlns
|
|
549
|
-
* and any id that already starts with the prefix.
|
|
550
|
-
*/
|
|
551
|
-
export function namespaceSvgIds(svg: string, prefix: string): string {
|
|
552
|
-
if (!prefix) return svg
|
|
553
|
-
// Collect declared ids so we only rewrite refs that point at our defs
|
|
554
|
-
// (never an accidental `url(#…)` inside escaped label text).
|
|
555
|
-
const declared = new Set<string>()
|
|
556
|
-
for (const m of svg.matchAll(/\sid="([^"]+)"/g)) declared.add(m[1]!)
|
|
557
|
-
const namespaced = (id: string) => id.startsWith(prefix) ? id : `${prefix}${id}`
|
|
558
|
-
let out = svg.replace(/(\sid=")([^"]+)(")/g, (_full, pre, id: string, post) => `${pre}${namespaced(id)}${post}`)
|
|
559
|
-
out = out.replace(/url\(#([^)]+)\)/g, (full, id: string) => declared.has(id) ? `url(#${namespaced(id)})` : full)
|
|
560
|
-
out = out.replace(/(\saria-(?:labelledby|describedby)=")([^"]+)(")/g, (_full, pre, value: string, post) => {
|
|
561
|
-
const refs = value.split(/\s+/).map(id => declared.has(id) ? namespaced(id) : id)
|
|
562
|
-
return `${pre}${refs.join(' ')}${post}`
|
|
563
|
-
})
|
|
564
|
-
out = out.replace(/(\s(?:xlink:)?href=")#([^"]+)(")/g, (full, pre, id: string, post) =>
|
|
565
|
-
declared.has(id) ? `${pre}#${namespaced(id)}${post}` : full,
|
|
566
|
-
)
|
|
567
|
-
return out
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
function renderEdgeLabel(edge: PositionedEdge, font: string, style: ResolvedRenderStyle, sceneId: string): SceneNode {
|
|
571
|
-
// Use layout-computed label position when available (layout-aware, avoids collisions).
|
|
572
|
-
// Fall back to geometric midpoint of the edge polyline.
|
|
573
|
-
const mid = edge.labelPosition ?? edgeMidpoint(edge.points)
|
|
574
|
-
const label = applyTextTransform(edge.label!, style.edgeTextTransform)
|
|
575
|
-
const padding = 8
|
|
576
|
-
const strokeWidth = edge.style === 'thick' ? style.lineWidth * 2 : style.lineWidth
|
|
577
|
-
const haloPadding = padding + Math.max(4, strokeWidth * 2)
|
|
578
|
-
|
|
579
|
-
// Measure text (works for both single and multi-line)
|
|
580
|
-
const metrics = measureMultilineText(label, style.edgeLabelFontSize, style.edgeLabelFontWeight)
|
|
581
|
-
const haloWidth = metrics.width + haloPadding * 2
|
|
582
|
-
const haloHeight = metrics.height + haloPadding * 2
|
|
583
|
-
const haloX = mid.x - haloWidth / 2
|
|
584
|
-
const haloY = mid.y - haloHeight / 2
|
|
585
|
-
const halo = marks.shape({
|
|
586
|
-
id: `${sceneId}:halo`,
|
|
587
|
-
role: 'chrome',
|
|
588
|
-
geometry: { kind: 'rect', x: haloX, y: haloY, width: haloWidth, height: haloHeight, rx: 4, ry: 4 },
|
|
589
|
-
paint: { fill: 'var(--bg)', stroke: 'none' },
|
|
590
|
-
}, `<rect class="edge-label-halo" x="${haloX}" y="${haloY}" ` +
|
|
591
|
-
`width="${haloWidth}" height="${haloHeight}" rx="4" ry="4" fill="var(--bg)" stroke="none" />`)
|
|
592
|
-
|
|
593
|
-
// Wrap in <g class="edge-label"> with reference to the edge it belongs to
|
|
594
|
-
const labelTextColor = style.edgeTextColor ?? 'var(--_text-sec)'
|
|
595
|
-
const content = marks.text({
|
|
596
|
-
id: `${sceneId}:text`,
|
|
597
|
-
role: 'label',
|
|
598
|
-
text: label,
|
|
599
|
-
x: mid.x,
|
|
600
|
-
y: mid.y,
|
|
601
|
-
fontSize: style.edgeLabelFontSize,
|
|
602
|
-
anchor: 'middle',
|
|
603
|
-
paint: { fill: labelTextColor },
|
|
604
|
-
}, renderMultilineTextWithBackground(
|
|
605
|
-
label,
|
|
606
|
-
mid.x,
|
|
607
|
-
mid.y,
|
|
608
|
-
metrics.width,
|
|
609
|
-
metrics.height,
|
|
610
|
-
style.edgeLabelFontSize,
|
|
611
|
-
padding,
|
|
612
|
-
// Use --_text-sec for better contrast (was --_text-muted)
|
|
613
|
-
`text-anchor="middle" font-size="${style.edgeLabelFontSize}" font-weight="${style.edgeLabelFontWeight}"${style.edgeLetterSpacing !== 0 ? ` letter-spacing="${style.edgeLetterSpacing}"` : ''} fill="${escapeAttr(labelTextColor)}"`,
|
|
614
|
-
// Increased stroke width from 0.5 to 1 for better label separation from edges
|
|
615
|
-
`rx="2" ry="2" fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="1"`
|
|
616
|
-
))
|
|
617
|
-
|
|
618
|
-
// Semantic wrapper: links label to its edge via data-from/data-to
|
|
619
|
-
return marks.group({
|
|
620
|
-
id: sceneId,
|
|
621
|
-
role: 'edge-label',
|
|
622
|
-
open: `<g class="edge-label" data-from="${escapeAttr(edge.source)}" data-to="${escapeAttr(edge.target)}" data-label="${escapeAttr(edge.label!)}">`,
|
|
623
|
-
close: '</g>',
|
|
624
|
-
children: [
|
|
625
|
-
{ indent: 2, node: halo },
|
|
626
|
-
{ indent: 2, node: content },
|
|
627
|
-
],
|
|
628
|
-
})
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
/** Get the midpoint of a polyline (by walking segments) */
|
|
632
|
-
function edgeMidpoint(points: Point[]): Point {
|
|
633
|
-
if (points.length === 0) return { x: 0, y: 0 }
|
|
634
|
-
if (points.length === 1) return points[0]!
|
|
635
|
-
|
|
636
|
-
// Calculate total length
|
|
637
|
-
let totalLength = 0
|
|
638
|
-
for (let i = 1; i < points.length; i++) {
|
|
639
|
-
totalLength += dist(points[i - 1]!, points[i]!)
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// Walk to the halfway point
|
|
643
|
-
let remaining = totalLength / 2
|
|
644
|
-
for (let i = 1; i < points.length; i++) {
|
|
645
|
-
const segLen = dist(points[i - 1]!, points[i]!)
|
|
646
|
-
if (remaining <= segLen) {
|
|
647
|
-
const t = remaining / segLen
|
|
648
|
-
return {
|
|
649
|
-
x: points[i - 1]!.x + t * (points[i]!.x - points[i - 1]!.x),
|
|
650
|
-
y: points[i - 1]!.y + t * (points[i]!.y - points[i - 1]!.y),
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
remaining -= segLen
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
return points[points.length - 1]!
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function dist(a: Point, b: Point): number {
|
|
660
|
-
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2)
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// ============================================================================
|
|
664
|
-
// Node rendering
|
|
665
|
-
// ============================================================================
|
|
666
|
-
|
|
667
|
-
/**
|
|
668
|
-
* Render a complete node: shape + label wrapped in a semantic <g> element.
|
|
669
|
-
*
|
|
670
|
-
* The group includes data attributes for:
|
|
671
|
-
* - data-id: original Mermaid node ID (for edge matching)
|
|
672
|
-
* - data-label: display label text
|
|
673
|
-
* - data-shape: shape type (rectangle, diamond, circle, etc.)
|
|
674
|
-
*/
|
|
675
|
-
function renderNode(node: PositionedNode, font: string, style: ResolvedRenderStyle): SceneNode {
|
|
676
|
-
const shape = renderNodeShape(node, style)
|
|
677
|
-
const label = renderNodeLabel(node, font, style)
|
|
678
|
-
|
|
679
|
-
// Combine shape and label inside a semantic group
|
|
680
|
-
// This enables reliable node identification without heuristics
|
|
681
|
-
// #81: append user-assigned Mermaid class names so external stylesheets can
|
|
682
|
-
// target semantic node classes (e.g. `.hot { ... }`). Sanitize to valid CSS
|
|
683
|
-
// identifier chars; structural `node` class always comes first.
|
|
684
|
-
const userClasses = (node.classNames ?? [])
|
|
685
|
-
.map(c => c.replace(/[^A-Za-z0-9_-]/g, ''))
|
|
686
|
-
.filter(Boolean)
|
|
687
|
-
const classAttr = ['node', ...userClasses].join(' ')
|
|
688
|
-
const channels: SemanticChannels | undefined =
|
|
689
|
-
node.shape === 'state-start' ? { status: 'start' } :
|
|
690
|
-
node.shape === 'state-end' ? { status: 'end' } :
|
|
691
|
-
undefined
|
|
692
|
-
|
|
693
|
-
const children: Array<{ node: SceneNode; indent: number }> = [{ indent: 2, node: shape }]
|
|
694
|
-
if (label) {
|
|
695
|
-
children.push({ indent: 2, node: label })
|
|
696
|
-
}
|
|
697
|
-
return marks.group({
|
|
698
|
-
id: `node:${node.id}`,
|
|
699
|
-
role: 'node',
|
|
700
|
-
open: `<g class="${classAttr}" data-id="${escapeAttr(node.id)}" data-label="${escapeAttr(node.label)}" data-shape="${node.shape}">`,
|
|
701
|
-
close: '</g>',
|
|
702
|
-
children,
|
|
703
|
-
channels,
|
|
704
|
-
})
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
function renderNodeShape(node: PositionedNode, style: ResolvedRenderStyle): SceneNode {
|
|
708
|
-
const { x, y, width, height, shape, inlineStyle } = node
|
|
709
|
-
|
|
710
|
-
// Resolve fill and stroke — inline styles (from mermaid `style` directives)
|
|
711
|
-
// override the CSS variable defaults. When no inline style is present, the
|
|
712
|
-
// CSS variable handles theming automatically via color-mix() derivation.
|
|
713
|
-
const rawFill = inlineStyle?.fill ?? style.nodeFillColor ?? 'var(--_node-fill)'
|
|
714
|
-
const rawStroke = inlineStyle?.stroke ?? style.nodeBorderColor ?? 'var(--_node-stroke)'
|
|
715
|
-
const rawSw = inlineStyle?.['stroke-width'] ?? String(style.nodeLineWidth)
|
|
716
|
-
const fill = escapeAttr(rawFill)
|
|
717
|
-
const stroke = escapeAttr(rawStroke)
|
|
718
|
-
const sw = escapeAttr(rawSw)
|
|
719
|
-
|
|
720
|
-
const piece = ((): ShapePiece => {
|
|
721
|
-
switch (shape) {
|
|
722
|
-
case 'service':
|
|
723
|
-
return renderRect(x, y, width, height, fill, stroke, sw, style.cornerRadius ?? 0)
|
|
724
|
-
case 'diamond':
|
|
725
|
-
return renderDiamond(x, y, width, height, fill, stroke, sw)
|
|
726
|
-
case 'rounded':
|
|
727
|
-
return renderRoundedRect(x, y, width, height, fill, stroke, sw, style.cornerRadius ?? 6)
|
|
728
|
-
case 'stadium':
|
|
729
|
-
return renderStadium(x, y, width, height, fill, stroke, sw)
|
|
730
|
-
case 'circle':
|
|
731
|
-
return renderCircle(x, y, width, height, fill, stroke, sw)
|
|
732
|
-
case 'subroutine':
|
|
733
|
-
return renderSubroutine(x, y, width, height, fill, stroke, sw, style.cornerRadius ?? 0)
|
|
734
|
-
case 'doublecircle':
|
|
735
|
-
return renderDoubleCircle(x, y, width, height, fill, stroke, sw)
|
|
736
|
-
case 'hexagon':
|
|
737
|
-
return renderHexagon(x, y, width, height, fill, stroke, sw)
|
|
738
|
-
case 'cylinder':
|
|
739
|
-
return renderCylinder(x, y, width, height, fill, stroke, sw)
|
|
740
|
-
case 'asymmetric':
|
|
741
|
-
return renderAsymmetric(x, y, width, height, fill, stroke, sw)
|
|
742
|
-
case 'trapezoid':
|
|
743
|
-
return renderTrapezoid(x, y, width, height, fill, stroke, sw)
|
|
744
|
-
case 'trapezoid-alt':
|
|
745
|
-
return renderTrapezoidAlt(x, y, width, height, fill, stroke, sw)
|
|
746
|
-
case 'lean-r':
|
|
747
|
-
return renderLeanR(x, y, width, height, fill, stroke, sw)
|
|
748
|
-
case 'lean-l':
|
|
749
|
-
return renderLeanL(x, y, width, height, fill, stroke, sw)
|
|
750
|
-
case 'state-start':
|
|
751
|
-
return renderStateStart(x, y, width, height)
|
|
752
|
-
case 'state-end':
|
|
753
|
-
return renderStateEnd(x, y, width, height)
|
|
754
|
-
case 'rectangle':
|
|
755
|
-
default:
|
|
756
|
-
return renderRect(x, y, width, height, fill, stroke, sw, style.cornerRadius ?? 0)
|
|
757
|
-
}
|
|
758
|
-
})()
|
|
759
|
-
|
|
760
|
-
const paint = shape === 'state-start'
|
|
761
|
-
? { fill: 'var(--_text)', stroke: 'none' }
|
|
762
|
-
: shape === 'state-end'
|
|
763
|
-
? { fill: 'var(--_text)', stroke: 'var(--_text)' }
|
|
764
|
-
: { fill: rawFill, stroke: rawStroke, strokeWidth: rawSw }
|
|
765
|
-
|
|
766
|
-
return marks.shape({
|
|
767
|
-
id: `node-shape:${node.id}`,
|
|
768
|
-
role: 'node',
|
|
769
|
-
geometry: piece.geometry,
|
|
770
|
-
paint,
|
|
771
|
-
}, piece.crisp)
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// --- Basic shapes ---
|
|
775
|
-
|
|
776
|
-
function renderRect(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string, radius: number = 0): ShapePiece {
|
|
777
|
-
return {
|
|
778
|
-
geometry: { kind: 'rect', x, y, width: w, height: h, rx: radius, ry: radius },
|
|
779
|
-
crisp:
|
|
780
|
-
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
|
|
781
|
-
`rx="${radius}" ry="${radius}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function renderRoundedRect(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string, radius: number = 6): ShapePiece {
|
|
786
|
-
return {
|
|
787
|
-
geometry: { kind: 'rect', x, y, width: w, height: h, rx: radius, ry: radius },
|
|
788
|
-
crisp:
|
|
789
|
-
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
|
|
790
|
-
`rx="${radius}" ry="${radius}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
function renderStadium(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
795
|
-
const r = h / 2
|
|
796
|
-
return {
|
|
797
|
-
geometry: { kind: 'rect', x, y, width: w, height: h, rx: r, ry: r },
|
|
798
|
-
crisp:
|
|
799
|
-
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
|
|
800
|
-
`rx="${r}" ry="${r}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
function renderCircle(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
805
|
-
const cx = x + w / 2
|
|
806
|
-
const cy = y + h / 2
|
|
807
|
-
const r = Math.min(w, h) / 2
|
|
808
|
-
return {
|
|
809
|
-
geometry: { kind: 'circle', cx, cy, r },
|
|
810
|
-
crisp:
|
|
811
|
-
`<circle cx="${cx}" cy="${cy}" r="${r}" ` +
|
|
812
|
-
`fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
function renderDiamond(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
817
|
-
const cx = x + w / 2
|
|
818
|
-
const cy = y + h / 2
|
|
819
|
-
const hw = w / 2
|
|
820
|
-
const hh = h / 2
|
|
821
|
-
const pts = [
|
|
822
|
-
{ x: cx, y: cy - hh }, // top
|
|
823
|
-
{ x: cx + hw, y: cy }, // right
|
|
824
|
-
{ x: cx, y: cy + hh }, // bottom
|
|
825
|
-
{ x: cx - hw, y: cy }, // left
|
|
826
|
-
]
|
|
827
|
-
const points = pts.map(p => `${p.x},${p.y}`).join(' ')
|
|
828
|
-
|
|
829
|
-
return {
|
|
830
|
-
geometry: { kind: 'polygon', points: pts },
|
|
831
|
-
crisp: `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// --- Batch 1 shapes ---
|
|
836
|
-
|
|
837
|
-
/** Subroutine: rectangle with double vertical borders on left and right */
|
|
838
|
-
function renderSubroutine(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string, radius: number = 0): ShapePiece {
|
|
839
|
-
const inset = 8 // distance from edge to inner vertical line
|
|
840
|
-
return {
|
|
841
|
-
geometry: {
|
|
842
|
-
kind: 'compound',
|
|
843
|
-
children: [
|
|
844
|
-
{ kind: 'rect', x, y, width: w, height: h, rx: radius, ry: radius },
|
|
845
|
-
{ kind: 'line', x1: x + inset, y1: y, x2: x + inset, y2: y + h },
|
|
846
|
-
{ kind: 'line', x1: x + w - inset, y1: y, x2: x + w - inset, y2: y + h },
|
|
847
|
-
],
|
|
848
|
-
},
|
|
849
|
-
crisp:
|
|
850
|
-
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
|
|
851
|
-
`rx="${radius}" ry="${radius}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />` +
|
|
852
|
-
`\n<line x1="${x + inset}" y1="${y}" x2="${x + inset}" y2="${y + h}" ` +
|
|
853
|
-
`stroke="${stroke}" stroke-width="${sw}" />` +
|
|
854
|
-
`\n<line x1="${x + w - inset}" y1="${y}" x2="${x + w - inset}" y2="${y + h}" ` +
|
|
855
|
-
`stroke="${stroke}" stroke-width="${sw}" />`,
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
/** Double circle: two concentric circles with a gap between them */
|
|
860
|
-
function renderDoubleCircle(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
861
|
-
const cx = x + w / 2
|
|
862
|
-
const cy = y + h / 2
|
|
863
|
-
const outerR = Math.min(w, h) / 2
|
|
864
|
-
const innerR = outerR - 5 // 5px gap between rings
|
|
865
|
-
return {
|
|
866
|
-
geometry: {
|
|
867
|
-
kind: 'compound',
|
|
868
|
-
children: [
|
|
869
|
-
{ kind: 'circle', cx, cy, r: outerR },
|
|
870
|
-
{ kind: 'circle', cx, cy, r: innerR },
|
|
871
|
-
],
|
|
872
|
-
},
|
|
873
|
-
crisp:
|
|
874
|
-
`<circle cx="${cx}" cy="${cy}" r="${outerR}" ` +
|
|
875
|
-
`fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />` +
|
|
876
|
-
`\n<circle cx="${cx}" cy="${cy}" r="${innerR}" ` +
|
|
877
|
-
`fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
/** Hexagon: 6-point polygon with flat top/bottom and angled sides */
|
|
882
|
-
function renderHexagon(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
883
|
-
const inset = h / 4 // horizontal inset for the angled sides
|
|
884
|
-
const pts = [
|
|
885
|
-
{ x: x + inset, y }, // top-left
|
|
886
|
-
{ x: x + w - inset, y }, // top-right
|
|
887
|
-
{ x: x + w, y: y + h / 2 }, // mid-right
|
|
888
|
-
{ x: x + w - inset, y: y + h }, // bottom-right
|
|
889
|
-
{ x: x + inset, y: y + h }, // bottom-left
|
|
890
|
-
{ x, y: y + h / 2 }, // mid-left
|
|
891
|
-
]
|
|
892
|
-
const points = pts.map(p => `${p.x},${p.y}`).join(' ')
|
|
893
|
-
|
|
894
|
-
return {
|
|
895
|
-
geometry: { kind: 'polygon', points: pts },
|
|
896
|
-
crisp: `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// --- Batch 2 shapes ---
|
|
901
|
-
|
|
902
|
-
/** Cylinder / database: top ellipse cap + body rect + bottom ellipse */
|
|
903
|
-
function renderCylinder(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
904
|
-
const ry = 7 // ellipse vertical radius for the cap
|
|
905
|
-
const cx = x + w / 2
|
|
906
|
-
const bodyTop = y + ry
|
|
907
|
-
const bodyH = h - 2 * ry
|
|
908
|
-
|
|
909
|
-
return {
|
|
910
|
-
geometry: {
|
|
911
|
-
kind: 'compound',
|
|
912
|
-
children: [
|
|
913
|
-
{ kind: 'rect', x, y: bodyTop, width: w, height: bodyH },
|
|
914
|
-
{ kind: 'line', x1: x, y1: bodyTop, x2: x, y2: bodyTop + bodyH },
|
|
915
|
-
{ kind: 'line', x1: x + w, y1: bodyTop, x2: x + w, y2: bodyTop + bodyH },
|
|
916
|
-
{ kind: 'ellipse', cx, cy: y + h - ry, rx: w / 2, ry },
|
|
917
|
-
{ kind: 'ellipse', cx, cy: bodyTop, rx: w / 2, ry },
|
|
918
|
-
],
|
|
919
|
-
},
|
|
920
|
-
crisp: (
|
|
921
|
-
// Body rectangle (no top border — covered by top ellipse)
|
|
922
|
-
`<rect x="${x}" y="${bodyTop}" width="${w}" height="${bodyH}" ` +
|
|
923
|
-
`fill="${fill}" stroke="none" />` +
|
|
924
|
-
// Left and right body borders
|
|
925
|
-
`\n<line x1="${x}" y1="${bodyTop}" x2="${x}" y2="${bodyTop + bodyH}" stroke="${stroke}" stroke-width="${sw}" />` +
|
|
926
|
-
`\n<line x1="${x + w}" y1="${bodyTop}" x2="${x + w}" y2="${bodyTop + bodyH}" stroke="${stroke}" stroke-width="${sw}" />` +
|
|
927
|
-
// Bottom ellipse (half visible)
|
|
928
|
-
`\n<ellipse cx="${cx}" cy="${y + h - ry}" rx="${w / 2}" ry="${ry}" ` +
|
|
929
|
-
`fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />` +
|
|
930
|
-
// Top ellipse (full, on top)
|
|
931
|
-
`\n<ellipse cx="${cx}" cy="${bodyTop}" rx="${w / 2}" ry="${ry}" ` +
|
|
932
|
-
`fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
933
|
-
),
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
/** Asymmetric / flag: rectangle with a pointed left edge */
|
|
938
|
-
function renderAsymmetric(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
939
|
-
const indent = 12 // how far the point indents
|
|
940
|
-
const pts = [
|
|
941
|
-
{ x: x + indent, y }, // top-left (indented)
|
|
942
|
-
{ x: x + w, y }, // top-right
|
|
943
|
-
{ x: x + w, y: y + h }, // bottom-right
|
|
944
|
-
{ x: x + indent, y: y + h }, // bottom-left (indented)
|
|
945
|
-
{ x, y: y + h / 2 }, // left point
|
|
946
|
-
]
|
|
947
|
-
const points = pts.map(p => `${p.x},${p.y}`).join(' ')
|
|
948
|
-
|
|
949
|
-
return {
|
|
950
|
-
geometry: { kind: 'polygon', points: pts },
|
|
951
|
-
crisp: `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/** Trapezoid [/text\]: wider bottom, narrower top */
|
|
956
|
-
function renderTrapezoid(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
957
|
-
const inset = w * 0.15 // top edge is narrower by this amount on each side
|
|
958
|
-
const pts = [
|
|
959
|
-
{ x: x + inset, y }, // top-left (indented)
|
|
960
|
-
{ x: x + w - inset, y }, // top-right (indented)
|
|
961
|
-
{ x: x + w, y: y + h }, // bottom-right (full width)
|
|
962
|
-
{ x, y: y + h }, // bottom-left (full width)
|
|
963
|
-
]
|
|
964
|
-
const points = pts.map(p => `${p.x},${p.y}`).join(' ')
|
|
965
|
-
|
|
966
|
-
return {
|
|
967
|
-
geometry: { kind: 'polygon', points: pts },
|
|
968
|
-
crisp: `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
/** Trapezoid-alt [\text/]: wider top, narrower bottom */
|
|
973
|
-
function renderTrapezoidAlt(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
974
|
-
const inset = w * 0.15 // bottom edge is narrower
|
|
975
|
-
const pts = [
|
|
976
|
-
{ x, y }, // top-left (full width)
|
|
977
|
-
{ x: x + w, y }, // top-right (full width)
|
|
978
|
-
{ x: x + w - inset, y: y + h }, // bottom-right (indented)
|
|
979
|
-
{ x: x + inset, y: y + h }, // bottom-left (indented)
|
|
980
|
-
]
|
|
981
|
-
const points = pts.map(p => `${p.x},${p.y}`).join(' ')
|
|
982
|
-
|
|
983
|
-
return {
|
|
984
|
-
geometry: { kind: 'polygon', points: pts },
|
|
985
|
-
crisp: `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/** Parallelogram [/text/]: leans right (top edge shifted right) */
|
|
990
|
-
function renderLeanR(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
991
|
-
const inset = w * 0.15 // horizontal shear of the slanted sides
|
|
992
|
-
const pts = [
|
|
993
|
-
{ x: x + inset, y }, // top-left (shifted right)
|
|
994
|
-
{ x: x + w, y }, // top-right (full width)
|
|
995
|
-
{ x: x + w - inset, y: y + h }, // bottom-right (shifted left)
|
|
996
|
-
{ x, y: y + h }, // bottom-left (full width)
|
|
997
|
-
]
|
|
998
|
-
const points = pts.map(p => `${p.x},${p.y}`).join(' ')
|
|
999
|
-
|
|
1000
|
-
return {
|
|
1001
|
-
geometry: { kind: 'polygon', points: pts },
|
|
1002
|
-
crisp: `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
/** Parallelogram [\text\]: leans left (top edge shifted left) */
|
|
1007
|
-
function renderLeanL(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): ShapePiece {
|
|
1008
|
-
const inset = w * 0.15 // horizontal shear of the slanted sides
|
|
1009
|
-
const pts = [
|
|
1010
|
-
{ x, y }, // top-left (full width)
|
|
1011
|
-
{ x: x + w - inset, y }, // top-right (shifted left)
|
|
1012
|
-
{ x: x + w, y: y + h }, // bottom-right (full width)
|
|
1013
|
-
{ x: x + inset, y: y + h }, // bottom-left (shifted right)
|
|
1014
|
-
]
|
|
1015
|
-
const points = pts.map(p => `${p.x},${p.y}`).join(' ')
|
|
1016
|
-
|
|
1017
|
-
return {
|
|
1018
|
-
geometry: { kind: 'polygon', points: pts },
|
|
1019
|
-
crisp: `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// --- Batch 3: State diagram pseudostates ---
|
|
1024
|
-
|
|
1025
|
-
/** State start: small filled circle using primary text color */
|
|
1026
|
-
function renderStateStart(x: number, y: number, w: number, h: number): ShapePiece {
|
|
1027
|
-
const cx = x + w / 2
|
|
1028
|
-
const cy = y + h / 2
|
|
1029
|
-
const r = Math.min(w, h) / 2 - 2
|
|
1030
|
-
return {
|
|
1031
|
-
geometry: { kind: 'circle', cx, cy, r },
|
|
1032
|
-
crisp: `<circle cx="${cx}" cy="${cy}" r="${r}" fill="var(--_text)" stroke="none" />`,
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
/** State end: bullseye — outer ring + inner filled circle using primary text color */
|
|
1037
|
-
function renderStateEnd(x: number, y: number, w: number, h: number): ShapePiece {
|
|
1038
|
-
const cx = x + w / 2
|
|
1039
|
-
const cy = y + h / 2
|
|
1040
|
-
const outerR = Math.min(w, h) / 2 - 2
|
|
1041
|
-
const innerR = outerR - 4
|
|
1042
|
-
return {
|
|
1043
|
-
geometry: {
|
|
1044
|
-
kind: 'compound',
|
|
1045
|
-
children: [
|
|
1046
|
-
{ kind: 'circle', cx, cy, r: outerR },
|
|
1047
|
-
{ kind: 'circle', cx, cy, r: innerR },
|
|
1048
|
-
],
|
|
1049
|
-
},
|
|
1050
|
-
crisp:
|
|
1051
|
-
`<circle cx="${cx}" cy="${cy}" r="${outerR}" ` +
|
|
1052
|
-
`fill="none" stroke="var(--_text)" stroke-width="${STROKE_WIDTHS.innerBox * 2}" />` +
|
|
1053
|
-
`\n<circle cx="${cx}" cy="${cy}" r="${innerR}" fill="var(--_text)" stroke="none" />`,
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
// ============================================================================
|
|
1058
|
-
// Node label rendering
|
|
1059
|
-
// ============================================================================
|
|
1060
|
-
|
|
1061
|
-
function renderNodeLabel(node: PositionedNode, font: string, style: ResolvedRenderStyle): SceneNode | null {
|
|
1062
|
-
// State pseudostates have no label
|
|
1063
|
-
if (node.shape === 'state-start' || node.shape === 'state-end') {
|
|
1064
|
-
if (!node.label) return null
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
const cx = node.x + node.width / 2
|
|
1068
|
-
const cy = node.y + node.height / 2
|
|
1069
|
-
|
|
1070
|
-
const rawTextColor = resolveInlineNodeTextColor(node.inlineStyle, style.nodeTextColor ?? 'var(--_text)')
|
|
1071
|
-
const textColor = escapeAttr(rawTextColor)
|
|
1072
|
-
const label = applyTextTransform(node.label, style.nodeTextTransform)
|
|
1073
|
-
|
|
1074
|
-
return marks.text({
|
|
1075
|
-
id: `node-label:${node.id}`,
|
|
1076
|
-
role: 'label',
|
|
1077
|
-
text: label,
|
|
1078
|
-
x: cx,
|
|
1079
|
-
y: cy,
|
|
1080
|
-
fontSize: style.nodeLabelFontSize,
|
|
1081
|
-
anchor: 'middle',
|
|
1082
|
-
paint: { fill: rawTextColor },
|
|
1083
|
-
}, renderMultilineText(
|
|
1084
|
-
label,
|
|
1085
|
-
cx,
|
|
1086
|
-
cy,
|
|
1087
|
-
style.nodeLabelFontSize,
|
|
1088
|
-
`text-anchor="middle" font-size="${style.nodeLabelFontSize}" font-weight="${style.nodeLabelFontWeight}"${style.nodeLetterSpacing !== 0 ? ` letter-spacing="${style.nodeLetterSpacing}"` : ''} fill="${textColor}"`
|
|
1089
|
-
))
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
// ============================================================================
|
|
1093
|
-
// Utilities
|
|
1094
|
-
// ============================================================================
|
|
1095
|
-
|
|
1096
|
-
/**
|
|
1097
|
-
* Escape a string for use as an XML/HTML attribute value.
|
|
1098
|
-
* Escapes quotes and ampersands to prevent attribute injection.
|
|
1099
|
-
*/
|