mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 01:59:46 -04:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82370fdd63 | |||
| e707ffa84f | |||
| 77faeea584 | |||
| b31cec37e3 | |||
| 2c80a77906 | |||
| ec0e5373e1 | |||
| 334b278547 | |||
| 9b7b402737 | |||
| 5e6370363b | |||
| dd6020656e | |||
| 8c758e443b | |||
| 0df6aed6ca | |||
| aa05fd23b3 | |||
| fd09760903 | |||
| 3a4772abb4 | |||
| 2cf65c025a | |||
| f3f1204802 | |||
| b3aea1c39d | |||
| 79a493e7f5 | |||
| 952d4d446f |
@@ -571,6 +571,20 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.8",
|
||||
@@ -679,6 +693,7 @@
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"modal": "0.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -881,6 +896,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/merman": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
@@ -1512,6 +1528,18 @@
|
||||
|
||||
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
|
||||
|
||||
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
|
||||
@@ -1720,6 +1748,10 @@
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
|
||||
@@ -1814,6 +1846,8 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||
|
||||
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
|
||||
|
||||
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
|
||||
@@ -2064,6 +2098,8 @@
|
||||
|
||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
|
||||
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
@@ -3298,6 +3334,8 @@
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
@@ -3538,6 +3576,10 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||
|
||||
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
|
||||
|
||||
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
@@ -4570,6 +4612,8 @@
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||
|
||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
@@ -4796,6 +4840,8 @@
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
|
||||
|
||||
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
||||
|
||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||
@@ -4834,6 +4880,10 @@
|
||||
|
||||
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
|
||||
|
||||
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
|
||||
|
||||
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
|
||||
|
||||
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
|
||||
|
||||
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
|
||||
@@ -5632,6 +5682,8 @@
|
||||
|
||||
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
|
||||
|
||||
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||
@@ -6590,6 +6642,8 @@
|
||||
|
||||
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||
|
||||
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
|
||||
|
||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
@@ -6734,6 +6788,8 @@
|
||||
|
||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
||||
|
||||
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
||||
|
||||
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
@@ -198,8 +198,20 @@ const responseBody = (body: string | void, request: HttpClientRequest.HttpClient
|
||||
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
||||
}
|
||||
|
||||
const decodeProviderBody = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
||||
if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
|
||||
if (body.body && body.body.length <= 500) {
|
||||
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
|
||||
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
|
||||
}
|
||||
return `Provider request failed with HTTP ${status}`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Workspace } from "@opencode-ai/console-core/workspace.js"
|
||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import z from "zod"
|
||||
|
||||
const Body = z.object({ workspaceID: z.string().startsWith("wrk_") })
|
||||
|
||||
export async function POST(event: APIEvent) {
|
||||
if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = Body.safeParse(await event.request.json().catch(() => undefined))
|
||||
if (!body.success) {
|
||||
return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 })
|
||||
}
|
||||
return Workspace.unblock(body.data.workspaceID)
|
||||
.then(() => Response.json({ success: true, message: "Workspace unblocked" }))
|
||||
.catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }))
|
||||
}
|
||||
@@ -103,4 +103,16 @@ export namespace Workspace {
|
||||
.where(eq(WorkspaceTable.id, Actor.workspace())),
|
||||
)
|
||||
})
|
||||
|
||||
export const unblock = fn(z.string().startsWith("wrk_"), async (workspaceID) => {
|
||||
await Database.transaction(async (tx) => {
|
||||
const workspace = await tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.then((rows) => rows[0])
|
||||
if (!workspace) throw new Error("Workspace not found")
|
||||
await tx.update(WorkspaceTable).set({ is_blocked: false }).where(eq(WorkspaceTable.id, workspaceID))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"exports": {
|
||||
"./environment": "./src/environment/index.ts",
|
||||
"./testing/environment-conformance": "./test/lib/environment-conformance.ts",
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./instructions": "./src/instructions/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
|
||||
+60
-105
@@ -1,15 +1,11 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "2d214a71-3b0a-48c1-a667-741952c4e188",
|
||||
"id": "2aefa7b6-6847-4490-96e8-6680159f757c",
|
||||
"prevIds": [
|
||||
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
|
||||
"6ff49c08-7759-48fd-beca-6086853fce79"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "account_state",
|
||||
"entityType": "tables"
|
||||
@@ -75,84 +71,8 @@
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "type",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''",
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "branch",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "directory",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "extra",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "project_id",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_used",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1385,18 +1305,53 @@
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_workspace_project_id_project_id_fk",
|
||||
"entityType": "fks",
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "provider",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "binding",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "created_at",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "last_used_at",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
@@ -1564,15 +1519,6 @@
|
||||
"entityType": "pks",
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "workspace_pk",
|
||||
"table": "workspace",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
@@ -1690,6 +1636,15 @@
|
||||
"table": "session_v2",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "workspace_pk",
|
||||
"table": "workspace",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
export * as Account from "./account"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import type { HttpClientError } from "effect/unstable/http"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("AccountID"))
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
|
||||
export type OrgID = Schema.Schema.Type<typeof OrgID>
|
||||
|
||||
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
|
||||
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
|
||||
|
||||
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
|
||||
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
|
||||
|
||||
export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
|
||||
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
|
||||
|
||||
export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
|
||||
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
||||
|
||||
export class Info extends Schema.Class<Info>("Account")({
|
||||
id: ID,
|
||||
email: Schema.String,
|
||||
url: Schema.String,
|
||||
active_org_id: Schema.NullOr(OrgID),
|
||||
}) {}
|
||||
|
||||
export class Org extends Schema.Class<Org>("Org")({
|
||||
id: OrgID,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {
|
||||
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
|
||||
return new AccountTransportError({
|
||||
method: error.request.method,
|
||||
url: error.request.url,
|
||||
description: error.description,
|
||||
cause: error.cause,
|
||||
})
|
||||
}
|
||||
|
||||
override get message(): string {
|
||||
return [
|
||||
`Could not reach ${this.method} ${this.url}.`,
|
||||
`This failed before the server returned an HTTP response.`,
|
||||
this.description,
|
||||
`Check your network, proxy, or VPN configuration and try again.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError
|
||||
|
||||
export class Login extends Schema.Class<Login>("Login")({
|
||||
code: DeviceCode,
|
||||
user: UserCode,
|
||||
url: Schema.String,
|
||||
server: Schema.String,
|
||||
expiry: Schema.Duration,
|
||||
interval: Schema.Duration,
|
||||
}) {}
|
||||
|
||||
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
|
||||
|
||||
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
|
||||
|
||||
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
|
||||
|
||||
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
|
||||
|
||||
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
|
||||
export type PollResult = Schema.Schema.Type<typeof PollResult>
|
||||
@@ -1,24 +1,21 @@
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
|
||||
import { Account } from "../account"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
|
||||
export const AccountTable = sqliteTable("account", {
|
||||
id: text().$type<Account.ID>().primaryKey(),
|
||||
id: text().primaryKey(),
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().$type<Account.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<Account.RefreshToken>().notNull(),
|
||||
access_token: text().notNull(),
|
||||
refresh_token: text().notNull(),
|
||||
token_expiry: integer(),
|
||||
...Timestamps,
|
||||
})
|
||||
|
||||
export const AccountStateTable = sqliteTable("account_state", {
|
||||
id: integer().primaryKey(),
|
||||
active_account_id: text()
|
||||
.$type<Account.ID>()
|
||||
.references(() => AccountTable.id, { onDelete: "set null" }),
|
||||
active_org_id: text().$type<Account.OrgID>(),
|
||||
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
|
||||
active_org_id: text(),
|
||||
})
|
||||
|
||||
// LEGACY
|
||||
@@ -27,8 +24,8 @@ export const ControlAccountTable = sqliteTable(
|
||||
{
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().$type<Account.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<Account.RefreshToken>().notNull(),
|
||||
access_token: text().notNull(),
|
||||
refresh_token: text().notNull(),
|
||||
token_expiry: integer(),
|
||||
active: integer({ mode: "boolean" })
|
||||
.notNull()
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||
import { ProjectTable } from "../project/sql"
|
||||
import { Project } from "../project"
|
||||
import { Workspace } from "../workspace"
|
||||
|
||||
export const WorkspaceTable = sqliteTable("workspace", {
|
||||
id: text().$type<Workspace.ID>().primaryKey(),
|
||||
type: text().notNull(),
|
||||
name: text().notNull().default(""),
|
||||
branch: text(),
|
||||
directory: text(),
|
||||
extra: text({ mode: "json" }),
|
||||
project_id: text()
|
||||
.$type<Project.ID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
time_used: integer()
|
||||
.notNull()
|
||||
.$default(() => Date.now()),
|
||||
})
|
||||
+1
@@ -42,5 +42,6 @@ export const migrations: DatabaseMigration.Migration[] = (
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
import("./migration/20260804233008_loose_psylocke"),
|
||||
import("./migration/20260805200742_import_legacy_credentials"),
|
||||
import("./migration/20260808023530_workspace_domain"),
|
||||
])
|
||||
).map((module) => module.default)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260808023530_workspace_domain",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DROP TABLE \`workspace\`;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`workspace\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`provider\` text NOT NULL,
|
||||
\`binding\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`last_used_at\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -4,19 +4,6 @@ import type { DatabaseMigration } from "./migration"
|
||||
const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`workspace\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`type\` text NOT NULL,
|
||||
\`name\` text DEFAULT '' NOT NULL,
|
||||
\`branch\` text,
|
||||
\`directory\` text,
|
||||
\`extra\` text,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`time_used\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`account_state\` (
|
||||
\`id\` integer PRIMARY KEY,
|
||||
@@ -216,6 +203,15 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`workspace\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`provider\` text NOT NULL,
|
||||
\`binding\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`last_used_at\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner
|
||||
import type { Files } from "./files"
|
||||
import { makeFiles } from "./index"
|
||||
import { makeLocalDriver } from "./local"
|
||||
import { Location } from "../location"
|
||||
import { Workspace } from "../workspace"
|
||||
|
||||
export interface Interface {
|
||||
readonly files: Files
|
||||
@@ -17,10 +19,25 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
return Service.of({ files: makeFiles(makeLocalDriver(spawner)), spawner })
|
||||
const location = yield* Location.Service
|
||||
const workspace = yield* Workspace.Service
|
||||
const driver = location.workspaceID
|
||||
? yield* workspace.connect(location.workspaceID).pipe(
|
||||
// Environment has no error channel; an unknown or destroyed placement is a configuration defect by design.
|
||||
Effect.mapError(
|
||||
(cause) => new Error(`Failed to bind Environment to workspace ${location.workspaceID}`, { cause }),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
: makeLocalDriver(spawner)
|
||||
return Service.of({ files: makeFiles(driver), spawner: driver.spawner })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [CrossSpawnSpawner.node] })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [CrossSpawnSpawner.node, Location.node, Workspace.node],
|
||||
})
|
||||
|
||||
export * as EnvironmentService from "./environment"
|
||||
|
||||
@@ -16,7 +16,6 @@ import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import { Slug } from "../util/slug"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { SessionSchema } from "./schema"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
@@ -376,13 +375,6 @@ const layer = Layer.effectDiscard(
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||
if (!event.data.location.workspaceID) return
|
||||
yield* db
|
||||
.update(WorkspaceTable)
|
||||
.set({ time_used: Date.now() })
|
||||
.where(eq(WorkspaceTable.id, event.data.location.workspaceID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Moved, (event) =>
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as PatchTool from "./patch"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
@@ -15,6 +14,7 @@ import { Location } from "../../location"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission"
|
||||
import DESCRIPTION from "../patch.txt"
|
||||
import { fileDiff } from "./file-diff"
|
||||
|
||||
export const name = "patch"
|
||||
|
||||
@@ -353,22 +353,16 @@ function errorMessage(error: unknown) {
|
||||
|
||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||
const patch = trimDiff(createTwoFilesPatch(change.target.absolute, change.target.absolute, change.before, after))
|
||||
const counts =
|
||||
change.type === "delete"
|
||||
? { additions: 0, deletions: change.before.split("\n").length }
|
||||
: diffLines(change.before, after).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
const diff = fileDiff(
|
||||
change.target.absolute,
|
||||
change.before,
|
||||
after,
|
||||
change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
|
||||
)
|
||||
return {
|
||||
...diff,
|
||||
file: target,
|
||||
patch,
|
||||
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
|
||||
...counts,
|
||||
patch: trimDiff(diff.patch),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,219 @@
|
||||
export * as Workspace from "./workspace"
|
||||
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Clock, Context, Duration, Effect, Exit, Layer, Ref, Schedule, Schema, Scope } from "effect"
|
||||
import { systemError } from "effect/PlatformError"
|
||||
import { make } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Driver as EnvironmentDriver } from "./environment/driver"
|
||||
import { Database } from "./database/database"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { WorkspaceDriver } from "./workspace/driver"
|
||||
import { WorkspaceTable } from "./workspace/sql"
|
||||
|
||||
export const ID = Workspace.ID
|
||||
export type ID = typeof ID.Type
|
||||
export type ID = Workspace.ID
|
||||
|
||||
export class Info extends Schema.Class<Info>("Workspace.Info")({
|
||||
id: ID,
|
||||
provider: Schema.String,
|
||||
binding: WorkspaceDriver.Binding,
|
||||
createdAt: Schema.Number,
|
||||
lastUsedAt: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Workspace.NotFound", { workspaceID: ID }) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (provider: string) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
|
||||
readonly connect: (
|
||||
workspaceID: ID,
|
||||
) => Effect.Effect<EnvironmentDriver, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
|
||||
readonly destroy: (
|
||||
workspaceID: ID,
|
||||
) => Effect.Effect<void, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
readonly idleThreshold?: Duration.Input
|
||||
readonly pollInterval?: Duration.Input
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
|
||||
|
||||
interface Connection {
|
||||
readonly driver: WorkspaceDriver.Interface
|
||||
readonly environment: EnvironmentDriver
|
||||
readonly saveBinding: (binding: WorkspaceDriver.Binding) => Effect.Effect<void>
|
||||
readonly lastActivity: Ref.Ref<number>
|
||||
readonly active: Ref.Ref<number>
|
||||
readonly scope: Scope.Closeable
|
||||
}
|
||||
|
||||
export const configured = (options: Options = {}) =>
|
||||
makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Database.node, WorkspaceDriver.node],
|
||||
})
|
||||
|
||||
const layer = (options: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const registry = yield* WorkspaceDriver.RegistryService
|
||||
const lifetime = yield* Scope.Scope
|
||||
const connections = new Map<ID, Connection>()
|
||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||
const idleThreshold = Duration.toMillis(options.idleThreshold ?? Duration.minutes(20))
|
||||
|
||||
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new NotFound({ workspaceID })
|
||||
return row
|
||||
})
|
||||
|
||||
const open = Effect.fn("Workspace.open")(function* (workspaceID: ID) {
|
||||
const existing = connections.get(workspaceID)
|
||||
if (existing) return existing
|
||||
|
||||
const row = yield* load(workspaceID)
|
||||
const driver = yield* registry.get(row.provider)
|
||||
const saveBinding = (value: WorkspaceDriver.Binding) =>
|
||||
db
|
||||
.update(WorkspaceTable)
|
||||
.set({ binding: value })
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const scope = yield* Scope.fork(lifetime)
|
||||
const environment = yield* driver.connect({ workspaceID, binding: row.binding, saveBinding }).pipe(
|
||||
Effect.provideService(Scope.Scope, scope),
|
||||
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
|
||||
)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const connection: Connection = {
|
||||
driver,
|
||||
environment,
|
||||
saveBinding,
|
||||
lastActivity: yield* Ref.make(now),
|
||||
active: yield* Ref.make(0),
|
||||
scope,
|
||||
}
|
||||
connections.set(workspaceID, connection)
|
||||
yield* db
|
||||
.update(WorkspaceTable)
|
||||
.set({ last_used_at: now })
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return connection
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
yield* Effect.forEach(
|
||||
[...connections.entries()],
|
||||
([workspaceID, expected]) =>
|
||||
locks.withLock(workspaceID)(
|
||||
Effect.gen(function* () {
|
||||
const connection = connections.get(workspaceID)
|
||||
if (connection !== expected || (yield* Ref.get(connection.active)) > 0) return
|
||||
const lastActivity = yield* Ref.get(connection.lastActivity)
|
||||
if (now - lastActivity < idleThreshold) return
|
||||
const row = yield* load(workspaceID)
|
||||
// Deliberate: a racing spawn blocks, then wakes cleanly. Unlocking mid-suspend could reattach a sandbox being terminated.
|
||||
yield* connection.driver.suspendForIdle({
|
||||
workspaceID,
|
||||
binding: row.binding,
|
||||
saveBinding: connection.saveBinding,
|
||||
})
|
||||
yield* db
|
||||
.update(WorkspaceTable)
|
||||
.set({ last_used_at: lastActivity })
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
connections.delete(workspaceID)
|
||||
yield* Scope.close(connection.scope, Exit.void)
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("workspace idle suspension failed", cause))),
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
}).pipe(Effect.repeat(Schedule.spaced(options.pollInterval ?? Duration.minutes(1))), Effect.forkScoped)
|
||||
|
||||
return Service.of({
|
||||
create: Effect.fn("Workspace.create")(function* (provider) {
|
||||
const driver = yield* registry.get(provider)
|
||||
const workspaceID = ID.create()
|
||||
const result = yield* driver.create({ workspaceID })
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
yield* db
|
||||
.insert(WorkspaceTable)
|
||||
.values({ id: workspaceID, provider, binding: result.binding, created_at: now, last_used_at: now })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return new Info({ id: workspaceID, provider, binding: result.binding, createdAt: now, lastUsedAt: now })
|
||||
}),
|
||||
connect: Effect.fn("Workspace.connect")(function* (workspaceID) {
|
||||
const spawner = make((command) =>
|
||||
Effect.acquireRelease(
|
||||
locks.withLock(workspaceID)(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* open(workspaceID).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "Workspace",
|
||||
method: "spawn",
|
||||
description: `Failed to wake workspace ${workspaceID}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis)
|
||||
yield* Ref.update(connection.active, (active) => active + 1)
|
||||
return connection
|
||||
}),
|
||||
),
|
||||
(connection) =>
|
||||
locks.withLock(workspaceID)(
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(connection.active, (active) => active - 1)
|
||||
yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis)
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.flatMap((connection) => connection.environment.spawner.spawn(command))),
|
||||
)
|
||||
// Overrides are connection-bound; per-spawn routing is required before any driver ships them, so they are deliberately omitted.
|
||||
return { spawner }
|
||||
}),
|
||||
destroy: Effect.fn("Workspace.destroy")(function* (workspaceID) {
|
||||
yield* locks.withLock(workspaceID)(
|
||||
Effect.gen(function* () {
|
||||
const row = yield* load(workspaceID)
|
||||
const connection = connections.get(workspaceID)
|
||||
connections.delete(workspaceID)
|
||||
if (connection) yield* Scope.close(connection.scope, Exit.void)
|
||||
const driver = yield* registry.get(row.provider)
|
||||
yield* driver.destroy({ workspaceID, binding: row.binding })
|
||||
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = configured()
|
||||
|
||||
// TODO(workspace-plan): add the boot janitor and ~23h safety snapshot rotation in a later PR.
|
||||
// TODO(workspace-plan): make cold wake interruptible with a re-pin loop against janitor races.
|
||||
// TODO(workspace-plan): consider RcMap at end-of-series consolidation; idle suspend and destroy need distinct finalizers.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export * as WorkspaceDriver from "./driver"
|
||||
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import type { Driver as EnvironmentDriver } from "../environment/driver"
|
||||
|
||||
/**
|
||||
* Smallest provider-owned JSON value required to reconnect to the same
|
||||
* provider resource. Core stores it opaquely and hands it back; only the
|
||||
* owning driver reads inside.
|
||||
*/
|
||||
export const Binding = Schema.Record(Schema.String, Schema.Json)
|
||||
export type Binding = typeof Binding.Type
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceDriver.Error", {
|
||||
message: Schema.optional(Schema.String),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class ProviderNotFound extends Schema.TaggedErrorClass<ProviderNotFound>()("WorkspaceDriver.ProviderNotFound", {
|
||||
provider: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (input: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
}) => Effect.Effect<{ readonly binding: Binding }, Error>
|
||||
readonly connect: (input: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly binding: Binding
|
||||
readonly saveBinding: (binding: Binding) => Effect.Effect<void>
|
||||
}) => Effect.Effect<EnvironmentDriver, Error, Scope.Scope>
|
||||
readonly suspendForIdle: (input: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly binding: Binding
|
||||
readonly saveBinding: (binding: Binding) => Effect.Effect<void>
|
||||
}) => Effect.Effect<void, Error>
|
||||
readonly destroy: (input: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly binding: Binding
|
||||
}) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const make = (driver: Interface) => driver
|
||||
|
||||
export interface Registry {
|
||||
readonly get: (provider: string) => Effect.Effect<Interface, ProviderNotFound>
|
||||
}
|
||||
|
||||
export class RegistryService extends Context.Service<RegistryService, Registry>()(
|
||||
"@opencode/WorkspaceDriverRegistry",
|
||||
) {}
|
||||
|
||||
export const registry = (drivers: Readonly<Record<string, Interface>>): Registry => ({
|
||||
get: (provider) => {
|
||||
const driver = drivers[provider]
|
||||
return driver ? Effect.succeed(driver) : Effect.fail(new ProviderNotFound({ provider }))
|
||||
},
|
||||
})
|
||||
|
||||
export const registryNode = (drivers: Readonly<Record<string, Interface>>) =>
|
||||
makeGlobalNode({
|
||||
service: RegistryService,
|
||||
layer: Layer.succeed(RegistryService, RegistryService.of(registry(drivers))),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
export const node = registryNode({})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import type { WorkspaceDriver } from "./driver"
|
||||
|
||||
export const WorkspaceTable = sqliteTable("workspace", {
|
||||
id: text().$type<Workspace.ID>().primaryKey(),
|
||||
provider: text().notNull(),
|
||||
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
|
||||
created_at: integer().notNull(),
|
||||
last_used_at: integer().notNull(),
|
||||
})
|
||||
@@ -9,11 +9,12 @@ import { Environment } from "@opencode-ai/core/environment"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string, environmentLayer = LayerNode.compile(Environment.node)) {
|
||||
function provide(directory: string, transformFiles: EnvironmentFilesTransform = () => ({})) {
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
@@ -21,7 +22,7 @@ function provide(directory: string, environmentLayer = LayerNode.compile(Environ
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
[Location.node, activeLocation],
|
||||
[Environment.node, environmentLayer],
|
||||
[Environment.node, transformEnvironmentFiles(activeLocation, transformFiles)],
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -240,18 +241,8 @@ describe("FileMutation", () => {
|
||||
)
|
||||
})
|
||||
|
||||
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
|
||||
return Layer.effect(
|
||||
Environment.Service,
|
||||
Effect.gen(function* () {
|
||||
const environment = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...environment,
|
||||
files: {
|
||||
...environment.files,
|
||||
write: (target, content) => run(environment.files.write(target, content), target),
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
function instrumentWrites(
|
||||
run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>,
|
||||
): EnvironmentFilesTransform {
|
||||
return (files) => ({ write: (target, content) => run(files.write(target, content), target) })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export type EnvironmentFilesTransform = (files: Environment.Files) => Partial<Environment.Files>
|
||||
|
||||
export function transformEnvironmentFiles(
|
||||
location: Layer.Layer<Location.Service>,
|
||||
transform: EnvironmentFilesTransform = () => ({}),
|
||||
) {
|
||||
return Layer.effect(
|
||||
Environment.Service,
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...current,
|
||||
files: { ...current.files, ...transform(current.files) },
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(AppNodeBuilder.build(Environment.node, [[Location.node, location]])))
|
||||
}
|
||||
@@ -3,12 +3,15 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
|
||||
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
||||
const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [[Location.node, tempLocationLayer]]))
|
||||
|
||||
describe("Ripgrep", () => {
|
||||
it.live("globs files as an array", () =>
|
||||
@@ -129,7 +132,9 @@ describe("Ripgrep", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "generated.ts"), `Cloudflare${"x".repeat(70 * 1024)}\n`))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(tmp.path, "generated.ts"), `Cloudflare${"x".repeat(70 * 1024)}\n`),
|
||||
)
|
||||
|
||||
const matches = yield* (yield* Ripgrep.Service).grep({
|
||||
cwd: tmp.path,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { EditTool } from "@opencode-ai/core/tool/plugin/edit"
|
||||
import { transformEnvironmentFiles } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -80,29 +81,6 @@ const reset = () => {
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const environment = Layer.effect(
|
||||
Environment.Service,
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...current,
|
||||
files: {
|
||||
...current.files,
|
||||
read: (target, range) =>
|
||||
current.files
|
||||
.read(target, range)
|
||||
.pipe(
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
|
||||
),
|
||||
),
|
||||
write: (target, content) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
|
||||
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -115,7 +93,23 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
|
||||
[
|
||||
[Environment.node, environment],
|
||||
[
|
||||
Environment.node,
|
||||
transformEnvironmentFiles(activeLocation, (files) => ({
|
||||
read: (target, range) =>
|
||||
files
|
||||
.read(target, range)
|
||||
.pipe(
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => reads++).pipe(
|
||||
Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes))),
|
||||
),
|
||||
),
|
||||
),
|
||||
write: (target, content) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(files.write(target, content))),
|
||||
})),
|
||||
],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
|
||||
import { transformEnvironmentFiles } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -82,34 +83,6 @@ const reset = () => {
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const environment = Layer.effect(
|
||||
Environment.Service,
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...current,
|
||||
files: {
|
||||
...current.files,
|
||||
read: (target, range) =>
|
||||
Effect.sync(() => {
|
||||
if (!editApproved) readsBeforeEditApproval++
|
||||
}).pipe(Effect.andThen(current.files.read(target, range))),
|
||||
remove: (target) => {
|
||||
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
|
||||
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
|
||||
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
|
||||
return current.files.remove(target)
|
||||
},
|
||||
write: (target, content) => {
|
||||
if (failWriteTarget && path.basename(target) === failWriteTarget)
|
||||
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") }))
|
||||
return current.files.write(target, content)
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
|
||||
const withTool = <A, E, R>(
|
||||
directory: string,
|
||||
body: (registry: Tool.Interface) => Effect.Effect<A, E, R>,
|
||||
@@ -126,7 +99,27 @@ const withTool = <A, E, R>(
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
|
||||
[Environment.node, environment],
|
||||
[
|
||||
Environment.node,
|
||||
transformEnvironmentFiles(activeLocation, (files) => ({
|
||||
read: (target, range) =>
|
||||
Effect.sync(() => {
|
||||
if (!editApproved) readsBeforeEditApproval++
|
||||
}).pipe(Effect.andThen(files.read(target, range))),
|
||||
remove: (target) => {
|
||||
if (failRemoveTarget && path.basename(target) === failRemoveTarget)
|
||||
return Effect.die("forced remove failure")
|
||||
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
|
||||
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
|
||||
return files.remove(target)
|
||||
},
|
||||
write: (target, content) => {
|
||||
if (failWriteTarget && path.basename(target) === failWriteTarget)
|
||||
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") }))
|
||||
return files.write(target, content)
|
||||
},
|
||||
})),
|
||||
],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
@@ -215,7 +208,7 @@ describe("PatchTool", () => {
|
||||
file: "remove.txt",
|
||||
status: "deleted",
|
||||
additions: 0,
|
||||
deletions: 2,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-remove"),
|
||||
},
|
||||
],
|
||||
@@ -248,6 +241,29 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("counts deleted lines with and without a trailing newline", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(directory, "trailing.txt"), "remove\n"),
|
||||
fs.writeFile(path.join(directory, "unterminated.txt"), "remove"),
|
||||
]),
|
||||
)
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Delete File: trailing.txt\n*** Delete File: unterminated.txt\n*** End Patch"),
|
||||
)
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.output.files).toMatchObject([
|
||||
{ file: "trailing.txt", additions: 0, deletions: 1 },
|
||||
{ file: "unterminated.txt", additions: 0, deletions: 1 },
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent patch transactions", () =>
|
||||
withTempTool((directory, registry) => {
|
||||
const target = path.join(directory, "concurrent.txt")
|
||||
@@ -446,7 +462,7 @@ describe("PatchTool", () => {
|
||||
{
|
||||
file: "renamed/dir/name.txt",
|
||||
status: "modified",
|
||||
patch: expect.stringContaining("-old content\n+new content"),
|
||||
patch: expect.stringContaining(`Index: ${source}`),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { WriteTool } from "@opencode-ai/core/tool/plugin/write"
|
||||
import { transformEnvironmentFiles } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -68,21 +69,6 @@ const reset = () => {
|
||||
denyAction = undefined
|
||||
}
|
||||
|
||||
const environment = Layer.effect(
|
||||
Environment.Service,
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Environment.Service
|
||||
return Environment.Service.of({
|
||||
...current,
|
||||
files: {
|
||||
...current.files,
|
||||
write: (target, content) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
||||
|
||||
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -95,7 +81,13 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
||||
[
|
||||
[Environment.node, environment],
|
||||
[
|
||||
Environment.node,
|
||||
transformEnvironmentFiles(activeLocation, (files) => ({
|
||||
write: (target, content) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(files.write(target, content))),
|
||||
})),
|
||||
],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, expect } from "bun:test"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { makeMemoryDriver } from "@opencode-ai/core/environment"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { WorkspaceTable } from "@opencode-ai/core/workspace/sql"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const calls: Array<{ readonly operation: string; readonly binding?: WorkspaceDriver.Binding }> = []
|
||||
const memory = makeMemoryDriver()
|
||||
let failConnect = false
|
||||
|
||||
const driver = WorkspaceDriver.make({
|
||||
create: ({ workspaceID }) => {
|
||||
calls.push({ operation: "create" })
|
||||
return Effect.succeed({ binding: { workspaceID, generation: 0 } })
|
||||
},
|
||||
connect: ({ binding }) => {
|
||||
calls.push({ operation: "connect", binding })
|
||||
if (failConnect) return Effect.fail(new WorkspaceDriver.Error({ message: "wake failed" }))
|
||||
return Effect.succeed(memory)
|
||||
},
|
||||
suspendForIdle: ({ binding, saveBinding }) => {
|
||||
calls.push({ operation: "suspendForIdle", binding })
|
||||
return saveBinding({ ...binding, generation: Number(binding.generation) + 1, suspended: true })
|
||||
},
|
||||
destroy: ({ binding }) => {
|
||||
calls.push({ operation: "destroy", binding })
|
||||
return Effect.void
|
||||
},
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Workspace.configured({ idleThreshold: "5 minutes", pollInterval: "1 minute" })]),
|
||||
[[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: driver })]],
|
||||
),
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
calls.splice(0)
|
||||
failConnect = false
|
||||
})
|
||||
|
||||
it.effect("persists the workspace lifecycle and reconnects after idle suspension", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const created = yield* workspace.create("fake")
|
||||
|
||||
expect(created.id.startsWith("wrk_")).toBe(true)
|
||||
expect(created.binding).toEqual({ workspaceID: created.id, generation: 0 })
|
||||
|
||||
const environment = yield* workspace.connect(created.id)
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create"])
|
||||
|
||||
yield* TestClock.adjust("4 minutes")
|
||||
yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("activity"))).pipe(Effect.exit)
|
||||
yield* TestClock.adjust("4 minutes")
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create", "connect"])
|
||||
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create", "connect", "suspendForIdle"])
|
||||
|
||||
const stored = yield* Database.Service.use(({ db }) =>
|
||||
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, created.id)).get(),
|
||||
).pipe(Effect.orDie)
|
||||
expect(stored?.binding).toEqual({ workspaceID: created.id, generation: 1, suspended: true })
|
||||
expect(stored?.last_used_at).toBe(4 * 60 * 1000)
|
||||
|
||||
yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("wake"))).pipe(Effect.exit)
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create", "connect", "suspendForIdle", "connect"])
|
||||
expect(calls.at(-1)?.binding).toEqual({ workspaceID: created.id, generation: 1, suspended: true })
|
||||
|
||||
yield* workspace.destroy(created.id)
|
||||
expect(calls.at(-1)?.operation).toBe("destroy")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces wake failures through the spawn error channel", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const created = yield* workspace.create("fake")
|
||||
const environment = yield* workspace.connect(created.id)
|
||||
yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("connect"))).pipe(Effect.exit)
|
||||
|
||||
yield* TestClock.adjust("6 minutes")
|
||||
failConnect = true
|
||||
|
||||
const error = yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("wake"))).pipe(Effect.flip)
|
||||
expect(error).toMatchObject({
|
||||
_tag: "PlatformError",
|
||||
reason: {
|
||||
_tag: "Unknown",
|
||||
module: "Workspace",
|
||||
method: "spawn",
|
||||
description: `Failed to wake workspace ${created.id}`,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./plugin": "./src/plugin.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import stringWidth from "string-width"
|
||||
import { DiagramCanvas, DiagramCanvasSizeError, type DiagramCanvasCell } from "./canvas.js"
|
||||
|
||||
describe("DiagramCanvas", () => {
|
||||
test("rejects canvases that exceed the rendering budget", () => {
|
||||
expect(() => new DiagramCanvas(2_000, 1_000)).toThrow(DiagramCanvasSizeError)
|
||||
})
|
||||
|
||||
test("writes cells and text while clipping out-of-bounds positions", () => {
|
||||
const canvas = new DiagramCanvas<"label">(5, 2)
|
||||
|
||||
canvas.setCell(0, 0, "A", "label")
|
||||
canvas.setCell(9, 0, "X", "label")
|
||||
canvas.setText(2, 1, "hey", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("A\n hey")
|
||||
})
|
||||
|
||||
test("uses measured character widths for text placement", () => {
|
||||
const canvas = new DiagramCanvas<"label">(5, 1)
|
||||
|
||||
canvas.setText(0, 0, "a界b", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("a界b")
|
||||
expect(stringWidth(canvas.toString())).toBe(4)
|
||||
})
|
||||
|
||||
test("preserves combined graphemes while placing later text", () => {
|
||||
const canvas = new DiagramCanvas<"label">(4, 1)
|
||||
|
||||
canvas.setText(0, 0, "e\u0301x", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("e\u0301x")
|
||||
expect(stringWidth(canvas.toString())).toBe(2)
|
||||
})
|
||||
|
||||
test("merges cells through the adapter-provided merge function", () => {
|
||||
type Style = "line"
|
||||
const canvas = new DiagramCanvas<Style>(3, 1, {
|
||||
mergeCell: (existing, incoming): DiagramCanvasCell<Style> => ({
|
||||
...incoming,
|
||||
char: existing.char === "─" && incoming.char === "│" ? "┼" : incoming.char,
|
||||
}),
|
||||
})
|
||||
|
||||
canvas.setCell(1, 0, "─", "line")
|
||||
canvas.setCell(1, 0, "│", "line")
|
||||
|
||||
expect(canvas.toString()).toBe(" ┼")
|
||||
})
|
||||
|
||||
test("iterates style and metadata runs", () => {
|
||||
interface Metadata {
|
||||
stateId?: string
|
||||
}
|
||||
const canvas = new DiagramCanvas<"state", Metadata>(4, 1)
|
||||
const runs: string[] = []
|
||||
|
||||
canvas.setText(0, 0, "AB", "state", { stateId: "A" })
|
||||
canvas.setText(2, 0, "CD", "state", { stateId: "B" })
|
||||
canvas.forEachRun(
|
||||
(run) => runs.push(`${run.text}:${run.style}:${run.cell.stateId}`),
|
||||
() => runs.push("newline"),
|
||||
{ key: (cell) => [cell.style, cell.stateId] },
|
||||
)
|
||||
|
||||
expect(runs).toEqual(["AB:state:A", "CD:state:B"])
|
||||
})
|
||||
|
||||
test("can trim bottom whitespace for renderers with dynamic height", () => {
|
||||
const canvas = new DiagramCanvas<"label">(3, 3)
|
||||
const runs: string[] = []
|
||||
canvas.setText(0, 0, "top", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("top\n\n")
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("top")
|
||||
expect(canvas.getTextSize()).toEqual({ width: 3, height: 3 })
|
||||
expect(canvas.getTextSize({ trimBottom: true })).toEqual({ width: 3, height: 1 })
|
||||
|
||||
canvas.forEachRun(
|
||||
(run) => runs.push(run.text),
|
||||
() => runs.push("newline"),
|
||||
{ trimBottom: true },
|
||||
)
|
||||
expect(runs).toEqual(["top"])
|
||||
})
|
||||
|
||||
test("can trim unused leading whitespace reserved by layout", () => {
|
||||
const canvas = new DiagramCanvas<"label">(3, 3)
|
||||
canvas.setText(0, 2, "end", "label")
|
||||
|
||||
expect(canvas.toString({ trimTop: true })).toBe("end")
|
||||
expect(canvas.getTextSize({ trimTop: true })).toEqual({ width: 3, height: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import stringWidth from "string-width"
|
||||
import { diagramTextGraphemes } from "./text.js"
|
||||
|
||||
export type DiagramCanvasCell<Style extends string, Metadata extends object = object> = {
|
||||
char: string
|
||||
style?: Style
|
||||
} & Partial<Metadata>
|
||||
|
||||
export interface DiagramCanvasRun<Style extends string, Metadata extends object = object> {
|
||||
text: string
|
||||
style: Style | undefined
|
||||
cell: DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
export interface DiagramCanvasOptions<Style extends string, Metadata extends object = object> {
|
||||
measure?: (text: string) => number
|
||||
mergeCell?: (
|
||||
existing: DiagramCanvasCell<Style, Metadata>,
|
||||
incoming: DiagramCanvasCell<Style, Metadata>,
|
||||
) => DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
export interface DiagramCanvasTextOptions {
|
||||
trimTop?: boolean
|
||||
trimBottom?: boolean
|
||||
}
|
||||
|
||||
export interface DiagramCanvasTextSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type DiagramCanvasTextMetadata<Metadata extends object> =
|
||||
| Partial<Metadata>
|
||||
| ((x: number, y: number) => Partial<Metadata>)
|
||||
|
||||
export interface DiagramCanvasRunOptions<Style extends string, Metadata extends object = object> {
|
||||
key?: (cell: DiagramCanvasCell<Style, Metadata>) => readonly unknown[]
|
||||
trimTop?: boolean
|
||||
trimBottom?: boolean
|
||||
}
|
||||
|
||||
const MAX_DIAGRAM_CELLS = 1_000_000
|
||||
|
||||
export class DiagramCanvasSizeError extends Error {
|
||||
constructor(
|
||||
readonly width: number,
|
||||
readonly height: number,
|
||||
) {
|
||||
super(`Diagram canvas ${width}x${height} exceeds the ${MAX_DIAGRAM_CELLS.toLocaleString()} cell limit`)
|
||||
this.name = "DiagramCanvasSizeError"
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyCell<Style extends string, Metadata extends object>(): DiagramCanvasCell<Style, Metadata> {
|
||||
return { char: " " } as DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
function sameKey(left: readonly unknown[] | undefined, right: readonly unknown[]): boolean {
|
||||
return Boolean(left && left.length === right.length && left.every((value, index) => Object.is(value, right[index])))
|
||||
}
|
||||
|
||||
export class DiagramCanvas<Style extends string, Metadata extends object = object> {
|
||||
readonly rows: Array<Array<DiagramCanvasCell<Style, Metadata>>>
|
||||
|
||||
private readonly measure: (text: string) => number
|
||||
private readonly mergeCell?: DiagramCanvasOptions<Style, Metadata>["mergeCell"]
|
||||
|
||||
constructor(
|
||||
readonly width: number,
|
||||
readonly height: number,
|
||||
options: DiagramCanvasOptions<Style, Metadata> = {},
|
||||
) {
|
||||
if (width * height > MAX_DIAGRAM_CELLS) throw new DiagramCanvasSizeError(width, height)
|
||||
this.measure = options.measure ?? stringWidth
|
||||
this.mergeCell = options.mergeCell
|
||||
this.rows = Array.from({ length: height }, () => Array.from({ length: width }, () => createEmptyCell()))
|
||||
}
|
||||
|
||||
private rowTextEnd(row: Array<DiagramCanvasCell<Style, Metadata>>): number {
|
||||
let rowEnd = row.length
|
||||
while (rowEnd > 0 && row[rowEnd - 1]?.char === " ") rowEnd -= 1
|
||||
return rowEnd
|
||||
}
|
||||
|
||||
private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd = this.rowTextEnd(row)): string {
|
||||
return row
|
||||
.slice(0, rowEnd)
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
}
|
||||
|
||||
private textRowRange(trimTop: boolean, trimBottom: boolean): { start: number; end: number } {
|
||||
let start = 0
|
||||
let end = this.rows.length
|
||||
if (trimTop) while (start < end && this.rowTextEnd(this.rows[start]!) === 0) start += 1
|
||||
if (trimBottom) while (end > start && this.rowTextEnd(this.rows[end - 1]!) === 0) end -= 1
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
setCell(x: number, y: number, char: string, style?: Style, metadata?: Partial<Metadata>): void {
|
||||
if (y < 0 || y >= this.rows.length || x < 0 || x >= this.rows[y]!.length) return
|
||||
|
||||
const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
|
||||
this.rows[y]![x] = this.mergeCell?.(this.rows[y]![x]!, incoming) ?? incoming
|
||||
}
|
||||
|
||||
getCell(x: number, y: number): DiagramCanvasCell<Style, Metadata> | undefined {
|
||||
return this.rows[y]?.[x]
|
||||
}
|
||||
|
||||
setText(x: number, y: number, text: string, style?: Style, metadata?: DiagramCanvasTextMetadata<Metadata>): void {
|
||||
let offset = 0
|
||||
for (const grapheme of diagramTextGraphemes(text)) {
|
||||
const width = Math.max(1, this.measure(grapheme))
|
||||
const metadataAt = (cellX: number) => (typeof metadata === "function" ? metadata(cellX, y) : metadata)
|
||||
this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
|
||||
for (let continuation = 1; continuation < width; continuation++) {
|
||||
this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))
|
||||
}
|
||||
offset += width
|
||||
}
|
||||
}
|
||||
|
||||
toString(options: DiagramCanvasTextOptions = {}): string {
|
||||
const lines: string[] = []
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
lines.push(this.rowText(this.rows[rowIndex]!))
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
getTextSize(options: DiagramCanvasTextOptions = {}): DiagramCanvasTextSize {
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
let width = 0
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
const row = this.rows[rowIndex]!
|
||||
const rowEnd = this.rowTextEnd(row)
|
||||
if (rowEnd > 0) width = Math.max(width, this.measure(this.rowText(row, rowEnd)))
|
||||
}
|
||||
return { width, height: rows.end - rows.start }
|
||||
}
|
||||
|
||||
forEachRun(
|
||||
onRun: (run: DiagramCanvasRun<Style, Metadata>) => void,
|
||||
onLineEnd: () => void,
|
||||
options: DiagramCanvasRunOptions<Style, Metadata> = {},
|
||||
): void {
|
||||
const key = options.key
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
const row = this.rows[rowIndex]!
|
||||
const rowEnd = this.rowTextEnd(row)
|
||||
|
||||
let currentCell: DiagramCanvasCell<Style, Metadata> | undefined
|
||||
let currentKey: readonly unknown[] | undefined
|
||||
let currentText = ""
|
||||
const flush = () => {
|
||||
if (!currentText || !currentCell) return
|
||||
onRun({ text: currentText, style: currentCell.style, cell: currentCell })
|
||||
currentText = ""
|
||||
}
|
||||
|
||||
for (let x = 0; x < rowEnd; x++) {
|
||||
const cell = row[x]!
|
||||
const nextKey = key?.(cell)
|
||||
const sameRun = currentCell && (key ? sameKey(currentKey, nextKey!) : currentCell.style === cell.style)
|
||||
if (!sameRun) {
|
||||
flush()
|
||||
currentCell = cell
|
||||
currentKey = nextKey
|
||||
}
|
||||
currentText += cell.char
|
||||
}
|
||||
|
||||
flush()
|
||||
if (rowIndex < rows.end - 1) onLineEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { blendColor, createColorRampTheme, numberedStyleKeys, rgba } from "./style.js"
|
||||
|
||||
describe("diagram style helpers", () => {
|
||||
test("converts rgb tuples and blends optional RGBA values", () => {
|
||||
const black = RGBA.fromInts(0, 0, 0, 255)
|
||||
const white = RGBA.fromInts(10, 20, 30, 255)
|
||||
|
||||
expect(rgba([1, 2, 3]).equals(RGBA.fromInts(1, 2, 3, 255))).toBe(true)
|
||||
expect(blendColor(black, white, 0.5).equals(RGBA.fromInts(5, 10, 15, 255))).toBe(true)
|
||||
expect(blendColor(undefined, white, 0.5)?.equals(white)).toBe(true)
|
||||
expect(blendColor(undefined, undefined, 0.5)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("creates numbered style keys and color ramps", () => {
|
||||
const styles = numberedStyleKeys("requestFade", [1, 2, 3] as const)
|
||||
const theme = createColorRampTheme(styles, RGBA.fromInts(0, 0, 0, 255), RGBA.fromInts(12, 24, 36, 255))
|
||||
|
||||
expect(styles).toEqual(["requestFade1", "requestFade2", "requestFade3"])
|
||||
expect(theme.requestFade1.equals(RGBA.fromInts(3, 6, 9, 255))).toBe(true)
|
||||
expect(theme.requestFade3.equals(RGBA.fromInts(9, 18, 27, 255))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
export type DiagramRgb = readonly [number, number, number]
|
||||
export type DiagramFadeStep = 1 | 2 | 3 | 4 | 5
|
||||
|
||||
export const DIAGRAM_FADE_STEPS = [1, 2, 3, 4, 5] as const satisfies readonly DiagramFadeStep[]
|
||||
|
||||
export function numberedStyleKeys<Prefix extends string, Step extends number>(
|
||||
prefix: Prefix,
|
||||
steps: readonly Step[],
|
||||
): Array<`${Prefix}${Step}`> {
|
||||
return steps.map((step) => `${prefix}${step}` as `${Prefix}${Step}`)
|
||||
}
|
||||
|
||||
export function rgba(rgb: DiagramRgb): RGBA {
|
||||
return RGBA.fromInts(rgb[0], rgb[1], rgb[2], 255)
|
||||
}
|
||||
|
||||
export function blendColor(from: RGBA, to: RGBA, amount: number): RGBA
|
||||
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined
|
||||
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined {
|
||||
if (!from && !to) return undefined
|
||||
if (!from) return to
|
||||
if (!to) return from
|
||||
|
||||
const [fromR, fromG, fromB, fromA] = from.toInts()
|
||||
const [toR, toG, toB, toA] = to.toInts()
|
||||
const mix = (left: number, right: number) => left + (right - left) * amount
|
||||
|
||||
return RGBA.fromInts(mix(fromR, toR), mix(fromG, toG), mix(fromB, toB), mix(fromA, toA))
|
||||
}
|
||||
|
||||
export function colorsEqual(left?: RGBA, right?: RGBA): boolean {
|
||||
if (!left || !right) return left === right
|
||||
return left.equals(right)
|
||||
}
|
||||
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA,
|
||||
to: RGBA,
|
||||
): Record<Style, RGBA>
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA | undefined,
|
||||
to: RGBA | undefined,
|
||||
): Record<Style, RGBA | undefined>
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA | undefined,
|
||||
to: RGBA | undefined,
|
||||
): Record<Style, RGBA | undefined> {
|
||||
return Object.fromEntries(
|
||||
styles.map((style, index) => [style, blendColor(from, to, (index + 1) / (styles.length + 1))]),
|
||||
) as Record<Style, RGBA | undefined>
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { BorderChars } from "@opentui/core"
|
||||
import { DiagramCanvas } from "./canvas.js"
|
||||
import {
|
||||
diagramArrowHead,
|
||||
drawDiagramDiamond,
|
||||
drawDiagramFrame,
|
||||
drawOrthogonalPath,
|
||||
mergeDiagramLineGlyph,
|
||||
} from "./drawing.js"
|
||||
|
||||
describe("diagram drawing", () => {
|
||||
test("merges line glyphs with square and rounded corners", () => {
|
||||
expect(mergeDiagramLineGlyph("─", "│")).toBe("┼")
|
||||
expect(mergeDiagramLineGlyph("─", "│", "rounded")).toBe("┼")
|
||||
expect(mergeDiagramLineGlyph("─", "╭", "rounded")).toBe("┬")
|
||||
expect(mergeDiagramLineGlyph("a", "│")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("draws frames through caller-provided cell writers", () => {
|
||||
const canvas = new DiagramCanvas<"frame">(8, 4)
|
||||
drawDiagramFrame(
|
||||
{ left: 1, top: 0, width: 6, height: 4, centerX: 4, centerY: 2 },
|
||||
BorderChars.rounded,
|
||||
(x, y, char) => canvas.setCell(x, y, char, "frame"),
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n │ │\n ╰────╯")
|
||||
})
|
||||
|
||||
test("draws diamond frames through caller-provided cell writers", () => {
|
||||
const canvas = new DiagramCanvas<"frame">(9, 5)
|
||||
drawDiagramDiamond({ left: 0, top: 0, width: 9, height: 5, centerX: 4, centerY: 2 }, (x, y, char) =>
|
||||
canvas.setCell(x, y, char, "frame"),
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭───╮\n╭─╯ ╰─╮\n│ │\n╰─╮ ╭─╯\n ╰───╯")
|
||||
expect(canvas.toString()).not.toMatch(/[╱╲\\/]/)
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with selected corner style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(7, 4)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
{ x: 3, y: 2 },
|
||||
{ x: 6, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ cornerStyle: "rounded" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("───╮\n │\n ╰──")
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with heavy line style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(7, 4)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
{ x: 3, y: 2 },
|
||||
{ x: 6, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ lineStyle: "heavy" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("━━━┓\n ┃\n ┗━━")
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with dashed line style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(9, 1)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 8, y: 0 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ lineStyle: "dashed" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("─ ─ ─ ─")
|
||||
})
|
||||
|
||||
test("keeps container frame policy separate from edge drawing policy", () => {
|
||||
const canvas = new DiagramCanvas<"group" | "edge">(8, 5, {
|
||||
mergeCell: (existing, incoming) => {
|
||||
if (incoming.style === "edge") return incoming
|
||||
return existing.char === " " ? incoming : existing
|
||||
},
|
||||
})
|
||||
|
||||
drawDiagramFrame(
|
||||
{ left: 1, top: 0, width: 6, height: 5, centerX: 4, centerY: 2 },
|
||||
BorderChars.rounded,
|
||||
(x, y, char) => canvas.setCell(x, y, char, "group"),
|
||||
)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 7, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
)
|
||||
canvas.setCell(7, 2, "▶", "edge")
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n───────▶\n │ │\n ╰────╯")
|
||||
})
|
||||
|
||||
test("selects filled and line arrow heads", () => {
|
||||
expect(diagramArrowHead("right")).toBe("▶")
|
||||
expect(diagramArrowHead("left", "line")).toBe("←")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,301 @@
|
||||
import { BorderChars, type BorderCharacters } from "@opentui/core"
|
||||
import {
|
||||
directionBetween,
|
||||
walkOrthogonalSegment,
|
||||
type DiagramBounds,
|
||||
type DiagramDirection,
|
||||
type DiagramPoint,
|
||||
} from "./geometry.js"
|
||||
|
||||
export type DiagramLineCornerStyle = "square" | "rounded"
|
||||
export type DiagramLineStyle = "single" | "heavy" | "dashed"
|
||||
export type DiagramArrowHeadStyle = "filled" | "line"
|
||||
|
||||
export interface DiagramDiamondCharacters {
|
||||
topLeft: string
|
||||
topRight: string
|
||||
upperLeft: string
|
||||
upperLeftJoin: string
|
||||
upperRightJoin: string
|
||||
upperRight: string
|
||||
vertical: string
|
||||
lowerLeft: string
|
||||
lowerLeftJoin: string
|
||||
lowerRightJoin: string
|
||||
lowerRight: string
|
||||
bottomLeft: string
|
||||
bottomRight: string
|
||||
horizontal: string
|
||||
}
|
||||
|
||||
export const DIAGRAM_ARROW_HEADS = new Set(["▶", "◀", "▼", "▲", "→", "←", "↓", "↑"])
|
||||
const HEAVY_LINE_GLYPHS = new Set(Object.values(BorderChars.heavy))
|
||||
|
||||
export const DIAGRAM_DIAMOND_CHARS = {
|
||||
topLeft: "╭",
|
||||
topRight: "╮",
|
||||
upperLeft: "╭",
|
||||
upperLeftJoin: "╯",
|
||||
upperRightJoin: "╰",
|
||||
upperRight: "╮",
|
||||
vertical: "│",
|
||||
lowerLeft: "╰",
|
||||
lowerLeftJoin: "╮",
|
||||
lowerRightJoin: "╭",
|
||||
lowerRight: "╯",
|
||||
bottomLeft: "╰",
|
||||
bottomRight: "╯",
|
||||
horizontal: "─",
|
||||
} as const satisfies DiagramDiamondCharacters
|
||||
|
||||
export function diagramDiamondCharactersFromBorder(chars: BorderCharacters): DiagramDiamondCharacters {
|
||||
return {
|
||||
topLeft: chars.topLeft,
|
||||
topRight: chars.topRight,
|
||||
upperLeft: chars.topLeft,
|
||||
upperLeftJoin: chars.bottomRight,
|
||||
upperRightJoin: chars.bottomLeft,
|
||||
upperRight: chars.topRight,
|
||||
vertical: chars.vertical,
|
||||
lowerLeft: chars.bottomLeft,
|
||||
lowerLeftJoin: chars.topRight,
|
||||
lowerRightJoin: chars.topLeft,
|
||||
lowerRight: chars.bottomRight,
|
||||
bottomLeft: chars.bottomLeft,
|
||||
bottomRight: chars.bottomRight,
|
||||
horizontal: chars.horizontal,
|
||||
}
|
||||
}
|
||||
|
||||
function lineDirections(char: string): readonly DiagramDirection[] | undefined {
|
||||
switch (char) {
|
||||
case "─":
|
||||
return ["left", "right"]
|
||||
case "│":
|
||||
return ["up", "down"]
|
||||
case "━":
|
||||
return ["left", "right"]
|
||||
case "┃":
|
||||
return ["up", "down"]
|
||||
case "┌":
|
||||
case "╭":
|
||||
case "┏":
|
||||
return ["right", "down"]
|
||||
case "┐":
|
||||
case "╮":
|
||||
case "┓":
|
||||
return ["left", "down"]
|
||||
case "└":
|
||||
case "╰":
|
||||
case "┗":
|
||||
return ["up", "right"]
|
||||
case "┘":
|
||||
case "╯":
|
||||
case "┛":
|
||||
return ["up", "left"]
|
||||
case "├":
|
||||
case "┣":
|
||||
return ["up", "down", "right"]
|
||||
case "┤":
|
||||
case "┫":
|
||||
return ["up", "down", "left"]
|
||||
case "┬":
|
||||
case "┳":
|
||||
return ["left", "right", "down"]
|
||||
case "┴":
|
||||
case "┻":
|
||||
return ["left", "right", "up"]
|
||||
case "┼":
|
||||
case "╋":
|
||||
return ["up", "down", "left", "right"]
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function diagramLineGlyph(
|
||||
directions: ReadonlySet<DiagramDirection>,
|
||||
cornerStyle: DiagramLineCornerStyle = "square",
|
||||
lineStyle: DiagramLineStyle = "single",
|
||||
): string {
|
||||
const up = directions.has("up")
|
||||
const down = directions.has("down")
|
||||
const left = directions.has("left")
|
||||
const right = directions.has("right")
|
||||
if (lineStyle === "heavy") {
|
||||
const chars = BorderChars.heavy
|
||||
if (up && down && left && right) return chars.cross
|
||||
if (up && down && right) return chars.leftT
|
||||
if (up && down && left) return chars.rightT
|
||||
if (left && right && down) return chars.topT
|
||||
if (left && right && up) return chars.bottomT
|
||||
if (up && right) return chars.bottomLeft
|
||||
if (up && left) return chars.bottomRight
|
||||
if (down && right) return chars.topLeft
|
||||
if (down && left) return chars.topRight
|
||||
if (up || down) return chars.vertical
|
||||
return chars.horizontal
|
||||
}
|
||||
if (up && down && left && right) return "┼"
|
||||
if (up && down && right) return "├"
|
||||
if (up && down && left) return "┤"
|
||||
if (left && right && down) return "┬"
|
||||
if (left && right && up) return "┴"
|
||||
if (up && right) return cornerStyle === "rounded" ? "╰" : "└"
|
||||
if (up && left) return cornerStyle === "rounded" ? "╯" : "┘"
|
||||
if (down && right) return cornerStyle === "rounded" ? "╭" : "┌"
|
||||
if (down && left) return cornerStyle === "rounded" ? "╮" : "┐"
|
||||
if (up || down) return "│"
|
||||
return "─"
|
||||
}
|
||||
|
||||
function isHeavyLineGlyph(char: string): boolean {
|
||||
return HEAVY_LINE_GLYPHS.has(char)
|
||||
}
|
||||
|
||||
function segmentGlyph(direction: DiagramDirection, lineStyle: DiagramLineStyle | undefined): string {
|
||||
const directions = new Set<DiagramDirection>(
|
||||
direction === "left" || direction === "right" ? ["left", "right"] : ["up", "down"],
|
||||
)
|
||||
return diagramLineGlyph(directions, "square", lineStyle === "heavy" ? "heavy" : "single")
|
||||
}
|
||||
|
||||
export function mergeDiagramLineGlyph(
|
||||
existing: string,
|
||||
incoming: string,
|
||||
cornerStyle: DiagramLineCornerStyle = "square",
|
||||
): string | undefined {
|
||||
const existingDirections = lineDirections(existing)
|
||||
const incomingDirections = lineDirections(incoming)
|
||||
if (!existingDirections || !incomingDirections) return undefined
|
||||
|
||||
return diagramLineGlyph(
|
||||
new Set([...existingDirections, ...incomingDirections]),
|
||||
cornerStyle,
|
||||
isHeavyLineGlyph(existing) && isHeavyLineGlyph(incoming) ? "heavy" : "single",
|
||||
)
|
||||
}
|
||||
|
||||
export function diagramArrowHead(direction: DiagramDirection, style: DiagramArrowHeadStyle = "filled"): string {
|
||||
if (style === "line") {
|
||||
if (direction === "right") return "→"
|
||||
if (direction === "left") return "←"
|
||||
if (direction === "up") return "↑"
|
||||
return "↓"
|
||||
}
|
||||
|
||||
if (direction === "right") return "▶"
|
||||
if (direction === "left") return "◀"
|
||||
if (direction === "up") return "▲"
|
||||
return "▼"
|
||||
}
|
||||
|
||||
export function diagramArrowHeadBetween(
|
||||
from: DiagramPoint,
|
||||
to: DiagramPoint,
|
||||
style: DiagramArrowHeadStyle = "filled",
|
||||
): string {
|
||||
const direction = directionBetween(from, to)
|
||||
return direction ? diagramArrowHead(direction, style) : diagramArrowHead("right", style)
|
||||
}
|
||||
|
||||
export function drawDiagramFrame(
|
||||
bounds: DiagramBounds,
|
||||
chars: BorderCharacters,
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
): void {
|
||||
setCell(bounds.left, bounds.top, chars.topLeft)
|
||||
setCell(bounds.left + bounds.width - 1, bounds.top, chars.topRight)
|
||||
setCell(bounds.left, bounds.top + bounds.height - 1, chars.bottomLeft)
|
||||
setCell(bounds.left + bounds.width - 1, bounds.top + bounds.height - 1, chars.bottomRight)
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
setCell(x, bounds.top, chars.horizontal)
|
||||
setCell(x, bounds.top + bounds.height - 1, chars.horizontal)
|
||||
}
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
setCell(bounds.left, y, chars.vertical)
|
||||
setCell(bounds.left + bounds.width - 1, y, chars.vertical)
|
||||
}
|
||||
}
|
||||
|
||||
export function fillDiagramFrameInterior(bounds: DiagramBounds, setCell: (x: number, y: number) => void): void {
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) setCell(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
export function drawDiagramDiamond(
|
||||
bounds: DiagramBounds,
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
chars: DiagramDiamondCharacters = DIAGRAM_DIAMOND_CHARS,
|
||||
): void {
|
||||
const left = bounds.left
|
||||
const right = bounds.left + bounds.width - 1
|
||||
const top = bounds.top
|
||||
const bottom = bounds.top + bounds.height - 1
|
||||
const capInset = Math.min(2, Math.max(1, Math.floor((bounds.width - 1) / 2)))
|
||||
const capLeft = left + capInset
|
||||
const capRight = right - capInset
|
||||
|
||||
setCell(capLeft, top, chars.topLeft)
|
||||
for (let x = capLeft + 1; x < capRight; x++) setCell(x, top, chars.horizontal)
|
||||
setCell(capRight, top, chars.topRight)
|
||||
|
||||
setCell(left, top + 1, chars.upperLeft)
|
||||
for (let x = left + 1; x < capLeft; x++) setCell(x, top + 1, chars.horizontal)
|
||||
setCell(capLeft, top + 1, chars.upperLeftJoin)
|
||||
setCell(capRight, top + 1, chars.upperRightJoin)
|
||||
for (let x = capRight + 1; x < right; x++) setCell(x, top + 1, chars.horizontal)
|
||||
setCell(right, top + 1, chars.upperRight)
|
||||
|
||||
for (let y = top + 2; y < bottom - 1; y++) {
|
||||
setCell(left, y, chars.vertical)
|
||||
setCell(right, y, chars.vertical)
|
||||
}
|
||||
|
||||
setCell(left, bottom - 1, chars.lowerLeft)
|
||||
for (let x = left + 1; x < capLeft; x++) setCell(x, bottom - 1, chars.horizontal)
|
||||
setCell(capLeft, bottom - 1, chars.lowerLeftJoin)
|
||||
setCell(capRight, bottom - 1, chars.lowerRightJoin)
|
||||
for (let x = capRight + 1; x < right; x++) setCell(x, bottom - 1, chars.horizontal)
|
||||
setCell(right, bottom - 1, chars.lowerRight)
|
||||
|
||||
setCell(capLeft, bottom, chars.bottomLeft)
|
||||
for (let x = capLeft + 1; x < capRight; x++) setCell(x, bottom, chars.horizontal)
|
||||
setCell(capRight, bottom, chars.bottomRight)
|
||||
}
|
||||
|
||||
export function drawOrthogonalPath(
|
||||
points: readonly DiagramPoint[],
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
options: { cornerStyle?: DiagramLineCornerStyle; lineStyle?: DiagramLineStyle } = {},
|
||||
): void {
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) continue
|
||||
const glyph = segmentGlyph(direction, options.lineStyle)
|
||||
let step = index === 1 ? 0 : 1
|
||||
walkOrthogonalSegment(from, to, index === 1, (point) => {
|
||||
if (options.lineStyle !== "dashed" || step % 2 === 0) setCell(point.x, point.y, glyph)
|
||||
step += 1
|
||||
})
|
||||
}
|
||||
|
||||
for (let index = 1; index < points.length - 1; index++) {
|
||||
const previous = points[index - 1]!
|
||||
const current = points[index]!
|
||||
const next = points[index + 1]!
|
||||
const fromDirection = directionBetween(current, previous)
|
||||
const toDirection = directionBetween(current, next)
|
||||
const directions = new Set<DiagramDirection>()
|
||||
if (fromDirection) directions.add(fromDirection)
|
||||
if (toDirection) directions.add(toDirection)
|
||||
setCell(
|
||||
current.x,
|
||||
current.y,
|
||||
diagramLineGlyph(directions, options.cornerStyle, options.lineStyle === "heavy" ? "heavy" : "single"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
diagramBoundsFromBounds,
|
||||
diagramBoundsFromPoints,
|
||||
diagramBoundsFromRect,
|
||||
boundsSidePoint,
|
||||
directionBetween,
|
||||
lane,
|
||||
orthogonalPath,
|
||||
orthogonalPathPoints,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
point,
|
||||
translateDiagramBounds,
|
||||
walkOrthogonalSegment,
|
||||
} from "./geometry.js"
|
||||
|
||||
describe("diagram geometry", () => {
|
||||
test("directions are only defined for orthogonal point pairs", () => {
|
||||
expect(directionBetween(point(1, 2), point(5, 2))).toBe("right")
|
||||
expect(directionBetween(point(1, 2), point(1, 0))).toBe("up")
|
||||
expect(directionBetween(point(1, 2), point(5, 4))).toBeUndefined()
|
||||
expect(directionBetween(point(1, 2), point(1, 2))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("bounds side points describe border and outside ports", () => {
|
||||
const bounds = { left: 10, top: 4, width: 8, height: 5, centerX: 14, centerY: 6 }
|
||||
|
||||
expect(boundsSidePoint(bounds, "left", "border")).toEqual(point(10, 6))
|
||||
expect(boundsSidePoint(bounds, "left")).toEqual(point(9, 6))
|
||||
expect(boundsSidePoint(bounds, "right", "border")).toEqual(point(17, 6))
|
||||
expect(boundsSidePoint(bounds, "right")).toEqual(point(18, 6))
|
||||
expect(boundsSidePoint(bounds, "top", "border")).toEqual(point(14, 4))
|
||||
expect(boundsSidePoint(bounds, "bottom")).toEqual(point(14, 9))
|
||||
})
|
||||
|
||||
test("bounds helpers create, translate, and union bounds", () => {
|
||||
const bounds = diagramBoundsFromRect(2, 3, 5, 4)
|
||||
|
||||
expect(bounds).toEqual({ left: 2, top: 3, width: 5, height: 4, centerX: 4, centerY: 5 })
|
||||
translateDiagramBounds(bounds, 3, -1)
|
||||
expect(bounds).toEqual({ left: 5, top: 2, width: 5, height: 4, centerX: 7, centerY: 4 })
|
||||
expect(diagramBoundsFromBounds([bounds, diagramBoundsFromRect(0, 0, 2, 2)])).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 6,
|
||||
centerX: 5,
|
||||
centerY: 3,
|
||||
})
|
||||
expect(diagramBoundsFromPoints([point(2, 2), point(4, 5)])).toEqual({
|
||||
left: 2,
|
||||
top: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
centerX: 3,
|
||||
centerY: 4,
|
||||
})
|
||||
})
|
||||
|
||||
test("paths compose through lanes while removing duplicate joints", () => {
|
||||
expect(pathThrough([point(0, 0), point(0, 0), point(3, 0)])).toEqual([point(0, 0), point(3, 0)])
|
||||
expect(pathViaLane(point(0, 0), lane("x", 4), point(8, 3))).toEqual([
|
||||
point(0, 0),
|
||||
point(4, 0),
|
||||
point(4, 3),
|
||||
point(8, 3),
|
||||
])
|
||||
})
|
||||
|
||||
test("composed paths do not alias caller-owned points", () => {
|
||||
const start = point(0, 0)
|
||||
const end = point(8, 3)
|
||||
const firstPath = pathViaLane(start, lane("x", 4), end)
|
||||
const secondPath = pathViaLane(start, lane("x", 5), end)
|
||||
|
||||
expect(firstPath[0]).not.toBe(start)
|
||||
expect(firstPath[firstPath.length - 1]).not.toBe(end)
|
||||
expect(firstPath[0]).not.toBe(secondPath[0])
|
||||
|
||||
firstPath[0]!.x += 10
|
||||
firstPath[firstPath.length - 1]!.y += 10
|
||||
|
||||
expect(start).toEqual(point(0, 0))
|
||||
expect(end).toEqual(point(8, 3))
|
||||
expect(secondPath[0]).toEqual(point(0, 0))
|
||||
expect(secondPath[secondPath.length - 1]).toEqual(point(8, 3))
|
||||
})
|
||||
|
||||
test("orthogonal paths choose a terminal lane on the dominant axis", () => {
|
||||
expect(orthogonalPath(point(0, 0), point(10, 4))).toEqual([point(0, 0), point(6, 0), point(6, 4), point(10, 4)])
|
||||
})
|
||||
|
||||
test("orthogonal segment walkers exclude endpoints", () => {
|
||||
const visited: Array<{ x: number; y: number }> = []
|
||||
walkOrthogonalSegment(point(0, 0), point(3, 0), false, (next) => {
|
||||
visited.push(next)
|
||||
})
|
||||
expect(visited).toEqual([point(1, 0), point(2, 0)])
|
||||
})
|
||||
|
||||
test("orthogonal path points include endpoints without duplicating joints", () => {
|
||||
expect(orthogonalPathPoints([point(0, 0), point(3, 0), point(3, 2)])).toEqual([
|
||||
point(0, 0),
|
||||
point(1, 0),
|
||||
point(2, 0),
|
||||
point(3, 0),
|
||||
point(3, 1),
|
||||
point(3, 2),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
export type DiagramAxis = "x" | "y"
|
||||
export type DiagramDirection = "up" | "down" | "left" | "right"
|
||||
export type DiagramSide = "left" | "right" | "top" | "bottom"
|
||||
|
||||
export interface DiagramPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface DiagramBounds {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
centerX: number
|
||||
centerY: number
|
||||
}
|
||||
|
||||
export interface DiagramSegment {
|
||||
from: DiagramPoint
|
||||
to: DiagramPoint
|
||||
axis: DiagramAxis
|
||||
direction: DiagramDirection
|
||||
length: number
|
||||
}
|
||||
|
||||
export interface DiagramLane {
|
||||
axis: DiagramAxis
|
||||
coordinate: number
|
||||
}
|
||||
|
||||
export interface DiagramSpan {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
const DIRECTION_AXIS = {
|
||||
left: "x",
|
||||
right: "x",
|
||||
up: "y",
|
||||
down: "y",
|
||||
} as const satisfies Record<DiagramDirection, DiagramAxis>
|
||||
|
||||
const DIRECTION_SIGN = {
|
||||
left: -1,
|
||||
right: 1,
|
||||
up: -1,
|
||||
down: 1,
|
||||
} as const satisfies Record<DiagramDirection, -1 | 1>
|
||||
|
||||
const DIRECTION_SIDE = {
|
||||
left: "left",
|
||||
right: "right",
|
||||
up: "top",
|
||||
down: "bottom",
|
||||
} as const satisfies Record<DiagramDirection, DiagramSide>
|
||||
|
||||
const OPPOSITE_SIDE = {
|
||||
left: "right",
|
||||
right: "left",
|
||||
top: "bottom",
|
||||
bottom: "top",
|
||||
} as const satisfies Record<DiagramSide, DiagramSide>
|
||||
|
||||
export function point(x: number, y: number): DiagramPoint {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function diagramBoundsFromRect(left: number, top: number, width: number, height: number): DiagramBounds {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
export function translateDiagramBounds(bounds: DiagramBounds, dx: number, dy: number): void {
|
||||
bounds.left += dx
|
||||
bounds.top += dy
|
||||
bounds.centerX += dx
|
||||
bounds.centerY += dy
|
||||
}
|
||||
|
||||
export function diagramBoundsFromBounds(bounds: readonly DiagramBounds[]): DiagramBounds | undefined {
|
||||
if (bounds.length === 0) return undefined
|
||||
const left = Math.min(...bounds.map((bound) => bound.left))
|
||||
const top = Math.min(...bounds.map((bound) => bound.top))
|
||||
const right = Math.max(...bounds.map((bound) => bound.left + bound.width))
|
||||
const bottom = Math.max(...bounds.map((bound) => bound.top + bound.height))
|
||||
return diagramBoundsFromRect(left, top, right - left, bottom - top)
|
||||
}
|
||||
|
||||
export function diagramBoundsFromPoints(points: readonly DiagramPoint[]): DiagramBounds | undefined {
|
||||
if (points.length === 0) return undefined
|
||||
const left = Math.min(...points.map((point) => point.x))
|
||||
const top = Math.min(...points.map((point) => point.y))
|
||||
const right = Math.max(...points.map((point) => point.x))
|
||||
const bottom = Math.max(...points.map((point) => point.y))
|
||||
return diagramBoundsFromRect(left, top, right - left + 1, bottom - top + 1)
|
||||
}
|
||||
|
||||
export function coordinate(point: DiagramPoint, axis: DiagramAxis): number {
|
||||
return point[axis]
|
||||
}
|
||||
|
||||
export function withCoordinate(point: DiagramPoint, axis: DiagramAxis, value: number): DiagramPoint {
|
||||
return axis === "x" ? { x: value, y: point.y } : { x: point.x, y: value }
|
||||
}
|
||||
|
||||
export function shiftPoint(point: DiagramPoint, direction: DiagramDirection, distance = 1): DiagramPoint {
|
||||
switch (direction) {
|
||||
case "left":
|
||||
return { x: point.x - distance, y: point.y }
|
||||
case "right":
|
||||
return { x: point.x + distance, y: point.y }
|
||||
case "up":
|
||||
return { x: point.x, y: point.y - distance }
|
||||
case "down":
|
||||
return { x: point.x, y: point.y + distance }
|
||||
}
|
||||
}
|
||||
|
||||
export function clampPoint(point: DiagramPoint, min: DiagramPoint = { x: 0, y: 0 }): DiagramPoint {
|
||||
return { x: Math.max(min.x, point.x), y: Math.max(min.y, point.y) }
|
||||
}
|
||||
|
||||
export function samePoint(left: DiagramPoint, right: DiagramPoint): boolean {
|
||||
return left.x === right.x && left.y === right.y
|
||||
}
|
||||
|
||||
export function directionAxis(direction: DiagramDirection): DiagramAxis {
|
||||
return DIRECTION_AXIS[direction]
|
||||
}
|
||||
|
||||
export function directionSign(direction: DiagramDirection): -1 | 1 {
|
||||
return DIRECTION_SIGN[direction]
|
||||
}
|
||||
|
||||
export function sideForDirection(direction: DiagramDirection): DiagramSide {
|
||||
return DIRECTION_SIDE[direction]
|
||||
}
|
||||
|
||||
export function oppositeSide(side: DiagramSide): DiagramSide {
|
||||
return OPPOSITE_SIDE[side]
|
||||
}
|
||||
|
||||
export function directionBetween(from: DiagramPoint, to: DiagramPoint): DiagramDirection | undefined {
|
||||
if (from.y === to.y) {
|
||||
if (to.x > from.x) return "right"
|
||||
if (to.x < from.x) return "left"
|
||||
}
|
||||
if (from.x === to.x) {
|
||||
if (to.y > from.y) return "down"
|
||||
if (to.y < from.y) return "up"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function boundsCenter(bounds: DiagramBounds): DiagramPoint {
|
||||
return point(bounds.centerX, bounds.centerY)
|
||||
}
|
||||
|
||||
export function boundsSidePoint(
|
||||
bounds: DiagramBounds,
|
||||
side: DiagramSide,
|
||||
surface: "border" | "outside" = "outside",
|
||||
): DiagramPoint {
|
||||
switch (side) {
|
||||
case "left":
|
||||
return point(bounds.left - (surface === "outside" ? 1 : 0), bounds.centerY)
|
||||
case "right":
|
||||
return point(bounds.left + bounds.width - (surface === "outside" ? 0 : 1), bounds.centerY)
|
||||
case "top":
|
||||
return point(bounds.centerX, bounds.top - (surface === "outside" ? 1 : 0))
|
||||
case "bottom":
|
||||
return point(bounds.centerX, bounds.top + bounds.height - (surface === "outside" ? 0 : 1))
|
||||
}
|
||||
}
|
||||
|
||||
export function centerCoordinate(bounds: DiagramBounds, axis: DiagramAxis): number {
|
||||
return axis === "x" ? bounds.centerX : bounds.centerY
|
||||
}
|
||||
|
||||
export function snapCoordinate(source: number, target: number, tolerance: number): number {
|
||||
return Math.abs(source - target) <= tolerance ? target : source
|
||||
}
|
||||
|
||||
export function pathThrough(points: readonly DiagramPoint[]): DiagramPoint[] {
|
||||
const path: DiagramPoint[] = []
|
||||
for (const next of points) {
|
||||
if (!path.length || !samePoint(path[path.length - 1]!, next)) path.push(point(next.x, next.y))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function lane(axis: DiagramAxis, coordinate: number): DiagramLane {
|
||||
return { axis, coordinate }
|
||||
}
|
||||
|
||||
export function pathViaLane(start: DiagramPoint, routeLane: DiagramLane, end: DiagramPoint): DiagramPoint[] {
|
||||
return pathThrough([
|
||||
start,
|
||||
withCoordinate(start, routeLane.axis, routeLane.coordinate),
|
||||
withCoordinate(end, routeLane.axis, routeLane.coordinate),
|
||||
end,
|
||||
])
|
||||
}
|
||||
|
||||
function dominantAxis(start: DiagramPoint, end: DiagramPoint): DiagramAxis {
|
||||
return Math.abs(end.x - start.x) >= Math.abs(end.y - start.y) ? "x" : "y"
|
||||
}
|
||||
|
||||
function terminalLane(start: DiagramPoint, end: DiagramPoint, axis: DiagramAxis, terminalClearance: number): number {
|
||||
const startCoordinate = coordinate(start, axis)
|
||||
const endCoordinate = coordinate(end, axis)
|
||||
const delta = endCoordinate - startCoordinate
|
||||
const sign = Math.sign(delta)
|
||||
if (sign === 0) return endCoordinate
|
||||
return endCoordinate - sign * Math.min(terminalClearance, Math.max(1, Math.abs(delta) - 1))
|
||||
}
|
||||
|
||||
export function orthogonalPath(
|
||||
start: DiagramPoint,
|
||||
end: DiagramPoint,
|
||||
options: { preferredAxis?: DiagramAxis; terminalClearance?: number } = {},
|
||||
): DiagramPoint[] {
|
||||
if (start.x === end.x || start.y === end.y) return pathThrough([start, end])
|
||||
|
||||
const laneAxis = options.preferredAxis ?? dominantAxis(start, end)
|
||||
return pathViaLane(start, lane(laneAxis, terminalLane(start, end, laneAxis, options.terminalClearance ?? 4)), end)
|
||||
}
|
||||
|
||||
export function segmentBetween(from: DiagramPoint, to: DiagramPoint): DiagramSegment | undefined {
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) return undefined
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
axis: directionAxis(direction),
|
||||
direction,
|
||||
length: Math.abs(coordinate(to, directionAxis(direction)) - coordinate(from, directionAxis(direction))),
|
||||
}
|
||||
}
|
||||
|
||||
export function walkOrthogonalSegment(
|
||||
from: DiagramPoint,
|
||||
to: DiagramPoint,
|
||||
includeStart: boolean,
|
||||
visit: (point: DiagramPoint) => boolean | void,
|
||||
): void {
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) return
|
||||
|
||||
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
|
||||
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
|
||||
let cursor = includeStart ? from : point(from.x + dx, from.y + dy)
|
||||
|
||||
while (!samePoint(cursor, to)) {
|
||||
if (visit(cursor) === false) return
|
||||
cursor = point(cursor.x + dx, cursor.y + dy)
|
||||
}
|
||||
}
|
||||
|
||||
export function orthogonalPathPoints(points: readonly DiagramPoint[]): DiagramPoint[] {
|
||||
const path: DiagramPoint[] = []
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) continue
|
||||
|
||||
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
|
||||
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
|
||||
let cursor = path.length === 0 ? point(from.x, from.y) : point(from.x + dx, from.y + dy)
|
||||
while (true) {
|
||||
path.push(cursor)
|
||||
if (samePoint(cursor, to)) break
|
||||
cursor = point(cursor.x + dx, cursor.y + dy)
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function orderedSpan(left: number, right: number): DiagramSpan {
|
||||
return left <= right ? { start: left, end: right } : { start: right, end: left }
|
||||
}
|
||||
|
||||
export function segmentSpan(segment: DiagramSegment): DiagramSpan {
|
||||
return orderedSpan(coordinate(segment.from, segment.axis), coordinate(segment.to, segment.axis))
|
||||
}
|
||||
|
||||
export function pointOnSegment(segment: DiagramSegment, coordinateValue: number): DiagramPoint {
|
||||
return withCoordinate(segment.from, segment.axis, coordinateValue)
|
||||
}
|
||||
|
||||
export function insetSpan(span: DiagramSpan, amount: number): DiagramSpan {
|
||||
return { start: span.start + amount, end: span.end - amount }
|
||||
}
|
||||
|
||||
export function spanCapacity(span: DiagramSpan): number {
|
||||
return Math.max(0, span.end - span.start + 1)
|
||||
}
|
||||
|
||||
export function centeredSpanStart(span: DiagramSpan, width: number): number {
|
||||
return span.start + Math.floor((spanCapacity(span) - width) / 2)
|
||||
}
|
||||
|
||||
export function midpoint(span: DiagramSpan): number {
|
||||
return Math.round((span.start + span.end) / 2)
|
||||
}
|
||||
|
||||
export function advanceCoordinate(origin: number, direction: DiagramDirection, distance: number): number {
|
||||
return origin + directionSign(direction) * distance
|
||||
}
|
||||
|
||||
export function beforeNearestCoordinate(
|
||||
points: readonly DiagramPoint[],
|
||||
axis: DiagramAxis,
|
||||
direction: DiagramDirection,
|
||||
clearance: number,
|
||||
): number {
|
||||
const coordinates = points.map((point) => coordinate(point, axis))
|
||||
const nearest = directionSign(direction) > 0 ? Math.min(...coordinates) : Math.max(...coordinates)
|
||||
return advanceCoordinate(nearest, direction, -clearance)
|
||||
}
|
||||
|
||||
export function afterFarthestCoordinate(
|
||||
points: readonly DiagramPoint[],
|
||||
axis: DiagramAxis,
|
||||
direction: DiagramDirection,
|
||||
clearance: number,
|
||||
): number {
|
||||
const coordinates = points.map((point) => coordinate(point, axis))
|
||||
const farthest = directionSign(direction) > 0 ? Math.max(...coordinates) : Math.min(...coordinates)
|
||||
return advanceCoordinate(farthest, direction, clearance)
|
||||
}
|
||||
|
||||
export function keepBefore(preferred: number, boundary: number, direction: DiagramDirection): number {
|
||||
return directionSign(direction) > 0 ? Math.min(preferred, boundary) : Math.max(preferred, boundary)
|
||||
}
|
||||
|
||||
export function keepAfter(preferred: number, boundary: number, direction: DiagramDirection): number {
|
||||
return directionSign(direction) > 0 ? Math.max(preferred, boundary) : Math.min(preferred, boundary)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface NumberedMermaidLine {
|
||||
readonly lineNumber: number
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
export function* numberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
|
||||
const lines = content.split(/\r?\n/)
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
yield { lineNumber: index + 1, text: lines[index]!.trim() }
|
||||
}
|
||||
}
|
||||
|
||||
export function* meaningfulNumberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
|
||||
for (const line of numberedMermaidLines(content)) {
|
||||
if (line.text && !line.text.startsWith("%%")) yield line
|
||||
}
|
||||
}
|
||||
|
||||
export function firstMeaningfulMermaidLine(content: string): string | undefined {
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (line && !line.startsWith("%%")) return line
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function stripMermaidQuotes(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { StyledText, type TextChunk } from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasRun, DiagramCanvasRunOptions } from "./canvas.js"
|
||||
|
||||
export function renderDiagramGridStyledText<Style extends string, Metadata extends object = object>(
|
||||
grid: DiagramCanvas<Style, Metadata>,
|
||||
fg: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["fg"],
|
||||
bg?: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["bg"],
|
||||
options?: DiagramCanvasRunOptions<Style, Metadata>,
|
||||
): StyledText {
|
||||
const chunks: TextChunk[] = []
|
||||
|
||||
grid.forEachRun(
|
||||
(run) => {
|
||||
chunks.push({ __isChunk: true, text: run.text, fg: fg(run), bg: bg?.(run) })
|
||||
},
|
||||
() => {
|
||||
chunks.push({ __isChunk: true, text: "\n" })
|
||||
},
|
||||
options,
|
||||
)
|
||||
|
||||
return new StyledText(chunks)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function splitDiagramLines(value: string): string[] {
|
||||
return value.split(/<br\s*\/?>/i).map((line) => line.trim())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "./text.js"
|
||||
|
||||
describe("diagram text helpers", () => {
|
||||
test("splits Mermaid-style line breaks", () => {
|
||||
expect(splitDiagramLines("one<br/> two <br>three")).toEqual(["one", "two", "three"])
|
||||
})
|
||||
|
||||
test("measures padded text boxes", () => {
|
||||
expect(measureDiagramTextBox("wide<br/>x", { paddingX: 2, paddingY: 1 })).toEqual({
|
||||
width: 8,
|
||||
height: 4,
|
||||
lines: ["wide", "x"],
|
||||
})
|
||||
})
|
||||
|
||||
test("measures terminal cell width", () => {
|
||||
expect(diagramTextWidth("abc")).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import stringWidth from "string-width"
|
||||
import { splitDiagramLines } from "./text-lines.js"
|
||||
|
||||
export { splitDiagramLines } from "./text-lines.js"
|
||||
|
||||
export interface DiagramTextBoxSize {
|
||||
width: number
|
||||
height: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export function diagramTextWidth(value: string): number {
|
||||
return stringWidth(value)
|
||||
}
|
||||
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
export function* diagramTextGraphemes(value: string): Generator<string> {
|
||||
for (const { segment } of graphemeSegmenter.segment(value)) yield segment
|
||||
}
|
||||
|
||||
export function measureDiagramTextBox(
|
||||
value: string,
|
||||
options: { paddingX?: number; paddingY?: number; minInnerWidth?: number } = {},
|
||||
): DiagramTextBoxSize {
|
||||
const lines = splitDiagramLines(value)
|
||||
const innerWidth = Math.max(...lines.map(diagramTextWidth), options.minInnerWidth ?? 1)
|
||||
return {
|
||||
width: innerWidth + (options.paddingX ?? 0) * 2,
|
||||
height: lines.length + (options.paddingY ?? 0) * 2,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MermaidDiagramKind } from "./diagnostics.js"
|
||||
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
|
||||
import { isMermaidStateDiagram } from "./state/parser.js"
|
||||
|
||||
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
|
||||
if (isMermaidFlowchartDiagram(content)) return "flowchart"
|
||||
if (isMermaidSequenceDiagram(content)) return "sequence"
|
||||
if (isMermaidStateDiagram(content)) return "state"
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
|
||||
|
||||
/** An otherwise valid diagram contains syntax that merman does not support. */
|
||||
export class MermaidSyntaxError extends Error {
|
||||
readonly _tag = "MermaidSyntaxError"
|
||||
|
||||
constructor(
|
||||
readonly kind: MermaidDiagramKind,
|
||||
readonly lineNumber: number,
|
||||
readonly sourceLine: string,
|
||||
reason = "Unsupported syntax",
|
||||
) {
|
||||
super(`${reason} in ${kind} diagram at line ${lineNumber}: "${sourceLine}"`)
|
||||
this.name = "MermaidSyntaxError"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
|
||||
import { walkOrthogonalSegment } from "../core/geometry.js"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
|
||||
import { splitDiagramLines } from "../core/text.js"
|
||||
import {
|
||||
DIAGRAM_ARROW_HEADS,
|
||||
diagramArrowHeadBetween,
|
||||
diagramDiamondCharactersFromBorder,
|
||||
diagramLineGlyph,
|
||||
drawDiagramDiamond,
|
||||
drawDiagramFrame,
|
||||
fillDiagramFrameInterior,
|
||||
drawOrthogonalPath,
|
||||
mergeDiagramLineGlyph,
|
||||
} from "../core/drawing.js"
|
||||
import { layoutFlowchartDiagram, visualLength } from "./layout.js"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { flowchartDirectionBetween, flowchartSourceConnector } from "./routing.js"
|
||||
import {
|
||||
DATABASE_EDGE_FADE_STYLES,
|
||||
NODE_EDGE_FADE_STYLES,
|
||||
type FlowchartCellStyle,
|
||||
type FlowchartEdgeFadeStyle,
|
||||
type FlowchartGrid,
|
||||
} from "./style.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNode,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartPoint,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export const DEFAULT_BORDER_STYLE = "rounded" satisfies BorderStyle
|
||||
function mergeFlowchartCell(
|
||||
existing: DiagramCanvasCell<FlowchartCellStyle>,
|
||||
incoming: DiagramCanvasCell<FlowchartCellStyle>,
|
||||
): DiagramCanvasCell<FlowchartCellStyle> {
|
||||
if (incoming.style !== "edge") return incoming
|
||||
if (existing.style === "label") return existing
|
||||
if (incoming.char === " ") return existing
|
||||
if (existing.style !== "edge" || existing.char === " ") return incoming
|
||||
if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
char: mergeDiagramLineGlyph(existing.char, incoming.char, "rounded") ?? incoming.char,
|
||||
} as DiagramCanvasCell<FlowchartCellStyle>
|
||||
}
|
||||
|
||||
function setNodeText(grid: FlowchartGrid, x: number, y: number, text: string, style: FlowchartCellStyle): void {
|
||||
grid.setText(x, y, text, style)
|
||||
}
|
||||
|
||||
function drawNode(
|
||||
grid: FlowchartGrid,
|
||||
node: FlowchartNode,
|
||||
bounds: FlowchartNodeBounds,
|
||||
borderStyle: BorderStyle,
|
||||
): void {
|
||||
const chars = BorderChars[borderStyle]
|
||||
const style: FlowchartCellStyle = node.shape === "database" ? "database" : "node"
|
||||
|
||||
if (node.shape === "decision") {
|
||||
drawDiagramDiamond(
|
||||
bounds,
|
||||
(x, y, char) => grid.setCell(x, y, char, style),
|
||||
diagramDiamondCharactersFromBorder(chars),
|
||||
)
|
||||
} else if (node.shape === "subroutine") {
|
||||
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
|
||||
drawSubroutineNode(grid, bounds, chars, style)
|
||||
} else if (node.shape === "database") {
|
||||
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
|
||||
drawDatabaseNode(grid, bounds, chars, style)
|
||||
} else {
|
||||
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, style))
|
||||
}
|
||||
|
||||
const textTop =
|
||||
node.shape === "decision"
|
||||
? bounds.top + Math.floor((bounds.height - bounds.lines.length) / 2)
|
||||
: node.shape === "database"
|
||||
? bounds.top + 2
|
||||
: bounds.top + 1
|
||||
for (const [index, line] of bounds.lines.entries()) {
|
||||
const lineX =
|
||||
node.shape === "subroutine"
|
||||
? bounds.left + 3
|
||||
: bounds.left + Math.max(1, Math.floor((bounds.width - visualLength(line)) / 2))
|
||||
setNodeText(grid, lineX, textTop + index, line, style)
|
||||
}
|
||||
}
|
||||
|
||||
function drawSubroutineNode(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
chars: BorderCharacters,
|
||||
style: FlowchartCellStyle,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, style))
|
||||
const leftRailX = bounds.left + 2
|
||||
const rightRailX = bounds.left + bounds.width - 3
|
||||
grid.setCell(leftRailX, bounds.top, chars.topT, style)
|
||||
grid.setCell(rightRailX, bounds.top, chars.topT, style)
|
||||
grid.setCell(leftRailX, bounds.top + bounds.height - 1, chars.bottomT, style)
|
||||
grid.setCell(rightRailX, bounds.top + bounds.height - 1, chars.bottomT, style)
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
grid.setCell(leftRailX, y, chars.vertical, style)
|
||||
grid.setCell(rightRailX, y, chars.vertical, style)
|
||||
}
|
||||
}
|
||||
|
||||
function drawDatabaseNode(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
chars: BorderCharacters,
|
||||
style: FlowchartCellStyle,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, style))
|
||||
const topRailY = bounds.top + 1
|
||||
const bottomRailY = bounds.top + bounds.height - 2
|
||||
for (const y of [topRailY, bottomRailY]) {
|
||||
grid.setCell(bounds.left, y, chars.leftT, style)
|
||||
grid.setCell(bounds.left + bounds.width - 1, y, chars.rightT, style)
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
grid.setCell(x, y, chars.horizontal, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawSubgraphFrame(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds, borderStyle: BorderStyle): void {
|
||||
const chars = BorderChars[borderStyle]
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, "group"))
|
||||
}
|
||||
|
||||
function drawSubgraphLabel(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds): void {
|
||||
if (bounds.label) {
|
||||
const lines = splitDiagramLines(bounds.label)
|
||||
const labelY = bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length
|
||||
for (const [index, line] of lines.entries()) {
|
||||
grid.setText(bounds.left + 2, labelY + index, ` ${line} `, "group")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawEdgeLabel(grid: FlowchartGrid, route: FlowchartEdgeRoute, style: FlowchartCellStyle): void {
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
|
||||
for (const [index, line] of label.lines.entries()) {
|
||||
grid.setText(label.point.x, label.point.y + index, line, style)
|
||||
}
|
||||
}
|
||||
|
||||
function drawRoutedEdge(grid: FlowchartGrid, route: FlowchartEdgeRoute): void {
|
||||
const { edge, points } = route
|
||||
if (points.length < 2) return
|
||||
const style: FlowchartCellStyle = "edge"
|
||||
|
||||
drawOrthogonalPath(points, (x, y, char) => grid.setCell(x, y, char, style), {
|
||||
cornerStyle: "rounded",
|
||||
lineStyle: edge.style === "thick" ? "heavy" : "single",
|
||||
})
|
||||
if (edge.arrowhead !== false) {
|
||||
const end = points[points.length - 1]!
|
||||
const arrowFrom = points[points.length - 2]!
|
||||
grid.setCell(end.x, end.y, diagramArrowHeadBetween(arrowFrom, end), style)
|
||||
}
|
||||
if (edge.label) {
|
||||
drawEdgeLabel(grid, route, "label")
|
||||
}
|
||||
}
|
||||
|
||||
function flowchartNodeStyle(node: FlowchartNode | undefined): "node" | "database" {
|
||||
return node?.shape === "database" ? "database" : "node"
|
||||
}
|
||||
|
||||
function sourceFadeStyles(sourceStyle: "node" | "database"): readonly FlowchartEdgeFadeStyle[] {
|
||||
return sourceStyle === "database" ? DATABASE_EDGE_FADE_STYLES : NODE_EDGE_FADE_STYLES
|
||||
}
|
||||
|
||||
function styleExistingEdgeCell(grid: FlowchartGrid, x: number, y: number, style: FlowchartEdgeFadeStyle): void {
|
||||
const cell = grid.getCell(x, y)
|
||||
if (cell) grid.setCell(x, y, cell.char, style)
|
||||
}
|
||||
|
||||
function routeCellOccupancy(routes: readonly FlowchartEdgeRoute[]): Map<string, number> {
|
||||
const occupancy = new Map<string, number>()
|
||||
for (const route of routes) {
|
||||
const routeCells = new Set<string>()
|
||||
for (let index = 1; index < route.points.length; index++) {
|
||||
walkOrthogonalSegment(route.points[index - 1]!, route.points[index]!, index === 1, (point) => {
|
||||
routeCells.add(`${point.x}:${point.y}`)
|
||||
})
|
||||
}
|
||||
for (const key of routeCells) occupancy.set(key, (occupancy.get(key) ?? 0) + 1)
|
||||
}
|
||||
return occupancy
|
||||
}
|
||||
|
||||
function fadeSourcePath(
|
||||
grid: FlowchartGrid,
|
||||
connector: FlowchartPoint,
|
||||
points: FlowchartPoint[],
|
||||
styles: readonly FlowchartEdgeFadeStyle[],
|
||||
occupancy: ReadonlyMap<string, number>,
|
||||
): void {
|
||||
const from = points[0]
|
||||
const to = points[1]
|
||||
if (!from || !to) return
|
||||
const privateCells = [connector]
|
||||
|
||||
walkOrthogonalSegment(from, to, true, (point) => {
|
||||
const key = `${point.x}:${point.y}`
|
||||
const cell = grid.getCell(point.x, point.y)
|
||||
if (occupancy.get(key) !== 1 || !cell || !"─━│┃".includes(cell.char) || cell.style === "label") return false
|
||||
privateCells.push(point)
|
||||
})
|
||||
|
||||
for (const [index, point] of privateCells.entries()) {
|
||||
const styleIndex = Math.min(
|
||||
styles.length - 1,
|
||||
Math.floor(((index + 1) * styles.length) / (privateCells.length + 1)),
|
||||
)
|
||||
styleExistingEdgeCell(grid, point.x, point.y, styles[styleIndex]!)
|
||||
}
|
||||
}
|
||||
|
||||
function drawSourceConnectors(
|
||||
grid: FlowchartGrid,
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const nodesById = new Map(diagram.nodes.map((node) => [node.id, node]))
|
||||
const occupancy = routeCellOccupancy(routes)
|
||||
|
||||
for (const route of routes) {
|
||||
const from = bounds.get(route.edge.from)
|
||||
const sourcePoint = route.points[0]
|
||||
if (!from || !sourcePoint) continue
|
||||
const styles = sourceFadeStyles(flowchartNodeStyle(nodesById.get(route.edge.from)))
|
||||
const connector = flowchartSourceConnector(from, sourcePoint)
|
||||
grid.setCell(connector.x, connector.y, connector.char, "edge")
|
||||
const routeDirection = route.points[1] ? flowchartDirectionBetween(sourcePoint, route.points[1]!) : undefined
|
||||
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
|
||||
if (routeDirection && connectorDirection) {
|
||||
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
|
||||
if (cell) {
|
||||
cell.char = diagramLineGlyph(
|
||||
new Set([routeDirection, connectorDirection]),
|
||||
"rounded",
|
||||
route.edge.style === "thick" ? "heavy" : "single",
|
||||
)
|
||||
cell.style = "edge"
|
||||
}
|
||||
}
|
||||
fadeSourcePath(grid, connector, route.points, styles, occupancy)
|
||||
}
|
||||
}
|
||||
|
||||
export function drawFlowchartDiagramGrid(
|
||||
diagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions = {},
|
||||
): FlowchartGrid {
|
||||
const borderStyle = options.borderStyle ?? DEFAULT_BORDER_STYLE
|
||||
const layout = layoutFlowchartDiagram(diagram, options)
|
||||
const { bounds, routes, subgraphBounds, width, height } = layout
|
||||
diagram = layout.diagram
|
||||
const grid = new DiagramCanvas<FlowchartCellStyle>(width, height, {
|
||||
mergeCell: mergeFlowchartCell,
|
||||
})
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const bound = subgraphBounds.get(subgraph.id)
|
||||
if (bound) drawSubgraphFrame(grid, bound, borderStyle)
|
||||
}
|
||||
for (const route of routes) drawRoutedEdge(grid, route)
|
||||
for (const node of diagram.nodes) {
|
||||
const bound = bounds.get(node.id)
|
||||
if (bound) drawNode(grid, node, bound, borderStyle)
|
||||
}
|
||||
drawSourceConnectors(grid, diagram, bounds, routes)
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const bound = subgraphBounds.get(subgraph.id)
|
||||
if (bound) drawSubgraphLabel(grid, bound)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
|
||||
const measure = (text: string): number => text.length
|
||||
|
||||
describe("flowchart edge labels", () => {
|
||||
test("places vertical-route labels beside the bus", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 22, y: 3 },
|
||||
{ x: 22, y: 7 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 23, y: 5 })
|
||||
})
|
||||
|
||||
test("places labels inline only when padded text fits with clearance", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 13, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 2 })
|
||||
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 9, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 1 })
|
||||
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 7, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 1 })
|
||||
})
|
||||
|
||||
test("uses vertical bus labels before short terminal branches", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 155, y: 5 },
|
||||
{ x: 150, y: 5 },
|
||||
{ x: 150, y: 9 },
|
||||
{ x: 146, y: 9 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 151, y: 7 })
|
||||
})
|
||||
|
||||
test("measures br-delimited edge label lines as a block", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 20, y: 2 },
|
||||
],
|
||||
"first<br/>second line",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.lines).toEqual([" first ", " second line "])
|
||||
expect(layout.width).toBe(13)
|
||||
expect(layout.height).toBe(2)
|
||||
})
|
||||
|
||||
test("places multiline horizontal edge labels outside the route row", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 5 },
|
||||
{ x: 20, y: 5 },
|
||||
],
|
||||
"first<br/>second",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.point.y + layout.height).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
test("centers multiline vertical edge labels beside their route", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 22, y: 2 },
|
||||
{ x: 22, y: 10 },
|
||||
],
|
||||
"one<br/>two<br/>three",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.point).toEqual({ x: 23, y: 5 })
|
||||
expect(layout.height).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
clampPoint,
|
||||
centeredSpanStart,
|
||||
insetSpan,
|
||||
midpoint,
|
||||
point,
|
||||
pointOnSegment,
|
||||
segmentBetween,
|
||||
segmentSpan,
|
||||
shiftPoint,
|
||||
spanCapacity,
|
||||
type DiagramSegment,
|
||||
} from "../core/geometry.js"
|
||||
import { splitDiagramLines } from "../core/text.js"
|
||||
import type { FlowchartPoint } from "./types.js"
|
||||
|
||||
const LABEL_BUS_CLEARANCE = 3
|
||||
const LABEL_NODE_CLEARANCE = 2
|
||||
const LABEL_LINE_CLEARANCE = 2
|
||||
const LABEL_PADDING = 1
|
||||
|
||||
export interface FlowchartEdgeLabelLayout {
|
||||
lines: string[]
|
||||
point: FlowchartPoint
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function flowchartLabelText(label: string): string {
|
||||
return `${" ".repeat(LABEL_PADDING)}${label}${" ".repeat(LABEL_PADDING)}`
|
||||
}
|
||||
|
||||
export function flowchartLabelWidth(label: string, measure: (text: string) => number): number {
|
||||
return Math.max(...splitDiagramLines(label).map((line) => measure(line) + LABEL_PADDING * 2))
|
||||
}
|
||||
|
||||
function minimumInlineLabelLength(labelWidth: number): number {
|
||||
return labelWidth + LABEL_LINE_CLEARANCE * 2 - 1
|
||||
}
|
||||
|
||||
export function flowchartHorizontalLabelRankGap(labelWidth: number): number {
|
||||
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + 1
|
||||
}
|
||||
|
||||
export function flowchartVerticalBranchLabelGap(labelWidth: number): number {
|
||||
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + LABEL_NODE_CLEARANCE
|
||||
}
|
||||
|
||||
function inlineLabelSlot(segment: DiagramSegment, labelWidth: number): { x: number; fits: boolean } {
|
||||
const slot = insetSpan(segmentSpan(segment), LABEL_LINE_CLEARANCE)
|
||||
return { x: centeredSpanStart(slot, labelWidth), fits: spanCapacity(slot) >= labelWidth }
|
||||
}
|
||||
|
||||
function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHeight: number): FlowchartPoint {
|
||||
if (segment.axis === "x") {
|
||||
const slot = inlineLabelSlot(segment, labelWidth)
|
||||
if (labelHeight === 1 && slot.fits) return point(slot.x, segment.from.y)
|
||||
|
||||
if (labelHeight > 1) {
|
||||
return shiftPoint(point(slot.x, segment.from.y), "up", labelHeight)
|
||||
}
|
||||
|
||||
return clampPoint(shiftPoint(shiftPoint(segment.from, segment.direction, LABEL_LINE_CLEARANCE), "up", labelHeight))
|
||||
}
|
||||
|
||||
const center = shiftPoint(pointOnSegment(segment, midpoint(segmentSpan(segment))), "right")
|
||||
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
|
||||
}
|
||||
|
||||
function bestLabelSegment(points: readonly FlowchartPoint[], labelWidth: number): DiagramSegment | undefined {
|
||||
let roomyHorizontal: DiagramSegment | undefined
|
||||
let verticalBus: DiagramSegment | undefined
|
||||
let longest: DiagramSegment | undefined
|
||||
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const segment = segmentBetween(points[index - 1]!, points[index]!)
|
||||
if (!segment) continue
|
||||
if (!roomyHorizontal && segment.axis === "x" && inlineLabelSlot(segment, labelWidth).fits) roomyHorizontal = segment
|
||||
if (!verticalBus && segment.axis === "y") verticalBus = segment
|
||||
if (!longest || segment.length > longest.length) longest = segment
|
||||
}
|
||||
|
||||
return roomyHorizontal ?? verticalBus ?? longest
|
||||
}
|
||||
|
||||
function flowchartLabelPoint(
|
||||
points: readonly FlowchartPoint[],
|
||||
labelWidth: number,
|
||||
labelHeight: number,
|
||||
): FlowchartPoint {
|
||||
const segment = bestLabelSegment(points, labelWidth)
|
||||
return segment ? segmentLabelPoint(segment, labelWidth, labelHeight) : (points[0] ?? point(0, 0))
|
||||
}
|
||||
|
||||
export function flowchartEdgeLabelLayout(
|
||||
points: readonly FlowchartPoint[],
|
||||
label: string,
|
||||
measure: (text: string) => number,
|
||||
): FlowchartEdgeLabelLayout {
|
||||
const lines = splitDiagramLines(label).map(flowchartLabelText)
|
||||
const width = flowchartLabelWidth(label, measure)
|
||||
const height = lines.length
|
||||
return { lines, point: flowchartLabelPoint(points, width, height), width, height }
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
import {
|
||||
diagramBoundsFromBounds,
|
||||
diagramBoundsFromPoints,
|
||||
segmentBetween,
|
||||
segmentSpan,
|
||||
translateDiagramBounds,
|
||||
} from "../core/geometry.js"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
|
||||
import {
|
||||
flowchartEdgeLabelLayout,
|
||||
flowchartHorizontalLabelRankGap,
|
||||
flowchartLabelWidth,
|
||||
flowchartVerticalBranchLabelGap,
|
||||
} from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { routeFlowchartEdges } from "./routing.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNode,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartNodeSize,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export const DEFAULT_MIN_NODE_GAP = 5
|
||||
export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
|
||||
export const DEFAULT_MIN_RANK_GAP = 7
|
||||
export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
|
||||
export const COMPACT_MIN_RANK_GAP = 4
|
||||
export const COMPACT_MIN_VERTICAL_RANK_GAP = 2
|
||||
const SUBGRAPH_PADDING_X = 2
|
||||
const SUBGRAPH_PADDING_TOP = 1
|
||||
const SUBGRAPH_PADDING_BOTTOM = 1
|
||||
|
||||
export interface FlowchartLayout {
|
||||
diagram: FlowchartDiagram
|
||||
bounds: Map<string, FlowchartNodeBounds>
|
||||
routes: FlowchartEdgeRoute[]
|
||||
subgraphBounds: Map<string, FlowchartSubgraphBounds>
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type FlowchartBounds = Pick<FlowchartSubgraphBounds, "left" | "top" | "width" | "height" | "centerX" | "centerY">
|
||||
|
||||
function horizontalRankGaps(
|
||||
diagram: FlowchartDiagram,
|
||||
normalizedRanks: ReadonlyMap<string, number>,
|
||||
rankKeys: readonly number[],
|
||||
fallback: number,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
||||
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (!edge.label) continue
|
||||
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
||||
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
||||
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
|
||||
|
||||
const labelGap = flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength))
|
||||
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
|
||||
gaps[index] = Math.max(gaps[index]!, labelGap)
|
||||
}
|
||||
}
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
function verticalRankGaps(
|
||||
diagram: FlowchartDiagram,
|
||||
normalizedRanks: ReadonlyMap<string, number>,
|
||||
rankKeys: readonly number[],
|
||||
fallback: number,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
||||
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (!edge.label) continue
|
||||
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
||||
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
||||
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
|
||||
|
||||
const labelHeight = splitDiagramLines(edge.label).length
|
||||
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
|
||||
gaps[index] = Math.max(gaps[index]!, labelHeight + 2)
|
||||
}
|
||||
}
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
function isHorizontalDirection(direction: FlowchartDirection): boolean {
|
||||
return direction === "LR" || direction === "RL"
|
||||
}
|
||||
|
||||
export function visualLength(value: string): number {
|
||||
return diagramTextWidth(value)
|
||||
}
|
||||
|
||||
export function normalizePositiveInt(value: number | undefined, fallback: number): number {
|
||||
if (value === undefined || !Number.isFinite(value)) return fallback
|
||||
return Math.max(1, Math.trunc(value))
|
||||
}
|
||||
|
||||
function nodeSize(node: FlowchartNode): FlowchartNodeSize {
|
||||
const { lines, width } = measureDiagramTextBox(node.label, { paddingX: 2 })
|
||||
const innerWidth = width - 4
|
||||
if (node.shape === "decision") {
|
||||
const width = innerWidth + 6
|
||||
return {
|
||||
width: width % 2 === 0 ? width + 1 : width,
|
||||
height: Math.max(5, lines.length + 4),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
if (node.shape === "database") return { width: innerWidth + 4, height: lines.length + 4, lines }
|
||||
if (node.shape === "subroutine") return { width: innerWidth + 6, height: lines.length + 2, lines }
|
||||
return { width: innerWidth + 4, height: lines.length + 2, lines }
|
||||
}
|
||||
|
||||
function rankNodes(diagram: FlowchartDiagram): Map<string, number> {
|
||||
const ranks = new Map<string, number>()
|
||||
const outgoing = new Map<string, string[]>()
|
||||
const incoming = new Set<string>()
|
||||
const incomingCounts = new Map(diagram.nodes.map((node) => [node.id, 0]))
|
||||
|
||||
const nodeIds = new Set(diagram.nodes.map((node) => node.id))
|
||||
const rankEdges = diagram.edges.flatMap((edge) => {
|
||||
if (!edge.orderOnly) return [edge]
|
||||
const fromIds = nodeIds.has(edge.from) ? [edge.from] : [...collectSubgraphNodeIds(diagram, edge.from)]
|
||||
const toIds = nodeIds.has(edge.to) ? [edge.to] : [...collectSubgraphNodeIds(diagram, edge.to)]
|
||||
return fromIds.flatMap((from) => toIds.map((to) => ({ ...edge, from, to })))
|
||||
})
|
||||
|
||||
for (const edge of rankEdges) {
|
||||
const list = outgoing.get(edge.from) ?? []
|
||||
list.push(edge.to)
|
||||
outgoing.set(edge.from, list)
|
||||
incoming.add(edge.to)
|
||||
incomingCounts.set(edge.to, (incomingCounts.get(edge.to) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const starts = diagram.nodes.filter((node) => !incoming.has(node.id))
|
||||
if (starts.length === 0 && diagram.nodes[0]) starts.push(diagram.nodes[0])
|
||||
|
||||
const queue = starts.map((node) => node.id)
|
||||
for (const node of starts) ranks.set(node.id, 0)
|
||||
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const id = queue[index]!
|
||||
const rank = ranks.get(id) ?? 0
|
||||
for (const to of outgoing.get(id) ?? []) {
|
||||
const nextRank = rank + 1
|
||||
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
|
||||
ranks.set(to, nextRank)
|
||||
queue.push(to)
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
if (!ranks.has(node.id)) ranks.set(node.id, ranks.size)
|
||||
}
|
||||
|
||||
const acyclicRanks = new Map<string, number>()
|
||||
const acyclicQueue = diagram.nodes.filter((node) => incomingCounts.get(node.id) === 0).map((node) => node.id)
|
||||
for (const id of acyclicQueue) acyclicRanks.set(id, 0)
|
||||
for (let index = 0; index < acyclicQueue.length; index++) {
|
||||
const id = acyclicQueue[index]!
|
||||
const rank = acyclicRanks.get(id) ?? 0
|
||||
for (const to of outgoing.get(id) ?? []) {
|
||||
acyclicRanks.set(to, Math.max(acyclicRanks.get(to) ?? 0, rank + 1))
|
||||
const remainingIncoming = (incomingCounts.get(to) ?? 0) - 1
|
||||
incomingCounts.set(to, remainingIncoming)
|
||||
if (remainingIncoming === 0) acyclicQueue.push(to)
|
||||
}
|
||||
}
|
||||
for (const [id, rank] of acyclicRanks) ranks.set(id, rank)
|
||||
|
||||
return ranks
|
||||
}
|
||||
|
||||
function translateBounds(bounds: FlowchartBounds, dx: number, dy: number): void {
|
||||
translateDiagramBounds(bounds, dx, dy)
|
||||
}
|
||||
|
||||
function translateRoutes(routes: readonly FlowchartEdgeRoute[], dx: number, dy: number): void {
|
||||
for (const route of routes) {
|
||||
for (const point of route.points) {
|
||||
point.x += dx
|
||||
point.y += dy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boundsFromChildren(children: readonly FlowchartBounds[]): FlowchartBounds | undefined {
|
||||
return diagramBoundsFromBounds(children)
|
||||
}
|
||||
|
||||
function subgraphBoundFromChildren(
|
||||
id: string,
|
||||
label: string,
|
||||
children: readonly FlowchartBounds[],
|
||||
): FlowchartSubgraphBounds {
|
||||
const labelLines = splitDiagramLines(label)
|
||||
const labelHeight = labelLines.length
|
||||
let left = Math.min(...children.map((child) => child.left)) - SUBGRAPH_PADDING_X
|
||||
const top = Math.min(...children.map((child) => child.top)) - Math.max(SUBGRAPH_PADDING_TOP, labelHeight)
|
||||
let right = Math.max(...children.map((child) => child.left + child.width)) + SUBGRAPH_PADDING_X
|
||||
const bottom =
|
||||
Math.max(...children.map((child) => child.top + child.height)) + Math.max(SUBGRAPH_PADDING_BOTTOM, labelHeight)
|
||||
const minWidth = Math.max(...labelLines.map(visualLength)) + 5
|
||||
|
||||
if (right - left < minWidth) {
|
||||
const extra = minWidth - (right - left)
|
||||
left -= Math.floor(extra / 2)
|
||||
right += Math.ceil(extra / 2)
|
||||
}
|
||||
|
||||
const width = right - left
|
||||
const height = Math.max(3, bottom - top)
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
labelSide: "top",
|
||||
}
|
||||
}
|
||||
|
||||
function spansOverlap(leftStart: number, leftEnd: number, rightStart: number, rightEnd: number): boolean {
|
||||
return leftStart <= rightEnd && rightStart <= leftEnd
|
||||
}
|
||||
|
||||
function labelSlot(bounds: FlowchartSubgraphBounds, side: FlowchartSubgraphBounds["labelSide"]): FlowchartBounds {
|
||||
const lines = splitDiagramLines(bounds.label)
|
||||
const left = bounds.left + 2
|
||||
const height = lines.length
|
||||
const top = side === "top" ? bounds.top : bounds.top + bounds.height - height
|
||||
const width = Math.max(...lines.map((line) => visualLength(` ${line} `)))
|
||||
return { left, top, width, height, centerX: left + Math.floor(width / 2), centerY: top + Math.floor(height / 2) }
|
||||
}
|
||||
|
||||
function segmentOverlapsSlot(
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
slot: FlowchartBounds,
|
||||
): boolean {
|
||||
const segment = segmentBetween(from, to)
|
||||
if (!segment) return false
|
||||
|
||||
const slotRight = slot.left + slot.width - 1
|
||||
const slotBottom = slot.top + slot.height - 1
|
||||
const span = segmentSpan(segment)
|
||||
if (segment.axis === "x") {
|
||||
return (
|
||||
segment.from.y >= slot.top &&
|
||||
segment.from.y <= slotBottom &&
|
||||
spansOverlap(span.start, span.end, slot.left, slotRight)
|
||||
)
|
||||
}
|
||||
return (
|
||||
segment.from.x >= slot.left &&
|
||||
segment.from.x <= slotRight &&
|
||||
spansOverlap(span.start, span.end, slot.top, slotBottom)
|
||||
)
|
||||
}
|
||||
|
||||
function routeOverlapsSlot(route: FlowchartEdgeRoute, slot: FlowchartBounds): boolean {
|
||||
for (let index = 1; index < route.points.length; index++) {
|
||||
if (segmentOverlapsSlot(route.points[index - 1]!, route.points[index]!, slot)) return true
|
||||
}
|
||||
|
||||
const routeLabelBounds = labelBounds(route)
|
||||
if (
|
||||
!routeLabelBounds ||
|
||||
!spansOverlap(
|
||||
routeLabelBounds.top,
|
||||
routeLabelBounds.top + routeLabelBounds.height - 1,
|
||||
slot.top,
|
||||
slot.top + slot.height - 1,
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return spansOverlap(
|
||||
routeLabelBounds.left,
|
||||
routeLabelBounds.left + routeLabelBounds.width - 1,
|
||||
slot.left,
|
||||
slot.left + slot.width - 1,
|
||||
)
|
||||
}
|
||||
|
||||
function chooseSubgraphLabelSide(
|
||||
bounds: FlowchartSubgraphBounds,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): FlowchartSubgraphBounds["labelSide"] {
|
||||
const topSlot = labelSlot(bounds, "top")
|
||||
if (!routes.some((route) => routeOverlapsSlot(route, topSlot))) return "top"
|
||||
|
||||
const bottomSlot = labelSlot(bounds, "bottom")
|
||||
return routes.some((route) => routeOverlapsSlot(route, bottomSlot)) ? "top" : "bottom"
|
||||
}
|
||||
|
||||
function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBounds | undefined {
|
||||
return diagramBoundsFromPoints(points)
|
||||
}
|
||||
|
||||
function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined {
|
||||
if (!route.edge.label) return undefined
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
|
||||
const { point, width, height } = label
|
||||
return {
|
||||
left: point.x,
|
||||
top: point.y,
|
||||
width,
|
||||
height,
|
||||
centerX: point.x + Math.floor(width / 2),
|
||||
centerY: point.y + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
function subgraphRouteBounds(subgraphNodeIds: Set<string>, routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
|
||||
return routeRenderBounds(
|
||||
routes.filter((route) => subgraphNodeIds.has(route.edge.from) && subgraphNodeIds.has(route.edge.to)),
|
||||
)
|
||||
}
|
||||
|
||||
function routeRenderBounds(routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
|
||||
const bounds: FlowchartBounds[] = []
|
||||
for (const route of routes) {
|
||||
const routeBounds = pathBounds(route.points)
|
||||
if (routeBounds) bounds.push(routeBounds)
|
||||
const routeLabelBounds = labelBounds(route)
|
||||
if (routeLabelBounds) bounds.push(routeLabelBounds)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
function layoutRankedNodes(
|
||||
diagram: FlowchartDiagram,
|
||||
direction: FlowchartDirection,
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): Map<string, FlowchartNodeBounds> {
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
let widestPaddedEdgeLabel = 0
|
||||
for (const edge of diagram.edges) {
|
||||
if (edge.label)
|
||||
widestPaddedEdgeLabel = Math.max(widestPaddedEdgeLabel, flowchartLabelWidth(edge.label, visualLength))
|
||||
}
|
||||
const rankNodeGap = horizontal
|
||||
? minNodeGap
|
||||
: Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP, flowchartVerticalBranchLabelGap(widestPaddedEdgeLabel))
|
||||
const ranks = rankNodes(diagram)
|
||||
const maxRank = Math.max(0, ...ranks.values())
|
||||
const ranksByIndex = new Map<number, FlowchartNode[]>()
|
||||
const normalizedRanks = new Map<string, number>()
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
const rank = ranks.get(node.id) ?? 0
|
||||
const normalizedRank = direction === "RL" || direction === "BT" ? maxRank - rank : rank
|
||||
normalizedRanks.set(node.id, normalizedRank)
|
||||
const nodes = ranksByIndex.get(normalizedRank) ?? []
|
||||
nodes.push(node)
|
||||
ranksByIndex.set(normalizedRank, nodes)
|
||||
}
|
||||
|
||||
const rankKeys = [...ranksByIndex.keys()].sort((a, b) => a - b)
|
||||
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
|
||||
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
|
||||
const bounds = new Map<string, FlowchartNodeBounds>()
|
||||
|
||||
if (horizontal) {
|
||||
const columnWidths = rankKeys.map((rank) =>
|
||||
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.width)),
|
||||
)
|
||||
const columnHeights = rankKeys.map((rank) => {
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
return (
|
||||
nodes.reduce((total, node) => total + sizes.get(node.id)!.height, 0) +
|
||||
Math.max(0, nodes.length - 1) * rankNodeGap
|
||||
)
|
||||
})
|
||||
const canvasHeight = Math.max(1, ...columnHeights)
|
||||
const centerBaseline = Math.floor(canvasHeight / 2)
|
||||
let x = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankKeys[rankIndex]!
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
const columnWidth = columnWidths[rankIndex]!
|
||||
let y = centerBaseline - Math.floor(columnHeights[rankIndex]! / 2)
|
||||
for (const node of nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const left = x + Math.floor((columnWidth - size.width) / 2)
|
||||
bounds.set(node.id, {
|
||||
id: node.id,
|
||||
...size,
|
||||
left,
|
||||
top: y,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: y + Math.floor(size.height / 2),
|
||||
})
|
||||
y += size.height + rankNodeGap
|
||||
}
|
||||
x += columnWidth + (horizontalGaps[rankIndex] ?? 0)
|
||||
}
|
||||
} else {
|
||||
const rowHeights = rankKeys.map((rank) =>
|
||||
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.height)),
|
||||
)
|
||||
const rowWidths = rankKeys.map((rank) => {
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
return (
|
||||
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
|
||||
Math.max(0, nodes.length - 1) * rankNodeGap
|
||||
)
|
||||
})
|
||||
const canvasWidth = Math.max(1, ...rowWidths)
|
||||
let y = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankKeys[rankIndex]!
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
const rowHeight = rowHeights[rankIndex]!
|
||||
let x = Math.floor((canvasWidth - rowWidths[rankIndex]!) / 2)
|
||||
for (const node of nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const top = y + Math.floor((rowHeight - size.height) / 2)
|
||||
bounds.set(node.id, {
|
||||
id: node.id,
|
||||
...size,
|
||||
left: x,
|
||||
top,
|
||||
centerX: x + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
x += size.width + rankNodeGap
|
||||
}
|
||||
y += rowHeight + (verticalGaps[rankIndex] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
return bounds
|
||||
}
|
||||
|
||||
function layoutLocalSubgraphDirections(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): void {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (!subgraph.direction || subgraph.direction === diagram.direction) continue
|
||||
const nodeIds = new Set(subgraph.nodeIds)
|
||||
const nodes = diagram.nodes.filter((node) => nodeIds.has(node.id))
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const currentBounds = boundsFromChildren(nodes.flatMap((node) => nodeBounds.get(node.id) ?? []))
|
||||
if (!currentBounds) continue
|
||||
|
||||
const localDiagram: FlowchartDiagram = {
|
||||
direction: subgraph.direction,
|
||||
nodes,
|
||||
edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)),
|
||||
subgraphs: [],
|
||||
}
|
||||
const localNodeGap = isHorizontalDirection(subgraph.direction) ? Math.max(4, minNodeGap - 1) : minNodeGap
|
||||
const localBounds = layoutRankedNodes(localDiagram, subgraph.direction, sizes, localNodeGap, requestedMinRankGap)
|
||||
const localExtent = boundsFromChildren([...localBounds.values()])
|
||||
if (!localExtent) continue
|
||||
|
||||
const targetLeft = currentBounds.left + Math.floor((currentBounds.width - localExtent.width) / 2)
|
||||
const targetTop = currentBounds.top + Math.floor((currentBounds.height - localExtent.height) / 2)
|
||||
const dx = targetLeft - localExtent.left
|
||||
const dy = targetTop - localExtent.top
|
||||
|
||||
for (const [nodeId, bound] of localBounds) {
|
||||
translateBounds(bound, dx, dy)
|
||||
nodeBounds.set(nodeId, bound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (!subgraph.direction) continue
|
||||
if (subgraph.nodeIds.includes(edge.from) && subgraph.nodeIds.includes(edge.to)) return subgraph.direction
|
||||
}
|
||||
return diagram.direction
|
||||
}
|
||||
|
||||
function collectSubgraphNodeIds(diagram: FlowchartDiagram, subgraphId: string): Set<string> {
|
||||
const nodeIds = new Set<string>()
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
if (subgraph.id !== subgraphId && subgraph.parentId !== subgraphId) continue
|
||||
for (const nodeId of subgraph.nodeIds) nodeIds.add(nodeId)
|
||||
if (subgraph.parentId === subgraphId) {
|
||||
for (const nodeId of collectSubgraphNodeIds(diagram, subgraph.id)) nodeIds.add(nodeId)
|
||||
}
|
||||
}
|
||||
return nodeIds
|
||||
}
|
||||
|
||||
function rankGraphComponents(ids: readonly string[], outgoing: ReadonlyMap<string, ReadonlySet<string>>): Map<string, number> {
|
||||
const reachable = new Map<string, Set<string>>()
|
||||
for (const id of ids) {
|
||||
const seen = new Set<string>()
|
||||
const queue = [id]
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const current = queue[index]!
|
||||
if (seen.has(current)) continue
|
||||
seen.add(current)
|
||||
queue.push(...(outgoing.get(current) ?? []))
|
||||
}
|
||||
reachable.set(id, seen)
|
||||
}
|
||||
|
||||
const componentById = new Map<string, number>()
|
||||
const components: string[][] = []
|
||||
for (const id of ids) {
|
||||
if (componentById.has(id)) continue
|
||||
const component = ids.filter(
|
||||
(candidate) => !componentById.has(candidate) && reachable.get(id)!.has(candidate) && reachable.get(candidate)!.has(id),
|
||||
)
|
||||
const componentIndex = components.length
|
||||
components.push(component)
|
||||
for (const member of component) componentById.set(member, componentIndex)
|
||||
}
|
||||
|
||||
const componentOutgoing = new Map(components.map((_, index) => [index, new Set<number>()]))
|
||||
const incoming = new Map(components.map((_, index) => [index, 0]))
|
||||
for (const [from, targets] of outgoing) {
|
||||
const fromComponent = componentById.get(from)!
|
||||
for (const to of targets) {
|
||||
const toComponent = componentById.get(to)!
|
||||
if (fromComponent === toComponent || componentOutgoing.get(fromComponent)!.has(toComponent)) continue
|
||||
componentOutgoing.get(fromComponent)!.add(toComponent)
|
||||
incoming.set(toComponent, incoming.get(toComponent)! + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const componentRanks = new Map<number, number>()
|
||||
const queue = components.map((_, index) => index).filter((index) => incoming.get(index) === 0)
|
||||
for (const component of queue) componentRanks.set(component, 0)
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const component = queue[index]!
|
||||
for (const to of componentOutgoing.get(component)!) {
|
||||
componentRanks.set(to, Math.max(componentRanks.get(to) ?? 0, componentRanks.get(component)! + 1))
|
||||
incoming.set(to, incoming.get(to)! - 1)
|
||||
if (incoming.get(to) === 0) queue.push(to)
|
||||
}
|
||||
}
|
||||
|
||||
return new Map(ids.map((id) => [id, componentRanks.get(componentById.get(id)!) ?? 0]))
|
||||
}
|
||||
|
||||
function separateTopLevelItems(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
gap: number,
|
||||
): boolean {
|
||||
const hasLocalDirection = (diagram.subgraphs ?? []).some(
|
||||
(subgraph) => subgraph.direction && subgraph.direction !== diagram.direction,
|
||||
)
|
||||
const coveredNodeIds = new Set<string>()
|
||||
const items: { id: string; bounds: FlowchartBounds; nodeIds: Set<string>; rank: number }[] = []
|
||||
const itemByEndpoint = new Map<string, string>()
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
|
||||
const topLevelSubgraphId = (id: string): string => {
|
||||
let current = subgraphById.get(id)
|
||||
while (current?.parentId) current = subgraphById.get(current.parentId)
|
||||
return current?.id ?? id
|
||||
}
|
||||
for (const subgraph of subgraphs) {
|
||||
if (subgraph.parentId) continue
|
||||
const bounds = subgraphBounds.get(subgraph.id)
|
||||
const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
|
||||
if (!bounds || nodeIds.size === 0) continue
|
||||
items.push({ id: subgraph.id, bounds, nodeIds, rank: 0 })
|
||||
itemByEndpoint.set(subgraph.id, subgraph.id)
|
||||
for (const nodeId of nodeIds) {
|
||||
coveredNodeIds.add(nodeId)
|
||||
itemByEndpoint.set(nodeId, subgraph.id)
|
||||
}
|
||||
}
|
||||
for (const subgraph of subgraphs) itemByEndpoint.set(subgraph.id, topLevelSubgraphId(subgraph.id))
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
if (coveredNodeIds.has(node.id)) continue
|
||||
const bounds = nodeBounds.get(node.id)
|
||||
if (!bounds) continue
|
||||
items.push({ id: node.id, bounds, nodeIds: new Set([node.id]), rank: 0 })
|
||||
itemByEndpoint.set(node.id, node.id)
|
||||
}
|
||||
if (items.length < 2) return false
|
||||
|
||||
const horizontal = isHorizontalDirection(diagram.direction)
|
||||
const moveItem = (item: (typeof items)[number], dx: number, dy: number): void => {
|
||||
for (const nodeId of item.nodeIds) {
|
||||
const bounds = nodeBounds.get(nodeId)
|
||||
if (bounds) translateBounds(bounds, dx, dy)
|
||||
}
|
||||
}
|
||||
if (hasLocalDirection) {
|
||||
items.sort((a, b) => (horizontal ? a.bounds.left - b.bounds.left : a.bounds.top - b.bounds.top))
|
||||
let cursor: number | undefined
|
||||
let moved = false
|
||||
for (const item of items) {
|
||||
const start = horizontal ? item.bounds.left : item.bounds.top
|
||||
const size = horizontal ? item.bounds.width : item.bounds.height
|
||||
if (cursor === undefined) {
|
||||
cursor = start + size + gap
|
||||
continue
|
||||
}
|
||||
const shift = cursor - start
|
||||
moved ||= shift !== 0
|
||||
moveItem(item, horizontal ? shift : 0, horizontal ? 0 : shift)
|
||||
cursor = start + shift + size + gap
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
const topLevelIds = new Set(subgraphs.filter((subgraph) => !subgraph.parentId).map((subgraph) => subgraph.id))
|
||||
const rankedItems = items.filter((item) => topLevelIds.has(item.id))
|
||||
if (rankedItems.length < 2) return false
|
||||
|
||||
const itemById = new Map(rankedItems.map((item) => [item.id, item]))
|
||||
const outgoing = new Map(rankedItems.map((item) => [item.id, new Set<string>()]))
|
||||
for (const edge of diagram.edges) {
|
||||
const from = itemByEndpoint.get(edge.from)
|
||||
const to = itemByEndpoint.get(edge.to)
|
||||
if (!from || !to || from === to || !itemById.has(from) || !itemById.has(to) || outgoing.get(from)!.has(to)) continue
|
||||
outgoing.get(from)!.add(to)
|
||||
}
|
||||
|
||||
const ranks = rankGraphComponents(
|
||||
rankedItems.map((item) => item.id),
|
||||
outgoing,
|
||||
)
|
||||
for (const item of rankedItems) item.rank = ranks.get(item.id)!
|
||||
|
||||
const reversed = diagram.direction === "RL" || diagram.direction === "BT"
|
||||
const primaryStart = (item: (typeof items)[number]): number => {
|
||||
const start = horizontal ? item.bounds.left : item.bounds.top
|
||||
const size = horizontal ? item.bounds.width : item.bounds.height
|
||||
return reversed ? -(start + size) : start
|
||||
}
|
||||
const itemsByRank = Map.groupBy(rankedItems, (item) => item.rank)
|
||||
const rankKeys = [...itemsByRank.keys()].sort((a, b) => a - b)
|
||||
let cursor: number | undefined
|
||||
let moved = false
|
||||
for (const rank of rankKeys) {
|
||||
const rankItems = itemsByRank.get(rank)!
|
||||
const start = Math.min(...rankItems.map(primaryStart))
|
||||
const end = Math.max(
|
||||
...rankItems.map((item) => primaryStart(item) + (horizontal ? item.bounds.width : item.bounds.height)),
|
||||
)
|
||||
if (cursor === undefined) {
|
||||
cursor = end + gap
|
||||
continue
|
||||
}
|
||||
const shift = Math.max(0, cursor - start)
|
||||
if (shift > 0) {
|
||||
moved = true
|
||||
for (const item of rankItems) {
|
||||
const offset = reversed ? -shift : shift
|
||||
moveItem(item, horizontal ? offset : 0, horizontal ? 0 : offset)
|
||||
}
|
||||
}
|
||||
cursor = end + shift + gap
|
||||
}
|
||||
|
||||
for (const rank of rankKeys) {
|
||||
const rankItems = itemsByRank
|
||||
.get(rank)!
|
||||
.toSorted((a, b) =>
|
||||
horizontal ? a.bounds.top - b.bounds.top : a.bounds.left - b.bounds.left,
|
||||
)
|
||||
let crossCursor: number | undefined
|
||||
for (const item of rankItems) {
|
||||
const start = horizontal ? item.bounds.top : item.bounds.left
|
||||
const size = horizontal ? item.bounds.height : item.bounds.width
|
||||
if (crossCursor === undefined) {
|
||||
crossCursor = start + size + gap
|
||||
continue
|
||||
}
|
||||
const shift = Math.max(0, crossCursor - start)
|
||||
if (shift > 0) {
|
||||
moved = true
|
||||
moveItem(item, horizontal ? 0 : shift, horizontal ? shift : 0)
|
||||
}
|
||||
crossCursor = start + shift + size + gap
|
||||
}
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
function layoutSubgraphs(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): Map<string, FlowchartSubgraphBounds> {
|
||||
const subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
|
||||
for (const subgraph of [...subgraphs].reverse()) {
|
||||
const children: FlowchartBounds[] = []
|
||||
for (const nodeId of subgraph.nodeIds) {
|
||||
const bound = nodeBounds.get(nodeId)
|
||||
if (bound) children.push(bound)
|
||||
}
|
||||
children.push(...subgraphRouteBounds(new Set(subgraph.nodeIds), routes))
|
||||
for (const childSubgraph of subgraphs) {
|
||||
if (childSubgraph.parentId !== subgraph.id) continue
|
||||
const bound = subgraphBounds.get(childSubgraph.id)
|
||||
if (bound) children.push(bound)
|
||||
}
|
||||
if (children.length > 0) {
|
||||
const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children)
|
||||
bound.labelSide = chooseSubgraphLabelSide(bound, routes)
|
||||
subgraphBounds.set(subgraph.id, bound)
|
||||
}
|
||||
}
|
||||
|
||||
return subgraphBounds
|
||||
}
|
||||
|
||||
function layoutFlowchartWithDirection(
|
||||
sourceDiagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartLayout {
|
||||
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
const minNodeGap = normalizePositiveInt(options.minNodeGap, DEFAULT_MIN_NODE_GAP)
|
||||
const requestedMinRankGap = normalizePositiveInt(
|
||||
options.minRankGap,
|
||||
options.compact
|
||||
? horizontal
|
||||
? COMPACT_MIN_RANK_GAP
|
||||
: COMPACT_MIN_VERTICAL_RANK_GAP
|
||||
: horizontal
|
||||
? DEFAULT_MIN_RANK_GAP
|
||||
: DEFAULT_MIN_VERTICAL_RANK_GAP,
|
||||
)
|
||||
const sizes = new Map(diagram.nodes.map((node) => [node.id, nodeSize(node)]))
|
||||
const bounds = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap)
|
||||
layoutLocalSubgraphDirections(diagram, bounds, sizes, minNodeGap, requestedMinRankGap)
|
||||
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
let subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
||||
let routes: FlowchartEdgeRoute[]
|
||||
if (subgraphs.length === 0) {
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
} else {
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
const moved = separateTopLevelItems(
|
||||
diagram,
|
||||
bounds,
|
||||
subgraphBounds,
|
||||
Math.max(1, Math.floor(requestedMinRankGap / 2)),
|
||||
)
|
||||
if (moved) {
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
}
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
}
|
||||
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
|
||||
const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left)))
|
||||
const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top)))
|
||||
if (dx > 0 || dy > 0) {
|
||||
for (const bound of allBounds) translateBounds(bound, dx, dy)
|
||||
translateRoutes(routes, dx, dy)
|
||||
}
|
||||
|
||||
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
|
||||
const maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
return { diagram, bounds, routes, subgraphBounds, width: maxX + 4, height: maxY + 4 }
|
||||
}
|
||||
|
||||
export function layoutFlowchartDiagram(
|
||||
sourceDiagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions = {},
|
||||
): FlowchartLayout {
|
||||
const direction = options.direction ?? sourceDiagram.direction
|
||||
const layout = layoutFlowchartWithDirection(sourceDiagram, options, direction)
|
||||
const maxWidth = options.layoutMaxWidth
|
||||
if (!isHorizontalDirection(direction) || maxWidth === undefined || !Number.isFinite(maxWidth)) return layout
|
||||
if (layout.width <= Math.max(1, Math.trunc(maxWidth))) return layout
|
||||
|
||||
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD")
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { BorderStyle } from "@opentui/core"
|
||||
import type { FlowchartDirection } from "./types.js"
|
||||
|
||||
export interface FlowchartDiagramRenderOptions {
|
||||
compact?: boolean
|
||||
direction?: FlowchartDirection
|
||||
borderStyle?: BorderStyle
|
||||
minNodeGap?: number
|
||||
minRankGap?: number
|
||||
/** Fold oversized horizontal layouts vertically when their rendered width exceeds this limit. */
|
||||
layoutMaxWidth?: number
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeStyle,
|
||||
FlowchartNode,
|
||||
FlowchartSubgraph,
|
||||
} from "./types.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import {
|
||||
firstMeaningfulMermaidLine,
|
||||
meaningfulNumberedMermaidLines,
|
||||
stripMermaidQuotes as stripQuotes,
|
||||
} from "../core/mermaid.js"
|
||||
|
||||
const DEFAULT_DIRECTION = "TD" satisfies FlowchartDirection
|
||||
const FLOWCHART_HEADER_RE = /^(flowchart|graph)(?:\s+(TB|TD|BT|LR|RL))?$/i
|
||||
const ID_RE = "[A-Za-z_][A-Za-z0-9_.-]*"
|
||||
const SUBGRAPH_RE = /^subgraph\s+(.+)$/i
|
||||
const SUBGRAPH_WITH_LABEL_RE = new RegExp(`^(${ID_RE})\\s*\\[(.+)\\]$`)
|
||||
const SUBGRAPH_DIRECTION_RE = /^direction\s+(TB|TD|BT|LR|RL)$/i
|
||||
const IGNORED_PRESENTATION_RE = /^(?:classDef|class|style|linkStyle)\b/i
|
||||
const DATABASE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\((.+)\\)\\]$`)
|
||||
const SUBROUTINE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\[(.+)\\]\\]$`)
|
||||
const ROUNDED_BRACKET_NODE_RE = new RegExp(`^(${ID_RE})\\(\\[(.+)\\]\\)$`)
|
||||
const ROUNDED_NODE_RE = new RegExp(`^(${ID_RE})\\((.+)\\)$`)
|
||||
const DECISION_NODE_RE = new RegExp(`^(${ID_RE})\\{(.+)\\}$`)
|
||||
const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`)
|
||||
const ID_ONLY_RE = new RegExp(`^${ID_RE}$`)
|
||||
const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
|
||||
const EDGE_OPERATOR_RE =
|
||||
/(-\.(?!->)(.+?)\.->)|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->)|(-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/g
|
||||
|
||||
function normalizeDirection(value?: string): FlowchartDirection {
|
||||
const upper = value?.toUpperCase()
|
||||
if (upper === "TB" || upper === "TD" || upper === "BT" || upper === "LR" || upper === "RL") return upper
|
||||
return DEFAULT_DIRECTION
|
||||
}
|
||||
|
||||
function normalizeSubgraphId(value: string, index: number): string {
|
||||
const stripped = stripQuotes(value)
|
||||
return ID_ONLY_RE.test(stripped) ? stripped : `subgraph_${index + 1}`
|
||||
}
|
||||
|
||||
function parseSubgraphToken(token: string, index: number): Pick<FlowchartSubgraph, "id" | "label"> {
|
||||
const trimmed = token
|
||||
.trim()
|
||||
.replace(/\s*:::.*$/, "")
|
||||
.replace(/;$/, "")
|
||||
const withLabel = trimmed.match(SUBGRAPH_WITH_LABEL_RE)
|
||||
if (withLabel) {
|
||||
return { id: withLabel[1]!, label: stripQuotes(withLabel[2]!) }
|
||||
}
|
||||
|
||||
const label = stripQuotes(trimmed)
|
||||
return { id: normalizeSubgraphId(trimmed, index), label }
|
||||
}
|
||||
|
||||
function parseNodeToken(token: string): FlowchartNode {
|
||||
const trimmed = token.trim().replace(/;$/, "")
|
||||
const database = trimmed.match(DATABASE_NODE_RE)
|
||||
if (database) return { id: database[1]!, label: stripQuotes(database[2]!), shape: "database" }
|
||||
|
||||
const subroutine = trimmed.match(SUBROUTINE_NODE_RE)
|
||||
if (subroutine) return { id: subroutine[1]!, label: stripQuotes(subroutine[2]!), shape: "subroutine" }
|
||||
|
||||
const roundedBracket = trimmed.match(ROUNDED_BRACKET_NODE_RE)
|
||||
if (roundedBracket) return { id: roundedBracket[1]!, label: stripQuotes(roundedBracket[2]!), shape: "rounded" }
|
||||
|
||||
const rounded = trimmed.match(ROUNDED_NODE_RE)
|
||||
if (rounded) return { id: rounded[1]!, label: stripQuotes(rounded[2]!), shape: "rounded" }
|
||||
|
||||
const decision = trimmed.match(DECISION_NODE_RE)
|
||||
if (decision) return { id: decision[1]!, label: stripQuotes(decision[2]!), shape: "decision" }
|
||||
|
||||
const box = trimmed.match(BOX_NODE_RE)
|
||||
if (box) return { id: box[1]!, label: stripQuotes(box[2]!), shape: "box" }
|
||||
|
||||
return { id: trimmed, label: trimmed, shape: "box" }
|
||||
}
|
||||
|
||||
function hasExplicitNodeShape(token: string): boolean {
|
||||
return EXPLICIT_NODE_SHAPE_RE.test(token.trim())
|
||||
}
|
||||
|
||||
function ensureNode(nodes: Map<string, FlowchartNode>, token: string): FlowchartNode {
|
||||
const node = parseNodeToken(token)
|
||||
const existing = nodes.get(node.id)
|
||||
if (!existing) {
|
||||
nodes.set(node.id, node)
|
||||
return node
|
||||
}
|
||||
|
||||
if (hasExplicitNodeShape(token)) {
|
||||
existing.label = node.label
|
||||
existing.shape = node.shape
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
function addNodeToSubgraph(subgraph: FlowchartSubgraph | undefined, nodeId: string): void {
|
||||
if (!subgraph || subgraph.nodeIds.includes(nodeId)) return
|
||||
subgraph.nodeIds.push(nodeId)
|
||||
}
|
||||
|
||||
function stripNodeToken(token: string): string {
|
||||
return token
|
||||
.replace(/\s*:::.*$/, "")
|
||||
.replace(/;$/, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
|
||||
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
|
||||
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function createEdge(
|
||||
from: string,
|
||||
to: string,
|
||||
label: string,
|
||||
style: FlowchartEdgeStyle | undefined,
|
||||
arrowhead: boolean,
|
||||
): FlowchartEdge {
|
||||
const edge: FlowchartEdge = style ? { from, to, label, style } : { from, to, label }
|
||||
if (!arrowhead) edge.arrowhead = false
|
||||
return edge
|
||||
}
|
||||
|
||||
interface ParsedEdgeOperator {
|
||||
index: number
|
||||
end: number
|
||||
label: string
|
||||
style: FlowchartEdgeStyle | undefined
|
||||
arrowhead: boolean
|
||||
orderOnly: boolean
|
||||
}
|
||||
|
||||
function parseEdgeOperators(line: string): ParsedEdgeOperator[] {
|
||||
return [...line.matchAll(EDGE_OPERATOR_RE)].map((match) => {
|
||||
const inlineDashedArrow = match[1]
|
||||
const startArrow = inlineDashedArrow ?? match[3] ?? match[6]!
|
||||
const endArrow = inlineDashedArrow ?? match[5] ?? match[6]!
|
||||
return {
|
||||
index: match.index,
|
||||
end: match.index + match[0].length,
|
||||
label: (match[2] ?? match[4] ?? match[7] ?? "").trim(),
|
||||
style: edgeStyleFromArrow(startArrow, endArrow),
|
||||
arrowhead: endArrow !== "---",
|
||||
orderOnly: endArrow === "~~~",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function isMermaidFlowchartDiagram(content: string): boolean {
|
||||
return FLOWCHART_HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
|
||||
}
|
||||
|
||||
export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram {
|
||||
const nodes = new Map<string, FlowchartNode>()
|
||||
const edges: FlowchartEdge[] = []
|
||||
const subgraphs: FlowchartSubgraph[] = []
|
||||
const subgraphStack: Array<{ subgraph: FlowchartSubgraph; lineNumber: number; sourceLine: string }> = []
|
||||
let direction: FlowchartDirection = DEFAULT_DIRECTION
|
||||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
const header = line.match(FLOWCHART_HEADER_RE)
|
||||
if (header) {
|
||||
direction = normalizeDirection(header[2])
|
||||
continue
|
||||
}
|
||||
|
||||
// Mermaid CSS styling does not apply to terminal theme rendering.
|
||||
if (IGNORED_PRESENTATION_RE.test(line)) continue
|
||||
|
||||
const subgraphMatch = line.match(SUBGRAPH_RE)
|
||||
if (subgraphMatch) {
|
||||
const parsed = parseSubgraphToken(subgraphMatch[1]!, subgraphs.length)
|
||||
const subgraph: FlowchartSubgraph = {
|
||||
...parsed,
|
||||
nodeIds: [],
|
||||
parentId: subgraphStack[subgraphStack.length - 1]?.subgraph.id,
|
||||
}
|
||||
subgraphs.push(subgraph)
|
||||
subgraphStack.push({ subgraph, lineNumber: source.lineNumber, sourceLine: line })
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^end$/i.test(line)) {
|
||||
if (subgraphStack.length === 0) {
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line, 'Unexpected "end" without an open subgraph')
|
||||
}
|
||||
subgraphStack.pop()
|
||||
continue
|
||||
}
|
||||
|
||||
const currentSubgraph = subgraphStack[subgraphStack.length - 1]?.subgraph
|
||||
|
||||
const subgraphDirection = line.match(SUBGRAPH_DIRECTION_RE)
|
||||
if (subgraphDirection) {
|
||||
if (!currentSubgraph) {
|
||||
throw new MermaidSyntaxError(
|
||||
"flowchart",
|
||||
source.lineNumber,
|
||||
line,
|
||||
'A "direction" statement requires an open subgraph',
|
||||
)
|
||||
}
|
||||
currentSubgraph.direction = normalizeDirection(subgraphDirection[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const edgeOperators = parseEdgeOperators(line)
|
||||
if (edgeOperators.length > 0) {
|
||||
const nodeTokens = [
|
||||
line.slice(0, edgeOperators[0]!.index),
|
||||
...edgeOperators.map((operator, index) =>
|
||||
line.slice(operator.end, edgeOperators[index + 1]?.index ?? line.length),
|
||||
),
|
||||
]
|
||||
|
||||
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
|
||||
const chainNodeIds = nodeTokens.map((token, index) => {
|
||||
const stripped = stripNodeToken(token)
|
||||
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
|
||||
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
|
||||
return ensureNode(nodes, stripped).id
|
||||
})
|
||||
for (const nodeId of chainNodeIds) {
|
||||
if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId)
|
||||
}
|
||||
for (let index = 0; index < edgeOperators.length; index++) {
|
||||
const operator = edgeOperators[index]!
|
||||
const edge = createEdge(
|
||||
chainNodeIds[index]!,
|
||||
chainNodeIds[index + 1]!,
|
||||
operator.label,
|
||||
operator.style,
|
||||
operator.arrowhead,
|
||||
)
|
||||
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (hasExplicitNodeShape(line) || ID_ONLY_RE.test(stripNodeToken(line))) {
|
||||
const node = ensureNode(nodes, line)
|
||||
addNodeToSubgraph(currentSubgraph, node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
}
|
||||
|
||||
const unclosedSubgraph = subgraphStack[subgraphStack.length - 1]
|
||||
if (unclosedSubgraph) {
|
||||
throw new MermaidSyntaxError(
|
||||
"flowchart",
|
||||
unclosedSubgraph.lineNumber,
|
||||
unclosedSubgraph.sourceLine,
|
||||
'Unclosed subgraph; expected "end"',
|
||||
)
|
||||
}
|
||||
|
||||
return { direction, nodes: [...nodes.values()], edges, subgraphs }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { drawFlowchartDiagramGrid } from "./drawing.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./parser.js"
|
||||
|
||||
export function renderFlowchartDiagram(content: string, options: FlowchartDiagramRenderOptions = {}): string {
|
||||
return drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(content), options).toString({
|
||||
trimTop: true,
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FlowchartDiagram, FlowchartNodeBounds } from "./types.js"
|
||||
import { routeFlowchartEdges } from "./routing.js"
|
||||
|
||||
function bounds(id: string, left: number, top: number): FlowchartNodeBounds {
|
||||
const width = 5
|
||||
const height = 3
|
||||
return {
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
lines: [id],
|
||||
left,
|
||||
top,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
function diagram(direction: FlowchartDiagram["direction"], edges: FlowchartDiagram["edges"]): FlowchartDiagram {
|
||||
return { direction, nodes: [], edges, subgraphs: [] }
|
||||
}
|
||||
|
||||
describe("flowchart routing", () => {
|
||||
test("routes a simple horizontal edge from source port to target port", () => {
|
||||
const edge = { from: "A", to: "B", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes a simple reverse horizontal edge into the target right port", () => {
|
||||
const edge = { from: "A", to: "B", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("RL", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 20, 0)],
|
||||
["B", bounds("B", 0, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 19, y: 1 },
|
||||
{ x: 5, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes horizontal back-edges above forward lanes", () => {
|
||||
const edge = { from: "B", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 22, y: -1 },
|
||||
{ x: 22, y: -4 },
|
||||
{ x: 2, y: -4 },
|
||||
{ x: 2, y: -1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes parallel horizontal edges on independent lanes", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes.map((route) => route.points)).toEqual([
|
||||
[
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 2, y: 3 },
|
||||
{ x: 2, y: 6 },
|
||||
{ x: 22, y: 6 },
|
||||
{ x: 22, y: 3 },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("spaces parallel horizontal lanes for multiline labels", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second 1<br/>second 2<br/>second 3" },
|
||||
{ from: "A", to: "B", label: "third 1<br/>third 2<br/>third 3" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes[2]!.points[1]!.y).toBeGreaterThan(routes[1]!.points[1]!.y + 3)
|
||||
})
|
||||
|
||||
test("routes horizontal fan-out through a shared bus lane", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "" },
|
||||
{ from: "A", to: "C", label: "" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 6)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
["C", bounds("C", 20, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes.map((route) => route.points)).toEqual([
|
||||
[
|
||||
{ x: 5, y: 7 },
|
||||
{ x: 8, y: 7 },
|
||||
{ x: 8, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 5, y: 7 },
|
||||
{ x: 8, y: 7 },
|
||||
{ x: 8, y: 13 },
|
||||
{ x: 19, y: 13 },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("routes each horizontal edge once when fan-in and fan-out overlap", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "C", label: "" },
|
||||
{ from: "A", to: "D", label: "" },
|
||||
{ from: "B", to: "C", label: "" },
|
||||
{ from: "B", to: "D", label: "" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 0, 12)],
|
||||
["C", bounds("C", 24, 0)],
|
||||
["D", bounds("D", 24, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toHaveLength(edges.length)
|
||||
expect(routes.map((route) => `${route.edge.from}->${route.edge.to}`).sort()).toEqual([
|
||||
"A->C",
|
||||
"A->D",
|
||||
"B->C",
|
||||
"B->D",
|
||||
])
|
||||
})
|
||||
|
||||
test("routes vertical back-edges around the left side", () => {
|
||||
const edge = { from: "B", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("TD", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 0, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: -1, y: 13 },
|
||||
{ x: -4, y: 13 },
|
||||
{ x: -4, y: 1 },
|
||||
{ x: -1, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes self edges below the source node", () => {
|
||||
const edge = { from: "A", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(diagram("TD", [edge]), new Map([["A", bounds("A", 0, 0)]]))
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 8, y: 1 },
|
||||
{ x: 8, y: 4 },
|
||||
{ x: 2, y: 4 },
|
||||
{ x: 2, y: 3 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes same-column horizontal-flow edges through vertical ports", () => {
|
||||
const edge = { from: "A", to: "B", label: "rollback" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 20, 0)],
|
||||
["B", bounds("B", 20, 8)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 22, y: 3 },
|
||||
{ x: 22, y: 7 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes overlapping horizontal-flow columns through vertical ports", () => {
|
||||
const edge = { from: "A", to: "B", label: "merge" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("RL", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 1, 8)],
|
||||
["B", bounds("B", 0, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 2, y: 7 },
|
||||
{ x: 2, y: 3 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,708 @@
|
||||
import {
|
||||
advanceCoordinate,
|
||||
afterFarthestCoordinate,
|
||||
beforeNearestCoordinate,
|
||||
boundsCenter,
|
||||
boundsSidePoint,
|
||||
centerCoordinate,
|
||||
coordinate,
|
||||
keepAfter,
|
||||
keepBefore,
|
||||
lane,
|
||||
oppositeSide,
|
||||
orthogonalPath,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
sideForDirection,
|
||||
snapCoordinate,
|
||||
withCoordinate,
|
||||
type DiagramAxis,
|
||||
type DiagramDirection,
|
||||
type DiagramLane,
|
||||
type DiagramSide,
|
||||
} from "../core/geometry.js"
|
||||
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartPoint,
|
||||
FlowchartSubgraph,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export { directionBetween as flowchartDirectionBetween } from "../core/geometry.js"
|
||||
|
||||
const BUS_CLEARANCE = 3
|
||||
const NODE_CLEARANCE = 2
|
||||
type HorizontalTravel = Extract<DiagramDirection, "left" | "right">
|
||||
type VerticalTravel = Extract<DiagramDirection, "up" | "down">
|
||||
type PortRole = "source" | "target"
|
||||
|
||||
interface EdgeRecord {
|
||||
edge: FlowchartEdge
|
||||
sourcePort: FlowchartPoint
|
||||
targetPort: FlowchartPoint
|
||||
}
|
||||
|
||||
function isVerticalDirection(direction: FlowchartDirection): boolean {
|
||||
return direction === "TB" || direction === "TD" || direction === "BT"
|
||||
}
|
||||
|
||||
function verticalTravel(from: FlowchartNodeBounds, to: FlowchartNodeBounds): VerticalTravel {
|
||||
return centerCoordinate(to, "y") >= centerCoordinate(from, "y") ? "down" : "up"
|
||||
}
|
||||
|
||||
function isVerticalBackEdge(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): boolean {
|
||||
return direction === "BT"
|
||||
? centerCoordinate(to, "y") > centerCoordinate(from, "y")
|
||||
: centerCoordinate(to, "y") < centerCoordinate(from, "y")
|
||||
}
|
||||
|
||||
function isHorizontalBackEdge(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): boolean {
|
||||
return direction === "RL"
|
||||
? centerCoordinate(to, "x") > centerCoordinate(from, "x")
|
||||
: centerCoordinate(to, "x") < centerCoordinate(from, "x")
|
||||
}
|
||||
|
||||
function horizontalTravel(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): HorizontalTravel {
|
||||
const targetIsRight = centerCoordinate(to, "x") > centerCoordinate(from, "x")
|
||||
const targetIsSameOrRight = centerCoordinate(to, "x") >= centerCoordinate(from, "x")
|
||||
return direction === "RL" ? (targetIsRight ? "right" : "left") : targetIsSameOrRight ? "right" : "left"
|
||||
}
|
||||
|
||||
function verticalBackEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
leftBoundary?: number,
|
||||
): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(from, "left")
|
||||
const end = boundsSidePoint(to, "left")
|
||||
const busX = Math.min(
|
||||
afterFarthestCoordinate([start, end], "x", "left", BUS_CLEARANCE),
|
||||
leftBoundary === undefined ? Number.POSITIVE_INFINITY : leftBoundary - BUS_CLEARANCE * 2,
|
||||
)
|
||||
return pathViaLane(start, lane("x", busX), end)
|
||||
}
|
||||
|
||||
function verticalForwardEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const travel = verticalTravel(from, to)
|
||||
const startSide = sideForDirection(travel)
|
||||
const endSide = oppositeSide(startSide)
|
||||
const sourceCenter = boundsCenter(from)
|
||||
const targetCenter = boundsCenter(to)
|
||||
const start = withCoordinate(boundsSidePoint(from, startSide), "x", snapCoordinate(sourceCenter.x, targetCenter.x, 1))
|
||||
const end = boundsSidePoint(to, endSide)
|
||||
return orthogonalPath(start, end, { preferredAxis: "y" })
|
||||
}
|
||||
|
||||
function horizontalBackEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(from, "top")
|
||||
const end = boundsSidePoint(to, "top")
|
||||
const busY = afterFarthestCoordinate([start, end], "y", "up", BUS_CLEARANCE)
|
||||
return pathViaLane(start, lane("y", busY), end)
|
||||
}
|
||||
|
||||
function horizontalEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartPoint[] {
|
||||
const overlapsHorizontally = from.left < to.left + to.width && to.left < from.left + from.width
|
||||
if (overlapsHorizontally) return verticalForwardEdgePath(from, to)
|
||||
|
||||
if (isHorizontalBackEdge(from, to, direction)) return horizontalBackEdgePath(from, to)
|
||||
|
||||
const travel = horizontalTravel(from, to, direction)
|
||||
const startSide = sideForDirection(travel)
|
||||
return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)))
|
||||
}
|
||||
|
||||
function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(bounds, "right")
|
||||
const end = boundsSidePoint(bounds, "bottom")
|
||||
const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE
|
||||
const bottomLaneY = bounds.top + bounds.height + 1
|
||||
return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
|
||||
}
|
||||
|
||||
function parallelEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
laneCoordinate: number,
|
||||
): FlowchartPoint[] {
|
||||
if (!isVerticalDirection(direction)) {
|
||||
const start = boundsSidePoint(from, "bottom")
|
||||
const end = boundsSidePoint(to, "bottom")
|
||||
return pathViaLane(start, lane("y", laneCoordinate), end)
|
||||
}
|
||||
|
||||
const start = boundsSidePoint(from, "right")
|
||||
const end = boundsSidePoint(to, "right")
|
||||
return pathViaLane(start, lane("x", laneCoordinate), end)
|
||||
}
|
||||
|
||||
function labelHeight(edge: FlowchartEdge): number {
|
||||
return edge.label ? splitDiagramLines(edge.label).length : 0
|
||||
}
|
||||
|
||||
function rightRenderExtent(route: FlowchartEdgeRoute): number {
|
||||
let right = Math.max(...route.points.map((point) => point.x))
|
||||
if (route.edge.label) {
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth)
|
||||
right = Math.max(right, label.point.x + label.width - 1)
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
function edgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
leftBoundary?: number,
|
||||
): FlowchartPoint[] {
|
||||
if (from.id === to.id) return selfEdgePath(from)
|
||||
if (!isVerticalDirection(direction)) return horizontalEdgePath(from, to, direction)
|
||||
return isVerticalBackEdge(from, to, direction)
|
||||
? verticalBackEdgePath(from, to, leftBoundary)
|
||||
: verticalForwardEdgePath(from, to)
|
||||
}
|
||||
|
||||
function sourceFanOutLane(
|
||||
sourcePort: FlowchartPoint,
|
||||
targetPorts: readonly FlowchartPoint[],
|
||||
axis: DiagramAxis,
|
||||
travel: DiagramDirection,
|
||||
): number {
|
||||
const sourceCoordinate = coordinate(sourcePort, axis)
|
||||
const unclamped = keepBefore(
|
||||
advanceCoordinate(coordinate(sourcePort, axis), travel, BUS_CLEARANCE),
|
||||
beforeNearestCoordinate(targetPorts, axis, travel, NODE_CLEARANCE),
|
||||
travel,
|
||||
)
|
||||
return keepAfter(unclamped, sourceCoordinate, travel)
|
||||
}
|
||||
|
||||
function targetFanInLane(
|
||||
sourcePorts: readonly FlowchartPoint[],
|
||||
targetPort: FlowchartPoint,
|
||||
axis: DiagramAxis,
|
||||
travel: DiagramDirection,
|
||||
): number {
|
||||
const targetCoordinate = coordinate(targetPort, axis)
|
||||
const unclamped = keepAfter(
|
||||
advanceCoordinate(coordinate(targetPort, axis), travel, -BUS_CLEARANCE),
|
||||
afterFarthestCoordinate(sourcePorts, axis, travel, NODE_CLEARANCE),
|
||||
travel,
|
||||
)
|
||||
return keepBefore(unclamped, targetCoordinate, travel)
|
||||
}
|
||||
|
||||
function portForTravel(bounds: FlowchartNodeBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint {
|
||||
const side = role === "source" ? sideForDirection(travel) : oppositeSide(sideForDirection(travel))
|
||||
return boundsSidePoint(bounds, side)
|
||||
}
|
||||
|
||||
function horizontalForwardRecords(
|
||||
edges: FlowchartEdge[],
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): EdgeRecord[] {
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const records: EdgeRecord[] = []
|
||||
for (const edge of edges) {
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!source || !target) continue
|
||||
const forward =
|
||||
direction === "RL"
|
||||
? centerCoordinate(target, "x") < centerCoordinate(source, "x")
|
||||
: centerCoordinate(target, "x") > centerCoordinate(source, "x")
|
||||
if (!forward) continue
|
||||
records.push({
|
||||
edge,
|
||||
sourcePort: portForTravel(source, travel, "source"),
|
||||
targetPort: portForTravel(target, travel, "target"),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function verticalForwardRecords(
|
||||
edges: FlowchartEdge[],
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): EdgeRecord[] {
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const records: EdgeRecord[] = []
|
||||
for (const edge of edges) {
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!source || !target) continue
|
||||
const forward =
|
||||
direction === "BT"
|
||||
? centerCoordinate(target, "y") < centerCoordinate(source, "y")
|
||||
: centerCoordinate(target, "y") > centerCoordinate(source, "y")
|
||||
if (!forward) continue
|
||||
records.push({
|
||||
edge,
|
||||
sourcePort: portForTravel(source, travel, "source"),
|
||||
targetPort: portForTravel(target, travel, "target"),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function horizontalExitSubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
|
||||
if (subgraph.nodeIds.includes(edge.from) && !subgraph.nodeIds.includes(edge.to)) return subgraph
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function horizontalEntrySubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
|
||||
if (subgraph.nodeIds.includes(edge.to) && !subgraph.nodeIds.includes(edge.from)) return subgraph
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function horizontalSubgraphEntryTravel(subgraph: FlowchartSubgraph): HorizontalTravel {
|
||||
return subgraph.direction === "RL" ? "left" : "right"
|
||||
}
|
||||
|
||||
function horizontalSubgraphEntryLane(subgraph: FlowchartSubgraph, subgraphBound: FlowchartSubgraphBounds): number {
|
||||
return subgraph.direction === "RL"
|
||||
? subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
|
||||
: subgraphBound.left - BUS_CLEARANCE
|
||||
}
|
||||
|
||||
function horizontalSubgraphJoinY(from: FlowchartSubgraphBounds, targetSubgraphBound: FlowchartSubgraphBounds): number {
|
||||
if (from.centerY <= targetSubgraphBound.centerY) {
|
||||
const start = from.top + from.height
|
||||
const end = targetSubgraphBound.top - 1
|
||||
return start <= end ? Math.floor((start + end) / 2) : start
|
||||
}
|
||||
|
||||
const start = targetSubgraphBound.top + targetSubgraphBound.height
|
||||
const end = from.top - 1
|
||||
return start <= end ? Math.floor((start + end) / 2) : end
|
||||
}
|
||||
|
||||
function horizontalSubgraphExitJoinY(
|
||||
from: FlowchartSubgraphBounds,
|
||||
targetPort: FlowchartPoint,
|
||||
targetBelow: boolean,
|
||||
): number {
|
||||
if (targetBelow) {
|
||||
const outside = from.top + from.height
|
||||
const beforeTarget = targetPort.y - 1
|
||||
const preferred = targetPort.y - BUS_CLEARANCE
|
||||
return outside <= beforeTarget ? Math.min(Math.max(outside, preferred), beforeTarget) : beforeTarget
|
||||
}
|
||||
|
||||
const outside = from.top - 1
|
||||
const afterTarget = targetPort.y + 1
|
||||
const preferred = targetPort.y + BUS_CLEARANCE
|
||||
return afterTarget <= outside ? Math.max(Math.min(outside, preferred), afterTarget) : afterTarget
|
||||
}
|
||||
|
||||
function groupRecords<Record>(records: readonly Record[], key: (record: Record) => string): Map<string, Record[]> {
|
||||
const groups = new Map<string, Record[]>()
|
||||
for (const record of records) {
|
||||
const groupKey = key(record)
|
||||
const group = groups.get(groupKey) ?? []
|
||||
group.push(record)
|
||||
groups.set(groupKey, group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function fanRoute(
|
||||
edge: FlowchartEdge,
|
||||
sourcePort: FlowchartPoint,
|
||||
targetPort: FlowchartPoint,
|
||||
routeLane: DiagramLane,
|
||||
): FlowchartEdgeRoute {
|
||||
return { edge, points: pathViaLane(sourcePort, routeLane, targetPort) }
|
||||
}
|
||||
|
||||
function alignClusteredVerticalSources(records: readonly EdgeRecord[]): EdgeRecord[] {
|
||||
const xs = records.map((record) => record.sourcePort.x)
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
if (maxX - minX > 1) return [...records]
|
||||
|
||||
const x = Math.round(xs.reduce((total, value) => total + value, 0) / xs.length)
|
||||
return records.map((record) => ({ ...record, sourcePort: { ...record.sourcePort, x } }))
|
||||
}
|
||||
|
||||
function routeHorizontalFanOut(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
|
||||
if (sourceRecords.length < 2) continue
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const sourcePort = sourceRecords[0]!.sourcePort
|
||||
const targetPorts = sourceRecords.map((record) => record.targetPort)
|
||||
|
||||
const busX = sourceFanOutLane(sourcePort, targetPorts, "x", travel)
|
||||
for (const record of sourceRecords) {
|
||||
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalFanIn(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
|
||||
for (const targetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
|
||||
if (targetRecords.length < 2) continue
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const targetPort = targetRecords[0]!.targetPort
|
||||
const sourcePorts = targetRecords.map((record) => record.sourcePort)
|
||||
|
||||
const busX = targetFanInLane(sourcePorts, targetPort, "x", travel)
|
||||
for (const record of targetRecords) {
|
||||
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("x", busX)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeVerticalFanOut(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
|
||||
if (sourceRecords.length < 2) continue
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const sourcePort = sourceRecords[0]!.sourcePort
|
||||
const targetPorts = sourceRecords.map((record) => record.targetPort)
|
||||
|
||||
const busY = sourceFanOutLane(sourcePort, targetPorts, "y", travel)
|
||||
for (const record of sourceRecords) {
|
||||
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("y", busY)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeVerticalFanIn(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
|
||||
for (const unalignedTargetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
|
||||
const targetRecords = alignClusteredVerticalSources(unalignedTargetRecords)
|
||||
if (targetRecords.length < 2) continue
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const targetPort = targetRecords[0]!.targetPort
|
||||
const sourcePorts = targetRecords.map((record) => record.sourcePort)
|
||||
|
||||
const busY = targetFanInLane(sourcePorts, targetPort, "y", travel)
|
||||
for (const record of targetRecords) {
|
||||
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("y", busY)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeParallelEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
|
||||
leftBoundary: number | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const groups = groupRecords(diagram.edges, (edge) => `${directionForEdge(edge)}:${edge.from}:${edge.to}`)
|
||||
for (const edges of groups.values()) {
|
||||
if (edges.length < 2) continue
|
||||
const from = bounds.get(edges[0]!.from)
|
||||
const to = bounds.get(edges[0]!.to)
|
||||
if (!from || !to || from.id === to.id) continue
|
||||
const direction = directionForEdge(edges[0]!)
|
||||
const canonicalRoute = { edge: edges[0]!, points: edgePath(from, to, direction, leftBoundary) }
|
||||
routes.push(canonicalRoute)
|
||||
handled.add(edges[0]!)
|
||||
let previousRoute = canonicalRoute
|
||||
for (let index = 1; index < edges.length; index++) {
|
||||
const edge = edges[index]!
|
||||
const laneCoordinate = isVerticalDirection(direction)
|
||||
? Math.max(
|
||||
Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + BUS_CLEARANCE,
|
||||
rightRenderExtent(previousRoute) + NODE_CLEARANCE,
|
||||
)
|
||||
: Math.max(
|
||||
Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + BUS_CLEARANCE,
|
||||
Math.max(...previousRoute.points.map((point) => point.y)) + Math.max(2, labelHeight(edge) + 1),
|
||||
)
|
||||
const route = { edge, points: parallelEdgePath(from, to, direction, laneCoordinate) }
|
||||
routes.push(route)
|
||||
handled.add(edge)
|
||||
previousRoute = route
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalSubgraphExitFanIn(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
if (!subgraphBounds) return
|
||||
|
||||
const groups = new Map<string, { edge: FlowchartEdge; subgraph: FlowchartSubgraph; source: FlowchartNodeBounds }[]>()
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const subgraph = horizontalExitSubgraph(diagram, edge)
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!subgraph || !source || !target) continue
|
||||
|
||||
const key = `${subgraph.id}:${edge.to}`
|
||||
const group = groups.get(key) ?? []
|
||||
group.push({ edge, subgraph, source })
|
||||
groups.set(key, group)
|
||||
}
|
||||
|
||||
for (const group of groups.values()) {
|
||||
const subgraph = group[0]!.subgraph
|
||||
const subgraphBound = subgraphBounds.get(subgraph.id)
|
||||
const target = bounds.get(group[0]!.edge.to)
|
||||
if (!subgraphBound || !target) continue
|
||||
|
||||
const travel: HorizontalTravel = subgraph.direction === "RL" ? "left" : "right"
|
||||
const busX =
|
||||
subgraph.direction === "RL"
|
||||
? subgraphBound.left - BUS_CLEARANCE
|
||||
: subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
|
||||
const targetSubgraph = horizontalEntrySubgraph(diagram, group[0]!.edge)
|
||||
const targetSubgraphBound = targetSubgraph ? subgraphBounds.get(targetSubgraph.id) : undefined
|
||||
const targetBelow = target.centerY >= subgraphBound.centerY
|
||||
const targetPort = targetSubgraph
|
||||
? portForTravel(target, horizontalSubgraphEntryTravel(targetSubgraph), "target")
|
||||
: boundsSidePoint(target, targetBelow ? "top" : "bottom")
|
||||
const joinY = targetSubgraphBound
|
||||
? horizontalSubgraphJoinY(subgraphBound, targetSubgraphBound)
|
||||
: horizontalSubgraphExitJoinY(subgraphBound, targetPort, targetBelow)
|
||||
const entryX =
|
||||
targetSubgraph && targetSubgraphBound
|
||||
? horizontalSubgraphEntryLane(targetSubgraph, targetSubgraphBound)
|
||||
: targetPort.x
|
||||
|
||||
for (const record of group) {
|
||||
const sourcePort = portForTravel(record.source, travel, "source")
|
||||
routes.push({
|
||||
edge: record.edge,
|
||||
points: pathThrough([
|
||||
sourcePort,
|
||||
{ x: busX, y: sourcePort.y },
|
||||
{ x: busX, y: joinY },
|
||||
{ x: entryX, y: joinY },
|
||||
{ x: entryX, y: targetPort.y },
|
||||
targetPort,
|
||||
]),
|
||||
})
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalSubgraphEntries(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
if (!subgraphBounds) return
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const subgraph = horizontalEntrySubgraph(diagram, edge)
|
||||
const subgraphBound = subgraph ? subgraphBounds.get(subgraph.id) : undefined
|
||||
const from = bounds.get(edge.from)
|
||||
const to = bounds.get(edge.to)
|
||||
if (!subgraph || !subgraphBound || !from || !to) continue
|
||||
|
||||
const targetPort = portForTravel(to, horizontalSubgraphEntryTravel(subgraph), "target")
|
||||
const entryX = horizontalSubgraphEntryLane(subgraph, subgraphBound)
|
||||
const travel = verticalTravel(from, to)
|
||||
const sourcePort = portForTravel(from, travel, "source")
|
||||
routes.push({
|
||||
edge,
|
||||
points: pathThrough([sourcePort, { x: entryX, y: sourcePort.y }, { x: entryX, y: targetPort.y }, targetPort]),
|
||||
})
|
||||
handled.add(edge)
|
||||
}
|
||||
}
|
||||
|
||||
function pathIntersectsBounds(points: readonly FlowchartPoint[], bounds: FlowchartNodeBounds): boolean {
|
||||
const right = bounds.left + bounds.width - 1
|
||||
const bottom = bounds.top + bounds.height - 1
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
if (from.x === to.x) {
|
||||
if (
|
||||
from.x >= bounds.left &&
|
||||
from.x <= right &&
|
||||
Math.max(from.y, to.y) >= bounds.top &&
|
||||
Math.min(from.y, to.y) <= bottom
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
from.y >= bounds.top &&
|
||||
from.y <= bottom &&
|
||||
Math.max(from.x, to.x) >= bounds.left &&
|
||||
Math.min(from.x, to.x) <= right
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function avoidNodeObstacles(
|
||||
route: FlowchartEdgeRoute,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartEdgeRoute {
|
||||
const obstacle = [...bounds.values()].some(
|
||||
(bound) => bound.id !== route.edge.from && bound.id !== route.edge.to && pathIntersectsBounds(route.points, bound),
|
||||
)
|
||||
if (!obstacle) return route
|
||||
|
||||
const from = bounds.get(route.edge.from)
|
||||
const to = bounds.get(route.edge.to)
|
||||
if (!from || !to) return route
|
||||
if (isVerticalDirection(direction)) {
|
||||
const start = boundsSidePoint(from, "right")
|
||||
const end = boundsSidePoint(to, "right")
|
||||
const busX = Math.max(...[...bounds.values()].map((bound) => bound.left + bound.width - 1)) + BUS_CLEARANCE
|
||||
return { edge: route.edge, points: pathViaLane(start, lane("x", busX), end) }
|
||||
}
|
||||
|
||||
const start = boundsSidePoint(from, "top")
|
||||
const end = boundsSidePoint(to, "top")
|
||||
const busY = Math.min(...[...bounds.values()].map((bound) => bound.top)) - BUS_CLEARANCE
|
||||
return { edge: route.edge, points: pathViaLane(start, lane("y", busY), end) }
|
||||
}
|
||||
|
||||
export function routeFlowchartEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
|
||||
subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
): FlowchartEdgeRoute[] {
|
||||
const routedDiagram = { ...diagram, edges: diagram.edges.filter((edge) => !edge.orderOnly) }
|
||||
const handled = new Set<FlowchartEdge>()
|
||||
const routes: FlowchartEdgeRoute[] = []
|
||||
const leftBoundary = subgraphBounds
|
||||
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
|
||||
: undefined
|
||||
|
||||
routeParallelEdges(routedDiagram, bounds, directionForEdge, leftBoundary, handled, routes)
|
||||
|
||||
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
|
||||
const horizontalEdges = routedDiagram.edges.filter(
|
||||
(edge) => !handled.has(edge) && directionForEdge(edge) === direction,
|
||||
)
|
||||
if (horizontalEdges.length === 0) continue
|
||||
const records = horizontalForwardRecords(horizontalEdges, bounds, direction)
|
||||
routeHorizontalFanOut(records, direction, handled, routes)
|
||||
routeHorizontalFanIn(records, direction, handled, routes)
|
||||
}
|
||||
|
||||
routeHorizontalSubgraphExitFanIn(routedDiagram, bounds, subgraphBounds, handled, routes)
|
||||
routeHorizontalSubgraphEntries(routedDiagram, bounds, subgraphBounds, handled, routes)
|
||||
|
||||
for (const direction of ["TD", "TB", "BT"] satisfies FlowchartDirection[]) {
|
||||
const verticalEdges = routedDiagram.edges.filter(
|
||||
(edge) => !handled.has(edge) && directionForEdge(edge) === direction,
|
||||
)
|
||||
if (verticalEdges.length === 0) continue
|
||||
const records = verticalForwardRecords(verticalEdges, bounds, direction)
|
||||
routeVerticalFanOut(records, direction, handled, routes)
|
||||
routeVerticalFanIn(records, direction, handled, routes)
|
||||
}
|
||||
|
||||
for (const edge of routedDiagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const from = bounds.get(edge.from)
|
||||
const to = bounds.get(edge.to)
|
||||
if (!from || !to) continue
|
||||
routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) })
|
||||
}
|
||||
return routes.map((route) => avoidNodeObstacles(route, bounds, directionForEdge(route.edge)))
|
||||
}
|
||||
|
||||
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
|
||||
if (sourcePoint.x < bounds.left) return "left"
|
||||
if (sourcePoint.x >= bounds.left + bounds.width) return "right"
|
||||
if (sourcePoint.y < bounds.top) return "top"
|
||||
return "bottom"
|
||||
}
|
||||
|
||||
function connectorChar(side: DiagramSide): string {
|
||||
switch (side) {
|
||||
case "left":
|
||||
return "┤"
|
||||
case "right":
|
||||
return "├"
|
||||
case "top":
|
||||
return "┴"
|
||||
case "bottom":
|
||||
return "┬"
|
||||
}
|
||||
}
|
||||
|
||||
export function flowchartSourceConnector(
|
||||
from: FlowchartNodeBounds,
|
||||
sourcePoint: FlowchartPoint,
|
||||
): { x: number; y: number; char: string } {
|
||||
const side = sideForOutsidePoint(from, sourcePoint)
|
||||
const connector = boundsSidePoint(from, side, "border")
|
||||
return {
|
||||
x: side === "top" || side === "bottom" ? sourcePoint.x : connector.x,
|
||||
y: side === "left" || side === "right" ? sourcePoint.y : connector.y,
|
||||
char: connectorChar(side),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { RGBA, type StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas } from "../core/canvas.js"
|
||||
import { renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import {
|
||||
createColorRampTheme,
|
||||
DIAGRAM_FADE_STEPS,
|
||||
numberedStyleKeys,
|
||||
rgba,
|
||||
type DiagramFadeStep,
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
|
||||
export type FlowchartBaseCellStyle = "node" | "database" | "edge" | "label" | "group"
|
||||
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
|
||||
export type FlowchartCellStyle = FlowchartBaseCellStyle | FlowchartEdgeFadeStyle
|
||||
export type FlowchartGrid = DiagramCanvas<FlowchartCellStyle>
|
||||
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
|
||||
export const DEFAULT_THEME_RGB = {
|
||||
node: [228, 239, 232],
|
||||
database: [228, 239, 232],
|
||||
edge: [134, 225, 200],
|
||||
label: [134, 225, 200],
|
||||
group: [76, 99, 89],
|
||||
} as const satisfies Record<FlowchartBaseCellStyle, DiagramRgb>
|
||||
|
||||
export const NODE_EDGE_FADE_STYLES = numberedStyleKeys("nodeEdgeFade", DIAGRAM_FADE_STEPS)
|
||||
export const DATABASE_EDGE_FADE_STYLES = numberedStyleKeys("databaseEdgeFade", DIAGRAM_FADE_STEPS)
|
||||
|
||||
export function resolveFlowchartStyleColors(
|
||||
colors: Partial<Record<FlowchartCellStyle, RGBA | undefined>> = {},
|
||||
): FlowchartStyleColors {
|
||||
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
|
||||
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
|
||||
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
|
||||
return {
|
||||
node,
|
||||
database,
|
||||
edge,
|
||||
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
|
||||
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
|
||||
...createColorRampTheme(NODE_EDGE_FADE_STYLES, node, edge),
|
||||
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, database, edge),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderGridStyledText(grid: FlowchartGrid, colors: FlowchartStyleColors): StyledText {
|
||||
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
|
||||
trimTop: true,
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
|
||||
|
||||
export type FlowchartDirection = "TB" | "TD" | "BT" | "LR" | "RL"
|
||||
export type FlowchartNodeShape = "box" | "rounded" | "database" | "decision" | "subroutine"
|
||||
export type FlowchartEdgeStyle = "thick" | "dashed"
|
||||
|
||||
export interface FlowchartNode {
|
||||
id: string
|
||||
label: string
|
||||
shape: FlowchartNodeShape
|
||||
}
|
||||
|
||||
export interface FlowchartEdge {
|
||||
from: string
|
||||
to: string
|
||||
label: string
|
||||
style?: FlowchartEdgeStyle
|
||||
arrowhead?: false
|
||||
orderOnly?: boolean
|
||||
}
|
||||
|
||||
export interface FlowchartSubgraph {
|
||||
id: string
|
||||
label: string
|
||||
nodeIds: string[]
|
||||
parentId?: string
|
||||
direction?: FlowchartDirection
|
||||
}
|
||||
|
||||
export interface FlowchartDiagram {
|
||||
direction: FlowchartDirection
|
||||
nodes: FlowchartNode[]
|
||||
edges: FlowchartEdge[]
|
||||
subgraphs?: FlowchartSubgraph[]
|
||||
}
|
||||
|
||||
export interface FlowchartNodeSize {
|
||||
width: number
|
||||
height: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface FlowchartNodeBounds extends FlowchartNodeSize, DiagramBounds {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface FlowchartSubgraphBounds extends DiagramBounds {
|
||||
id: string
|
||||
label: string
|
||||
labelSide: "top" | "bottom"
|
||||
}
|
||||
|
||||
export type FlowchartPoint = DiagramPoint
|
||||
|
||||
export interface FlowchartEdgeRoute {
|
||||
edge: FlowchartEdge
|
||||
points: FlowchartPoint[]
|
||||
}
|
||||
|
||||
export type FlowchartEdgeDirection = DiagramDirection
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
TextRenderable,
|
||||
RenderableEvents,
|
||||
createMarkdownCodeBlockRenderer,
|
||||
parseColor,
|
||||
type ColorInput,
|
||||
type MarkdownOptions,
|
||||
type MarkdownCodeBlockRenderer,
|
||||
type MouseEvent,
|
||||
type RenderContext,
|
||||
type RGBA,
|
||||
type StyledText,
|
||||
} from "@opentui/core"
|
||||
import { MermaidSyntaxError } from "./diagnostics.js"
|
||||
import { DiagramCanvasSizeError } from "./core/canvas.js"
|
||||
import { detectMermaidDiagram } from "./detect.js"
|
||||
import { drawFlowchartDiagramGrid } from "./flowchart/drawing.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { renderGridStyledText, resolveFlowchartStyleColors } from "./flowchart/style.js"
|
||||
import { drawSequenceDiagramGrid } from "./sequence/drawing.js"
|
||||
import { parseMermaidSequenceDiagram } from "./sequence/parser.js"
|
||||
import { renderSequenceGridStyledText } from "./sequence/render-grid.js"
|
||||
import { resolveSequenceStyleColors } from "./sequence/style.js"
|
||||
import { drawStateDiagramGrid } from "./state/drawing.js"
|
||||
import { parseMermaidStateDiagram } from "./state/parser.js"
|
||||
import { renderStateGridStyledText } from "./state/render-grid.js"
|
||||
import { resolveStateStyleColors } from "./state/style.js"
|
||||
|
||||
type DiagramKind = NonNullable<ReturnType<typeof detectMermaidDiagram>>
|
||||
|
||||
interface PreparedDiagram {
|
||||
readonly kind: DiagramKind
|
||||
readonly source: string
|
||||
readonly text: StyledText
|
||||
readonly height: number
|
||||
}
|
||||
|
||||
export interface MermaidMarkdownRendererOptions {
|
||||
compact?: boolean
|
||||
colors?: {
|
||||
text?: ColorInput
|
||||
primary?: ColorInput
|
||||
secondary?: ColorInput
|
||||
muted?: ColorInput
|
||||
warning?: ColorInput
|
||||
background?: ColorInput
|
||||
}
|
||||
}
|
||||
|
||||
function color(value: ColorInput | undefined): RGBA | undefined {
|
||||
return value === undefined ? undefined : parseColor(value)
|
||||
}
|
||||
|
||||
class StaticDiagramRenderable extends TextRenderable {
|
||||
constructor(ctx: RenderContext, prepared: PreparedDiagram) {
|
||||
super(ctx, {
|
||||
content: prepared.text,
|
||||
width: "100%",
|
||||
height: prepared.height,
|
||||
wrapMode: "none",
|
||||
selectable: false,
|
||||
marginTop: 1,
|
||||
})
|
||||
let dragX: number | undefined
|
||||
this.onMouseDown = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
ctx.clearSelection()
|
||||
dragX = event.x
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
this.onMouseDrag = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (dragX === undefined) return
|
||||
const dx = event.x - dragX
|
||||
dragX = event.x
|
||||
if (dx) this.scrollX -= dx
|
||||
}
|
||||
this.onMouseDragEnd = (event: MouseEvent) => {
|
||||
dragX = undefined
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
this.onMouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
dragX = undefined
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
this.onMouseScroll = (event: MouseEvent) => {
|
||||
const scroll = event.scroll
|
||||
if (!scroll || (scroll.direction !== "left" && scroll.direction !== "right")) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkdownRendererOptions): PreparedDiagram {
|
||||
const colors = options.colors ?? {}
|
||||
switch (kind) {
|
||||
case "flowchart": {
|
||||
const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), { compact: options.compact })
|
||||
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
|
||||
return {
|
||||
kind,
|
||||
source,
|
||||
text: renderGridStyledText(
|
||||
grid,
|
||||
resolveFlowchartStyleColors({
|
||||
node: color(colors.primary),
|
||||
database: color(colors.primary),
|
||||
edge: color(colors.secondary),
|
||||
label: color(colors.text),
|
||||
group: color(colors.muted),
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
}
|
||||
}
|
||||
case "sequence": {
|
||||
const grid = drawSequenceDiagramGrid(parseMermaidSequenceDiagram(source), { compact: options.compact })
|
||||
const size = grid.getTextSize()
|
||||
return {
|
||||
kind,
|
||||
source,
|
||||
text: renderSequenceGridStyledText(
|
||||
grid,
|
||||
resolveSequenceStyleColors({
|
||||
participant: color(colors.primary),
|
||||
lifeline: color(colors.muted),
|
||||
group: color(colors.secondary),
|
||||
request: color(colors.primary),
|
||||
response: color(colors.primary),
|
||||
fragment: color(colors.secondary),
|
||||
fragmentLabelBg: color(colors.background),
|
||||
note: color(colors.warning),
|
||||
noteBg: color(colors.background),
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
}
|
||||
}
|
||||
case "state": {
|
||||
const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source))
|
||||
const size = grid.getTextSize({ trimBottom: true })
|
||||
return {
|
||||
kind,
|
||||
source,
|
||||
text: renderStateGridStyledText(
|
||||
grid,
|
||||
resolveStateStyleColors({
|
||||
state: color(colors.primary),
|
||||
composite: color(colors.muted),
|
||||
transition: color(colors.secondary),
|
||||
label: color(colors.text),
|
||||
noteBorder: color(colors.warning),
|
||||
noteText: color(colors.warning),
|
||||
noteConnector: color(colors.muted),
|
||||
start: color(colors.muted),
|
||||
end: color(colors.muted),
|
||||
choice: color(colors.secondary),
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an OpenTUI Markdown node renderer for fenced Mermaid diagrams. */
|
||||
export function createMermaidMarkdownRenderer(
|
||||
ctx: RenderContext,
|
||||
input: MermaidMarkdownRendererOptions | (() => MermaidMarkdownRendererOptions) = {},
|
||||
): NonNullable<MarkdownOptions["renderNode"]> {
|
||||
return createMarkdownCodeBlockRenderer({ mermaid: createMermaidCodeBlockRenderer(ctx, input) })!
|
||||
}
|
||||
|
||||
export function createMermaidCodeBlockRenderer(
|
||||
ctx: RenderContext,
|
||||
input: MermaidMarkdownRendererOptions | (() => MermaidMarkdownRendererOptions) = {},
|
||||
): MarkdownCodeBlockRenderer {
|
||||
const lastGood = new Map<string, PreparedDiagram>()
|
||||
return (token, context) => {
|
||||
const kind = detectMermaidDiagram(token.text)
|
||||
if (!kind) return undefined
|
||||
// OpenTUI's default block ID is the stable identity available for this fence across streaming updates.
|
||||
const key = context.defaultRender()?.id
|
||||
const options = typeof input === "function" ? input() : input
|
||||
|
||||
try {
|
||||
const prepared = prepareDiagram(kind, token.text, options)
|
||||
const diagram = new StaticDiagramRenderable(ctx, prepared)
|
||||
if (key) claimLastGood(key, prepared, diagram, lastGood)
|
||||
return diagram
|
||||
} catch (error) {
|
||||
if (error instanceof MermaidSyntaxError) {
|
||||
const previous = key ? lastGood.get(key) : undefined
|
||||
if (!previous || previous.kind !== kind) return undefined
|
||||
const diagram = new StaticDiagramRenderable(ctx, previous)
|
||||
claimLastGood(key!, previous, diagram, lastGood)
|
||||
return diagram
|
||||
}
|
||||
if (error instanceof DiagramCanvasSizeError) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function claimLastGood(
|
||||
key: string,
|
||||
value: PreparedDiagram,
|
||||
owner: StaticDiagramRenderable,
|
||||
cache: Map<string, PreparedDiagram>,
|
||||
): void {
|
||||
const claim = { ...value }
|
||||
cache.set(key, claim)
|
||||
owner.once(RenderableEvents.DESTROYED, () => {
|
||||
// Reconciliation destroys the old block before synchronously creating its replacement.
|
||||
queueMicrotask(() => {
|
||||
if (cache.get(key) === claim) cache.delete(key)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createOpenCodeDiagramPalette } from "./palette.js"
|
||||
|
||||
type Rgb = readonly [number, number, number]
|
||||
|
||||
const rgb = (value: Rgb) => RGBA.fromInts(...value)
|
||||
|
||||
describe("OpenCode diagram palette", () => {
|
||||
test.each(
|
||||
[
|
||||
{
|
||||
name: "dark theme",
|
||||
text: [230, 232, 240],
|
||||
subdued: [114, 120, 138],
|
||||
secondary: [172, 176, 189],
|
||||
muted: [149, 154, 169],
|
||||
},
|
||||
{
|
||||
name: "light theme",
|
||||
text: [32, 35, 43],
|
||||
subdued: [119, 125, 138],
|
||||
secondary: [76, 80, 91],
|
||||
muted: [93, 98, 110],
|
||||
},
|
||||
] satisfies ReadonlyArray<{
|
||||
name: string
|
||||
text: Rgb
|
||||
subdued: Rgb
|
||||
secondary: Rgb
|
||||
muted: Rgb
|
||||
}>,
|
||||
)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
|
||||
const primary = rgb(text)
|
||||
const info = RGBA.fromInts(40, 120, 220)
|
||||
const background = RGBA.fromInts(10, 20, 30)
|
||||
const palette = createOpenCodeDiagramPalette({
|
||||
text: primary,
|
||||
subdued: rgb(subdued),
|
||||
info,
|
||||
background,
|
||||
})
|
||||
|
||||
expect(palette.text).toBe(primary)
|
||||
expect(palette.primary).toBe(primary)
|
||||
expect(palette.secondary.equals(rgb(secondary))).toBe(true)
|
||||
expect(palette.muted.equals(rgb(muted))).toBe(true)
|
||||
expect(palette.warning).toBe(info)
|
||||
expect(palette.background).toBe(background)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { blendColor } from "./core/color/style.js"
|
||||
|
||||
export interface OpenCodeDiagramPaletteInput {
|
||||
readonly text: RGBA
|
||||
readonly subdued: RGBA
|
||||
readonly info: RGBA
|
||||
readonly background: RGBA
|
||||
}
|
||||
|
||||
export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput) {
|
||||
return {
|
||||
text: input.text,
|
||||
primary: input.text,
|
||||
secondary: blendColor(input.text, input.subdued, 0.5),
|
||||
muted: blendColor(input.text, input.subdued, 0.7),
|
||||
warning: input.info,
|
||||
background: input.background,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMermaidCodeBlockRenderer } from "./markdown.js"
|
||||
import { createOpenCodeDiagramPalette } from "./palette.js"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.merman",
|
||||
setup(context) {
|
||||
context.markdown.registerCodeBlockRenderer(
|
||||
"mermaid",
|
||||
createMermaidCodeBlockRenderer(context.renderer, () => ({
|
||||
colors: createOpenCodeDiagramPalette({
|
||||
text: context.theme.text.default,
|
||||
subdued: context.theme.text.subdued,
|
||||
info: context.theme.text.feedback.info.default,
|
||||
background: context.theme.background.default,
|
||||
}),
|
||||
})),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,688 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderSequenceDiagram } from "./diagram.js"
|
||||
import { drawSequenceDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
|
||||
describe("SequenceDiagram", () => {
|
||||
test("parses Mermaid sequenceDiagram participants and messages", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
`)
|
||||
|
||||
expect(diagram.participants).toEqual([
|
||||
{ id: "B", label: "Browser" },
|
||||
{ id: "S", label: "Server" },
|
||||
])
|
||||
expect(diagram.messages).toEqual([
|
||||
{ from: "B", to: "S", label: "GET /", style: "solid" },
|
||||
{ from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" },
|
||||
])
|
||||
expect(diagram.steps).toEqual([
|
||||
{ type: "message", message: { from: "B", to: "S", label: "GET /", style: "solid" } },
|
||||
{ type: "message", message: { from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("renders a terminal sequence diagram", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭─────────╮ ╭────────╮
|
||||
│ Browser │ │ Server │
|
||||
╰────┬────╯ ╰────┬───╯
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────▶
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◀─────────────────┤
|
||||
│ │
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders a compact terminal sequence diagram without participant boxes", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant Leaf as leaf tool
|
||||
participant Location as LocationMutation
|
||||
participant File as FileMutation
|
||||
Leaf->>Location: resolve(path)
|
||||
Location-->>Leaf: Plan(target, authority anchor)
|
||||
Leaf->>File: commit(plan)
|
||||
File->>Location: revalidate(plan)
|
||||
Location-->>File: same target or reject
|
||||
`,
|
||||
{ compact: true },
|
||||
)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
leaf tool LocationMutation FileMutation
|
||||
│ │ │
|
||||
├─ resolve(path) ───────────────────▶ │
|
||||
│ │ │
|
||||
◀─ Plan(target, authority anchor) ──┤ │
|
||||
│ │ │
|
||||
├─ commit(plan) ───────────────────────────────────────────────▶
|
||||
│ │ │
|
||||
│ ◀─ revalidate(plan) ───────┤
|
||||
│ │ │
|
||||
│ ├─ same target or reject ──▶
|
||||
│ │ │
|
||||
`)
|
||||
})
|
||||
|
||||
test("keeps structured sequence steps visible in compact mode", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant Worker
|
||||
participant Store
|
||||
Note over Worker,Store: transaction
|
||||
alt accepted
|
||||
Worker->>Worker: prepare
|
||||
Worker->>Store: commit
|
||||
end
|
||||
`,
|
||||
{ compact: true },
|
||||
)
|
||||
|
||||
expectDiagram(output).toContainInOrder("Worker", "Store", "transaction", "alt: accepted", "prepare", "commit")
|
||||
})
|
||||
|
||||
test("keeps compact labels above arrows when they do not fit inline", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
"sequenceDiagram\n participant A\n participant B\n participant C\n A->>C: this label is deliberately much too long to fit between endpoints despite intermediate spacing",
|
||||
{ compact: true },
|
||||
)
|
||||
const lines = output.split("\n")
|
||||
|
||||
expect(lines.findIndex((line) => line.includes("deliberately"))).toBeLessThan(
|
||||
lines.findIndex((line) => line.includes("▶")),
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes invalid participant gaps for text rendering", () => {
|
||||
const content = "sequenceDiagram\n A->>B: hello"
|
||||
|
||||
expect(renderSequenceDiagram(content, { minParticipantGap: Number.NaN })).toContain("hello")
|
||||
})
|
||||
|
||||
test("connects participant headers to lifelines", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
`)
|
||||
|
||||
const lines = output.split("\n")
|
||||
const browserCenter = lines[1]!.indexOf("w")
|
||||
const serverCenter = lines[1]!.indexOf("v")
|
||||
|
||||
expect(lines[2]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[3]?.[browserCenter]).toBe("│")
|
||||
expect(lines[2]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[3]?.[serverCenter]).toBe("│")
|
||||
})
|
||||
|
||||
test("ramps participant frames into neutral lifelines", () => {
|
||||
const grid = drawSequenceDiagramGrid(
|
||||
parseMermaidSequenceDiagram(
|
||||
"sequenceDiagram\n participant Browser\n participant Server\n Note over Browser,Server: context\n Browser->>Server: request",
|
||||
),
|
||||
)
|
||||
const rampStyles = grid.rows
|
||||
.flatMap((row) => row.map((cell) => cell.style))
|
||||
.filter((style) => style?.startsWith("lifelineRamp"))
|
||||
|
||||
expect(new Set(rampStyles)).toEqual(new Set(["lifelineRamp1", "lifelineRamp2", "lifelineRamp3"]))
|
||||
})
|
||||
|
||||
test("renders notes and long cross-participant messages in order", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
participant Store as Ticket store
|
||||
Note over Browser,Server: native browser Basic prompt
|
||||
Browser->>Server: POST connect-token
|
||||
Server->>Store: issue { ptyID, scope }
|
||||
`)
|
||||
|
||||
expectDiagram(output).toContainInOrder(
|
||||
"native browser Basic prompt",
|
||||
"POST connect-token",
|
||||
"issue { ptyID, scope }",
|
||||
)
|
||||
})
|
||||
|
||||
test("renders notes to the left and right of a participant", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
participant OpenTUI
|
||||
Note right of OpenTUI: child is not renderable
|
||||
Note left of OpenTUI: remove failed`)
|
||||
|
||||
expect(output).toContain("child is not renderable")
|
||||
expect(output).toContain("remove failed")
|
||||
})
|
||||
|
||||
test("preserves the parsed shape of notes over participants", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
Note over A: hello`)
|
||||
|
||||
expect(diagram.steps).toContainEqual({ type: "note", note: { over: ["A"], label: "hello" } })
|
||||
})
|
||||
|
||||
test("parses the complete source of a br-delimited note", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
Note over Tool: directory instead?<br/>the WrongKind error carries the answer —<br/>branch to list, no extra round trip`)
|
||||
|
||||
expect(diagram.steps).toContainEqual({
|
||||
type: "note",
|
||||
note: {
|
||||
over: ["Tool"],
|
||||
label:
|
||||
"directory instead?<br/>the WrongKind error carries the answer —<br/>branch to list, no extra round trip",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("parses activation shorthand and control blocks", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>+Server: request
|
||||
alt accepted
|
||||
Server-->>-Browser: response
|
||||
else rejected
|
||||
activate Server
|
||||
Server-->>Browser: error
|
||||
deactivate Server
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.steps).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
from: "Browser",
|
||||
to: "Server",
|
||||
label: "request",
|
||||
style: "solid",
|
||||
activate: "Server",
|
||||
},
|
||||
},
|
||||
{ type: "fragment", fragment: { kind: "alt", label: "accepted" } },
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
from: "Server",
|
||||
to: "Browser",
|
||||
label: "response",
|
||||
style: "dashed",
|
||||
deactivate: "Server",
|
||||
},
|
||||
},
|
||||
{ type: "fragment", fragment: { kind: "else", label: "rejected" } },
|
||||
{ type: "activation", activation: { participant: "Server", active: true } },
|
||||
{
|
||||
type: "message",
|
||||
message: { from: "Server", to: "Browser", label: "error", style: "dashed" },
|
||||
},
|
||||
{ type: "activation", activation: { participant: "Server", active: false } },
|
||||
{ type: "fragment", fragment: { kind: "end", label: "alt" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("parses activation syntax without rendering activation bars", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>+Server: request
|
||||
Server-->>-Browser: response
|
||||
`)
|
||||
|
||||
expect(output).not.toContain("┃")
|
||||
expect(output).toContain("request")
|
||||
expect(output).toContain("response")
|
||||
})
|
||||
|
||||
test("parses Mermaid arrow head variants", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
A->B: open solid
|
||||
B-->A: open dashed
|
||||
A-xB: failed solid
|
||||
B--xA: failed dashed
|
||||
A-)B: async solid
|
||||
B--)A: async dashed
|
||||
`)
|
||||
|
||||
expect(diagram.messages).toEqual([
|
||||
{ from: "A", to: "B", label: "open solid", style: "solid", head: "open" },
|
||||
{ from: "B", to: "A", label: "open dashed", style: "dashed", head: "open" },
|
||||
{ from: "A", to: "B", label: "failed solid", style: "solid", head: "cross" },
|
||||
{ from: "B", to: "A", label: "failed dashed", style: "dashed", head: "cross" },
|
||||
{ from: "A", to: "B", label: "async solid", style: "solid", head: "async" },
|
||||
{ from: "B", to: "A", label: "async dashed", style: "dashed", head: "async" },
|
||||
])
|
||||
})
|
||||
|
||||
test("renders Mermaid arrow head variants", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
A->B: open solid
|
||||
B-->A: open dashed
|
||||
A-xB: failed solid
|
||||
B--xA: failed dashed
|
||||
A-)B: async solid
|
||||
B--)A: async dashed
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭───╮ ╭───╮
|
||||
│ A │ │ B │
|
||||
╰─┬─╯ ╰─┬─╯
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders boxed alt else regions", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
alt accepted
|
||||
Browser->>Server: ok
|
||||
else rejected
|
||||
Server-->>Browser: no
|
||||
end
|
||||
`)
|
||||
|
||||
expectDiagram(output).toContainInOrder("╭─ alt: accepted", "ok", "├─ else: rejected", "no", "╰")
|
||||
expect(output).not.toContain("end alt")
|
||||
})
|
||||
|
||||
test("expands a fragment frame for a longer else label", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
A->>B: start
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else validation failed with a substantially longer explanation
|
||||
B-->>A: no
|
||||
end`)
|
||||
const lines = output.split("\n")
|
||||
const elseRow = lines.find((line) => line.includes("validation failed"))!
|
||||
const endRow = [...lines].reverse().find((line) => line.includes("╰"))!
|
||||
|
||||
expect(elseRow.lastIndexOf("┤")).toBe(endRow.lastIndexOf("╯"))
|
||||
})
|
||||
|
||||
test("preserves combined graphemes in participant names", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A as Cafe\u0301
|
||||
participant B
|
||||
A->>B: hi`)
|
||||
|
||||
expect(output).toContain("Cafe\u0301")
|
||||
})
|
||||
|
||||
test("renders fragment boxes with lifeline overhang", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt ok
|
||||
A->>B: yes
|
||||
end
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
|
||||
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
|
||||
|
||||
expect(fragmentStart).toBeLessThan(participantCenter)
|
||||
})
|
||||
|
||||
test("supports configurable fragment border styles", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else no
|
||||
B-->>A: no
|
||||
end
|
||||
`,
|
||||
{ fragmentBorderStyle: "double" },
|
||||
)
|
||||
|
||||
expect(output).toContain("╔")
|
||||
expect(output).toContain("╠")
|
||||
expect(output).toContain("╚")
|
||||
expect(output).toContain("═")
|
||||
expect(output).toContain("║")
|
||||
})
|
||||
|
||||
test("parses and renders autonumbered messages", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
Browser->>API: request
|
||||
API-->>Browser: response
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
Browser->>API: request
|
||||
API-->>Browser: response
|
||||
`)
|
||||
|
||||
expect(diagram.messages.map((message) => message.number)).toEqual([1, 2])
|
||||
expect(output).toContain("1. request")
|
||||
expect(output).toContain("2. response")
|
||||
})
|
||||
|
||||
test("supports autonumber start and increment", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber 10 5
|
||||
Browser->>API: first
|
||||
API-->>Browser: second
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber 10 5
|
||||
Browser->>API: first
|
||||
API-->>Browser: second
|
||||
`)
|
||||
|
||||
expect(diagram.messages.map((message) => message.number)).toEqual([10, 15])
|
||||
expect(output).toContain("10. first")
|
||||
expect(output).toContain("15. second")
|
||||
})
|
||||
|
||||
test("parses and renders loop regions", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
loop retry up to 3x
|
||||
Browser->>API: GET /users/42
|
||||
API-->>Browser: 503
|
||||
end
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
loop retry up to 3x
|
||||
Browser->>API: GET /users/42
|
||||
API-->>Browser: 503
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.steps[0]).toEqual({
|
||||
type: "fragment",
|
||||
fragment: { kind: "loop", label: "retry up to 3x" },
|
||||
})
|
||||
expect(output).toContain("╭─ ↻ loop: retry up to 3x")
|
||||
expect(output).not.toContain("end loop")
|
||||
expect(output.indexOf("loop: retry up to 3x")).toBeLessThan(output.indexOf("GET /users/42"))
|
||||
})
|
||||
|
||||
test("parses Mermaid box participant groups", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
participant Cache
|
||||
end
|
||||
box Purple Storage Layer
|
||||
participant DB
|
||||
end
|
||||
box "Purple Literal Label"
|
||||
participant Worker
|
||||
end
|
||||
Browser->>API: request
|
||||
`)
|
||||
|
||||
expect(diagram.groups).toEqual([
|
||||
{ label: "Backend", participantIds: ["API", "Cache"] },
|
||||
{ label: "Storage Layer", participantIds: ["DB"] },
|
||||
{ label: "Purple Literal Label", participantIds: ["Worker"] },
|
||||
])
|
||||
expect(diagram.steps).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
message: { from: "Browser", to: "API", label: "request", style: "solid" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("adds implicit participants inside box groups", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant API
|
||||
box Backend
|
||||
API->>DB: query
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.groups).toEqual([{ label: "Backend", participantIds: ["API", "DB"] }])
|
||||
expect(diagram.steps).toEqual([
|
||||
{ type: "message", message: { from: "API", to: "DB", label: "query", style: "solid" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not clip long non-adjacent messages or notes", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message needs room past the final participant
|
||||
Note over A,C: this note also needs full horizontal room
|
||||
`)
|
||||
|
||||
expect(output).toContain("this message needs room past the final participant")
|
||||
expect(output).toContain("this note also needs full horizontal room")
|
||||
})
|
||||
|
||||
test("keeps long content inside participant groups and fragment frames", () => {
|
||||
const group = renderSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message text runs far outside of the group container boundary
|
||||
end`)
|
||||
const fragment = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
A->>C: this non adjacent message is deliberately much wider than the frame
|
||||
end`)
|
||||
|
||||
const groupMessageRow = group.split("\n").find((line) => line.includes("this message text"))!
|
||||
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
|
||||
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
|
||||
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
|
||||
})
|
||||
|
||||
test("keeps long notes inside groups and nested fragment frames intact", () => {
|
||||
const groupedNote = renderSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
Note over A,C: this note text runs far outside of the group container boundary
|
||||
end`)
|
||||
const fragmentNote = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
Note over A,C: this non adjacent note is deliberately much wider than the frame
|
||||
end`)
|
||||
const nested = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt outer
|
||||
loop inner heading wider than outer frame and participant span
|
||||
A->>B: x
|
||||
end
|
||||
end`)
|
||||
|
||||
expect(groupedNote).toContain("this note text runs far outside of the group container boundary")
|
||||
expect(fragmentNote).toContain("this non adjacent note is deliberately much wider than the frame")
|
||||
expect(nested).toContain("span ─╮│")
|
||||
expect(nested).toContain("──────╯│")
|
||||
})
|
||||
|
||||
test("does not draw external participants inside groups expanded by self messages", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
box G
|
||||
participant A
|
||||
end
|
||||
participant B as External
|
||||
A->>A: this self-loop extends underneath the external participant header`)
|
||||
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
|
||||
const lines = output.split("\n")
|
||||
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
|
||||
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
|
||||
|
||||
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
|
||||
})
|
||||
|
||||
test("renders full-height participant group boxes", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
participant Cache
|
||||
participant DB
|
||||
end
|
||||
Browser->>API: GET /users/42
|
||||
API->>Cache: get user:42
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Backend ──────────────────────────────────╮
|
||||
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
|
||||
│ Browser │ │ │ API │ │ Cache │ │ DB │ │
|
||||
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │
|
||||
├──────────────────▶ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────▶ │ │
|
||||
│ │ │ │ │ │
|
||||
╰────────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("lets message lines pass through group borders without intersections", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
end
|
||||
Browser->>API: GET /users/42
|
||||
`)
|
||||
const arrowLine = output.split("\n").find((line) => line.includes("▶"))!
|
||||
|
||||
expect(arrowLine).toContain("───────────────▶")
|
||||
expect(arrowLine).not.toContain("┼")
|
||||
})
|
||||
|
||||
test("renders self messages as loopback arrows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Service
|
||||
Service->>Service: Check Permissions
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭─────────╮
|
||||
│ Service │
|
||||
╰────┬────╯
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◀────────────────────╯
|
||||
│"
|
||||
`)
|
||||
})
|
||||
|
||||
test("places two spacer rows above note badges and one below", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: one
|
||||
Note over Browser,Server: phase
|
||||
Browser->>Server: two
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const noteRow = lines.findIndex((line) => line.includes("phase"))
|
||||
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
|
||||
|
||||
expect(noteRow).toBeGreaterThan(0)
|
||||
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow - 2]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
|
||||
expect(nextMessageRow).toBe(noteRow + 2)
|
||||
})
|
||||
|
||||
test("renders br-delimited message labels across multiple rows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
|
||||
`)
|
||||
|
||||
expect(output).toContain("POST connect-token")
|
||||
expect(output).toContain("· Basic (cached by browser)")
|
||||
expect(output).toContain("· X-OpenCode-Ticket: 1")
|
||||
expect(output.indexOf("· X-OpenCode-Ticket: 1")).toBeLessThan(output.indexOf("├"))
|
||||
})
|
||||
|
||||
test("renders br-delimited notes as rows without source tags", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
Note over Tool: directory instead?<br/>the WrongKind error carries the answer —<br/>branch to list, no extra round trip`)
|
||||
|
||||
expectDiagram(output).toContainInOrder(
|
||||
"directory instead?",
|
||||
"the WrongKind error carries the answer —",
|
||||
"branch to list, no extra round trip",
|
||||
)
|
||||
expect(output).not.toMatch(/<br\s*\/?\s*>/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { drawSequenceDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { renderSequenceGridText } from "./render-grid.js"
|
||||
import type { SequenceDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export function renderSequenceDiagram(content: string, options: SequenceDiagramRenderOptions = {}): string {
|
||||
return renderSequenceGridText(drawSequenceDiagramGrid(parseMermaidSequenceDiagram(content), options))
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { BorderChars, type BorderStyle } from "@opentui/core"
|
||||
import { DiagramCanvas } from "../core/canvas.js"
|
||||
import { DEFAULT_FRAGMENT_BORDER_STYLE } from "./options.js"
|
||||
import {
|
||||
createSequencePlacementPlan,
|
||||
type SequenceGroupPlacement,
|
||||
type SequenceHorizontalBounds,
|
||||
type SequenceStepPlacement,
|
||||
} from "./placement.js"
|
||||
import type { SequenceGrid } from "./render-grid.js"
|
||||
import { SEQUENCE_FADE_STEPS as FADE_STEPS } from "./style.js"
|
||||
import type {
|
||||
MessageStyle,
|
||||
SequenceArrowHead,
|
||||
SequenceCellStyle,
|
||||
SequenceDiagram,
|
||||
SequenceDiagramRenderOptions,
|
||||
} from "./types.js"
|
||||
|
||||
const SEQUENCE_BORDER = BorderChars.rounded
|
||||
|
||||
function arrowHeadChar(head: SequenceArrowHead | undefined, direction: 1 | -1): string {
|
||||
switch (head) {
|
||||
case "open":
|
||||
return direction === 1 ? ">" : "<"
|
||||
case "cross":
|
||||
return "✕"
|
||||
case "async":
|
||||
return direction === 1 ? ")" : "("
|
||||
default:
|
||||
return direction === 1 ? "▶" : "◀"
|
||||
}
|
||||
}
|
||||
|
||||
function createGrid(width: number, height: number): SequenceGrid {
|
||||
return new DiagramCanvas(width, height)
|
||||
}
|
||||
|
||||
function setCell(grid: SequenceGrid, x: number, y: number, char: string, style?: SequenceCellStyle): void {
|
||||
grid.setCell(x, y, char, style)
|
||||
}
|
||||
|
||||
function setText(grid: SequenceGrid, x: number, y: number, text: string, style?: SequenceCellStyle): void {
|
||||
grid.setText(Math.max(0, x), y, text, style)
|
||||
}
|
||||
|
||||
function setArrowDepartureFade(
|
||||
grid: SequenceGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
direction: 1 | -1,
|
||||
style: SequenceCellStyle,
|
||||
): void {
|
||||
setCell(
|
||||
grid,
|
||||
x,
|
||||
y,
|
||||
direction === 1 ? SEQUENCE_BORDER.leftT : SEQUENCE_BORDER.rightT,
|
||||
`${style}Fade1` as SequenceCellStyle,
|
||||
)
|
||||
for (let step = 2; step <= 5; step++) {
|
||||
setCell(grid, x + direction * (step - 1), y, SEQUENCE_BORDER.horizontal, `${style}Fade${step}` as SequenceCellStyle)
|
||||
}
|
||||
}
|
||||
|
||||
function groupVerticalChar(existing: string | undefined): string | undefined {
|
||||
switch (existing) {
|
||||
case undefined:
|
||||
case " ":
|
||||
return SEQUENCE_BORDER.vertical
|
||||
case SEQUENCE_BORDER.vertical:
|
||||
return SEQUENCE_BORDER.vertical
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function setGroupVerticalCell(grid: SequenceGrid, x: number, y: number): void {
|
||||
const existing = grid.getCell(x, y)?.char
|
||||
const char = groupVerticalChar(existing)
|
||||
if (char) setCell(grid, x, y, char, "group")
|
||||
}
|
||||
|
||||
function renderParticipantGroups(
|
||||
grid: SequenceGrid,
|
||||
groupBounds: readonly SequenceGroupPlacement[],
|
||||
bottomY: number,
|
||||
): void {
|
||||
for (const bounds of groupBounds) {
|
||||
for (let x = bounds.leftX; x <= bounds.rightX; x++) {
|
||||
setCell(grid, x, 0, SEQUENCE_BORDER.horizontal, "group")
|
||||
setCell(grid, x, bottomY, SEQUENCE_BORDER.horizontal, "group")
|
||||
}
|
||||
|
||||
setCell(grid, bounds.leftX, 0, SEQUENCE_BORDER.topLeft, "group")
|
||||
setCell(grid, bounds.rightX, 0, SEQUENCE_BORDER.topRight, "group")
|
||||
setCell(grid, bounds.leftX, bottomY, SEQUENCE_BORDER.bottomLeft, "group")
|
||||
setCell(grid, bounds.rightX, bottomY, SEQUENCE_BORDER.bottomRight, "group")
|
||||
|
||||
for (let y = 1; y < bottomY; y++) {
|
||||
setGroupVerticalCell(grid, bounds.leftX, y)
|
||||
setGroupVerticalCell(grid, bounds.rightX, y)
|
||||
}
|
||||
|
||||
if (bounds.labelText) {
|
||||
setText(grid, bounds.leftX + 2, 0, bounds.labelText, "group")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawFragmentWalls(
|
||||
grid: SequenceGrid,
|
||||
bounds: SequenceHorizontalBounds,
|
||||
startY: number,
|
||||
endY: number,
|
||||
borderStyle: BorderStyle,
|
||||
): void {
|
||||
if (endY < startY) return
|
||||
const border = BorderChars[borderStyle]
|
||||
|
||||
for (let y = startY; y <= endY; y++) {
|
||||
setCell(grid, bounds.leftX, y, border.vertical, "fragment")
|
||||
setCell(grid, bounds.rightX, y, border.vertical, "fragment")
|
||||
}
|
||||
}
|
||||
|
||||
function renderFragment(
|
||||
grid: SequenceGrid,
|
||||
placement: Extract<SequenceStepPlacement, { type: "fragment" }>,
|
||||
borderStyle: BorderStyle,
|
||||
): void {
|
||||
const { bounds, fragment, labelText: label, y } = placement
|
||||
const border = BorderChars[borderStyle]
|
||||
const { leftX, rightX } = bounds
|
||||
|
||||
const leftChar =
|
||||
fragment.kind === "alt" || fragment.kind === "loop"
|
||||
? border.topLeft
|
||||
: fragment.kind === "else"
|
||||
? border.leftT
|
||||
: border.bottomLeft
|
||||
const rightChar =
|
||||
fragment.kind === "alt" || fragment.kind === "loop"
|
||||
? border.topRight
|
||||
: fragment.kind === "else"
|
||||
? border.rightT
|
||||
: border.bottomRight
|
||||
|
||||
for (let x = leftX; x <= rightX; x++) {
|
||||
setCell(grid, x, y, border.horizontal, "fragment")
|
||||
}
|
||||
|
||||
setCell(grid, leftX, y, leftChar, "fragment")
|
||||
setCell(grid, rightX, y, rightChar, "fragment")
|
||||
if (label) {
|
||||
setText(grid, leftX + 2, y, label, "fragmentLabel")
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelfMessage(
|
||||
grid: SequenceGrid,
|
||||
placement: Extract<SequenceStepPlacement, { type: "selfMessage" }>,
|
||||
style: MessageStyle,
|
||||
): void {
|
||||
const { centerX, rightX, topY: topRow, bottomY: bottomRow, labelLines, message } = placement
|
||||
|
||||
setArrowDepartureFade(grid, centerX, topRow, 1, style)
|
||||
for (let x = centerX + FADE_STEPS.length; x < rightX; x++) {
|
||||
setCell(grid, x, topRow, SEQUENCE_BORDER.horizontal, style)
|
||||
}
|
||||
setCell(grid, rightX, topRow, SEQUENCE_BORDER.topRight, style)
|
||||
|
||||
for (let lineIndex = 0; lineIndex < labelLines.length; lineIndex++) {
|
||||
const y = topRow + lineIndex + 1
|
||||
setCell(grid, centerX, y, SEQUENCE_BORDER.vertical, "lifeline")
|
||||
setText(grid, centerX + 2, y, labelLines[lineIndex]!, style)
|
||||
setCell(grid, rightX, y, SEQUENCE_BORDER.vertical, style)
|
||||
}
|
||||
|
||||
for (let x = centerX + 1; x < rightX; x++) {
|
||||
setCell(grid, x, bottomRow, SEQUENCE_BORDER.horizontal, style)
|
||||
}
|
||||
const headX = message.head === undefined ? centerX : centerX + 1
|
||||
setCell(grid, headX, bottomRow, arrowHeadChar(message.head, -1), style)
|
||||
setCell(grid, rightX, bottomRow, SEQUENCE_BORDER.bottomRight, style)
|
||||
}
|
||||
|
||||
export function drawSequenceDiagramGrid(
|
||||
diagram: SequenceDiagram,
|
||||
options: SequenceDiagramRenderOptions = {},
|
||||
): SequenceGrid {
|
||||
const plan = createSequencePlacementPlan(diagram, options)
|
||||
if (plan.width === 0 || plan.height === 0) return createGrid(0, 0)
|
||||
const fragmentBorderStyle = options.fragmentBorderStyle ?? DEFAULT_FRAGMENT_BORDER_STYLE
|
||||
const grid = createGrid(plan.width, plan.height)
|
||||
|
||||
if (plan.groups.length > 0) renderParticipantGroups(grid, plan.groups, plan.height - 1)
|
||||
|
||||
for (const placement of plan.participants) {
|
||||
const { participant, centerX: center, headerLeftX, headerRightX, labelX } = placement
|
||||
const { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY } = plan.rows
|
||||
|
||||
if (options.compact) {
|
||||
setText(grid, labelX, participantHeaderY, participant.label, "participant")
|
||||
} else {
|
||||
for (let x = headerLeftX; x <= headerRightX; x++) {
|
||||
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
}
|
||||
|
||||
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
|
||||
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "participant")
|
||||
setCell(grid, headerLeftX, participantHeaderY, SEQUENCE_BORDER.vertical, "participant")
|
||||
setCell(grid, headerRightX, participantHeaderY, SEQUENCE_BORDER.vertical, "participant")
|
||||
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
|
||||
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
|
||||
setText(grid, labelX, participantHeaderY, participant.label, "participant")
|
||||
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
|
||||
}
|
||||
|
||||
for (let y = lifelineStartY; y <= lifelineEndY; y++) {
|
||||
const distance = y - lifelineStartY
|
||||
const style = !options.compact && distance < 3 ? (`lifelineRamp${distance + 1}` as SequenceCellStyle) : "lifeline"
|
||||
setCell(grid, center, y, SEQUENCE_BORDER.vertical, style)
|
||||
}
|
||||
}
|
||||
|
||||
for (const placement of plan.steps) {
|
||||
if (placement.type === "note") {
|
||||
for (let lineIndex = 0; lineIndex < placement.textLines.length; lineIndex++) {
|
||||
setText(grid, placement.textX, placement.textY + lineIndex, placement.textLines[lineIndex]!, "noteBadge")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (placement.type === "fragment") {
|
||||
if (placement.wallsBefore) {
|
||||
drawFragmentWalls(
|
||||
grid,
|
||||
placement.wallsBefore.bounds,
|
||||
placement.wallsBefore.startY,
|
||||
placement.wallsBefore.endY,
|
||||
fragmentBorderStyle,
|
||||
)
|
||||
}
|
||||
renderFragment(grid, placement, fragmentBorderStyle)
|
||||
continue
|
||||
}
|
||||
|
||||
const message = placement.message
|
||||
const messageStyle: MessageStyle = message.style === "dashed" ? "response" : "request"
|
||||
|
||||
if (placement.type === "selfMessage") {
|
||||
renderSelfMessage(grid, placement, messageStyle)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!placement.inlineLabel) {
|
||||
for (let lineIndex = 0; lineIndex < placement.labelLines.length; lineIndex++) {
|
||||
setText(grid, placement.labelX, placement.labelY + lineIndex, placement.labelLines[lineIndex]!, messageStyle)
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = placement.leftX + 1; x < placement.rightX; x++) {
|
||||
setCell(grid, x, placement.arrowY, SEQUENCE_BORDER.horizontal, messageStyle)
|
||||
}
|
||||
|
||||
setArrowDepartureFade(grid, placement.fromX, placement.arrowY, placement.direction, messageStyle)
|
||||
setCell(grid, placement.headX, placement.arrowY, arrowHeadChar(message.head, placement.direction), messageStyle)
|
||||
if (placement.inlineLabel) setText(grid, placement.labelX, placement.labelY, placement.inlineLabel, messageStyle)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { BorderStyle } from "@opentui/core"
|
||||
|
||||
export const DEFAULT_MIN_PARTICIPANT_GAP = 18
|
||||
export const DEFAULT_FRAGMENT_BORDER_STYLE = "rounded" satisfies BorderStyle
|
||||
|
||||
export function normalizeSequenceMinParticipantGap(value: number | undefined): number {
|
||||
return value === undefined || !Number.isFinite(value) ? DEFAULT_MIN_PARTICIPANT_GAP : Math.max(1, Math.floor(value))
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
firstMeaningfulMermaidLine,
|
||||
meaningfulNumberedMermaidLines,
|
||||
stripMermaidQuotes as stripQuotes,
|
||||
} from "../core/mermaid.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import type {
|
||||
SequenceArrowHead,
|
||||
SequenceDiagram,
|
||||
SequenceMessage,
|
||||
SequenceParticipant,
|
||||
SequenceParticipantGroup,
|
||||
SequenceStep,
|
||||
} from "./types.js"
|
||||
|
||||
const MESSAGE_RE = /^(.+?)\s*(-->>|->>|--x|-x|--\)|-\)|-->|->)([+-]?)\s*(.+?)\s*:\s*(.*)$/
|
||||
const NOTE_RE = /^note\s+(over|left\s+of|right\s+of)\s+(.+?)\s*:\s*(.*)$/i
|
||||
const PARTICIPANT_RE = /^(?:participant|actor)\s+(\S+)(?:\s+as\s+(.+))?$/i
|
||||
const ACTIVATION_RE = /^(activate|deactivate)\s+(.+)$/i
|
||||
const BOX_RE = /^box(?:\s+(.+))?$/i
|
||||
const ALT_RE = /^alt\s+(.+)$/i
|
||||
const ELSE_RE = /^else(?:\s+(.+))?$/i
|
||||
const LOOP_RE = /^loop\s+(.+)$/i
|
||||
const AUTONUMBER_RE = /^autonumber(?:\s+(\d+)(?:\s+(\d+))?)?$/i
|
||||
const CSS_COLOR_NAMES = new Set([
|
||||
"black",
|
||||
"white",
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"yellow",
|
||||
"cyan",
|
||||
"magenta",
|
||||
"silver",
|
||||
"gray",
|
||||
"grey",
|
||||
"maroon",
|
||||
"olive",
|
||||
"lime",
|
||||
"aqua",
|
||||
"teal",
|
||||
"navy",
|
||||
"fuchsia",
|
||||
"purple",
|
||||
"orange",
|
||||
"brightblack",
|
||||
"brightred",
|
||||
"brightgreen",
|
||||
"brightblue",
|
||||
"brightyellow",
|
||||
"brightcyan",
|
||||
"brightmagenta",
|
||||
"brightwhite",
|
||||
])
|
||||
|
||||
function arrowHeadForSyntax(arrow: string): SequenceArrowHead | undefined {
|
||||
if (arrow.endsWith("x")) return "cross"
|
||||
if (arrow.endsWith(")")) return "async"
|
||||
if (arrow.endsWith(">") && !arrow.endsWith(">>")) return "open"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isBoxColorToken(value: string): boolean {
|
||||
const lowerValue = value.toLowerCase()
|
||||
return (
|
||||
lowerValue === "transparent" ||
|
||||
CSS_COLOR_NAMES.has(lowerValue) ||
|
||||
/^#[0-9a-f]{3,8}$/i.test(value) ||
|
||||
/^rgba?\(.+\)$/i.test(value)
|
||||
)
|
||||
}
|
||||
|
||||
function splitLeadingBoxToken(value: string): { token: string; rest: string } {
|
||||
if (/^rgba?\(/i.test(value)) {
|
||||
const closeIndex = value.indexOf(")")
|
||||
if (closeIndex >= 0) {
|
||||
return { token: value.slice(0, closeIndex + 1), rest: value.slice(closeIndex + 1).trim() }
|
||||
}
|
||||
}
|
||||
|
||||
const firstSpace = value.search(/\s/)
|
||||
return firstSpace < 0
|
||||
? { token: value, rest: "" }
|
||||
: { token: value.slice(0, firstSpace), rest: value.slice(firstSpace + 1).trim() }
|
||||
}
|
||||
|
||||
function boxLabelText(value: string | undefined): string {
|
||||
const rawLabel = (value ?? "").trim()
|
||||
if ((rawLabel.startsWith('"') && rawLabel.endsWith('"')) || (rawLabel.startsWith("'") && rawLabel.endsWith("'"))) {
|
||||
return stripQuotes(rawLabel)
|
||||
}
|
||||
|
||||
const label = stripQuotes(rawLabel)
|
||||
if (!label) return ""
|
||||
const { token, rest } = splitLeadingBoxToken(label)
|
||||
return isBoxColorToken(token) ? stripQuotes(rest) : label
|
||||
}
|
||||
|
||||
function addParticipantToGroup(group: SequenceParticipantGroup | undefined, participantId: string): void {
|
||||
if (!group || group.participantIds.includes(participantId)) return
|
||||
group.participantIds.push(participantId)
|
||||
}
|
||||
|
||||
function ensureParticipant(
|
||||
participants: SequenceParticipant[],
|
||||
id: string,
|
||||
label: string = id,
|
||||
replaceExistingLabel: boolean = false,
|
||||
): void {
|
||||
const existing = participants.find((participant) => participant.id === id)
|
||||
if (existing) {
|
||||
if (replaceExistingLabel) existing.label = label
|
||||
return
|
||||
}
|
||||
participants.push({ id, label })
|
||||
}
|
||||
|
||||
export function isMermaidSequenceDiagram(content: string): boolean {
|
||||
return firstMeaningfulMermaidLine(content)?.toLowerCase() === "sequencediagram"
|
||||
}
|
||||
|
||||
export function parseMermaidSequenceDiagram(content: string): SequenceDiagram {
|
||||
const participants: SequenceParticipant[] = []
|
||||
const messages: SequenceMessage[] = []
|
||||
const steps: SequenceStep[] = []
|
||||
const groups: SequenceParticipantGroup[] = []
|
||||
const blockStack: Array<{ kind: "box" | "alt" | "loop"; lineNumber: number; sourceLine: string }> = []
|
||||
const groupStack: SequenceParticipantGroup[] = []
|
||||
let nextMessageNumber: number | undefined
|
||||
let messageNumberIncrement = 1
|
||||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
if (line.toLowerCase() === "sequencediagram") continue
|
||||
|
||||
const autonumberMatch = line.match(AUTONUMBER_RE)
|
||||
if (autonumberMatch) {
|
||||
nextMessageNumber = Number.parseInt(autonumberMatch[1] ?? "1", 10)
|
||||
messageNumberIncrement = Number.parseInt(autonumberMatch[2] ?? "1", 10)
|
||||
continue
|
||||
}
|
||||
|
||||
const boxMatch = line.match(BOX_RE)
|
||||
if (boxMatch) {
|
||||
const group: SequenceParticipantGroup = { label: boxLabelText(boxMatch[1]), participantIds: [] }
|
||||
groups.push(group)
|
||||
groupStack.push(group)
|
||||
blockStack.push({ kind: "box", lineNumber: source.lineNumber, sourceLine: line })
|
||||
continue
|
||||
}
|
||||
|
||||
const participantMatch = line.match(PARTICIPANT_RE)
|
||||
if (participantMatch) {
|
||||
const id = stripQuotes(participantMatch[1]!)
|
||||
ensureParticipant(participants, id, stripQuotes(participantMatch[2] ?? id), true)
|
||||
addParticipantToGroup(groupStack[groupStack.length - 1], id)
|
||||
continue
|
||||
}
|
||||
|
||||
const noteMatch = line.match(NOTE_RE)
|
||||
if (noteMatch) {
|
||||
const position = noteMatch[1]!.toLowerCase().replace(/\s+of$/, "") as "over" | "left" | "right"
|
||||
const over = noteMatch[2]!
|
||||
.split(",")
|
||||
.map((participant) => stripQuotes(participant))
|
||||
.filter((participant) => participant.length > 0)
|
||||
for (const participant of over) ensureParticipant(participants, participant)
|
||||
steps.push({
|
||||
type: "note",
|
||||
note: {
|
||||
over,
|
||||
label: stripQuotes(noteMatch[3]!),
|
||||
...(position === "over" ? {} : { position }),
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const activationMatch = line.match(ACTIVATION_RE)
|
||||
if (activationMatch) {
|
||||
const participant = stripQuotes(activationMatch[2]!)
|
||||
ensureParticipant(participants, participant)
|
||||
steps.push({
|
||||
type: "activation",
|
||||
activation: { participant, active: activationMatch[1]!.toLowerCase() === "activate" },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const altMatch = line.match(ALT_RE)
|
||||
if (altMatch) {
|
||||
blockStack.push({ kind: "alt", lineNumber: source.lineNumber, sourceLine: line })
|
||||
steps.push({ type: "fragment", fragment: { kind: "alt", label: stripQuotes(altMatch[1]!) } })
|
||||
continue
|
||||
}
|
||||
|
||||
const loopMatch = line.match(LOOP_RE)
|
||||
if (loopMatch) {
|
||||
blockStack.push({ kind: "loop", lineNumber: source.lineNumber, sourceLine: line })
|
||||
steps.push({ type: "fragment", fragment: { kind: "loop", label: stripQuotes(loopMatch[1]!) } })
|
||||
continue
|
||||
}
|
||||
|
||||
const elseMatch = line.match(ELSE_RE)
|
||||
if (elseMatch) {
|
||||
if (blockStack[blockStack.length - 1]?.kind !== "alt") {
|
||||
throw new MermaidSyntaxError(
|
||||
"sequence",
|
||||
source.lineNumber,
|
||||
line,
|
||||
'Unexpected "else" without an open "alt" block',
|
||||
)
|
||||
}
|
||||
steps.push({ type: "fragment", fragment: { kind: "else", label: stripQuotes(elseMatch[1] ?? "") } })
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.toLowerCase() === "end") {
|
||||
const block = blockStack.pop()
|
||||
if (!block)
|
||||
throw new MermaidSyntaxError("sequence", source.lineNumber, line, 'Unexpected "end" without an open block')
|
||||
if (block.kind === "box") {
|
||||
groupStack.pop()
|
||||
continue
|
||||
}
|
||||
steps.push({ type: "fragment", fragment: { kind: "end", label: block.kind } })
|
||||
continue
|
||||
}
|
||||
|
||||
const messageMatch = line.match(MESSAGE_RE)
|
||||
if (messageMatch) {
|
||||
const from = stripQuotes(messageMatch[1]!)
|
||||
const arrow = messageMatch[2]!
|
||||
const activationMarker = messageMatch[3]!
|
||||
const to = stripQuotes(messageMatch[4]!)
|
||||
const message: SequenceMessage = {
|
||||
from,
|
||||
to,
|
||||
label: stripQuotes(messageMatch[5]!),
|
||||
style: arrow.startsWith("--") ? "dashed" : "solid",
|
||||
}
|
||||
const activeGroup = groupStack[groupStack.length - 1]
|
||||
ensureParticipant(participants, from)
|
||||
ensureParticipant(participants, to)
|
||||
addParticipantToGroup(activeGroup, from)
|
||||
addParticipantToGroup(activeGroup, to)
|
||||
const head = arrowHeadForSyntax(arrow)
|
||||
if (head) message.head = head
|
||||
if (nextMessageNumber !== undefined) {
|
||||
message.number = nextMessageNumber
|
||||
nextMessageNumber += messageNumberIncrement
|
||||
}
|
||||
if (activationMarker === "+") message.activate = to
|
||||
else if (activationMarker === "-") message.deactivate = from
|
||||
messages.push(message)
|
||||
steps.push({ type: "message", message })
|
||||
continue
|
||||
}
|
||||
|
||||
throw new MermaidSyntaxError("sequence", source.lineNumber, line)
|
||||
}
|
||||
|
||||
const unclosedBlock = blockStack[blockStack.length - 1]
|
||||
if (unclosedBlock) {
|
||||
throw new MermaidSyntaxError(
|
||||
"sequence",
|
||||
unclosedBlock.lineNumber,
|
||||
unclosedBlock.sourceLine,
|
||||
`Unclosed ${unclosedBlock.kind} block; expected "end"`,
|
||||
)
|
||||
}
|
||||
|
||||
return { participants, messages, steps, groups }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { createSequencePlacementPlan } from "./placement.js"
|
||||
|
||||
describe("createSequencePlacementPlan", () => {
|
||||
test("expands one fragment frame for a longer else label", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
A->>B: start
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else validation failed with a substantially longer explanation
|
||||
B-->>A: no
|
||||
end`),
|
||||
)
|
||||
const fragments = plan.steps.filter((step) => step.type === "fragment")
|
||||
|
||||
expect(fragments).toHaveLength(3)
|
||||
expect(fragments.map((fragment) => fragment.bounds.rightX)).toEqual([
|
||||
fragments[0]!.bounds.rightX,
|
||||
fragments[0]!.bounds.rightX,
|
||||
fragments[0]!.bounds.rightX,
|
||||
])
|
||||
expect(fragments[1]!.labelText).toContain("validation failed")
|
||||
})
|
||||
|
||||
test("includes non-adjacent message and note labels within planned width", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message needs room past the final participant
|
||||
Note over A,C: this note also needs full horizontal room`),
|
||||
)
|
||||
const message = plan.steps.find((step) => step.type === "message")!
|
||||
const note = plan.steps.find((step) => step.type === "note")!
|
||||
|
||||
const messageWidth = Math.max(...message.labelLines.map(diagramTextWidth))
|
||||
expect(message.labelX + messageWidth).toBeLessThanOrEqual(plan.width)
|
||||
expect(note.textX + Math.max(...note.textLines.map(diagramTextWidth))).toBeLessThanOrEqual(plan.width)
|
||||
})
|
||||
|
||||
test("keeps side notes clear of adjacent participant lifelines", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
Note right of A: a long note between the first two participants
|
||||
Note left of C: another long note between the final participants`),
|
||||
)
|
||||
const notes = plan.steps.filter((step) => step.type === "note")
|
||||
|
||||
expect(notes[0]!.textX + Math.max(...notes[0]!.textLines.map(diagramTextWidth))).toBeLessThan(
|
||||
plan.participants[1]!.centerX,
|
||||
)
|
||||
expect(notes[1]!.textX).toBeGreaterThan(plan.participants[1]!.centerX)
|
||||
})
|
||||
|
||||
test("allocates group space around a contained self-message loop", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
box Backend
|
||||
participant Service
|
||||
Service->>Service: Check Permissions
|
||||
end`),
|
||||
)
|
||||
const group = plan.groups[0]!
|
||||
const message = plan.steps.find((step) => step.type === "selfMessage")!
|
||||
|
||||
expect(group.leftX).toBeGreaterThanOrEqual(0)
|
||||
expect(group.rightX).toBeGreaterThanOrEqual(message.rightX + 2)
|
||||
expect(plan.width).toBeGreaterThan(group.rightX)
|
||||
})
|
||||
|
||||
test("keeps external participants outside a group expanded by internal content", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
box G
|
||||
participant A
|
||||
end
|
||||
participant B as External
|
||||
A->>A: this self-loop extends underneath the external participant header`),
|
||||
)
|
||||
const group = plan.groups[0]!
|
||||
const external = plan.participants.find((participant) => participant.participant.id === "B")!
|
||||
|
||||
expect(external.headerLeftX).toBeGreaterThan(group.rightX)
|
||||
})
|
||||
|
||||
test("expands group and fragment frames around contained long content", () => {
|
||||
const groupPlan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message text runs far outside of the group container boundary
|
||||
end`),
|
||||
)
|
||||
const group = groupPlan.groups[0]!
|
||||
const groupedMessage = groupPlan.steps.find((step) => step.type === "message")!
|
||||
const groupedMessageRight = groupedMessage.labelX + Math.max(...groupedMessage.labelLines.map(diagramTextWidth)) - 1
|
||||
|
||||
expect(group.rightX).toBeGreaterThan(groupedMessageRight)
|
||||
|
||||
const fragmentPlan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
A->>C: this non adjacent message is deliberately much wider than the frame
|
||||
end`),
|
||||
)
|
||||
const fragment = fragmentPlan.steps
|
||||
.filter((step) => step.type === "fragment")
|
||||
.find((step) => step.fragment.kind === "alt")!
|
||||
const fragmentMessage = fragmentPlan.steps.find((step) => step.type === "message")!
|
||||
const fragmentMessageRight =
|
||||
fragmentMessage.labelX + Math.max(...fragmentMessage.labelLines.map(diagramTextWidth)) - 1
|
||||
|
||||
expect(fragment.bounds.rightX).toBeGreaterThan(fragmentMessageRight)
|
||||
})
|
||||
|
||||
test("sizes multiline notes and their group and fragment bounds for every br spelling", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
box Tools
|
||||
participant Tool
|
||||
alt retry
|
||||
Note over Tool: directory instead?<br/>the WrongKind error carries the answer —<br/>branch to list, no extra round trip
|
||||
Note over Tool: first<br>second<br />third
|
||||
end
|
||||
end`),
|
||||
)
|
||||
const notes = plan.steps.filter((step) => step.type === "note")
|
||||
const fragment = plan.steps.filter((step) => step.type === "fragment").find((step) => step.fragment.kind === "alt")!
|
||||
const group = plan.groups[0]!
|
||||
const firstNoteWidth = Math.max(...notes[0]!.textLines.map(diagramTextWidth))
|
||||
|
||||
expect(notes[0]!.textLines.map((line) => line.trim())).toEqual([
|
||||
"directory instead?",
|
||||
"the WrongKind error carries the answer —",
|
||||
"branch to list, no extra round trip",
|
||||
])
|
||||
expect(notes[1]!.textLines.map((line) => line.trim())).toEqual(["first", "second", "third"])
|
||||
expect(fragment.bounds.rightX).toBeGreaterThan(notes[0]!.textX + firstNoteWidth - 1)
|
||||
expect(group.rightX).toBeGreaterThan(notes[0]!.textX + firstNoteWidth - 1)
|
||||
expect(plan.height).toBeGreaterThan(notes[1]!.textY + notes[1]!.textLines.length)
|
||||
})
|
||||
|
||||
test("preserves nesting inset when a child fragment has a wide heading", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt outer
|
||||
loop inner heading wider than outer frame and participant span
|
||||
A->>B: x
|
||||
end
|
||||
end`),
|
||||
)
|
||||
const starts = plan.steps
|
||||
.filter((step) => step.type === "fragment")
|
||||
.filter((step) => step.fragment.kind === "alt" || step.fragment.kind === "loop")
|
||||
|
||||
expect(starts[0]!.bounds.rightX).toBeGreaterThan(starts[1]!.bounds.rightX)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,633 @@
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import { normalizeSequenceMinParticipantGap } from "./options.js"
|
||||
import type {
|
||||
SequenceDiagram,
|
||||
SequenceDiagramRenderOptions,
|
||||
SequenceFragment,
|
||||
SequenceMessage,
|
||||
SequenceNote,
|
||||
SequenceParticipant,
|
||||
SequenceParticipantGroup,
|
||||
SequenceStep,
|
||||
} from "./types.js"
|
||||
|
||||
const NOTE_HORIZONTAL_PADDING = 1
|
||||
const GROUP_HORIZONTAL_PADDING = 2
|
||||
const FRAGMENT_HORIZONTAL_OVERHANG = 3
|
||||
|
||||
export interface SequenceHorizontalBounds {
|
||||
leftX: number
|
||||
rightX: number
|
||||
}
|
||||
|
||||
export interface SequenceParticipantPlacement {
|
||||
participant: SequenceParticipant
|
||||
centerX: number
|
||||
headerLeftX: number
|
||||
headerRightX: number
|
||||
labelX: number
|
||||
}
|
||||
|
||||
export interface SequenceGroupPlacement {
|
||||
group: SequenceParticipantGroup
|
||||
labelText: string
|
||||
leftX: number
|
||||
rightX: number
|
||||
}
|
||||
|
||||
export interface SequenceWallPlacement {
|
||||
bounds: SequenceHorizontalBounds
|
||||
startY: number
|
||||
endY: number
|
||||
}
|
||||
|
||||
export type SequenceStepPlacement =
|
||||
| { type: "note"; note: SequenceNote; textLines: string[]; textX: number; textY: number }
|
||||
| {
|
||||
type: "fragment"
|
||||
fragment: SequenceFragment
|
||||
labelText: string
|
||||
bounds: SequenceHorizontalBounds
|
||||
y: number
|
||||
wallsBefore?: SequenceWallPlacement
|
||||
}
|
||||
| {
|
||||
type: "message"
|
||||
message: SequenceMessage
|
||||
labelLines: string[]
|
||||
labelX: number
|
||||
labelY: number
|
||||
arrowY: number
|
||||
fromX: number
|
||||
toX: number
|
||||
leftX: number
|
||||
rightX: number
|
||||
headX: number
|
||||
direction: 1 | -1
|
||||
inlineLabel?: string
|
||||
}
|
||||
| {
|
||||
type: "selfMessage"
|
||||
message: SequenceMessage
|
||||
labelLines: string[]
|
||||
centerX: number
|
||||
rightX: number
|
||||
topY: number
|
||||
bottomY: number
|
||||
}
|
||||
|
||||
export interface SequencePlacementPlan {
|
||||
width: number
|
||||
height: number
|
||||
rows: {
|
||||
participantHeaderTopY: number
|
||||
participantHeaderY: number
|
||||
participantRuleY: number
|
||||
lifelineStartY: number
|
||||
lifelineEndY: number
|
||||
}
|
||||
participants: SequenceParticipantPlacement[]
|
||||
groups: SequenceGroupPlacement[]
|
||||
steps: SequenceStepPlacement[]
|
||||
}
|
||||
|
||||
interface SequenceGroupRange {
|
||||
group: SequenceParticipantGroup
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
}
|
||||
|
||||
interface PendingFragmentFrame {
|
||||
startIndex: number
|
||||
bounds: SequenceHorizontalBounds
|
||||
}
|
||||
|
||||
interface ActiveFragmentFrame {
|
||||
bounds: SequenceHorizontalBounds
|
||||
boundaryY: number
|
||||
}
|
||||
|
||||
function visualLength(value: string): number {
|
||||
return diagramTextWidth(value)
|
||||
}
|
||||
|
||||
function centeredStart(center: number, text: string): number {
|
||||
return center - Math.floor(visualLength(text) / 2)
|
||||
}
|
||||
|
||||
function mermaidLabelLines(label: string): string[] {
|
||||
const lines = label.split(/(?:<br\s*\/?\s*>|\\n)/i).map((line) => line.trimEnd())
|
||||
return lines.length > 0 ? lines : [""]
|
||||
}
|
||||
|
||||
function noteLabelLines(label: string): string[] {
|
||||
const lines = mermaidLabelLines(label)
|
||||
const width = labelLinesWidth(lines)
|
||||
const padding = " ".repeat(NOTE_HORIZONTAL_PADDING)
|
||||
return lines.map((line) => `${padding}${line}${" ".repeat(width - visualLength(line))}${padding}`)
|
||||
}
|
||||
|
||||
function messageLabelText(message: SequenceMessage): string {
|
||||
return message.number === undefined ? message.label : `${message.number}. ${message.label}`
|
||||
}
|
||||
|
||||
function participantHeaderWidth(label: string, compact: boolean): number {
|
||||
return compact ? visualLength(label) : Math.max(5, visualLength(label) + 4)
|
||||
}
|
||||
|
||||
function fragmentLabelText(fragment: SequenceFragment): string {
|
||||
if (fragment.kind === "end") return ""
|
||||
const prefix = fragment.kind === "loop" ? "↻ loop" : fragment.kind
|
||||
return ` ${prefix}: ${fragment.label} `
|
||||
}
|
||||
|
||||
function messageLabelLines(label: string): string[] {
|
||||
return mermaidLabelLines(label)
|
||||
}
|
||||
|
||||
function labelLinesWidth(lines: string[]): number {
|
||||
return lines.reduce((max, line) => Math.max(max, visualLength(line)), 0)
|
||||
}
|
||||
|
||||
function messageWidth(message: SequenceMessage): number {
|
||||
return labelLinesWidth(messageLabelLines(messageLabelText(message)))
|
||||
}
|
||||
|
||||
function selfMessageLoopWidthForLines(labelLines: string[]): number {
|
||||
return Math.max(10, labelLinesWidth(labelLines) + 4)
|
||||
}
|
||||
|
||||
function selfMessageLoopWidth(message: SequenceMessage): number {
|
||||
return selfMessageLoopWidthForLines(messageLabelLines(messageLabelText(message)))
|
||||
}
|
||||
|
||||
function arrowHeadX(toX: number, direction: 1 | -1, head: SequenceMessage["head"]): number {
|
||||
return head === undefined ? toX : toX - direction
|
||||
}
|
||||
|
||||
function inlineMessageLabel(
|
||||
message: SequenceMessage,
|
||||
labelLines: string[],
|
||||
fromX: number,
|
||||
toX: number,
|
||||
compact: boolean,
|
||||
): string | undefined {
|
||||
if (!compact || message.from === message.to || labelLines.length !== 1) return undefined
|
||||
const label = ` ${labelLines[0]} `
|
||||
return visualLength(label) <= Math.abs(toX - fromX) - 3 ? label : undefined
|
||||
}
|
||||
|
||||
function getStepHeight(
|
||||
step: SequenceStep,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
compact: boolean,
|
||||
): number {
|
||||
if (step.type === "note") return noteLabelLines(step.note.label).length + 2
|
||||
if (step.type === "activation") return 0
|
||||
if (step.type === "fragment") return 2
|
||||
const labelLines = messageLabelLines(messageLabelText(step.message))
|
||||
const fromX = centers[participantIndexes.get(step.message.from) ?? -1]
|
||||
const toX = centers[participantIndexes.get(step.message.to) ?? -1]
|
||||
if (fromX !== undefined && toX !== undefined && inlineMessageLabel(step.message, labelLines, fromX, toX, compact)) {
|
||||
return 2
|
||||
}
|
||||
return labelLines.length + (step.message.from === step.message.to ? 3 : 2)
|
||||
}
|
||||
|
||||
function createParticipantIndexMap(diagram: SequenceDiagram): Map<string, number> {
|
||||
return new Map(diagram.participants.map((participant, index) => [participant.id, index]))
|
||||
}
|
||||
|
||||
function getParticipantIndexes(participantIndexes: Map<string, number>, participantIds: string[]): number[] {
|
||||
return participantIds.map((id) => participantIndexes.get(id) ?? -1).filter((index) => index >= 0)
|
||||
}
|
||||
|
||||
function groupLabelText(group: SequenceParticipantGroup): string {
|
||||
return group.label ? ` ${group.label} ` : ""
|
||||
}
|
||||
|
||||
function getGroupRanges(diagram: SequenceDiagram, participantIndexes: Map<string, number>): SequenceGroupRange[] {
|
||||
return diagram.groups.flatMap((group) => {
|
||||
const indexes = getParticipantIndexes(participantIndexes, group.participantIds)
|
||||
return indexes.length === 0 ? [] : [{ group, startIndex: Math.min(...indexes), endIndex: Math.max(...indexes) }]
|
||||
})
|
||||
}
|
||||
|
||||
function getStepParticipantIndexes(step: SequenceStep, participantIndexes: Map<string, number>): number[] {
|
||||
if (step.type === "message") {
|
||||
return getParticipantIndexes(participantIndexes, [step.message.from, step.message.to])
|
||||
}
|
||||
if (step.type === "note") return getParticipantIndexes(participantIndexes, step.note.over)
|
||||
return []
|
||||
}
|
||||
|
||||
function getStepContentBounds(
|
||||
step: SequenceStep,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
): SequenceHorizontalBounds | undefined {
|
||||
if (step.type === "message") {
|
||||
const fromIndex = participantIndexes.get(step.message.from) ?? -1
|
||||
const toIndex = participantIndexes.get(step.message.to) ?? -1
|
||||
if (fromIndex < 0 || toIndex < 0) return undefined
|
||||
const fromX = centers[fromIndex]!
|
||||
const toX = centers[toIndex]!
|
||||
if (fromIndex === toIndex) return { leftX: fromX, rightX: fromX + selfMessageLoopWidth(step.message) }
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
return { leftX, rightX: Math.max(rightX, leftX + 2 + messageWidth(step.message) - 1) }
|
||||
}
|
||||
if (step.type !== "note") return undefined
|
||||
const indexes = getParticipantIndexes(participantIndexes, step.note.over)
|
||||
if (indexes.length === 0) return undefined
|
||||
const centerX = Math.floor((centers[Math.min(...indexes)]! + centers[Math.max(...indexes)]!) / 2)
|
||||
const textLines = noteLabelLines(step.note.label)
|
||||
const width = labelLinesWidth(textLines)
|
||||
const leftX =
|
||||
step.note.position === "left"
|
||||
? centerX - width - 2
|
||||
: step.note.position === "right"
|
||||
? centerX + 2
|
||||
: centerX - Math.floor(width / 2)
|
||||
return { leftX, rightX: leftX + width - 1 }
|
||||
}
|
||||
|
||||
function rangeContainsIndexes(range: SequenceGroupRange, indexes: readonly number[]): boolean {
|
||||
return indexes.length > 0 && indexes.every((index) => index >= range.startIndex && index <= range.endIndex)
|
||||
}
|
||||
|
||||
function resolveGroupBounds(
|
||||
diagram: SequenceDiagram,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
groupRanges: SequenceGroupRange[],
|
||||
compact: boolean,
|
||||
): SequenceGroupPlacement[] {
|
||||
return groupRanges.map((range) => {
|
||||
let contentLeftX = centers[range.startIndex]!
|
||||
let contentRightX = centers[range.endIndex]!
|
||||
for (let i = range.startIndex; i <= range.endIndex; i++) {
|
||||
const headerWidth = participantHeaderWidth(diagram.participants[i]!.label, compact)
|
||||
const headerStartX = centers[i]! - Math.floor(headerWidth / 2)
|
||||
contentLeftX = Math.min(contentLeftX, headerStartX)
|
||||
contentRightX = Math.max(contentRightX, headerStartX + headerWidth - 1)
|
||||
}
|
||||
for (const step of diagram.steps) {
|
||||
const indexes = getStepParticipantIndexes(step, participantIndexes)
|
||||
if (!rangeContainsIndexes(range, indexes)) continue
|
||||
const bounds = getStepContentBounds(step, centers, participantIndexes)
|
||||
if (bounds) {
|
||||
contentLeftX = Math.min(contentLeftX, bounds.leftX)
|
||||
contentRightX = Math.max(contentRightX, bounds.rightX)
|
||||
}
|
||||
}
|
||||
const labelText = groupLabelText(range.group)
|
||||
let leftX = contentLeftX - GROUP_HORIZONTAL_PADDING
|
||||
let rightX = contentRightX + GROUP_HORIZONTAL_PADDING
|
||||
const extraWidth = Math.max(0, visualLength(labelText) + 4 - (rightX - leftX + 1))
|
||||
leftX -= Math.floor(extraWidth / 2)
|
||||
rightX += Math.ceil(extraWidth / 2)
|
||||
return { group: range.group, labelText, leftX, rightX }
|
||||
})
|
||||
}
|
||||
|
||||
function expandHorizontalBounds(bounds: SequenceHorizontalBounds, leftX: number, rightX: number): void {
|
||||
bounds.leftX = Math.min(bounds.leftX, leftX)
|
||||
bounds.rightX = Math.max(bounds.rightX, rightX)
|
||||
}
|
||||
|
||||
function getDiagramContentBounds(
|
||||
diagram: SequenceDiagram,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
compact: boolean,
|
||||
): SequenceHorizontalBounds {
|
||||
const bounds = { leftX: 0, rightX: 0 }
|
||||
for (let i = 0; i < diagram.participants.length; i++) {
|
||||
const headerWidth = participantHeaderWidth(diagram.participants[i]!.label, compact)
|
||||
const labelStartX = centers[i]! - Math.floor(headerWidth / 2)
|
||||
expandHorizontalBounds(bounds, labelStartX, labelStartX + headerWidth - 1)
|
||||
}
|
||||
for (const step of diagram.steps) {
|
||||
const content = getStepContentBounds(step, centers, participantIndexes)
|
||||
if (content) expandHorizontalBounds(bounds, content.leftX, content.rightX)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
function getFragmentFrameBounds(
|
||||
centers: number[],
|
||||
fragment: SequenceFragment,
|
||||
nestingDepth = 0,
|
||||
): SequenceHorizontalBounds | undefined {
|
||||
const leftParticipantX = centers[0]
|
||||
const rightParticipantX = centers[centers.length - 1]
|
||||
if (leftParticipantX === undefined || rightParticipantX === undefined) return undefined
|
||||
const leftX = leftParticipantX - FRAGMENT_HORIZONTAL_OVERHANG + nestingDepth
|
||||
const participantRightX = rightParticipantX + FRAGMENT_HORIZONTAL_OVERHANG - nestingDepth
|
||||
return { leftX, rightX: Math.max(participantRightX, leftX + 2 + visualLength(fragmentLabelText(fragment)) + 1) }
|
||||
}
|
||||
|
||||
function getFragmentFrameBoundsByStep(
|
||||
centers: number[],
|
||||
steps: SequenceStep[],
|
||||
participantIndexes: Map<string, number>,
|
||||
): Map<number, SequenceHorizontalBounds> {
|
||||
const boundsByStep = new Map<number, SequenceHorizontalBounds>()
|
||||
const activeFrames: PendingFragmentFrame[] = []
|
||||
for (const [index, step] of steps.entries()) {
|
||||
if (step.type !== "fragment") {
|
||||
const content = getStepContentBounds(step, centers, participantIndexes)
|
||||
if (content) {
|
||||
for (const frame of activeFrames) {
|
||||
expandHorizontalBounds(frame.bounds, content.leftX - 1, content.rightX + 1)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
const bounds = getFragmentFrameBounds(centers, step.fragment, activeFrames.length)
|
||||
if (!bounds) continue
|
||||
if (step.fragment.kind === "alt" || step.fragment.kind === "loop") {
|
||||
activeFrames.push({ startIndex: index, bounds: { ...bounds } })
|
||||
continue
|
||||
}
|
||||
const frame = activeFrames[activeFrames.length - 1]
|
||||
if (!frame) continue
|
||||
expandHorizontalBounds(frame.bounds, bounds.leftX, bounds.rightX)
|
||||
if (step.fragment.kind !== "end") continue
|
||||
activeFrames.pop()
|
||||
boundsByStep.set(frame.startIndex, frame.bounds)
|
||||
const parent = activeFrames[activeFrames.length - 1]
|
||||
if (parent) expandHorizontalBounds(parent.bounds, frame.bounds.leftX - 1, frame.bounds.rightX + 1)
|
||||
}
|
||||
for (const frame of activeFrames) boundsByStep.set(frame.startIndex, frame.bounds)
|
||||
return boundsByStep
|
||||
}
|
||||
|
||||
function resolveParticipantCenters(
|
||||
diagram: SequenceDiagram,
|
||||
participantIndexes: Map<string, number>,
|
||||
minParticipantGap: number,
|
||||
compact: boolean,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, diagram.participants.length - 1) }, (_, index) => {
|
||||
const left = diagram.participants[index]!
|
||||
const right = diagram.participants[index + 1]!
|
||||
return Math.max(
|
||||
minParticipantGap,
|
||||
Math.ceil(participantHeaderWidth(left.label, compact) / 2) +
|
||||
Math.ceil(participantHeaderWidth(right.label, compact) / 2) +
|
||||
6,
|
||||
)
|
||||
})
|
||||
for (const message of diagram.messages) {
|
||||
const fromIndex = participantIndexes.get(message.from) ?? -1
|
||||
const toIndex = participantIndexes.get(message.to) ?? -1
|
||||
if (fromIndex === toIndex && fromIndex >= 0 && fromIndex < diagram.participants.length - 1) {
|
||||
gaps[fromIndex] = Math.max(
|
||||
gaps[fromIndex]!,
|
||||
selfMessageLoopWidth(message) + Math.ceil(visualLength(diagram.participants[fromIndex + 1]!.label) / 2) + 2,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (fromIndex < 0 || toIndex < 0 || Math.abs(fromIndex - toIndex) !== 1) continue
|
||||
const gapIndex = Math.min(fromIndex, toIndex)
|
||||
gaps[gapIndex] = Math.max(gaps[gapIndex]!, messageWidth(message) + 6)
|
||||
}
|
||||
for (const step of diagram.steps) {
|
||||
if (step.type !== "note") continue
|
||||
const indexes = getParticipantIndexes(participantIndexes, step.note.over)
|
||||
if (indexes.length === 1 && step.note.position && step.note.position !== "over") {
|
||||
const participantIndex = indexes[0]!
|
||||
const gapIndex = step.note.position === "left" ? participantIndex - 1 : participantIndex
|
||||
if (gapIndex >= 0 && gapIndex < gaps.length) {
|
||||
gaps[gapIndex] = Math.max(gaps[gapIndex]!, labelLinesWidth(noteLabelLines(step.note.label)) + 4)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (indexes.length !== 2 || Math.abs(indexes[0]! - indexes[1]!) !== 1) continue
|
||||
const gapIndex = Math.min(indexes[0]!, indexes[1]!)
|
||||
gaps[gapIndex] = Math.max(gaps[gapIndex]!, labelLinesWidth(noteLabelLines(step.note.label)) + 4)
|
||||
}
|
||||
const centers = [Math.max(1, Math.floor(participantHeaderWidth(diagram.participants[0]?.label ?? "", compact) / 2))]
|
||||
for (let i = 1; i < diagram.participants.length; i++) centers[i] = centers[i - 1]! + gaps[i - 1]!
|
||||
return centers
|
||||
}
|
||||
|
||||
function separateExpandedGroupsFromExternalParticipants(
|
||||
diagram: SequenceDiagram,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
ranges: SequenceGroupRange[],
|
||||
compact: boolean,
|
||||
): number[] {
|
||||
const adjusted = [...centers]
|
||||
for (let pass = 0; pass < Math.max(1, ranges.length * 2); pass++) {
|
||||
let changed = false
|
||||
const groups = resolveGroupBounds(diagram, adjusted, participantIndexes, ranges, compact)
|
||||
for (const [index, range] of ranges.entries()) {
|
||||
const group = groups[index]!
|
||||
if (range.startIndex > 0) {
|
||||
const previousIndex = range.startIndex - 1
|
||||
const previousWidth = participantHeaderWidth(diagram.participants[previousIndex]!.label, compact)
|
||||
const previousRight = adjusted[previousIndex]! - Math.floor(previousWidth / 2) + previousWidth - 1
|
||||
const shift = previousRight + GROUP_HORIZONTAL_PADDING + 1 - group.leftX
|
||||
if (shift > 0) {
|
||||
for (let participantIndex = range.startIndex; participantIndex < adjusted.length; participantIndex++) {
|
||||
adjusted[participantIndex]! += shift
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (range.endIndex < diagram.participants.length - 1) {
|
||||
const nextIndex = range.endIndex + 1
|
||||
const nextWidth = participantHeaderWidth(diagram.participants[nextIndex]!.label, compact)
|
||||
const nextLeft = adjusted[nextIndex]! - Math.floor(nextWidth / 2)
|
||||
const shift = group.rightX + GROUP_HORIZONTAL_PADDING + 1 - nextLeft
|
||||
if (shift > 0) {
|
||||
for (let participantIndex = nextIndex; participantIndex < adjusted.length; participantIndex++) {
|
||||
adjusted[participantIndex]! += shift
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!changed) return adjusted
|
||||
}
|
||||
return adjusted
|
||||
}
|
||||
|
||||
export function createSequencePlacementPlan(
|
||||
diagram: SequenceDiagram,
|
||||
options: Pick<SequenceDiagramRenderOptions, "compact" | "minParticipantGap"> = {},
|
||||
): SequencePlacementPlan {
|
||||
if (diagram.participants.length === 0) {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
rows: {
|
||||
participantHeaderTopY: 0,
|
||||
participantHeaderY: 0,
|
||||
participantRuleY: 0,
|
||||
lifelineStartY: 0,
|
||||
lifelineEndY: 0,
|
||||
},
|
||||
participants: [],
|
||||
groups: [],
|
||||
steps: [],
|
||||
}
|
||||
}
|
||||
const indexes = createParticipantIndexMap(diagram)
|
||||
const compact = options.compact ?? false
|
||||
let centers = resolveParticipantCenters(
|
||||
diagram,
|
||||
indexes,
|
||||
normalizeSequenceMinParticipantGap(options.minParticipantGap),
|
||||
compact,
|
||||
)
|
||||
const ranges = getGroupRanges(diagram, indexes)
|
||||
centers = separateExpandedGroupsFromExternalParticipants(diagram, centers, indexes, ranges, compact)
|
||||
let groups = resolveGroupBounds(diagram, centers, indexes, ranges, compact)
|
||||
let contentBounds = getDiagramContentBounds(diagram, centers, indexes, compact)
|
||||
let frameBounds = getFragmentFrameBoundsByStep(centers, diagram.steps, indexes)
|
||||
const fragmentBounds = (): SequenceHorizontalBounds => {
|
||||
const result = { leftX: 0, rightX: 0 }
|
||||
for (const bounds of frameBounds.values()) expandHorizontalBounds(result, bounds.leftX, bounds.rightX)
|
||||
return result
|
||||
}
|
||||
let fragments = fragmentBounds()
|
||||
const leftOverflow = Math.min(
|
||||
groups.reduce((left, group) => Math.min(left, group.leftX), 0),
|
||||
contentBounds.leftX,
|
||||
fragments.leftX,
|
||||
0,
|
||||
)
|
||||
if (leftOverflow < 0) {
|
||||
centers = centers.map((center) => center - leftOverflow)
|
||||
groups = resolveGroupBounds(diagram, centers, indexes, ranges, compact)
|
||||
contentBounds = getDiagramContentBounds(diagram, centers, indexes, compact)
|
||||
frameBounds = getFragmentFrameBoundsByStep(centers, diagram.steps, indexes)
|
||||
fragments = fragmentBounds()
|
||||
}
|
||||
const hasGroups = groups.length > 0
|
||||
const participantHeaderTopY = hasGroups ? 1 : 0
|
||||
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
|
||||
const participantRuleY = participantHeaderTopY + (compact ? 0 : 2)
|
||||
const lifelineStartY = participantRuleY + 1
|
||||
const stepStartY = lifelineStartY + 1
|
||||
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
|
||||
const baseHeight =
|
||||
stepStartY + diagram.steps.reduce((total, step) => total + getStepHeight(step, centers, indexes, compact), 0)
|
||||
const height = hasGroups ? Math.max(5, baseHeight + 1) : Math.max(3, baseHeight)
|
||||
const lifelineEndY = hasGroups ? height - 2 : height - 1
|
||||
const participants = diagram.participants.map((participant, index) => {
|
||||
const centerX = centers[index]!
|
||||
const width = participantHeaderWidth(participant.label, compact)
|
||||
const headerLeftX = centerX - Math.floor(width / 2)
|
||||
return {
|
||||
participant,
|
||||
centerX,
|
||||
headerLeftX,
|
||||
headerRightX: headerLeftX + width - 1,
|
||||
labelX: centeredStart(centerX, participant.label),
|
||||
}
|
||||
})
|
||||
const steps: SequenceStepPlacement[] = []
|
||||
let stepY = stepStartY
|
||||
const activeFrames: ActiveFragmentFrame[] = []
|
||||
for (const [stepIndex, step] of diagram.steps.entries()) {
|
||||
if (step.type === "activation") continue
|
||||
const stepHeight = getStepHeight(step, centers, indexes, compact)
|
||||
if (step.type === "note") {
|
||||
const noteIndexes = getParticipantIndexes(indexes, step.note.over)
|
||||
if (noteIndexes.length > 0) {
|
||||
const centerX = Math.floor((centers[Math.min(...noteIndexes)]! + centers[Math.max(...noteIndexes)]!) / 2)
|
||||
const textLines = noteLabelLines(step.note.label)
|
||||
const width = labelLinesWidth(textLines)
|
||||
const textX =
|
||||
step.note.position === "left"
|
||||
? centerX - width - 2
|
||||
: step.note.position === "right"
|
||||
? centerX + 2
|
||||
: centerX - Math.floor(width / 2)
|
||||
steps.push({ type: "note", note: step.note, textLines, textX, textY: stepY + 1 })
|
||||
}
|
||||
stepY += stepHeight
|
||||
continue
|
||||
}
|
||||
if (step.type === "fragment") {
|
||||
let bounds = frameBounds.get(stepIndex) ?? getFragmentFrameBounds(centers, step.fragment)
|
||||
let wallsBefore: SequenceWallPlacement | undefined
|
||||
if (step.fragment.kind === "alt" || step.fragment.kind === "loop") {
|
||||
if (bounds) activeFrames.push({ bounds, boundaryY: stepY })
|
||||
} else {
|
||||
const frame = activeFrames[activeFrames.length - 1]
|
||||
if (frame) {
|
||||
bounds = frame.bounds
|
||||
wallsBefore = { bounds, startY: frame.boundaryY + 1, endY: stepY - 1 }
|
||||
if (step.fragment.kind === "end") activeFrames.pop()
|
||||
else frame.boundaryY = stepY
|
||||
}
|
||||
}
|
||||
if (bounds)
|
||||
steps.push({
|
||||
type: "fragment",
|
||||
fragment: step.fragment,
|
||||
labelText: fragmentLabelText(step.fragment),
|
||||
bounds,
|
||||
y: stepY,
|
||||
wallsBefore,
|
||||
})
|
||||
stepY += stepHeight
|
||||
continue
|
||||
}
|
||||
const fromIndex = indexes.get(step.message.from) ?? -1
|
||||
const toIndex = indexes.get(step.message.to) ?? -1
|
||||
if (fromIndex < 0 || toIndex < 0) continue
|
||||
const labelLines = messageLabelLines(messageLabelText(step.message))
|
||||
if (fromIndex === toIndex) {
|
||||
const centerX = centers[fromIndex]!
|
||||
steps.push({
|
||||
type: "selfMessage",
|
||||
message: step.message,
|
||||
labelLines,
|
||||
centerX,
|
||||
rightX: centerX + selfMessageLoopWidthForLines(labelLines),
|
||||
topY: stepY,
|
||||
bottomY: stepY + labelLines.length + 1,
|
||||
})
|
||||
} else {
|
||||
const fromX = centers[fromIndex]!
|
||||
const toX = centers[toIndex]!
|
||||
const direction: 1 | -1 = toX > fromX ? 1 : -1
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
|
||||
steps.push({
|
||||
type: "message",
|
||||
message: step.message,
|
||||
labelLines,
|
||||
labelX: leftX + 2,
|
||||
labelY: stepY,
|
||||
arrowY: inlineLabel ? stepY : stepY + labelLines.length,
|
||||
fromX,
|
||||
toX,
|
||||
leftX,
|
||||
rightX,
|
||||
direction,
|
||||
headX: arrowHeadX(toX, direction, step.message.head),
|
||||
inlineLabel,
|
||||
})
|
||||
}
|
||||
stepY += stepHeight
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
rows: { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY },
|
||||
participants,
|
||||
groups,
|
||||
steps,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas } from "../core/canvas.js"
|
||||
import { renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import { sequenceStyleBackgroundColor, sequenceStyleColor, type SequenceStyleColors } from "./style.js"
|
||||
import type { SequenceCellStyle } from "./types.js"
|
||||
|
||||
export type SequenceGrid = DiagramCanvas<SequenceCellStyle>
|
||||
|
||||
export function renderSequenceGridText(grid: SequenceGrid): string {
|
||||
return grid.toString()
|
||||
}
|
||||
|
||||
export function renderSequenceGridStyledText(
|
||||
grid: SequenceGrid,
|
||||
colors: Parameters<typeof sequenceStyleColor>[1],
|
||||
): StyledText {
|
||||
return renderDiagramGridStyledText(
|
||||
grid,
|
||||
(run) => sequenceStyleColor(run.style, colors),
|
||||
(run) => sequenceStyleBackgroundColor(run.style, colors as Required<SequenceStyleColors>),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import {
|
||||
createColorRampTheme,
|
||||
DIAGRAM_FADE_STEPS,
|
||||
numberedStyleKeys,
|
||||
rgba,
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
import type { FadeStyle, LifelineRampStyle, SequenceCellStyle } from "./types.js"
|
||||
|
||||
export interface SequenceStyleColors {
|
||||
participant?: RGBA
|
||||
lifeline?: RGBA
|
||||
group?: RGBA
|
||||
request?: RGBA
|
||||
response?: RGBA
|
||||
fragment?: RGBA
|
||||
fragmentLabelBg?: RGBA
|
||||
note?: RGBA
|
||||
noteBg?: RGBA
|
||||
}
|
||||
|
||||
export const SEQUENCE_FADE_STEPS = DIAGRAM_FADE_STEPS
|
||||
const LIFELINE_RAMP_STYLES = [
|
||||
"lifelineRamp1",
|
||||
"lifelineRamp2",
|
||||
"lifelineRamp3",
|
||||
] as const satisfies readonly LifelineRampStyle[]
|
||||
|
||||
const DEFAULT_THEME_RGB = {
|
||||
participant: [228, 239, 232],
|
||||
lifeline: [111, 138, 126],
|
||||
group: [76, 99, 89],
|
||||
request: [134, 225, 200],
|
||||
response: [230, 177, 126],
|
||||
fragment: [154, 184, 169],
|
||||
fragmentLabelBg: [28, 43, 36],
|
||||
noteFg: [215, 229, 221],
|
||||
noteBg: [36, 56, 47],
|
||||
} as const satisfies Record<string, DiagramRgb>
|
||||
|
||||
export function resolveSequenceStyleColors(
|
||||
colors: SequenceStyleColors = {},
|
||||
): Required<SequenceStyleColors> & Record<FadeStyle | LifelineRampStyle, RGBA> {
|
||||
const participant = colors.participant ?? rgba(DEFAULT_THEME_RGB.participant)
|
||||
const lifeline = colors.lifeline ?? rgba(DEFAULT_THEME_RGB.lifeline)
|
||||
const request = colors.request ?? rgba(DEFAULT_THEME_RGB.request)
|
||||
const response = colors.response ?? rgba(DEFAULT_THEME_RGB.response)
|
||||
return {
|
||||
participant,
|
||||
lifeline,
|
||||
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
|
||||
request,
|
||||
response,
|
||||
fragment: colors.fragment ?? rgba(DEFAULT_THEME_RGB.fragment),
|
||||
fragmentLabelBg: colors.fragmentLabelBg ?? rgba(DEFAULT_THEME_RGB.fragmentLabelBg),
|
||||
note: colors.note ?? rgba(DEFAULT_THEME_RGB.noteFg),
|
||||
noteBg: colors.noteBg ?? rgba(DEFAULT_THEME_RGB.noteBg),
|
||||
...createColorRampTheme(numberedStyleKeys("requestFade", SEQUENCE_FADE_STEPS), lifeline, request),
|
||||
...createColorRampTheme(numberedStyleKeys("responseFade", SEQUENCE_FADE_STEPS), lifeline, response),
|
||||
...createColorRampTheme(LIFELINE_RAMP_STYLES, participant, lifeline),
|
||||
}
|
||||
}
|
||||
|
||||
export function sequenceStyleColor(
|
||||
style: SequenceCellStyle | undefined,
|
||||
colors: Required<SequenceStyleColors> & Record<FadeStyle | LifelineRampStyle, RGBA>,
|
||||
): RGBA | undefined {
|
||||
if (style === "noteBadge") return colors.note
|
||||
if (style === "fragmentLabel") return colors.fragment
|
||||
return style ? colors[style] : undefined
|
||||
}
|
||||
|
||||
export function sequenceStyleBackgroundColor(
|
||||
style: SequenceCellStyle | undefined,
|
||||
colors: Required<SequenceStyleColors>,
|
||||
): RGBA | undefined {
|
||||
if (style === "note" || style === "noteBadge") return colors.noteBg
|
||||
if (style === "fragmentLabel") return colors.fragmentLabelBg
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { BorderStyle } from "@opentui/core"
|
||||
|
||||
export interface SequenceParticipant {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SequenceParticipantGroup {
|
||||
label: string
|
||||
participantIds: string[]
|
||||
}
|
||||
|
||||
export interface SequenceMessage {
|
||||
from: string
|
||||
to: string
|
||||
label: string
|
||||
style: "solid" | "dashed"
|
||||
head?: SequenceArrowHead
|
||||
number?: number
|
||||
activate?: string
|
||||
deactivate?: string
|
||||
}
|
||||
|
||||
export type SequenceArrowHead = "open" | "cross" | "async"
|
||||
|
||||
export interface SequenceNote {
|
||||
over: string[]
|
||||
label: string
|
||||
position?: "over" | "left" | "right"
|
||||
}
|
||||
|
||||
export interface SequenceActivation {
|
||||
participant: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface SequenceFragment {
|
||||
kind: "alt" | "else" | "loop" | "end"
|
||||
label: string
|
||||
}
|
||||
|
||||
export type SequenceStep =
|
||||
| { type: "message"; message: SequenceMessage }
|
||||
| { type: "note"; note: SequenceNote }
|
||||
| { type: "activation"; activation: SequenceActivation }
|
||||
| { type: "fragment"; fragment: SequenceFragment }
|
||||
|
||||
export interface SequenceDiagram {
|
||||
participants: SequenceParticipant[]
|
||||
messages: SequenceMessage[]
|
||||
steps: SequenceStep[]
|
||||
groups: SequenceParticipantGroup[]
|
||||
}
|
||||
|
||||
export interface SequenceDiagramRenderOptions {
|
||||
compact?: boolean
|
||||
minParticipantGap?: number
|
||||
fragmentBorderStyle?: BorderStyle
|
||||
}
|
||||
|
||||
export type MessageStyle = "request" | "response"
|
||||
export type FadeStyle = `${MessageStyle}Fade${1 | 2 | 3 | 4 | 5}`
|
||||
export type LifelineRampStyle = `lifelineRamp${1 | 2 | 3}`
|
||||
export type SequenceCellStyle =
|
||||
| "participant"
|
||||
| "lifeline"
|
||||
| "group"
|
||||
| MessageStyle
|
||||
| FadeStyle
|
||||
| LifelineRampStyle
|
||||
| "fragment"
|
||||
| "fragmentLabel"
|
||||
| "note"
|
||||
| "noteBadge"
|
||||
@@ -0,0 +1,605 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import stringWidth from "string-width"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderStateDiagram } from "./diagram.js"
|
||||
import { drawStateDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
|
||||
describe("StateDiagram", () => {
|
||||
test("detects and parses Mermaid state diagrams", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
%% request lifecycle
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: done
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expect(diagram.direction).toBe("LR")
|
||||
expect(diagram.states).toEqual([
|
||||
{ id: "__start", label: "●", kind: "start" },
|
||||
{ id: "Idle", label: "Idle", kind: "state" },
|
||||
{ id: "Loading", label: "Loading", kind: "state" },
|
||||
{ id: "Success", label: "Success", kind: "state" },
|
||||
{ id: "__end", label: "◎", kind: "end" },
|
||||
])
|
||||
expect(diagram.transitions).toEqual([
|
||||
{ from: "__start", to: "Idle", label: "" },
|
||||
{ from: "Idle", to: "Loading", label: "submit" },
|
||||
{ from: "Loading", to: "Success", label: "done" },
|
||||
{ from: "Success", to: "__end", label: "" },
|
||||
])
|
||||
})
|
||||
|
||||
test("parses quoted state aliases", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
state "Waiting<br/>for Payment" as WaitingPayment
|
||||
[*] --> WaitingPayment
|
||||
`)
|
||||
|
||||
expect(diagram.states).toContainEqual({
|
||||
id: "WaitingPayment",
|
||||
label: "Waiting<br/>for Payment",
|
||||
kind: "state",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses choice pseudo-states", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
[*] --> Decision
|
||||
state Decision <<choice>>
|
||||
Decision --> Accepted: yes
|
||||
`)
|
||||
|
||||
expect(diagram.states).toContainEqual({ id: "Decision", label: "┼", kind: "choice" })
|
||||
})
|
||||
|
||||
test("parses composite states and notes", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
}
|
||||
note right of Editing
|
||||
Draft changes
|
||||
end note
|
||||
`)
|
||||
|
||||
expect(diagram.composites).toContainEqual({ id: "Authenticated", label: "Authenticated" })
|
||||
expect(diagram.states).toContainEqual({
|
||||
id: "Idle",
|
||||
label: "Idle",
|
||||
kind: "state",
|
||||
parentId: "Authenticated",
|
||||
})
|
||||
expect(diagram.states).toContainEqual({
|
||||
id: "Authenticated.__start",
|
||||
label: "●",
|
||||
kind: "start",
|
||||
parentId: "Authenticated",
|
||||
})
|
||||
expect(diagram.notes).toEqual([{ target: "Editing", position: "right", lines: ["Draft changes"] }])
|
||||
})
|
||||
|
||||
test("renders a horizontal state diagram", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: done
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭──────╮ submit ╭─────────╮ done ╭─────────╮
|
||||
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
|
||||
╰──────╯ ╰─────────╯ ╰─────────╯
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders reverse horizontal direction from right to left", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction RL
|
||||
A --> B`)
|
||||
const labelRow = output.split("\n").find((line) => line.includes(" A ") && line.includes(" B "))!
|
||||
|
||||
expect(labelRow.indexOf("B")).toBeLessThan(labelRow.indexOf("A"))
|
||||
expect(output).toContain("◀")
|
||||
})
|
||||
|
||||
test("does not mutate a parsed diagram when rendering with a direction override", () => {
|
||||
const diagram = parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B`)
|
||||
|
||||
drawStateDiagramGrid(diagram, { direction: "RL" })
|
||||
|
||||
expect(diagram.direction).toBe("LR")
|
||||
})
|
||||
|
||||
test("places right-to-left transition labels between intact frames", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction RL
|
||||
A --> B: reopen after a very detailed reviewer comment`)
|
||||
|
||||
expect(output).toContain("╭───╮")
|
||||
expect(output.match(/╭───╮/g)?.length).toBe(2)
|
||||
expect(output).toContain("reopen after a very detailed reviewer comment")
|
||||
})
|
||||
|
||||
test("keeps Unicode state labels inside their measured frame", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state "界" as Wide`)
|
||||
const widths = output.split("\n").map((line) => stringWidth(line))
|
||||
|
||||
expect(new Set(widths).size).toBe(1)
|
||||
expect(output).toContain("界")
|
||||
})
|
||||
|
||||
test("reserves horizontal room for long transition labels", () => {
|
||||
const label = "this transition label is much wider than the route"
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: ${label}`)
|
||||
const labelRow = output.split("\n").find((line) => line.includes(label))!
|
||||
|
||||
expect(labelRow.match(/╭───╮/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
test("renders every line of multiline transition labels", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: first<br/>second`)
|
||||
|
||||
expect(output).toContain("first")
|
||||
expect(output).toContain("second")
|
||||
})
|
||||
|
||||
test("keeps reciprocal multiline transition labels clear of routes", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
[*] --> Running: create from base image
|
||||
Running --> Dormant: 📸 suspend hook fires<br/>(WE must call it on idle)
|
||||
Dormant --> Running: wake from snapshot image<br/>(apt installs restored!)
|
||||
Running --> Lost: 💥 sandbox dies BEFORE hook fires<br/>(crash, our bug, race)
|
||||
Lost --> Running: wake from LAST snapshot<br/>⚠ files since then GONE`)
|
||||
const labelLines = [
|
||||
"create from base image",
|
||||
"📸 suspend hook fires",
|
||||
"(WE must call it on idle)",
|
||||
"wake from snapshot image",
|
||||
"(apt installs restored!)",
|
||||
"💥 sandbox dies BEFORE hook fires",
|
||||
"(crash, our bug, race)",
|
||||
"wake from LAST snapshot",
|
||||
"⚠ files since then GONE",
|
||||
]
|
||||
|
||||
for (const line of labelLines) expect(output.split(line)).toHaveLength(2)
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"
|
||||
create from base image ╭─────────╮
|
||||
●───────────────────────▶│ Running │
|
||||
╰──┬──────╯ 💥 sandbox dies BEFORE hook fires
|
||||
▲ │ ▲ (crash, our bug, race)
|
||||
╭────────┼─╰───┼───────╮
|
||||
▼ ╭────┼─────╯ ▼
|
||||
╭──────┴──╮ │ ╭──────╮
|
||||
│ Dormant │ │ │ Lost │
|
||||
╰─────────╯ │ ╰───┬──╯
|
||||
│ │
|
||||
📸 suspend hook fires │ │
|
||||
(WE must call it on idle)│ │
|
||||
╰───────────────╯
|
||||
wake from snapshot image
|
||||
(apt installs restored!)
|
||||
wake from LAST snapshot
|
||||
⚠ files since then GONE"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders a vertical state diagram", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction TB
|
||||
[*] --> Cart
|
||||
Cart --> Payment: checkout
|
||||
Payment --> Complete
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ●
|
||||
│
|
||||
│
|
||||
│
|
||||
▼
|
||||
╭──────╮
|
||||
│ Cart │
|
||||
╰───┬──╯
|
||||
│
|
||||
│ checkout
|
||||
│
|
||||
▼
|
||||
╭─────────╮
|
||||
│ Payment │
|
||||
╰────┬────╯
|
||||
│
|
||||
│
|
||||
│
|
||||
▼
|
||||
╭──────────╮
|
||||
│ Complete │
|
||||
╰──────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders branched and backward transitions visibly", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: 200 OK
|
||||
Loading --> Error: timeout
|
||||
Error --> Loading: retry
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭──────╮ submit ╭─────────╮ 200 OK ╭─────────╮
|
||||
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
|
||||
╰──────╯ ╰──┬──────╯ ╰─────────╯
|
||||
│ ▲
|
||||
timeout │ │
|
||||
▼ │ retry
|
||||
╭─────┴─╮
|
||||
│ Error │
|
||||
╰───────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("captures converging labeled branches with long state names", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
[*] --> Waiting
|
||||
state "Waiting for surface and workspace" as Waiting
|
||||
state "Surface bound only" as Surface
|
||||
state "Workspace bound only" as Workspace
|
||||
state "Ready with queued input" as Ready
|
||||
state "Agent activity requested" as Active
|
||||
Waiting --> Surface: InteractionSurfaceBound
|
||||
Waiting --> Workspace: WorkspaceBound
|
||||
Surface --> Ready: WorkspaceBound
|
||||
Workspace --> Ready: InteractionSurfaceBound
|
||||
Ready --> Active: AgentActivityRequested`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭───────────────────────────────────╮ InteractionSurfaceBound ╭────────────────────╮ WorkspaceBound ╭─────────────────────────╮ AgentActivityRequested ╭──────────────────────────╮
|
||||
●────────────▶│ Waiting for surface and workspace ├────────────────────────▶│ Surface bound only ├───────────────▶│ Ready with queued input ├───────────────────────▶│ Agent activity requested │
|
||||
╰───────────────┬───────────────────╯ ╰────────────────────╯ ╰─────────────────────────╯ ╰──────────────────────────╯
|
||||
│ WorkspaceBound ▲
|
||||
╰──────────────────────────────────────────────────────╮ │
|
||||
│ InteractionSurfaceBound │
|
||||
▼ ╭───────────────────────────────────────╯
|
||||
╭─────────────┴────────╮
|
||||
│ Workspace bound only │
|
||||
╰──────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("keeps raised note connectors off outgoing transitions", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: 200 OK
|
||||
Loading --> Error: timeout
|
||||
note right of Loading : waiting for response
|
||||
Error --> Loading: retry
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╔══════════════════════╗
|
||||
╔═══╣ waiting for response ║
|
||||
║ ╚══════════════════════╝
|
||||
║
|
||||
║
|
||||
╭──────╮ submit ╭─────────╮ 200 OK ╭─────────╮
|
||||
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
|
||||
╰──────╯ ╰──┬──────╯ ╰─────────╯
|
||||
│ ▲
|
||||
timeout │ │
|
||||
▼ │ retry
|
||||
╭─────┴─╮
|
||||
│ Error │
|
||||
╰───────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("places a composite note above an occupied right side", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
[*] --> Processing
|
||||
|
||||
state Processing {
|
||||
[*] --> Validate
|
||||
Validate --> Decision
|
||||
state Decision <<choice>>
|
||||
Decision --> Accepted: valid
|
||||
Decision --> Rejected: invalid
|
||||
Accepted --> [*]
|
||||
Rejected --> [*]
|
||||
}
|
||||
|
||||
note right of Processing
|
||||
Validation and routing happen
|
||||
inside this composite state.
|
||||
end note
|
||||
|
||||
Processing --> Complete
|
||||
Complete --> [*]`)
|
||||
const lines = output.split("\n")
|
||||
const noteBottom = lines.findIndex((line) => line.includes("╚═══════════════════════════════╝"))
|
||||
const completeRow = lines.findIndex((line) => line.includes(" Complete "))
|
||||
|
||||
expect(noteBottom).toBeGreaterThanOrEqual(0)
|
||||
expect(noteBottom).toBeLessThan(completeRow)
|
||||
expect(lines.slice(noteBottom + 1, completeRow).every((line) => !line.includes("Complete"))).toBe(true)
|
||||
expect(lines).toHaveLength(20)
|
||||
})
|
||||
|
||||
test("renders configurable line arrowheads", () => {
|
||||
const output = renderStateDiagram(
|
||||
`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
`,
|
||||
{ arrowHeadStyle: "line" },
|
||||
)
|
||||
|
||||
expect(output).toContain("→")
|
||||
expect(output).not.toContain("▶")
|
||||
})
|
||||
|
||||
test("renders self transitions and choice branches", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
state Decision <<choice>>
|
||||
[*] --> Editing
|
||||
Editing --> Editing: type
|
||||
Editing --> Decision: submit
|
||||
Decision --> Saved: ok
|
||||
Decision --> Error: fail
|
||||
Error --> Editing: retry
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─────────╮ submit ok ╭───────╮
|
||||
●────────────▶│ Editing ├─────────────┬────────────▶│ Saved │
|
||||
╰──┬──────╯ │ ╰───────╯
|
||||
▲ │ ▲ type │ fail
|
||||
│ ╰────╯ │
|
||||
│ ▼
|
||||
│ ╭───────╮
|
||||
│ │ Error │
|
||||
│ ╰───┬───╯
|
||||
│ │
|
||||
│ │
|
||||
│ retry │
|
||||
╰──────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("connects lower routed branches into choice junctions", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state Decision <<choice>>
|
||||
[*] --> Fork
|
||||
Fork --> Upper
|
||||
Fork --> Lower
|
||||
Upper --> Decision
|
||||
Lower --> Decision
|
||||
Decision --> Done
|
||||
Done --> [*]`)
|
||||
|
||||
expect(output).toContain("Upper ├─────────────┬────────────▶│ Done")
|
||||
})
|
||||
|
||||
test("renders self transitions as loops in vertical diagrams", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
Working --> Working: retry`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭─────────╮
|
||||
│ Working │
|
||||
╰──┬──────╯
|
||||
│ ▲ retry
|
||||
╰────╯
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders parallel transitions without losing labels", () => {
|
||||
const horizontal = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: first
|
||||
A --> B: second`)
|
||||
const vertical = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
A --> B: first
|
||||
A --> B: second`)
|
||||
|
||||
expect(horizontal).toContain("first")
|
||||
expect(horizontal).toContain("second")
|
||||
expect(vertical).toContain("first")
|
||||
expect(vertical).toContain("second")
|
||||
})
|
||||
|
||||
test("keeps independent overlapping feedback labels and paths distinct", () => {
|
||||
const content = (direction: "LR" | "RL") => `stateDiagram-v2
|
||||
direction ${direction}
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
C --> D: finish
|
||||
C --> A: reset A
|
||||
D --> B: reset B`
|
||||
|
||||
for (const direction of ["LR", "RL"] as const) {
|
||||
const output = renderStateDiagram(content(direction))
|
||||
expect(output).toContain("reset A")
|
||||
expect(output).toContain("reset B")
|
||||
expect(output).not.toContain("res│t")
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps independent internal feedback paths inside their composite frame", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state Runtime {
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
C --> D: finish
|
||||
C --> A: reset A
|
||||
D --> B: reset B
|
||||
}`)
|
||||
const lines = output.split("\n")
|
||||
const frameTop = lines.findIndex((line) => line.includes("Runtime"))
|
||||
const upperFeedback = lines.findIndex((line) => line.includes("reset B"))
|
||||
|
||||
expect(upperFeedback).toBeGreaterThan(frameTop)
|
||||
expect(output).toContain("reset A")
|
||||
expect(output).not.toContain("res│t")
|
||||
})
|
||||
|
||||
test("places notes away from independent feedback corridors", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
C --> D: finish
|
||||
C --> A: reset A
|
||||
D --> B: reset B
|
||||
note right of B : note beside B`)
|
||||
|
||||
expect(output).toContain("note beside B")
|
||||
expect(output).toContain("reset B")
|
||||
expect(output).not.toContain("╭─║")
|
||||
expect(output).not.toContain("║──")
|
||||
})
|
||||
|
||||
test("keeps duplicate feedback labels away from an independent return path", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B
|
||||
B --> C
|
||||
C --> D
|
||||
C --> A: ca
|
||||
D --> B: db1
|
||||
D --> B: db2`)
|
||||
|
||||
expect(output).toContain("ca")
|
||||
expect(output).toContain("db1")
|
||||
expect(output).toContain("db2")
|
||||
expect(output).not.toContain("c│")
|
||||
})
|
||||
|
||||
test("renders composite state containers", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
Editing --> [*]: save
|
||||
}
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭─ Authenticated ──────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ ╭──────╮ open ╭─────────╮ save │
|
||||
│ ─────────────▶│ Idle ├────────────▶│ Editing ├────────────── │
|
||||
│ ╰──────╯ ╰─────────╯ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("routes transitions entering and leaving composite states through scoped markers", () => {
|
||||
const content = `
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Authenticated: login
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
Editing --> [*]: save
|
||||
}
|
||||
Authenticated --> [*]: logout
|
||||
`
|
||||
const diagram = parseMermaidStateDiagram(content)
|
||||
const output = renderStateDiagram(content)
|
||||
|
||||
expect(diagram.transitions).toContainEqual({
|
||||
from: "__start",
|
||||
to: "Authenticated.__start",
|
||||
label: "login",
|
||||
})
|
||||
expect(diagram.transitions).toContainEqual({
|
||||
from: "Authenticated.__end",
|
||||
to: "__end",
|
||||
label: "logout",
|
||||
})
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Authenticated ──────────────────╮
|
||||
│ │
|
||||
login │ ╭──────╮ open ╭─────────╮ │ save
|
||||
●────────────▶│ Idle ├────────────▶│ Editing ├────────────▶◎
|
||||
│ ╰──────╯ ╰─────────╯ │
|
||||
│ │
|
||||
╰──────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders notes attached to states", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
note right of Loading : waits for response
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭──────╮ submit ╭─────────╮ ╔════════════════════╗
|
||||
●────────────▶│ Idle ├────────────▶│ Loading │════╣ waits for response ║
|
||||
╰──────╯ ╰─────────╯ ╚════════════════════╝"
|
||||
`)
|
||||
})
|
||||
|
||||
test("keeps composite-to-choice elbows connected", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
[*] --> Authenticated
|
||||
state Authenticated {
|
||||
[*] --> Editing
|
||||
Editing --> Ready
|
||||
}
|
||||
note right of Editing
|
||||
Draft changes
|
||||
end note
|
||||
Authenticated --> Decision
|
||||
state Decision <<choice>>
|
||||
Decision --> [*]`)
|
||||
|
||||
expect(output).toContain("╰─────────────┬\n")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { drawStateDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { renderStateGridText } from "./render-grid.js"
|
||||
import type { StateDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export function renderStateDiagram(content: string, options: StateDiagramRenderOptions = {}): string {
|
||||
return renderStateGridText(drawStateDiagramGrid(parseMermaidStateDiagram(content), options))
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
|
||||
import {
|
||||
diagramArrowHead,
|
||||
diagramLineGlyph,
|
||||
drawDiagramFrame,
|
||||
fillDiagramFrameInterior,
|
||||
mergeDiagramLineGlyph,
|
||||
} from "../core/drawing.js"
|
||||
import {
|
||||
createStateDiagramLayout,
|
||||
expandCompositeBoundsForFeedback,
|
||||
expandCompositeBoundsForInternalTransitions,
|
||||
type StateDiagramBoxBounds as BoxBounds,
|
||||
type StateDiagramNoteBounds as StateNoteBounds,
|
||||
} from "./layout.js"
|
||||
import { DEFAULT_STATE_ARROW_HEAD_STYLE, DEFAULT_STATE_BORDER_STYLE, normalizeStateMinStateGap } from "./options.js"
|
||||
import type { StateGrid } from "./render-grid.js"
|
||||
import {
|
||||
createStateTransitionJunctionPlans,
|
||||
createStateTransitionRenderPlans,
|
||||
measureStateTransitionLabel,
|
||||
type StateTransitionRenderPlan,
|
||||
} from "./routing.js"
|
||||
import type {
|
||||
NoteConnectorRampStyle,
|
||||
StateCellStyle,
|
||||
StateDiagram,
|
||||
StateDiagramArrowHeadStyle,
|
||||
StateDiagramRenderOptions,
|
||||
StateDiagramState,
|
||||
} from "./types.js"
|
||||
import { isHiddenCompositeMarker, prepareVisibleStateDiagram } from "./visible-model.js"
|
||||
|
||||
type StateCell = DiagramCanvasCell<StateCellStyle>
|
||||
|
||||
function translateTransitionPlans(
|
||||
plans: readonly StateTransitionRenderPlan[],
|
||||
dy: number,
|
||||
): StateTransitionRenderPlan[] {
|
||||
return plans.map((plan) => ({
|
||||
...plan,
|
||||
cells: plan.cells.map((cell) => ({ ...cell, y: cell.y + dy })),
|
||||
path: plan.path.map(([x, y]) => [x, y + dy]),
|
||||
label: plan.label ? { ...plan.label, y: plan.label.y + dy } : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function makeGrid(width: number, height: number): StateGrid {
|
||||
return new DiagramCanvas(width, height, {
|
||||
mergeCell: (existing, incoming): StateCell => {
|
||||
const shouldMerge = existing.style === "transition" && incoming.style === "transition"
|
||||
return {
|
||||
...incoming,
|
||||
char: shouldMerge
|
||||
? (mergeDiagramLineGlyph(existing.char, incoming.char, "rounded") ?? incoming.char)
|
||||
: incoming.char,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function setCell(grid: StateGrid, x: number, y: number, char: string, style?: StateCellStyle): void {
|
||||
grid.setCell(x, y, char, style)
|
||||
}
|
||||
|
||||
function setText(grid: StateGrid, x: number, y: number, text: string, style?: StateCellStyle): void {
|
||||
grid.setText(x, y, text, style)
|
||||
}
|
||||
|
||||
function setTransitionLabel(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
lines: readonly string[],
|
||||
style: StateCellStyle,
|
||||
): void {
|
||||
lines.forEach((line, index) => setText(grid, x, y + index, line, style))
|
||||
}
|
||||
|
||||
function drawBox(
|
||||
grid: StateGrid,
|
||||
state: StateDiagramState,
|
||||
bounds: BoxBounds,
|
||||
lines: string[],
|
||||
borderStyle: BorderStyle,
|
||||
): void {
|
||||
if (isHiddenCompositeMarker(state)) return
|
||||
|
||||
if (state.kind !== "state") {
|
||||
setCell(grid, bounds.left, bounds.top, state.label, state.kind)
|
||||
return
|
||||
}
|
||||
const style: StateCellStyle = "state"
|
||||
fillDiagramFrameInterior(bounds, (x, y) => setCell(grid, x, y, " ", style))
|
||||
drawStateFrame(grid, bounds, BorderChars[borderStyle], style)
|
||||
lines.forEach((line, index) => {
|
||||
setText(grid, bounds.left + 2, bounds.top + 1 + index, line, style)
|
||||
})
|
||||
}
|
||||
|
||||
function drawStateFrame(grid: StateGrid, bounds: BoxBounds, chars: BorderCharacters, style: StateCellStyle): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style))
|
||||
}
|
||||
|
||||
function drawContainerFrame(
|
||||
grid: StateGrid,
|
||||
bounds: BoxBounds,
|
||||
label: string,
|
||||
chars: BorderCharacters,
|
||||
style: StateCellStyle,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style))
|
||||
if (label) setText(grid, bounds.left + 2, bounds.top, ` ${label} `, style)
|
||||
}
|
||||
|
||||
function drawHorizontalNoteConnector(grid: StateGrid, fromX: number, toX: number, y: number, char: string): void {
|
||||
const step = fromX <= toX ? 1 : -1
|
||||
for (let x = fromX; step === 1 ? x <= toX : x >= toX; x += step) {
|
||||
const distanceFromNote = Math.abs(toX - x)
|
||||
const style: StateCellStyle =
|
||||
distanceFromNote < 3 ? (`noteConnectorRamp${3 - distanceFromNote}` as NoteConnectorRampStyle) : "noteConnector"
|
||||
setCell(grid, x, y, char, style)
|
||||
}
|
||||
}
|
||||
|
||||
function drawNote(grid: StateGrid, bounds: StateNoteBounds, target: BoxBounds): void {
|
||||
const chars = BorderChars.double
|
||||
const connectorChars = BorderChars.double
|
||||
const noteX = bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width
|
||||
const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1
|
||||
const targetBottom = target.top + target.height - 1
|
||||
const noteBottom = bounds.top + bounds.height - 1
|
||||
const noteAbove = noteBottom < target.top
|
||||
const noteBelow = bounds.top > targetBottom
|
||||
let connectorY: number
|
||||
|
||||
if (noteAbove || noteBelow) {
|
||||
const targetY = noteAbove ? target.top - 1 : targetBottom + 1
|
||||
connectorY = bounds.centerY
|
||||
const verticalStep = targetY <= connectorY ? 1 : -1
|
||||
|
||||
for (let y = targetY; verticalStep === 1 ? y <= connectorY : y >= connectorY; y += verticalStep) {
|
||||
setCell(grid, targetX, y, connectorChars.vertical, "noteConnector")
|
||||
}
|
||||
|
||||
drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal)
|
||||
const connectorTurnsRight = targetX <= noteX
|
||||
const corner = noteAbove
|
||||
? connectorTurnsRight
|
||||
? connectorChars.topLeft
|
||||
: connectorChars.topRight
|
||||
: connectorTurnsRight
|
||||
? connectorChars.bottomLeft
|
||||
: connectorChars.bottomRight
|
||||
setCell(grid, targetX, connectorY, corner, "noteConnector")
|
||||
} else {
|
||||
connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2))
|
||||
drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal)
|
||||
}
|
||||
|
||||
drawContainerFrame(grid, bounds, "", chars, "noteBorder")
|
||||
setCell(
|
||||
grid,
|
||||
bounds.note.position === "right" ? bounds.left : bounds.left + bounds.width - 1,
|
||||
connectorY,
|
||||
bounds.note.position === "right" ? chars.rightT : chars.leftT,
|
||||
"noteBorder",
|
||||
)
|
||||
bounds.lines.forEach((line, index) => setText(grid, bounds.left + 2, bounds.top + 1 + index, line, "noteText"))
|
||||
}
|
||||
|
||||
function drawTransitionRenderPlan(
|
||||
grid: StateGrid,
|
||||
plan: StateTransitionRenderPlan,
|
||||
arrowHeadStyle: StateDiagramArrowHeadStyle,
|
||||
rampDeparture: boolean,
|
||||
): void {
|
||||
const departure = new Map(
|
||||
rampDeparture
|
||||
? plan.path.slice(0, 3).map(([x, y], index) => [`${x}:${y}`, `stateDepartureRamp${index + 1}` as StateCellStyle])
|
||||
: [],
|
||||
)
|
||||
for (const cell of plan.cells) {
|
||||
const char = cell.arrowDirection ? diagramArrowHead(cell.arrowDirection, arrowHeadStyle) : cell.char
|
||||
setCell(grid, cell.x, cell.y, char, departure.get(`${cell.x}:${cell.y}`) ?? "transition")
|
||||
}
|
||||
if (plan.label) {
|
||||
setTransitionLabel(grid, plan.label.x, plan.label.y, plan.label.lines, "label")
|
||||
}
|
||||
}
|
||||
|
||||
function drawTransitionJunctionPlans(
|
||||
grid: StateGrid,
|
||||
diagram: StateDiagram,
|
||||
bounds: Map<string, BoxBounds>,
|
||||
renderPlans: readonly StateTransitionRenderPlan[],
|
||||
): void {
|
||||
for (const plan of createStateTransitionJunctionPlans(diagram, bounds, renderPlans)) {
|
||||
const style = plan.kind === "choice" ? "choice" : "transition"
|
||||
setCell(grid, plan.bounds.left, plan.bounds.top, diagramLineGlyph(plan.connections, "rounded"), style)
|
||||
}
|
||||
}
|
||||
|
||||
export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: StateDiagramRenderOptions = {}): StateGrid {
|
||||
const directedDiagram = options.direction ? { ...sourceDiagram, direction: options.direction } : sourceDiagram
|
||||
const diagram = prepareVisibleStateDiagram(directedDiagram)
|
||||
const borderStyle = options.borderStyle ?? DEFAULT_STATE_BORDER_STYLE
|
||||
const arrowHeadStyle = options.arrowHeadStyle ?? DEFAULT_STATE_ARROW_HEAD_STYLE
|
||||
const minStateGap = normalizeStateMinStateGap(options.minStateGap)
|
||||
const { bounds, sizes, compositeBounds, noteBounds } = createStateDiagramLayout(diagram, {
|
||||
minStateGap,
|
||||
})
|
||||
let allBounds = [...bounds.values(), ...noteBounds]
|
||||
let maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
let feedbackLaneY = maxY + 3
|
||||
let feedbackTopY = Math.min(0, ...allBounds.map((bound) => bound.top)) - 3
|
||||
expandCompositeBoundsForFeedback(diagram, bounds, compositeBounds, feedbackLaneY)
|
||||
let transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY)
|
||||
const transitionTop = Math.min(
|
||||
0,
|
||||
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]),
|
||||
)
|
||||
if (transitionTop < 0) {
|
||||
const dy = -transitionTop
|
||||
for (const bound of new Set([...bounds.values(), ...noteBounds])) {
|
||||
bound.top += dy
|
||||
bound.centerY += dy
|
||||
}
|
||||
feedbackLaneY += dy
|
||||
feedbackTopY += dy
|
||||
transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY)
|
||||
}
|
||||
expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
|
||||
const contentTop = Math.min(
|
||||
0,
|
||||
...[...bounds.values(), ...noteBounds].map((bound) => bound.top),
|
||||
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]),
|
||||
)
|
||||
if (contentTop < 0) {
|
||||
const dy = -contentTop
|
||||
for (const bound of new Set([...bounds.values(), ...noteBounds])) {
|
||||
bound.top += dy
|
||||
bound.centerY += dy
|
||||
}
|
||||
transitionPlans = translateTransitionPlans(transitionPlans, dy)
|
||||
}
|
||||
allBounds = [...bounds.values(), ...noteBounds]
|
||||
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
|
||||
maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
const transitionLabelSizes = diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label))
|
||||
const maxTransitionLabelWidth = Math.max(0, ...transitionLabelSizes.map((size) => size.width))
|
||||
const maxTransitionLabelLines = Math.max(0, ...transitionLabelSizes.map((size) => size.height))
|
||||
const transitionRight = Math.max(
|
||||
maxX,
|
||||
...transitionPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.x + 1),
|
||||
...(plan.label ? [plan.label.x + measureStateTransitionLabel(plan.route.transition.label).width] : []),
|
||||
]),
|
||||
)
|
||||
const transitionBottom = Math.max(
|
||||
maxY,
|
||||
...transitionPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.y + 1),
|
||||
...(plan.label ? [plan.label.y + plan.label.lines.length] : []),
|
||||
]),
|
||||
)
|
||||
const grid = makeGrid(
|
||||
Math.max(maxX + Math.max(24, maxTransitionLabelWidth + 4), transitionRight + 2),
|
||||
Math.max(maxY + 8 + maxTransitionLabelLines, transitionBottom + 2),
|
||||
)
|
||||
for (const composite of diagram.composites) {
|
||||
const bound = compositeBounds.get(composite.id)
|
||||
if (!bound) continue
|
||||
drawContainerFrame(grid, bound, composite.label, BorderChars[borderStyle], "composite")
|
||||
}
|
||||
|
||||
for (const state of diagram.states) {
|
||||
const bound = bounds.get(state.id)
|
||||
const size = sizes.get(state.id)
|
||||
if (!bound || !size) continue
|
||||
drawBox(grid, state, bound, size.lines, borderStyle)
|
||||
}
|
||||
|
||||
for (const plan of transitionPlans) {
|
||||
const source = diagram.states.find((state) => state.id === plan.route.transition.from)
|
||||
drawTransitionRenderPlan(grid, plan, arrowHeadStyle, source?.kind === "state")
|
||||
}
|
||||
|
||||
drawTransitionJunctionPlans(grid, diagram, bounds, transitionPlans)
|
||||
|
||||
for (const noteBound of noteBounds) {
|
||||
const target = bounds.get(noteBound.note.target)
|
||||
if (target) drawNote(grid, noteBound, target)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function stateDiagramStartMarkerId(scope?: string): string {
|
||||
return scope ? `${scope}.__start` : "__start"
|
||||
}
|
||||
|
||||
export function stateDiagramEndMarkerId(scope?: string): string {
|
||||
return scope ? `${scope}.__end` : "__end"
|
||||
}
|
||||
|
||||
export function stateDiagramMarkerId(position: "from" | "to", scope?: string): string {
|
||||
return position === "from" ? stateDiagramStartMarkerId(scope) : stateDiagramEndMarkerId(scope)
|
||||
}
|
||||
|
||||
export function normalizeStateDiagramEndpoint(value: string, position: "from" | "to", scope?: string): string {
|
||||
return value === "[*]" ? stateDiagramMarkerId(position, scope) : value
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { StateDiagram } from "./types.js"
|
||||
import { createStateDiagramLayout } from "./layout.js"
|
||||
|
||||
describe("StateDiagramLayout", () => {
|
||||
test("lays out horizontal main-path states before branch states", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
{ id: "C", label: "C", kind: "state" },
|
||||
],
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "main" },
|
||||
{ from: "A", to: "C", label: "branch" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const a = layout.bounds.get("A")!
|
||||
const b = layout.bounds.get("B")!
|
||||
const c = layout.bounds.get("C")!
|
||||
|
||||
expect(a.left).toBeLessThan(b.left)
|
||||
expect(c.top).toBeGreaterThan(a.top)
|
||||
})
|
||||
|
||||
test("aligns a reconverging side branch under the parallel main-path stage", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: ["Fork", "Upper", "Lower", "Join"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "Fork", to: "Upper", label: "upper" },
|
||||
{ from: "Fork", to: "Lower", label: "lower" },
|
||||
{ from: "Upper", to: "Join", label: "join" },
|
||||
{ from: "Lower", to: "Join", label: "join" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const upper = layout.bounds.get("Upper")!
|
||||
const lower = layout.bounds.get("Lower")!
|
||||
|
||||
expect(lower.centerX).toBe(upper.centerX)
|
||||
expect(lower.top).toBeGreaterThan(upper.top)
|
||||
})
|
||||
|
||||
test("places note bounds outside their target state", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
],
|
||||
transitions: [{ from: "A", to: "B", label: "next" }],
|
||||
composites: [],
|
||||
notes: [{ target: "A", position: "right", lines: ["note"] }],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const target = layout.bounds.get("A")!
|
||||
const note = layout.noteBounds[0]!
|
||||
|
||||
expect(note.left).toBeGreaterThanOrEqual(target.left + target.width)
|
||||
expect(note.lines).toEqual(["note"])
|
||||
})
|
||||
|
||||
test("widens only the horizontal gap that carries a long label", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
{ id: "C", label: "C", kind: "state" },
|
||||
],
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "a transition label requiring substantially more room" },
|
||||
{ from: "B", to: "C", label: "ok" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const a = layout.bounds.get("A")!
|
||||
const b = layout.bounds.get("B")!
|
||||
const c = layout.bounds.get("C")!
|
||||
const longGap = b.left - (a.left + a.width)
|
||||
const shortGap = c.left - (b.left + b.width)
|
||||
|
||||
expect(longGap).toBeGreaterThan(shortGap)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,661 @@
|
||||
import { translateDiagramBounds } from "../core/geometry.js"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
|
||||
import {
|
||||
hasReverseTransition,
|
||||
isStateHorizontalFeedback,
|
||||
measureStateTransitionLabel,
|
||||
type StateTransitionRenderPlan,
|
||||
} from "./routing.js"
|
||||
import type {
|
||||
StateDiagram,
|
||||
StateDiagramCompositeState,
|
||||
StateDiagramNote,
|
||||
StateDiagramState,
|
||||
StateDiagramTransition,
|
||||
} from "./types.js"
|
||||
|
||||
export interface StateDiagramBoxBounds {
|
||||
id: string
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
centerX: number
|
||||
centerY: number
|
||||
}
|
||||
|
||||
export interface StateDiagramLayout {
|
||||
bounds: Map<string, StateDiagramBoxBounds>
|
||||
sizes: Map<string, { width: number; height: number; lines: string[] }>
|
||||
compositeBounds: Map<string, StateDiagramBoxBounds>
|
||||
noteBounds: StateDiagramNoteBounds[]
|
||||
}
|
||||
|
||||
export interface StateDiagramNoteBounds extends StateDiagramBoxBounds {
|
||||
note: StateDiagramNote
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface StateDiagramLayoutOptions {
|
||||
minStateGap: number
|
||||
}
|
||||
|
||||
function visualLength(value: string): number {
|
||||
return diagramTextWidth(value)
|
||||
}
|
||||
|
||||
function splitStateDiagramLines(value: string): string[] {
|
||||
return splitDiagramLines(value)
|
||||
}
|
||||
|
||||
function computeRanks(diagram: StateDiagram): Map<string, number> {
|
||||
const ranks = new Map<string, number>()
|
||||
const outgoing = new Map<string, string[]>()
|
||||
for (const transition of diagram.transitions) {
|
||||
const list = outgoing.get(transition.from) ?? []
|
||||
list.push(transition.to)
|
||||
outgoing.set(transition.from, list)
|
||||
}
|
||||
|
||||
const first = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
|
||||
if (!first) return ranks
|
||||
ranks.set(first, 0)
|
||||
const queue = [first]
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!
|
||||
const rank = ranks.get(id) ?? 0
|
||||
for (const to of outgoing.get(id) ?? []) {
|
||||
const nextRank = rank + 1
|
||||
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
|
||||
ranks.set(to, nextRank)
|
||||
queue.push(to)
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of diagram.states) {
|
||||
if (!ranks.has(state.id)) ranks.set(state.id, ranks.size)
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
|
||||
function outgoingTransitions(diagram: StateDiagram): Map<string, StateDiagramTransition[]> {
|
||||
const outgoing = new Map<string, StateDiagramTransition[]>()
|
||||
for (const transition of diagram.transitions) {
|
||||
const list = outgoing.get(transition.from) ?? []
|
||||
list.push(transition)
|
||||
outgoing.set(transition.from, list)
|
||||
}
|
||||
return outgoing
|
||||
}
|
||||
|
||||
function reaches(diagram: StateDiagram, from: string, target: string): boolean {
|
||||
const outgoing = outgoingTransitions(diagram)
|
||||
const visited = new Set<string>()
|
||||
const stack = [from]
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop()!
|
||||
if (id === target) return true
|
||||
if (visited.has(id)) continue
|
||||
visited.add(id)
|
||||
for (const transition of outgoing.get(id) ?? []) stack.push(transition.to)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function computeMainPath(diagram: StateDiagram): string[] {
|
||||
const outgoing = outgoingTransitions(diagram)
|
||||
const start = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
|
||||
if (!start) return []
|
||||
|
||||
const path = [start]
|
||||
const visited = new Set(path)
|
||||
let current = start
|
||||
while (true) {
|
||||
const candidates = (outgoing.get(current) ?? []).filter((transition) => !visited.has(transition.to))
|
||||
if (candidates.length === 0) break
|
||||
const next =
|
||||
candidates.find((transition) => diagram.states.find((state) => state.id === transition.to)?.kind === "end") ??
|
||||
candidates.find((transition) => !reaches(diagram, transition.to, current)) ??
|
||||
candidates.find((transition) => !hasReverseTransition(diagram, transition))
|
||||
if (!next) break
|
||||
path.push(next.to)
|
||||
visited.add(next.to)
|
||||
current = next.to
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
function stateSize(state: StateDiagramState): { width: number; height: number; lines: string[] } {
|
||||
if (state.kind !== "state") return { width: 1, height: 1, lines: [state.label] }
|
||||
return measureDiagramTextBox(state.label, { paddingX: 2, paddingY: 1 })
|
||||
}
|
||||
|
||||
function noteLines(note: StateDiagramNote): string[] {
|
||||
const lines = note.lines.flatMap(splitStateDiagramLines).map((line) => line.trim())
|
||||
return lines.length > 0 ? lines : [""]
|
||||
}
|
||||
|
||||
function noteSize(note: StateDiagramNote): { width: number; height: number; lines: string[] } {
|
||||
const lines = noteLines(note)
|
||||
const innerWidth = Math.max(...lines.map(diagramTextWidth), 1)
|
||||
return { width: innerWidth + 4, height: lines.length + 2, lines }
|
||||
}
|
||||
|
||||
function emptyLayout(
|
||||
bounds: Map<string, StateDiagramBoxBounds>,
|
||||
sizes: Map<string, { width: number; height: number; lines: string[] }>,
|
||||
): StateDiagramLayout {
|
||||
return { bounds, sizes, compositeBounds: new Map(), noteBounds: [] }
|
||||
}
|
||||
|
||||
function shiftBounds(bounds: Iterable<StateDiagramBoxBounds>, dx: number, dy: number): void {
|
||||
for (const bound of bounds) {
|
||||
translateDiagramBounds(bound, dx, dy)
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueBounds(...bounds: Iterable<StateDiagramBoxBounds>[]): StateDiagramBoxBounds[] {
|
||||
return [...new Set(bounds.flatMap((group) => [...group]))]
|
||||
}
|
||||
|
||||
function normalizeLayout(layout: StateDiagramLayout): void {
|
||||
const allBounds = uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds)
|
||||
if (allBounds.length === 0) return
|
||||
const minX = Math.min(0, ...allBounds.map((bound) => bound.left))
|
||||
const minY = Math.min(0, ...allBounds.map((bound) => bound.top))
|
||||
if (minX === 0 && minY === 0) return
|
||||
shiftBounds(allBounds, -minX, -minY)
|
||||
}
|
||||
|
||||
function addCompositeBounds(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
const statesByParent = new Map<string, string[]>()
|
||||
const compositesByParent = new Map<string, StateDiagramCompositeState[]>()
|
||||
for (const state of diagram.states) {
|
||||
if (!state.parentId) continue
|
||||
const states = statesByParent.get(state.parentId) ?? []
|
||||
states.push(state.id)
|
||||
statesByParent.set(state.parentId, states)
|
||||
}
|
||||
for (const composite of diagram.composites) {
|
||||
if (!composite.parentId) continue
|
||||
const composites = compositesByParent.get(composite.parentId) ?? []
|
||||
composites.push(composite)
|
||||
compositesByParent.set(composite.parentId, composites)
|
||||
}
|
||||
|
||||
const addComposite = (composite: StateDiagramCompositeState): StateDiagramBoxBounds | undefined => {
|
||||
const existing = layout.compositeBounds.get(composite.id)
|
||||
if (existing) return existing
|
||||
|
||||
for (const child of compositesByParent.get(composite.id) ?? []) addComposite(child)
|
||||
|
||||
const childBounds = [
|
||||
...(statesByParent.get(composite.id) ?? []),
|
||||
...(compositesByParent.get(composite.id) ?? []).map((child) => child.id),
|
||||
]
|
||||
.map((id) => layout.bounds.get(id))
|
||||
.filter((bound): bound is StateDiagramBoxBounds => Boolean(bound))
|
||||
if (childBounds.length === 0) return undefined
|
||||
|
||||
const left = Math.min(...childBounds.map((bound) => bound.left)) - 2
|
||||
const top = Math.min(...childBounds.map((bound) => bound.top)) - 2
|
||||
const right = Math.max(...childBounds.map((bound) => bound.left + bound.width)) + 2
|
||||
const bottom = Math.max(...childBounds.map((bound) => bound.top + bound.height)) + 2
|
||||
const width = Math.max(right - left, visualLength(composite.label) + 5)
|
||||
const bound = {
|
||||
id: composite.id,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height: bottom - top,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor((bottom - top) / 2),
|
||||
}
|
||||
layout.compositeBounds.set(composite.id, bound)
|
||||
layout.bounds.set(composite.id, bound)
|
||||
return bound
|
||||
}
|
||||
|
||||
for (const composite of diagram.composites) addComposite(composite)
|
||||
}
|
||||
|
||||
function addNoteBounds(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
const compositeIds = new Set(diagram.composites.map((composite) => composite.id))
|
||||
const avoidBounds = [...layout.bounds.values()].filter((bound) => !compositeIds.has(bound.id))
|
||||
const noteBounds: StateDiagramNoteBounds[] = []
|
||||
|
||||
for (const [index, note] of diagram.notes.entries()) {
|
||||
const target = layout.bounds.get(note.target)
|
||||
if (!target) continue
|
||||
const size = noteSize(note)
|
||||
noteBounds.push(placeNote(note, index, target, size, avoidBounds, noteBounds))
|
||||
}
|
||||
|
||||
layout.noteBounds = noteBounds
|
||||
}
|
||||
|
||||
function intersects(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
bound: StateDiagramBoxBounds,
|
||||
padding = 1,
|
||||
): boolean {
|
||||
return (
|
||||
left < bound.left + bound.width + padding &&
|
||||
left + width + padding > bound.left &&
|
||||
top < bound.top + bound.height + padding &&
|
||||
top + height + padding > bound.top
|
||||
)
|
||||
}
|
||||
|
||||
function createNoteBound(
|
||||
note: StateDiagramNote,
|
||||
index: number,
|
||||
left: number,
|
||||
top: number,
|
||||
size: { width: number; height: number; lines: string[] },
|
||||
): StateDiagramNoteBounds {
|
||||
return {
|
||||
id: `${note.target}-note-${index}`,
|
||||
left,
|
||||
top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
note,
|
||||
lines: size.lines,
|
||||
}
|
||||
}
|
||||
|
||||
function placeNote(
|
||||
note: StateDiagramNote,
|
||||
index: number,
|
||||
target: StateDiagramBoxBounds,
|
||||
size: { width: number; height: number; lines: string[] },
|
||||
avoidBounds: readonly StateDiagramBoxBounds[],
|
||||
existingNotes: readonly StateDiagramNoteBounds[],
|
||||
): StateDiagramNoteBounds {
|
||||
const gap = 4
|
||||
const baseLeft = note.position === "right" ? target.left + target.width + gap : target.left - size.width - gap
|
||||
const baseTop = target.centerY - Math.floor(size.height / 2)
|
||||
const candidateTops = [
|
||||
baseTop,
|
||||
target.top - size.height - 2,
|
||||
target.top + target.height + 2,
|
||||
baseTop - size.height - 2,
|
||||
baseTop + target.height + 2,
|
||||
]
|
||||
const collides = (left: number, top: number) => {
|
||||
for (const bound of avoidBounds) {
|
||||
if (bound.id !== target.id && intersects(left, top, size.width, size.height, bound)) return true
|
||||
}
|
||||
for (const bound of existingNotes) {
|
||||
if (intersects(left, top, size.width, size.height, bound)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for (const top of candidateTops) {
|
||||
if (!collides(baseLeft, top)) return createNoteBound(note, index, baseLeft, top, size)
|
||||
}
|
||||
|
||||
const shiftedLeft =
|
||||
note.position === "right"
|
||||
? Math.max(...avoidBounds.map((bound) => bound.left + bound.width), target.left + target.width) + gap
|
||||
: Math.min(...avoidBounds.map((bound) => bound.left), target.left) - size.width - gap
|
||||
return createNoteBound(note, index, shiftedLeft, baseTop + target.height + 1, size)
|
||||
}
|
||||
|
||||
function belongsToComposite(
|
||||
id: string,
|
||||
compositeId: string,
|
||||
statesById: Map<string, StateDiagramState>,
|
||||
compositesById: Map<string, StateDiagramCompositeState>,
|
||||
): boolean {
|
||||
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
|
||||
|
||||
while (parentId) {
|
||||
if (parentId === compositeId) return true
|
||||
parentId = compositesById.get(parentId)?.parentId
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
|
||||
for (const composite of [...diagram.composites].reverse()) {
|
||||
const bound = layout.compositeBounds.get(composite.id)
|
||||
if (!bound) continue
|
||||
|
||||
const descendantNotes = layout.noteBounds.filter((noteBound) =>
|
||||
belongsToComposite(noteBound.note.target, composite.id, statesById, compositesById),
|
||||
)
|
||||
if (descendantNotes.length === 0) continue
|
||||
|
||||
const childBounds = [bound, ...descendantNotes]
|
||||
const noteTop = Math.min(...childBounds.map((child) => child.top), bound.top)
|
||||
const noteBottom = Math.max(...childBounds.map((child) => child.top + child.height), bound.top + bound.height)
|
||||
const left = Math.min(...childBounds.map((child) => child.left)) - 2
|
||||
const top = noteTop < bound.top ? noteTop - 1 : bound.top
|
||||
const right = Math.max(...childBounds.map((child) => child.left + child.width)) + 2
|
||||
const bottom = noteBottom > bound.top + bound.height ? noteBottom + 1 : bound.top + bound.height
|
||||
|
||||
bound.left = left
|
||||
bound.top = top
|
||||
bound.width = Math.max(right - left, visualLength(composite.label) + 5)
|
||||
bound.height = bottom - top
|
||||
bound.centerX = bound.left + Math.floor(bound.width / 2)
|
||||
bound.centerY = bound.top + Math.floor(bound.height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
function boundsIntersect(left: StateDiagramBoxBounds, right: StateDiagramBoxBounds): boolean {
|
||||
return intersects(left.left, left.top, left.width, left.height, right, 0)
|
||||
}
|
||||
|
||||
function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
|
||||
for (const composite of diagram.composites) {
|
||||
const compositeBound = layout.compositeBounds.get(composite.id)
|
||||
if (!compositeBound) continue
|
||||
|
||||
for (const state of diagram.states) {
|
||||
if (belongsToComposite(state.id, composite.id, statesById, compositesById)) continue
|
||||
const bound = layout.bounds.get(state.id)
|
||||
if (!bound || !boundsIntersect(bound, compositeBound)) continue
|
||||
|
||||
const dx = compositeBound.left + compositeBound.width + 4 - bound.left
|
||||
if (dx <= 0) continue
|
||||
const leftThreshold = bound.left
|
||||
const boundsToShift: StateDiagramBoxBounds[] = []
|
||||
|
||||
for (const candidate of diagram.states) {
|
||||
if (belongsToComposite(candidate.id, composite.id, statesById, compositesById)) continue
|
||||
const candidateBound = layout.bounds.get(candidate.id)
|
||||
if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
|
||||
}
|
||||
|
||||
for (const candidate of diagram.composites) {
|
||||
if (candidate.id === composite.id || belongsToComposite(candidate.id, composite.id, statesById, compositesById))
|
||||
continue
|
||||
const candidateBound = layout.compositeBounds.get(candidate.id)
|
||||
if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
|
||||
}
|
||||
|
||||
shiftBounds(uniqueBounds(boundsToShift), dx, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeLayout(diagram: StateDiagram, layout: StateDiagramLayout): StateDiagramLayout {
|
||||
if (diagram.composites.length === 0 && diagram.notes.length === 0) return layout
|
||||
addCompositeBounds(diagram, layout)
|
||||
normalizeLayout(layout)
|
||||
addNoteBounds(diagram, layout)
|
||||
expandCompositeBoundsForNotes(diagram, layout)
|
||||
separateExternalBoundsFromComposites(diagram, layout)
|
||||
normalizeLayout(layout)
|
||||
return layout
|
||||
}
|
||||
|
||||
export function createStateDiagramLayout(
|
||||
diagram: StateDiagram,
|
||||
options: StateDiagramLayoutOptions,
|
||||
): StateDiagramLayout {
|
||||
if (diagram.direction === "LR" || diagram.direction === "RL") {
|
||||
return finalizeLayout(diagram, createHorizontalLayout(diagram, options))
|
||||
}
|
||||
|
||||
const ranks = computeRanks(diagram)
|
||||
const byRank = new Map<number, StateDiagramState[]>()
|
||||
for (const state of diagram.states) {
|
||||
const rank = ranks.get(state.id) ?? 0
|
||||
const list = byRank.get(rank) ?? []
|
||||
list.push(state)
|
||||
byRank.set(rank, list)
|
||||
}
|
||||
|
||||
const rankKeys = [...byRank.keys()].sort((a, b) => a - b)
|
||||
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
|
||||
const bounds = new Map<string, StateDiagramBoxBounds>()
|
||||
const outgoingLabelRows = new Map<string, number>()
|
||||
for (const transition of diagram.transitions) {
|
||||
const rows = measureStateTransitionLabel(transition.label).height
|
||||
outgoingLabelRows.set(transition.from, Math.max(outgoingLabelRows.get(transition.from) ?? 0, rows))
|
||||
}
|
||||
|
||||
const singleColumnCenter = Math.max(
|
||||
0,
|
||||
...rankKeys.flatMap((rank) => {
|
||||
const states = byRank.get(rank)!
|
||||
return states.length === 1 ? [Math.floor(sizes.get(states[0]!.id)!.width / 2)] : []
|
||||
}),
|
||||
)
|
||||
let y = 0
|
||||
for (const rank of rankKeys) {
|
||||
const states = byRank.get(rank)!
|
||||
const rowHeight = Math.max(...states.map((state) => sizes.get(state.id)!.height))
|
||||
let x = 0
|
||||
for (const state of states) {
|
||||
const size = sizes.get(state.id)!
|
||||
const top = y + Math.floor((rowHeight - size.height) / 2)
|
||||
const left = states.length === 1 ? singleColumnCenter - Math.floor(size.width / 2) : x
|
||||
bounds.set(state.id, {
|
||||
id: state.id,
|
||||
left,
|
||||
top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
x += size.width + options.minStateGap + 8
|
||||
}
|
||||
const labelRows = states.reduce((rows, state) => Math.max(rows, outgoingLabelRows.get(state.id) ?? 0), 0)
|
||||
y += rowHeight + Math.max(4, labelRows + 3)
|
||||
}
|
||||
|
||||
return finalizeLayout(diagram, emptyLayout(bounds, sizes))
|
||||
}
|
||||
|
||||
function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayoutOptions): StateDiagramLayout {
|
||||
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
|
||||
const bounds = new Map<string, StateDiagramBoxBounds>()
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const mainPath = computeMainPath(diagram)
|
||||
const mainIds = new Set(mainPath)
|
||||
const mainPathIndex = new Map(mainPath.map((id, index) => [id, index]))
|
||||
const baselineY = Math.max(
|
||||
1,
|
||||
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height),
|
||||
)
|
||||
const defaultGap = options.minStateGap + 8
|
||||
let x = 0
|
||||
|
||||
for (const [index, id] of mainPath.entries()) {
|
||||
const state = statesById.get(id)
|
||||
const size = sizes.get(id)
|
||||
if (!state || !size) continue
|
||||
const top = state.kind === "state" ? baselineY - Math.floor(size.height / 2) : baselineY
|
||||
bounds.set(id, {
|
||||
id,
|
||||
left: x,
|
||||
top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
centerX: x + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
const nextId = mainPath[index + 1]
|
||||
const adjacentLabelWidth = diagram.transitions
|
||||
.filter((transition) => transition.from === id && transition.to === nextId)
|
||||
.reduce((width, transition) => Math.max(width, measureStateTransitionLabel(transition.label).width), 0)
|
||||
x += size.width + Math.max(defaultGap, adjacentLabelWidth + 2)
|
||||
}
|
||||
|
||||
const branchesByParent = new Map<string, string[]>()
|
||||
for (const transition of diagram.transitions) {
|
||||
if (!mainIds.has(transition.from) || mainIds.has(transition.to)) continue
|
||||
const list = branchesByParent.get(transition.from) ?? []
|
||||
if (!list.includes(transition.to)) list.push(transition.to)
|
||||
branchesByParent.set(transition.from, list)
|
||||
}
|
||||
|
||||
for (const [parentId, branchIds] of branchesByParent) {
|
||||
const parent = bounds.get(parentId)
|
||||
if (!parent) continue
|
||||
const branchGap = defaultGap
|
||||
const branchSizes = branchIds.map((id) => sizes.get(id)!).filter(Boolean)
|
||||
const totalWidth =
|
||||
branchSizes.reduce((sum, size) => sum + size.width, 0) + Math.max(0, branchSizes.length - 1) * branchGap
|
||||
const parentIndex = mainPathIndex.get(parentId)
|
||||
const joinIds = branchIds.map(
|
||||
(id) =>
|
||||
diagram.transitions.find(
|
||||
(transition) =>
|
||||
transition.from === id &&
|
||||
mainIds.has(transition.to) &&
|
||||
parentIndex !== undefined &&
|
||||
(mainPathIndex.get(transition.to) ?? -1) > parentIndex + 1,
|
||||
)?.to,
|
||||
)
|
||||
const commonJoin = joinIds[0] && joinIds.every((id) => id === joinIds[0]) ? joinIds[0] : undefined
|
||||
const parallelLane = commonJoin && parentIndex !== undefined ? bounds.get(mainPath[parentIndex + 1]!) : undefined
|
||||
let left = (parallelLane?.centerX ?? parent.centerX) - Math.floor(totalWidth / 2)
|
||||
for (const branchId of branchIds) {
|
||||
if (bounds.has(branchId)) continue
|
||||
const size = sizes.get(branchId)
|
||||
if (!size) continue
|
||||
const top = baselineY + (parallelLane ? 6 : 5)
|
||||
bounds.set(branchId, {
|
||||
id: branchId,
|
||||
left,
|
||||
top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
left += size.width + branchGap
|
||||
}
|
||||
}
|
||||
|
||||
const ranks = computeRanks(diagram)
|
||||
const fallbackStates = diagram.states.filter((state) => !bounds.has(state.id))
|
||||
for (const state of fallbackStates) {
|
||||
const size = sizes.get(state.id)!
|
||||
const rank = ranks.get(state.id) ?? bounds.size
|
||||
const top = baselineY + 5
|
||||
const left = rank * (size.width + defaultGap)
|
||||
bounds.set(state.id, {
|
||||
id: state.id,
|
||||
left,
|
||||
top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
}
|
||||
|
||||
const minX = Math.min(0, ...[...bounds.values()].map((bound) => bound.left))
|
||||
if (minX < 0) {
|
||||
for (const bound of bounds.values()) {
|
||||
bound.left -= minX
|
||||
bound.centerX -= minX
|
||||
}
|
||||
}
|
||||
|
||||
if (diagram.direction === "RL") {
|
||||
const right = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width))
|
||||
for (const bound of bounds.values()) {
|
||||
bound.left = right - bound.left - bound.width
|
||||
bound.centerX = bound.left + Math.floor(bound.width / 2)
|
||||
}
|
||||
|
||||
const branchLabelGutter = Math.max(
|
||||
0,
|
||||
...diagram.transitions.flatMap((transition) => {
|
||||
const from = bounds.get(transition.from)
|
||||
const to = bounds.get(transition.to)
|
||||
return from && to && from.centerY !== to.centerY
|
||||
? [measureStateTransitionLabel(transition.label).width + 2]
|
||||
: []
|
||||
}),
|
||||
)
|
||||
if (branchLabelGutter > 0) {
|
||||
shiftBounds(bounds.values(), branchLabelGutter, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyLayout(bounds, sizes)
|
||||
}
|
||||
|
||||
export function expandCompositeBoundsForFeedback(
|
||||
diagram: StateDiagram,
|
||||
bounds: Map<string, StateDiagramBoxBounds>,
|
||||
compositeBounds: Map<string, StateDiagramBoxBounds>,
|
||||
feedbackLaneY: number,
|
||||
): void {
|
||||
if (diagram.direction !== "LR" && diagram.direction !== "RL") return
|
||||
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
|
||||
for (const composite of diagram.composites) {
|
||||
const compositeBound = compositeBounds.get(composite.id)
|
||||
if (!compositeBound) continue
|
||||
|
||||
const hasInternalFeedback = diagram.transitions.some((transition) => {
|
||||
if (!belongsToComposite(transition.from, composite.id, statesById, compositesById)) return false
|
||||
if (!belongsToComposite(transition.to, composite.id, statesById, compositesById)) return false
|
||||
const from = bounds.get(transition.from)
|
||||
const to = bounds.get(transition.to)
|
||||
return Boolean(from && to && isStateHorizontalFeedback(diagram, from, to))
|
||||
})
|
||||
if (!hasInternalFeedback) continue
|
||||
|
||||
const bottom = Math.max(compositeBound.top + compositeBound.height, feedbackLaneY + 2)
|
||||
compositeBound.height = bottom - compositeBound.top
|
||||
compositeBound.centerY = compositeBound.top + Math.floor(compositeBound.height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
export function expandCompositeBoundsForInternalTransitions(
|
||||
diagram: StateDiagram,
|
||||
compositeBounds: Map<string, StateDiagramBoxBounds>,
|
||||
transitionPlans: readonly StateTransitionRenderPlan[],
|
||||
): void {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
|
||||
for (const composite of diagram.composites) {
|
||||
const bound = compositeBounds.get(composite.id)
|
||||
if (!bound) continue
|
||||
const internalPlans = transitionPlans.filter(
|
||||
(plan) =>
|
||||
belongsToComposite(plan.route.transition.from, composite.id, statesById, compositesById) &&
|
||||
belongsToComposite(plan.route.transition.to, composite.id, statesById, compositesById),
|
||||
)
|
||||
const occupiedYs = internalPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.y),
|
||||
...(plan.label ? plan.label.lines.map((_, index) => plan.label!.y + index) : []),
|
||||
])
|
||||
if (occupiedYs.length === 0) continue
|
||||
|
||||
const top = Math.min(bound.top, Math.min(...occupiedYs) - 1)
|
||||
const bottom = Math.max(bound.top + bound.height, Math.max(...occupiedYs) + 2)
|
||||
bound.top = top
|
||||
bound.height = bottom - top
|
||||
bound.centerY = bound.top + Math.floor(bound.height / 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { BorderStyle } from "@opentui/core"
|
||||
import type { StateDiagramArrowHeadStyle } from "./types.js"
|
||||
|
||||
export const DEFAULT_STATE_DIAGRAM_MIN_STATE_GAP = 5
|
||||
export const DEFAULT_STATE_BORDER_STYLE = "rounded" satisfies BorderStyle
|
||||
export const DEFAULT_STATE_ARROW_HEAD_STYLE = "filled" satisfies StateDiagramArrowHeadStyle
|
||||
|
||||
export function normalizeStateMinStateGap(value: number | undefined): number {
|
||||
if (value === undefined || !Number.isFinite(value)) return DEFAULT_STATE_DIAGRAM_MIN_STATE_GAP
|
||||
return Math.max(1, Math.trunc(value))
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { firstMeaningfulMermaidLine, numberedMermaidLines } from "../core/mermaid.js"
|
||||
import { splitDiagramLines } from "../core/text-lines.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import { normalizeStateDiagramEndpoint, stateDiagramEndMarkerId, stateDiagramStartMarkerId } from "./endpoint.js"
|
||||
import type {
|
||||
StateDiagram,
|
||||
StateDiagramCompositeState,
|
||||
StateDiagramDirection,
|
||||
StateDiagramNote,
|
||||
StateDiagramState,
|
||||
StateDiagramTransition,
|
||||
} from "./types.js"
|
||||
|
||||
const DEFAULT_DIRECTION = "LR" satisfies StateDiagramDirection
|
||||
const STATE_RE = /^state\s+"([^"]+)"\s+as\s+(\S+)$/i
|
||||
const COMPOSITE_STATE_RE = /^state\s+(?:"([^"]+)"\s+as\s+)?(\S+)\s*\{$/i
|
||||
const CHOICE_STATE_RE = /^state\s+(\S+)\s+<<choice>>$/i
|
||||
const TRANSITION_RE = /^(\[\*\]|[^\s:]+)\s*-->\s*(\[\*\]|[^\s:]+)(?:\s*:\s*(.*))?$/
|
||||
const DIRECTION_RE = /^direction\s+(TB|TD|LR|RL)$/i
|
||||
const NOTE_INLINE_RE = /^note\s+(left|right)\s+of\s+(\S+)\s*:\s*(.*)$/i
|
||||
const NOTE_START_RE = /^note\s+(left|right)\s+of\s+(\S+)\s*$/i
|
||||
const NOTE_END_RE = /^end\s+note$/i
|
||||
|
||||
function normalizeDirection(value?: string): StateDiagramDirection {
|
||||
const upper = value?.toUpperCase()
|
||||
if (upper === "TB" || upper === "TD" || upper === "LR" || upper === "RL") return upper
|
||||
return DEFAULT_DIRECTION
|
||||
}
|
||||
|
||||
function isMermaidHeader(line: string): boolean {
|
||||
return line.toLowerCase() === "statediagram-v2" || line.toLowerCase() === "statediagram"
|
||||
}
|
||||
|
||||
function ensureState(
|
||||
states: Map<string, StateDiagramState>,
|
||||
id: string,
|
||||
label = id,
|
||||
kind: StateDiagramState["kind"] = "state",
|
||||
parentId?: string,
|
||||
): void {
|
||||
const existing = states.get(id)
|
||||
if (existing) {
|
||||
if (existing.label === existing.id && label !== id) existing.label = label
|
||||
if (parentId && !existing.parentId) existing.parentId = parentId
|
||||
if (kind !== "state") {
|
||||
existing.kind = kind
|
||||
existing.label = label
|
||||
}
|
||||
return
|
||||
}
|
||||
states.set(id, parentId ? { id, label, kind, parentId } : { id, label, kind })
|
||||
}
|
||||
|
||||
function resolveCompositeTransitionEndpoint(
|
||||
id: string,
|
||||
markerId: (scope?: string) => string,
|
||||
compositeIds: ReadonlySet<string>,
|
||||
states: Map<string, StateDiagramState>,
|
||||
): string {
|
||||
if (!compositeIds.has(id)) return id
|
||||
const marker = markerId(id)
|
||||
return states.has(marker) ? marker : id
|
||||
}
|
||||
|
||||
function resolveCompositeTransitions(
|
||||
transitions: readonly StateDiagramTransition[],
|
||||
compositeIds: ReadonlySet<string>,
|
||||
states: Map<string, StateDiagramState>,
|
||||
): StateDiagramTransition[] {
|
||||
return transitions.map((transition) => ({
|
||||
from: resolveCompositeTransitionEndpoint(transition.from, stateDiagramEndMarkerId, compositeIds, states),
|
||||
to: resolveCompositeTransitionEndpoint(transition.to, stateDiagramStartMarkerId, compositeIds, states),
|
||||
label: transition.label,
|
||||
}))
|
||||
}
|
||||
|
||||
export function isMermaidStateDiagram(content: string): boolean {
|
||||
return isMermaidHeader(firstMeaningfulMermaidLine(content) ?? "")
|
||||
}
|
||||
|
||||
export function parseMermaidStateDiagram(content: string): StateDiagram {
|
||||
const states = new Map<string, StateDiagramState>()
|
||||
const transitions: StateDiagramTransition[] = []
|
||||
const composites: StateDiagramCompositeState[] = []
|
||||
const notes: StateDiagramNote[] = []
|
||||
const parentStack: Array<{ id: string; lineNumber: number; sourceLine: string }> = []
|
||||
let pendingNote:
|
||||
| { target: string; position: "left" | "right"; lines: string[]; lineNumber: number; sourceLine: string }
|
||||
| undefined
|
||||
let direction: StateDiagramDirection = DEFAULT_DIRECTION
|
||||
|
||||
for (const source of numberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
if (pendingNote) {
|
||||
if (NOTE_END_RE.test(line)) {
|
||||
notes.push({
|
||||
target: pendingNote.target,
|
||||
position: pendingNote.position,
|
||||
lines: pendingNote.lines,
|
||||
})
|
||||
pendingNote = undefined
|
||||
} else if (line || pendingNote.lines.length > 0) {
|
||||
pendingNote.lines.push(line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line || line.startsWith("%%") || isMermaidHeader(line)) continue
|
||||
|
||||
if (line === "}") {
|
||||
if (parentStack.length === 0) {
|
||||
throw new MermaidSyntaxError("state", source.lineNumber, line, 'Unexpected "}" without an open composite state')
|
||||
}
|
||||
parentStack.pop()
|
||||
continue
|
||||
}
|
||||
|
||||
const parentId = parentStack[parentStack.length - 1]?.id
|
||||
|
||||
const directionMatch = line.match(DIRECTION_RE)
|
||||
if (directionMatch) {
|
||||
direction = normalizeDirection(directionMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const inlineNoteMatch = line.match(NOTE_INLINE_RE)
|
||||
if (inlineNoteMatch) {
|
||||
notes.push({
|
||||
position: inlineNoteMatch[1]!.toLowerCase() as "left" | "right",
|
||||
target: inlineNoteMatch[2]!,
|
||||
lines: splitDiagramLines(inlineNoteMatch[3]!.trim()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const noteMatch = line.match(NOTE_START_RE)
|
||||
if (noteMatch) {
|
||||
pendingNote = {
|
||||
position: noteMatch[1]!.toLowerCase() as "left" | "right",
|
||||
target: noteMatch[2]!,
|
||||
lines: [],
|
||||
lineNumber: source.lineNumber,
|
||||
sourceLine: line,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const compositeMatch = line.match(COMPOSITE_STATE_RE)
|
||||
if (compositeMatch) {
|
||||
const id = compositeMatch[2]!
|
||||
composites.push({
|
||||
id,
|
||||
label: compositeMatch[1] ?? id,
|
||||
...(parentId ? { parentId } : {}),
|
||||
})
|
||||
parentStack.push({ id, lineNumber: source.lineNumber, sourceLine: line })
|
||||
continue
|
||||
}
|
||||
|
||||
const stateMatch = line.match(STATE_RE)
|
||||
if (stateMatch) {
|
||||
ensureState(states, stateMatch[2]!, stateMatch[1]!, "state", parentId)
|
||||
continue
|
||||
}
|
||||
|
||||
const choiceMatch = line.match(CHOICE_STATE_RE)
|
||||
if (choiceMatch) {
|
||||
ensureState(states, choiceMatch[1]!, "┼", "choice", parentId)
|
||||
continue
|
||||
}
|
||||
|
||||
const transitionMatch = line.match(TRANSITION_RE)
|
||||
if (transitionMatch) {
|
||||
const rawFrom = transitionMatch[1]!
|
||||
const rawTo = transitionMatch[2]!
|
||||
const from = normalizeStateDiagramEndpoint(rawFrom, "from", parentId)
|
||||
const to = normalizeStateDiagramEndpoint(rawTo, "to", parentId)
|
||||
ensureState(states, from, rawFrom === "[*]" ? "●" : from, rawFrom === "[*]" ? "start" : "state", parentId)
|
||||
ensureState(states, to, rawTo === "[*]" ? "◎" : to, rawTo === "[*]" ? "end" : "state", parentId)
|
||||
transitions.push({ from, to, label: transitionMatch[3]?.trim() ?? "" })
|
||||
continue
|
||||
}
|
||||
|
||||
throw new MermaidSyntaxError("state", source.lineNumber, line)
|
||||
}
|
||||
|
||||
if (pendingNote) {
|
||||
throw new MermaidSyntaxError(
|
||||
"state",
|
||||
pendingNote.lineNumber,
|
||||
pendingNote.sourceLine,
|
||||
'Unclosed note; expected "end note"',
|
||||
)
|
||||
}
|
||||
|
||||
const unclosedComposite = parentStack[parentStack.length - 1]
|
||||
if (unclosedComposite) {
|
||||
throw new MermaidSyntaxError(
|
||||
"state",
|
||||
unclosedComposite.lineNumber,
|
||||
unclosedComposite.sourceLine,
|
||||
'Unclosed composite state; expected "}"',
|
||||
)
|
||||
}
|
||||
|
||||
if (composites.length === 0) {
|
||||
return { direction, states: [...states.values()], transitions, composites, notes }
|
||||
}
|
||||
|
||||
const compositeIds = new Set(composites.map((composite) => composite.id))
|
||||
return {
|
||||
direction,
|
||||
states: [...states.values()].filter((state) => !compositeIds.has(state.id)),
|
||||
transitions: resolveCompositeTransitions(transitions, compositeIds, states),
|
||||
composites,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas } from "../core/canvas.js"
|
||||
import { renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import type { StateStyleColors } from "./style.js"
|
||||
import type { StateCellStyle } from "./types.js"
|
||||
|
||||
export type StateGrid = DiagramCanvas<StateCellStyle>
|
||||
|
||||
export function renderStateGridText(grid: StateGrid): string {
|
||||
return grid.toString({ trimBottom: true })
|
||||
}
|
||||
|
||||
export function renderStateGridStyledText(grid: StateGrid, colors: StateStyleColors): StyledText {
|
||||
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { StateDiagramBoxBounds } from "./layout.js"
|
||||
import {
|
||||
createStateTransitionJunctionPlans,
|
||||
createStateTransitionRenderPlans,
|
||||
createStateTransitionRoutePlans,
|
||||
} from "./routing.js"
|
||||
import { prepareVisibleStateDiagram, type StateVisibleDiagram } from "./visible-model.js"
|
||||
|
||||
function bounds(id: string, centerX: number, centerY: number): StateDiagramBoxBounds {
|
||||
return { id, left: centerX - 2, top: centerY - 1, width: 5, height: 3, centerX, centerY }
|
||||
}
|
||||
|
||||
describe("createStateTransitionRoutePlans", () => {
|
||||
test("classifies horizontal transition behavior before painting", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B", "C"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "forward" },
|
||||
{ from: "B", to: "C", label: "branch" },
|
||||
{ from: "C", to: "A", label: "reset" },
|
||||
{ from: "B", to: "B", label: "retry" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 14, 4)],
|
||||
["C", bounds("C", 24, 10)],
|
||||
])
|
||||
|
||||
const plans = createStateTransitionRoutePlans(diagram, placements, 18)
|
||||
|
||||
expect(plans.map((plan) => [plan.transition.label, plan.kind])).toEqual([
|
||||
["forward", "horizontal-forward"],
|
||||
["branch", "vertical-elbow"],
|
||||
["reset", "bottom-feedback"],
|
||||
["retry", "self"],
|
||||
])
|
||||
expect(plans.find((plan) => plan.kind === "bottom-feedback")).toMatchObject({ railY: 18 })
|
||||
})
|
||||
|
||||
test("classifies vertical self-transitions as loops before directional routing", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "TB",
|
||||
states: [{ id: "Working", label: "Working", kind: "state" }],
|
||||
transitions: [{ from: "Working", to: "Working", label: "retry" }],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
expect(
|
||||
createStateTransitionRoutePlans(diagram, new Map([["Working", bounds("Working", 5, 3)]]), 12)[0],
|
||||
).toMatchObject({
|
||||
kind: "self",
|
||||
})
|
||||
})
|
||||
|
||||
test("allocates separate lanes for parallel transitions", () => {
|
||||
const horizontal: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const vertical = { ...horizontal, direction: "TB" as const }
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 18, 4)],
|
||||
])
|
||||
const verticalPlacements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 4, 14)],
|
||||
])
|
||||
|
||||
expect(createStateTransitionRoutePlans(horizontal, placements, 12).map((plan) => plan.kind)).toEqual([
|
||||
"horizontal-forward",
|
||||
"bottom-parallel",
|
||||
])
|
||||
expect(createStateTransitionRoutePlans(vertical, verticalPlacements, 22).map((plan) => plan.kind)).toEqual([
|
||||
"vertical",
|
||||
"side-parallel",
|
||||
])
|
||||
})
|
||||
|
||||
test("routes interleaving independent feedback transitions on opposite sides", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B", "C", "D"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "advance" },
|
||||
{ from: "B", to: "C", label: "continue" },
|
||||
{ from: "C", to: "D", label: "finish" },
|
||||
{ from: "C", to: "A", label: "reset A" },
|
||||
{ from: "D", to: "B", label: "reset B" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 14, 4)],
|
||||
["C", bounds("C", 24, 4)],
|
||||
["D", bounds("D", 34, 4)],
|
||||
])
|
||||
const plans = createStateTransitionRenderPlans(diagram, placements, 12).filter((plan) =>
|
||||
plan.route.transition.label.startsWith("reset"),
|
||||
)
|
||||
|
||||
expect(plans.map((plan) => plan.route.kind)).toEqual(["bottom-feedback", "top-feedback"])
|
||||
const firstCells = new Set(plans[0]!.path.map(([x, y]) => `${x}:${y}`))
|
||||
expect(plans[1]!.path.some(([x, y]) => firstCells.has(`${x}:${y}`))).toBe(false)
|
||||
})
|
||||
|
||||
test("routes nested same-side feedback transitions from inner to outer rails", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B", "C", "D"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "D", to: "A", label: "outer" },
|
||||
{ from: "C", to: "B", label: "inner" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 14, 4)],
|
||||
["C", bounds("C", 24, 4)],
|
||||
["D", bounds("D", 34, 4)],
|
||||
])
|
||||
const plans = createStateTransitionRenderPlans(diagram, placements, 12)
|
||||
const outer = plans.find((plan) => plan.route.transition.label === "outer")!
|
||||
const inner = plans.find((plan) => plan.route.transition.label === "inner")!
|
||||
const outerCells = new Set(outer.path.map(([x, y]) => `${x}:${y}`))
|
||||
|
||||
expect(outer.route).toMatchObject({ kind: "bottom-feedback", railY: 15 })
|
||||
expect(inner.route).toMatchObject({ kind: "bottom-feedback", railY: 12 })
|
||||
expect(inner.path.some(([x, y]) => outerCells.has(`${x}:${y}`))).toBe(false)
|
||||
})
|
||||
|
||||
test("allocates duplicate feedback transitions without crossing an independent feedback path", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B", "C", "D"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "C", to: "A", label: "ca" },
|
||||
{ from: "D", to: "B", label: "db1" },
|
||||
{ from: "D", to: "B", label: "db2" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 14, 4)],
|
||||
["C", bounds("C", 24, 4)],
|
||||
["D", bounds("D", 34, 4)],
|
||||
])
|
||||
const plans = createStateTransitionRenderPlans(diagram, placements, 12)
|
||||
const independent = plans.find((plan) => plan.route.transition.label === "ca")!
|
||||
const independentCells = new Set(independent.path.map(([x, y]) => `${x}:${y}`))
|
||||
const duplicates = plans.filter((plan) => plan.route.transition.label.startsWith("db"))
|
||||
|
||||
expect(duplicates.map((plan) => plan.route.kind)).toEqual(["top-feedback", "top-feedback"])
|
||||
expect(duplicates.every((plan) => plan.path.every(([x, y]) => !independentCells.has(`${x}:${y}`)))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createStateTransitionRenderPlans", () => {
|
||||
test("prepares concrete cells, labels, and route paths before painting", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [{ from: "A", to: "B", label: "next" }],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 14, 4)],
|
||||
])
|
||||
|
||||
const plan = createStateTransitionRenderPlans(diagram, placements, 18)[0]!
|
||||
|
||||
expect(plan.cells).toEqual([
|
||||
{ x: 6, y: 4, char: "├" },
|
||||
{ x: 7, y: 4, char: "─" },
|
||||
{ x: 8, y: 4, char: "─" },
|
||||
{ x: 9, y: 4, char: "─" },
|
||||
{ x: 10, y: 4, char: "─" },
|
||||
{ x: 11, y: 4, arrowDirection: "right" },
|
||||
])
|
||||
expect(plan.label).toEqual({ x: 8, y: 3, lines: ["next"] })
|
||||
expect(plan.path).toEqual([
|
||||
[6, 4],
|
||||
[7, 4],
|
||||
[8, 4],
|
||||
[9, 4],
|
||||
[10, 4],
|
||||
[11, 4],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createStateTransitionJunctionPlans", () => {
|
||||
test("prepares choice topology from connected transitions", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "Decision", label: "Decision", kind: "choice" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
{ id: "C", label: "C", kind: "state" },
|
||||
],
|
||||
transitions: [
|
||||
{ from: "A", to: "Decision", label: "" },
|
||||
{ from: "Decision", to: "B", label: "yes" },
|
||||
{ from: "Decision", to: "C", label: "no" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["Decision", { id: "Decision", left: 14, top: 4, width: 1, height: 1, centerX: 14, centerY: 4 }],
|
||||
["B", bounds("B", 24, 4)],
|
||||
["C", bounds("C", 4, 10)],
|
||||
])
|
||||
|
||||
const plan = createStateTransitionJunctionPlans(
|
||||
diagram,
|
||||
placements,
|
||||
createStateTransitionRenderPlans(diagram, placements, 18),
|
||||
)[0]!
|
||||
|
||||
expect(plan.kind).toBe("choice")
|
||||
expect([...plan.connections]).toEqual(["left", "right", "down"])
|
||||
expect(plan.transitions.map((transition) => transition.label)).toEqual(["", "yes", "no"])
|
||||
})
|
||||
|
||||
test("derives a lower choice connection from its routed elbow approach", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "Upper", label: "Upper", kind: "state" },
|
||||
{ id: "Lower", label: "Lower", kind: "state" },
|
||||
{ id: "Decision", label: "Decision", kind: "choice" },
|
||||
{ id: "Done", label: "Done", kind: "state" },
|
||||
],
|
||||
transitions: [
|
||||
{ from: "Upper", to: "Decision", label: "" },
|
||||
{ from: "Lower", to: "Decision", label: "" },
|
||||
{ from: "Decision", to: "Done", label: "" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["Upper", bounds("Upper", 14, 4)],
|
||||
["Lower", bounds("Lower", 14, 11)],
|
||||
["Decision", { ...bounds("Decision", 24, 4), left: 24, top: 4, width: 1, height: 1, centerX: 24, centerY: 4 }],
|
||||
["Done", bounds("Done", 34, 4)],
|
||||
])
|
||||
const renderPlans = createStateTransitionRenderPlans(diagram, placements, 18)
|
||||
|
||||
const plan = createStateTransitionJunctionPlans(diagram, placements, renderPlans)[0]!
|
||||
|
||||
expect([...plan.connections]).toContain("down")
|
||||
})
|
||||
})
|
||||
|
||||
describe("reconverging vertical elbows", () => {
|
||||
test("uses separate top connectors for a lower parallel lane", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["Fork", "Upper", "Lower", "Join"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "Fork", to: "Upper", label: "" },
|
||||
{ from: "Fork", to: "Lower", label: "down" },
|
||||
{ from: "Upper", to: "Join", label: "" },
|
||||
{ from: "Lower", to: "Join", label: "up" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["Fork", bounds("Fork", 4, 4)],
|
||||
["Upper", bounds("Upper", 14, 4)],
|
||||
["Lower", bounds("Lower", 14, 11)],
|
||||
["Join", bounds("Join", 24, 4)],
|
||||
])
|
||||
|
||||
const plans = createStateTransitionRenderPlans(diagram, placements, 18)
|
||||
const entering = plans.find((plan) => plan.route.transition.to === "Lower")!
|
||||
const leaving = plans.find((plan) => plan.route.transition.from === "Lower")!
|
||||
|
||||
expect(entering.route).toMatchObject({ kind: "vertical-elbow", offsetConnector: true })
|
||||
expect(leaving.route).toMatchObject({ kind: "vertical-elbow", offsetConnector: true })
|
||||
expect(entering.path.at(-1)).not.toEqual(leaving.path[0])
|
||||
expect(entering.cells.at(-1)).toMatchObject({ arrowDirection: "down" })
|
||||
expect(entering.cells.at(-2)).toMatchObject({ x: entering.cells.at(-1)!.x, char: "│" })
|
||||
expect(entering.cells.at(-2)!.y).toBe(entering.cells.at(-1)!.y - 1)
|
||||
expect(entering.cells.at(-3)).toMatchObject({ x: entering.cells.at(-1)!.x, char: "╮" })
|
||||
expect(entering.cells.at(-3)!.y).toBe(entering.cells.at(-1)!.y - 2)
|
||||
const enteringHorizontalY = entering.cells.find((cell) => cell.char === "╰")!.y
|
||||
const leavingHorizontalY = leaving.cells.find((cell) => cell.char === "╭")!.y
|
||||
expect(entering.label!.y).toBeLessThan(enteringHorizontalY)
|
||||
expect(leaving.label!.y).toBeLessThan(leavingHorizontalY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,734 @@
|
||||
import { BorderChars } from "@opentui/core"
|
||||
import type { DiagramDirection } from "../core/geometry.js"
|
||||
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
||||
import type { StateDiagramBoxBounds as BoxBounds } from "./layout.js"
|
||||
import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
|
||||
import { isHiddenCompositeMarker, type StateVisibleDiagram, type StateVisibleTransition } from "./visible-model.js"
|
||||
|
||||
interface StateTransitionRoutePlanBase {
|
||||
transition: StateVisibleTransition
|
||||
from: BoxBounds
|
||||
to: BoxBounds
|
||||
targetIsChoice: boolean
|
||||
}
|
||||
|
||||
export type StateTransitionRoutePlan =
|
||||
| (StateTransitionRoutePlanBase & { kind: "self" })
|
||||
| (StateTransitionRoutePlanBase & { kind: "horizontal-forward"; leftToRight: boolean })
|
||||
| (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "vertical-elbow"; hasReverse: boolean; offsetConnector: boolean })
|
||||
| (StateTransitionRoutePlanBase & { kind: "side-parallel"; railX: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "vertical" })
|
||||
|
||||
export type StateTransitionPathPoint = readonly [number, number]
|
||||
|
||||
interface StateTransitionRenderCellBase {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type StateTransitionRenderCell = StateTransitionRenderCellBase &
|
||||
({ char: string; arrowDirection?: never } | { char?: never; arrowDirection: DiagramDirection })
|
||||
|
||||
export interface StateTransitionRenderLabel {
|
||||
x: number
|
||||
y: number
|
||||
lines: readonly string[]
|
||||
}
|
||||
|
||||
export interface StateTransitionRenderPlan {
|
||||
route: StateTransitionRoutePlan
|
||||
cells: readonly StateTransitionRenderCell[]
|
||||
path: readonly StateTransitionPathPoint[]
|
||||
label?: StateTransitionRenderLabel
|
||||
}
|
||||
|
||||
export interface StateTransitionJunctionPlan {
|
||||
state: StateDiagramState
|
||||
bounds: BoxBounds
|
||||
connections: ReadonlySet<DiagramDirection>
|
||||
transitions: readonly StateVisibleTransition[]
|
||||
kind: "choice" | "hidden-composite-marker"
|
||||
}
|
||||
|
||||
interface StateTransitionRenderBuilder {
|
||||
route: StateTransitionRoutePlan
|
||||
cells: StateTransitionRenderCell[]
|
||||
path: StateTransitionPathPoint[]
|
||||
label?: StateTransitionRenderLabel
|
||||
}
|
||||
|
||||
export function measureStateTransitionLabel(label: string): { lines: string[]; width: number; height: number } {
|
||||
if (!label) return { lines: [], width: 0, height: 0 }
|
||||
const lines = splitDiagramLines(label)
|
||||
return { lines, width: Math.max(...lines.map(diagramTextWidth)), height: lines.length }
|
||||
}
|
||||
|
||||
export function hasReverseTransition(diagram: StateDiagram, transition: StateDiagramTransition): boolean {
|
||||
return diagram.transitions.some((other) => other.from === transition.to && other.to === transition.from)
|
||||
}
|
||||
|
||||
export function isStateHorizontalFeedback(
|
||||
diagram: Pick<StateDiagram, "direction">,
|
||||
from: BoxBounds,
|
||||
to: BoxBounds,
|
||||
): boolean {
|
||||
if (diagram.direction === "RL") return to.centerX > from.centerX
|
||||
return to.centerX < from.centerX
|
||||
}
|
||||
|
||||
interface FeedbackAllocation {
|
||||
side: "bottom" | "top"
|
||||
railY: number
|
||||
}
|
||||
|
||||
interface AllocatedFeedbackInterval extends FeedbackAllocation {
|
||||
left: number
|
||||
right: number
|
||||
lane: number
|
||||
}
|
||||
|
||||
interface FeedbackInterval {
|
||||
transition: StateVisibleTransition
|
||||
left: number
|
||||
right: number
|
||||
side?: "bottom" | "top"
|
||||
}
|
||||
|
||||
function feedbackIntervalsOverlap(
|
||||
left: { left: number; right: number },
|
||||
right: { left: number; right: number },
|
||||
): boolean {
|
||||
return left.left <= right.right && right.left <= left.right
|
||||
}
|
||||
|
||||
function feedbackIntervalsCross(
|
||||
left: { left: number; right: number },
|
||||
right: { left: number; right: number },
|
||||
): boolean {
|
||||
return (
|
||||
(left.left < right.left && right.left < left.right && left.right < right.right) ||
|
||||
(right.left < left.left && left.left < right.right && right.right < left.right)
|
||||
)
|
||||
}
|
||||
|
||||
function createFeedbackAllocations(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
feedbackLaneY: number,
|
||||
laneGap: number,
|
||||
feedbackTopY?: number,
|
||||
): ReadonlyMap<StateVisibleTransition, FeedbackAllocation> {
|
||||
if (diagram.direction !== "LR" && diagram.direction !== "RL") return new Map()
|
||||
const allocations = new Map<StateVisibleTransition, FeedbackAllocation>()
|
||||
const sidedIntervals: Record<"bottom" | "top", FeedbackInterval[]> = { bottom: [], top: [] }
|
||||
const canonicalSides = new Map<string, "bottom" | "top">()
|
||||
const topLaneY = feedbackTopY ?? Math.min(...[...bounds.values()].map((bound) => bound.top)) - 3
|
||||
const intervals: FeedbackInterval[] = []
|
||||
|
||||
for (const transition of diagram.transitions) {
|
||||
const from = bounds.get(transition.from)
|
||||
const to = bounds.get(transition.to)
|
||||
if (!from || !to || transition.from === transition.to || !isStateHorizontalFeedback(diagram, from, to)) continue
|
||||
if (from.centerY !== to.centerY && !(from.centerY > to.centerY)) continue
|
||||
|
||||
intervals.push({ transition, left: Math.min(from.centerX, to.centerX), right: Math.max(from.centerX, to.centerX) })
|
||||
}
|
||||
|
||||
for (const interval of intervals) {
|
||||
const endpointKey = `${interval.transition.from}\u0000${interval.transition.to}`
|
||||
const side =
|
||||
canonicalSides.get(endpointKey) ??
|
||||
(["bottom", "top"] as const).find(
|
||||
(candidate) => !sidedIntervals[candidate].some((existing) => feedbackIntervalsCross(existing, interval)),
|
||||
)
|
||||
if (!side) continue
|
||||
canonicalSides.set(endpointKey, side)
|
||||
interval.side = side
|
||||
sidedIntervals[side].push(interval)
|
||||
}
|
||||
|
||||
for (const side of ["bottom", "top"] as const) {
|
||||
const occupied: AllocatedFeedbackInterval[] = []
|
||||
const intervalsByWidth = [...sidedIntervals[side]].sort(
|
||||
(left, right) => left.right - left.left - (right.right - right.left),
|
||||
)
|
||||
for (const interval of intervalsByWidth) {
|
||||
let lane = 0
|
||||
while (occupied.some((existing) => existing.lane === lane && feedbackIntervalsOverlap(existing, interval))) lane++
|
||||
const railY = side === "bottom" ? feedbackLaneY + lane * laneGap : topLaneY - lane * laneGap
|
||||
occupied.push({ ...interval, side, lane, railY })
|
||||
allocations.set(interval.transition, { side, railY })
|
||||
}
|
||||
}
|
||||
|
||||
return allocations
|
||||
}
|
||||
|
||||
function hasOpposingTopConnector(
|
||||
diagram: StateVisibleDiagram,
|
||||
transition: StateVisibleTransition,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
): boolean {
|
||||
const from = bounds.get(transition.from)
|
||||
const to = bounds.get(transition.to)
|
||||
if (!from || !to || from.centerY === to.centerY) return false
|
||||
|
||||
const lowerId = from.centerY > to.centerY ? transition.from : transition.to
|
||||
const lower = bounds.get(lowerId)!
|
||||
const leavesLower = transition.from === lowerId
|
||||
return diagram.transitions.some((other) => {
|
||||
if (other === transition || (leavesLower ? other.to !== lowerId : other.from !== lowerId)) return false
|
||||
const otherFrom = bounds.get(other.from)
|
||||
const otherTo = bounds.get(other.to)
|
||||
if (!otherFrom || !otherTo || Math.max(otherFrom.centerY, otherTo.centerY) !== lower.centerY) return false
|
||||
if (other.from !== lowerId) return true
|
||||
return !(
|
||||
(diagram.direction === "LR" || diagram.direction === "RL") &&
|
||||
isStateHorizontalFeedback(diagram, otherFrom, otherTo)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function createStateTransitionRoutePlans(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
feedbackLaneY: number,
|
||||
feedbackTopY?: number,
|
||||
): StateTransitionRoutePlan[] {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const endpointOccurrences = new Map<string, number>()
|
||||
const maxLabelWidth = Math.max(
|
||||
0,
|
||||
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).width),
|
||||
)
|
||||
const parallelLaneGap = Math.max(
|
||||
3,
|
||||
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height + 2),
|
||||
)
|
||||
const sideLaneX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + maxLabelWidth + 3
|
||||
const feedbackAllocations = createFeedbackAllocations(diagram, bounds, feedbackLaneY, parallelLaneGap, feedbackTopY)
|
||||
|
||||
return diagram.transitions.flatMap((transition): StateTransitionRoutePlan[] => {
|
||||
const from = bounds.get(transition.from)
|
||||
const to = bounds.get(transition.to)
|
||||
if (!from || !to) return []
|
||||
|
||||
const targetState = statesById.get(transition.to)
|
||||
const targetIsChoice = targetState?.kind === "choice" || isHiddenCompositeMarker(targetState)
|
||||
const base = { transition, from, to, targetIsChoice }
|
||||
if (transition.from === transition.to) return [{ ...base, kind: "self" }]
|
||||
const endpointKey = `${transition.from}\u0000${transition.to}`
|
||||
const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
|
||||
endpointOccurrences.set(endpointKey, parallelIndex + 1)
|
||||
const feedback =
|
||||
(diagram.direction === "LR" || diagram.direction === "RL") && isStateHorizontalFeedback(diagram, from, to)
|
||||
const feedbackAllocation = feedbackAllocations.get(transition)
|
||||
if (feedbackAllocation) {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
kind: feedbackAllocation.side === "bottom" ? "bottom-feedback" : "top-feedback",
|
||||
railY: feedbackAllocation.railY,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (parallelIndex > 0) {
|
||||
if (diagram.direction === "LR" || diagram.direction === "RL") {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
kind: "bottom-parallel",
|
||||
railY: feedbackLaneY + (parallelIndex - 1) * parallelLaneGap,
|
||||
},
|
||||
]
|
||||
}
|
||||
return [{ ...base, kind: "side-parallel", railX: sideLaneX + (parallelIndex - 1) * parallelLaneGap }]
|
||||
}
|
||||
if (diagram.direction !== "LR" && diagram.direction !== "RL") return [{ ...base, kind: "vertical" }]
|
||||
|
||||
if (from.centerY !== to.centerY) {
|
||||
if (from.centerY > to.centerY && feedback) return [{ ...base, kind: "bottom-feedback", railY: feedbackLaneY }]
|
||||
const hasReverse = hasReverseTransition(diagram, transition)
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
kind: "vertical-elbow",
|
||||
hasReverse,
|
||||
offsetConnector: hasReverse || hasOpposingTopConnector(diagram, transition, bounds),
|
||||
},
|
||||
]
|
||||
}
|
||||
if (feedback) return [{ ...base, kind: "bottom-feedback", railY: feedbackLaneY }]
|
||||
return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
|
||||
})
|
||||
}
|
||||
|
||||
function addCell(builder: StateTransitionRenderBuilder, cell: StateTransitionRenderCell): void {
|
||||
builder.cells.push(cell)
|
||||
builder.path.push([cell.x, cell.y])
|
||||
}
|
||||
|
||||
function addPathPoint(builder: StateTransitionRenderBuilder, x: number, y: number): void {
|
||||
builder.path.push([x, y])
|
||||
}
|
||||
|
||||
function addLabel(builder: StateTransitionRenderBuilder, x: number, y: number, label: string): void {
|
||||
const metrics = measureStateTransitionLabel(label)
|
||||
if (metrics.lines.length > 0) builder.label = { x, y, lines: metrics.lines }
|
||||
}
|
||||
|
||||
function addHorizontalLine(
|
||||
builder: StateTransitionRenderBuilder,
|
||||
fromX: number,
|
||||
toX: number,
|
||||
y: number,
|
||||
direction: 1 | -1,
|
||||
): void {
|
||||
for (let x = fromX; direction === 1 ? x <= toX : x >= toX; x += direction) {
|
||||
addCell(builder, { x, y, char: "─" })
|
||||
}
|
||||
}
|
||||
|
||||
function addVerticalLine(
|
||||
builder: StateTransitionRenderBuilder,
|
||||
x: number,
|
||||
fromY: number,
|
||||
toY: number,
|
||||
direction: 1 | -1,
|
||||
): void {
|
||||
for (let y = fromY; direction === 1 ? y <= toY : y >= toY; y += direction) {
|
||||
addCell(builder, { x, y, char: "│" })
|
||||
}
|
||||
}
|
||||
|
||||
function addRightDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds): void {
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
addCell(builder, {
|
||||
x: bounds.left + bounds.width - 1,
|
||||
y: bounds.centerY,
|
||||
char: BorderChars.rounded.leftT,
|
||||
})
|
||||
}
|
||||
|
||||
function addLeftDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds): void {
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
addCell(builder, { x: bounds.left, y: bounds.centerY, char: BorderChars.rounded.rightT })
|
||||
}
|
||||
|
||||
function addBottomDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds, x: number): void {
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
addCell(builder, {
|
||||
x,
|
||||
y: bounds.top + bounds.height - 1,
|
||||
char: BorderChars.rounded.topT,
|
||||
})
|
||||
}
|
||||
|
||||
function addTopDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds, x: number): void {
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
addCell(builder, { x, y: bounds.top, char: BorderChars.rounded.bottomT })
|
||||
}
|
||||
|
||||
function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, targetIsChoice, leftToRight, transition } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "horizontal-forward" }
|
||||
>
|
||||
const y = from.centerY
|
||||
if (leftToRight) addRightDeparture(builder, from)
|
||||
else addLeftDeparture(builder, from)
|
||||
const step = leftToRight ? 1 : -1
|
||||
const startX = leftToRight ? from.left + from.width : from.left - 1
|
||||
const endX = leftToRight ? to.left - 1 : to.left + to.width
|
||||
addHorizontalLine(builder, startX, targetIsChoice ? endX : endX - step, y, step)
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, y)
|
||||
else addCell(builder, { x: endX, y, arrowDirection: leftToRight ? "right" : "left" })
|
||||
if (!transition.label) return
|
||||
const metrics = measureStateTransitionLabel(transition.label)
|
||||
const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
|
||||
addLabel(builder, labelX, Math.max(0, y - metrics.height), transition.label)
|
||||
}
|
||||
|
||||
function addSelfTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from: bounds, transition } = builder.route
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
const sourceX = bounds.left + Math.max(2, Math.floor(bounds.width / 3))
|
||||
const bottomY = bounds.top + bounds.height - 1
|
||||
const railY = bottomY + 2
|
||||
const targetX = Math.max(sourceX + 3, bounds.left + Math.min(bounds.width - 3, Math.ceil((bounds.width * 2) / 3)))
|
||||
|
||||
addBottomDeparture(builder, bounds, sourceX)
|
||||
addCell(builder, { x: sourceX, y: bottomY + 1, char: "│" })
|
||||
addCell(builder, { x: sourceX, y: railY, char: "╰" })
|
||||
for (let x = sourceX + 1; x < targetX; x++) addCell(builder, { x, y: railY, char: "─" })
|
||||
addCell(builder, { x: targetX, y: railY, char: "╯" })
|
||||
addCell(builder, { x: targetX, y: bottomY + 1, arrowDirection: "up" })
|
||||
if (transition.label) addLabel(builder, targetX + 2, bottomY + 1, transition.label)
|
||||
}
|
||||
|
||||
function outsideBottomY(bounds: BoxBounds): number {
|
||||
return bounds.top + bounds.height
|
||||
}
|
||||
|
||||
function outsideTopY(bounds: BoxBounds): number {
|
||||
return bounds.top - 1
|
||||
}
|
||||
|
||||
function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, targetIsChoice, transition, railY } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "bottom-feedback" | "bottom-parallel" }
|
||||
>
|
||||
const sourceX = from.centerX
|
||||
const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + 1 : to.left + to.width - 2) : to.centerX
|
||||
const targetRailCutsSource = targetX >= from.left && targetX <= from.left + from.width - 1
|
||||
const railTargetX = targetRailCutsSource ? Math.max(from.left + from.width, to.left + to.width) + 2 : targetX
|
||||
const sourceBottomY = outsideBottomY(from)
|
||||
const targetBottomY = outsideBottomY(to)
|
||||
addBottomDeparture(builder, from, sourceX)
|
||||
addVerticalLine(builder, sourceX, sourceBottomY, railY - 1, 1)
|
||||
addCell(builder, { x: sourceX, y: railY, char: sourceX > railTargetX ? "╯" : "╰" })
|
||||
if (sourceX !== railTargetX) {
|
||||
const horizontalStep = sourceX < railTargetX ? 1 : -1
|
||||
for (let x = sourceX + horizontalStep; x !== railTargetX; x += horizontalStep) {
|
||||
addCell(builder, { x, y: railY, char: "─" })
|
||||
}
|
||||
}
|
||||
addCell(builder, { x: railTargetX, y: railY, char: sourceX > railTargetX ? "╰" : "╯" })
|
||||
for (let y = railY - 1; y > targetBottomY; y--) addCell(builder, { x: railTargetX, y, char: "│" })
|
||||
if (railTargetX !== targetX) {
|
||||
addCell(builder, { x: railTargetX, y: targetBottomY, char: railTargetX < targetX ? "╭" : "╮" })
|
||||
const horizontalStep = railTargetX < targetX ? 1 : -1
|
||||
for (let x = railTargetX + horizontalStep; x !== targetX; x += horizontalStep) {
|
||||
addCell(builder, { x, y: targetBottomY, char: "─" })
|
||||
}
|
||||
}
|
||||
addCell(builder, { x: targetX, y: targetBottomY, ...(targetIsChoice ? { char: "│" } : { arrowDirection: "up" }) })
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
|
||||
if (!transition.label) return
|
||||
const metrics = measureStateTransitionLabel(transition.label)
|
||||
const horizontalRoom = Math.abs(sourceX - railTargetX) - 2
|
||||
const labelX =
|
||||
metrics.width <= horizontalRoom
|
||||
? Math.min(sourceX, railTargetX) + Math.max(1, Math.floor((Math.abs(sourceX - railTargetX) - metrics.width) / 2))
|
||||
: railTargetX + 2
|
||||
addLabel(builder, labelX, Math.max(0, railY - metrics.height), transition.label)
|
||||
}
|
||||
|
||||
function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, targetIsChoice, transition, railY } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "top-feedback" }
|
||||
>
|
||||
const sourceX = from.centerX
|
||||
const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + to.width - 2 : to.left + 1) : to.centerX
|
||||
const sourceTopY = outsideTopY(from)
|
||||
const targetTopY = outsideTopY(to)
|
||||
addTopDeparture(builder, from, sourceX)
|
||||
addVerticalLine(builder, sourceX, sourceTopY, railY + 1, -1)
|
||||
addCell(builder, { x: sourceX, y: railY, char: sourceX > targetX ? "╮" : "╭" })
|
||||
if (sourceX !== targetX) {
|
||||
const horizontalStep = sourceX < targetX ? 1 : -1
|
||||
for (let x = sourceX + horizontalStep; x !== targetX; x += horizontalStep)
|
||||
addCell(builder, { x, y: railY, char: "─" })
|
||||
}
|
||||
addCell(builder, { x: targetX, y: railY, char: sourceX > targetX ? "╭" : "╮" })
|
||||
for (let y = railY + 1; y < targetTopY; y++) addCell(builder, { x: targetX, y, char: "│" })
|
||||
addCell(builder, { x: targetX, y: targetTopY, ...(targetIsChoice ? { char: "│" } : { arrowDirection: "down" }) })
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
|
||||
if (!transition.label) return
|
||||
const metrics = measureStateTransitionLabel(transition.label)
|
||||
const horizontalRoom = Math.abs(sourceX - targetX) - 2
|
||||
const labelX =
|
||||
metrics.width <= horizontalRoom
|
||||
? Math.min(sourceX, targetX) + Math.max(1, Math.floor((Math.abs(sourceX - targetX) - metrics.width) / 2))
|
||||
: targetX + 2
|
||||
addLabel(builder, labelX, railY - metrics.height, transition.label)
|
||||
}
|
||||
|
||||
function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, targetIsChoice, transition, railX } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "side-parallel" }
|
||||
>
|
||||
const startX = from.left + from.width
|
||||
const endX = to.left + to.width
|
||||
const startY = from.centerY
|
||||
const endY = to.centerY
|
||||
const verticalStep: 1 | -1 = startY <= endY ? 1 : -1
|
||||
addRightDeparture(builder, from)
|
||||
addHorizontalLine(builder, startX, railX - 1, startY, 1)
|
||||
addCell(builder, { x: railX, y: startY, char: verticalStep === 1 ? "╮" : "╯" })
|
||||
for (let y = startY + verticalStep; y !== endY; y += verticalStep) addCell(builder, { x: railX, y, char: "│" })
|
||||
addCell(builder, { x: railX, y: endY, char: verticalStep === 1 ? "╯" : "╮" })
|
||||
for (let x = railX - 1; x > endX; x--) addCell(builder, { x, y: endY, char: "─" })
|
||||
addCell(builder, { x: endX, y: endY, ...(targetIsChoice ? { char: "─" } : { arrowDirection: "left" }) })
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
|
||||
if (transition.label) addLabel(builder, railX + 2, Math.min(startY, endY) + 1, transition.label)
|
||||
}
|
||||
|
||||
function innerConnectorX(bounds: BoxBounds, preferredX: number): number {
|
||||
if (bounds.width <= 2) return bounds.centerX
|
||||
return Math.max(bounds.left + 1, Math.min(bounds.left + bounds.width - 2, preferredX))
|
||||
}
|
||||
|
||||
function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, transition, targetIsChoice, hasReverse, offsetConnector } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "vertical-elbow" }
|
||||
>
|
||||
const topToBottom = from.centerY < to.centerY
|
||||
const offset = offsetConnector ? (topToBottom ? -2 : 2) : 0
|
||||
const startX = innerConnectorX(from, from.centerX + offset)
|
||||
const endX = innerConnectorX(to, to.centerX + offset)
|
||||
const startY = topToBottom ? from.top + from.height : from.top - 1
|
||||
const endY = topToBottom ? to.top - 1 : to.top + to.height
|
||||
const verticalStep = topToBottom ? 1 : -1
|
||||
if (topToBottom) addBottomDeparture(builder, from, startX)
|
||||
else addTopDeparture(builder, from, startX)
|
||||
const availableApproach = Math.max(0, Math.abs(endY - startY) - 1)
|
||||
const bendY = startX === endX ? endY : topToBottom ? endY - verticalStep * Math.min(2, availableApproach) : startY
|
||||
const targetApproachLength = Math.abs(endY - bendY)
|
||||
const hasTargetApproach = targetApproachLength > 0
|
||||
if (startY !== bendY) addVerticalLine(builder, startX, startY, bendY - verticalStep, verticalStep)
|
||||
if (startX !== endX) {
|
||||
const horizontalStep = startX < endX ? 1 : -1
|
||||
addCell(builder, {
|
||||
x: startX,
|
||||
y: bendY,
|
||||
char: topToBottom ? (startX < endX ? "╰" : "╯") : startX < endX ? "╭" : "╮",
|
||||
})
|
||||
for (let x = startX + horizontalStep; x !== endX; x += horizontalStep) addCell(builder, { x, y: bendY, char: "─" })
|
||||
if (hasTargetApproach) {
|
||||
addCell(builder, {
|
||||
x: endX,
|
||||
y: bendY,
|
||||
char: topToBottom ? (startX < endX ? "╮" : "╭") : startX < endX ? "╯" : "╰",
|
||||
})
|
||||
for (let distance = 1; distance < targetApproachLength; distance++) {
|
||||
addCell(builder, { x: endX, y: bendY + verticalStep * distance, char: "│" })
|
||||
}
|
||||
}
|
||||
}
|
||||
const targetChar = targetIsChoice ? (hasTargetApproach || startX === endX ? "│" : topToBottom ? "┬" : "┴") : undefined
|
||||
addCell(builder, {
|
||||
x: endX,
|
||||
y: endY,
|
||||
...(targetChar ? { char: targetChar } : { arrowDirection: topToBottom ? "down" : "up" }),
|
||||
})
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
|
||||
if (!transition.label) return
|
||||
const metrics = measureStateTransitionLabel(transition.label)
|
||||
if (topToBottom) {
|
||||
const leftLabelX = startX - metrics.width - 2
|
||||
const labelX = hasReverse || endX < startX ? (leftLabelX >= 0 ? leftLabelX : startX + 4) : startX + 2
|
||||
addLabel(
|
||||
builder,
|
||||
labelX,
|
||||
hasTargetApproach ? Math.max(0, bendY - metrics.height) : Math.min(startY + 1, endY),
|
||||
transition.label,
|
||||
)
|
||||
} else {
|
||||
const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
|
||||
addLabel(
|
||||
builder,
|
||||
startX === endX ? startX + 3 : labelX,
|
||||
hasTargetApproach ? Math.max(0, bendY - metrics.height) : Math.max(0, startY),
|
||||
transition.label,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function addVerticalTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, transition, targetIsChoice } = builder.route
|
||||
const topToBottom = from.centerY <= to.centerY
|
||||
const x = from.centerX
|
||||
const startY = topToBottom ? from.top + from.height : from.top - 1
|
||||
const endY = topToBottom ? to.top - 1 : to.top + to.height
|
||||
const step = topToBottom ? 1 : -1
|
||||
if (topToBottom) addBottomDeparture(builder, from, x)
|
||||
else addTopDeparture(builder, from, x)
|
||||
if (startY !== endY) addVerticalLine(builder, x, startY, endY - step, step)
|
||||
addCell(builder, {
|
||||
x,
|
||||
y: endY,
|
||||
...(targetIsChoice ? { char: "│" } : { arrowDirection: topToBottom ? "down" : "up" }),
|
||||
})
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
|
||||
if (transition.label) addLabel(builder, x + 2, Math.min(startY, endY) + 1, transition.label)
|
||||
}
|
||||
|
||||
function createStateTransitionRenderPlan(route: StateTransitionRoutePlan): StateTransitionRenderPlan {
|
||||
const builder: StateTransitionRenderBuilder = { route, cells: [], path: [] }
|
||||
switch (route.kind) {
|
||||
case "self":
|
||||
addSelfTransition(builder)
|
||||
break
|
||||
case "horizontal-forward":
|
||||
addHorizontalForward(builder)
|
||||
break
|
||||
case "bottom-feedback":
|
||||
case "bottom-parallel":
|
||||
addBottomLaneTransition(builder)
|
||||
break
|
||||
case "top-feedback":
|
||||
addTopFeedbackTransition(builder)
|
||||
break
|
||||
case "vertical-elbow":
|
||||
addVerticalElbowTransition(builder)
|
||||
break
|
||||
case "vertical":
|
||||
addVerticalTransition(builder)
|
||||
break
|
||||
case "side-parallel":
|
||||
addSideParallelTransition(builder)
|
||||
break
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
interface StateTransitionLabelRect {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function labelRect(label: StateTransitionRenderLabel, width: number): StateTransitionLabelRect {
|
||||
return { left: label.x, top: label.y, width, height: label.lines.length }
|
||||
}
|
||||
|
||||
function rectsOverlap(left: StateTransitionLabelRect, right: StateTransitionLabelRect): boolean {
|
||||
return (
|
||||
left.left < right.left + right.width &&
|
||||
left.left + left.width > right.left &&
|
||||
left.top < right.top + right.height &&
|
||||
left.top + left.height > right.top
|
||||
)
|
||||
}
|
||||
|
||||
function placeStateTransitionLabels(
|
||||
plans: readonly StateTransitionRenderPlan[],
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
): StateTransitionRenderPlan[] {
|
||||
const routeCells = new Set(plans.flatMap((plan) => plan.cells.map((cell) => `${cell.x}:${cell.y}`)))
|
||||
const placedLabels: StateTransitionLabelRect[] = []
|
||||
const stateRects = diagram.states.flatMap((state) => {
|
||||
const bound = bounds.get(state.id)
|
||||
return bound && !isHiddenCompositeMarker(state)
|
||||
? [{ left: bound.left, top: bound.top, width: bound.width, height: bound.height }]
|
||||
: []
|
||||
})
|
||||
|
||||
return plans.map((plan) => {
|
||||
if (!plan.label) return plan
|
||||
const width = Math.max(...plan.label.lines.map(diagramTextWidth))
|
||||
if (plan.label.lines.length === 1) {
|
||||
placedLabels.push(labelRect(plan.label, width))
|
||||
return plan
|
||||
}
|
||||
const statePadding = 1
|
||||
const isClear = (x: number, y: number): boolean => {
|
||||
if (x < 0 || y < 0) return false
|
||||
const rect = labelRect({ ...plan.label!, x, y }, width)
|
||||
if (
|
||||
stateRects.some((state) =>
|
||||
rectsOverlap(rect, {
|
||||
left: state.left - statePadding,
|
||||
top: state.top - statePadding,
|
||||
width: state.width + statePadding * 2,
|
||||
height: state.height + statePadding * 2,
|
||||
}),
|
||||
)
|
||||
)
|
||||
return false
|
||||
if (placedLabels.some((label) => rectsOverlap(rect, label))) return false
|
||||
for (let row = rect.top; row < rect.top + rect.height; row++) {
|
||||
for (let column = rect.left; column < rect.left + rect.width; column++) {
|
||||
if (routeCells.has(`${column}:${row}`)) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
let x = plan.label.x
|
||||
let y = plan.label.y
|
||||
if (!isClear(x, y)) {
|
||||
search: for (let distance = 1; distance < 500; distance++) {
|
||||
for (let dx = -distance; dx <= distance; dx++) {
|
||||
const dy = distance - Math.abs(dx)
|
||||
for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
|
||||
const candidateX = x + dx
|
||||
if (!isClear(candidateX, candidateY)) continue
|
||||
x = candidateX
|
||||
y = candidateY
|
||||
break search
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
placedLabels.push(labelRect({ ...plan.label, x, y }, width))
|
||||
return { ...plan, label: { ...plan.label, x, y } }
|
||||
})
|
||||
}
|
||||
|
||||
export function createStateTransitionRenderPlans(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
feedbackLaneY: number,
|
||||
feedbackTopY?: number,
|
||||
): StateTransitionRenderPlan[] {
|
||||
return placeStateTransitionLabels(
|
||||
createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, feedbackTopY).map(createStateTransitionRenderPlan),
|
||||
diagram,
|
||||
bounds,
|
||||
)
|
||||
}
|
||||
|
||||
function connectionDirection(from: StateTransitionPathPoint, to: StateTransitionPathPoint): DiagramDirection {
|
||||
const deltaX = to[0] - from[0]
|
||||
const deltaY = to[1] - from[1]
|
||||
if (Math.abs(deltaX) >= Math.abs(deltaY) && deltaX !== 0) return deltaX > 0 ? "right" : "left"
|
||||
if (deltaY !== 0) return deltaY > 0 ? "down" : "up"
|
||||
return "right"
|
||||
}
|
||||
|
||||
export function createStateTransitionJunctionPlans(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
renderPlans: readonly StateTransitionRenderPlan[],
|
||||
): StateTransitionJunctionPlan[] {
|
||||
return diagram.states.flatMap((state): StateTransitionJunctionPlan[] => {
|
||||
const kind =
|
||||
state.kind === "choice" ? "choice" : isHiddenCompositeMarker(state) ? "hidden-composite-marker" : undefined
|
||||
if (!kind) return []
|
||||
const stateBounds = bounds.get(state.id)
|
||||
if (!stateBounds) return []
|
||||
|
||||
const connections = new Set<DiagramDirection>()
|
||||
const transitions: StateVisibleTransition[] = []
|
||||
for (const transition of diagram.transitions) {
|
||||
const renderPlan = renderPlans.find((plan) => plan.route.transition === transition)
|
||||
let connected = false
|
||||
if (transition.to === state.id) {
|
||||
const junction = renderPlan?.path.at(-1)
|
||||
const neighbor = renderPlan?.path.at(-2)
|
||||
if (junction && neighbor) connections.add(connectionDirection(junction, neighbor))
|
||||
connected = true
|
||||
}
|
||||
if (transition.from === state.id) {
|
||||
const neighbor = renderPlan?.path[0]
|
||||
if (neighbor) connections.add(connectionDirection([stateBounds.left, stateBounds.top], neighbor))
|
||||
connected = true
|
||||
}
|
||||
if (connected) transitions.push(transition)
|
||||
}
|
||||
|
||||
return [{ state, bounds: stateBounds, connections, transitions, kind }]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { drawStateDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { resolveStateStyleColors } from "./style.js"
|
||||
|
||||
describe("state note connector styles", () => {
|
||||
test("ramps the final connector cells into the note border", () => {
|
||||
const grid = drawStateDiagramGrid(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B
|
||||
note right of A : note`),
|
||||
)
|
||||
const connectorStyles = grid.rows.flatMap((row) =>
|
||||
row.map((cell) => cell.style).filter((style) => style?.startsWith("noteConnectorRamp")),
|
||||
)
|
||||
|
||||
expect(new Set(connectorStyles)).toEqual(
|
||||
new Set(["noteConnectorRamp1", "noteConnectorRamp2", "noteConnectorRamp3"]),
|
||||
)
|
||||
})
|
||||
|
||||
test("resolves the ramp from connector color toward border color", () => {
|
||||
const colors = resolveStateStyleColors({
|
||||
noteConnector: RGBA.fromInts(0, 0, 0, 255),
|
||||
noteBorder: RGBA.fromInts(200, 200, 200, 255),
|
||||
})
|
||||
const reds = [
|
||||
colors.noteConnector.toInts()[0],
|
||||
colors.noteConnectorRamp1.toInts()[0],
|
||||
colors.noteConnectorRamp2.toInts()[0],
|
||||
colors.noteConnectorRamp3.toInts()[0],
|
||||
colors.noteBorder.toInts()[0],
|
||||
]
|
||||
|
||||
expect(reds).toEqual([...reds].sort((left, right) => left - right))
|
||||
expect(new Set(reds).size).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("state transition departure styles", () => {
|
||||
test("ramps once from an ordinary state without restarting at a choice fork", () => {
|
||||
const grid = drawStateDiagramGrid(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
Check --> Decision
|
||||
state Decision <<choice>>
|
||||
Decision --> Ready: yes
|
||||
Decision --> Failed: no`),
|
||||
)
|
||||
const rampStyles = grid.rows
|
||||
.flatMap((row) => row.map((cell) => cell.style))
|
||||
.filter((style) => style?.startsWith("stateDepartureRamp"))
|
||||
|
||||
expect(rampStyles).toHaveLength(3)
|
||||
expect(new Set(rampStyles)).toEqual(new Set(["stateDepartureRamp1", "stateDepartureRamp2", "stateDepartureRamp3"]))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createColorRampTheme, rgba, type DiagramRgb } from "../core/color/style.js"
|
||||
import type { BaseStateCellStyle, NoteConnectorRampStyle, StateCellStyle, StateDepartureRampStyle } from "./types.js"
|
||||
|
||||
const DEFAULT_THEME_RGB = {
|
||||
state: [228, 239, 232],
|
||||
composite: [111, 138, 126],
|
||||
transition: [134, 225, 200],
|
||||
label: [134, 225, 200],
|
||||
noteBorder: [141, 169, 155],
|
||||
noteText: [215, 229, 221],
|
||||
noteConnector: [141, 169, 155],
|
||||
start: [134, 225, 200],
|
||||
end: [230, 177, 126],
|
||||
choice: [134, 225, 200],
|
||||
} as const satisfies Record<BaseStateCellStyle, DiagramRgb>
|
||||
const NOTE_CONNECTOR_RAMP_STYLES = [
|
||||
"noteConnectorRamp1",
|
||||
"noteConnectorRamp2",
|
||||
"noteConnectorRamp3",
|
||||
] as const satisfies readonly NoteConnectorRampStyle[]
|
||||
const STATE_DEPARTURE_RAMP_STYLES = [
|
||||
"stateDepartureRamp1",
|
||||
"stateDepartureRamp2",
|
||||
"stateDepartureRamp3",
|
||||
] as const satisfies readonly StateDepartureRampStyle[]
|
||||
export type StateStyleColors = Required<Record<StateCellStyle, RGBA>>
|
||||
|
||||
export function resolveStateStyleColors(
|
||||
colors: Partial<Record<BaseStateCellStyle, RGBA | undefined>> = {},
|
||||
): StateStyleColors {
|
||||
const resolved = {
|
||||
state: colors.state ?? rgba(DEFAULT_THEME_RGB.state),
|
||||
composite: colors.composite ?? rgba(DEFAULT_THEME_RGB.composite),
|
||||
transition: colors.transition ?? rgba(DEFAULT_THEME_RGB.transition),
|
||||
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
|
||||
noteBorder: colors.noteBorder ?? rgba(DEFAULT_THEME_RGB.noteBorder),
|
||||
noteText: colors.noteText ?? rgba(DEFAULT_THEME_RGB.noteText),
|
||||
noteConnector: colors.noteConnector ?? rgba(DEFAULT_THEME_RGB.noteConnector),
|
||||
start: colors.start ?? rgba(DEFAULT_THEME_RGB.start),
|
||||
end: colors.end ?? rgba(DEFAULT_THEME_RGB.end),
|
||||
choice: colors.choice ?? rgba(DEFAULT_THEME_RGB.choice),
|
||||
}
|
||||
return {
|
||||
...resolved,
|
||||
...createColorRampTheme(NOTE_CONNECTOR_RAMP_STYLES, resolved.noteConnector, resolved.noteBorder),
|
||||
...createColorRampTheme(STATE_DEPARTURE_RAMP_STYLES, resolved.state, resolved.transition),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { BorderStyle } from "@opentui/core"
|
||||
|
||||
export type StateDiagramDirection = "TB" | "TD" | "LR" | "RL"
|
||||
export type StateDiagramArrowHeadStyle = "filled" | "line"
|
||||
|
||||
export interface StateDiagramState {
|
||||
id: string
|
||||
label: string
|
||||
kind: "state" | "start" | "end" | "choice"
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export interface StateDiagramTransition {
|
||||
from: string
|
||||
to: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface StateDiagramCompositeState {
|
||||
id: string
|
||||
label: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export interface StateDiagramNote {
|
||||
target: string
|
||||
position: "left" | "right"
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface StateDiagram {
|
||||
direction: StateDiagramDirection
|
||||
states: StateDiagramState[]
|
||||
transitions: StateDiagramTransition[]
|
||||
composites: StateDiagramCompositeState[]
|
||||
notes: StateDiagramNote[]
|
||||
}
|
||||
|
||||
export interface StateDiagramRenderOptions {
|
||||
direction?: StateDiagramDirection
|
||||
borderStyle?: BorderStyle
|
||||
arrowHeadStyle?: StateDiagramArrowHeadStyle
|
||||
minStateGap?: number
|
||||
}
|
||||
|
||||
export type NoteConnectorRampStyle = `noteConnectorRamp${1 | 2 | 3}`
|
||||
export type StateDepartureRampStyle = `stateDepartureRamp${1 | 2 | 3}`
|
||||
export type BaseStateCellStyle =
|
||||
| "state"
|
||||
| "composite"
|
||||
| "transition"
|
||||
| "label"
|
||||
| "noteBorder"
|
||||
| "noteText"
|
||||
| "noteConnector"
|
||||
| "start"
|
||||
| "end"
|
||||
| "choice"
|
||||
export type StateCellStyle = BaseStateCellStyle | NoteConnectorRampStyle | StateDepartureRampStyle
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { prepareVisibleStateDiagram } from "./visible-model.js"
|
||||
|
||||
describe("prepareVisibleStateDiagram", () => {
|
||||
test("collapses composite marker transitions", () => {
|
||||
const parsed = parseMermaidStateDiagram(`stateDiagram-v2
|
||||
[*] --> Authenticated: login
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
Editing --> [*]: save
|
||||
}
|
||||
Authenticated --> [*]: logout`)
|
||||
|
||||
const visible = prepareVisibleStateDiagram(parsed)
|
||||
const entry = visible.transitions.find((transition) => transition.from === "__start")
|
||||
const exit = visible.transitions.find((transition) => transition.to === "__end")
|
||||
|
||||
expect(visible.states.some((state) => state.id === "Authenticated.__start")).toBe(false)
|
||||
expect(visible.states.some((state) => state.id === "Authenticated.__end")).toBe(false)
|
||||
expect(entry).toMatchObject({ from: "__start", to: "Idle", label: "login" })
|
||||
expect(exit).toMatchObject({ from: "Editing", to: "__end", label: "save" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
|
||||
|
||||
export type StateVisibleTransition = StateDiagramTransition
|
||||
|
||||
export interface StateVisibleDiagram extends Omit<StateDiagram, "transitions"> {
|
||||
transitions: StateVisibleTransition[]
|
||||
}
|
||||
|
||||
export function isHiddenCompositeMarker(state: StateDiagramState | undefined): boolean {
|
||||
return Boolean(state?.parentId && (state.kind === "start" || state.kind === "end"))
|
||||
}
|
||||
|
||||
function composeTransitionLabel(incoming: StateDiagramTransition, outgoing: StateDiagramTransition): string {
|
||||
return incoming.label || outgoing.label
|
||||
}
|
||||
|
||||
function collapseHiddenCompositeMarkerTransitionsOnce(
|
||||
transitions: readonly StateVisibleTransition[],
|
||||
statesById: ReadonlyMap<string, StateDiagramState>,
|
||||
): { transitions: StateVisibleTransition[]; changed: boolean } {
|
||||
const hiddenMarkers = new Set(
|
||||
[...statesById.values()].filter((state) => isHiddenCompositeMarker(state)).map((state) => state.id),
|
||||
)
|
||||
if (hiddenMarkers.size === 0) return { transitions: [...transitions], changed: false }
|
||||
|
||||
const skipped = new Set<StateVisibleTransition>()
|
||||
const collapsed: StateVisibleTransition[] = []
|
||||
let changed = false
|
||||
|
||||
for (const markerId of hiddenMarkers) {
|
||||
const incoming = transitions.filter((transition) => transition.to === markerId && transition.from !== markerId)
|
||||
const outgoing = transitions.filter((transition) => transition.from === markerId && transition.to !== markerId)
|
||||
if (incoming.length === 0 || outgoing.length === 0) continue
|
||||
|
||||
changed = true
|
||||
for (const incomingTransition of incoming) {
|
||||
skipped.add(incomingTransition)
|
||||
for (const outgoingTransition of outgoing) {
|
||||
skipped.add(outgoingTransition)
|
||||
collapsed.push({
|
||||
from: incomingTransition.from,
|
||||
to: outgoingTransition.to,
|
||||
label: composeTransitionLabel(incomingTransition, outgoingTransition),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
transitions: [...transitions.filter((transition) => !skipped.has(transition)), ...collapsed],
|
||||
changed,
|
||||
}
|
||||
}
|
||||
|
||||
function collapseHiddenCompositeMarkerTransitions(diagram: StateDiagram): StateVisibleTransition[] {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
let transitions: StateVisibleTransition[] = diagram.transitions.map((transition) => ({ ...transition }))
|
||||
|
||||
while (true) {
|
||||
const result = collapseHiddenCompositeMarkerTransitionsOnce(transitions, statesById)
|
||||
transitions = result.transitions
|
||||
if (!result.changed) return transitions
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareVisibleStateDiagram(diagram: StateDiagram): StateVisibleDiagram {
|
||||
const transitions = collapseHiddenCompositeMarkerTransitions(diagram)
|
||||
const referencedHiddenMarkers = new Set<string>()
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
for (const transition of transitions) {
|
||||
const from = statesById.get(transition.from)
|
||||
const to = statesById.get(transition.to)
|
||||
if (from && isHiddenCompositeMarker(from)) referencedHiddenMarkers.add(from.id)
|
||||
if (to && isHiddenCompositeMarker(to)) referencedHiddenMarkers.add(to.id)
|
||||
}
|
||||
|
||||
return {
|
||||
...diagram,
|
||||
states: diagram.states.filter((state) => !isHiddenCompositeMarker(state) || referencedHiddenMarkers.has(state.id)),
|
||||
transitions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
|
||||
import { parseMermaidSequenceDiagram } from "../sequence/parser.js"
|
||||
import { parseMermaidStateDiagram } from "../state/parser.js"
|
||||
import { renderSequenceDiagram } from "../sequence/diagram.js"
|
||||
|
||||
describe("parser diagnostics", () => {
|
||||
test("ignores flowchart presentation directives that do not change terminal structure", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
classDef highlight fill:#fff
|
||||
class A highlight
|
||||
style B fill:#000
|
||||
linkStyle 0 stroke:#fff`)
|
||||
|
||||
expect(diagram.nodes.map((node) => node.id)).toEqual(["A", "B"])
|
||||
expect(diagram.edges).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reports unsupported structural flowchart statements with source location", () => {
|
||||
expect(() =>
|
||||
parseMermaidFlowchartDiagram(`flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
A --o B`),
|
||||
).toThrow('Unsupported syntax in flowchart diagram at line 3: "A --o B"')
|
||||
})
|
||||
|
||||
test("exposes structured syntax errors through top-level rendering", () => {
|
||||
try {
|
||||
renderSequenceDiagram(`sequenceDiagram
|
||||
A->>B: request
|
||||
opt retry`)
|
||||
throw new Error("expected render to reject unsupported syntax")
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(MermaidSyntaxError)
|
||||
if (!(error instanceof MermaidSyntaxError)) return
|
||||
expect(error.kind).toBe("sequence")
|
||||
expect(error.lineNumber).toBe(3)
|
||||
expect(error.sourceLine).toBe("opt retry")
|
||||
}
|
||||
})
|
||||
|
||||
test("reports unclosed state constructs at their opening line", () => {
|
||||
expect(() =>
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
state Running {
|
||||
[*] --> Ready`),
|
||||
).toThrow('Unclosed composite state; expected "}" in state diagram at line 2: "state Running {"')
|
||||
})
|
||||
|
||||
test("reports unsupported state statements", () => {
|
||||
expect(() => parseMermaidStateDiagram(`stateDiagram-v2\n hide empty description`)).toThrow(
|
||||
'Unsupported syntax in state diagram at line 2: "hide empty description"',
|
||||
)
|
||||
})
|
||||
|
||||
test("reports malformed sequence block endings", () => {
|
||||
expect(() =>
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
end`),
|
||||
).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"')
|
||||
})
|
||||
|
||||
test("does not attach else through an unclosed nested sequence block", () => {
|
||||
expect(() =>
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
alt available
|
||||
loop retry
|
||||
else fallback`),
|
||||
).toThrow('Unexpected "else" without an open "alt" block in sequence diagram at line 4: "else fallback"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect } from "bun:test"
|
||||
|
||||
function normalizeDiagram(value: string): string {
|
||||
const lines = value.replace(/\r\n/g, "\n").split("\n")
|
||||
while (lines[0]?.trim() === "") lines.shift()
|
||||
while (lines[lines.length - 1]?.trim() === "") lines.pop()
|
||||
const indentation = lines
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.reduce((min, line) => Math.min(min, line.match(/^\s*/)?.[0].length ?? 0), Number.POSITIVE_INFINITY)
|
||||
const trimBy = Number.isFinite(indentation) ? indentation : 0
|
||||
return lines.map((line) => line.slice(trimBy)).join("\n")
|
||||
}
|
||||
|
||||
export function expectDiagram(value: string): {
|
||||
toEqualDiagram(expected: string): void
|
||||
toContainInOrder(...needles: string[]): void
|
||||
} {
|
||||
return {
|
||||
toEqualDiagram(expected) {
|
||||
expect(value).toBe(normalizeDiagram(expected))
|
||||
},
|
||||
toContainInOrder(...needles) {
|
||||
let offset = -1
|
||||
for (const needle of needles) {
|
||||
const searchSpace = value.slice(offset + 1)
|
||||
expect(searchSpace).toContain(needle)
|
||||
const nextOffset = value.indexOf(needle, offset + 1)
|
||||
offset = nextOffset
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TreeSitterClient } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { createMermaidMarkdownRenderer } from "../markdown.js"
|
||||
|
||||
const syntaxStyle = SyntaxStyle.fromStyles({
|
||||
default: { fg: RGBA.fromValues(1, 1, 1, 1) },
|
||||
})
|
||||
let treeSitterClient: TreeSitterClient
|
||||
let renderer: Awaited<ReturnType<typeof createTestRenderer>>["renderer"] | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
const dataPath = join(tmpdir(), "merman-markdown-test-data")
|
||||
await mkdir(dataPath, { recursive: true })
|
||||
treeSitterClient = new TreeSitterClient({ dataPath })
|
||||
await treeSitterClient.initialize()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await treeSitterClient.destroy()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
renderer?.destroy()
|
||||
renderer = undefined
|
||||
})
|
||||
|
||||
async function renderMarkdown(
|
||||
markdown: MarkdownRenderable,
|
||||
renderOnce: () => Promise<void>,
|
||||
timeoutMs = 2_000,
|
||||
): Promise<void> {
|
||||
const hasPendingHighlights = (): boolean => {
|
||||
const children = [...markdown.getChildren()]
|
||||
while (children.length > 0) {
|
||||
const child = children.pop()!
|
||||
if (child instanceof CodeRenderable && child.isHighlighting) return true
|
||||
children.push(...child.getChildren())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
await renderOnce()
|
||||
while (hasPendingHighlights() && Date.now() - startedAt < timeoutMs) {
|
||||
await Bun.sleep(10)
|
||||
await renderOnce()
|
||||
}
|
||||
if (hasPendingHighlights()) throw new Error("Timed out waiting for Markdown highlights")
|
||||
await renderOnce()
|
||||
}
|
||||
|
||||
test("renders a Mermaid flowchart fence inside MarkdownRenderable", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-mermaid",
|
||||
content: `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
const frame = captureCharFrame()
|
||||
expect(frame).toContain("Start")
|
||||
expect(frame).toContain("Done")
|
||||
expect(frame).not.toContain("flowchart LR")
|
||||
expect(markdown.getChildren()[0]?.marginTop).toBe(1)
|
||||
})
|
||||
|
||||
test("recognizes normalized Mermaid fence info strings", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-normalized-mermaid",
|
||||
content: `\`\`\`MERMAID title=checkout
|
||||
flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
const frame = captureCharFrame()
|
||||
expect(frame).toContain("Start")
|
||||
expect(frame).not.toContain("flowchart LR")
|
||||
})
|
||||
|
||||
test("keeps surrounding Markdown content visible around a Mermaid diagram", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-with-mermaid",
|
||||
content: `Before
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
\`\`\`
|
||||
|
||||
After`,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
const frame = captureCharFrame()
|
||||
expect(frame).toContain("Before")
|
||||
expect(frame).toContain("Start")
|
||||
expect(frame).toContain("Done")
|
||||
expect(frame).toContain("After")
|
||||
})
|
||||
|
||||
test("renders an incomplete Mermaid fence as ordinary code", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 10 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-incomplete-mermaid",
|
||||
content: `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A -->
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
expect(captureCharFrame()).toContain("flowchart LR")
|
||||
})
|
||||
|
||||
test("keeps the last valid Mermaid diagram while a fence is streaming", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 12 })
|
||||
renderer = testRenderer.renderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-streaming-mermaid",
|
||||
content: `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Stable] --> B[Previous]
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
streaming: true,
|
||||
internalBlockMode: "top-level",
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
expect(testRenderer.captureCharFrame()).toContain("Previous")
|
||||
|
||||
markdown.content = `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Stable] --> B[Previous]
|
||||
B -->
|
||||
\`\`\``
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
expect(testRenderer.captureCharFrame()).toContain("Previous")
|
||||
expect(testRenderer.captureCharFrame()).not.toContain("flowchart LR")
|
||||
|
||||
markdown.content = `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Stable] --> B[Previous]
|
||||
B --> C[Current]
|
||||
\`\`\``
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
expect(testRenderer.captureCharFrame()).toContain("Current")
|
||||
})
|
||||
|
||||
test("renders a Mermaid sequence fence inside MarkdownRenderable", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-sequence",
|
||||
content: `\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
Alice->>Bob: Hello
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
const frame = captureCharFrame()
|
||||
expect(frame).toContain("Alice")
|
||||
expect(frame).toContain("Bob")
|
||||
expect(frame).not.toContain("sequenceDiagram")
|
||||
})
|
||||
|
||||
test("wraps wide Mermaid diagrams in a horizontal viewport", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 40, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-wide-sequence",
|
||||
content: `\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
participant A as Alpha participant
|
||||
participant B as Beta participant
|
||||
participant C as Gamma participant
|
||||
A->>B: first
|
||||
B->>C: second
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer, {
|
||||
compact: true,
|
||||
colors: { primary: "#ff0000" },
|
||||
}),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const diagram = markdown.getChildren()[0] as CodeRenderable
|
||||
expect(diagram.scrollWidth).toBeGreaterThan(diagram.width)
|
||||
expect(diagram.scrollX).toBe(0)
|
||||
|
||||
await testRenderer.mockMouse.drag(diagram.x + 20, diagram.y + 2, diagram.x + 5, diagram.y + 2)
|
||||
await testRenderer.renderOnce()
|
||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||
expect(diagram.hasSelection()).toBe(false)
|
||||
|
||||
diagram.scrollX = 0
|
||||
await testRenderer.mockMouse.scroll(diagram.x + 20, diagram.y + 2, "right")
|
||||
await testRenderer.renderOnce()
|
||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-state",
|
||||
content: `\`\`\`mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Idle
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
const frame = captureCharFrame()
|
||||
expect(frame).toContain("Idle")
|
||||
expect(frame).not.toContain("stateDiagram-v2")
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/client"
|
||||
import type { ResolvedTheme } from "@opencode-ai/theme/tui"
|
||||
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { CliRenderer, KeyEvent, MarkdownCodeBlockRenderer, Renderable } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import type { Store } from "solid-js/store"
|
||||
|
||||
@@ -406,6 +406,9 @@ export interface Context {
|
||||
readonly data: Data
|
||||
readonly attention: Attention
|
||||
readonly theme: ResolvedTheme
|
||||
readonly markdown: {
|
||||
registerCodeBlockRenderer(language: string, render: MarkdownCodeBlockRenderer): () => void
|
||||
}
|
||||
readonly keymap: Keymap
|
||||
readonly storage: Storage
|
||||
readonly ui: UI
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"modal": "0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -28,6 +28,7 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
@@ -43,6 +44,7 @@ import { formLocationLayer } from "./middleware/form-location"
|
||||
import { sessionLocationLayer } from "./middleware/session-location"
|
||||
import { ServerInfo } from "./server-info"
|
||||
import type { ServerOptions } from "./options"
|
||||
import { modalWorkspaceDriver, provider as modalProvider } from "./workspace/modal-workspace"
|
||||
|
||||
const applicationServices = LayerNode.group([
|
||||
Database.node,
|
||||
@@ -115,6 +117,10 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
||||
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
|
||||
[
|
||||
WorkspaceDriver.node,
|
||||
WorkspaceDriver.registryNode({ [modalProvider]: modalWorkspaceDriver({ app: "opencode-workspaces" }) }),
|
||||
],
|
||||
]
|
||||
const serviceLayer = options.simulation
|
||||
? Layer.unwrap(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { App, Image, ModalClient, ModalClientParams, Sandbox } from "modal"
|
||||
import { createModalSandboxWithClient, makeModalDriver, type ModalImageSpec, openModalClient } from "./modal"
|
||||
|
||||
export const provider = "modal"
|
||||
|
||||
export const ModalBinding = Schema.Struct({
|
||||
sandboxId: Schema.optional(Schema.String),
|
||||
snapshotImageId: Schema.optional(Schema.String),
|
||||
})
|
||||
export type ModalBinding = typeof ModalBinding.Type
|
||||
|
||||
export interface ModalWorkspaceOptions {
|
||||
readonly app: string
|
||||
readonly client?: ModalClientParams
|
||||
readonly image?: ModalImageSpec
|
||||
}
|
||||
|
||||
export const modalWorkspaceDriver = (options: ModalWorkspaceOptions): WorkspaceDriver.Interface => {
|
||||
const name = (workspaceID: string) => `ws-${workspaceID}`
|
||||
const decodeBinding = Schema.decodeUnknownOption(ModalBinding)
|
||||
let clientPromise: Promise<ModalClient> | undefined
|
||||
let appPromise: Promise<App> | undefined
|
||||
// The SDK client and app handle are shared for the process lifetime of this driver.
|
||||
const client = () => (clientPromise ??= openModalClient(options.client))
|
||||
const app = () =>
|
||||
(appPromise ??= client().then((value) => value.apps.fromName(options.app, { createIfMissing: true })))
|
||||
|
||||
const attempt = <A>(run: () => Promise<A>) =>
|
||||
Effect.tryPromise({ try: run, catch: (cause) => new WorkspaceDriver.Error({ cause }) })
|
||||
|
||||
const binding = (value: WorkspaceDriver.Binding): ModalBinding => Option.getOrElse(decodeBinding(value), () => ({}))
|
||||
|
||||
const live = async (lookup: () => Promise<Sandbox>) => {
|
||||
const { NotFoundError } = await import("modal")
|
||||
const sandbox = await lookup().catch((error) => {
|
||||
if (error instanceof NotFoundError) return undefined
|
||||
throw error
|
||||
})
|
||||
if (sandbox && (await sandbox.poll()) === null) return sandbox
|
||||
}
|
||||
|
||||
const findLive = async (modalClient: ModalClient, value: ModalBinding, workspaceID: string) => {
|
||||
if (value.sandboxId) {
|
||||
const sandboxID = value.sandboxId
|
||||
const sandbox = await live(() => modalClient.sandboxes.fromId(sandboxID))
|
||||
if (sandbox) return sandbox
|
||||
}
|
||||
// Name fallback is valid only before the first snapshot; afterward a live named sandbox is stale by design.
|
||||
if (value.snapshotImageId) return
|
||||
return live(() => modalClient.sandboxes.fromName(options.app, name(workspaceID)))
|
||||
}
|
||||
|
||||
const createSandbox = async (workspaceID: string, image?: Image) => {
|
||||
const { AlreadyExistsError } = await import("modal")
|
||||
const modalClient = await client()
|
||||
return createModalSandboxWithClient(
|
||||
modalClient,
|
||||
await app(),
|
||||
{
|
||||
image: options.image,
|
||||
sandbox: {
|
||||
name: name(workspaceID),
|
||||
tags: { workspace: workspaceID },
|
||||
timeoutMs: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
image,
|
||||
).catch((error) => {
|
||||
if (error instanceof AlreadyExistsError) return modalClient.sandboxes.fromName(options.app, name(workspaceID))
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
const deleteImage = (modalClient: ModalClient, imageID?: string) =>
|
||||
imageID ? attempt(() => modalClient.images.delete(imageID)).pipe(Effect.ignore) : Effect.void
|
||||
|
||||
const terminate = (sandbox?: Sandbox) =>
|
||||
sandbox ? attempt(() => sandbox.terminate({ wait: true })).pipe(Effect.ignore) : Effect.void
|
||||
|
||||
return WorkspaceDriver.make({
|
||||
create: ({ workspaceID }) =>
|
||||
attempt(async () => {
|
||||
const sandbox = await createSandbox(workspaceID)
|
||||
return { binding: { sandboxId: sandbox.sandboxId } }
|
||||
}),
|
||||
connect: ({ workspaceID, binding: value, saveBinding }) =>
|
||||
Effect.gen(function* () {
|
||||
const modalBinding = binding(value)
|
||||
const modalClient = yield* attempt(client)
|
||||
const sandbox = yield* attempt(async () => {
|
||||
const existing = await findLive(modalClient, modalBinding, workspaceID)
|
||||
const image =
|
||||
existing || !modalBinding.snapshotImageId
|
||||
? undefined
|
||||
: await modalClient.images.fromId(modalBinding.snapshotImageId)
|
||||
return existing ?? createSandbox(workspaceID, image)
|
||||
})
|
||||
if (modalBinding.sandboxId !== sandbox.sandboxId) {
|
||||
yield* saveBinding({ ...modalBinding, sandboxId: sandbox.sandboxId })
|
||||
}
|
||||
return makeModalDriver(sandbox)
|
||||
}),
|
||||
suspendForIdle: ({ workspaceID, binding: value, saveBinding }) =>
|
||||
Effect.gen(function* () {
|
||||
const modalBinding = binding(value)
|
||||
const modalClient = yield* attempt(client)
|
||||
const sandbox = yield* attempt(() => findLive(modalClient, modalBinding, workspaceID))
|
||||
if (!sandbox) return
|
||||
const snapshot = yield* attempt(() => sandbox.snapshotFilesystem({ ttlMs: null }))
|
||||
yield* saveBinding({ snapshotImageId: snapshot.imageId })
|
||||
yield* Effect.all([deleteImage(modalClient, modalBinding.snapshotImageId), terminate(sandbox)], {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
}),
|
||||
destroy: ({ workspaceID, binding: value }) =>
|
||||
Effect.gen(function* () {
|
||||
const modalBinding = binding(value)
|
||||
const modalClient = yield* attempt(client)
|
||||
const sandbox = yield* attempt(() => findLive(modalClient, modalBinding, workspaceID))
|
||||
yield* Effect.all([terminate(sandbox), deleteImage(modalClient, modalBinding.snapshotImageId)], {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Effect, Sink, Stream } from "effect"
|
||||
import { systemError } from "effect/PlatformError"
|
||||
import type { Command, KillOptions } from "effect/unstable/process/ChildProcess"
|
||||
import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Driver } from "@opencode-ai/core/environment"
|
||||
import type { App, Image, ModalClient, ModalClientParams, Sandbox, SandboxCreateParams } from "modal"
|
||||
|
||||
const INNER_WRAPPER = `
|
||||
pidfile=$1
|
||||
shift
|
||||
printf "%s" "$$" > "$pidfile"
|
||||
trap 'rm -f -- "$pidfile"' EXIT
|
||||
"$@"
|
||||
`
|
||||
|
||||
// Modal's VM runtime accepts process-group signals without delivering them
|
||||
// (kill(-pgid) returns 0 and nothing dies; direct-pid signals work), so the
|
||||
// group is enumerated from /proc and each member is signalled directly. The
|
||||
// second pass catches children forked between scan and signal.
|
||||
const KILL = `
|
||||
pidfile=$1
|
||||
sig=$2
|
||||
i=0
|
||||
while [ ! -s "$pidfile" ] && [ "$i" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done
|
||||
[ -s "$pidfile" ] || exit 47
|
||||
target=$(cat "$pidfile")
|
||||
pass=0
|
||||
while [ "$pass" -lt 2 ]; do
|
||||
for stat in /proc/[0-9]*/stat; do
|
||||
[ -e "$stat" ] || continue
|
||||
pid=\${stat#/proc/}
|
||||
pid=\${pid%/stat}
|
||||
set -- $(sed "s/.*) //" "$stat" 2>/dev/null)
|
||||
if [ "\${3:-}" = "$target" ]; then
|
||||
/bin/kill "-$sig" "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
pass=$((pass + 1))
|
||||
done
|
||||
`
|
||||
|
||||
export interface ModalImageSpec {
|
||||
readonly registry: string
|
||||
readonly dockerfileCommands: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface ModalSandboxCreateOptions {
|
||||
readonly image?: ModalImageSpec
|
||||
readonly sandbox?: SandboxCreateParams
|
||||
}
|
||||
|
||||
export interface ModalSandboxOptions extends ModalSandboxCreateOptions {
|
||||
readonly app: string
|
||||
readonly client?: ModalClientParams
|
||||
}
|
||||
|
||||
/**
|
||||
* Ubuntu supplies the GNU coreutils and findutils required by the derived Files
|
||||
* scripts. Busybox images do not satisfy the Environment contract.
|
||||
*/
|
||||
export const ubuntuImage: ModalImageSpec = {
|
||||
registry: "ubuntu:24.04",
|
||||
dockerfileCommands: [
|
||||
"RUN apt-get update && apt-get install -y --no-install-recommends git bash ripgrep ca-certificates coreutils findutils util-linux",
|
||||
],
|
||||
}
|
||||
|
||||
/** Creates a Modal sandbox lazily, keeping the SDK off the server startup path when Modal is unused. */
|
||||
export const createModalSandbox = async (options: ModalSandboxOptions) => {
|
||||
const client = await openModalClient(options.client)
|
||||
const app = await client.apps.fromName(options.app, { createIfMissing: true })
|
||||
const sandbox = await createModalSandboxWithClient(client, app, {
|
||||
image: options.image,
|
||||
sandbox: options.sandbox,
|
||||
})
|
||||
return {
|
||||
driver: makeModalDriver(sandbox),
|
||||
sandbox,
|
||||
terminate: () => sandbox.terminate(),
|
||||
}
|
||||
}
|
||||
|
||||
export const openModalClient = async (params?: ModalClientParams) => {
|
||||
const { ModalClient } = await import("modal")
|
||||
return new ModalClient(params)
|
||||
}
|
||||
|
||||
export const createModalSandboxWithClient = async (
|
||||
client: ModalClient,
|
||||
app: App,
|
||||
options: ModalSandboxCreateOptions,
|
||||
existingImage?: Image,
|
||||
) => {
|
||||
const imageSpec = options.image ?? ubuntuImage
|
||||
const image =
|
||||
existingImage ??
|
||||
client.images.fromRegistry(imageSpec.registry).dockerfileCommands([...imageSpec.dockerfileCommands])
|
||||
// Always Modal's Full-VM runtime (beta, enabled per account): a real kernel
|
||||
// with real device nodes, so workspaces can run Docker and other
|
||||
// kernel-dependent workloads. Costs versus gVisor, measured Aug 2026:
|
||||
// per-exec floor ~285-535ms versus ~90-165ms, and filesystem snapshots only
|
||||
// (no memory snapshots — acceptable; fs-snapshot is the persistence design).
|
||||
return client.sandboxes.create(app, image, {
|
||||
...options.sandbox,
|
||||
experimentalOptions: { ...options.sandbox?.experimentalOptions, vm_runtime: true },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts Modal exec to the Environment driver. Files intentionally has no native
|
||||
* overrides: exec latency dominates payload work (VM runtime floor measured
|
||||
* ~285-535ms per exec, Aug 2026), so the derived exec defaults are the simplest
|
||||
* implementation with no measured loss.
|
||||
*
|
||||
* Modal cannot signal a ContainerProcess. Each command therefore starts a new
|
||||
* process group and records its leader in a unique pid file; kill runs a second
|
||||
* sandbox command that enumerates that group from /proc and signals each member
|
||||
* directly (see KILL). Pid files are removed best-effort.
|
||||
*/
|
||||
export const makeModalDriver = (sandbox: Sandbox): Driver => {
|
||||
const spawn = Effect.fnUntraced(function* (command: Command) {
|
||||
if (command._tag === "PipedCommand") {
|
||||
return yield* Effect.fail(spawnError("spawn", "piped commands unsupported"))
|
||||
}
|
||||
if (command.options.additionalFds) {
|
||||
return yield* Effect.fail(spawnError("spawn", "additional file descriptors unsupported"))
|
||||
}
|
||||
|
||||
const pidFile = `/tmp/opencode-process-${crypto.randomUUID()}.pid`
|
||||
const env = compact(command.options.env)
|
||||
const isolatedEnv =
|
||||
command.options.extendEnv === false || (!command.options.extendEnv && command.options.env !== undefined)
|
||||
const argv = isolatedEnv ? ["env", "-i", ...Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`)] : []
|
||||
const process = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
sandbox.exec(
|
||||
["setsid", "--wait", "sh", "-c", INNER_WRAPPER, "sh", pidFile, ...argv, command.command, ...command.args],
|
||||
{
|
||||
mode: "binary",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
workdir: command.options.cwd,
|
||||
env: isolatedEnv ? undefined : env,
|
||||
},
|
||||
),
|
||||
catch: (cause) => spawnError("spawn", undefined, cause),
|
||||
})
|
||||
|
||||
const onError = (cause: unknown) => spawnError("process", undefined, cause)
|
||||
let exited = false
|
||||
const writer = process.stdin.getWriter()
|
||||
let closingStdin: Promise<void> | undefined
|
||||
const waited = process.wait().then((code) => {
|
||||
exited = true
|
||||
if (!closingStdin) writer.releaseLock()
|
||||
return code
|
||||
})
|
||||
const exitCode = Effect.tryPromise({ try: () => waited, catch: onError }).pipe(Effect.map(ExitCode))
|
||||
const kill = (options?: KillOptions) => {
|
||||
if (exited) return Effect.void
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const killer = await sandbox.exec(["sh", "-c", KILL, "sh", pidFile, options?.killSignal ?? "SIGTERM"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const code = await killer.wait()
|
||||
if (code !== 0) throw new Error(`modal kill exited ${code}`)
|
||||
},
|
||||
catch: onError,
|
||||
}).pipe(Effect.andThen(exitCode), Effect.asVoid)
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() => kill(command.options).pipe(Effect.ignore))
|
||||
|
||||
const closeStdin = Effect.tryPromise({
|
||||
try: () => (closingStdin ??= writer.close().finally(() => writer.releaseLock())),
|
||||
catch: onError,
|
||||
})
|
||||
const writeStdin = Sink.forEach((chunk: Uint8Array) =>
|
||||
Effect.tryPromise({ try: () => writer.write(chunk), catch: onError }),
|
||||
)
|
||||
const inputConfig = command.options.stdin
|
||||
const inputOptions =
|
||||
inputConfig !== undefined && typeof inputConfig === "object" && !Stream.isStream(inputConfig)
|
||||
? inputConfig
|
||||
: undefined
|
||||
const input = inputOptions?.stream ?? inputConfig
|
||||
const stdin = inputOptions?.endOnDone === false ? writeStdin : writeStdin.pipe(Sink.ensuring(closeStdin))
|
||||
if (input === "ignore") {
|
||||
yield* closeStdin
|
||||
}
|
||||
if (Stream.isStream(input)) {
|
||||
yield* Effect.forkScoped(Stream.run(input, stdin))
|
||||
}
|
||||
|
||||
const stdout = Stream.fromReadableStream({ evaluate: () => process.stdout, onError })
|
||||
const stderr = Stream.fromReadableStream({ evaluate: () => process.stderr, onError })
|
||||
return makeHandle({
|
||||
pid: ProcessId(crypto.getRandomValues(new Uint32Array(1))[0]),
|
||||
exitCode,
|
||||
isRunning: Effect.sync(() => !exited),
|
||||
kill,
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
all: Stream.merge(stdout, stderr),
|
||||
getInputFd: () => Sink.fail(spawnError("getInputFd", "unsupported")),
|
||||
getOutputFd: () => Stream.fail(spawnError("getOutputFd", "unsupported")),
|
||||
unref: Effect.succeed(Effect.void),
|
||||
})
|
||||
})
|
||||
|
||||
return { spawner: make(spawn) }
|
||||
}
|
||||
|
||||
const compact = (env: Record<string, string | undefined> | undefined) => {
|
||||
if (!env) return undefined
|
||||
return Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])))
|
||||
}
|
||||
|
||||
const spawnError = (method: string, description?: string, cause?: unknown) =>
|
||||
systemError({ _tag: "Unknown", module: "ModalDriver", method, description, cause })
|
||||
|
||||
export * as ModalDriver from "./modal"
|
||||
@@ -0,0 +1,67 @@
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Failed, makeFiles } from "@opencode-ai/core/environment"
|
||||
import { environmentConformance } from "@opencode-ai/core/testing/environment-conformance"
|
||||
import { createModalSandbox } from "../src/workspace/modal"
|
||||
|
||||
const enabled =
|
||||
!!process.env.OPENCODE_TEST_MODAL &&
|
||||
((!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) ||
|
||||
fs.existsSync(path.join(os.homedir(), ".modal.toml")))
|
||||
const modalTest = enabled ? test : test.skip
|
||||
const root = `/tmp/opencode-environment-${crypto.randomUUID()}`
|
||||
const sandbox = enabled
|
||||
? createModalSandbox({
|
||||
app: "opencode-environment-tests",
|
||||
sandbox: { timeoutMs: 10 * 60 * 1000 },
|
||||
})
|
||||
: undefined
|
||||
|
||||
modalTest(
|
||||
"kills a running modal process",
|
||||
async () => {
|
||||
const value = await sandbox!
|
||||
const started = performance.now()
|
||||
const exitCode = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* value.driver.spawner.spawn(ChildProcess.make("sleep", ["300"]))
|
||||
yield* handle.kill()
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
).pipe(Effect.timeout("10 seconds")),
|
||||
)
|
||||
expect(exitCode).not.toBe(0)
|
||||
expect(performance.now() - started).toBeLessThan(10_000)
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
environmentConformance(
|
||||
"modal environment",
|
||||
() =>
|
||||
Effect.promise(async () => {
|
||||
const value = await sandbox!
|
||||
return {
|
||||
files: makeFiles(value.driver),
|
||||
root,
|
||||
symlink: (target, link) =>
|
||||
Effect.scoped(value.driver.spawner.exitCode(ChildProcess.make("ln", ["-s", "--", target, link]))).pipe(
|
||||
Effect.flatMap((code) =>
|
||||
code === 0 ? Effect.void : Effect.fail(new Failed({ path: link, cause: new Error(`ln exited ${code}`) })),
|
||||
),
|
||||
Effect.mapError((cause) => (cause instanceof Failed ? cause : new Failed({ path: link, cause }))),
|
||||
),
|
||||
}
|
||||
}),
|
||||
!enabled,
|
||||
)
|
||||
|
||||
afterAll(async () => {
|
||||
if (!sandbox) return
|
||||
await (await sandbox).terminate()
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { expect, test } from "bun:test"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeFiles } from "@opencode-ai/core/environment"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { modalWorkspaceDriver, provider } from "../src/workspace/modal-workspace"
|
||||
|
||||
const enabled =
|
||||
!!process.env.OPENCODE_TEST_MODAL &&
|
||||
((!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) ||
|
||||
fs.existsSync(path.join(os.homedir(), ".modal.toml")))
|
||||
|
||||
const testLayer = Layer.provideMerge(
|
||||
AppNodeBuilder.build(Workspace.configured({ idleThreshold: "1 minute", pollInterval: "1 minute" }), [
|
||||
[
|
||||
WorkspaceDriver.node,
|
||||
WorkspaceDriver.registryNode({ [provider]: modalWorkspaceDriver({ app: "opencode-workspace-tests" }) }),
|
||||
],
|
||||
]),
|
||||
TestClock.layer(),
|
||||
)
|
||||
const modalTest = enabled ? test : test.skip
|
||||
|
||||
modalTest(
|
||||
"wakes a workspace from its filesystem snapshot",
|
||||
() =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
yield* Effect.acquireUseRelease(
|
||||
workspace.create(provider),
|
||||
(created) =>
|
||||
Effect.gen(function* () {
|
||||
const environment = yield* workspace.connect(created.id)
|
||||
const files = makeFiles(environment)
|
||||
const file = `/tmp/opencode-workspace-${crypto.randomUUID()}.txt`
|
||||
yield* files.write(file, new TextEncoder().encode("survived snapshot"))
|
||||
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
|
||||
const restored = yield* files.read(file)
|
||||
expect(new TextDecoder().decode(restored.bytes)).toBe("survived snapshot")
|
||||
}),
|
||||
(created) => workspace.destroy(created.id).pipe(Effect.ignore),
|
||||
)
|
||||
}).pipe(Effect.scoped, Effect.provide(testLayer)),
|
||||
),
|
||||
180_000,
|
||||
)
|
||||
@@ -82,6 +82,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/merman": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
|
||||
@@ -156,6 +156,7 @@ const appBindingCommands = [
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"diff.open",
|
||||
"app.debug",
|
||||
"app.console",
|
||||
"terminal.suspend",
|
||||
"terminal.title.toggle",
|
||||
@@ -957,6 +958,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
run: () => exit(),
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "app.debug",
|
||||
title: "Toggle debug panel",
|
||||
category: "System",
|
||||
run: () => {
|
||||
renderer.toggleDebugOverlay()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.console",
|
||||
title: "Toggle console",
|
||||
@@ -1144,15 +1154,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
}
|
||||
})
|
||||
|
||||
event.on("session.execution.failed", (evt, { workspace }) => {
|
||||
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message: evt.data.error.message,
|
||||
duration: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
// Suppress the full-screen overlay for transient startup and event-stream retry states.
|
||||
// Initial connection gets a longer grace period; retries surface more quickly.
|
||||
const [showReconnecting, setShowReconnecting] = createSignal(false)
|
||||
|
||||
@@ -41,7 +41,6 @@ export function DevToolsBar() {
|
||||
const [dumping, setDumping] = createSignal(false)
|
||||
const [dumpPath, setDumpPath] = createSignal<string>()
|
||||
const [dumpError, setDumpError] = createSignal<string>()
|
||||
const [debugOverlay, setDebugOverlay] = createSignal(Boolean(renderer.debugOverlay.enabled))
|
||||
const [frontendSamples, setFrontendSamples] = createSignal<readonly ProcessSample[]>([])
|
||||
let focus: Renderable | null
|
||||
const connected = createMemo(() => client.connection.status() === "connected")
|
||||
@@ -392,15 +391,6 @@ export function DevToolsBar() {
|
||||
>
|
||||
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
|
||||
</Action>
|
||||
<Action
|
||||
onClick={() => {
|
||||
renderer.toggleDebugOverlay()
|
||||
setDebugOverlay(Boolean(renderer.debugOverlay.enabled))
|
||||
}}
|
||||
hoverBackground
|
||||
>
|
||||
{debugOverlay() ? "[x]" : "[ ]"} Renderer debug overlay
|
||||
</Action>
|
||||
</box>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
|
||||
@@ -51,6 +51,17 @@ export function DialogOpen() {
|
||||
.catch(() => [] as SessionInfo[]),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const [matched] = createResource(
|
||||
() => {
|
||||
const value = filter().trim()
|
||||
return /^ses_[0-9A-Za-z]{26}$/.test(value) ? value : undefined
|
||||
},
|
||||
(sessionID) =>
|
||||
client.api.session
|
||||
.get({ sessionID })
|
||||
.then((session) => (session.id === sessionID ? session : undefined))
|
||||
.catch(() => undefined),
|
||||
)
|
||||
|
||||
const openTabs = createMemo(
|
||||
() => new Set(sessionTabs.enabled() ? sessionTabs.tabs().map((tab) => tab.sessionID) : []),
|
||||
@@ -60,7 +71,8 @@ export function DialogOpen() {
|
||||
)
|
||||
const sessions = createMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return [...data.session.list(), ...fetched()]
|
||||
const match = matched()
|
||||
return [...data.session.list(), ...fetched(), ...(match ? [match] : [])]
|
||||
.filter((session) => {
|
||||
if (session.parentID || seen.has(session.id)) return false
|
||||
seen.add(session.id)
|
||||
@@ -87,6 +99,7 @@ export function DialogOpen() {
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
return {
|
||||
title: withTimestampedFallback(session),
|
||||
searchText: session.id,
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Sessions",
|
||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
|
||||
@@ -141,7 +141,12 @@ export function DialogSessionList() {
|
||||
const option = (session: SessionInfo, category: string) => {
|
||||
const directory = session.location.directory
|
||||
const project = data.project.get(session.projectID)
|
||||
const footer = allProjects() ? Locale.truncate(projectName(project, directory) ?? "", 20) : undefined
|
||||
const relative = path.relative(project?.canonical ?? directory, directory)
|
||||
const footer = allProjects()
|
||||
? Locale.truncate(projectName(project, directory) ?? "", 20)
|
||||
: relative.startsWith("..") || path.isAbsolute(relative)
|
||||
? Locale.truncate(path.basename(directory), 20)
|
||||
: undefined
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
|
||||
@@ -46,6 +46,7 @@ export const Definitions = {
|
||||
leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
|
||||
|
||||
app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
|
||||
app_debug: keybind("none", "Toggle debug panel"),
|
||||
app_console: keybind("none", "Toggle console"),
|
||||
app_heap_snapshot: keybind("none", "Write heap snapshot"),
|
||||
app_toggle_animations: keybind("none", "Toggle animations"),
|
||||
@@ -250,6 +251,7 @@ export const Descriptions = Object.fromEntries(
|
||||
) as Record<KeybindName, string>
|
||||
export const CommandMap = {
|
||||
app_exit: "app.exit",
|
||||
app_debug: "app.debug",
|
||||
app_console: "app.console",
|
||||
app_heap_snapshot: "app.heap_snapshot",
|
||||
app_toggle_animations: "app.toggle.animations",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user