M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
Third-party notices — vendored crates under third_party/
|
||||
========================================================
|
||||
|
||||
This tree includes the following upstream projects, vendored in-source.
|
||||
Full license texts are next to each crate. Local modifications (if any) are
|
||||
documented in each crate's Cargo.toml header.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
mermaid-to-svg
|
||||
--------------------------------------------------------------------------------
|
||||
Path: third_party/mermaid-to-svg/
|
||||
License: MIT
|
||||
License file: third_party/mermaid-to-svg/LICENSE
|
||||
Upstream: https://github.com/warpdotdev/mermaid-to-svg
|
||||
Copyright: Copyright (c) 2025-2026 Denver Technologies, Inc. (see LICENSE)
|
||||
|
||||
Further ancestry (mermaid.js, dagre.js, and related) is listed in:
|
||||
third_party/mermaid-to-svg/THIRD_PARTY_NOTICES
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
dagre_rust
|
||||
--------------------------------------------------------------------------------
|
||||
Path: third_party/dagre_rust/
|
||||
License: Apache License 2.0
|
||||
License file: third_party/dagre_rust/LICENCE
|
||||
Upstream: https://github.com/r3alst/dagre-rust
|
||||
(also re-published via warpdotdev/mermaid-to-svg crates/dagre_rust)
|
||||
Version: 0.0.5
|
||||
crates.io: https://crates.io/crates/dagre_rust/0.0.5
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
graphlib_rust
|
||||
--------------------------------------------------------------------------------
|
||||
Path: third_party/graphlib_rust/
|
||||
License: Apache License 2.0
|
||||
License file: third_party/graphlib_rust/LICENCE
|
||||
Upstream: https://github.com/r3alst/graphlib-rust
|
||||
Version: 0.0.2
|
||||
crates.io: https://crates.io/crates/graphlib_rust/0.0.2
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
ordered_hashmap
|
||||
--------------------------------------------------------------------------------
|
||||
Path: third_party/ordered_hashmap/
|
||||
License: Apache License 2.0
|
||||
License file: third_party/ordered_hashmap/LICENCE
|
||||
Upstream: https://github.com/r3alst/ordered-hashmap
|
||||
Version: 0.0.3
|
||||
crates.io: https://crates.io/crates/ordered_hashmap/0.0.3
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
End of notices for vendored third_party/ crates.
|
||||
|
||||
Full product notices (all crates.io / git dependencies shipped with the Grok
|
||||
CLI, including MPL packages, Apache NOTICE files, libgit2 COPYING, and
|
||||
dual-license elections) live in:
|
||||
|
||||
THIRD-PARTY-NOTICES
|
||||
|
||||
Those entries cover the legal-requirements matrix (MIT, Apache-2.0, BSD-*,
|
||||
ISC, Zlib, Unicode-*, BSL-1.0, MPL-2.0, CDLA-Permissive-2.0, Unicode-DFS-2016,
|
||||
and the libgit2 GPL+linking-exception bundle). Dropped-from-release packages
|
||||
(CDDL inferno, EPL colored_json, WTFPL terminfo) are omitted there by design.
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
# Third-party vendored crates
|
||||
|
||||
This directory holds **upstream source** vendored into the repository. It is
|
||||
**not** first-party application code.
|
||||
|
||||
## Why vendor
|
||||
|
||||
These crates sit on the path that renders **untrusted model output** (diagram
|
||||
source → SVG). Vendoring gives a full audit surface, pins exact source, and
|
||||
avoids crates.io yanks. Local patches and upgrade checklists live in each
|
||||
crate’s `Cargo.toml` header comments — treat those as the source of truth when
|
||||
re-vendoring.
|
||||
|
||||
## Mermaid layout stack
|
||||
|
||||
| Crate | Version | License | Upstream | Full license text |
|
||||
|-------|---------|---------|----------|-------------------|
|
||||
| [`mermaid-to-svg`](./mermaid-to-svg/) | (path) | MIT | [warpdotdev/mermaid-to-svg](https://github.com/warpdotdev/mermaid-to-svg) | [`LICENSE`](./mermaid-to-svg/LICENSE) |
|
||||
| [`dagre_rust`](./dagre_rust/) | 0.0.5 | Apache-2.0 | [r3alst/dagre-rust](https://github.com/r3alst/dagre-rust) / Warp re-vendor | [`LICENCE`](./dagre_rust/LICENCE) |
|
||||
| [`graphlib_rust`](./graphlib_rust/) | 0.0.2 | Apache-2.0 | [r3alst/graphlib-rust](https://github.com/r3alst/graphlib-rust) | [`LICENCE`](./graphlib_rust/LICENCE) |
|
||||
| [`ordered_hashmap`](./ordered_hashmap/) | 0.0.3 | Apache-2.0 | [r3alst/ordered-hashmap](https://github.com/r3alst/ordered-hashmap) | [`LICENCE`](./ordered_hashmap/LICENCE) |
|
||||
|
||||
Dependency shape:
|
||||
|
||||
```text
|
||||
xai-grok-mermaid
|
||||
└── mermaid-to-svg (MIT)
|
||||
├── dagre_rust (Apache-2.0)
|
||||
│ ├── graphlib_rust
|
||||
│ └── ordered_hashmap
|
||||
└── graphlib_rust (Apache-2.0)
|
||||
└── ordered_hashmap
|
||||
```
|
||||
|
||||
## Notices and ancestry
|
||||
|
||||
- **[`NOTICE`](./NOTICE)** — short index of the crates above (names, licenses,
|
||||
upstream links, paths to full text). Prefer that file for a one-page overview.
|
||||
- **[`mermaid-to-svg/THIRD_PARTY_NOTICES`](./mermaid-to-svg/THIRD_PARTY_NOTICES)** —
|
||||
additional ancestry for the SVG engine (e.g. mermaid.js, dagre.js MIT notices).
|
||||
|
||||
British spelling **`LICENCE`** is intentional on the Apache crates (as upstream
|
||||
vendored); grepping only for `LICENSE` will miss them.
|
||||
|
||||
## crates.io dependencies
|
||||
|
||||
Normal Cargo dependencies (tokio, serde, …) are **not** under `third_party/`.
|
||||
They resolve via `Cargo.lock` / crates.io. Full attribution and license texts
|
||||
for the Grok CLI dependency closure are maintained in
|
||||
[`THIRD-PARTY-NOTICES`](../THIRD-PARTY-NOTICES).
|
||||
|
||||
This directory is only for **in-tree vendored** sources.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Read the `VENDORING NOTES` block at the top of the crate’s `Cargo.toml`.
|
||||
2. Re-apply listed local patches (fmt, hermetic env, unsafe fixes, dropped bins/tests).
|
||||
3. Confirm the license file still matches the declared `license =` field.
|
||||
4. Refresh [`NOTICE`](./NOTICE) if versions or upstream URLs change.
|
||||
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
# Vendored third-party crate.
|
||||
#
|
||||
# Upstream: dagre_rust 0.0.5 (Apache-2.0), as re-published in
|
||||
# warpdotdev/mermaid-to-svg `crates/dagre_rust` ("vendored from 0.0.5
|
||||
# with fixes" per upstream Cargo.toml).
|
||||
# Source of truth: https://github.com/warpdotdev/mermaid-to-svg (rev
|
||||
# 40cecf2be376e47e15053eadbfb782a531777420), crates/dagre_rust/.
|
||||
# Base crate: https://crates.io/crates/dagre_rust/0.0.5
|
||||
#
|
||||
# Why vendored: the Rust port of dagre.js (the same layout engine mermaid.js
|
||||
# uses). It is the layout core of the Warp `mermaid-to-svg` engine, which renders
|
||||
# untrusted model output, so vendoring gives a full audit surface and immunity to
|
||||
# upstream yanks. See third_party/mermaid-to-svg and crates/codegen/xai-grok-mermaid.
|
||||
#
|
||||
# ============================================================================
|
||||
# VENDORING NOTES — re-apply / re-bless ALL of the following on every upgrade:
|
||||
# ============================================================================
|
||||
# Local modifications to the upstream source:
|
||||
# 1. `cargo fmt` (rustfmt) applied so the crate satisfies the workspace fmt
|
||||
# gate; no semantic change. Otherwise the library source (`src/**`, incl. one
|
||||
# in-source unit test in `src/layout/util.rs`) is the `mermaid-to-svg`
|
||||
# in-repo copy; the `graphlib_rust` / `ordered_hashmap` deps are repointed to
|
||||
# the sibling vendored crates.
|
||||
# 2. UNSAFE REMOVAL (src/layout/util.rs): the upstream unique-id counter was a
|
||||
# `static mut UNIQUE_STARTER` incremented inside an `unsafe` block with no
|
||||
# synchronization — a data race whenever the engine renders on more than one
|
||||
# thread (e.g. the parallel `cargo test` suite). Replaced with a
|
||||
# `std::sync::atomic::AtomicUsize` (`fetch_add`); behaviour-preserving (still
|
||||
# hands out monotonic unique ids) and it removes the only `unsafe` here.
|
||||
#
|
||||
# Re-audit checklist:
|
||||
# - No `unsafe` remains (the `static mut` counter became an `AtomicUsize`; see
|
||||
# local modification 2), and no filesystem / env / network I/O in the source.
|
||||
[package]
|
||||
name = "dagre_rust"
|
||||
version = "0.0.5"
|
||||
edition = "2021"
|
||||
description = "Dagre layout in Rust (vendored, library-only)"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/r3alst/dagre-rust"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "dagre_rust"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
graphlib_rust = { path = "../graphlib_rust" }
|
||||
ordered_hashmap = { path = "../ordered_hashmap" }
|
||||
|
||||
# Clippy lints local/path crates (this vendored crate is one) under the
|
||||
# workspace's `-D warnings`. We do not restyle upstream third-party code, so
|
||||
# allow clippy here; our own crate (xai-grok-mermaid) is still fully linted.
|
||||
[lints.clippy]
|
||||
all = { level = "allow", priority = -1 }
|
||||
Vendored
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2023 Ameer Hamza
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
use crate::layout::util::unique_id;
|
||||
use crate::layout::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::{Edge, Graph};
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
|
||||
pub fn run(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let mut fas: Option<Vec<Edge>> = None;
|
||||
let graph_config = graph.graph();
|
||||
if graph_config.acyclicer.is_some()
|
||||
&& graph_config.acyclicer.clone().unwrap() == "greedy".to_string()
|
||||
{
|
||||
// TODO: need to implement this algorithm
|
||||
println!("greedy_fas");
|
||||
// greedyFAS
|
||||
} else {
|
||||
fas = Some(dfs_fas(graph));
|
||||
// println!("dfs_fas");
|
||||
}
|
||||
|
||||
let _fas = fas.unwrap_or(vec![]);
|
||||
for edge in _fas {
|
||||
let _edge_label = graph.edge_with_obj(&edge);
|
||||
if _edge_label.is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut edge_label = _edge_label.cloned().unwrap();
|
||||
graph.remove_edge_with_obj(&edge);
|
||||
edge_label.forward_name = edge.name.clone();
|
||||
edge_label.reversed = Some(true);
|
||||
let _ = graph.set_edge(
|
||||
&edge.w,
|
||||
&edge.v,
|
||||
Some(edge_label),
|
||||
Some(format!("rev{}", unique_id())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dfs_fas(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) -> Vec<Edge> {
|
||||
let mut fas: Vec<Edge> = vec![];
|
||||
let mut stack: OrderedHashMap<String, bool> = OrderedHashMap::new();
|
||||
let mut visited: OrderedHashMap<String, bool> = OrderedHashMap::new();
|
||||
|
||||
fn dfs(
|
||||
node_id: String,
|
||||
stack: &mut OrderedHashMap<String, bool>,
|
||||
visited: &mut OrderedHashMap<String, bool>,
|
||||
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
fas: &mut Vec<Edge>,
|
||||
) {
|
||||
if visited.contains_key(&node_id) {
|
||||
return ();
|
||||
}
|
||||
|
||||
visited.insert(node_id.clone(), true);
|
||||
stack.insert(node_id.clone(), true);
|
||||
let out_edges = graph.out_edges(&node_id, None).unwrap_or(vec![]);
|
||||
for edge in out_edges.into_iter() {
|
||||
if stack.contains_key(&edge.w) {
|
||||
fas.push(edge.clone());
|
||||
} else {
|
||||
dfs(edge.w.clone(), stack, visited, graph, fas);
|
||||
}
|
||||
}
|
||||
stack.remove(&node_id);
|
||||
}
|
||||
|
||||
for node_id in graph.nodes() {
|
||||
dfs(node_id, &mut stack, &mut visited, graph, &mut fas);
|
||||
}
|
||||
return fas;
|
||||
}
|
||||
|
||||
pub fn undo(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
for e in g.edges() {
|
||||
let Some(edge) = g.edge_mut_with_obj(&e) else {
|
||||
continue;
|
||||
};
|
||||
if edge.reversed.clone().unwrap_or(false) {
|
||||
let forward_name = edge.forward_name.clone();
|
||||
let mut label = edge.clone();
|
||||
label.reversed = None;
|
||||
label.forward_name = None;
|
||||
let _ = g.set_edge(&e.w, &e.v, Some(label), forward_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use crate::layout::util::add_dummy_node;
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::graph::GRAPH_NODE;
|
||||
use graphlib_rust::Graph;
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum BorderTypeName {
|
||||
BorderLeft,
|
||||
BorderRight,
|
||||
}
|
||||
|
||||
pub fn add_border_segments(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
fn dfs(v: &String, g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let children = g.children(v);
|
||||
if children.len() > 0 {
|
||||
for cv in children.iter() {
|
||||
dfs(cv, g);
|
||||
}
|
||||
}
|
||||
let node = g.node_mut(v).unwrap();
|
||||
if node.min_rank.is_some() {
|
||||
node.border_left = Some(OrderedHashMap::new());
|
||||
node.border_right = Some(OrderedHashMap::new());
|
||||
let mut rank = node.min_rank.clone().unwrap_or(0);
|
||||
let max_rank = node.max_rank.clone().unwrap_or(0) + 1;
|
||||
while rank < max_rank {
|
||||
add_border_node(g, BorderTypeName::BorderLeft, "_bl", v, &rank);
|
||||
add_border_node(g, BorderTypeName::BorderRight, "_br", v, &rank);
|
||||
rank += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let children = g.children(&GRAPH_NODE.to_string());
|
||||
for v in children.iter() {
|
||||
dfs(v, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_border_node(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
prop: BorderTypeName,
|
||||
prefix: &str,
|
||||
sg: &String,
|
||||
rank: &i32,
|
||||
) {
|
||||
let mut label = GraphNode::default();
|
||||
label.rank = Some(rank.clone());
|
||||
label.border_type = Some(prop.clone());
|
||||
|
||||
let curr = add_dummy_node(g, "border".to_string(), label, prefix.to_string());
|
||||
|
||||
let sg_node = g.node_mut(sg).unwrap();
|
||||
let mut border = sg_node.border_left.as_mut().unwrap();
|
||||
match prop {
|
||||
BorderTypeName::BorderRight => {
|
||||
border = sg_node.border_right.as_mut().unwrap();
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
border.insert(rank.clone(), curr.clone());
|
||||
|
||||
let prev = border.get(&(rank - 1));
|
||||
if prev.is_some() {
|
||||
let prev_v = prev.cloned().unwrap();
|
||||
let mut graph_edge = GraphEdge::default();
|
||||
graph_edge.weight = Some(1.0);
|
||||
let _ = g.set_edge(&prev_v, &curr, Some(graph_edge), None);
|
||||
}
|
||||
|
||||
let _ = g.set_parent(&curr, Some(sg.clone()));
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::Graph;
|
||||
|
||||
pub fn adjust(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let rank_dir = g.graph().rankdir.clone().unwrap();
|
||||
if &rank_dir == "lr" || &rank_dir == "rl" {
|
||||
swap_width_height(g);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undo(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let rank_dir = g.graph().rankdir.clone().unwrap();
|
||||
if &rank_dir == "bt" || &rank_dir == "rl" {
|
||||
reverse_y(g);
|
||||
}
|
||||
|
||||
if &rank_dir == "lr" || &rank_dir == "rl" {
|
||||
swap_x_y(g);
|
||||
swap_width_height(g);
|
||||
}
|
||||
}
|
||||
|
||||
fn swap_width_height(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let nodes = g.nodes();
|
||||
// =swapWidthHeightOne
|
||||
nodes.iter().for_each(|v| {
|
||||
let node = g.node_mut(v).unwrap();
|
||||
let w = node.width.clone();
|
||||
node.width = node.height;
|
||||
node.height = w;
|
||||
});
|
||||
let edges = g.edges();
|
||||
// =swapWidthHeightOne
|
||||
edges.iter().for_each(|e| {
|
||||
let edge_label = g.edge_mut_with_obj(&e).unwrap();
|
||||
let w = edge_label.width.clone();
|
||||
edge_label.width = edge_label.height;
|
||||
edge_label.height = w;
|
||||
});
|
||||
}
|
||||
|
||||
fn reverse_y(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let nodes = g.nodes();
|
||||
nodes.iter().for_each(|v| {
|
||||
// =reverseYOne
|
||||
let node = g.node_mut(v).unwrap();
|
||||
node.y = -node.y;
|
||||
});
|
||||
|
||||
let edges = g.edges();
|
||||
edges.iter().for_each(|e| {
|
||||
// =reverseYOne
|
||||
let edge_label = g.edge_mut_with_obj(&e).unwrap();
|
||||
let mut points = edge_label.points.clone().unwrap_or(vec![]);
|
||||
points.iter_mut().for_each(|point| {
|
||||
point.y = -point.y;
|
||||
});
|
||||
edge_label.points = Some(points);
|
||||
edge_label.y = -edge_label.y;
|
||||
});
|
||||
}
|
||||
|
||||
fn swap_x_y(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let nodes = g.nodes();
|
||||
nodes.iter().for_each(|v| {
|
||||
// =swapXYOne
|
||||
let node = g.node_mut(v).unwrap();
|
||||
let x = node.x.clone();
|
||||
node.x = node.y;
|
||||
node.y = x;
|
||||
});
|
||||
|
||||
let edges = g.edges();
|
||||
edges.iter().for_each(|e| {
|
||||
// =swapXYOne
|
||||
let edge_label = g.edge_mut_with_obj(&e).unwrap();
|
||||
let mut points = edge_label.points.clone().unwrap_or(vec![]);
|
||||
points.iter_mut().for_each(|point| {
|
||||
let x = point.x.clone();
|
||||
point.x = point.y;
|
||||
point.y = x;
|
||||
});
|
||||
edge_label.points = Some(points);
|
||||
|
||||
let x = edge_label.x.clone();
|
||||
edge_label.x = edge_label.y;
|
||||
edge_label.y = x;
|
||||
});
|
||||
}
|
||||
+645
@@ -0,0 +1,645 @@
|
||||
use crate::layout::add_border_segments::add_border_segments;
|
||||
use crate::layout::order::order;
|
||||
use crate::layout::parent_dummy_chains::parent_dummy_chains;
|
||||
use crate::layout::rank::rank;
|
||||
use crate::layout::util::{
|
||||
as_non_compound_graph, intersect_rect, normalize_ranks, remove_empty_ranks,
|
||||
transfer_node_edge_labels, Rect,
|
||||
};
|
||||
use crate::{GraphConfig, GraphEdge, GraphEdgePoint, GraphNode};
|
||||
use graphlib_rust::{Graph, GraphOption};
|
||||
|
||||
pub mod acyclic;
|
||||
pub mod add_border_segments;
|
||||
pub mod coordinate_system;
|
||||
pub mod nesting_graph;
|
||||
pub mod normalize;
|
||||
pub mod order;
|
||||
pub mod parent_dummy_chains;
|
||||
pub mod position;
|
||||
pub mod rank;
|
||||
pub mod util;
|
||||
|
||||
const DEFAULT_RANK_SEP: f32 = 50.0;
|
||||
|
||||
pub fn layout(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let mut layout_graph = build_layout_graph(g);
|
||||
run_layout(&mut layout_graph);
|
||||
update_input_graph(g, &layout_graph);
|
||||
}
|
||||
|
||||
pub fn update_input_graph(
|
||||
input_graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
layout_graph: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) {
|
||||
for v in input_graph.nodes() {
|
||||
let input_label_ = input_graph.node_mut(&v);
|
||||
let layout_label = layout_graph.node(&v).unwrap();
|
||||
|
||||
if let Some(input_label) = input_label_ {
|
||||
input_label.x = layout_label.x;
|
||||
input_label.y = layout_label.y;
|
||||
|
||||
if layout_graph.children(&v).len() > 0 {
|
||||
input_label.width = layout_label.width;
|
||||
input_label.height = layout_label.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for e in input_graph.edges() {
|
||||
let input_label = input_graph.edge_mut_with_obj(&e).unwrap();
|
||||
let layout_label = layout_graph.edge_with_obj(&e).unwrap();
|
||||
|
||||
input_label.points = layout_label.points.clone();
|
||||
input_label.x = layout_label.x;
|
||||
input_label.y = layout_label.y;
|
||||
}
|
||||
|
||||
input_graph.graph_mut().width = layout_graph.graph().width;
|
||||
input_graph.graph_mut().height = layout_graph.graph().height;
|
||||
}
|
||||
|
||||
pub fn set_graph_label_default_values(graph_label: &mut GraphConfig) {
|
||||
if graph_label.ranksep.is_none() {
|
||||
graph_label.ranksep = Some(50.0);
|
||||
}
|
||||
|
||||
if graph_label.edgesep.is_none() {
|
||||
graph_label.edgesep = Some(20.0);
|
||||
}
|
||||
|
||||
if graph_label.nodesep.is_none() {
|
||||
graph_label.nodesep = Some(50.0);
|
||||
}
|
||||
|
||||
if graph_label.rankdir.is_none() {
|
||||
graph_label.rankdir = Some("tb".to_string());
|
||||
}
|
||||
|
||||
if graph_label.marginx.is_none() {
|
||||
graph_label.marginx = Some(0.0);
|
||||
}
|
||||
|
||||
if graph_label.marginy.is_none() {
|
||||
graph_label.marginy = Some(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_edge_label_default_values(edge_label: &mut GraphEdge) {
|
||||
if edge_label.minlen.is_none() {
|
||||
edge_label.minlen = Some(1.0);
|
||||
}
|
||||
|
||||
if edge_label.weight.is_none() {
|
||||
edge_label.weight = Some(1.0);
|
||||
}
|
||||
|
||||
if edge_label.width.is_none() {
|
||||
edge_label.width = Some(0.0);
|
||||
}
|
||||
|
||||
if edge_label.height.is_none() {
|
||||
edge_label.height = Some(0.0);
|
||||
}
|
||||
|
||||
if edge_label.labeloffset.is_none() {
|
||||
edge_label.labeloffset = Some(10.0);
|
||||
}
|
||||
|
||||
if edge_label.labelpos.is_none() {
|
||||
edge_label.labelpos = Some("r".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Constructs a new graph from the input graph, which can be used for layout.
|
||||
* This process copies only whitelisted attributes from the input graph to the
|
||||
* layout graph. Thus this function serves as a good place to determine what
|
||||
* attributes can influence layout.
|
||||
*/
|
||||
pub fn build_layout_graph(
|
||||
input_graph: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) -> Graph<GraphConfig, GraphNode, GraphEdge> {
|
||||
let mut g: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(Some(GraphOption {
|
||||
directed: Some(true),
|
||||
multigraph: Some(true),
|
||||
compound: Some(true),
|
||||
}));
|
||||
|
||||
let mut graph_label: GraphConfig = input_graph.graph().clone();
|
||||
set_graph_label_default_values(&mut graph_label);
|
||||
g.set_graph(graph_label);
|
||||
|
||||
for node_id in input_graph.nodes().iter() {
|
||||
let _node = input_graph.node(node_id);
|
||||
if _node.is_none() {
|
||||
continue;
|
||||
}
|
||||
g.set_node(node_id.clone(), _node.cloned());
|
||||
let _ = g.set_parent(node_id, input_graph.parent(node_id).cloned());
|
||||
}
|
||||
|
||||
for edge_obj in input_graph.edges() {
|
||||
let _edge = input_graph.edge_with_obj(&edge_obj);
|
||||
if _edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut edge_label = _edge.cloned().unwrap();
|
||||
set_edge_label_default_values(&mut edge_label);
|
||||
let _ = g.set_edge_with_obj(&edge_obj, Some(edge_label));
|
||||
}
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
/*
|
||||
* This idea comes from the Gansner paper: to account for edge labels in our
|
||||
* layout we split each rank in half by doubling minlen and halving ranksep.
|
||||
* Then we can place labels at these mid-points between nodes.
|
||||
*
|
||||
* We also add some minimal padding to the width to push the label for the edge
|
||||
* away from the edge itself a bit.
|
||||
*/
|
||||
pub fn make_space_for_edge_labels(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let graph_config = graph.graph_mut();
|
||||
graph_config.ranksep = Some(graph_config.ranksep.unwrap_or(DEFAULT_RANK_SEP) / 2.0);
|
||||
|
||||
// moving in nested block due to borrow checker
|
||||
{
|
||||
let graph_config = graph.graph().clone();
|
||||
let edge_objs = graph.edges();
|
||||
for edge_obj in edge_objs.into_iter() {
|
||||
let _edge = graph.edge_mut_with_obj(&edge_obj);
|
||||
if _edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
let edge = _edge.unwrap();
|
||||
|
||||
let minlen = edge.minlen.unwrap_or(1.0);
|
||||
let labelpos = edge.labelpos.clone().unwrap_or("".to_string());
|
||||
let labeloffset = edge.labeloffset.unwrap_or(10.0);
|
||||
let rankdir = graph_config.rankdir.clone().unwrap_or("".to_string());
|
||||
|
||||
edge.minlen = Some(minlen * 2.0);
|
||||
if labelpos != "c" {
|
||||
if rankdir == "tb" || rankdir == "bt" {
|
||||
edge.width = Some(edge.width.unwrap_or(0.0) + labeloffset);
|
||||
} else {
|
||||
edge.height = Some(edge.height.unwrap_or(0.0) + labeloffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Creates temporary dummy nodes that capture the rank in which each edge's
|
||||
* label is going to, if it has one of non-zero width and height. We do this
|
||||
* so that we can safely remove empty ranks while preserving balance for the
|
||||
* label's position.
|
||||
*/
|
||||
pub fn inject_edge_label_proxies(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let edges = g.edges();
|
||||
for e in edges.into_iter() {
|
||||
let edge_ = g.edge_with_obj(&e);
|
||||
if let Some(edge) = edge_ {
|
||||
if edge.width.clone().unwrap_or(0.0) > 0.0 && edge.height.clone().unwrap_or(0.0) > 0.0 {
|
||||
let v = g.node(&e.v);
|
||||
let w = g.node(&e.w);
|
||||
let v_rank = v.cloned().unwrap_or(GraphNode::default()).rank.unwrap_or(0);
|
||||
let w_rank = w.cloned().unwrap_or(GraphNode::default()).rank.unwrap_or(0);
|
||||
let mut label = GraphNode::default();
|
||||
label.rank = Some((w_rank - v_rank) / 2 + v_rank);
|
||||
label.width = edge.width.unwrap_or(0.0);
|
||||
label.height = edge.height.unwrap_or(0.0);
|
||||
label.labelpos = edge.labelpos.clone();
|
||||
label.e = Some(e.clone());
|
||||
util::add_dummy_node(g, "edge-proxy".to_string(), label, "_ep".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assign_rank_min_max(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let mut max_rank = 0;
|
||||
let vs = g.nodes();
|
||||
for v in vs.iter() {
|
||||
let node_ = g.node(v);
|
||||
if node_.is_none() {
|
||||
continue;
|
||||
}
|
||||
let node = node_.unwrap();
|
||||
if node.border_top.is_some() {
|
||||
let border_top = node.border_top.clone().unwrap();
|
||||
let border_bottom = node.border_bottom.clone().unwrap();
|
||||
let _min_rank = g.node(&border_top).cloned().unwrap().rank.unwrap_or(0);
|
||||
let _max_rank = g.node(&border_bottom).cloned().unwrap().rank.unwrap_or(0);
|
||||
|
||||
let _node = g.node_mut(v).unwrap();
|
||||
_node.min_rank = Some(_min_rank);
|
||||
_node.max_rank = Some(_max_rank.clone());
|
||||
max_rank = std::cmp::max(max_rank, _max_rank);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum GraphElement<'a> {
|
||||
Node(&'a GraphNode),
|
||||
Edge(&'a GraphEdge),
|
||||
}
|
||||
|
||||
impl<'a> GraphElement<'a> {
|
||||
fn x(&self) -> f32 {
|
||||
match self {
|
||||
GraphElement::Node(node) => node.x,
|
||||
GraphElement::Edge(edge) => edge.x,
|
||||
}
|
||||
}
|
||||
|
||||
fn y(&self) -> f32 {
|
||||
match self {
|
||||
GraphElement::Node(node) => node.y,
|
||||
GraphElement::Edge(edge) => edge.y,
|
||||
}
|
||||
}
|
||||
|
||||
fn width(&self) -> f32 {
|
||||
match self {
|
||||
GraphElement::Node(node) => node.width,
|
||||
GraphElement::Edge(edge) => edge.width.unwrap_or(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn height(&self) -> f32 {
|
||||
match self {
|
||||
GraphElement::Node(node) => node.height,
|
||||
GraphElement::Edge(edge) => edge.height.unwrap_or(0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn translate_graph(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let mut min_x = f64::INFINITY as f32;
|
||||
let mut max_x: f32 = 0.0;
|
||||
let mut min_y = f64::INFINITY as f32;
|
||||
let mut max_y: f32 = 0.0;
|
||||
let mut graph_label = g.graph().clone();
|
||||
let margin_x = graph_label.marginx.unwrap_or(0.0);
|
||||
let margin_y = graph_label.marginy.unwrap_or(0.0);
|
||||
|
||||
fn get_extremes(
|
||||
attrs: &GraphElement,
|
||||
min_x: &mut f32,
|
||||
max_x: &mut f32,
|
||||
min_y: &mut f32,
|
||||
max_y: &mut f32,
|
||||
) {
|
||||
let x = attrs.x();
|
||||
let y = attrs.y();
|
||||
let w = attrs.width();
|
||||
let h = attrs.height();
|
||||
*min_x = min_x.min(x - w / 2.0);
|
||||
*max_x = max_x.max(x + w / 2.0);
|
||||
*min_y = min_y.min(y - h / 2.0);
|
||||
*max_y = max_y.max(y + h / 2.0);
|
||||
}
|
||||
|
||||
for v in g.nodes() {
|
||||
get_extremes(
|
||||
&GraphElement::Node(g.node(&v).unwrap()),
|
||||
&mut min_x,
|
||||
&mut max_x,
|
||||
&mut min_y,
|
||||
&mut max_y,
|
||||
);
|
||||
}
|
||||
|
||||
for e in g.edges() {
|
||||
let edge = g.edge_with_obj(&e).unwrap();
|
||||
if edge.width.unwrap_or(0.0) > 0.0 && edge.height.unwrap_or(0.0) > 0.0 {
|
||||
get_extremes(
|
||||
&GraphElement::Edge(edge),
|
||||
&mut min_x,
|
||||
&mut max_x,
|
||||
&mut min_y,
|
||||
&mut max_y,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
min_x -= margin_x;
|
||||
min_y -= margin_y;
|
||||
|
||||
for v in g.nodes() {
|
||||
let node = g.node_mut(&v).unwrap();
|
||||
node.x -= min_x;
|
||||
node.y -= min_y;
|
||||
}
|
||||
|
||||
for e in g.edges() {
|
||||
let edge = g.edge_mut_with_obj(&e).unwrap();
|
||||
let has_label = edge.width.unwrap_or(0.0) > 0.0 && edge.height.unwrap_or(0.0) > 0.0;
|
||||
if edge.points.is_some() {
|
||||
for p in edge.points.as_mut().unwrap() {
|
||||
p.x -= min_x;
|
||||
p.y -= min_y;
|
||||
}
|
||||
}
|
||||
if has_label {
|
||||
edge.x -= min_x;
|
||||
edge.y -= min_y;
|
||||
}
|
||||
}
|
||||
|
||||
graph_label.width = max_x - min_x + margin_x;
|
||||
graph_label.height = max_y - min_y + margin_y;
|
||||
|
||||
g.set_graph(graph_label);
|
||||
}
|
||||
|
||||
pub fn assign_node_intersects(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
for e in g.edges() {
|
||||
let mut edge = g.edge_mut_with_obj(&e).cloned().unwrap();
|
||||
let node_v = g.node(&e.v).cloned().unwrap();
|
||||
let node_w = g.node(&e.w).cloned().unwrap();
|
||||
let (p1, p2) =
|
||||
if edge.points.is_none() || edge.points.as_ref().map_or(true, |p| p.is_empty()) {
|
||||
edge.points = Some(vec![]);
|
||||
(
|
||||
GraphEdgePoint {
|
||||
x: node_w.x,
|
||||
y: node_w.y,
|
||||
},
|
||||
GraphEdgePoint {
|
||||
x: node_v.x,
|
||||
y: node_v.y,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
let points = edge.points.clone().unwrap();
|
||||
let r1 = GraphEdgePoint {
|
||||
x: points[0].x,
|
||||
y: points[0].y,
|
||||
};
|
||||
|
||||
let r2 = GraphEdgePoint {
|
||||
x: points[points.len() - 1].x,
|
||||
y: points[points.len() - 1].y,
|
||||
};
|
||||
|
||||
(r1, r2)
|
||||
};
|
||||
|
||||
let points = edge.points.as_mut().unwrap();
|
||||
points.insert(
|
||||
0,
|
||||
intersect_rect(
|
||||
&Rect {
|
||||
x: node_v.x,
|
||||
y: node_v.y,
|
||||
width: node_v.width,
|
||||
height: node_v.height,
|
||||
},
|
||||
&p1,
|
||||
),
|
||||
);
|
||||
|
||||
points.push(intersect_rect(
|
||||
&Rect {
|
||||
x: node_w.x,
|
||||
y: node_w.y,
|
||||
width: node_w.width,
|
||||
height: node_w.height,
|
||||
},
|
||||
&p2,
|
||||
));
|
||||
|
||||
let _ = g.set_edge_with_obj(&e, Some(edge));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_edge_label_proxies(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let vs = g.nodes();
|
||||
for v in vs.iter() {
|
||||
let node = g.node(v).unwrap();
|
||||
if node.dummy.is_some() && node.dummy.clone().unwrap() == "edge-proxy" {
|
||||
let rank = node.rank.unwrap_or(0);
|
||||
let graph_edge_ = g.edge_mut_with_obj(&node.e.clone().unwrap());
|
||||
if let Some(graph_edge) = graph_edge_ {
|
||||
graph_edge.label_rank = Some(rank);
|
||||
}
|
||||
g.remove_node(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fixup_edge_label_coords(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
g.edges().iter().for_each(|e| {
|
||||
let edge = g.edge_mut_with_obj(&e.to_owned()).unwrap();
|
||||
if edge.x != 0.0 {
|
||||
let labelpos = edge.labelpos.clone().unwrap_or("".to_string());
|
||||
let labeloffset = edge.labeloffset.clone().unwrap_or(0.0);
|
||||
if labelpos == "l" || labelpos == "r" {
|
||||
edge.width = Some(edge.width.unwrap_or(0.0) - labeloffset);
|
||||
}
|
||||
|
||||
if labelpos == "l" {
|
||||
edge.x -= edge.width.clone().unwrap_or(0.0) / 2.0 + labeloffset;
|
||||
} else if labelpos == "r" {
|
||||
edge.x += edge.width.clone().unwrap_or(0.0) / 2.0 + labeloffset;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn reverse_points_for_reversed_edges(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
for e in g.edges() {
|
||||
let edge = g.edge_mut_with_obj(&e.to_owned()).unwrap();
|
||||
if edge.reversed.clone().unwrap_or(false) {
|
||||
if edge.points.is_some() {
|
||||
let points = edge.points.as_mut().unwrap();
|
||||
points.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_border_nodes(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
for v in g.nodes() {
|
||||
if g.children(&v).len() > 0 {
|
||||
let mut node = g.node(&v).cloned().unwrap();
|
||||
let Some(border_top) = node.border_top.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(border_bottom) = node.border_bottom.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(t) = g.node(border_top).cloned() else {
|
||||
continue;
|
||||
};
|
||||
let Some(b) = g.node(border_bottom).cloned() else {
|
||||
continue;
|
||||
};
|
||||
let Some(border_left) = node.border_left.clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some(border_right) = node.border_right.clone() else {
|
||||
continue;
|
||||
};
|
||||
let mut l_keys: Vec<i32> = border_left.keys().cloned().collect();
|
||||
if l_keys.is_empty() {
|
||||
continue;
|
||||
}
|
||||
l_keys.sort();
|
||||
let mut r_keys: Vec<i32> = border_right.keys().cloned().collect();
|
||||
if r_keys.is_empty() {
|
||||
continue;
|
||||
}
|
||||
r_keys.sort();
|
||||
let Some(l_node_id) = border_left.get(&l_keys[l_keys.len() - 1]) else {
|
||||
continue;
|
||||
};
|
||||
let Some(r_node_id) = border_right.get(&r_keys[r_keys.len() - 1]) else {
|
||||
continue;
|
||||
};
|
||||
let Some(l) = g.node(l_node_id).cloned() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = g.node(r_node_id).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
node.width = (r.x - l.x).abs();
|
||||
node.height = (b.y - t.y).abs();
|
||||
node.x = l.x + node.width / 2.0;
|
||||
node.y = t.y + node.height / 2.0;
|
||||
|
||||
g.set_node(v.clone(), Some(node));
|
||||
}
|
||||
}
|
||||
|
||||
g.nodes().iter().for_each(|v| {
|
||||
let node = g.node(v).unwrap();
|
||||
if node.dummy.is_some() && node.dummy.clone().unwrap() == "border" {
|
||||
g.remove_node(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn remove_self_edges(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let edge_objs = graph.edges();
|
||||
for edge_obj in edge_objs.into_iter() {
|
||||
if edge_obj.v == edge_obj.w {
|
||||
let edge_label = graph.edge_with_obj(&edge_obj).cloned().unwrap();
|
||||
let node = graph.node_mut(&edge_obj.v).unwrap();
|
||||
node.self_edges.push((edge_obj.clone(), edge_label));
|
||||
graph.remove_edge_with_obj(&edge_obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_self_edges(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let layers = util::build_layer_matrix(graph);
|
||||
layers.iter().for_each(|layer| {
|
||||
let mut order_shift = 0;
|
||||
layer.iter().enumerate().for_each(|(i, v)| {
|
||||
let node = graph.node_mut(v).unwrap();
|
||||
node.order = Some(i + order_shift);
|
||||
let rank = node.rank.clone();
|
||||
|
||||
let self_edges = node.self_edges.clone();
|
||||
self_edges.into_iter().for_each(|(edge, graph_edge)| {
|
||||
let mut _graph_node = GraphNode::default();
|
||||
_graph_node.width = graph_edge.width.clone().unwrap_or(0.0);
|
||||
_graph_node.height = graph_edge.height.clone().unwrap_or(0.0);
|
||||
_graph_node.rank = rank.clone();
|
||||
order_shift += 1;
|
||||
_graph_node.order = Some(i + order_shift);
|
||||
_graph_node.e = Some(edge.clone());
|
||||
_graph_node.label = Some(graph_edge.clone());
|
||||
util::add_dummy_node(
|
||||
graph,
|
||||
"selfedge".to_string(),
|
||||
_graph_node,
|
||||
"_se".to_string(),
|
||||
);
|
||||
});
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
pub fn position_self_edges(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
for v in g.nodes() {
|
||||
let node = g.node(&v).cloned().unwrap();
|
||||
if node.dummy.unwrap_or("".to_string()) == "selfedge" {
|
||||
let self_node = g.node(&node.e.as_ref().unwrap().v).unwrap();
|
||||
let x = self_node.x + self_node.width / 2.0;
|
||||
let y = self_node.y;
|
||||
let dx = node.x - x;
|
||||
let dy = self_node.height / 2.0;
|
||||
let mut graph_edge = node.label.clone().unwrap();
|
||||
graph_edge.points = Some(vec![
|
||||
GraphEdgePoint {
|
||||
x: x + 2.0 * dx / 3.0,
|
||||
y: y - dy,
|
||||
},
|
||||
GraphEdgePoint {
|
||||
x: x + 2.0 * dx / 3.0,
|
||||
y: y - dy,
|
||||
},
|
||||
GraphEdgePoint {
|
||||
x: x + 5.0 * dx / 6.0,
|
||||
y: y - dy,
|
||||
},
|
||||
GraphEdgePoint { x: x + dx, y },
|
||||
GraphEdgePoint {
|
||||
x: x + 5.0 * dx / 6.0,
|
||||
y: y + dy,
|
||||
},
|
||||
GraphEdgePoint {
|
||||
x: x + 2.0 * dx / 3.0,
|
||||
y: y + dy,
|
||||
},
|
||||
]);
|
||||
graph_edge.x = node.x;
|
||||
graph_edge.y = node.y;
|
||||
let _ = g.set_edge_with_obj(&node.e.unwrap(), Some(graph_edge));
|
||||
g.remove_node(&v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_layout(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
make_space_for_edge_labels(graph);
|
||||
remove_self_edges(graph);
|
||||
acyclic::run(graph);
|
||||
nesting_graph::run(graph);
|
||||
// calculating ranks
|
||||
let mut nc_graph: Graph<GraphConfig, GraphNode, GraphEdge> = as_non_compound_graph(graph);
|
||||
rank(&mut nc_graph);
|
||||
transfer_node_edge_labels(&nc_graph, graph);
|
||||
// done with calculating ranks
|
||||
inject_edge_label_proxies(graph);
|
||||
remove_empty_ranks(graph);
|
||||
nesting_graph::cleanup(graph);
|
||||
normalize_ranks(graph);
|
||||
assign_rank_min_max(graph);
|
||||
remove_edge_label_proxies(graph);
|
||||
normalize::run(graph);
|
||||
parent_dummy_chains(graph);
|
||||
add_border_segments(graph);
|
||||
order(graph);
|
||||
insert_self_edges(graph);
|
||||
coordinate_system::adjust(graph);
|
||||
position::position(graph);
|
||||
position_self_edges(graph);
|
||||
remove_border_nodes(graph);
|
||||
normalize::undo(graph);
|
||||
fixup_edge_label_coords(graph);
|
||||
coordinate_system::undo(graph);
|
||||
translate_graph(graph);
|
||||
assign_node_intersects(graph);
|
||||
reverse_points_for_reversed_edges(graph);
|
||||
acyclic::undo(graph);
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
use crate::layout::{util, GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::graph::GRAPH_NODE;
|
||||
use graphlib_rust::Graph;
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
|
||||
/*
|
||||
* A nesting graph creates dummy nodes for the tops and bottoms of subgraphs,
|
||||
* adds appropriate edges to ensure that all cluster nodes are placed between
|
||||
* these boundries, and ensures that the graph is connected.
|
||||
*
|
||||
* In addition we ensure, through the use of the minlen property, that nodes
|
||||
* and subgraph border nodes to not end up on the same rank.
|
||||
*
|
||||
* Preconditions:
|
||||
*
|
||||
* 1. Input graph is a DAG
|
||||
* 2. Nodes in the input graph has a minlen attribute
|
||||
*
|
||||
* Postconditions:
|
||||
*
|
||||
* 1. Input graph is connected.
|
||||
* 2. Dummy nodes are added for the tops and bottoms of subgraphs.
|
||||
* 3. The minlen attribute for nodes is adjusted to ensure nodes do not
|
||||
* get placed on the same rank as subgraph border nodes.
|
||||
*
|
||||
* The nesting graph idea comes from Sander, "Layout of Compound Directed
|
||||
* Graphs."
|
||||
*/
|
||||
pub fn run(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let graph_node = GraphNode::default();
|
||||
let root = util::add_dummy_node(graph, "root".to_string(), graph_node, "_root".to_string());
|
||||
let depths = tree_depths(graph);
|
||||
let mut height: usize = 0;
|
||||
for depth in depths.values() {
|
||||
if depth > &height {
|
||||
height = depth.to_owned();
|
||||
}
|
||||
}
|
||||
if height > 0 {
|
||||
height -= 1;
|
||||
}
|
||||
|
||||
let node_sep = (2 * height + 1) as f32;
|
||||
graph.graph_mut().nesting_root = Some(root.clone());
|
||||
|
||||
// Multiply minlen by nodeSep to align nodes on non-border ranks.
|
||||
let edge_objs = graph.edges();
|
||||
for edge_obj in edge_objs.into_iter() {
|
||||
let _edge_label = graph.edge_mut_with_obj(&edge_obj);
|
||||
if _edge_label.is_none() {
|
||||
continue;
|
||||
}
|
||||
let edge_label = _edge_label.unwrap();
|
||||
edge_label.minlen = Some(edge_label.minlen.unwrap_or(1.0) * node_sep);
|
||||
}
|
||||
|
||||
// Calculate a weight that is sufficient to keep subgraphs vertically compact
|
||||
let weight = sum_weights(graph) + 1.0;
|
||||
|
||||
// Create border nodes and link them up
|
||||
let children = graph.children(&GRAPH_NODE.to_string());
|
||||
for child_id in children.into_iter() {
|
||||
dfs(
|
||||
graph, &root, &node_sep, &weight, &height, &depths, &child_id,
|
||||
);
|
||||
}
|
||||
|
||||
// Save the multiplier for node layers for later removal of empty border
|
||||
// layers.
|
||||
graph.graph_mut().node_rank_factor = Some(node_sep);
|
||||
}
|
||||
|
||||
fn tree_depths(graph: &Graph<GraphConfig, GraphNode, GraphEdge>) -> OrderedHashMap<String, usize> {
|
||||
let mut depths: OrderedHashMap<String, usize> = OrderedHashMap::new();
|
||||
|
||||
fn dfs(
|
||||
node_id: String,
|
||||
depth: usize,
|
||||
depths: &mut OrderedHashMap<String, usize>,
|
||||
graph: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) {
|
||||
let children = graph.children(&node_id);
|
||||
for child_id in children.iter() {
|
||||
// recursion for child node ids
|
||||
dfs(child_id.clone(), depth + 1, depths, graph);
|
||||
}
|
||||
// setting for current node
|
||||
depths.insert(node_id.clone(), depth);
|
||||
}
|
||||
|
||||
// processing root nodes
|
||||
for node_id in graph.children(&GRAPH_NODE.to_string()) {
|
||||
dfs(node_id, 1, &mut depths, graph);
|
||||
}
|
||||
return depths;
|
||||
}
|
||||
|
||||
fn dfs(
|
||||
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
root: &String,
|
||||
node_sep: &f32,
|
||||
weight: &f32,
|
||||
height: &usize,
|
||||
depths: &OrderedHashMap<String, usize>,
|
||||
node_id: &String,
|
||||
) {
|
||||
let children = graph.children(node_id);
|
||||
if children.len() == 0 {
|
||||
if node_id != root {
|
||||
let mut graph_edge = GraphEdge::default();
|
||||
graph_edge.minlen = Some(node_sep.clone());
|
||||
graph_edge.weight = Some(0.0);
|
||||
let _ = graph.set_edge(&root, &node_id, Some(graph_edge), None);
|
||||
}
|
||||
return ();
|
||||
}
|
||||
|
||||
let top = util::add_border_node(graph, "_bt", None, None);
|
||||
let bottom = util::add_border_node(graph, "_bb", None, None);
|
||||
|
||||
let _pt = graph.set_parent(&top, Some(node_id.clone()));
|
||||
let _pb = graph.set_parent(&bottom, Some(node_id.clone()));
|
||||
|
||||
let _label = graph.node_mut(node_id);
|
||||
if let Some(label) = _label {
|
||||
label.border_top = Some(top.clone());
|
||||
label.border_bottom = Some(bottom.clone());
|
||||
}
|
||||
|
||||
for child_id in children.into_iter() {
|
||||
dfs(graph, root, node_sep, weight, height, depths, &child_id);
|
||||
|
||||
let _child_node = graph.node(&child_id);
|
||||
if _child_node.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let child_node = _child_node.unwrap();
|
||||
let border_top = child_node.border_top.clone();
|
||||
let border_bottom = child_node.border_bottom.clone();
|
||||
|
||||
let mut child_top = child_id.clone();
|
||||
let mut child_bottom = child_id.clone();
|
||||
let mut this_weight: f32 = 2.0 * weight.clone();
|
||||
let mut minlen: usize = 1;
|
||||
|
||||
if border_top.is_some() {
|
||||
child_top = border_top.clone().unwrap();
|
||||
}
|
||||
if border_bottom.is_some() {
|
||||
child_bottom = border_bottom.unwrap();
|
||||
}
|
||||
if border_top.is_some() {
|
||||
this_weight = weight.clone();
|
||||
}
|
||||
if child_top == child_bottom {
|
||||
minlen = height - depths.get(node_id).cloned().unwrap_or(0) + 1;
|
||||
}
|
||||
|
||||
let mut _ct_graph_edge = GraphEdge::default();
|
||||
_ct_graph_edge.minlen = Some(minlen.clone() as f32);
|
||||
_ct_graph_edge.weight = Some(this_weight.clone());
|
||||
_ct_graph_edge.nesting_edge = Some(true);
|
||||
let _ct = graph.set_edge(&top, &child_top, Some(_ct_graph_edge), None);
|
||||
|
||||
let mut _cb_graph_edge = GraphEdge::default();
|
||||
_cb_graph_edge.minlen = Some(minlen.clone() as f32);
|
||||
_cb_graph_edge.weight = Some(this_weight.clone());
|
||||
_cb_graph_edge.nesting_edge = Some(true);
|
||||
let _cb = graph.set_edge(&child_bottom, &bottom, Some(_cb_graph_edge), None);
|
||||
}
|
||||
|
||||
if graph.parent(node_id).is_none() {
|
||||
let mut graph_edge = GraphEdge::default();
|
||||
graph_edge.minlen =
|
||||
Some((depths.get(node_id).cloned().unwrap_or(0) + height.clone()) as f32);
|
||||
graph_edge.weight = Some(0.0);
|
||||
graph_edge.nesting_edge = Some(true);
|
||||
let _ = graph.set_edge(&root, &top, Some(graph_edge), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn sum_weights(graph: &Graph<GraphConfig, GraphNode, GraphEdge>) -> f32 {
|
||||
let mut total_weights: f32 = 0.0;
|
||||
|
||||
for edge in graph.edges() {
|
||||
if let Some(edge_label) = graph.edge_with_obj(&edge) {
|
||||
if let Some(weight) = edge_label.weight {
|
||||
total_weights += weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total_weights;
|
||||
}
|
||||
|
||||
pub fn cleanup(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let graph_label = graph.graph();
|
||||
if graph_label.nesting_root.is_some() {
|
||||
graph.remove_node(&graph_label.nesting_root.clone().unwrap());
|
||||
}
|
||||
graph.graph_mut().nesting_root = None;
|
||||
// removing nesting edge
|
||||
let edges = graph.edges();
|
||||
for edge in edges.into_iter() {
|
||||
let _edge_label = graph.edge_with_obj(&edge);
|
||||
if let Some(edge_label) = _edge_label {
|
||||
if edge_label.nesting_edge.clone().unwrap_or(false) {
|
||||
graph.remove_edge_with_obj(&edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
use crate::layout::util::add_dummy_node;
|
||||
use crate::{GraphConfig, GraphEdge, GraphEdgePoint, GraphNode};
|
||||
use graphlib_rust::{Edge, Graph};
|
||||
/*
|
||||
* Breaks any long edges in the graph into short segments that span 1 layer
|
||||
* each. This operation is undoable with the denormalize function.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. The input graph is a DAG.
|
||||
* 2. Each node in the graph has a "rank" property.
|
||||
*
|
||||
* Post-condition:
|
||||
*
|
||||
* 1. All edges in the graph have a length of 1.
|
||||
* 2. Dummy nodes are added where edges have been split into segments.
|
||||
* 3. The graph is augmented with a "dummyChains" attribute which contains
|
||||
* the first dummy in each chain of dummy nodes produced.
|
||||
*/
|
||||
|
||||
pub fn run(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
g.graph_mut().dummy_chains = Some(vec![]);
|
||||
let edges = g.edges();
|
||||
for edge_obj in edges.into_iter() {
|
||||
normalize_edge(g, &edge_obj);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_edge(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>, e: &Edge) {
|
||||
let mut v = e.v.clone();
|
||||
let w = e.w.clone();
|
||||
let mut v_rank = g
|
||||
.node(&v)
|
||||
.unwrap_or(&GraphNode::default())
|
||||
.rank
|
||||
.clone()
|
||||
.unwrap_or(0);
|
||||
let w_rank = g
|
||||
.node(&w)
|
||||
.unwrap_or(&GraphNode::default())
|
||||
.rank
|
||||
.clone()
|
||||
.unwrap_or(0);
|
||||
// let name = e.name.clone(); // TODO: it was creating error for multi-graph option
|
||||
let Some(edge_label) = g.edge_mut_with_obj(&e) else {
|
||||
return;
|
||||
};
|
||||
edge_label.points = Some(vec![]);
|
||||
let weight = edge_label.weight.clone();
|
||||
let label_rank = edge_label.label_rank.unwrap_or(0);
|
||||
|
||||
if w_rank == v_rank + 1 {
|
||||
return ();
|
||||
}
|
||||
|
||||
let _edge_label = edge_label.clone();
|
||||
g.remove_edge_with_obj(&e);
|
||||
|
||||
let mut i = 0;
|
||||
v_rank += 1;
|
||||
while v_rank < w_rank {
|
||||
let mut attrs = GraphNode::default();
|
||||
attrs.edge_label = Some(_edge_label.clone());
|
||||
attrs.edge_obj = Some(e.clone());
|
||||
attrs.rank = Some(v_rank.clone());
|
||||
let mut dummy_type = "edge".to_string();
|
||||
if v_rank == label_rank {
|
||||
attrs.width = _edge_label.width.clone().unwrap_or(0.0);
|
||||
attrs.height = _edge_label.height.clone().unwrap_or(0.0);
|
||||
attrs.labelpos = _edge_label.labelpos.clone();
|
||||
dummy_type = "edge-label".to_string();
|
||||
}
|
||||
let dummy = add_dummy_node(g, dummy_type, attrs, "_d".to_string());
|
||||
let mut dummy_edge_label = GraphEdge::default();
|
||||
dummy_edge_label.weight = weight.clone();
|
||||
let _ = g.set_edge(&v, &dummy, Some(dummy_edge_label), None); // remove name from here
|
||||
if i == 0 {
|
||||
let graph_label = g.graph_mut();
|
||||
if graph_label.dummy_chains.is_none() {
|
||||
graph_label.dummy_chains = Some(vec![]);
|
||||
}
|
||||
let dummy_chains = graph_label.dummy_chains.as_mut().unwrap();
|
||||
dummy_chains.push(dummy.clone());
|
||||
}
|
||||
v = dummy.clone();
|
||||
i += 1;
|
||||
v_rank += 1;
|
||||
}
|
||||
|
||||
let mut graph_edge = GraphEdge::default();
|
||||
graph_edge.weight = weight;
|
||||
let _ = g.set_edge(
|
||||
&v,
|
||||
&w,
|
||||
Some(graph_edge),
|
||||
None, // removed name from here
|
||||
);
|
||||
}
|
||||
|
||||
pub fn undo(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
if g.graph().dummy_chains.is_none() {
|
||||
return ();
|
||||
}
|
||||
let dummy_chains = g.graph().dummy_chains.clone().unwrap();
|
||||
for v_ in dummy_chains.iter() {
|
||||
let node_ = g.node(v_);
|
||||
if node_.is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut node = node_.cloned().unwrap();
|
||||
let mut orig_label = node.edge_label.clone().unwrap_or(GraphEdge::default());
|
||||
let edge_obj = node.edge_obj.unwrap();
|
||||
let mut v = v_.clone();
|
||||
while node.dummy.is_some() {
|
||||
let sucs = g.successors(&v).unwrap_or(vec![]);
|
||||
let default_w = "".to_string();
|
||||
let w = sucs.first().unwrap_or(&default_w);
|
||||
g.remove_node(&v);
|
||||
let points = orig_label.points.as_mut().unwrap();
|
||||
points.push(GraphEdgePoint {
|
||||
x: node.x.clone(),
|
||||
y: node.y.clone(),
|
||||
});
|
||||
if node.dummy.as_ref().unwrap() == "edge-label" {
|
||||
orig_label.x = node.x.clone();
|
||||
orig_label.y = node.y.clone();
|
||||
orig_label.width = Some(node.width.clone());
|
||||
orig_label.height = Some(node.height.clone());
|
||||
}
|
||||
v = w.clone();
|
||||
node = g.node(&v).cloned().unwrap();
|
||||
}
|
||||
let _ = g.set_edge_with_obj(&edge_obj, Some(orig_label));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::Graph;
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
|
||||
pub fn add_subgraph_constraints(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
cg: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
vs: &Vec<String>,
|
||||
) {
|
||||
let mut prev: OrderedHashMap<String, String> = OrderedHashMap::new();
|
||||
let mut _root_prev: Option<String> = None;
|
||||
|
||||
vs.iter().for_each(|v| {
|
||||
let mut child = g.parent(v).cloned();
|
||||
let mut _parent: Option<String> = None;
|
||||
let mut _prev_child: Option<String> = None;
|
||||
while child.is_some() {
|
||||
_parent = g.parent(&child.clone().unwrap()).cloned();
|
||||
if _parent.is_some() {
|
||||
_prev_child = prev
|
||||
.get(&_parent.clone().unwrap_or("".to_string()))
|
||||
.cloned();
|
||||
prev.insert(_parent.clone().unwrap(), child.clone().unwrap());
|
||||
} else {
|
||||
_prev_child = _root_prev.clone();
|
||||
_root_prev = child.clone();
|
||||
}
|
||||
|
||||
let prev_child = _prev_child.clone().unwrap_or("".to_string());
|
||||
let child_ = child.clone().unwrap_or("".to_string());
|
||||
if _prev_child.is_some() && prev_child != child_ {
|
||||
let _ = cg.set_edge(&prev_child, &child_, None, None);
|
||||
return ();
|
||||
}
|
||||
child = _parent.clone();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::Graph;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Barycenter {
|
||||
pub v: String,
|
||||
pub barycenter: Option<f32>,
|
||||
pub weight: Option<f32>,
|
||||
}
|
||||
|
||||
pub fn barycenter(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
movable: &Vec<String>,
|
||||
) -> Vec<Barycenter> {
|
||||
movable
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let in_v = g.in_edges(v, None).unwrap_or(vec![]);
|
||||
if in_v.len() == 0 {
|
||||
return Barycenter {
|
||||
v: v.clone(),
|
||||
barycenter: None,
|
||||
weight: None,
|
||||
};
|
||||
}
|
||||
|
||||
//( sum, weight )
|
||||
let mut sum = 0.0_f64;
|
||||
let mut weight = 0.0_f64;
|
||||
in_v.iter().for_each(|e| {
|
||||
let edge = g.edge_with_obj(&e).unwrap();
|
||||
let node_u = g.node(&e.v).unwrap();
|
||||
let edge_weight = edge.weight.clone().unwrap_or(0.0) as f64;
|
||||
sum += edge_weight * (node_u.order.clone().unwrap_or(0) as f64);
|
||||
weight += edge_weight;
|
||||
});
|
||||
|
||||
return Barycenter {
|
||||
v: v.clone(),
|
||||
barycenter: Some((sum / weight) as f32),
|
||||
weight: Some(weight as f32),
|
||||
};
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use crate::layout::util::unique_id;
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::{Edge, Graph, GraphOption};
|
||||
|
||||
/*
|
||||
* Constructs a graph that can be used to sort a layer of nodes. The graph will
|
||||
* contain all base and subgraph nodes from the request layer in their original
|
||||
* hierarchy and any edges that are incident on these nodes and are of the type
|
||||
* requested by the "relationship" parameter.
|
||||
*
|
||||
* Nodes from the requested rank that do not have parents are assigned a root
|
||||
* node in the output graph, which is set in the root graph attribute. This
|
||||
* makes it easy to walk the hierarchy of movable nodes during ordering.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Input graph is a DAG
|
||||
* 2. Base nodes in the input graph have a rank attribute
|
||||
* 3. Subgraph nodes in the input graph has minRank and maxRank attributes
|
||||
* 4. Edges have an assigned weight
|
||||
*
|
||||
* Post-conditions:
|
||||
*
|
||||
* 1. Output graph has all nodes in the movable rank with preserved
|
||||
* hierarchy.
|
||||
* 2. Root nodes in the movable layer are made children of the node
|
||||
* indicated by the root attribute of the graph.
|
||||
* 3. Non-movable nodes incident on movable nodes, selected by the
|
||||
* relationship parameter, are included in the graph (without hierarchy).
|
||||
* 4. Edges incident on movable nodes, selected by the relationship
|
||||
* parameter, are added to the output graph.
|
||||
* 5. The weights for copied edges are aggregated as need, since the output
|
||||
* graph is not a multi-graph.
|
||||
*/
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum GraphRelationship {
|
||||
InEdges,
|
||||
OutEdges,
|
||||
}
|
||||
|
||||
pub fn build_layer_graph(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
rank: &i32,
|
||||
relationship: GraphRelationship,
|
||||
) -> Graph<GraphConfig, GraphNode, GraphEdge> {
|
||||
let root = create_root_node(g);
|
||||
let mut result: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(Some(GraphOption {
|
||||
directed: Some(true),
|
||||
compound: Some(true),
|
||||
multigraph: None,
|
||||
}));
|
||||
let graph_label = result.graph_mut();
|
||||
graph_label.root = Some(root.clone());
|
||||
|
||||
g.nodes().iter().for_each(|v| {
|
||||
let node = g.node(v).unwrap();
|
||||
let parent = g.parent(v);
|
||||
|
||||
let in_rank = node.rank == Some(*rank);
|
||||
let in_subgraph_rank = match (node.min_rank, node.max_rank) {
|
||||
(Some(min_rank), Some(max_rank)) => min_rank <= *rank && *rank <= max_rank,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let mut _relationship: Vec<Edge> = g.in_edges(v, None).unwrap_or(vec![]);
|
||||
match relationship {
|
||||
GraphRelationship::OutEdges => {
|
||||
_relationship = g.out_edges(v, None).unwrap_or(vec![]);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
if in_rank || in_subgraph_rank {
|
||||
result.set_node(v.clone(), Some(node.clone()));
|
||||
if parent.is_some() {
|
||||
let _ = result.set_parent(v, parent.cloned());
|
||||
} else {
|
||||
let _ = result.set_parent(v, Some(root.clone()));
|
||||
}
|
||||
|
||||
// This assumes we have only short edges!
|
||||
_relationship.iter().for_each(|e| {
|
||||
let u = if &e.v == v { e.w.clone() } else { e.v.clone() };
|
||||
let edge = result.edge(&u, &v, None);
|
||||
let weight = if edge.is_some() {
|
||||
edge.unwrap().weight.clone().unwrap_or(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut edge_label = GraphEdge::default();
|
||||
edge_label.weight =
|
||||
Some(g.edge_with_obj(&e).unwrap().weight.clone().unwrap_or(0.0) + weight);
|
||||
let _ = result.set_edge(&u, &v, Some(edge_label), None);
|
||||
});
|
||||
|
||||
if node.min_rank.is_some() {
|
||||
let mut graph_node = GraphNode::default();
|
||||
graph_node.border_left_ = node.border_left.as_ref().unwrap().get(rank).cloned();
|
||||
graph_node.border_right_ = node.border_right.as_ref().unwrap().get(rank).cloned();
|
||||
result.set_node(v.clone(), Some(graph_node));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn create_root_node(g: &Graph<GraphConfig, GraphNode, GraphEdge>) -> String {
|
||||
let mut v = format!("_root{}", unique_id());
|
||||
while g.has_node(&v) {
|
||||
v = format!("_root{}", unique_id());
|
||||
}
|
||||
|
||||
v
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use graphlib_rust::Graph;
|
||||
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
|
||||
/*
|
||||
* A function that takes a layering (an array of layers, each with an array of
|
||||
* ordererd nodes) and a graph and returns a weighted crossing count.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Input graph must be simple (not a multigraph), directed, and include
|
||||
* only simple edges.
|
||||
* 2. Edges in the input graph must have assigned weights.
|
||||
*
|
||||
* Post-conditions:
|
||||
*
|
||||
* 1. The graph and layering matrix are left unchanged.
|
||||
*
|
||||
* This algorithm is derived from Barth, et al., "Bilayer Cross Counting."
|
||||
*/
|
||||
|
||||
pub fn cross_count(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
layering: &Vec<Vec<String>>,
|
||||
) -> f32 {
|
||||
let mut cc = 0.0;
|
||||
for i in 1..layering.len() {
|
||||
cc += two_layer_cross_count(g, &layering[i - 1], &layering[i]);
|
||||
}
|
||||
cc
|
||||
}
|
||||
|
||||
pub fn two_layer_cross_count(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
north_layer: &Vec<String>,
|
||||
south_layer: &Vec<String>,
|
||||
) -> f32 {
|
||||
let mut south_pos: HashMap<String, usize> = HashMap::new();
|
||||
for (i, v) in south_layer.iter().enumerate() {
|
||||
south_pos.insert(v.clone(), i);
|
||||
}
|
||||
|
||||
let mut south_entries: Vec<(usize, f32)> = vec![];
|
||||
for v in north_layer {
|
||||
let mut out_edges: Vec<(usize, f32)> = g
|
||||
.out_edges(v, None)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|e| {
|
||||
let pos = south_pos.get(&e.w)?;
|
||||
let weight = g
|
||||
.edge_with_obj(&e)
|
||||
.and_then(|edge| edge.weight)
|
||||
.unwrap_or(0.0);
|
||||
Some((*pos, weight))
|
||||
})
|
||||
.collect();
|
||||
|
||||
out_edges.sort_by(|e1, e2| e1.0.cmp(&e2.0));
|
||||
south_entries.extend(out_edges);
|
||||
}
|
||||
|
||||
let mut first_index: usize = 1;
|
||||
while first_index < south_layer.len() {
|
||||
first_index <<= 1;
|
||||
}
|
||||
let tree_size = 2 * first_index - 1;
|
||||
first_index -= 1;
|
||||
|
||||
let mut tree: Vec<f32> = vec![0.0; tree_size];
|
||||
|
||||
let mut cc = 0.0;
|
||||
for (pos, weight) in south_entries {
|
||||
let mut index = pos + first_index;
|
||||
tree[index] += weight;
|
||||
|
||||
let mut weight_sum = 0.0;
|
||||
while index > 0 {
|
||||
if index % 2 != 0 {
|
||||
weight_sum += tree[index + 1];
|
||||
}
|
||||
index = (index - 1) >> 1;
|
||||
tree[index] += weight;
|
||||
}
|
||||
cc += weight * weight_sum;
|
||||
}
|
||||
|
||||
cc
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::Graph;
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
|
||||
/*
|
||||
* Assigns an initial order value for each node by performing a DFS search
|
||||
* starting from nodes in the first rank. Nodes are assigned an order in their
|
||||
* rank as they are first visited.
|
||||
*
|
||||
* This approach comes from Gansner, et al., "A Technique for Drawing Directed
|
||||
* Graphs."
|
||||
*
|
||||
* Returns a layering matrix with an array per layer and each layer sorted by
|
||||
* the order of its nodes.
|
||||
*/
|
||||
|
||||
pub fn init_order(g: &Graph<GraphConfig, GraphNode, GraphEdge>) -> Vec<Vec<String>> {
|
||||
let mut visited: OrderedHashMap<String, bool> = OrderedHashMap::new();
|
||||
let mut simple_nodes: Vec<String> = g
|
||||
.nodes()
|
||||
.into_iter()
|
||||
.filter(|v| g.children(v).len() == 0)
|
||||
.collect();
|
||||
let max_rank = simple_nodes
|
||||
.iter()
|
||||
.map(|v| g.node(v).unwrap().rank.clone().unwrap_or(0))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mut layers: Vec<Vec<String>> = (0..=max_rank).map(|_| -> Vec<String> { vec![] }).collect();
|
||||
|
||||
fn dfs(
|
||||
v: &String,
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
visited: &mut OrderedHashMap<String, bool>,
|
||||
layers: &mut Vec<Vec<String>>,
|
||||
) {
|
||||
if visited.contains_key(v) {
|
||||
return ();
|
||||
}
|
||||
|
||||
visited.insert(v.clone(), true);
|
||||
let node = g.node(v).unwrap();
|
||||
let node_rank = node.rank.unwrap_or(0) as usize;
|
||||
if layers.get(node_rank.clone()).is_none() {
|
||||
layers.insert(node_rank.clone(), vec![]);
|
||||
}
|
||||
let layer: &mut Vec<String> = layers.get_mut(node_rank.clone()).unwrap();
|
||||
layer.push(v.clone());
|
||||
|
||||
let sucs = g.successors(v).unwrap_or(vec![]);
|
||||
for sv in sucs.iter() {
|
||||
dfs(sv, g, visited, layers)
|
||||
}
|
||||
}
|
||||
|
||||
simple_nodes.sort_by(|v1, v2| {
|
||||
let v1_rank = g.node(v1).unwrap().rank.clone().unwrap_or(0);
|
||||
let v2_rank = g.node(v2).unwrap().rank.clone().unwrap_or(0);
|
||||
|
||||
v1_rank.cmp(&v2_rank)
|
||||
});
|
||||
|
||||
for v in simple_nodes.iter() {
|
||||
dfs(v, g, &mut visited, &mut layers);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
pub mod add_subgraph_constraints;
|
||||
pub mod barycenter;
|
||||
pub mod build_layer_graph;
|
||||
pub mod cross_count;
|
||||
pub mod init_order;
|
||||
pub mod resolve_conflicts;
|
||||
pub mod sort;
|
||||
pub mod sort_subgraph;
|
||||
|
||||
use crate::layout::order::add_subgraph_constraints::add_subgraph_constraints;
|
||||
use crate::layout::order::build_layer_graph::{build_layer_graph, GraphRelationship};
|
||||
use crate::layout::order::cross_count::cross_count;
|
||||
use crate::layout::order::init_order::init_order;
|
||||
use crate::layout::order::sort_subgraph::sort_subgraph;
|
||||
use crate::layout::util;
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::graph::GRAPH_NODE;
|
||||
use graphlib_rust::Graph;
|
||||
|
||||
/*
|
||||
* Applies heuristics to minimize edge crossings in the graph and sets the best
|
||||
* order solution as an order attribute on each node.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Graph must be DAG
|
||||
* 2. Graph nodes must be objects with a "rank" attribute
|
||||
* 3. Graph edges must have the "weight" attribute
|
||||
*
|
||||
* Post-conditions:
|
||||
*
|
||||
* 1. Graph nodes will have an "order" attribute based on the results of the
|
||||
* algorithm.
|
||||
*/
|
||||
|
||||
pub fn order(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let max_rank = util::max_rank(g);
|
||||
let down_layer_ranks: Vec<i32> = (1..=max_rank).collect();
|
||||
let up_layer_ranks: Vec<i32> = (0..max_rank).rev().collect();
|
||||
|
||||
let mut layering = init_order(g);
|
||||
assign_order(g, &layering);
|
||||
|
||||
// Start with the init ordering as the best candidate.
|
||||
// The original dagre.js code starts with bestCC = Infinity, which means
|
||||
// the first sweep's result always replaces it. However, if the init
|
||||
// ordering already has minimal crossings, using it as the starting point
|
||||
// ensures the sweep loop doesn't accidentally select a different ordering
|
||||
// with the same crossing count but worse visual layout (e.g., a mirrored
|
||||
// ordering produced by a biased sweep).
|
||||
let init_cc = cross_count(g, &layering) as f64;
|
||||
let mut best_cc = init_cc;
|
||||
let mut best: Vec<Vec<String>> = layering.clone();
|
||||
|
||||
let mut i = 0;
|
||||
let mut last_best = 0;
|
||||
while last_best < 4 {
|
||||
if i % 2 != 0 {
|
||||
sweep_layer_graphs(g, &down_layer_ranks, GraphRelationship::InEdges, i % 4 >= 2);
|
||||
} else {
|
||||
sweep_layer_graphs(g, &up_layer_ranks, GraphRelationship::OutEdges, i % 4 >= 2);
|
||||
}
|
||||
|
||||
layering = util::build_layer_matrix(g);
|
||||
let cc = cross_count(g, &layering) as f64;
|
||||
if cc < best_cc {
|
||||
last_best = 0;
|
||||
best = layering.clone();
|
||||
best_cc = cc;
|
||||
}
|
||||
|
||||
last_best += 1;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
assign_order(g, &best);
|
||||
}
|
||||
|
||||
fn sweep_layer_graphs(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
ranks: &Vec<i32>,
|
||||
relationship: GraphRelationship,
|
||||
bias_right: bool,
|
||||
) {
|
||||
let mut cg: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(None);
|
||||
|
||||
ranks.iter().for_each(|rank| {
|
||||
let lg = build_layer_graph(g, rank, relationship);
|
||||
let root = lg.graph().root.clone().unwrap_or(GRAPH_NODE.to_string());
|
||||
let sorted = sort_subgraph(&lg, &root, &cg, &bias_right);
|
||||
sorted.vs.iter().enumerate().for_each(|(i, v)| {
|
||||
if let Some(node) = g.node_mut(v) {
|
||||
node.order = Some(i);
|
||||
}
|
||||
});
|
||||
add_subgraph_constraints(&lg, &mut cg, &sorted.vs);
|
||||
});
|
||||
}
|
||||
|
||||
fn assign_order(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>, layering: &Vec<Vec<String>>) {
|
||||
for layer in layering {
|
||||
for (i, v) in layer.iter().enumerate() {
|
||||
let node_label = g.node_mut(v).unwrap();
|
||||
node_label.order = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Given a list of entries of the form {v, barycenter, weight} and a
|
||||
* constraint graph this function will resolve any conflicts between the
|
||||
* constraint graph and the barycenters for the entries. If the barycenters for
|
||||
* an entry would violate a constraint in the constraint graph then we coalesce
|
||||
* the nodes in the conflict into a new node that respects the contraint and
|
||||
* aggregates barycenter and weight information.
|
||||
*
|
||||
* This implementation is based on the description in Forster, "A Fast and
|
||||
* Simple Hueristic for Constrained Two-Level Crossing Reduction," thought it
|
||||
* differs in some specific details.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Each entry has the form {v, barycenter, weight}, or if the node has
|
||||
* no barycenter, then {v}.
|
||||
*
|
||||
* Returns:
|
||||
*
|
||||
* A new list of entries of the form {vs, i, barycenter, weight}. The list
|
||||
* `vs` may either be a singleton or it may be an aggregation of nodes
|
||||
* ordered such that they do not violate constraints from the constraint
|
||||
* graph. The property `i` is the lowest original index of any of the
|
||||
* elements in `vs`.
|
||||
*/
|
||||
use std::collections::HashMap;
|
||||
|
||||
use graphlib_rust::Graph;
|
||||
|
||||
use crate::layout::order::barycenter::Barycenter;
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedBaryEntry {
|
||||
pub vs: Vec<String>,
|
||||
pub i: usize,
|
||||
pub barycenter: Option<f32>,
|
||||
pub weight: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ConflictEntry {
|
||||
indegree: i32,
|
||||
ins: Vec<usize>,
|
||||
outs: Vec<usize>,
|
||||
vs: Vec<String>,
|
||||
i: usize,
|
||||
barycenter: Option<f32>,
|
||||
weight: Option<f32>,
|
||||
merged: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_conflicts(
|
||||
entries: &Vec<Barycenter>,
|
||||
cg: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) -> Vec<ResolvedBaryEntry> {
|
||||
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
|
||||
let mut mapped_entries: Vec<ConflictEntry> = Vec::with_capacity(entries.len());
|
||||
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
id_to_idx.insert(entry.v.clone(), i);
|
||||
mapped_entries.push(ConflictEntry {
|
||||
indegree: 0,
|
||||
ins: vec![],
|
||||
outs: vec![],
|
||||
vs: vec![entry.v.clone()],
|
||||
i,
|
||||
barycenter: entry.barycenter,
|
||||
weight: entry.weight,
|
||||
merged: false,
|
||||
});
|
||||
}
|
||||
|
||||
cg.edges().iter().for_each(|e| {
|
||||
if let (Some(&v_idx), Some(&w_idx)) = (id_to_idx.get(&e.v), id_to_idx.get(&e.w)) {
|
||||
mapped_entries[w_idx].indegree += 1;
|
||||
mapped_entries[v_idx].outs.push(w_idx);
|
||||
}
|
||||
});
|
||||
|
||||
let mut source_set: Vec<usize> = mapped_entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, entry)| if entry.indegree == 0 { Some(idx) } else { None })
|
||||
.collect();
|
||||
|
||||
let mut entries_order: Vec<usize> = Vec::new();
|
||||
|
||||
while let Some(v_idx) = source_set.pop() {
|
||||
entries_order.push(v_idx);
|
||||
|
||||
let ins = mapped_entries[v_idx].ins.clone();
|
||||
ins.into_iter().rev().for_each(|u_idx| {
|
||||
handle_in(&mut mapped_entries, v_idx, u_idx);
|
||||
});
|
||||
|
||||
let outs = mapped_entries[v_idx].outs.clone();
|
||||
outs.into_iter().for_each(|w_idx| {
|
||||
handle_out(&mut mapped_entries, v_idx, w_idx, &mut source_set);
|
||||
});
|
||||
}
|
||||
|
||||
entries_order
|
||||
.into_iter()
|
||||
.filter(|idx| !mapped_entries[*idx].merged)
|
||||
.map(|idx| {
|
||||
let entry = &mapped_entries[idx];
|
||||
ResolvedBaryEntry {
|
||||
vs: entry.vs.clone(),
|
||||
i: entry.i,
|
||||
barycenter: entry.barycenter,
|
||||
weight: entry.weight,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn handle_in(entries: &mut [ConflictEntry], v_idx: usize, u_idx: usize) {
|
||||
if entries[u_idx].merged {
|
||||
return;
|
||||
}
|
||||
|
||||
let u_barycenter = entries[u_idx].barycenter;
|
||||
let v_barycenter = entries[v_idx].barycenter;
|
||||
if u_barycenter.is_none()
|
||||
|| v_barycenter.is_none()
|
||||
|| u_barycenter.unwrap() >= v_barycenter.unwrap()
|
||||
{
|
||||
merge_entries(entries, v_idx, u_idx);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_out(
|
||||
entries: &mut [ConflictEntry],
|
||||
v_idx: usize,
|
||||
w_idx: usize,
|
||||
source_set: &mut Vec<usize>,
|
||||
) {
|
||||
entries[w_idx].ins.push(v_idx);
|
||||
entries[w_idx].indegree -= 1;
|
||||
if entries[w_idx].indegree == 0 {
|
||||
source_set.push(w_idx);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_entries(entries: &mut [ConflictEntry], target_idx: usize, source_idx: usize) {
|
||||
let mut sum = 0.0;
|
||||
let mut weight = 0.0;
|
||||
|
||||
if let (Some(target_barycenter), Some(target_weight)) =
|
||||
(entries[target_idx].barycenter, entries[target_idx].weight)
|
||||
{
|
||||
sum += target_barycenter * target_weight;
|
||||
weight += target_weight;
|
||||
}
|
||||
|
||||
if let (Some(source_barycenter), Some(source_weight)) =
|
||||
(entries[source_idx].barycenter, entries[source_idx].weight)
|
||||
{
|
||||
sum += source_barycenter * source_weight;
|
||||
weight += source_weight;
|
||||
}
|
||||
|
||||
let mut vs = entries[source_idx].vs.clone();
|
||||
vs.extend(entries[target_idx].vs.clone());
|
||||
|
||||
entries[target_idx].vs = vs;
|
||||
entries[target_idx].barycenter = Some(sum / weight);
|
||||
entries[target_idx].weight = Some(weight);
|
||||
entries[target_idx].i = std::cmp::min(entries[source_idx].i, entries[target_idx].i);
|
||||
|
||||
entries[source_idx].merged = true;
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::layout::order::resolve_conflicts::ResolvedBaryEntry;
|
||||
use crate::layout::order::sort_subgraph::SubgraphResult;
|
||||
use crate::layout::util;
|
||||
use crate::layout::util::PartitionResponse;
|
||||
|
||||
pub fn sort(entries: &Vec<ResolvedBaryEntry>, bias_right: &bool) -> SubgraphResult {
|
||||
let parts: PartitionResponse<ResolvedBaryEntry> = util::partition(
|
||||
entries,
|
||||
Box::new(|val: &ResolvedBaryEntry| -> bool { val.barycenter.is_some() }),
|
||||
);
|
||||
let mut sortable = parts.lhs.clone();
|
||||
let mut unsortable = parts.rhs.clone();
|
||||
|
||||
sortable.sort_by(|e1, e2| compare_with_bias(e1, e2, bias_right));
|
||||
unsortable.sort_by(|e1, e2| e2.i.cmp(&e1.i));
|
||||
|
||||
let mut vs: Vec<Vec<String>> = vec![];
|
||||
let mut sum = 0.0;
|
||||
let mut weight = 0.0;
|
||||
let mut vs_index: usize = 0;
|
||||
|
||||
vs_index = consume_unsortable(&mut vs, &mut unsortable, vs_index);
|
||||
sortable.iter().for_each(|entry| {
|
||||
vs_index += entry.vs.len();
|
||||
vs.push(entry.vs.clone());
|
||||
let entry_weight = entry.weight.unwrap_or(0.0);
|
||||
sum += entry.barycenter.unwrap_or(0.0) * entry_weight;
|
||||
weight += entry_weight;
|
||||
vs_index = consume_unsortable(&mut vs, &mut unsortable, vs_index);
|
||||
});
|
||||
|
||||
let mut result = SubgraphResult::default();
|
||||
result.vs = vs.into_iter().flatten().collect();
|
||||
if weight != 0.0 {
|
||||
result.barycenter = Some(sum / weight);
|
||||
result.weight = Some(weight);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn consume_unsortable(
|
||||
vs: &mut Vec<Vec<String>>,
|
||||
unsortable: &mut Vec<ResolvedBaryEntry>,
|
||||
mut index: usize,
|
||||
) -> usize {
|
||||
loop {
|
||||
let last = match unsortable.last() {
|
||||
Some(last) => last,
|
||||
None => return index,
|
||||
};
|
||||
|
||||
if last.i > index {
|
||||
return index;
|
||||
}
|
||||
|
||||
let last = unsortable.pop().unwrap();
|
||||
vs.push(last.vs);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_with_bias(
|
||||
entry_v: &ResolvedBaryEntry,
|
||||
entry_w: &ResolvedBaryEntry,
|
||||
bias: &bool,
|
||||
) -> Ordering {
|
||||
let barycenter_v = entry_v.barycenter.unwrap_or(0.0);
|
||||
let barycenter_w = entry_w.barycenter.unwrap_or(0.0);
|
||||
if barycenter_v < barycenter_w {
|
||||
return Ordering::Less;
|
||||
} else if barycenter_v > barycenter_w {
|
||||
return Ordering::Greater;
|
||||
}
|
||||
|
||||
if !bias {
|
||||
entry_v.i.cmp(&entry_w.i)
|
||||
} else {
|
||||
entry_w.i.cmp(&entry_v.i)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use graphlib_rust::Graph;
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
|
||||
use crate::layout::order::barycenter::{barycenter, Barycenter};
|
||||
use crate::layout::order::resolve_conflicts::{resolve_conflicts, ResolvedBaryEntry};
|
||||
use crate::layout::order::sort::sort;
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SubgraphResult {
|
||||
pub vs: Vec<String>,
|
||||
pub barycenter: Option<f32>,
|
||||
pub weight: Option<f32>,
|
||||
}
|
||||
|
||||
pub fn sort_subgraph(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
v: &String,
|
||||
cg: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
bias_right: &bool,
|
||||
) -> SubgraphResult {
|
||||
let mut movable = g.children(v);
|
||||
let node = g.node(v);
|
||||
let bl = node.and_then(|n| n.border_left_.clone());
|
||||
let br = node.and_then(|n| n.border_right_.clone());
|
||||
let mut subgraphs: OrderedHashMap<String, SubgraphResult> = OrderedHashMap::new();
|
||||
|
||||
if let (Some(bl_), Some(br_)) = (bl.as_ref(), br.as_ref()) {
|
||||
movable = movable
|
||||
.into_iter()
|
||||
.filter(|w| w != bl_ && w != br_)
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut barycenters = barycenter(g, &movable);
|
||||
barycenters.iter_mut().for_each(|entry| {
|
||||
if !g.children(&entry.v).is_empty() {
|
||||
let subgraph_result = sort_subgraph(g, &entry.v, cg, bias_right);
|
||||
subgraphs.insert(entry.v.clone(), subgraph_result.clone());
|
||||
if subgraph_result.barycenter.is_some() {
|
||||
merge_barycenters(entry, &subgraph_result);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut entries = resolve_conflicts(&barycenters, cg);
|
||||
expand_subgraphs(&mut entries, &subgraphs);
|
||||
|
||||
let mut result = sort(&entries, bias_right);
|
||||
if let (Some(bl_), Some(br_)) = (bl, br) {
|
||||
let mut vs: Vec<String> = Vec::with_capacity(result.vs.len() + 2);
|
||||
vs.push(bl_.clone());
|
||||
vs.extend(result.vs.clone());
|
||||
vs.push(br_.clone());
|
||||
result.vs = vs;
|
||||
|
||||
let bl_preds = g.predecessors(&bl_).unwrap_or_default();
|
||||
if !bl_preds.is_empty() {
|
||||
let br_preds = g.predecessors(&br_).unwrap_or_default();
|
||||
if !br_preds.is_empty() {
|
||||
let bl_pred = g.node(&bl_preds[0]).unwrap();
|
||||
let br_pred = g.node(&br_preds[0]).unwrap();
|
||||
let bl_pred_order = bl_pred.order.unwrap_or(0) as f32;
|
||||
let br_pred_order = br_pred.order.unwrap_or(0) as f32;
|
||||
|
||||
let result_barycenter = result.barycenter.unwrap_or(0.0);
|
||||
let result_weight = result.weight.unwrap_or(0.0);
|
||||
result.barycenter = Some(
|
||||
(result_barycenter * result_weight + bl_pred_order + br_pred_order)
|
||||
/ (result_weight + 2.0),
|
||||
);
|
||||
result.weight = Some(result_weight + 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn expand_subgraphs(
|
||||
entries: &mut Vec<ResolvedBaryEntry>,
|
||||
subgraphs: &OrderedHashMap<String, SubgraphResult>,
|
||||
) {
|
||||
entries.iter_mut().for_each(|entry| {
|
||||
let mut vs: Vec<String> = vec![];
|
||||
entry.vs.iter().for_each(|v| {
|
||||
if let Some(subgraph) = subgraphs.get(v) {
|
||||
vs.extend(subgraph.vs.clone());
|
||||
return;
|
||||
}
|
||||
vs.push(v.clone());
|
||||
});
|
||||
|
||||
entry.vs = vs;
|
||||
});
|
||||
}
|
||||
|
||||
fn merge_barycenters(target: &mut Barycenter, other: &SubgraphResult) {
|
||||
let (other_barycenter, other_weight) = match (other.barycenter, other.weight) {
|
||||
(Some(barycenter), Some(weight)) => (barycenter, weight),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
if let (Some(target_barycenter), Some(target_weight)) = (target.barycenter, target.weight) {
|
||||
target.barycenter = Some(
|
||||
(target_barycenter * target_weight + other_barycenter * other_weight)
|
||||
/ (target_weight + other_weight),
|
||||
);
|
||||
target.weight = Some(target_weight + other_weight);
|
||||
} else {
|
||||
target.barycenter = Some(other_barycenter);
|
||||
target.weight = Some(other_weight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::graph::GRAPH_NODE;
|
||||
use graphlib_rust::Graph;
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
|
||||
pub fn parent_dummy_chains(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let post_order_nums: OrderedHashMap<String, (i32, i32)> = postorder(g);
|
||||
let dummy_chains = g.graph().dummy_chains.clone().unwrap_or(vec![]);
|
||||
|
||||
for v_ in dummy_chains.iter() {
|
||||
let mut v = v_.clone();
|
||||
let mut node = g.node(&v).unwrap();
|
||||
let edge_obj = node.edge_obj.clone().unwrap();
|
||||
let path_data = find_path(g, &post_order_nums, &edge_obj.v, &edge_obj.w);
|
||||
let path = path_data.0;
|
||||
let lca = path_data.1;
|
||||
|
||||
let mut path_idx = 0;
|
||||
let mut path_v = path.get(path_idx).cloned().unwrap_or(lca.clone());
|
||||
let mut ascending = true;
|
||||
|
||||
while v != edge_obj.w {
|
||||
node = g.node(&v).unwrap();
|
||||
let node_rank = node.rank.unwrap_or(0);
|
||||
|
||||
if ascending {
|
||||
loop {
|
||||
path_v = path.get(path_idx).cloned().unwrap_or(lca.clone());
|
||||
if path_v == lca {
|
||||
ascending = false;
|
||||
break;
|
||||
}
|
||||
|
||||
let Some(path_v_id) = path_v.as_ref() else {
|
||||
break;
|
||||
};
|
||||
let max_rank = g.node(path_v_id).unwrap().max_rank.unwrap_or(0);
|
||||
if max_rank < node_rank {
|
||||
path_idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !ascending {
|
||||
while path_idx < path.len().saturating_sub(1) {
|
||||
let Some(next) = path.get(path_idx + 1).cloned() else {
|
||||
break;
|
||||
};
|
||||
|
||||
let Some(next_id) = next.as_ref() else {
|
||||
break;
|
||||
};
|
||||
|
||||
if g.node(next_id).unwrap().min_rank.unwrap_or(0) <= node_rank {
|
||||
path_idx += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
path_v = path.get(path_idx).cloned().unwrap_or(lca.clone());
|
||||
}
|
||||
|
||||
let _ = g.set_parent(&v, path_v.clone());
|
||||
|
||||
let Some(next) = g.successors(&v).unwrap_or_default().first().cloned() else {
|
||||
break;
|
||||
};
|
||||
v = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find a path from v to w through the lowest common ancestor (LCA). Return the
|
||||
// full path and the LCA.
|
||||
fn find_path(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
post_order_nums: &OrderedHashMap<String, (i32, i32)>,
|
||||
v: &String,
|
||||
w: &String,
|
||||
) -> (Vec<Option<String>>, Option<String>) {
|
||||
let mut v_path: Vec<Option<String>> = vec![];
|
||||
let mut w_path: Vec<Option<String>> = vec![];
|
||||
|
||||
let v_post_order_num = post_order_nums.get(v).cloned().unwrap_or((0, 0));
|
||||
let w_post_order_num = post_order_nums.get(w).cloned().unwrap_or((0, 0));
|
||||
let low = std::cmp::min(v_post_order_num.0, w_post_order_num.0);
|
||||
let lim = std::cmp::max(v_post_order_num.1, w_post_order_num.1);
|
||||
|
||||
let mut parent: Option<String> = Some(v.clone());
|
||||
loop {
|
||||
parent = parent.and_then(|p| g.parent(&p).cloned());
|
||||
v_path.push(parent.clone());
|
||||
|
||||
let Some(parent_id) = parent.as_ref() else {
|
||||
break;
|
||||
};
|
||||
|
||||
let Some(post_order_num) = post_order_nums.get(parent_id) else {
|
||||
break;
|
||||
};
|
||||
if post_order_num.0 <= low && lim <= post_order_num.1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let lca = parent.clone();
|
||||
|
||||
parent = Some(w.clone());
|
||||
loop {
|
||||
parent = parent.and_then(|p| g.parent(&p).cloned());
|
||||
if parent == lca {
|
||||
break;
|
||||
}
|
||||
|
||||
w_path.push(parent.clone());
|
||||
|
||||
if parent.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
w_path.reverse();
|
||||
v_path.extend(w_path);
|
||||
(v_path, lca)
|
||||
}
|
||||
|
||||
fn postorder(g: &Graph<GraphConfig, GraphNode, GraphEdge>) -> OrderedHashMap<String, (i32, i32)> {
|
||||
let mut result: OrderedHashMap<String, (i32, i32)> = OrderedHashMap::new();
|
||||
let mut lim = 0;
|
||||
|
||||
fn dfs(
|
||||
v: &String,
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
lim: &mut i32,
|
||||
result: &mut OrderedHashMap<String, (i32, i32)>,
|
||||
) {
|
||||
let low = lim.clone();
|
||||
g.children(&v).iter().for_each(|v_| {
|
||||
dfs(v_, g, lim, result);
|
||||
});
|
||||
result.insert(v.clone(), (low, lim.clone()));
|
||||
*lim += 1;
|
||||
}
|
||||
|
||||
g.children(&GRAPH_NODE.to_string()).iter().for_each(|v| {
|
||||
dfs(v, g, &mut lim, &mut result);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
+734
@@ -0,0 +1,734 @@
|
||||
/*
|
||||
* This module provides coordinate assignment based on Brandes and Köpf, "Fast
|
||||
* and Simple Horizontal Coordinate Assignment."
|
||||
*/
|
||||
|
||||
use crate::layout::add_border_segments::BorderTypeName;
|
||||
use crate::layout::util;
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::{Graph, GraphOption};
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/*
|
||||
* Marks all edges in the graph with a type-1 conflict with the "type1Conflict"
|
||||
* property. A type-1 conflict is one where a non-inner segment crosses an
|
||||
* inner segment. An inner segment is an edge with both incident nodes marked
|
||||
* with the "dummy" property.
|
||||
*
|
||||
* This algorithm scans layer by layer, starting with the second, for type-1
|
||||
* conflicts between the current layer and the previous layer. For each layer
|
||||
* it scans the nodes from left to right until it reaches one that is incident
|
||||
* on an inner segment. It then scans predecessors to determine if they have
|
||||
* edges that cross that inner segment. At the end a final scan is done for all
|
||||
* nodes on the current rank to see if they cross the last visited inner
|
||||
* segment.
|
||||
*
|
||||
* This algorithm (safely) assumes that a dummy node will only be incident on a
|
||||
* single node in the layers being scanned.
|
||||
*/
|
||||
fn find_type_1_conflicts(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
layering: &Vec<Vec<String>>,
|
||||
) -> OrderedHashMap<String, OrderedHashMap<String, bool>> {
|
||||
let mut conflicts = OrderedHashMap::new();
|
||||
|
||||
fn visit_layer(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
prev_layer: &Vec<String>,
|
||||
layer: &Vec<String>,
|
||||
conflicts: &mut OrderedHashMap<String, OrderedHashMap<String, bool>>,
|
||||
) {
|
||||
// last visited node in the previous layer that is incident on an inner
|
||||
// segment.
|
||||
let mut k0 = 0;
|
||||
// Tracks the last node in this layer scanned for crossings with a type-1
|
||||
// segment.
|
||||
let mut scan_pos = 0;
|
||||
let prev_layer_length = prev_layer.len();
|
||||
let last_node = layer.last().unwrap().clone();
|
||||
|
||||
for (i, v) in layer.iter().enumerate() {
|
||||
let w = find_other_inner_segment_node(g, v);
|
||||
let k1 = if let Some(ref w) = w {
|
||||
g.node(w).unwrap().order.unwrap_or(0)
|
||||
} else {
|
||||
prev_layer_length
|
||||
};
|
||||
|
||||
if w.is_some() || *v == last_node {
|
||||
for scan_node in layer[scan_pos..=i].iter() {
|
||||
for u in g.predecessors(scan_node).unwrap() {
|
||||
let u_label = g.node(&u).unwrap();
|
||||
let u_pos = u_label.order.unwrap_or(0);
|
||||
if (u_pos < k0 || k1 < u_pos)
|
||||
&& !(u_label.dummy.is_some()
|
||||
&& g.node(scan_node).unwrap().dummy.is_some())
|
||||
{
|
||||
add_conflict(conflicts, &u, scan_node);
|
||||
}
|
||||
}
|
||||
}
|
||||
scan_pos = i + 1;
|
||||
k0 = k1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layering
|
||||
.iter()
|
||||
.filter(|layer| !layer.is_empty())
|
||||
.reduce(|prev_layer, layer| {
|
||||
visit_layer(g, &prev_layer, layer, &mut conflicts);
|
||||
|
||||
layer
|
||||
});
|
||||
|
||||
conflicts
|
||||
}
|
||||
|
||||
pub fn find_type_2_conflicts(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
layering: &Vec<Vec<String>>,
|
||||
) -> OrderedHashMap<String, OrderedHashMap<String, bool>> {
|
||||
let mut conflicts: OrderedHashMap<String, OrderedHashMap<String, bool>> = OrderedHashMap::new();
|
||||
|
||||
fn scan(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
south: &Vec<String>,
|
||||
south_pos: usize,
|
||||
south_end: usize,
|
||||
prev_north_border: i32,
|
||||
next_north_border: i32,
|
||||
conflicts: &mut OrderedHashMap<String, OrderedHashMap<String, bool>>,
|
||||
) {
|
||||
for i in south_pos..south_end {
|
||||
let v = south.get(i).cloned().unwrap();
|
||||
if g.node(&v).is_some() && g.node(&v).unwrap().dummy.is_some() {
|
||||
let preds = g.predecessors(&v).unwrap_or(vec![]);
|
||||
preds.iter().for_each(|u| {
|
||||
let u_node_ = g.node(u);
|
||||
if let Some(u_node) = u_node_ {
|
||||
let u_node_order = u_node.order.unwrap_or(0) as i32;
|
||||
if u_node.dummy.is_some()
|
||||
&& (u_node_order < prev_north_border
|
||||
|| u_node_order > next_north_border)
|
||||
{
|
||||
add_conflict(conflicts, u, &v);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_layer(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
north: &Vec<String>,
|
||||
south: &Vec<String>,
|
||||
conflicts: &mut OrderedHashMap<String, OrderedHashMap<String, bool>>,
|
||||
) {
|
||||
let mut prev_north_pos: i32 = -1;
|
||||
let mut next_north_pos: i32 = -1;
|
||||
let mut south_pos: usize = 0;
|
||||
|
||||
for (south_lookahead, v) in south.iter().enumerate() {
|
||||
if let Some(v_node) = g.node(v) {
|
||||
if v_node.dummy.as_deref() == Some("border") {
|
||||
let predecessors = g.predecessors(v).unwrap_or(vec![]);
|
||||
if !predecessors.is_empty() {
|
||||
next_north_pos =
|
||||
g.node(&predecessors[0]).unwrap().order.unwrap_or(0) as i32;
|
||||
scan(
|
||||
g,
|
||||
south,
|
||||
south_pos,
|
||||
south_lookahead,
|
||||
prev_north_pos,
|
||||
next_north_pos,
|
||||
conflicts,
|
||||
);
|
||||
south_pos = south_lookahead;
|
||||
prev_north_pos = next_north_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan(
|
||||
g,
|
||||
south,
|
||||
south_pos,
|
||||
south.len(),
|
||||
next_north_pos,
|
||||
north.len() as i32,
|
||||
conflicts,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for i in 1..layering.len() {
|
||||
if layering[i - 1].is_empty() || layering[i].is_empty() {
|
||||
continue;
|
||||
}
|
||||
visit_layer(g, &layering[i - 1], &layering[i], &mut conflicts);
|
||||
}
|
||||
|
||||
conflicts
|
||||
}
|
||||
|
||||
fn find_other_inner_segment_node(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
v: &String,
|
||||
) -> Option<String> {
|
||||
if g.node(v).unwrap().dummy.is_some() {
|
||||
let preds = g.predecessors(v).unwrap_or(vec![]);
|
||||
return preds
|
||||
.iter()
|
||||
.find(|u| g.node(u).unwrap().dummy.is_some())
|
||||
.cloned();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn add_conflict(
|
||||
conflicts: &mut OrderedHashMap<String, OrderedHashMap<String, bool>>,
|
||||
v_: &String,
|
||||
w_: &String,
|
||||
) {
|
||||
let mut v = v_.clone();
|
||||
let mut w = w_.clone();
|
||||
if v.cmp(&w) == Ordering::Greater {
|
||||
let tmp = v;
|
||||
v = w;
|
||||
w = tmp;
|
||||
}
|
||||
|
||||
let _conflicts_v = conflicts.get(&v);
|
||||
if _conflicts_v.is_none() {
|
||||
conflicts.insert(v.clone(), OrderedHashMap::new());
|
||||
}
|
||||
|
||||
let conflicts_v = conflicts.get_mut(&v).unwrap();
|
||||
conflicts_v.insert(w.clone(), true);
|
||||
}
|
||||
|
||||
pub fn has_conflict(
|
||||
conflicts: &OrderedHashMap<String, OrderedHashMap<String, bool>>,
|
||||
v_: &String,
|
||||
w_: &String,
|
||||
) -> bool {
|
||||
let mut v = v_;
|
||||
let mut w = w_;
|
||||
if v_.cmp(w_) == Ordering::Greater {
|
||||
let tmp = v;
|
||||
v = w;
|
||||
w = tmp;
|
||||
}
|
||||
|
||||
let empty_hashmap: OrderedHashMap<String, bool> = OrderedHashMap::new();
|
||||
conflicts.get(v).unwrap_or(&empty_hashmap).contains_key(w)
|
||||
}
|
||||
|
||||
/*
|
||||
* Try to align nodes into vertical "blocks" where possible. This algorithm
|
||||
* attempts to align a node with one of its median neighbors. If the edge
|
||||
* connecting a neighbor is a type-1 conflict then we ignore that possibility.
|
||||
* If a previous node has already formed a block with a node after the node
|
||||
* we're trying to form a block with, we also ignore that possibility - our
|
||||
* blocks would be split in that scenario.
|
||||
*/
|
||||
// root -> 0, align -> 1
|
||||
pub fn vertical_alignment(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
layering: &Vec<Vec<String>>,
|
||||
conflicts: &OrderedHashMap<String, OrderedHashMap<String, bool>>,
|
||||
neighbor_fn: Box<dyn Fn(&Graph<GraphConfig, GraphNode, GraphEdge>, &String) -> Vec<String>>,
|
||||
) -> (OrderedHashMap<String, String>, Vec<String>) {
|
||||
let mut root: OrderedHashMap<String, String> = OrderedHashMap::new();
|
||||
let mut align: OrderedHashMap<String, String> = OrderedHashMap::new();
|
||||
let mut pos: OrderedHashMap<String, usize> = OrderedHashMap::new();
|
||||
|
||||
// We cache the position here based on the layering because the graph and
|
||||
// layering may be out of sync. The layering matrix is manipulated to
|
||||
// generate different extreme alignments.
|
||||
layering.iter().for_each(|layer| {
|
||||
layer.iter().enumerate().for_each(|(order, v)| {
|
||||
root.insert(v.clone(), v.clone());
|
||||
align.insert(v.clone(), v.clone());
|
||||
pos.insert(v.clone(), order);
|
||||
});
|
||||
});
|
||||
|
||||
layering.iter().for_each(|layer| {
|
||||
let mut prev_idx: i32 = -1;
|
||||
layer.iter().for_each(|v| {
|
||||
let mut ws: Vec<String> = neighbor_fn(g, v)
|
||||
.into_iter()
|
||||
.filter(|w| pos.contains_key(w))
|
||||
.collect();
|
||||
if ws.len() > 0 {
|
||||
ws.sort_by(|w1, w2| pos.get(w1).unwrap().cmp(pos.get(w2).unwrap()));
|
||||
let mp = (ws.len() as f32 - 1.0) / 2.0;
|
||||
let mut i = mp as usize;
|
||||
let il = mp.ceil() as usize;
|
||||
while i <= il {
|
||||
let w = ws[i].clone();
|
||||
if align.get(v).unwrap() == v
|
||||
&& prev_idx < (pos.get(&w).cloned().unwrap() as i32)
|
||||
&& !has_conflict(conflicts, v, &w)
|
||||
{
|
||||
align.insert(w.clone(), v.clone());
|
||||
|
||||
root.insert(v.clone(), root.get(&w).unwrap().clone());
|
||||
align.insert(v.clone(), root.get(&w).unwrap().clone());
|
||||
|
||||
prev_idx = pos.get(&w).unwrap().clone() as i32;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let align_keys: Vec<String> = align.keys().cloned().collect();
|
||||
return (root, align_keys);
|
||||
}
|
||||
|
||||
pub fn horizontal_compaction(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
layering: &Vec<Vec<String>>,
|
||||
root: &OrderedHashMap<String, String>,
|
||||
align: &Vec<String>,
|
||||
reverse_sep: bool,
|
||||
) -> OrderedHashMap<String, f32> {
|
||||
// This portion of the algorithm differs from BK due to a number of problems.
|
||||
// Instead of their algorithm we construct a new block graph and do two
|
||||
// sweeps. The first sweep places blocks with the smallest possible
|
||||
// coordinates. The second sweep removes unused space by moving blocks to the
|
||||
// greatest coordinates without violating separation.
|
||||
let mut xs: OrderedHashMap<String, f32> = OrderedHashMap::new();
|
||||
let block_g: Graph<GraphOption, String, f32> =
|
||||
build_block_graph(g, layering, root, reverse_sep);
|
||||
let border_type = if reverse_sep {
|
||||
BorderTypeName::BorderLeft
|
||||
} else {
|
||||
BorderTypeName::BorderRight
|
||||
};
|
||||
|
||||
fn iterate(
|
||||
set_xs_func: fn(
|
||||
&String,
|
||||
&mut OrderedHashMap<String, f32>,
|
||||
&Graph<GraphOption, String, f32>,
|
||||
&Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
&BorderTypeName,
|
||||
),
|
||||
next_nodes_func: Box<dyn Fn(&Graph<GraphOption, String, f32>, &String) -> Vec<String>>,
|
||||
block_g: &Graph<GraphOption, String, f32>,
|
||||
xs: &mut OrderedHashMap<String, f32>,
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
border_type: &BorderTypeName,
|
||||
) {
|
||||
let mut stack = block_g.nodes();
|
||||
let mut elem = stack.pop();
|
||||
let mut visited: OrderedHashMap<String, bool> = OrderedHashMap::new();
|
||||
while elem.is_some() {
|
||||
let elem_ = elem.unwrap();
|
||||
if visited.contains_key(&elem_) {
|
||||
set_xs_func(&elem_, xs, block_g, g, border_type);
|
||||
} else {
|
||||
visited.insert(elem_.clone(), true);
|
||||
stack.push(elem_.clone());
|
||||
stack.append(&mut (next_nodes_func(block_g, &elem_) as Vec<String>));
|
||||
}
|
||||
|
||||
elem = stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
// First pass, assign smallest coordinates
|
||||
fn pass1(
|
||||
elem: &String,
|
||||
xs: &mut OrderedHashMap<String, f32>,
|
||||
block_g: &Graph<GraphOption, String, f32>,
|
||||
_g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
_border_type: &BorderTypeName,
|
||||
) {
|
||||
let in_edges = block_g.in_edges(elem, None).unwrap_or(vec![]);
|
||||
let val: f32 = in_edges.iter().fold(0.0, |acc, e| {
|
||||
let pred_x = xs.get(&e.v).cloned().unwrap_or(0.0);
|
||||
let sep = block_g.edge_with_obj(&e).cloned().unwrap_or(0.0);
|
||||
let ev = pred_x + sep;
|
||||
acc.max(ev)
|
||||
});
|
||||
xs.insert(elem.clone(), val);
|
||||
}
|
||||
|
||||
// Second pass, assign greatest coordinates
|
||||
fn pass2(
|
||||
elem: &String,
|
||||
xs: &mut OrderedHashMap<String, f32>,
|
||||
block_g: &Graph<GraphOption, String, f32>,
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
border_type: &BorderTypeName,
|
||||
) {
|
||||
let out_edges = block_g.out_edges(elem, None).unwrap_or(vec![]);
|
||||
let min: f64 = out_edges.iter().fold(f64::INFINITY, |acc, e| {
|
||||
let succ_x = xs.get(&e.w).cloned().unwrap_or(0.0);
|
||||
let sep = block_g.edge_with_obj(&e).cloned().unwrap_or(0.0);
|
||||
let ev = succ_x - sep;
|
||||
|
||||
acc.min(ev as f64)
|
||||
});
|
||||
|
||||
if min != f64::INFINITY {
|
||||
let Some(node) = g.node(elem) else {
|
||||
return;
|
||||
};
|
||||
if node.border_type.as_ref() != Some(border_type) {
|
||||
xs.insert(
|
||||
elem.clone(),
|
||||
xs.get(elem).cloned().unwrap_or(0.0).max(min as f32),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
iterate(
|
||||
pass1,
|
||||
Box::new(|block_g, v| -> Vec<String> { block_g.predecessors(v).unwrap_or(vec![]) }),
|
||||
&block_g,
|
||||
&mut xs,
|
||||
g,
|
||||
&border_type,
|
||||
);
|
||||
|
||||
iterate(
|
||||
pass2,
|
||||
Box::new(|block_g, v| -> Vec<String> { block_g.successors(v).unwrap_or(vec![]) }),
|
||||
&block_g,
|
||||
&mut xs,
|
||||
g,
|
||||
&border_type,
|
||||
);
|
||||
|
||||
// Assign x coordinates to all nodes
|
||||
align.iter().for_each(|v| {
|
||||
let Some(root_id) = root.get(v) else {
|
||||
return;
|
||||
};
|
||||
let Some(root_x) = xs.get(root_id).cloned() else {
|
||||
return;
|
||||
};
|
||||
xs.insert(v.clone(), root_x);
|
||||
});
|
||||
|
||||
xs
|
||||
}
|
||||
|
||||
pub fn build_block_graph(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
layering: &Vec<Vec<String>>,
|
||||
root: &OrderedHashMap<String, String>,
|
||||
reverse_sep: bool,
|
||||
) -> Graph<GraphOption, String, f32> {
|
||||
let mut block_graph: Graph<GraphOption, String, f32> = Graph::new(None);
|
||||
let graph_label = g.graph();
|
||||
let sep_fn: Box<dyn Fn(&Graph<GraphConfig, GraphNode, GraphEdge>, &String, &String) -> f32> =
|
||||
sep(
|
||||
graph_label.nodesep.as_ref().unwrap(),
|
||||
graph_label.edgesep.as_ref().unwrap(),
|
||||
&reverse_sep,
|
||||
);
|
||||
|
||||
layering.iter().for_each(|layer| {
|
||||
let mut u: Option<String> = None;
|
||||
layer.iter().for_each(|v| {
|
||||
let v_root = root.get(v).unwrap();
|
||||
block_graph.set_node(v_root.clone(), None);
|
||||
if u.is_some() {
|
||||
let u_ = u.as_ref().unwrap();
|
||||
let u_root = root.get(u_).unwrap();
|
||||
if u_root != v_root {
|
||||
let prev_max = block_graph
|
||||
.edge(&u_root, &v_root, None)
|
||||
.cloned()
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let _ = block_graph.set_edge(
|
||||
&u_root,
|
||||
&v_root,
|
||||
Some((sep_fn(g, v, u_) as f32).max(prev_max)),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
u = Some(v.clone());
|
||||
});
|
||||
});
|
||||
|
||||
block_graph
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns the alignment that has the smallest width of the given alignments.
|
||||
*/
|
||||
pub fn find_smallest_width_alignment<'a>(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
xss: &'a OrderedHashMap<String, OrderedHashMap<String, f32>>,
|
||||
) -> &'a OrderedHashMap<String, f32> {
|
||||
xss.values()
|
||||
.min_by(|xs1, xs2| {
|
||||
let mut max1 = f64::NEG_INFINITY;
|
||||
let mut min1 = f64::INFINITY;
|
||||
|
||||
xs1.iter().for_each(|(v, x)| {
|
||||
let half_width = width(g, v) / 2.0;
|
||||
max1 = max1.max((*x + half_width) as f64);
|
||||
min1 = min1.min((*x - half_width) as f64);
|
||||
});
|
||||
|
||||
let r1 = max1 - min1;
|
||||
|
||||
let mut max2 = f64::NEG_INFINITY;
|
||||
let mut min2 = f64::INFINITY;
|
||||
|
||||
xs2.iter().for_each(|(v, x)| {
|
||||
let half_width = width(g, v) / 2.0;
|
||||
max2 = max2.max((*x + half_width) as f64);
|
||||
min2 = min2.min((*x - half_width) as f64);
|
||||
});
|
||||
|
||||
let r2 = max2 - min2;
|
||||
|
||||
r1.total_cmp(&r2)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/*
|
||||
* Align the coordinates of each of the layout alignments such that
|
||||
* left-biased alignments have their minimum coordinate at the same point as
|
||||
* the minimum coordinate of the smallest width alignment and right-biased
|
||||
* alignments have their maximum coordinate at the same point as the maximum
|
||||
* coordinate of the smallest width alignment.
|
||||
*/
|
||||
fn align_coordinates(
|
||||
xss: &mut OrderedHashMap<String, OrderedHashMap<String, f32>>,
|
||||
align_to: &OrderedHashMap<String, f32>,
|
||||
) {
|
||||
let align_to_vals: Vec<f32> = align_to.values().cloned().collect();
|
||||
let align_to_min = align_to_vals
|
||||
.iter()
|
||||
.min_by(|a, b| a.partial_cmp(b).unwrap())
|
||||
.unwrap()
|
||||
.clone();
|
||||
let align_to_max = align_to_vals
|
||||
.iter()
|
||||
.max_by(|a, b| a.partial_cmp(b).unwrap())
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
vec!["u", "d"].iter().for_each(|vert| {
|
||||
vec!["l", "r"].iter().for_each(|horiz| {
|
||||
let alignment = vert.to_string() + horiz;
|
||||
let xs = xss.get(&alignment).unwrap();
|
||||
if xs == align_to {
|
||||
return;
|
||||
}
|
||||
|
||||
let xs_vals: Vec<f32> = xs.values().cloned().collect();
|
||||
let delta = if *horiz == "l" {
|
||||
align_to_min
|
||||
- *xs_vals
|
||||
.iter()
|
||||
.min_by(|a, b| a.partial_cmp(b).unwrap())
|
||||
.unwrap()
|
||||
} else {
|
||||
align_to_max
|
||||
- *xs_vals
|
||||
.iter()
|
||||
.max_by(|a, b| a.partial_cmp(b).unwrap())
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
if delta != 0.0 {
|
||||
let _xs = xss.get_mut(&alignment).unwrap();
|
||||
_xs.values_mut().for_each(|x| {
|
||||
*x += delta;
|
||||
});
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn balance(
|
||||
xss: &OrderedHashMap<String, OrderedHashMap<String, f32>>,
|
||||
align: Option<String>,
|
||||
) -> OrderedHashMap<String, f32> {
|
||||
let mut xss_clone = xss.clone();
|
||||
if let Some(ul) = xss_clone.get_mut(&"ul".to_string()) {
|
||||
let keys: Vec<String> = ul.keys().cloned().collect();
|
||||
keys.iter().for_each(|v| {
|
||||
if align.is_some() {
|
||||
let empty_hash: OrderedHashMap<String, f32> = OrderedHashMap::new();
|
||||
let empty_string = "".to_string();
|
||||
let _balance = xss
|
||||
.get(align.as_ref().unwrap_or(&empty_string))
|
||||
.unwrap_or(&empty_hash)
|
||||
.get(v)
|
||||
.cloned()
|
||||
.unwrap_or(0.0);
|
||||
let item = ul.get_mut(v).unwrap();
|
||||
*item = _balance;
|
||||
} else {
|
||||
let mut xs: Vec<f32> = xss
|
||||
.values()
|
||||
.map(|_xs| _xs.get(v).cloned().unwrap_or(f64::INFINITY as f32))
|
||||
.collect();
|
||||
xs.sort_by(|f1, f2| f1.total_cmp(f2));
|
||||
let xs1 = xs.get(1).cloned().unwrap_or(0.0);
|
||||
let xs2 = xs.get(2).cloned().unwrap_or(0.0);
|
||||
let item = ul.get_mut(v).unwrap();
|
||||
*item = (xs1 + xs2) / 2.0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
xss_clone.get_mut(&"ul".to_string()).unwrap().to_owned()
|
||||
}
|
||||
|
||||
pub fn position_x(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) -> OrderedHashMap<String, f32> {
|
||||
let layering = util::build_layer_matrix(g);
|
||||
if layering.iter().all(|layer| layer.is_empty()) {
|
||||
return OrderedHashMap::new();
|
||||
}
|
||||
let mut conflicts = find_type_1_conflicts(g, &layering);
|
||||
conflicts.extend(find_type_2_conflicts(g, &layering));
|
||||
|
||||
let mut xss: OrderedHashMap<String, OrderedHashMap<String, f32>> = OrderedHashMap::new();
|
||||
let mut adjusted_layering: Option<Vec<Vec<String>>> = None;
|
||||
vec!["u", "d"].iter().for_each(|vert| {
|
||||
adjusted_layering = Some(if vert == &"u" {
|
||||
layering.clone()
|
||||
} else {
|
||||
let mut layering_ = layering.clone();
|
||||
layering_.reverse();
|
||||
layering_
|
||||
});
|
||||
|
||||
vec!["l", "r"].iter().for_each(|horiz| {
|
||||
if horiz == &"r" {
|
||||
adjusted_layering
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.for_each(|inner| inner.reverse());
|
||||
}
|
||||
|
||||
let neighbor_fn: Box<
|
||||
dyn Fn(&Graph<GraphConfig, GraphNode, GraphEdge>, &String) -> Vec<String>,
|
||||
> = if vert == &"u" {
|
||||
Box::new(
|
||||
|g: &Graph<GraphConfig, GraphNode, GraphEdge>, v: &String| -> Vec<String> {
|
||||
g.predecessors(v).unwrap_or(vec![])
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Box::new(
|
||||
|g: &Graph<GraphConfig, GraphNode, GraphEdge>, v: &String| -> Vec<String> {
|
||||
g.successors(v).unwrap_or(vec![])
|
||||
},
|
||||
)
|
||||
};
|
||||
let align = vertical_alignment(
|
||||
g,
|
||||
adjusted_layering.as_ref().unwrap(),
|
||||
&conflicts,
|
||||
neighbor_fn,
|
||||
);
|
||||
let mut xs = horizontal_compaction(
|
||||
g,
|
||||
adjusted_layering.as_ref().unwrap(),
|
||||
&align.0,
|
||||
&align.1,
|
||||
horiz == &"r",
|
||||
);
|
||||
if horiz == &"r" {
|
||||
let mut xs_: OrderedHashMap<String, f32> = OrderedHashMap::new();
|
||||
xs.iter().for_each(|(k, v)| {
|
||||
xs_.insert(k.clone(), -v.clone());
|
||||
});
|
||||
xs = xs_;
|
||||
}
|
||||
|
||||
xss.insert(String::from(vert.to_string() + horiz), xs);
|
||||
});
|
||||
});
|
||||
|
||||
let smallest_width = find_smallest_width_alignment(g, &xss).clone();
|
||||
align_coordinates(&mut xss, &smallest_width);
|
||||
return balance(&xss, g.graph().align.clone());
|
||||
}
|
||||
|
||||
fn sep(
|
||||
node_sep: &f32,
|
||||
edge_sep: &f32,
|
||||
reverse_sep: &bool,
|
||||
) -> Box<dyn Fn(&Graph<GraphConfig, GraphNode, GraphEdge>, &String, &String) -> f32> {
|
||||
let node_sep_ = node_sep.clone();
|
||||
let edge_sep_ = edge_sep.clone();
|
||||
let reverse_sep_ = reverse_sep.clone();
|
||||
Box::new(
|
||||
move |g: &Graph<GraphConfig, GraphNode, GraphEdge>, v: &String, w: &String| -> f32 {
|
||||
let v_label = g.node(v).unwrap();
|
||||
let w_label = g.node(w).unwrap();
|
||||
let mut sum: f32 = 0.0;
|
||||
let mut delta: f32 = 0.0;
|
||||
|
||||
sum += v_label.width / 2.0;
|
||||
if let Some(v_label_labelpos) = v_label.labelpos.as_ref() {
|
||||
if v_label_labelpos == "l" {
|
||||
delta = -v_label.width / 2.0;
|
||||
} else if v_label_labelpos == "r" {
|
||||
delta = v_label.width / 2.0;
|
||||
}
|
||||
}
|
||||
if delta != 0.0 {
|
||||
sum += if reverse_sep_ { delta } else { -delta }
|
||||
}
|
||||
|
||||
sum += if v_label.dummy.is_some() {
|
||||
edge_sep_
|
||||
} else {
|
||||
node_sep_
|
||||
} / 2.0;
|
||||
sum += if w_label.dummy.is_some() {
|
||||
edge_sep_
|
||||
} else {
|
||||
node_sep_
|
||||
} / 2.0;
|
||||
|
||||
sum += w_label.width / 2.0;
|
||||
delta = 0.0;
|
||||
if let Some(w_label_labelpos) = w_label.labelpos.as_ref() {
|
||||
if w_label_labelpos == "l" {
|
||||
delta = w_label.width / 2.0;
|
||||
} else if w_label_labelpos == "r" {
|
||||
delta = -w_label.width / 2.0;
|
||||
}
|
||||
}
|
||||
if delta != 0.0 {
|
||||
sum += if reverse_sep_ { delta } else { -delta }
|
||||
}
|
||||
|
||||
sum
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn width(g: &Graph<GraphConfig, GraphNode, GraphEdge>, v: &String) -> f32 {
|
||||
g.node(v).unwrap().width
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
pub mod bk;
|
||||
|
||||
use crate::layout::position::bk::position_x;
|
||||
use crate::layout::util;
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::Graph;
|
||||
|
||||
pub fn position(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let mut ncg: Graph<GraphConfig, GraphNode, GraphEdge> = util::as_non_compound_graph(g);
|
||||
|
||||
position_y(&mut ncg);
|
||||
position_x(&mut ncg).iter().for_each(|(v, x)| {
|
||||
g.node_mut(v).unwrap().x = x.clone();
|
||||
g.node_mut(v).unwrap().y = ncg.node(v).unwrap().y;
|
||||
});
|
||||
}
|
||||
|
||||
fn position_y(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let layering = util::build_layer_matrix(g);
|
||||
let rank_sep = g.graph().ranksep.clone().unwrap();
|
||||
let mut prev_y = 0.0;
|
||||
layering.iter().for_each(|layer| {
|
||||
let max_height: f32 = layer
|
||||
.iter()
|
||||
.map(|v| g.node(v).unwrap().height as i32)
|
||||
.max()
|
||||
.unwrap_or(0) as f32;
|
||||
|
||||
layer.iter().for_each(|v| {
|
||||
let node = g.node_mut(v).unwrap();
|
||||
node.y = prev_y + max_height / 2.0;
|
||||
});
|
||||
|
||||
prev_y += max_height + rank_sep;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use crate::layout::rank::util::slack;
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::{Edge, Graph, GraphOption};
|
||||
|
||||
/*
|
||||
* Constructs a spanning tree with tight edges and adjusted the input node's
|
||||
* ranks to achieve this. A tight edge is one that is has a length that matches
|
||||
* its "minlen" attribute.
|
||||
*
|
||||
* The basic structure for this function is derived from Gansner, et al., "A
|
||||
* Technique for Drawing Directed Graphs."
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Graph must be a DAG.
|
||||
* 2. Graph must be connected.
|
||||
* 3. Graph must have at least one node.
|
||||
* 5. Graph nodes must have been previously assigned a "rank" property that
|
||||
* respects the "minlen" property of incident edges.
|
||||
* 6. Graph edges must have a "minlen" property.
|
||||
*
|
||||
* Post-conditions:
|
||||
*
|
||||
* - Graph nodes will have their rank adjusted to ensure that all edges are
|
||||
* tight.
|
||||
*
|
||||
* Returns a tree (undirected graph) that is constructed using only "tight"
|
||||
* edges.
|
||||
*/
|
||||
|
||||
pub fn feasible_tree(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) -> Graph<GraphConfig, GraphNode, GraphEdge> {
|
||||
let mut t: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(Some(GraphOption {
|
||||
directed: Some(false),
|
||||
multigraph: Some(false),
|
||||
compound: Some(false),
|
||||
}));
|
||||
|
||||
// Choose arbitrary node from which to start our tree
|
||||
let start = g.nodes().first().cloned().unwrap_or("".to_string());
|
||||
let size = g.node_count();
|
||||
t.set_node(start, Some(GraphNode::default()));
|
||||
|
||||
while tight_tree(&mut t, g) < size {
|
||||
if let Some(edge) = find_min_stack_edge(&t, g) {
|
||||
let delta = if t.has_node(&edge.v) {
|
||||
slack(g, &edge)
|
||||
} else {
|
||||
-1 * slack(g, &edge)
|
||||
};
|
||||
shift_ranks(&t, g, delta);
|
||||
}
|
||||
}
|
||||
|
||||
t
|
||||
}
|
||||
|
||||
/*
|
||||
* Finds a maximal tree of tight edges and returns the number of nodes in the
|
||||
* tree.
|
||||
*/
|
||||
fn tight_tree(
|
||||
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) -> usize {
|
||||
fn dfs(
|
||||
v: &String,
|
||||
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) {
|
||||
let node_edges = g.node_edges(v, None).unwrap_or(vec![]);
|
||||
for node_edge in node_edges {
|
||||
let edge_v = node_edge.v.clone();
|
||||
let mut _w: Option<&String> = None;
|
||||
if v == &edge_v {
|
||||
_w = Some(&node_edge.w);
|
||||
} else {
|
||||
_w = Some(&edge_v);
|
||||
}
|
||||
let w = _w.unwrap().clone();
|
||||
if !t.has_node(&w) && slack(g, &node_edge) == 0 {
|
||||
t.set_node(w.clone(), Some(GraphNode::default()));
|
||||
let _ = t.set_edge(&v, &w, Some(GraphEdge::default()), None);
|
||||
dfs(&w, t, g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nodes = t.nodes();
|
||||
for node_id in nodes.into_iter() {
|
||||
dfs(&node_id, t, g);
|
||||
}
|
||||
return t.node_count();
|
||||
}
|
||||
|
||||
/*
|
||||
* Finds the edge with the smallest slack that is incident on tree and returns
|
||||
* it.
|
||||
*/
|
||||
fn find_min_stack_edge(
|
||||
t: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) -> Option<Edge> {
|
||||
let edges = g.edges();
|
||||
let result = edges
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut e_: Option<i32> = None;
|
||||
if t.has_node(&e.v) != t.has_node(&e.w) {
|
||||
e_ = Some(slack(g, e));
|
||||
}
|
||||
(e, e_)
|
||||
})
|
||||
.filter(|(_, e_)| e_.is_some())
|
||||
.min_by(|(_, e1_), (_, e2_)| e1_.unwrap().cmp(&e2_.unwrap()));
|
||||
|
||||
if result.is_some() {
|
||||
Some(result.unwrap().0.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn shift_ranks(
|
||||
t: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
delta: i32,
|
||||
) {
|
||||
let nodes = t.nodes();
|
||||
for node_id in nodes.into_iter() {
|
||||
let node_ = g.node_mut(&node_id);
|
||||
if let Some(node) = node_ {
|
||||
node.rank = Some(node.rank.unwrap_or(0) + delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
pub mod feasible_tree;
|
||||
pub mod network_simplex;
|
||||
pub mod util;
|
||||
|
||||
use crate::layout::rank::feasible_tree::feasible_tree;
|
||||
use crate::layout::rank::network_simplex::network_simplex;
|
||||
use crate::layout::rank::util::longest_path;
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::Graph;
|
||||
|
||||
/*
|
||||
* Assigns a rank to each node in the input graph that respects the "minlen"
|
||||
* constraint specified on edges between nodes.
|
||||
*
|
||||
* This basic structure is derived from Gansner, et al., "A Technique for
|
||||
* Drawing Directed Graphs."
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Graph must be a connected DAG
|
||||
* 2. Graph nodes must be objects
|
||||
* 3. Graph edges must have "weight" and "minlen" attributes
|
||||
*
|
||||
* Post-conditions:
|
||||
*
|
||||
* 1. Graph nodes will have a "rank" attribute based on the results of the
|
||||
* algorithm. Ranks can start at any index (including negative), we'll
|
||||
* fix them up later.
|
||||
*/
|
||||
|
||||
pub fn rank(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let _ranker = g.graph().ranker.clone();
|
||||
match _ranker {
|
||||
Some(ranker) => {
|
||||
let ranker_str = &*ranker;
|
||||
if ranker_str == "network-simplex" {
|
||||
network_simplex(g);
|
||||
} else if ranker_str == "tight-tree" {
|
||||
tight_tree_ranker(g);
|
||||
} else if ranker_str == "longest-path" {
|
||||
longest_path(g);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
network_simplex(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tight_tree_ranker(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
longest_path(g);
|
||||
feasible_tree(g);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::layout::rank::feasible_tree::feasible_tree;
|
||||
use crate::layout::rank::util::{longest_path, slack};
|
||||
use crate::layout::util;
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::algo::postorder::postorder;
|
||||
use graphlib_rust::{Edge, Graph};
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
|
||||
/*
|
||||
* The network simplex algorithm assigns ranks to each node in the input graph
|
||||
* and iteratively improves the ranking to reduce the length of edges.
|
||||
*
|
||||
* Preconditions:
|
||||
*
|
||||
* 1. The input graph must be a DAG.
|
||||
* 2. All nodes in the graph must have an object value.
|
||||
* 3. All edges in the graph must have "minlen" and "weight" attributes.
|
||||
*
|
||||
* Postconditions:
|
||||
*
|
||||
* 1. All nodes in the graph will have an assigned "rank" attribute that has
|
||||
* been optimized by the network simplex algorithm. Ranks start at 0.
|
||||
*
|
||||
*
|
||||
* A rough sketch of the algorithm is as follows:
|
||||
*
|
||||
* 1. Assign initial ranks to each node. We use the longest path algorithm,
|
||||
* which assigns ranks to the lowest position possible. In general this
|
||||
* leads to very wide bottom ranks and unnecessarily long edges.
|
||||
* 2. Construct a feasible tight tree. A tight tree is one such that all
|
||||
* edges in the tree have no slack (difference between length of edge
|
||||
* and minlen for the edge). This by itself greatly improves the assigned
|
||||
* rankings by shorting edges.
|
||||
* 3. Iteratively find edges that have negative cut values. Generally a
|
||||
* negative cut value indicates that the edge could be removed and a new
|
||||
* tree edge could be added to produce a more compact graph.
|
||||
*
|
||||
* Much of the algorithms here are derived from Gansner, et al., "A Technique
|
||||
* for Drawing Directed Graphs." The structure of the file roughly follows the
|
||||
* structure of the overall algorithm.
|
||||
*/
|
||||
|
||||
pub fn network_simplex(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let mut simplified = util::simplify(g);
|
||||
|
||||
longest_path(&mut simplified);
|
||||
|
||||
let mut t: Graph<GraphConfig, GraphNode, GraphEdge> = feasible_tree(&mut simplified);
|
||||
init_low_lim_values(&mut t, None);
|
||||
init_cut_values(&mut t, &mut simplified);
|
||||
|
||||
while let Some(e) = leave_edge(&t) {
|
||||
let Some(f) = enter_edge(&t, &simplified, &e) else {
|
||||
break;
|
||||
};
|
||||
exchange_edges(&mut t, &mut simplified, &e, f);
|
||||
}
|
||||
|
||||
for v in g.nodes() {
|
||||
let Some(node) = g.node_mut(&v) else {
|
||||
continue;
|
||||
};
|
||||
let Some(simple_node) = simplified.node(&v) else {
|
||||
continue;
|
||||
};
|
||||
node.rank = simple_node.rank;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Initializes cut values for all edges in the tree.
|
||||
*/
|
||||
fn init_cut_values(
|
||||
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) {
|
||||
let node_ids = t.nodes();
|
||||
let mut vs = postorder(t, &node_ids);
|
||||
vs.pop();
|
||||
for node_id in vs {
|
||||
assign_cut_value(t, g, &node_id);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Given the tight tree, its graph, and a child in the graph calculate and
|
||||
* return the cut value for the edge between the child and its parent.
|
||||
*/
|
||||
fn assign_cut_value(
|
||||
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
child: &String,
|
||||
) {
|
||||
let cutvalue = calc_cut_value(t, g, child);
|
||||
let child_lab_ = t.node_mut(child);
|
||||
if let Some(child_lab) = child_lab_ {
|
||||
let parent = child_lab.parent.clone().unwrap_or("".to_string());
|
||||
let edge_label_ = t.edge_mut(&child, &parent, None);
|
||||
if let Some(edge_label) = edge_label_ {
|
||||
edge_label.cutvalue = Some(cutvalue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Given the tight tree, its graph, and a child in the graph calculate and
|
||||
* return the cut value for the edge between the child and its parent.
|
||||
*/
|
||||
fn calc_cut_value(
|
||||
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
child: &String,
|
||||
) -> f32 {
|
||||
// The accumulated cut value for the edge between this node and its parent
|
||||
let mut cut_value = 0.0;
|
||||
let child_lab_ = t.node_mut(child);
|
||||
if let Some(child_lab) = child_lab_ {
|
||||
let parent = child_lab.parent.clone().unwrap_or("".to_string());
|
||||
// True if the child is on the tail end of the edge in the directed graph
|
||||
let mut child_is_tail = true;
|
||||
// The graph's view of the tree edge we're inspecting
|
||||
let mut graph_edge = g.edge_mut(child, &parent, None);
|
||||
|
||||
if graph_edge.is_none() {
|
||||
child_is_tail = false;
|
||||
graph_edge = g.edge_mut(&parent, &child, None);
|
||||
}
|
||||
|
||||
cut_value = graph_edge
|
||||
.cloned()
|
||||
.unwrap_or(GraphEdge::default())
|
||||
.weight
|
||||
.unwrap_or(0.0);
|
||||
let edge_objs_ = g.node_edges(child, None);
|
||||
if let Some(edge_objs) = edge_objs_ {
|
||||
for e in edge_objs {
|
||||
let is_out_edge = &e.v == child;
|
||||
let other = if is_out_edge {
|
||||
e.w.clone()
|
||||
} else {
|
||||
e.v.clone()
|
||||
};
|
||||
|
||||
if other != parent {
|
||||
let points_to_head = is_out_edge == child_is_tail;
|
||||
let other_weight = g
|
||||
.edge_with_obj(&e)
|
||||
.unwrap_or(&GraphEdge::default())
|
||||
.weight
|
||||
.unwrap_or(0.0);
|
||||
|
||||
cut_value += if points_to_head {
|
||||
other_weight
|
||||
} else {
|
||||
-other_weight
|
||||
};
|
||||
|
||||
if is_tree_edge(t, child, &other) {
|
||||
let out_cut_value = t
|
||||
.edge(&child, &other, None)
|
||||
.unwrap_or(&GraphEdge::default())
|
||||
.cutvalue
|
||||
.unwrap_or(0.0);
|
||||
cut_value += if points_to_head {
|
||||
-out_cut_value
|
||||
} else {
|
||||
out_cut_value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cut_value
|
||||
}
|
||||
|
||||
fn init_low_lim_values(tree: &mut Graph<GraphConfig, GraphNode, GraphEdge>, root_: Option<String>) {
|
||||
let mut root = tree.nodes().first().cloned().unwrap_or("".to_string());
|
||||
if root_.is_some() {
|
||||
root = root_.unwrap();
|
||||
}
|
||||
let mut visited: OrderedHashMap<String, bool> = OrderedHashMap::new();
|
||||
dfs_assign_low_lim(tree, &mut visited, 1, &root, None);
|
||||
}
|
||||
|
||||
fn dfs_assign_low_lim(
|
||||
tree: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
visited: &mut OrderedHashMap<String, bool>,
|
||||
next_lim_: usize,
|
||||
v: &String,
|
||||
parent: Option<&String>,
|
||||
) -> usize {
|
||||
let low = next_lim_.clone();
|
||||
let mut next_lim = next_lim_.clone();
|
||||
|
||||
visited.entry(v.clone()).or_insert(true);
|
||||
let neighbors_ = tree.neighbors(v);
|
||||
if let Some(neighbors) = neighbors_ {
|
||||
for w in neighbors.into_iter() {
|
||||
if !visited.contains_key(&w) {
|
||||
next_lim = dfs_assign_low_lim(tree, visited, next_lim.clone(), &w, Some(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let label_ = tree.node_mut(v);
|
||||
if let Some(label) = label_ {
|
||||
label.low = Some(low);
|
||||
label.lim = Some(next_lim.clone());
|
||||
next_lim += 1;
|
||||
|
||||
if parent.is_some() {
|
||||
label.parent = Some(parent.cloned().unwrap());
|
||||
} else {
|
||||
// TODO should be able to remove this when we incrementally update low lim
|
||||
label.parent = None;
|
||||
}
|
||||
}
|
||||
|
||||
next_lim
|
||||
}
|
||||
|
||||
fn leave_edge(tree: &Graph<GraphConfig, GraphNode, GraphEdge>) -> Option<Edge> {
|
||||
let edge_objs = tree.edges();
|
||||
edge_objs
|
||||
.iter()
|
||||
.find(|edge_obj| {
|
||||
tree.edge_with_obj(edge_obj)
|
||||
.unwrap_or(&GraphEdge::default())
|
||||
.cutvalue
|
||||
.unwrap_or(0.0)
|
||||
< 0.0
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn enter_edge(
|
||||
t: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
edge: &Edge,
|
||||
) -> Option<Edge> {
|
||||
let mut v = edge.v.clone();
|
||||
let mut w = edge.w.clone();
|
||||
|
||||
// For the rest of this function we assume that v is the tail and w is the
|
||||
// head, so if we don't have this edge in the graph we should flip it to
|
||||
// match the correct orientation.
|
||||
if !g.has_edge(&v, &w, None) {
|
||||
v = edge.w.clone();
|
||||
w = edge.v.clone();
|
||||
}
|
||||
|
||||
let v_label = t.node(&v).cloned().unwrap_or(GraphNode::default());
|
||||
let w_label = t.node(&w).cloned().unwrap_or(GraphNode::default());
|
||||
let mut tail_label = &v_label;
|
||||
let mut flip = false;
|
||||
|
||||
// If the root is in the tail of the edge then we need to flip the logic that
|
||||
// checks for the head and tail nodes in the candidates function below.
|
||||
if v_label.lim.clone().unwrap_or(0) > w_label.lim.clone().unwrap_or(0) {
|
||||
tail_label = &w_label;
|
||||
flip = true;
|
||||
}
|
||||
|
||||
let edge_objs = g.edges();
|
||||
let candidates = edge_objs.iter().filter(|edge_obj| {
|
||||
let v_node = t.node(&edge_obj.v).cloned().unwrap_or(GraphNode::default());
|
||||
let w_node = t.node(&edge_obj.w).cloned().unwrap_or(GraphNode::default());
|
||||
flip == is_descendant(&v_node, tail_label) && flip != is_descendant(&w_node, tail_label)
|
||||
});
|
||||
|
||||
candidates
|
||||
.min_by(|e1, e2| slack(g, e1).cmp(&slack(g, e2)))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn exchange_edges(
|
||||
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
e: &Edge,
|
||||
f: Edge,
|
||||
) {
|
||||
let v = e.v.clone();
|
||||
let w = e.w.clone();
|
||||
t.remove_edge(&v, &w, None);
|
||||
let _ = t.set_edge(&f.v, &f.w, Some(GraphEdge::default()), None);
|
||||
init_low_lim_values(t, None);
|
||||
init_cut_values(t, g);
|
||||
update_ranks(t, g);
|
||||
}
|
||||
|
||||
fn update_ranks(
|
||||
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) {
|
||||
let Some(root) = t.nodes().first().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut children: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for v in t.nodes() {
|
||||
let parent = t.node(&v).unwrap_or(&GraphNode::default()).parent.clone();
|
||||
let Some(parent) = parent else {
|
||||
continue;
|
||||
};
|
||||
|
||||
children.entry(parent).or_default().push(v);
|
||||
}
|
||||
|
||||
let mut stack: Vec<String> = vec![root.clone()];
|
||||
while let Some(parent) = stack.pop() {
|
||||
let Some(vs) = children.get(&parent).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for v in vs {
|
||||
let mut edge = g.edge(&v, &parent, None);
|
||||
let mut flipped = false;
|
||||
if edge.is_none() {
|
||||
edge = g.edge(&parent, &v, None);
|
||||
flipped = true;
|
||||
}
|
||||
|
||||
let minlen = edge.unwrap_or(&GraphEdge::default()).minlen.unwrap_or(0.0);
|
||||
let parent_rank = g
|
||||
.node(&parent)
|
||||
.unwrap_or(&GraphNode::default())
|
||||
.rank
|
||||
.unwrap_or(0);
|
||||
if let Some(v_node) = g.node_mut(&v) {
|
||||
v_node.rank = Some(
|
||||
parent_rank
|
||||
+ if flipped {
|
||||
minlen as i32
|
||||
} else {
|
||||
-(minlen as i32)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
stack.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns true if the edge is in the tree.
|
||||
*/
|
||||
fn is_tree_edge(tree: &Graph<GraphConfig, GraphNode, GraphEdge>, u: &String, v: &String) -> bool {
|
||||
tree.has_edge(&u, &v, None)
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns true if the specified node is descendant of the root node per the
|
||||
* assigned low and lim attributes in the tree.
|
||||
*/
|
||||
fn is_descendant(v_label: &GraphNode, root_label: &GraphNode) -> bool {
|
||||
let low = root_label.low.clone().unwrap_or(0);
|
||||
let v_lim = v_label.lim.clone().unwrap_or(0);
|
||||
let root_lim = root_label.lim.clone().unwrap_or(0);
|
||||
low <= v_lim && v_lim <= root_lim
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
use crate::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::{Edge, Graph};
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
|
||||
/*
|
||||
* Initializes ranks for the input graph using the longest path algorithm. This
|
||||
* algorithm scales well and is fast in practice, it yields rather poor
|
||||
* solutions. Nodes are pushed to the lowest layer possible, leaving the bottom
|
||||
* ranks wide and leaving edges longer than necessary. However, due to its
|
||||
* speed, this algorithm is good for getting an initial ranking that can be fed
|
||||
* into other algorithms.
|
||||
*
|
||||
* This algorithm does not normalize layers because it will be used by other
|
||||
* algorithms in most cases. If using this algorithm directly, be sure to
|
||||
* run normalize at the end.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Input graph is a DAG.
|
||||
* 2. Input graph node labels can be assigned properties.
|
||||
*
|
||||
* Post-conditions:
|
||||
*
|
||||
* 1. Each node will be assign an (unnormalized) "rank" property.
|
||||
*/
|
||||
|
||||
pub fn longest_path(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let mut visited: OrderedHashMap<String, bool> = OrderedHashMap::new();
|
||||
|
||||
fn dfs(
|
||||
v: &String,
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
visited: &mut OrderedHashMap<String, bool>,
|
||||
) -> i32 {
|
||||
let node_label = g.node(v);
|
||||
if visited.contains_key(v) {
|
||||
return node_label
|
||||
.cloned()
|
||||
.unwrap_or(GraphNode::default())
|
||||
.rank
|
||||
.unwrap_or(0);
|
||||
}
|
||||
visited.insert(v.clone(), true);
|
||||
|
||||
let ranks: Vec<i32> = g
|
||||
.out_edges(v, None)
|
||||
.unwrap_or(vec![])
|
||||
.iter()
|
||||
.map(|e| {
|
||||
dfs(&e.w, g, visited)
|
||||
- (g.edge_with_obj(&e)
|
||||
.cloned()
|
||||
.unwrap_or(GraphEdge::default())
|
||||
.minlen
|
||||
.unwrap_or(0.0)
|
||||
.round() as i32)
|
||||
})
|
||||
.collect();
|
||||
let rank: i32 = ranks.iter().min().cloned().unwrap_or(0) as i32;
|
||||
{
|
||||
let _node_label = g.node_mut(v);
|
||||
if let Some(node_label) = _node_label {
|
||||
node_label.rank = Some(rank.clone());
|
||||
}
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
for node_id in g.sources().into_iter() {
|
||||
dfs(&node_id, g, &mut visited);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns the amount of slack for the given edge. The slack is defined as the
|
||||
* difference between the length of the edge and its minimum length.
|
||||
*/
|
||||
pub fn slack(g: &Graph<GraphConfig, GraphNode, GraphEdge>, e: &Edge) -> i32 {
|
||||
let w_rank = g
|
||||
.node(&e.w)
|
||||
.cloned()
|
||||
.unwrap_or(GraphNode::default())
|
||||
.rank
|
||||
.unwrap_or(0);
|
||||
let v_rank = g
|
||||
.node(&e.v)
|
||||
.cloned()
|
||||
.unwrap_or(GraphNode::default())
|
||||
.rank
|
||||
.unwrap_or(0);
|
||||
let minlen = g
|
||||
.edge_with_obj(e)
|
||||
.cloned()
|
||||
.unwrap_or(GraphEdge::default())
|
||||
.minlen
|
||||
.unwrap_or(10.0)
|
||||
.round() as i32;
|
||||
return w_rank - v_rank - minlen;
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
use crate::layout::{GraphConfig, GraphEdge, GraphNode};
|
||||
use crate::GraphEdgePoint;
|
||||
use graphlib_rust::{Graph, GraphOption};
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
// VENDORING PATCH: upstream used a `static mut` mutated in an `unsafe` block,
|
||||
// which is a data race when the engine renders on multiple threads (e.g. the
|
||||
// parallel `cargo test` suite). An `AtomicUsize` is behaviour-preserving (still
|
||||
// hands out monotonic unique ids) and removes the only `unsafe` in this crate.
|
||||
static UNIQUE_STARTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub fn unique_id() -> usize {
|
||||
UNIQUE_STARTER.fetch_add(1, Ordering::Relaxed) + 1
|
||||
}
|
||||
|
||||
/*
|
||||
* Adds a dummy node to the graph and return v.
|
||||
*/
|
||||
pub fn add_dummy_node(
|
||||
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
node_type: String,
|
||||
data: GraphNode,
|
||||
name: String,
|
||||
) -> String {
|
||||
// Generating Random Id
|
||||
let mut node_id = format!("{}{}", name, unique_id());
|
||||
while graph.has_node(&node_id) {
|
||||
node_id = format!("{}{}", name, unique_id());
|
||||
}
|
||||
|
||||
// Setting in Graph
|
||||
let mut node_data = data.clone();
|
||||
node_data.dummy = Some(node_type);
|
||||
graph.set_node(node_id.clone(), Some(node_data));
|
||||
return node_id;
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns a new graph with only simple edges. Handles aggregation of data
|
||||
* associated with multi-edges.
|
||||
*/
|
||||
pub fn simplify(
|
||||
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) -> Graph<GraphConfig, GraphNode, GraphEdge> {
|
||||
let mut simplified: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(Some(GraphOption {
|
||||
directed: Some(true),
|
||||
multigraph: None,
|
||||
compound: None,
|
||||
}));
|
||||
simplified.set_graph(g.graph().clone());
|
||||
|
||||
let nodes = g.nodes();
|
||||
let edges = g.edges();
|
||||
for node_id in nodes.into_iter() {
|
||||
simplified.set_node(node_id.clone(), g.node(&node_id).cloned());
|
||||
}
|
||||
for edge_obj in edges.into_iter() {
|
||||
let edge_label_ = g.edge_with_obj(&edge_obj);
|
||||
|
||||
let mut simple_label = simplified
|
||||
.edge(&edge_obj.v, &edge_obj.w, None)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
let mut edge = GraphEdge::default();
|
||||
edge.weight = Some(0.0);
|
||||
edge.minlen = Some(1.0);
|
||||
edge
|
||||
});
|
||||
|
||||
let edge_label = edge_label_.cloned().unwrap_or_else(|| {
|
||||
let mut edge = GraphEdge::default();
|
||||
edge.weight = Some(0.0);
|
||||
edge.minlen = Some(1.0);
|
||||
edge
|
||||
});
|
||||
|
||||
let minlen = edge_label.minlen.unwrap_or(1.0);
|
||||
simple_label.minlen = Some(simple_label.minlen.unwrap_or(1.0).max(minlen));
|
||||
|
||||
let weight = edge_label.weight.unwrap_or(0.0);
|
||||
simple_label.weight = Some(simple_label.weight.unwrap_or(0.0) + weight);
|
||||
|
||||
let _ = simplified.set_edge(&edge_obj.v, &edge_obj.w, Some(simple_label), None);
|
||||
}
|
||||
|
||||
simplified
|
||||
}
|
||||
|
||||
/*
|
||||
* it implement same logic as simplify do but, it uses Ref instead of creating new graph
|
||||
*/
|
||||
pub fn simplify_ref(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let edges = g.edges();
|
||||
for edge_obj in edges.into_iter() {
|
||||
let edge_label_ = g.edge_mut_with_obj(&edge_obj);
|
||||
if let Some(edge_label) = edge_label_ {
|
||||
if edge_label.weight.is_none() {
|
||||
edge_label.weight = Some(0.0);
|
||||
}
|
||||
if edge_label.minlen.is_none() {
|
||||
edge_label.minlen = Some(1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_non_compound_graph(
|
||||
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) -> Graph<GraphConfig, GraphNode, GraphEdge> {
|
||||
let mut simplified: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(Some(GraphOption {
|
||||
directed: Some(true),
|
||||
multigraph: Some(true),
|
||||
compound: Some(false),
|
||||
}));
|
||||
simplified.set_graph(g.graph().clone());
|
||||
|
||||
let nodes = g.nodes();
|
||||
for v in nodes.into_iter() {
|
||||
if g.children(&v).len() == 0 {
|
||||
simplified.set_node(
|
||||
v.clone(),
|
||||
Some(g.node(&v).cloned().unwrap_or(GraphNode::default())),
|
||||
);
|
||||
}
|
||||
}
|
||||
let edge_objs = g.edges();
|
||||
for e in edge_objs.into_iter() {
|
||||
let _ = simplified.set_edge_with_obj(&e, g.edge_with_obj(&e).cloned());
|
||||
}
|
||||
|
||||
return simplified;
|
||||
}
|
||||
|
||||
pub fn transfer_node_edge_labels(
|
||||
source: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
destination: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
) {
|
||||
let nodes = source.nodes();
|
||||
for v in nodes.into_iter() {
|
||||
if source.children(&v).len() == 0 {
|
||||
destination.set_node(
|
||||
v.clone(),
|
||||
Some(source.node(&v).cloned().unwrap_or(GraphNode::default())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let edge_objs = source.edges();
|
||||
for e in edge_objs.into_iter() {
|
||||
let _ = destination.set_edge_with_obj(&e, source.edge_with_obj(&e).cloned());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Rect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/*
|
||||
* Finds where a line starting at point ({x, y}) would intersect a rectangle
|
||||
* ({x, y, width, height}) if it were pointing at the rectangle's center.
|
||||
*/
|
||||
pub fn intersect_rect(rect: &Rect, point: &GraphEdgePoint) -> GraphEdgePoint {
|
||||
let x = rect.x;
|
||||
let y = rect.y;
|
||||
|
||||
// Rectangle intersection algorithm from:
|
||||
// http://math.stackexchange.com/questions/108113/find-edge-between-two-boxes
|
||||
let dx = point.x - x;
|
||||
let dy = point.y - y;
|
||||
let w = rect.width / 2.0;
|
||||
let h = rect.height / 2.0;
|
||||
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
return GraphEdgePoint { x: x + w, y };
|
||||
}
|
||||
|
||||
let (sx, sy) = if (dy.abs() * w) > (dx.abs() * h) {
|
||||
// Intersection is top or bottom of rect.
|
||||
if dy < 0.0 {
|
||||
(-h * dx / dy, -h)
|
||||
} else {
|
||||
(h * dx / dy, h)
|
||||
}
|
||||
} else {
|
||||
// Intersection is left or right of rect.
|
||||
if dx < 0.0 {
|
||||
(-w, -w * dy / dx)
|
||||
} else {
|
||||
(w, w * dy / dx)
|
||||
}
|
||||
};
|
||||
|
||||
GraphEdgePoint {
|
||||
x: x + sx,
|
||||
y: y + sy,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_close(left: f32, right: f32) {
|
||||
assert!((left - right).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_rect_returns_boundary_point_for_center_point() {
|
||||
let rect = Rect {
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
width: 8.0,
|
||||
height: 4.0,
|
||||
};
|
||||
let point = GraphEdgePoint { x: 10.0, y: 20.0 };
|
||||
|
||||
let intersection = intersect_rect(&rect, &point);
|
||||
|
||||
assert_close(intersection.x, 14.0);
|
||||
assert_close(intersection.y, 20.0);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Given a DAG with each node assigned "rank" and "order" properties, this
|
||||
* function will produce a matrix with the ids of each node.
|
||||
*/
|
||||
pub fn build_layer_matrix(g: &Graph<GraphConfig, GraphNode, GraphEdge>) -> Vec<Vec<String>> {
|
||||
let mut layering: Vec<OrderedHashMap<usize, String>> =
|
||||
(0..=max_rank(g)).map(|_| OrderedHashMap::new()).collect();
|
||||
|
||||
g.nodes().iter().for_each(|v| {
|
||||
let node = g.node(v).unwrap();
|
||||
let Some(rank) = node.rank else {
|
||||
return;
|
||||
};
|
||||
|
||||
let layer: &mut OrderedHashMap<usize, String> = layering.get_mut(rank as usize).unwrap();
|
||||
layer.insert(node.order.unwrap_or(0), v.clone());
|
||||
});
|
||||
|
||||
layering
|
||||
.into_iter()
|
||||
.map(|layer| {
|
||||
let mut keys: Vec<usize> = layer.keys().cloned().collect();
|
||||
keys.sort();
|
||||
keys.iter()
|
||||
.map(|key| layer.get(key).cloned().unwrap())
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/*
|
||||
* Adjusts the ranks for all nodes in the graph such that all nodes v have
|
||||
* rank(v) >= 0 and at least one node w has rank(w) = 0.
|
||||
*/
|
||||
pub fn normalize_ranks(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let node_ids = graph.nodes();
|
||||
let node_ranks: Vec<i32> = node_ids
|
||||
.iter()
|
||||
.map(|v| {
|
||||
graph
|
||||
.node(v)
|
||||
.unwrap_or(&GraphNode::default())
|
||||
.rank
|
||||
.clone()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
let min = node_ranks.iter().min().cloned().unwrap_or(0);
|
||||
node_ids.iter().for_each(|node_id| {
|
||||
let node_ = graph.node_mut(node_id);
|
||||
if let Some(node) = node_ {
|
||||
if node.rank.is_some() {
|
||||
node.rank = Some(node.rank.unwrap() - min);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove_empty_ranks(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
|
||||
let nodes: Vec<String> = graph.nodes();
|
||||
if nodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let node_ranks: Vec<i32> = nodes
|
||||
.iter()
|
||||
.map(|v| {
|
||||
graph
|
||||
.node(v)
|
||||
.cloned()
|
||||
.unwrap_or(GraphNode::default())
|
||||
.rank
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
let offset: i32 = node_ranks.iter().min().cloned().unwrap_or(0);
|
||||
let max_rank: i32 = node_ranks.iter().max().cloned().unwrap_or(0) - offset;
|
||||
|
||||
let mut layers: Vec<Vec<String>> = vec![Vec::new(); (max_rank + 1).max(0) as usize];
|
||||
for v in &nodes {
|
||||
let rank = graph
|
||||
.node(v)
|
||||
.unwrap_or(&GraphNode::default())
|
||||
.rank
|
||||
.unwrap_or(0)
|
||||
- offset;
|
||||
if rank >= 0 {
|
||||
layers[rank as usize].push(v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let node_rank_factor = graph.graph().node_rank_factor.clone().unwrap_or(0.0) as i32;
|
||||
if node_rank_factor <= 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut delta = 0;
|
||||
for (i, vs) in layers.iter().enumerate() {
|
||||
let i = i as i32;
|
||||
if vs.is_empty() && i % node_rank_factor != 0 {
|
||||
delta -= 1;
|
||||
} else if delta != 0 {
|
||||
for v in vs {
|
||||
if let Some(node) = graph.node_mut(v) {
|
||||
node.rank = Some(node.rank.unwrap_or(0) + delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_border_node(
|
||||
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
prefix: &str,
|
||||
rank: Option<&usize>,
|
||||
order: Option<&usize>,
|
||||
) -> String {
|
||||
let mut node = GraphNode::default();
|
||||
|
||||
if rank.is_some() {
|
||||
node.rank = Some(rank.cloned().unwrap_or(0) as i32);
|
||||
}
|
||||
if order.is_some() {
|
||||
node.order = Some(order.cloned().unwrap_or(0));
|
||||
}
|
||||
|
||||
return add_dummy_node(graph, "border".to_string(), node, prefix.to_string());
|
||||
}
|
||||
|
||||
pub fn max_rank(g: &Graph<GraphConfig, GraphNode, GraphEdge>) -> i32 {
|
||||
g.nodes()
|
||||
.iter()
|
||||
.filter_map(|v| g.node(v).and_then(|n| n.rank))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PartitionResponse<V> {
|
||||
pub lhs: Vec<V>,
|
||||
pub rhs: Vec<V>,
|
||||
}
|
||||
/*
|
||||
* Partition a collection into two groups: `lhs` and `rhs`. If the supplied
|
||||
* function returns true for an entry it goes into `lhs`. Otherwise it goes
|
||||
* into `rhs.
|
||||
*/
|
||||
pub fn partition<V: Clone>(
|
||||
collection: &Vec<V>,
|
||||
fn_: Box<dyn Fn(&V) -> bool>,
|
||||
) -> PartitionResponse<V> {
|
||||
let mut result: PartitionResponse<V> = PartitionResponse {
|
||||
lhs: vec![],
|
||||
rhs: vec![],
|
||||
};
|
||||
|
||||
collection.iter().for_each(|val| {
|
||||
if fn_(val) {
|
||||
result.lhs.push(val.clone());
|
||||
} else {
|
||||
result.rhs.push(val.clone());
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
Vendored
+131
@@ -0,0 +1,131 @@
|
||||
pub mod layout;
|
||||
|
||||
use crate::layout::add_border_segments::BorderTypeName;
|
||||
use graphlib_rust::Edge;
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GraphNode {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub class: Option<String>,
|
||||
pub label: Option<GraphEdge>,
|
||||
pub padding: Option<f32>,
|
||||
pub padding_x: Option<f32>,
|
||||
pub padding_y: Option<f32>,
|
||||
pub rx: Option<f32>,
|
||||
pub ry: Option<f32>,
|
||||
pub shape: Option<String>,
|
||||
pub dummy: Option<String>,
|
||||
pub rank: Option<i32>,
|
||||
pub min_rank: Option<i32>,
|
||||
pub max_rank: Option<i32>,
|
||||
pub order: Option<usize>,
|
||||
pub border_top: Option<String>,
|
||||
pub border_bottom: Option<String>,
|
||||
pub border_left: Option<OrderedHashMap<i32, String>>,
|
||||
pub border_right: Option<OrderedHashMap<i32, String>>,
|
||||
pub border_left_: Option<String>,
|
||||
pub border_right_: Option<String>,
|
||||
pub low: Option<usize>,
|
||||
pub lim: Option<usize>,
|
||||
pub parent: Option<String>,
|
||||
pub e: Option<Edge>,
|
||||
pub edge_label: Option<GraphEdge>,
|
||||
pub edge_obj: Option<Edge>,
|
||||
pub labelpos: Option<String>,
|
||||
pub border_type: Option<BorderTypeName>,
|
||||
pub self_edges: Vec<(Edge, GraphEdge)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GraphEdgePoint {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraphEdge {
|
||||
pub forward_name: Option<String>,
|
||||
pub reversed: Option<bool>,
|
||||
pub minlen: Option<f32>,
|
||||
pub weight: Option<f32>,
|
||||
pub width: Option<f32>,
|
||||
pub height: Option<f32>,
|
||||
pub label_rank: Option<i32>,
|
||||
pub labeloffset: Option<f32>,
|
||||
pub labelpos: Option<String>,
|
||||
pub nesting_edge: Option<bool>,
|
||||
pub cutvalue: Option<f32>,
|
||||
pub points: Option<Vec<GraphEdgePoint>>,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraphConfig {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
|
||||
pub nodesep: Option<f32>, // default 50
|
||||
pub edgesep: Option<f32>, // default 20
|
||||
pub ranksep: Option<f32>, // default 50
|
||||
pub marginx: Option<f32>, // default 0
|
||||
pub marginy: Option<f32>, // default 0
|
||||
pub rankdir: Option<String>, // lr, lr, tb, bt // default tb
|
||||
pub acyclicer: Option<String>, // greedy, dfs, unknown-should-still-work
|
||||
pub ranker: Option<String>, // "longest-path", "tight-tree", "network-simplex", "unknown-should-still-work"
|
||||
pub align: Option<String>,
|
||||
pub nesting_root: Option<String>, // id of dummy nesting root
|
||||
pub root: Option<String>,
|
||||
pub node_rank_factor: Option<f32>, // default 0
|
||||
pub dummy_chains: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Default for GraphConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
nodesep: Some(50.0),
|
||||
edgesep: Some(20.0),
|
||||
ranksep: Some(50.0),
|
||||
marginx: None,
|
||||
marginy: None,
|
||||
rankdir: Some("tb".to_string()),
|
||||
acyclicer: None,
|
||||
ranker: None,
|
||||
align: None,
|
||||
nesting_root: None,
|
||||
root: None,
|
||||
node_rank_factor: None,
|
||||
dummy_chains: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GraphEdge {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
forward_name: None,
|
||||
reversed: None,
|
||||
minlen: Some(1.0),
|
||||
weight: Some(1.0),
|
||||
width: Some(0.0),
|
||||
height: Some(0.0),
|
||||
label_rank: None,
|
||||
labeloffset: Some(0.0),
|
||||
labelpos: Some("r".to_string()),
|
||||
nesting_edge: None,
|
||||
cutvalue: None,
|
||||
points: None,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
# Vendored third-party crate.
|
||||
#
|
||||
# Upstream: graphlib_rust 0.0.2 (Apache-2.0)
|
||||
# Source of truth: https://crates.io/crates/graphlib_rust/0.0.2
|
||||
#
|
||||
# Why vendored: the Rust port of dagre.js's graphlib, used by the vendored
|
||||
# `dagre_rust` layout engine and directly by the Warp `mermaid-to-svg` engine.
|
||||
# It is a young single-author crate on the untrusted-input render path, so
|
||||
# vendoring gives a full audit surface and immunity to upstream yanks. See
|
||||
# third_party/mermaid-to-svg and crates/codegen/xai-grok-mermaid.
|
||||
#
|
||||
# ============================================================================
|
||||
# VENDORING NOTES — re-apply / re-bless ALL of the following on every upgrade:
|
||||
# ============================================================================
|
||||
# Local modifications to the upstream source:
|
||||
# 1. `cargo fmt` (rustfmt) applied so the crate satisfies the workspace fmt
|
||||
# gate; no semantic change. The `ordered_hashmap` dep is repointed to the
|
||||
# sibling vendored crate (path dep below).
|
||||
#
|
||||
# Re-audit checklist:
|
||||
# - No `unsafe`, no filesystem / env / network I/O in the vendored source.
|
||||
[package]
|
||||
name = "graphlib_rust"
|
||||
version = "0.0.2"
|
||||
edition = "2021"
|
||||
description = "Dagre's graphlib in Rust (vendored, library-only)"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/r3alst/graphlib-rust"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "graphlib_rust"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
ordered_hashmap = { path = "../ordered_hashmap" }
|
||||
|
||||
# Vendored third-party code is not restyled to our lint standards, so silence
|
||||
# the lints it trips under the workspace's `-D warnings` (the source is kept
|
||||
# verbatim — only this manifest carries the allows). Our own crate
|
||||
# (xai-grok-mermaid) is still fully linted.
|
||||
[lints.rust]
|
||||
noop_method_call = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
all = { level = "allow", priority = -1 }
|
||||
Vendored
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2023 Ameer Hamza
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
use crate::Graph;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fmt::Debug;
|
||||
|
||||
/*
|
||||
* A helper that preforms a pre- or post-order traversal on the input graph
|
||||
* and returns the nodes in the order they were visited. If the graph is
|
||||
* undirected then this algorithm will navigate using neighbors. If the graph
|
||||
* is directed then this algorithm will navigate using successors.
|
||||
*
|
||||
* If the order is not "post", it will be treated as "pre".
|
||||
*/
|
||||
pub fn dfs<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug>(
|
||||
g: &mut Graph<GL, N, E>,
|
||||
vs: &Vec<String>,
|
||||
order: &str,
|
||||
) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let navigation: Box<dyn Fn(&String, &Graph<GL, N, E>) -> Vec<String>> =
|
||||
Box::new(move |v: &String, g: &Graph<GL, N, E>| {
|
||||
if g.is_directed() {
|
||||
g.successors(&v).unwrap_or(vec![])
|
||||
} else {
|
||||
g.neighbors(&v).unwrap_or(vec![])
|
||||
}
|
||||
});
|
||||
|
||||
let order_func = match order {
|
||||
"post" => post_order_dfs,
|
||||
_ => pre_order_dfs,
|
||||
};
|
||||
|
||||
let mut acc: Vec<String> = vec![];
|
||||
let mut visited: HashMap<String, bool> = HashMap::new();
|
||||
for v in vs.iter() {
|
||||
if !g.has_node(v) {
|
||||
return Err(format!("Graph does not have node: {}", v).into());
|
||||
}
|
||||
|
||||
order_func(v, &navigation, &mut visited, &mut acc, g);
|
||||
}
|
||||
|
||||
Ok(acc)
|
||||
}
|
||||
|
||||
fn post_order_dfs<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug>(
|
||||
v: &String,
|
||||
navigation: &Box<dyn Fn(&String, &Graph<GL, N, E>) -> Vec<String>>,
|
||||
visited: &mut HashMap<String, bool>,
|
||||
acc: &mut Vec<String>,
|
||||
g: &Graph<GL, N, E>,
|
||||
) {
|
||||
let mut stack: Vec<(String, bool)> = vec![(v.clone(), false)];
|
||||
while stack.len() > 0 {
|
||||
let curr_ = stack.pop();
|
||||
if curr_.is_none() {
|
||||
continue;
|
||||
}
|
||||
let curr = curr_.unwrap();
|
||||
if curr.1 {
|
||||
acc.push(curr.0.clone());
|
||||
} else {
|
||||
if !visited.contains_key(&curr.0) {
|
||||
visited.insert(curr.0.clone(), true);
|
||||
stack.push((curr.0.clone(), true));
|
||||
|
||||
// TODO: for_each_right implement in future
|
||||
let _navigation_nodes: Vec<String> = navigation(&curr.0, g);
|
||||
let mut idx = _navigation_nodes.len();
|
||||
while idx > 0 {
|
||||
let nav_node = _navigation_nodes.get(idx - 1).cloned().unwrap();
|
||||
stack.push((nav_node, false));
|
||||
idx -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pre_order_dfs<GL: Default, N: Default + Clone, E: Default + Clone>(
|
||||
v: &String,
|
||||
navigation: &Box<dyn Fn(&String, &Graph<GL, N, E>) -> Vec<String>>,
|
||||
visited: &mut HashMap<String, bool>,
|
||||
acc: &mut Vec<String>,
|
||||
g: &Graph<GL, N, E>,
|
||||
) {
|
||||
let mut stack: Vec<String> = vec![v.clone()];
|
||||
while stack.len() > 0 {
|
||||
let curr_ = stack.pop();
|
||||
if curr_.is_none() {
|
||||
continue;
|
||||
}
|
||||
let curr = curr_.unwrap();
|
||||
if !visited.contains_key(&curr) {
|
||||
visited.insert(curr.clone(), true);
|
||||
acc.push(curr.clone());
|
||||
|
||||
// TODO: for_each_right implement in future
|
||||
let _navigation_nodes: Vec<String> = navigation(&curr, g);
|
||||
let mut idx = _navigation_nodes.len() as i32;
|
||||
while idx >= 0 {
|
||||
stack.push(String::from(_navigation_nodes.get(idx as usize).unwrap()));
|
||||
idx -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod dfs;
|
||||
pub mod postorder;
|
||||
pub mod preorder;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
use crate::algo::dfs::dfs;
|
||||
use crate::Graph;
|
||||
use std::fmt::Debug;
|
||||
|
||||
// TODO: need to check if exceptions are required
|
||||
pub fn postorder<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug>(
|
||||
g: &mut Graph<GL, N, E>,
|
||||
vs: &Vec<String>,
|
||||
) -> Vec<String> {
|
||||
return match dfs(g, vs, "post") {
|
||||
Ok(t) => t,
|
||||
_ => vec![],
|
||||
};
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
use crate::algo::dfs::dfs;
|
||||
use crate::Graph;
|
||||
use std::fmt::Debug;
|
||||
|
||||
// TODO: need to check if exceptions are required
|
||||
pub fn preorder<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug>(
|
||||
g: &mut Graph<GL, N, E>,
|
||||
vs: &Vec<String>,
|
||||
) -> Vec<String> {
|
||||
return match dfs(g, vs, "pre") {
|
||||
Ok(t) => t,
|
||||
_ => vec![],
|
||||
};
|
||||
}
|
||||
+965
@@ -0,0 +1,965 @@
|
||||
use ordered_hashmap::OrderedHashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::error::Error;
|
||||
use std::fmt::Debug;
|
||||
use std::hash::Hash;
|
||||
|
||||
pub const DEFAULT_EDGE_NAME: &str = "\x00";
|
||||
pub const GRAPH_NODE: &str = "\x00";
|
||||
pub const EDGE_KEY_DELIM: &str = "\x01";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Edge {
|
||||
pub v: String,
|
||||
pub w: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GraphOption {
|
||||
pub directed: Option<bool>,
|
||||
pub multigraph: Option<bool>,
|
||||
pub compound: Option<bool>,
|
||||
}
|
||||
|
||||
pub enum DefaultNodeLabel<N> {
|
||||
Val(Option<N>),
|
||||
Func(Box<dyn Fn(String) -> Option<N>>),
|
||||
}
|
||||
|
||||
pub enum DefaultEdgeLabel<E> {
|
||||
Val(Option<E>),
|
||||
Func(Box<dyn Fn(String) -> Option<E>>),
|
||||
}
|
||||
|
||||
pub enum EdgeOrString<E> {
|
||||
Edge(E),
|
||||
String(String),
|
||||
}
|
||||
|
||||
pub enum EdgeLabelOrString<E> {
|
||||
EdgeLabel(E),
|
||||
String(String),
|
||||
}
|
||||
|
||||
pub struct Graph<GL, N, E>
|
||||
where
|
||||
GL: Default,
|
||||
{
|
||||
// GraphLabel Type, Node Type, Node Index Type, Edge Type, Edge Index Type
|
||||
_is_directed: bool,
|
||||
_is_multigraph: bool,
|
||||
_is_compound: bool,
|
||||
|
||||
// Label for the graph itself
|
||||
_label: GL,
|
||||
|
||||
// Defaults to be set when creating a new node
|
||||
_default_node_label_fn: DefaultNodeLabel<N>,
|
||||
|
||||
// Defaults to be set when creating a new edge
|
||||
_default_edge_label_fn: DefaultEdgeLabel<E>,
|
||||
|
||||
// v -> label
|
||||
_nodes: OrderedHashMap<String, N>,
|
||||
|
||||
// v -> e -> edgeObj
|
||||
_in: OrderedHashMap<String, OrderedHashMap<String, Edge>>,
|
||||
|
||||
// u -> v -> Number
|
||||
_preds: OrderedHashMap<String, OrderedHashMap<String, usize>>,
|
||||
|
||||
// v -> e -> edgeObj
|
||||
_out: OrderedHashMap<String, OrderedHashMap<String, Edge>>,
|
||||
|
||||
// v -> w -> Number
|
||||
_sucs: OrderedHashMap<String, OrderedHashMap<String, usize>>,
|
||||
|
||||
// e -> edgeObj
|
||||
_edge_objs: OrderedHashMap<String, Edge>,
|
||||
|
||||
// e -> label
|
||||
_edge_labels: OrderedHashMap<String, E>,
|
||||
|
||||
/* Number of nodes in the graph. Should only be changed by the implementation. */
|
||||
_node_count: usize,
|
||||
|
||||
/* Number of edges in the graph. Should only be changed by the implementation. */
|
||||
_edge_count: usize,
|
||||
|
||||
// v -> w
|
||||
_parent: OrderedHashMap<String, String>,
|
||||
|
||||
// v -> w -> boolean
|
||||
_children: OrderedHashMap<String, OrderedHashMap<String, bool>>,
|
||||
}
|
||||
|
||||
impl<GL: Default, N, E> Default for Graph<GL, N, E> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
_is_directed: true,
|
||||
_is_multigraph: false,
|
||||
_is_compound: false,
|
||||
_label: GL::default(),
|
||||
_default_node_label_fn: DefaultNodeLabel::Val(None),
|
||||
_default_edge_label_fn: DefaultEdgeLabel::Val(None),
|
||||
_nodes: OrderedHashMap::new(),
|
||||
_in: OrderedHashMap::new(),
|
||||
_preds: OrderedHashMap::new(),
|
||||
_out: OrderedHashMap::new(),
|
||||
_sucs: OrderedHashMap::new(),
|
||||
_edge_objs: OrderedHashMap::new(),
|
||||
_edge_labels: OrderedHashMap::new(),
|
||||
_node_count: 0,
|
||||
_edge_count: 0,
|
||||
_parent: OrderedHashMap::new(),
|
||||
_children: OrderedHashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug> Graph<GL, N, E> {
|
||||
pub fn new(opts: Option<GraphOption>) -> Self {
|
||||
let mut graph = Self::default();
|
||||
|
||||
if let Some(_opts) = opts {
|
||||
if _opts.directed.is_some() {
|
||||
graph._is_directed = _opts.directed.unwrap();
|
||||
} else {
|
||||
graph._is_directed = true;
|
||||
}
|
||||
|
||||
if _opts.multigraph.is_some() {
|
||||
graph._is_multigraph = _opts.multigraph.unwrap();
|
||||
} else {
|
||||
graph._is_multigraph = false;
|
||||
}
|
||||
|
||||
if _opts.multigraph.is_some() {
|
||||
graph._is_multigraph = _opts.multigraph.unwrap();
|
||||
} else {
|
||||
graph._is_multigraph = false;
|
||||
}
|
||||
|
||||
if _opts.compound.is_some() {
|
||||
graph._is_compound = _opts.compound.unwrap();
|
||||
} else {
|
||||
graph._is_compound = false;
|
||||
}
|
||||
}
|
||||
|
||||
if graph._is_compound {
|
||||
// v -> parent
|
||||
graph._parent = OrderedHashMap::new();
|
||||
|
||||
graph._children = OrderedHashMap::new();
|
||||
graph
|
||||
._children
|
||||
.insert(GRAPH_NODE.clone().to_string(), OrderedHashMap::new());
|
||||
}
|
||||
|
||||
graph
|
||||
}
|
||||
|
||||
/* === Graph functions ========= */
|
||||
|
||||
/**
|
||||
* Whether graph was created with 'directed' flag set to true or not.
|
||||
*/
|
||||
pub fn is_directed(&self) -> bool {
|
||||
return self._is_directed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether graph was created with 'multigraph' flag set to true or not.
|
||||
*/
|
||||
pub fn is_multigraph(&self) -> bool {
|
||||
return self._is_multigraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether graph was created with 'compound' flag set to true or not.
|
||||
*/
|
||||
pub fn is_compound(&self) -> bool {
|
||||
return self._is_compound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the label of the graph.
|
||||
*/
|
||||
pub fn set_graph(&mut self, label: GL) -> &mut Self {
|
||||
self._label = label;
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the graph label.
|
||||
*/
|
||||
pub fn graph(&self) -> &GL {
|
||||
return &self._label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the graph label.
|
||||
*/
|
||||
pub fn graph_mut(&mut self) -> &mut GL {
|
||||
return &mut self._label;
|
||||
}
|
||||
|
||||
/* === Node functions ========== */
|
||||
|
||||
/**
|
||||
* Sets the default node label. If newDefault is a function, it will be
|
||||
* invoked ach time when setting a label for a node. Otherwise, this label
|
||||
* will be assigned as default label in case if no label was specified while
|
||||
* setting a node.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn set_default_node_label(&mut self, new_default: DefaultNodeLabel<N>) -> &mut Self {
|
||||
self._default_node_label_fn = new_default;
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn default_node_label(&self, node_id: String) -> N {
|
||||
let mut _node_label: Option<N> = None;
|
||||
match &self._default_node_label_fn {
|
||||
DefaultNodeLabel::Func(node_label_fn) => {
|
||||
_node_label = node_label_fn(node_id.clone());
|
||||
}
|
||||
DefaultNodeLabel::Val(node_label_) => {
|
||||
if node_label_.is_some() {
|
||||
_node_label = Some(node_label_.clone().unwrap());
|
||||
} else {
|
||||
_node_label = Some(N::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
return _node_label.unwrap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of nodes in the graph.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn node_count(&self) -> usize {
|
||||
return self._node_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all nodes of the graph. Note, the in case of compound graph subnodes are
|
||||
* not included in list.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn nodes(&self) -> Vec<String> {
|
||||
return self._nodes.keys().cloned().collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets list of nodes without in-edges.
|
||||
* Complexity: O(|V|).
|
||||
*/
|
||||
pub fn sources(&self) -> Vec<String> {
|
||||
return self
|
||||
.nodes()
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
if let Some(in_edges) = self._in.get(&n.to_owned().clone()) {
|
||||
return in_edges.len() == 0;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(|node_id| node_id.clone())
|
||||
.collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets list of nodes without out-edges.
|
||||
* Complexity: O(|V|).
|
||||
*/
|
||||
pub fn sinks(&self) -> Vec<String> {
|
||||
return self
|
||||
.nodes()
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
if let Some(out_edges) = self._out.get(&n.to_owned().clone()) {
|
||||
return out_edges.len() == 0;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(|node_id| node_id.clone())
|
||||
.collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes setNode method for each node in names list.
|
||||
* Complexity: O(|names|).
|
||||
*/
|
||||
pub fn set_nodes(&mut self, node_ids: Vec<String>, value: Option<N>) -> &mut Self {
|
||||
node_ids.iter().for_each(|node_id| {
|
||||
self.set_node(node_id.to_owned(), value.clone());
|
||||
});
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates the value for the node v in the graph. If label is supplied
|
||||
* it is set as the value for the node. If label is not supplied and the node was
|
||||
* created by this call then the default node label will be assigned.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn set_node(&mut self, v: String, value: Option<N>) -> &mut Self {
|
||||
if self._nodes.get(&v).is_some() {
|
||||
if value.is_some() {
|
||||
self._nodes.insert(v, value.unwrap());
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
if value.is_some() {
|
||||
self._nodes.insert(v.clone(), value.unwrap());
|
||||
} else {
|
||||
self._nodes
|
||||
.insert(v.clone(), self.default_node_label(v.clone()));
|
||||
}
|
||||
|
||||
if self._is_compound {
|
||||
let _graph_node = GRAPH_NODE.clone().to_string();
|
||||
self._parent.insert(v.clone(), _graph_node.clone());
|
||||
self._children.insert(v.clone(), OrderedHashMap::new());
|
||||
self._children
|
||||
.entry(_graph_node.clone())
|
||||
.or_insert(OrderedHashMap::new())
|
||||
.entry(v.clone())
|
||||
.or_insert(true);
|
||||
}
|
||||
|
||||
self._in.insert(v.clone(), OrderedHashMap::new());
|
||||
self._preds.insert(v.clone(), OrderedHashMap::new());
|
||||
self._out.insert(v.clone(), OrderedHashMap::new());
|
||||
self._sucs.insert(v.clone(), OrderedHashMap::new());
|
||||
self._node_count += 1;
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the label of node with specified name.
|
||||
* Complexity: O(|V|).
|
||||
*/
|
||||
pub fn node(&self, v: &String) -> Option<&N> {
|
||||
return self._nodes.get(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the label of node with specified name.
|
||||
* Complexity: O(|V|).
|
||||
*/
|
||||
pub fn node_mut(&mut self, v: &String) -> Option<&mut N> {
|
||||
return self._nodes.get_mut(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether graph has a node with specified name or not.
|
||||
*/
|
||||
pub fn has_node(&self, v: &String) -> bool {
|
||||
return self._nodes.contains_key(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the node with the name from the graph or do nothing if the node is not in
|
||||
* the graph. If the node was removed this function also removes any incident
|
||||
* edges.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn remove_node(&mut self, v: &String) -> &mut Self {
|
||||
if self._nodes.contains_key(v) {
|
||||
self._nodes.remove(v);
|
||||
|
||||
if self._is_compound {
|
||||
self._remove_from_parents_child_list(v);
|
||||
if self._parent.contains_key(v) {
|
||||
self._parent.remove(v);
|
||||
}
|
||||
self.children(v).iter().for_each(|child_id| {
|
||||
// TODO: exception handling
|
||||
let _ = self.set_parent(child_id, None);
|
||||
});
|
||||
self._children.remove(v);
|
||||
}
|
||||
// removing in edges
|
||||
if let Some(in_edges) = self._in.get(v) {
|
||||
let edge_ids: Vec<String> = in_edges.keys().cloned().collect();
|
||||
edge_ids.iter().for_each(|edge_id| {
|
||||
if let Some(edge) = self._edge_objs.get(edge_id) {
|
||||
self.remove_edge_with_obj(&edge.to_owned());
|
||||
}
|
||||
});
|
||||
self._in.remove(v);
|
||||
}
|
||||
|
||||
self._preds.remove(v);
|
||||
|
||||
// removing out edges
|
||||
if let Some(out_edges) = self._out.get(v) {
|
||||
let edge_ids: Vec<String> = out_edges.keys().cloned().collect();
|
||||
edge_ids.iter().for_each(|edge_id| {
|
||||
if let Some(edge) = self._edge_objs.get(edge_id) {
|
||||
self.remove_edge_with_obj(&edge.to_owned());
|
||||
}
|
||||
});
|
||||
self._out.remove(v);
|
||||
}
|
||||
self._sucs.remove(v);
|
||||
self._node_count -= 1;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets node p as a parent for node v if it is defined, or removes the
|
||||
* parent for v if p is undefined. Method throws an exception in case of
|
||||
* invoking it in context of noncompound graph.
|
||||
* Average-case complexity: O(1).
|
||||
*/
|
||||
pub fn set_parent(
|
||||
&mut self,
|
||||
v: &String,
|
||||
parent: Option<String>,
|
||||
) -> Result<&mut Self, Box<dyn Error>> {
|
||||
if !self._is_compound {
|
||||
return Err("Cannot set parent in a non-compound graph".into());
|
||||
}
|
||||
|
||||
let mut _parent: String = "".to_string();
|
||||
|
||||
if parent.is_none() {
|
||||
_parent = GRAPH_NODE.to_string();
|
||||
} else {
|
||||
_parent = parent.unwrap().clone();
|
||||
let mut ancestor = _parent.clone();
|
||||
while let Some(new_ancestor) = self.parent(&ancestor) {
|
||||
if &new_ancestor == &v {
|
||||
return Err(format!(
|
||||
"Setting {} as parent of {} would create a cycle",
|
||||
_parent.clone(),
|
||||
v
|
||||
)
|
||||
.into());
|
||||
}
|
||||
ancestor = new_ancestor.clone();
|
||||
}
|
||||
|
||||
self.set_node(_parent.clone(), None);
|
||||
}
|
||||
|
||||
self.set_node(v.clone(), None);
|
||||
self._remove_from_parents_child_list(v);
|
||||
self._parent.insert(v.clone(), _parent.clone());
|
||||
self._children
|
||||
.entry(_parent.clone())
|
||||
.or_insert_with(OrderedHashMap::new)
|
||||
.insert(v.clone(), true);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn _remove_from_parents_child_list(&mut self, v: &String) {
|
||||
if let Some(parent) = self._parent.get(v) {
|
||||
if let Some(children) = self._children.get_mut(parent) {
|
||||
children.remove(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets parent node for node v.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn parent(&self, v: &String) -> Option<&String> {
|
||||
if self._is_compound {
|
||||
if let Some(parent) = self._parent.get(v) {
|
||||
if parent != GRAPH_NODE {
|
||||
return Some(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets list of direct children of node v.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn children(&self, v: &String) -> Vec<String> {
|
||||
if self._is_compound {
|
||||
if let Some(children) = self._children.get(v) {
|
||||
return children.keys().cloned().collect();
|
||||
}
|
||||
} else if v == GRAPH_NODE {
|
||||
return self._nodes.keys().cloned().collect();
|
||||
} else if self.has_node(&v) {
|
||||
return vec![];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all nodes that are predecessors of the specified node or undefined if node v is not in
|
||||
* the graph. Behavior is undefined for undirected graphs - use neighbors instead.
|
||||
* Complexity: O(|V|).
|
||||
*/
|
||||
pub fn predecessors(&self, v: &String) -> Option<Vec<String>> {
|
||||
if let Some(preds) = self._preds.get(v) {
|
||||
return Some(preds.keys().cloned().collect());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all nodes that are successors of the specified node or undefined if node v is not in
|
||||
* the graph. Behavior is undefined for undirected graphs - use neighbors instead.
|
||||
* Complexity: O(|V|).
|
||||
*/
|
||||
pub fn successors(&self, v: &String) -> Option<Vec<String>> {
|
||||
if let Some(sucs) = self._sucs.get(v) {
|
||||
return Some(sucs.keys().cloned().collect());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all nodes that are predecessors or successors of the specified node or undefined if
|
||||
* node v is not in the graph.
|
||||
* Complexity: O(|V|).
|
||||
*/
|
||||
pub fn neighbors(&self, v: &String) -> Option<Vec<String>> {
|
||||
if let Some(preds) = self.predecessors(v) {
|
||||
let mut union: HashSet<String> = HashSet::new();
|
||||
preds.into_iter().for_each(|pred| {
|
||||
union.insert(pred);
|
||||
});
|
||||
if let Some(sucs) = self.successors(v) {
|
||||
sucs.into_iter().for_each(|successor| {
|
||||
union.insert(successor);
|
||||
});
|
||||
}
|
||||
|
||||
return Some(union.into_iter().collect());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_leaf(&self, v: &String) -> bool {
|
||||
let mut _neighbors: Option<Vec<String>> = None;
|
||||
if self.is_directed() {
|
||||
_neighbors = self.successors(v);
|
||||
} else {
|
||||
_neighbors = self.neighbors(v);
|
||||
}
|
||||
|
||||
if _neighbors.is_none() || _neighbors.unwrap().len() == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new graph with nodes filtered via filter. Edges incident to rejected node
|
||||
* are also removed. In case of compound graph, if parent is rejected by filter,
|
||||
* than all its children are rejected too.
|
||||
* Average-case complexity: O(|E|+|V|).
|
||||
*/
|
||||
pub fn filter_nodes<F>(&self, filter: F) -> Self
|
||||
where
|
||||
F: Fn(&String) -> bool,
|
||||
{
|
||||
let mut copy: Graph<GL, N, E> = Graph::new(Some(GraphOption {
|
||||
directed: Some(self._is_directed.clone()),
|
||||
multigraph: Some(self._is_multigraph.clone()),
|
||||
compound: Some(self._is_compound.clone()),
|
||||
}));
|
||||
|
||||
for (v, value) in self._nodes.iter() {
|
||||
if filter(v) {
|
||||
copy.set_node(v.clone(), Some(value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
for e_v in self._edge_objs.values() {
|
||||
if copy._nodes.contains_key(&e_v.v) && copy._nodes.contains_key(&e_v.w) {
|
||||
if let Some(edge_label) = self.edge_with_obj(e_v) {
|
||||
let _ = copy.set_edge_with_obj(e_v, Some(edge_label.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut parents: OrderedHashMap<String, String> = OrderedHashMap::new();
|
||||
|
||||
if self._is_compound {
|
||||
let node_ids: Vec<String> = copy._nodes.keys().cloned().into_iter().collect();
|
||||
for v in node_ids {
|
||||
let parent = find_parent(&v, &mut parents, &mut copy, self);
|
||||
let _ = copy.set_parent(&v, parent);
|
||||
}
|
||||
}
|
||||
|
||||
copy
|
||||
}
|
||||
|
||||
/* === Edge functions ========== */
|
||||
|
||||
/**
|
||||
* Sets the default edge label or factory function. This label will be
|
||||
* assigned as default label in case if no label was specified while setting
|
||||
* an edge or this function will be invoked each time when setting an edge
|
||||
* with no label specified and returned value * will be used as a label for edge.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn set_default_edge_label(&mut self, new_default: DefaultEdgeLabel<E>) -> &mut Self {
|
||||
self._default_edge_label_fn = new_default;
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn default_edge_label(&self, edge_id: String) -> E {
|
||||
let mut _edge_label: Option<E> = None;
|
||||
match &self._default_edge_label_fn {
|
||||
DefaultEdgeLabel::Func(edge_label_fn) => {
|
||||
_edge_label = edge_label_fn(edge_id.clone());
|
||||
}
|
||||
DefaultEdgeLabel::Val(edge_label_) => {
|
||||
if edge_label_.is_some() {
|
||||
_edge_label = Some(edge_label_.clone().unwrap());
|
||||
} else {
|
||||
_edge_label = Some(E::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
return _edge_label.unwrap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of edges in the graph.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn edge_count(&self) -> usize {
|
||||
return self._edge_count.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets edges of the graph. In case of compound graph subgraphs are not considered.
|
||||
* Complexity: O(|E|).
|
||||
*/
|
||||
pub fn edges(&self) -> Vec<Edge> {
|
||||
return self._edge_objs.values().cloned().collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish an edges path over the nodes in nodes list. If some edge is already
|
||||
* exists, it will update its label, otherwise it will create an edge between pair
|
||||
* of nodes with label provided or default label if no label provided.
|
||||
* Complexity: O(|nodes|).
|
||||
*/
|
||||
pub fn set_path(&mut self, vs: &Vec<String>, value: Option<E>) {
|
||||
vs.iter().reduce(|v1, v2| {
|
||||
let _ = self.set_edge(v1, v2, value.clone(), None);
|
||||
v2
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates the label for the edge (v, w) with the optionally supplied
|
||||
* name. If label is supplied it is set as the value for the edge. If label is not
|
||||
* supplied and the edge was created by this call then the default edge label will
|
||||
* be assigned. The name parameter is only useful with multigraphs.
|
||||
*/
|
||||
pub fn set_edge(
|
||||
&mut self,
|
||||
v: &String,
|
||||
w: &String,
|
||||
edge_label: Option<E>,
|
||||
name: Option<String>,
|
||||
) -> Result<&mut Self, Box<dyn Error>> {
|
||||
let e = edge_args_to_id(&self._is_directed, v, w, &name);
|
||||
if self._edge_labels.contains_key(&e) {
|
||||
if edge_label.is_some() {
|
||||
self._edge_labels.insert(e.clone(), edge_label.unwrap());
|
||||
}
|
||||
return Ok(self);
|
||||
}
|
||||
|
||||
if name.is_some() && !self._is_multigraph {
|
||||
return Err("Cannot set a named edge when isMultigraph = false".into());
|
||||
}
|
||||
|
||||
// It didn't exist, so we need to create it.
|
||||
// First ensure the nodes exist.
|
||||
self.set_node(v.clone(), None);
|
||||
self.set_node(w.clone(), None);
|
||||
|
||||
if edge_label.is_some() {
|
||||
self._edge_labels
|
||||
.insert(e.clone(), edge_label.clone().unwrap());
|
||||
} else {
|
||||
self._edge_labels
|
||||
.insert(e.clone(), self.default_edge_label(e.clone()));
|
||||
}
|
||||
|
||||
let edge_obj = edge_args_to_obj(&self.is_directed(), v, w, &name);
|
||||
// Ensure we add undirected edges in a consistent way.
|
||||
self._edge_objs.insert(e.clone(), edge_obj.clone());
|
||||
if let Some(preds) = self._preds.get_mut(w) {
|
||||
increment_or_init_entry(preds, v);
|
||||
}
|
||||
if let Some(sucs) = self._sucs.get_mut(v) {
|
||||
increment_or_init_entry(sucs, w);
|
||||
}
|
||||
|
||||
let in_edges = self
|
||||
._in
|
||||
.entry(w.clone())
|
||||
.or_insert_with(OrderedHashMap::new);
|
||||
in_edges.insert(e.clone(), edge_obj.clone());
|
||||
|
||||
let out_edges = self
|
||||
._out
|
||||
.entry(v.clone())
|
||||
.or_insert_with(OrderedHashMap::new);
|
||||
out_edges.insert(e.clone(), edge_obj.clone());
|
||||
|
||||
self._edge_count += 1;
|
||||
return Ok(self);
|
||||
}
|
||||
|
||||
pub fn set_edge_with_obj(
|
||||
&mut self,
|
||||
e: &Edge,
|
||||
edge_label: Option<E>,
|
||||
) -> Result<&mut Self, Box<dyn Error>> {
|
||||
self.set_edge(&e.v, &e.w, edge_label, None)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the label for the specified edge.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn edge(&self, v: &String, w: &String, name: Option<String>) -> Option<&E> {
|
||||
let e = edge_args_to_id(&self._is_directed, v, w, &name);
|
||||
return self._edge_labels.get(&e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the label for the specified edge.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn edge_with_obj(&self, edge: &Edge) -> Option<&E> {
|
||||
let e = edge_obj_to_id(&self._is_directed, edge);
|
||||
return self._edge_labels.get(&e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the label for the specified edge.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn edge_mut(&mut self, v: &String, w: &String, name: Option<String>) -> Option<&mut E> {
|
||||
let e = edge_args_to_id(&self._is_directed, v, w, &name);
|
||||
return self._edge_labels.get_mut(&e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the label for the specified edge.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn edge_mut_with_obj(&mut self, edge: &Edge) -> Option<&mut E> {
|
||||
let e = edge_obj_to_id(&self._is_directed, edge);
|
||||
return self._edge_labels.get_mut(&e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether the graph contains specified edge or not. No subgraphs are considered.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn has_edge(&self, v: &String, w: &String, name: Option<String>) -> bool {
|
||||
let e = edge_args_to_id(&self._is_directed, v, w, &name);
|
||||
self._edge_labels.contains_key(&e)
|
||||
}
|
||||
|
||||
pub fn has_edge_with_obj(&self, edge: &Edge) -> bool {
|
||||
let e = edge_obj_to_id(&self._is_directed, edge);
|
||||
self._edge_labels.contains_key(&e)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the specified edge from the graph. No subgraphs are considered.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn remove_edge(&mut self, v: &String, w: &String, name: Option<String>) -> &mut Self {
|
||||
let e: String = edge_args_to_id(&self._is_directed, v, w, &name);
|
||||
if let Some(edge) = self._edge_objs.get_mut(&e) {
|
||||
let v = edge.v.clone();
|
||||
let w = edge.w.clone();
|
||||
self._edge_labels.remove(&e);
|
||||
self._edge_objs.remove(&e);
|
||||
if self._preds.contains_key(&w) {
|
||||
decrement_or_remove_entry(self._preds.get_mut(&w).unwrap(), &v);
|
||||
}
|
||||
if self._sucs.contains_key(&v) {
|
||||
decrement_or_remove_entry(self._sucs.get_mut(&v).unwrap(), &w);
|
||||
}
|
||||
|
||||
if self._in.contains_key(&w) {
|
||||
self._in.get_mut(&w).unwrap().remove(&e);
|
||||
}
|
||||
|
||||
if self._out.contains_key(&v) {
|
||||
self._out.get_mut(&v).unwrap().remove(&e);
|
||||
}
|
||||
self._edge_count -= 1;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the specified edge from the graph. No subgraphs are considered.
|
||||
* Complexity: O(1).
|
||||
*/
|
||||
pub fn remove_edge_with_obj(&mut self, e: &Edge) -> &mut Self {
|
||||
self.remove_edge(&e.v, &e.w, None);
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all edges that point to the node v. Optionally filters those edges down to just those
|
||||
* coming from node u. Behavior is undefined for undirected graphs - use nodeEdges instead.
|
||||
* Complexity: O(|E|).
|
||||
*/
|
||||
pub fn in_edges(&self, v: &String, u: Option<String>) -> Option<Vec<Edge>> {
|
||||
if let Some(in_edges) = self._in.get(v) {
|
||||
let mut _in_edges: Vec<Edge> = in_edges.values().cloned().collect();
|
||||
if u.is_none() {
|
||||
return Some(_in_edges.clone());
|
||||
}
|
||||
let _u = u.unwrap();
|
||||
return Some(_in_edges.into_iter().filter(|edge| edge.v == _u).collect());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all edges that are pointed at by node v. Optionally filters those edges down to just
|
||||
* those point to w. Behavior is undefined for undirected graphs - use nodeEdges instead.
|
||||
* Complexity: O(|E|).
|
||||
*/
|
||||
pub fn out_edges(&self, v: &String, w: Option<String>) -> Option<Vec<Edge>> {
|
||||
if let Some(out_edges) = self._out.get(v) {
|
||||
let mut _out_edges: Vec<Edge> = out_edges.values().cloned().collect();
|
||||
if w.is_none() {
|
||||
return Some(_out_edges.clone());
|
||||
}
|
||||
|
||||
let _w = w.unwrap();
|
||||
return Some(_out_edges.into_iter().filter(|edge| edge.w == _w).collect());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all edges to or from node v regardless of direction. Optionally filters those edges
|
||||
* down to just those between nodes v and w regardless of direction.
|
||||
* Complexity: O(|E|).
|
||||
*/
|
||||
pub fn node_edges(&self, v: &String, w: Option<String>) -> Option<Vec<Edge>> {
|
||||
let _in_edges = self.in_edges(v, w.clone());
|
||||
if let Some(mut in_edges) = _in_edges {
|
||||
let _out_edges = self.out_edges(v, w.clone());
|
||||
if let Some(out_edges) = _out_edges {
|
||||
in_edges.append(out_edges.clone().as_mut());
|
||||
}
|
||||
return Some(in_edges);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_or_init_entry<K: Hash + Eq + Clone>(map: &mut OrderedHashMap<K, usize>, k: &K) {
|
||||
if let Some(e) = map.get_mut(&k) {
|
||||
*e += 1;
|
||||
} else {
|
||||
map.insert(k.clone(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn decrement_or_remove_entry<K: Hash + Eq + Clone>(map: &mut OrderedHashMap<K, usize>, k: &K) {
|
||||
if let Some(value) = map.get_mut(k) {
|
||||
*value -= 1;
|
||||
if *value <= 0 {
|
||||
map.remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_args_to_id(is_directed: &bool, v_: &String, w_: &String, name: &Option<String>) -> String {
|
||||
let mut v: &str = &*v_;
|
||||
let mut w: &str = &*w_;
|
||||
if !is_directed.to_owned() && v > w {
|
||||
let tmp = v;
|
||||
v = w;
|
||||
w = tmp;
|
||||
}
|
||||
|
||||
if name.is_some() {
|
||||
return v.to_owned() + EDGE_KEY_DELIM + w + EDGE_KEY_DELIM + &*name.clone().unwrap();
|
||||
}
|
||||
return v.to_owned() + EDGE_KEY_DELIM + w + EDGE_KEY_DELIM + DEFAULT_EDGE_NAME;
|
||||
}
|
||||
|
||||
fn edge_args_to_obj(is_directed: &bool, v_: &String, w_: &String, name: &Option<String>) -> Edge {
|
||||
let mut v: &str = &*v_;
|
||||
let mut w: &str = &*w_;
|
||||
if !is_directed.to_owned() && v > w {
|
||||
let tmp = v;
|
||||
v = w;
|
||||
w = tmp;
|
||||
}
|
||||
|
||||
return Edge {
|
||||
v: v.to_string(),
|
||||
w: w.to_string(),
|
||||
name: name.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
fn edge_obj_to_id(is_directed: &bool, edge: &Edge) -> String {
|
||||
return edge_args_to_id(is_directed, &edge.v, &edge.w, &edge.name);
|
||||
}
|
||||
|
||||
fn find_parent<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug>(
|
||||
v: &String,
|
||||
parents: &mut OrderedHashMap<String, String>,
|
||||
copy: &mut Graph<GL, N, E>,
|
||||
graph: &Graph<GL, N, E>,
|
||||
) -> Option<String> {
|
||||
let parent = graph.parent(v);
|
||||
if parent.is_none() || copy._nodes.contains_key(&parent.unwrap().clone()) {
|
||||
if !parent.is_none() {
|
||||
parents.insert(v.clone(), parent.unwrap().clone());
|
||||
return parent.cloned();
|
||||
}
|
||||
None
|
||||
} else if let Some(parent_value) = parents.get(&parent.unwrap().clone()) {
|
||||
Some(parent_value.clone())
|
||||
} else {
|
||||
if parent.is_some() {
|
||||
find_parent(parent.as_ref().unwrap(), parents, copy, graph)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
pub mod algo;
|
||||
pub mod graph;
|
||||
|
||||
pub use graph::DefaultEdgeLabel;
|
||||
pub use graph::DefaultNodeLabel;
|
||||
pub use graph::Edge;
|
||||
pub use graph::EdgeLabelOrString;
|
||||
pub use graph::EdgeOrString;
|
||||
pub use graph::Graph;
|
||||
pub use graph::GraphOption;
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
# Vendored third-party crate.
|
||||
#
|
||||
# Upstream: warpdotdev/mermaid-to-svg (MIT) —
|
||||
# https://github.com/warpdotdev/mermaid-to-svg
|
||||
# rev 40cecf2be376e47e15053eadbfb782a531777420
|
||||
#
|
||||
# Why vendored (not a crates.io pin): the engine renders untrusted model output
|
||||
# and is a young dependency. Vendoring gives a full audit surface and immunity to
|
||||
# upstream yanks for the hardened CLI binary. Warp's engine delegates layout
|
||||
# to a Rust port of dagre.js (the same algorithm mermaid.js uses), which fixes
|
||||
# the cyclic-flowchart back-edge mis-routing of the previous engine. See
|
||||
# crates/codegen/xai-grok-mermaid.
|
||||
#
|
||||
# ============================================================================
|
||||
# VENDORING NOTES — single source of truth. Re-apply / re-bless ALL of the
|
||||
# following on every upstream upgrade:
|
||||
# ============================================================================
|
||||
#
|
||||
# Local modifications to the upstream source:
|
||||
# 1. NOT VENDORED: `src/bin/render_mermaid.rs` (CLI binary). Its `[[bin]]` and
|
||||
# the dev-only deps (insta/rust-embed/resvg/roxmltree/image/anyhow/serde*)
|
||||
# are dropped — `xai-grok-mermaid` rasterizes the SVG itself with the
|
||||
# workspace `resvg`, so this is used as an SVG generator only.
|
||||
# 2. NOT VENDORED: every `*_tests.rs` snapshot/fixture test plus `reference_svg`
|
||||
# and `fixtures` (they `include`/embed un-vendored `samples/` assets and need
|
||||
# the dropped dev-deps). Their `#[cfg(test)] #[path = "..."] mod tests;`
|
||||
# declarations and the `pub mod fixtures;` / `mod reference_svg;` lines were
|
||||
# removed from the kept sources. The two *in-source* `mod tests` blocks
|
||||
# (`src/lib.rs` public-API smoke tests, `src/mermaid_port/dagre_layout_port.rs`
|
||||
# unit tests) use no dev-deps and are kept; `[lib] doctest = false`. One
|
||||
# LOCAL ADDITION to the kept `src/lib.rs` tests: `test_simple_c4_diagram`
|
||||
# (marked in-source), filling the only dispatched diagram type that upstream
|
||||
# left without a `test_simple_*` smoke test.
|
||||
# 3. HERMETIC PATCH (src/mermaid_port/mod.rs): `is_enabled()` returns `false`
|
||||
# unconditionally. Upstream read the `MERMAID_TO_SVG_USE_PORT` env var to
|
||||
# enable an experimental flowchart layout "port"; reading the environment is
|
||||
# non-deterministic over untrusted input, and the port mis-routes cyclic
|
||||
# back-edges (detached arrowheads). The default `layout::compute_layout`
|
||||
# path routes cycles correctly, so the port stays off. Because the port is
|
||||
# unreachable, `mermaid_port::compute_layout_ported` carries an
|
||||
# `#[allow(dead_code)]`; the module is kept verbatim (not deleted) so future
|
||||
# re-vendor diffs stay minimal.
|
||||
# 4. Dropped the unused `petgraph` and `regex` dependencies (declared upstream,
|
||||
# referenced nowhere in the vendored source; `cargo shear` confirms).
|
||||
# 5. `thiserror` pinned to "2" (upstream "1.0") to match the workspace; the
|
||||
# `#[derive(Error)]` / `#[error("…")]` API used in `src/error.rs` is
|
||||
# unchanged across the major.
|
||||
# 6. `cargo fmt` (rustfmt) applied so the crate satisfies the workspace fmt
|
||||
# gate; no semantic change.
|
||||
# 7. LOCAL REWRITE (src/sequence_diagram.rs; deliberately NOT marked in-source
|
||||
# — this note is the sole record, so re-apply it on every upstream
|
||||
# upgrade): the upstream sequence parser hard-failed ("Unrecognized
|
||||
# sequenceDiagram line") on most of the standard Mermaid sequence grammar. Added: `activate`/`deactivate`
|
||||
# (+ the `+`/`-` message shorthand) drawn as lifeline activation bars;
|
||||
# `actor`; `create`/`destroy`; `box … end`; `autonumber` (message-number
|
||||
# prefixes); `title`; `accTitle`/`accDescr` (incl. `{ … }` blocks) and
|
||||
# `links`/`link`/`properties` accepted-and-skipped; `par`/`and`,
|
||||
# `critical`/`option`, `break`, `rect` fragments; the full arrow set
|
||||
# (`->`, `-->`, `-x`, `--x`, `-)`, `--)`, `<<->>`, `<<-->>`) with per-type
|
||||
# markers. Keyword matching is now case-insensitive and word-bounded
|
||||
# (Mermaid's lexer is case-insensitive; `optimize…` is no longer an `opt`
|
||||
# fragment). Arrow markers are theme-colored (were unstyled black,
|
||||
# invisible-on-dark) and note text is fixed-dark on the fixed light-yellow
|
||||
# note fill. Covered by the (likewise unmarked) `test_sequence_*` tests in
|
||||
# src/lib.rs.
|
||||
# 8. LOCAL MODIFICATION (new `unicode-width` dep): text width estimates count
|
||||
# East Asian wide characters as two narrow units via `text_wrap::
|
||||
# display_width_units` (upstream counted chars/graphemes, halving CJK label
|
||||
# widths so they overflowed their nodes). Touches `text_wrap.rs` and the
|
||||
# local estimators in `sequence_diagram.rs` / `xychart_diagram.rs` /
|
||||
# `mindmap_diagram.rs`; ASCII measurement is unchanged. Covered by the
|
||||
# `test_cjk_*` tests in src/lib.rs.
|
||||
# 9. LOCAL MODIFICATION (src/parser.rs): support Mermaid's open edge-label
|
||||
# syntax (`A -- text --> B`, `-- text ---`, `== text ==>`, `== text ===`,
|
||||
# `-. text .-`; upstream only parsed the `|text|` form plus `-. text .->`),
|
||||
# and make `find_edge_start` bracket/quote-aware so edge tokens inside node
|
||||
# labels (`A["x --> y"]`) are not treated as edges. Upstream parsed
|
||||
# `B -- 是 --> C` as a literal node named "B -- 是". Covered by the
|
||||
# `test_open_edge_label_*` / `test_edge_tokens_inside_node_labels_*` tests
|
||||
# in src/lib.rs.
|
||||
# 10. LOCAL MODIFICATION (src/text_wrap.rs): a single unbreakable token (a
|
||||
# "word" with no internal whitespace) is kept whole on its own line and the
|
||||
# box widens to fit it, instead of being hard-sliced mid-identifier at a
|
||||
# grapheme boundary once it exceeds `wrapping_width` (upstream sliced
|
||||
# `mark_filter_restore_context` into `mark_filter_restore_conte` + `xt`).
|
||||
# This matches Mermaid's default `htmlLabels: true` rendering, which this
|
||||
# port does not implement (src/config.rs), so it is MORE faithful to
|
||||
# upstream Mermaid. Because `wrap_text_lines` is the shared wrapper, this
|
||||
# applies to ALL of its callers — flowchart node labels, edge labels, and
|
||||
# subgraph titles, plus state-diagram node/edge labels (state diagrams parse
|
||||
# into the same `FlowchartGraph` and reuse this layout/renderer) — not just
|
||||
# node boxes. The class/er/requirement diagrams have their own renderers that
|
||||
# call only `line_width` (single-line, no wrapping), so they are unaffected.
|
||||
# Only a pathologically long token — wider than
|
||||
# SINGLE_TOKEN_WIDTH_CAP_FACTOR (5x) the wrap width — is force-broken,
|
||||
# preferring the last identifier boundary (`_`/`-`/`.`/`/`) under the cap.
|
||||
# Per-diagram width is now intentionally bounded only by the downstream
|
||||
# rasterizer's PNG caps (xai-grok-mermaid), not by the layout — a deliberate
|
||||
# legibility-vs-completeness tradeoff. Multi-word labels still wrap at spaces
|
||||
# as before (`wrapping_width` unchanged). Covered by the unit tests in
|
||||
# `src/text_wrap.rs`.
|
||||
# 11. LOCAL MODIFICATION (src/layout.rs): flowchart edges whose endpoint is a
|
||||
# subgraph id (cluster-target edges) have the dagre route points that fall
|
||||
# strictly inside the destination/source cluster rect trimmed (via
|
||||
# `trim_cluster_interior_points`) before `clip_edge_to_boundaries`, so the
|
||||
# edge approaches the cluster boundary from outside instead of routing to the
|
||||
# first-declared interior member (`subgraph_entry_node_id`) and curling back.
|
||||
# A single transition point is kept per trimmed side; the polyline never
|
||||
# drops below two points. Only forward (non-back) cluster-target edges are
|
||||
# affected — node-target edges and back edges are unchanged. Covered by the
|
||||
# `trim_*` tests in src/layout.rs.
|
||||
# 12. LOCAL REWRITE (src/xychart_diagram.rs): upstream only parsed a numeric
|
||||
# x-axis (`x-axis min --> max`) + a single `line`, hard-failing on the
|
||||
# categorical form `x-axis ["a", …]` (so such charts fell back to a code
|
||||
# block — "opening image for this diagram fails"). Added: categorical
|
||||
# (band-scale) x-axis; optional quoted axis titles (y rotated, x centered);
|
||||
# quote stripping for the title/labels; multiple `line` series in distinct
|
||||
# palette colors; y auto-ranging when no explicit range is given; and
|
||||
# theme-colored axes/text (was a fixed near-black, invisible on dark). `bar`
|
||||
# stays unsupported (ignored). Covered by `xychart_diagram::tests` here and
|
||||
# the `categorical_xychart_*` raster test in
|
||||
# crates/codegen/xai-grok-mermaid/tests/pure_engine.rs.
|
||||
#
|
||||
# Re-audit checklist:
|
||||
# - This crate's own source contains no `unsafe` and no filesystem / network I/O.
|
||||
# Text metrics are font-free (fixed char-width in `src/text_wrap.rs`), so the
|
||||
# engine performs NO system-font enumeration or on-disk font cache — glyph
|
||||
# rasterization is the separate, bundled-font `xai-grok-mermaid::rasterize`.
|
||||
# - SIBLING VENDORED LAYOUT DEPS (this header is the audit source-of-truth for
|
||||
# the whole stack): `ordered_hashmap` retains two `unsafe` raw-pointer blocks
|
||||
# in `iter_mut`/`values_mut` (documented + re-audited in its own Cargo.toml);
|
||||
# `dagre_rust` and `graphlib_rust` contain no `unsafe` (dagre's upstream
|
||||
# `static mut` counter was replaced with an `AtomicUsize` — see its Cargo.toml).
|
||||
# - YAML frontmatter parsing (`src/config.rs`, `src/kanban_diagram.rs`) uses the
|
||||
# workspace `serde_yaml`; it parses in-memory source only (no file/URL refs).
|
||||
[package]
|
||||
name = "mermaid-to-svg"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Convert Mermaid diagram source to SVG via a dagre layout port (vendored, library-only)"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/warpdotdev/mermaid-to-svg"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "mermaid_to_svg"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
thiserror = "2"
|
||||
dagre_rust = { path = "../dagre_rust" }
|
||||
graphlib_rust = { path = "../graphlib_rust" }
|
||||
unicode-segmentation = "1"
|
||||
unicode-width = "0.2"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# Clippy lints local/path crates (this vendored crate is one) under the
|
||||
# workspace's `-D warnings`. We do not restyle upstream third-party code, so
|
||||
# allow clippy here; our own crate (xai-grok-mermaid) is still fully linted.
|
||||
[lints.clippy]
|
||||
all = { level = "allow", priority = -1 }
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2025-2026 Denver Technologies, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
This project includes code derived from or inspired by the following
|
||||
third-party projects.
|
||||
|
||||
================================================================================
|
||||
|
||||
mermaid.js
|
||||
https://github.com/mermaid-js/mermaid
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 - 2022 Knut Sveidqvist
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
|
||||
dagre.js
|
||||
https://github.com/dagrejs/dagre
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2012-2014 Chris Pettitt
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
|
||||
graphlib.js
|
||||
https://github.com/dagrejs/graphlib
|
||||
|
||||
Ancestry of the vendored graphlib_rust port (third_party/graphlib_rust/).
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2012-2014 Chris Pettitt
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
|
||||
dagre_rust
|
||||
https://github.com/r3alst/dagre-rust
|
||||
|
||||
Vendored in third_party/dagre_rust/ with local modifications.
|
||||
|
||||
Copyright 2023 Ameer Hamza
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FlowchartGraph {
|
||||
pub direction: GraphDirection,
|
||||
pub statements: Vec<Statement>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GraphDirection {
|
||||
TopToBottom,
|
||||
BottomToTop,
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Statement {
|
||||
Node(Node),
|
||||
Edge(Edge),
|
||||
Subgraph(Subgraph),
|
||||
Style(StyleStatement),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Node {
|
||||
pub id: String,
|
||||
pub label: Option<String>,
|
||||
pub shape: NodeShape,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NodeShape {
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Stadium,
|
||||
Diamond,
|
||||
Hexagon,
|
||||
Asymmetric,
|
||||
Subroutine,
|
||||
Cylinder,
|
||||
Circle,
|
||||
StartState,
|
||||
EndState,
|
||||
ForkJoin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Edge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub label: Option<String>,
|
||||
pub style: EdgeStyle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EdgeStyle {
|
||||
Arrow,
|
||||
Line,
|
||||
DottedArrow,
|
||||
DottedLine,
|
||||
ThickArrow,
|
||||
ThickLine,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Subgraph {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub statements: Vec<Statement>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StyleStatement {
|
||||
pub node_id: String,
|
||||
pub properties: Vec<(String, String)>,
|
||||
}
|
||||
+547
@@ -0,0 +1,547 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::MermaidError;
|
||||
use crate::text_wrap::line_width;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
/// Mermaid 11.12.2 block default padding (from `getConfig2()?.block?.padding ?? 8`).
|
||||
/// This is the layout padding between sibling blocks AND the node shape padding
|
||||
/// (added to bbox.width / bbox.height in rect2()).
|
||||
const BLOCK_PADDING: f64 = 8.0;
|
||||
|
||||
/// Approximate per-character width for 16px Trebuchet MS rendered in Chromium.
|
||||
/// Derived from reference SVG foreignObject measurements: "A" → 10.953, "B" → 10.984.
|
||||
/// This replaces DEFAULT_CHAR_WIDTH (8.0) which is too narrow for block-beta nodes.
|
||||
const BLOCK_CHAR_WIDTH: f64 = 10.97;
|
||||
|
||||
/// Approximate text height for a single line of 16px Trebuchet MS in Chromium foreignObject.
|
||||
/// Reference SVG shows foreignObject height = 19 for single-line labels.
|
||||
const BLOCK_TEXT_HEIGHT: f64 = 19.0;
|
||||
|
||||
/// ViewBox margin around content bounds (from `bounds2.x - 5, bounds2.y - 5, …+10, …+10`).
|
||||
const VB_MARGIN: f64 = 5.0;
|
||||
|
||||
/// Arrow point marker offset (from mermaid's `markerOffsets.arrow_point = 4`).
|
||||
/// Applied via `getLineFunctionsWithOffset` to shift the last edge point backward
|
||||
/// so the arrowhead marker tip lands near the target node's edge.
|
||||
const ARROW_POINT_OFFSET: f64 = 4.0;
|
||||
|
||||
pub fn render_block_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let diagram = parse_block_beta(mermaid_source)?;
|
||||
|
||||
let ordered_nodes = &diagram.node_order;
|
||||
|
||||
// --- Phase 1: Calculate node sizes (like mermaid's calculateBlockSize) ---
|
||||
// Mermaid inserts the node into the DOM, calls getBBox(), then stores
|
||||
// { width: bbox.width, height: bbox.height }. The rect2() shape adds
|
||||
// `node.padding` (= block.padding = 8) to both dimensions.
|
||||
// We approximate the label bbox using BLOCK_CHAR_WIDTH and BLOCK_TEXT_HEIGHT.
|
||||
let mut node_sizes: HashMap<String, (f64, f64)> = HashMap::new();
|
||||
for id in ordered_nodes {
|
||||
let label = diagram.nodes.get(id).map(String::as_str).unwrap_or(id);
|
||||
let text_w = line_width(label, BLOCK_CHAR_WIDTH);
|
||||
let w = text_w + BLOCK_PADDING;
|
||||
let h = BLOCK_TEXT_HEIGHT + BLOCK_PADDING;
|
||||
node_sizes.insert(id.clone(), (w, h));
|
||||
}
|
||||
|
||||
// --- Phase 2: setBlockSizes (normalize children to max width/height) ---
|
||||
let max_w = node_sizes.values().map(|(w, _)| *w).fold(0.0_f64, f64::max);
|
||||
let max_h = node_sizes.values().map(|(_, h)| *h).fold(0.0_f64, f64::max);
|
||||
// All children get the same dimensions (mermaid normalizes to maxChildSize).
|
||||
for size in node_sizes.values_mut() {
|
||||
*size = (max_w, max_h);
|
||||
}
|
||||
|
||||
// --- Phase 3: layoutBlocks (position each child) ---
|
||||
// Mermaid logic: columns determine how many nodes per row.
|
||||
// startingPosX = -padding (because root.size.x is 0, which is falsy in JS)
|
||||
// child.x = startingPosX + padding + halfWidth; startingPosX = child.x + halfWidth
|
||||
// child.y = parent.y - parent.height/2 + py*(height+padding) + height/2 + padding
|
||||
//
|
||||
// First compute the root block size so we can derive child y positions.
|
||||
let columns = diagram.columns;
|
||||
let num_items = ordered_nodes.len() as i32;
|
||||
let x_size = if columns > 0 && columns < num_items {
|
||||
columns
|
||||
} else {
|
||||
num_items
|
||||
};
|
||||
let y_size = if x_size > 0 {
|
||||
(num_items as f64 / x_size as f64).ceil() as i32
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let _root_w = x_size as f64 * (max_w + BLOCK_PADDING) + BLOCK_PADDING;
|
||||
let root_h = y_size as f64 * (max_h + BLOCK_PADDING) + BLOCK_PADDING;
|
||||
|
||||
let mut node_layout: HashMap<String, (f64, f64)> = HashMap::new();
|
||||
let half_w = max_w / 2.0;
|
||||
let mut starting_pos_x = -BLOCK_PADDING;
|
||||
let mut current_row: i32 = 0;
|
||||
|
||||
for (col_pos, id) in ordered_nodes.iter().enumerate() {
|
||||
let (_, py) = calculate_block_position(columns, col_pos as i32);
|
||||
if py != current_row {
|
||||
current_row = py;
|
||||
starting_pos_x = -BLOCK_PADDING;
|
||||
}
|
||||
let cx = starting_pos_x + BLOCK_PADDING + half_w;
|
||||
// Mermaid: child.size.y = parent.y - parent.height/2 + py*(height+padding) + height/2 + padding
|
||||
// parent.y = 0, parent.height = root_h
|
||||
let cy = -root_h / 2.0 + py as f64 * (max_h + BLOCK_PADDING) + max_h / 2.0 + BLOCK_PADDING;
|
||||
node_layout.insert(id.clone(), (cx, cy));
|
||||
starting_pos_x = cx + half_w;
|
||||
}
|
||||
|
||||
// --- Phase 4: findBounds ---
|
||||
let (mut min_x, mut min_y, mut max_x, mut max_y) = (
|
||||
f64::INFINITY,
|
||||
f64::INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
);
|
||||
for id in ordered_nodes {
|
||||
let (cx, cy) = node_layout[id];
|
||||
let (w, h) = node_sizes[id];
|
||||
min_x = min_x.min(cx - w / 2.0);
|
||||
min_y = min_y.min(cy - h / 2.0);
|
||||
max_x = max_x.max(cx + w / 2.0);
|
||||
max_y = max_y.max(cy + h / 2.0);
|
||||
}
|
||||
let bounds_w = max_x - min_x;
|
||||
let bounds_h = max_y - min_y;
|
||||
|
||||
let vb_x = min_x - VB_MARGIN;
|
||||
let vb_y = min_y - VB_MARGIN;
|
||||
let vb_w = bounds_w + VB_MARGIN * 2.0;
|
||||
let vb_h = bounds_h + VB_MARGIN * 2.0;
|
||||
|
||||
let background_color = if theme.background == "#ffffff" {
|
||||
"white"
|
||||
} else {
|
||||
theme.background.as_str()
|
||||
};
|
||||
let text_color = if theme.text_color == "#333333" {
|
||||
"#333"
|
||||
} else {
|
||||
theme.text_color.as_str()
|
||||
};
|
||||
|
||||
let mut svg = String::new();
|
||||
svg.push_str(&format!(
|
||||
"<svg aria-roledescription=\"block\" role=\"graphics-document document\" viewBox=\"{vb_x} {vb_y} {vb_w} {vb_h}\" style=\"max-width: {vb_w}px; background-color: {background_color};\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">"
|
||||
));
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<style>{}</style>",
|
||||
block_css(
|
||||
text_color,
|
||||
&theme.edge_color,
|
||||
&theme.node_fill,
|
||||
&theme.node_stroke
|
||||
)
|
||||
));
|
||||
|
||||
svg.push_str("<g/>");
|
||||
svg.push_str(
|
||||
"<marker orient=\"auto\" markerHeight=\"12\" markerWidth=\"12\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"6\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-pointEnd\"><path style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 0 0 L 10 5 L 0 10 z\"/></marker>"
|
||||
);
|
||||
svg.push_str(
|
||||
"<marker orient=\"auto\" markerHeight=\"12\" markerWidth=\"12\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"4.5\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-pointStart\"><path style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 0 5 L 10 10 L 10 0 z\"/></marker>"
|
||||
);
|
||||
svg.push_str(
|
||||
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"11\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-circleEnd\"><circle style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" r=\"5\" cy=\"5\" cx=\"5\"/></marker>"
|
||||
);
|
||||
svg.push_str(
|
||||
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"-1\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-circleStart\"><circle style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" r=\"5\" cy=\"5\" cx=\"5\"/></marker>"
|
||||
);
|
||||
svg.push_str(
|
||||
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5.2\" refX=\"12\" viewBox=\"0 0 11 11\" class=\"marker cross block\" id=\"my-svg_block-crossEnd\"><path style=\"stroke-width: 2; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 1,1 l 9,9 M 10,1 l -9,9\"/></marker>"
|
||||
);
|
||||
svg.push_str(
|
||||
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5.2\" refX=\"-1\" viewBox=\"0 0 11 11\" class=\"marker cross block\" id=\"my-svg_block-crossStart\"><path style=\"stroke-width: 2; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 1,1 l 9,9 M 10,1 l -9,9\"/></marker>"
|
||||
);
|
||||
|
||||
svg.push_str("<g class=\"block\">");
|
||||
|
||||
// --- Render nodes ---
|
||||
for id in ordered_nodes {
|
||||
let Some((cx, cy)) = node_layout.get(id).copied() else {
|
||||
continue;
|
||||
};
|
||||
let (w, h) = node_sizes.get(id).copied().unwrap_or((0.0, 0.0));
|
||||
let label = diagram.nodes.get(id).map(String::as_str).unwrap_or(id);
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"node default default flowchart-label\" id=\"{id}\" transform=\"translate({cx}, {cy})\">",
|
||||
id = escape_xml(id)
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<rect class=\"basic label-container\" style=\"\" rx=\"0\" ry=\"0\" x=\"{x2}\" y=\"{y2}\" width=\"{w}\" height=\"{h}\"/>",
|
||||
x2 = -w / 2.0,
|
||||
y2 = -h / 2.0
|
||||
));
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"label\" style=\"\"><text text-anchor=\"middle\" dominant-baseline=\"central\" class=\"nodeLabel\" dy=\"0\">{}</text></g>",
|
||||
escape_xml(label),
|
||||
));
|
||||
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
// --- Render edges ---
|
||||
// Mermaid 11.12.2: 3 points [start_center, midpoint, end_center],
|
||||
// clipped via node intersection, then curveBasis path.
|
||||
for (idx, (from, to)) in diagram.edges.iter().enumerate() {
|
||||
let Some((fx, fy)) = node_layout.get(from).copied() else {
|
||||
continue;
|
||||
};
|
||||
let Some((tx, ty)) = node_layout.get(to).copied() else {
|
||||
continue;
|
||||
};
|
||||
let (fw, fh) = node_sizes.get(from).copied().unwrap_or((0.0, 0.0));
|
||||
let (tw, th) = node_sizes.get(to).copied().unwrap_or((0.0, 0.0));
|
||||
|
||||
let mid_x = fx + (tx - fx) / 2.0;
|
||||
let mid_y = fy + (ty - fy) / 2.0;
|
||||
|
||||
let start = rect_intersect(fx, fy, fw, fh, mid_x, mid_y);
|
||||
let end = rect_intersect(tx, ty, tw, th, mid_x, mid_y);
|
||||
|
||||
// Apply arrow_point marker offset to end point (mermaid's getLineFunctionsWithOffset).
|
||||
// This shifts the curve endpoint backward by ARROW_POINT_OFFSET in the edge direction
|
||||
// so the arrowhead marker tip lands at the correct position.
|
||||
let edge_dx = end.0 - start.0;
|
||||
let edge_dy = end.1 - start.1;
|
||||
let edge_len = (edge_dx * edge_dx + edge_dy * edge_dy).sqrt();
|
||||
let offset_end = if edge_len > 1e-9 {
|
||||
(
|
||||
end.0 - ARROW_POINT_OFFSET * edge_dx / edge_len,
|
||||
end.1 - ARROW_POINT_OFFSET * edge_dy / edge_len,
|
||||
)
|
||||
} else {
|
||||
end
|
||||
};
|
||||
|
||||
let points = vec![start, (mid_x, mid_y), offset_end];
|
||||
let d = curve_basis_path(&points);
|
||||
|
||||
let edge_no = idx + 1;
|
||||
let ls = format!("{}1", from.to_lowercase());
|
||||
let le = format!("{}1", to.to_lowercase());
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<path marker-end=\"url(#my-svg_block-pointEnd)\" class=\"edge-thickness-normal edge-pattern-solid flowchart-link LS-{ls} LE-{le}\" id=\"{edge_no}-{from}-{to}\" d=\"{d}\"/>",
|
||||
from = escape_xml(from),
|
||||
to = escape_xml(to)
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</g></svg>");
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
/// Compute the (px, py) grid position for a given column position.
|
||||
/// Mirrors mermaid's `calculateBlockPosition(columns, position)`.
|
||||
fn calculate_block_position(columns: i32, position: i32) -> (i32, i32) {
|
||||
if columns < 0 {
|
||||
return (position, 0);
|
||||
}
|
||||
if columns == 1 {
|
||||
return (0, position);
|
||||
}
|
||||
let px = position % columns;
|
||||
let py = position / columns;
|
||||
(px, py)
|
||||
}
|
||||
|
||||
/// Compute the intersection of a ray from inside (cx, cy) toward outside (ox, oy)
|
||||
/// with the boundary of a rect centered at (cx, cy) with given width and height.
|
||||
fn rect_intersect(cx: f64, cy: f64, w: f64, h: f64, ox: f64, oy: f64) -> (f64, f64) {
|
||||
let hw = w / 2.0;
|
||||
let hh = h / 2.0;
|
||||
let dx = ox - cx;
|
||||
let dy = oy - cy;
|
||||
|
||||
if dx.abs() < 1e-9 && dy.abs() < 1e-9 {
|
||||
return (cx + hw, cy);
|
||||
}
|
||||
|
||||
if dx.abs() > 1e-9 {
|
||||
let t_x = if dx > 0.0 { hw / dx } else { -hw / dx };
|
||||
let y_at_edge = cy + dy * t_x;
|
||||
if (y_at_edge - cy).abs() <= hh + 1e-9 {
|
||||
if dx > 0.0 {
|
||||
return (cx + hw, y_at_edge);
|
||||
} else {
|
||||
return (cx - hw, y_at_edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dy.abs() > 1e-9 {
|
||||
let t_y = if dy > 0.0 { hh / dy } else { -hh / dy };
|
||||
let x_at_edge = cx + dx * t_y;
|
||||
if dy > 0.0 {
|
||||
return (x_at_edge, cy + hh);
|
||||
} else {
|
||||
return (x_at_edge, cy - hh);
|
||||
}
|
||||
}
|
||||
|
||||
(cx + hw, cy)
|
||||
}
|
||||
|
||||
/// Generate an SVG path string using D3's curveBasis (uniform cubic B-spline).
|
||||
fn curve_basis_path(points: &[(f64, f64)]) -> String {
|
||||
if points.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if points.len() == 1 {
|
||||
return format!("M{},{}", fmt_num(points[0].0), fmt_num(points[0].1));
|
||||
}
|
||||
if points.len() == 2 {
|
||||
let (x0, y0) = points[0];
|
||||
let (x1, y1) = points[1];
|
||||
return format!(
|
||||
"M{},{}L{},{}",
|
||||
fmt_num(x0),
|
||||
fmt_num(y0),
|
||||
fmt_num(x1),
|
||||
fmt_num(y1)
|
||||
);
|
||||
}
|
||||
|
||||
let mut path = String::new();
|
||||
let n = points.len();
|
||||
|
||||
let (x0, y0) = points[0];
|
||||
path.push_str(&format!("M{},{}", fmt_num(x0), fmt_num(y0)));
|
||||
|
||||
let (x1, y1) = points[1];
|
||||
let lx = (2.0 * x0 + x1) / 3.0;
|
||||
let ly = (2.0 * y0 + y1) / 3.0;
|
||||
path.push_str(&format!("L{},{}", fmt_num(lx), fmt_num(ly)));
|
||||
|
||||
for i in 1..n - 1 {
|
||||
let (px, py) = points[i - 1];
|
||||
let (cx, cy) = points[i];
|
||||
let (nx, ny) = points[i + 1];
|
||||
|
||||
let cp1x = (2.0 * cx + px) / 3.0;
|
||||
let cp1y = (2.0 * cy + py) / 3.0;
|
||||
let cp2x = (2.0 * cx + nx) / 3.0;
|
||||
let cp2y = (2.0 * cy + ny) / 3.0;
|
||||
|
||||
if i == n - 2 {
|
||||
let end_x = (2.0 * cx + nx) / 3.0;
|
||||
let end_y = (2.0 * cy + ny) / 3.0;
|
||||
path.push_str(&format!(
|
||||
"C{},{},{},{},{},{}",
|
||||
fmt_num(cp1x),
|
||||
fmt_num(cp1y),
|
||||
fmt_num(cp2x),
|
||||
fmt_num(cp2y),
|
||||
fmt_num(end_x),
|
||||
fmt_num(end_y)
|
||||
));
|
||||
} else {
|
||||
let epx = (cx + nx) / 2.0;
|
||||
let epy = (cy + ny) / 2.0;
|
||||
path.push_str(&format!(
|
||||
"C{},{},{},{},{},{}",
|
||||
fmt_num(cp1x),
|
||||
fmt_num(cp1y),
|
||||
fmt_num(cp2x),
|
||||
fmt_num(cp2y),
|
||||
fmt_num(epx),
|
||||
fmt_num(epy)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let (xn, yn) = points[n - 1];
|
||||
path.push_str(&format!("L{},{}", fmt_num(xn), fmt_num(yn)));
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
fn fmt_num(v: f64) -> String {
|
||||
let rounded = (v * 1000.0).round() / 1000.0;
|
||||
if (rounded - rounded.round()).abs() < 1e-9 {
|
||||
format!("{:.0}", rounded)
|
||||
} else {
|
||||
let s = format!("{rounded:.3}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct BlockDiagram {
|
||||
nodes: HashMap<String, String>,
|
||||
/// Insertion-ordered list of node IDs (preserves declaration order).
|
||||
node_order: Vec<String>,
|
||||
edges: Vec<(String, String)>,
|
||||
/// Number of columns (from `columns N`). -1 means auto (single row).
|
||||
columns: i32,
|
||||
}
|
||||
|
||||
fn parse_block_beta(input: &str) -> Result<BlockDiagram, MermaidError> {
|
||||
let mut found_header = false;
|
||||
let mut nodes: HashMap<String, String> = HashMap::new();
|
||||
let mut node_order: Vec<String> = Vec::new();
|
||||
let mut edges: Vec<(String, String)> = Vec::new();
|
||||
let mut columns: i32 = -1;
|
||||
|
||||
let insert_node = |id: String,
|
||||
label: String,
|
||||
nodes: &mut HashMap<String, String>,
|
||||
order: &mut Vec<String>| {
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = nodes.entry(id.clone()) {
|
||||
order.push(id.clone());
|
||||
e.insert(label);
|
||||
} else if label != id {
|
||||
// Only update the label if the new label is explicit (not just the bare ID).
|
||||
nodes.insert(id, label);
|
||||
}
|
||||
};
|
||||
|
||||
for (idx, raw) in input.lines().enumerate() {
|
||||
let line_no = idx + 1;
|
||||
let line = raw.trim();
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
if line.split_whitespace().next() != Some("block-beta") {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected 'block-beta' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
found_header = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// columns directive
|
||||
if let Some(rest) = line.strip_prefix("columns") {
|
||||
let rest = rest.trim();
|
||||
if rest == "auto" {
|
||||
columns = -1;
|
||||
} else if let Ok(n) = rest.parse::<i32>() {
|
||||
columns = n;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip block:/end group markers (we don't support nested groups yet
|
||||
// but should not error on them)
|
||||
if line == "end" || line.starts_with("block:") || line.starts_with("block ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip style/classDef/class/linkStyle/space directives
|
||||
if line.starts_with("style ")
|
||||
|| line.starts_with("classDef ")
|
||||
|| line.starts_with("class ")
|
||||
|| line.starts_with("linkStyle ")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Space directive: `space` or `space:N`
|
||||
if line == "space" || line.starts_with("space:") {
|
||||
// Space nodes are invisible placeholders; skip for now
|
||||
continue;
|
||||
}
|
||||
|
||||
// Edge line: contains `-->`
|
||||
if let Some((lhs, rhs)) = line.split_once("-->") {
|
||||
let (from_id, from_label) = parse_block_node(lhs.trim(), line_no)?;
|
||||
let (to_id, to_label) = parse_block_node(rhs.trim(), line_no)?;
|
||||
insert_node(from_id.clone(), from_label, &mut nodes, &mut node_order);
|
||||
insert_node(to_id.clone(), to_label, &mut nodes, &mut node_order);
|
||||
edges.push((from_id, to_id));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standalone node declaration
|
||||
if let Ok((id, label)) = parse_block_node(line, line_no) {
|
||||
insert_node(id, label, &mut nodes, &mut node_order);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Expected 'block-beta' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(BlockDiagram {
|
||||
nodes,
|
||||
node_order,
|
||||
edges,
|
||||
columns,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_block_node(s: &str, line: usize) -> Result<(String, String), MermaidError> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line,
|
||||
message: "Empty node".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(bracket_start) = s.find('[') {
|
||||
let id = s[..bracket_start].trim().to_string();
|
||||
let inner = s[bracket_start + 1..].trim();
|
||||
let inner = inner.strip_suffix(']').unwrap_or(inner).trim();
|
||||
let label = strip_quotes(inner);
|
||||
if id.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Missing node id in '{s}'"),
|
||||
});
|
||||
}
|
||||
return Ok((id, label));
|
||||
}
|
||||
|
||||
Ok((s.to_string(), s.to_string()))
|
||||
}
|
||||
|
||||
fn strip_quotes(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
if let Some(inner) = s.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
|
||||
return inner.to_string();
|
||||
}
|
||||
if let Some(inner) = s.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
|
||||
return inner.to_string();
|
||||
}
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
fn block_css(text_color: &str, edge_color: &str, node_fill: &str, node_stroke: &str) -> String {
|
||||
format!(
|
||||
"#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{text_color};}}@keyframes edge-animation-frame{{from{{stroke-dashoffset:0;}}}}@keyframes dash{{to{{stroke-dashoffset:0;}}}}#my-svg .edge-animation-slow{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}}#my-svg .edge-animation-fast{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}}#my-svg .error-icon{{fill:#552222;}}#my-svg .error-text{{fill:#552222;stroke:#552222;}}#my-svg .edge-thickness-normal{{stroke-width:1px;}}#my-svg .edge-thickness-thick{{stroke-width:3.5px;}}#my-svg .edge-pattern-solid{{stroke-dasharray:0;}}#my-svg .edge-thickness-invisible{{stroke-width:0;fill:none;}}#my-svg .edge-pattern-dashed{{stroke-dasharray:3;}}#my-svg .edge-pattern-dotted{{stroke-dasharray:2;}}#my-svg .marker{{fill:{edge_color};stroke:{edge_color};}}#my-svg .marker.cross{{stroke:{edge_color};}}#my-svg svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}}#my-svg p{{margin:0;}}#my-svg .label{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:{text_color};}}#my-svg .cluster-label text{{fill:{text_color};}}#my-svg .cluster-label span,#my-svg p{{color:{text_color};}}#my-svg .label text,#my-svg span,#my-svg p{{fill:{text_color};color:{text_color};}}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{{fill:{node_fill};stroke:{node_stroke};stroke-width:1px;}}#my-svg .flowchart-label text{{text-anchor:middle;}}#my-svg .node .label{{text-align:center;}}#my-svg .node.clickable{{cursor:pointer;}}#my-svg .arrowheadPath{{fill:{edge_color};}}#my-svg .edgePath .path{{stroke:{edge_color};stroke-width:2.0px;}}#my-svg .flowchart-link{{stroke:{edge_color};fill:none;}}#my-svg .edgeLabel{{background-color:rgba(232,232,232, 0.8);text-align:center;}}#my-svg .edgeLabel rect{{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}}#my-svg .labelBkg{{background-color:rgba(232, 232, 232, 0.5);}}#my-svg .node .cluster{{fill:rgba(255, 255, 222, 0.5);stroke:rgba(170, 170, 51, 0.2);box-shadow:rgba(50, 50, 93, 0.25) 0px 13px 27px -5px,rgba(0, 0, 0, 0.3) 0px 8px 16px -8px;stroke-width:1px;}}#my-svg .cluster text{{fill:{text_color};}}#my-svg .cluster span,#my-svg p{{color:{text_color};}}#my-svg div.mermaidTooltip{{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}}#my-svg .flowchartTitleText{{text-anchor:middle;font-size:18px;fill:{text_color};}}#my-svg :root{{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;}}",
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+1201
File diff suppressed because it is too large
Load Diff
+1144
File diff suppressed because it is too large
Load Diff
+246
@@ -0,0 +1,246 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_yaml::Value;
|
||||
|
||||
use crate::theme::{MermaidTheme, MermaidThemePreset, MermaidThemeVariables};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ParsedMermaidSource<'a> {
|
||||
pub body: Cow<'a, str>,
|
||||
pub frontmatter: Option<MermaidFrontmatter>,
|
||||
pub config: RenderConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MermaidFrontmatter {
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RenderConfig {
|
||||
pub theme: Option<MermaidThemePreset>,
|
||||
pub theme_variables: MermaidThemeVariables,
|
||||
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
|
||||
pub layout: Option<String>,
|
||||
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
|
||||
pub look: Option<String>,
|
||||
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
|
||||
pub security_level: Option<String>,
|
||||
pub font_family: Option<String>,
|
||||
pub font_size: Option<String>,
|
||||
pub flowchart: FlowchartConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct FlowchartConfig {
|
||||
pub curve: Option<String>,
|
||||
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
|
||||
pub html_labels: Option<bool>,
|
||||
pub node_spacing: Option<u32>,
|
||||
pub rank_spacing: Option<u32>,
|
||||
pub padding: Option<u32>,
|
||||
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
|
||||
pub diagram_padding: Option<u32>,
|
||||
pub wrapping_width: Option<u32>,
|
||||
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
|
||||
pub use_max_width: Option<bool>,
|
||||
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
|
||||
pub default_renderer: Option<String>,
|
||||
}
|
||||
|
||||
impl RenderConfig {
|
||||
pub fn to_mermaid_theme(&self) -> Option<MermaidTheme> {
|
||||
if self.theme.is_none() && self.theme_variables.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut theme = self.theme.unwrap_or(MermaidThemePreset::Default).to_theme();
|
||||
self.theme_variables.apply_to(&mut theme);
|
||||
Some(theme)
|
||||
}
|
||||
|
||||
pub fn font_size_px(&self) -> Option<f64> {
|
||||
self.font_size.as_deref().and_then(parse_font_size)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_mermaid_frontmatter(source: &str) -> ParsedMermaidSource<'_> {
|
||||
let Some((yaml_start, yaml_end, body_start)) = frontmatter_bounds(source) else {
|
||||
return ParsedMermaidSource {
|
||||
body: Cow::Borrowed(source),
|
||||
frontmatter: None,
|
||||
config: RenderConfig::default(),
|
||||
};
|
||||
};
|
||||
|
||||
let body = Cow::Owned(source[body_start..].to_string());
|
||||
let yaml = &source[yaml_start..yaml_end];
|
||||
let Some(value) = parse_yaml_value(yaml) else {
|
||||
return ParsedMermaidSource {
|
||||
body,
|
||||
frontmatter: Some(MermaidFrontmatter::default()),
|
||||
config: RenderConfig::default(),
|
||||
};
|
||||
};
|
||||
|
||||
let frontmatter = parse_frontmatter_metadata(&value);
|
||||
let config = parse_render_config(&value);
|
||||
|
||||
ParsedMermaidSource {
|
||||
body,
|
||||
frontmatter: Some(frontmatter),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_yaml_value(yaml: &str) -> Option<Value> {
|
||||
if yaml.trim().is_empty() {
|
||||
return Some(Value::Null);
|
||||
}
|
||||
|
||||
serde_yaml::from_str::<Value>(yaml).ok()
|
||||
}
|
||||
|
||||
fn parse_frontmatter_metadata(value: &Value) -> MermaidFrontmatter {
|
||||
MermaidFrontmatter {
|
||||
title: mapping_value(value, "title").and_then(value_to_string),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_render_config(value: &Value) -> RenderConfig {
|
||||
let Some(config) = mapping_value(value, "config") else {
|
||||
return RenderConfig::default();
|
||||
};
|
||||
|
||||
RenderConfig {
|
||||
theme: mapping_value(config, "theme")
|
||||
.and_then(value_to_string)
|
||||
.and_then(|theme| MermaidThemePreset::parse(&theme)),
|
||||
theme_variables: parse_theme_variables(mapping_value(config, "themeVariables")),
|
||||
layout: mapping_value(config, "layout").and_then(value_to_string),
|
||||
look: mapping_value(config, "look").and_then(value_to_string),
|
||||
security_level: mapping_value(config, "securityLevel").and_then(value_to_string),
|
||||
font_family: mapping_value(config, "fontFamily").and_then(value_to_string),
|
||||
font_size: mapping_value(config, "fontSize").and_then(value_to_string),
|
||||
flowchart: parse_flowchart_config(mapping_value(config, "flowchart")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_theme_variables(value: Option<&Value>) -> MermaidThemeVariables {
|
||||
let mut variables = MermaidThemeVariables::default();
|
||||
let Some(Value::Mapping(mapping)) = value else {
|
||||
return variables;
|
||||
};
|
||||
|
||||
for (key, value) in mapping {
|
||||
let Some(key) = key.as_str() else {
|
||||
continue;
|
||||
};
|
||||
let Some(value) = value_to_string(value) else {
|
||||
continue;
|
||||
};
|
||||
variables.apply_mermaid_alias(key, value);
|
||||
}
|
||||
|
||||
variables
|
||||
}
|
||||
|
||||
fn parse_flowchart_config(value: Option<&Value>) -> FlowchartConfig {
|
||||
let Some(flowchart) = value else {
|
||||
return FlowchartConfig::default();
|
||||
};
|
||||
|
||||
FlowchartConfig {
|
||||
curve: mapping_value(flowchart, "curve").and_then(value_to_string),
|
||||
html_labels: mapping_value(flowchart, "htmlLabels").and_then(value_to_bool),
|
||||
node_spacing: mapping_value(flowchart, "nodeSpacing").and_then(value_to_u32),
|
||||
rank_spacing: mapping_value(flowchart, "rankSpacing").and_then(value_to_u32),
|
||||
padding: mapping_value(flowchart, "padding").and_then(value_to_u32),
|
||||
diagram_padding: mapping_value(flowchart, "diagramPadding").and_then(value_to_u32),
|
||||
wrapping_width: mapping_value(flowchart, "wrappingWidth").and_then(value_to_u32),
|
||||
use_max_width: mapping_value(flowchart, "useMaxWidth").and_then(value_to_bool),
|
||||
default_renderer: mapping_value(flowchart, "defaultRenderer").and_then(value_to_string),
|
||||
}
|
||||
}
|
||||
|
||||
fn mapping_value<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
let Value::Mapping(mapping) = value else {
|
||||
return None;
|
||||
};
|
||||
|
||||
mapping.get(&Value::String(key.to_string()))
|
||||
}
|
||||
|
||||
fn value_to_string(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(value) => Some(value.clone()),
|
||||
Value::Number(value) => Some(value.to_string()),
|
||||
Value::Bool(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_bool(value: &Value) -> Option<bool> {
|
||||
match value {
|
||||
Value::Bool(value) => Some(*value),
|
||||
Value::String(value) => match value.as_str() {
|
||||
"true" => Some(true),
|
||||
"false" => Some(false),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_u32(value: &Value) -> Option<u32> {
|
||||
match value {
|
||||
Value::Number(value) => value.as_u64().and_then(|value| u32::try_from(value).ok()),
|
||||
Value::String(value) => value.parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_font_size(value: &str) -> Option<f64> {
|
||||
let trimmed = value.trim();
|
||||
let numeric = trimmed.strip_suffix("px").unwrap_or(trimmed).trim();
|
||||
numeric
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|size| size.is_finite() && *size > 0.0)
|
||||
}
|
||||
|
||||
fn frontmatter_bounds(source: &str) -> Option<(usize, usize, usize)> {
|
||||
let mut cursor = 0;
|
||||
while cursor < source.len() {
|
||||
let end = next_line_end(source, cursor);
|
||||
let line = source[cursor..end].trim();
|
||||
if line.is_empty() {
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
if line != "---" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let yaml_start = end;
|
||||
let mut scan = end;
|
||||
while scan < source.len() {
|
||||
let scan_end = next_line_end(source, scan);
|
||||
if source[scan..scan_end].trim() == "---" {
|
||||
return Some((yaml_start, scan, scan_end));
|
||||
}
|
||||
scan = scan_end;
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn next_line_end(source: &str, start: usize) -> usize {
|
||||
source[start..]
|
||||
.find('\n')
|
||||
.map(|position| start + position + 1)
|
||||
.unwrap_or(source.len())
|
||||
}
|
||||
+936
@@ -0,0 +1,936 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::error::MermaidError;
|
||||
use crate::text_wrap::{line_width, DEFAULT_CHAR_WIDTH};
|
||||
use crate::theme::MermaidTheme;
|
||||
use dagre_rust::layout::layout as dagre_layout;
|
||||
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::Graph;
|
||||
|
||||
// --- Constants matching mermaid.js ER renderer defaults ---
|
||||
const PADDING: f64 = 10.0;
|
||||
const TEXT_PADDING: f64 = 6.0;
|
||||
const NODE_SEP: f64 = 140.0;
|
||||
const RANK_SEP: f64 = 80.0;
|
||||
const GRAPH_MARGIN: f64 = 8.0;
|
||||
const LINE_HEIGHT: f64 = 36.75;
|
||||
const MIN_ENTITY_WIDTH: f64 = 100.0;
|
||||
const FONT_SIZE: f64 = 16.0;
|
||||
const COLUMN_TEXT_PADDING: f64 = 8.0;
|
||||
|
||||
// --- ER-specific AST ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Cardinality {
|
||||
ZeroOrOne,
|
||||
ZeroOrMore,
|
||||
OneOrMore,
|
||||
OnlyOne,
|
||||
}
|
||||
|
||||
impl Cardinality {
|
||||
fn marker_name(&self) -> &'static str {
|
||||
match self {
|
||||
Cardinality::ZeroOrOne => "zeroOrOne",
|
||||
Cardinality::ZeroOrMore => "zeroOrMore",
|
||||
Cardinality::OneOrMore => "oneOrMore",
|
||||
Cardinality::OnlyOne => "onlyOne",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Identification {
|
||||
Identifying,
|
||||
NonIdentifying,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RelSpec {
|
||||
card_a: Cardinality,
|
||||
card_b: Cardinality,
|
||||
rel_type: Identification,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Attribute {
|
||||
attr_type: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Entity {
|
||||
id: String,
|
||||
attributes: Vec<Attribute>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Relationship {
|
||||
entity_a: String,
|
||||
entity_b: String,
|
||||
role: String,
|
||||
rel_spec: RelSpec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ErDiagram {
|
||||
entities: BTreeMap<String, Entity>,
|
||||
relationships: Vec<Relationship>,
|
||||
}
|
||||
|
||||
// --- Layout types ---
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EntityLayout {
|
||||
id: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
header_height: f64,
|
||||
max_type_width: f64,
|
||||
attributes: Vec<Attribute>,
|
||||
row_heights: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EdgeLayout {
|
||||
#[allow(dead_code)]
|
||||
from: String,
|
||||
#[allow(dead_code)]
|
||||
to: String,
|
||||
role: String,
|
||||
rel_spec: RelSpec,
|
||||
points: Vec<(f64, f64)>,
|
||||
label_pos: Option<(f64, f64)>,
|
||||
label_width: f64,
|
||||
label_height: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DiagramLayout {
|
||||
entities: BTreeMap<String, EntityLayout>,
|
||||
edges: Vec<EdgeLayout>,
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
// --- Public entry point ---
|
||||
|
||||
pub fn render_er_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let diagram = parse_er_diagram(mermaid_source)?;
|
||||
let layout = compute_layout(&diagram);
|
||||
Ok(render_svg(&layout, theme))
|
||||
}
|
||||
|
||||
// --- Parser ---
|
||||
|
||||
fn parse_er_diagram(input: &str) -> Result<ErDiagram, MermaidError> {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
let mut i = 0_usize;
|
||||
|
||||
// Find header
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if line.split_whitespace().next() == Some("erDiagram") {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
return Err(MermaidError::ParseError {
|
||||
line: i + 1,
|
||||
message: "Expected 'erDiagram' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut entities: BTreeMap<String, Entity> = BTreeMap::new();
|
||||
let mut referenced_entities: BTreeSet<String> = BTreeSet::new();
|
||||
let mut relationships: Vec<Relationship> = Vec::new();
|
||||
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
let line = raw.trim();
|
||||
let line_no = i + 1;
|
||||
i += 1;
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Entity block: `ENTITY_NAME {`
|
||||
if let Some(name_raw) = line.strip_suffix('{') {
|
||||
let name = name_raw.trim();
|
||||
if name.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected entity name before '{'".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut attrs: Vec<Attribute> = Vec::new();
|
||||
while i < lines.len() {
|
||||
let attr_raw = lines[i];
|
||||
let attr_line = attr_raw.trim();
|
||||
i += 1;
|
||||
|
||||
if attr_line.is_empty() || attr_line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
if attr_line == "}" {
|
||||
break;
|
||||
}
|
||||
// Parse "type name" pairs
|
||||
let parts: Vec<&str> = attr_line.splitn(2, char::is_whitespace).collect();
|
||||
if parts.len() >= 2 {
|
||||
attrs.push(Attribute {
|
||||
attr_type: parts[0].to_string(),
|
||||
name: parts[1].trim().to_string(),
|
||||
});
|
||||
} else if !parts.is_empty() {
|
||||
attrs.push(Attribute {
|
||||
attr_type: parts[0].to_string(),
|
||||
name: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entities.insert(
|
||||
name.to_string(),
|
||||
Entity {
|
||||
id: name.to_string(),
|
||||
attributes: attrs,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line == "}" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Relationship: `ENTITY_A ||--o{ ENTITY_B : label`
|
||||
if line.contains("--") || line.contains("..") {
|
||||
if let Some(rel) = parse_relationship(line, line_no)? {
|
||||
referenced_entities.insert(rel.entity_a.clone());
|
||||
referenced_entities.insert(rel.entity_b.clone());
|
||||
relationships.push(rel);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Unrecognized erDiagram line: {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure all referenced entities exist
|
||||
for id in referenced_entities {
|
||||
entities.entry(id.clone()).or_insert_with(|| Entity {
|
||||
id,
|
||||
attributes: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ErDiagram {
|
||||
entities,
|
||||
relationships,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_relationship(line: &str, line_no: usize) -> Result<Option<Relationship>, MermaidError> {
|
||||
// Split on `:` to get role label
|
||||
let (lhs, role) = match line.split_once(':') {
|
||||
Some((a, b)) => {
|
||||
let label = b.trim();
|
||||
(
|
||||
a.trim(),
|
||||
if label.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
label.to_string()
|
||||
},
|
||||
)
|
||||
}
|
||||
None => (line.trim(), String::new()),
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = lhs.split_whitespace().collect();
|
||||
if parts.len() < 3 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid erDiagram relationship: {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
let entity_a = parts[0].to_string();
|
||||
let rel_str = parts[1];
|
||||
let entity_b = parts[2].to_string();
|
||||
|
||||
let rel_spec = parse_rel_spec(rel_str, line_no)?;
|
||||
|
||||
Ok(Some(Relationship {
|
||||
entity_a,
|
||||
entity_b,
|
||||
role,
|
||||
rel_spec,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_rel_spec(s: &str, line_no: usize) -> Result<RelSpec, MermaidError> {
|
||||
// Format: cardA--cardB or cardA..cardB
|
||||
// Cards: || (only one), |o or o| (zero or one), }| or |{ (one or more),
|
||||
// }o or o{ (zero or more)
|
||||
// `--` = IDENTIFYING, `..` = NON_IDENTIFYING
|
||||
|
||||
let (left_part, rel_type, right_part) = if let Some(idx) = s.find("--") {
|
||||
(&s[..idx], Identification::Identifying, &s[idx + 2..])
|
||||
} else if let Some(idx) = s.find("..") {
|
||||
(&s[..idx], Identification::NonIdentifying, &s[idx + 2..])
|
||||
} else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid relationship spec: {s}"),
|
||||
});
|
||||
};
|
||||
|
||||
let card_a = parse_cardinality(left_part, line_no)?;
|
||||
let card_b = parse_cardinality(right_part, line_no)?;
|
||||
|
||||
Ok(RelSpec {
|
||||
card_a,
|
||||
card_b,
|
||||
rel_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_cardinality(s: &str, line_no: usize) -> Result<Cardinality, MermaidError> {
|
||||
match s {
|
||||
"||" => Ok(Cardinality::OnlyOne),
|
||||
"|o" | "o|" => Ok(Cardinality::ZeroOrOne),
|
||||
"|{" | "}|" => Ok(Cardinality::OneOrMore),
|
||||
"o{" | "}o" => Ok(Cardinality::ZeroOrMore),
|
||||
_ => Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid cardinality: {s}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layout computation using dagre ---
|
||||
|
||||
fn compute_entity_metrics(entity: &Entity) -> (f64, f64, f64, f64, Vec<f64>) {
|
||||
// Returns: (total_width, total_height, header_height, max_type_width, row_heights)
|
||||
let header_text = &entity.id;
|
||||
let header_width = line_width(header_text, DEFAULT_CHAR_WIDTH);
|
||||
let header_height = LINE_HEIGHT + TEXT_PADDING;
|
||||
|
||||
if entity.attributes.is_empty() {
|
||||
let width = (header_width + PADDING * 2.0).max(MIN_ENTITY_WIDTH);
|
||||
let height = header_height + PADDING;
|
||||
return (width, height, header_height, 0.0, Vec::new());
|
||||
}
|
||||
|
||||
let mut max_type_width: f64 = 0.0;
|
||||
let mut max_name_width: f64 = 0.0;
|
||||
let mut row_heights = Vec::new();
|
||||
|
||||
for attr in &entity.attributes {
|
||||
let type_w = line_width(&attr.attr_type, DEFAULT_CHAR_WIDTH);
|
||||
let name_w = line_width(&attr.name, DEFAULT_CHAR_WIDTH);
|
||||
max_type_width = max_type_width.max(type_w + COLUMN_TEXT_PADDING * 2.0);
|
||||
max_name_width = max_name_width.max(name_w + COLUMN_TEXT_PADDING * 2.0);
|
||||
row_heights.push(LINE_HEIGHT + TEXT_PADDING);
|
||||
}
|
||||
|
||||
let attr_total_width = max_type_width + max_name_width;
|
||||
let content_width = attr_total_width.max(header_width + PADDING * 2.0);
|
||||
let total_width = content_width.max(MIN_ENTITY_WIDTH);
|
||||
|
||||
let total_attr_height: f64 = row_heights.iter().sum();
|
||||
let total_height = header_height + total_attr_height;
|
||||
|
||||
(
|
||||
total_width,
|
||||
total_height,
|
||||
header_height,
|
||||
max_type_width,
|
||||
row_heights,
|
||||
)
|
||||
}
|
||||
|
||||
fn compute_layout(diagram: &ErDiagram) -> DiagramLayout {
|
||||
let mut entity_metrics: BTreeMap<String, (f64, f64, f64, f64, Vec<f64>)> = BTreeMap::new();
|
||||
for (id, entity) in &diagram.entities {
|
||||
entity_metrics.insert(id.clone(), compute_entity_metrics(entity));
|
||||
}
|
||||
|
||||
type DagreGraph = Graph<GraphConfig, GraphNode, GraphEdge>;
|
||||
let mut g: DagreGraph = Graph::new(Some(graphlib_rust::GraphOption {
|
||||
directed: Some(true),
|
||||
multigraph: Some(true),
|
||||
compound: Some(false),
|
||||
}));
|
||||
|
||||
g.set_graph(GraphConfig {
|
||||
rankdir: Some("tb".to_string()),
|
||||
nodesep: Some(NODE_SEP as f32),
|
||||
ranksep: Some(RANK_SEP as f32),
|
||||
edgesep: Some(20.0),
|
||||
marginx: Some(GRAPH_MARGIN as f32),
|
||||
marginy: Some(GRAPH_MARGIN as f32),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
for (id, (w, h, _, _, _)) in &entity_metrics {
|
||||
g.set_node(
|
||||
id.clone(),
|
||||
Some(GraphNode {
|
||||
width: *w as f32,
|
||||
height: *h as f32,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let mut edge_keys: Vec<(String, String)> = Vec::new();
|
||||
for rel in &diagram.relationships {
|
||||
let label_text = &rel.role;
|
||||
let label_width = if label_text.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
line_width(label_text, DEFAULT_CHAR_WIDTH)
|
||||
};
|
||||
let label_height = if label_text.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
LINE_HEIGHT
|
||||
};
|
||||
|
||||
let edge_label = GraphEdge {
|
||||
labelpos: Some("c".to_string()),
|
||||
width: Some(label_width as f32),
|
||||
height: Some(label_height as f32),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let _ = g.set_edge(&rel.entity_a, &rel.entity_b, Some(edge_label), None);
|
||||
edge_keys.push((rel.entity_a.clone(), rel.entity_b.clone()));
|
||||
}
|
||||
|
||||
dagre_layout(&mut g);
|
||||
|
||||
let mut positions: BTreeMap<String, (f64, f64)> = BTreeMap::new();
|
||||
for node_id in g.nodes() {
|
||||
if let Some(node) = g.node(&node_id) {
|
||||
positions.insert(node_id, (node.x as f64, node.y as f64));
|
||||
}
|
||||
}
|
||||
|
||||
let mut edges: Vec<EdgeLayout> = Vec::new();
|
||||
for (idx, (from, to)) in edge_keys.iter().enumerate() {
|
||||
let Some(edge) = g.edge(from, to, None) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let points: Vec<(f64, f64)> = edge
|
||||
.points
|
||||
.as_ref()
|
||||
.map(|pts| pts.iter().map(|p| (p.x as f64, p.y as f64)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let label_pos = if edge.width.unwrap_or(0.0) > 0.0 || edge.height.unwrap_or(0.0) > 0.0 {
|
||||
Some((edge.x as f64, edge.y as f64))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rel = &diagram.relationships[idx];
|
||||
let label_width = edge.width.unwrap_or(0.0) as f64;
|
||||
let label_height = edge.height.unwrap_or(0.0) as f64;
|
||||
|
||||
edges.push(EdgeLayout {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
role: rel.role.clone(),
|
||||
rel_spec: rel.rel_spec.clone(),
|
||||
points,
|
||||
label_pos,
|
||||
label_width,
|
||||
label_height,
|
||||
});
|
||||
}
|
||||
|
||||
let mut entity_layouts: BTreeMap<String, EntityLayout> = BTreeMap::new();
|
||||
for (id, (x, y)) in &positions {
|
||||
let Some((w, h, header_h, max_type_w, row_heights)) = entity_metrics.get(id).cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let entity = diagram.entities.get(id).cloned().unwrap_or(Entity {
|
||||
id: id.clone(),
|
||||
attributes: Vec::new(),
|
||||
});
|
||||
entity_layouts.insert(
|
||||
id.clone(),
|
||||
EntityLayout {
|
||||
id: id.clone(),
|
||||
x: *x,
|
||||
y: *y,
|
||||
width: w,
|
||||
height: h,
|
||||
header_height: header_h,
|
||||
max_type_width: max_type_w,
|
||||
attributes: entity.attributes,
|
||||
row_heights,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Compute bounds and normalize
|
||||
let (min_x, min_y, max_x, max_y) = compute_bounds(&entity_layouts, &edges);
|
||||
let dx = GRAPH_MARGIN - min_x;
|
||||
let dy = GRAPH_MARGIN - min_y;
|
||||
|
||||
for entity in entity_layouts.values_mut() {
|
||||
entity.x += dx;
|
||||
entity.y += dy;
|
||||
}
|
||||
for edge in &mut edges {
|
||||
for p in &mut edge.points {
|
||||
p.0 += dx;
|
||||
p.1 += dy;
|
||||
}
|
||||
if let Some((x, y)) = edge.label_pos {
|
||||
edge.label_pos = Some((x + dx, y + dy));
|
||||
}
|
||||
}
|
||||
|
||||
let width = (max_x - min_x) + GRAPH_MARGIN * 2.0;
|
||||
let height = (max_y - min_y) + GRAPH_MARGIN * 2.0;
|
||||
|
||||
DiagramLayout {
|
||||
entities: entity_layouts,
|
||||
edges,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_bounds(
|
||||
entities: &BTreeMap<String, EntityLayout>,
|
||||
edges: &[EdgeLayout],
|
||||
) -> (f64, f64, f64, f64) {
|
||||
let mut min_x = f64::INFINITY;
|
||||
let mut min_y = f64::INFINITY;
|
||||
let mut max_x = f64::NEG_INFINITY;
|
||||
let mut max_y = f64::NEG_INFINITY;
|
||||
|
||||
for entity in entities.values() {
|
||||
let left = entity.x - entity.width / 2.0;
|
||||
let right = entity.x + entity.width / 2.0;
|
||||
let top = entity.y - entity.height / 2.0;
|
||||
let bottom = entity.y + entity.height / 2.0;
|
||||
min_x = min_x.min(left);
|
||||
min_y = min_y.min(top);
|
||||
max_x = max_x.max(right);
|
||||
max_y = max_y.max(bottom);
|
||||
}
|
||||
|
||||
for edge in edges {
|
||||
for (x, y) in &edge.points {
|
||||
min_x = min_x.min(*x);
|
||||
min_y = min_y.min(*y);
|
||||
max_x = max_x.max(*x);
|
||||
max_y = max_y.max(*y);
|
||||
}
|
||||
if let Some((x, y)) = edge.label_pos {
|
||||
let left = x - edge.label_width / 2.0;
|
||||
let right = x + edge.label_width / 2.0;
|
||||
let top = y - edge.label_height / 2.0;
|
||||
let bottom = y + edge.label_height / 2.0;
|
||||
min_x = min_x.min(left);
|
||||
min_y = min_y.min(top);
|
||||
max_x = max_x.max(right);
|
||||
max_y = max_y.max(bottom);
|
||||
}
|
||||
}
|
||||
|
||||
if !min_x.is_finite() {
|
||||
min_x = 0.0;
|
||||
max_x = 0.0;
|
||||
}
|
||||
if !min_y.is_finite() {
|
||||
min_y = 0.0;
|
||||
max_y = 0.0;
|
||||
}
|
||||
|
||||
(min_x, min_y, max_x, max_y)
|
||||
}
|
||||
|
||||
// --- SVG Rendering ---
|
||||
|
||||
fn render_svg(layout: &DiagramLayout, theme: &MermaidTheme) -> String {
|
||||
let mut svg = String::new();
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<svg aria-roledescription=\"er\" role=\"graphics-document document\" \
|
||||
viewBox=\"0 0 {w} {h}\" style=\"max-width: {w}px; background-color: {bg};\" \
|
||||
class=\"erDiagram\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
|
||||
xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">",
|
||||
bg = theme.background,
|
||||
w = layout.width,
|
||||
h = layout.height
|
||||
));
|
||||
|
||||
// CSS styles matching mermaid.js ER theme
|
||||
svg.push_str(&format!(
|
||||
"<style>\
|
||||
#my-svg {{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:{font_size}px;fill:{text};}}\
|
||||
#my-svg .entityBox {{fill:{node_fill};stroke:{node_stroke};}}\
|
||||
#my-svg .relationshipLine {{stroke:{edge};stroke-width:1;fill:none;}}\
|
||||
#my-svg .marker {{fill:none !important;stroke:{edge} !important;stroke-width:1;}}\
|
||||
#my-svg .edgeLabel .label {{fill:{node_stroke};font-size:14px;}}
|
||||
#my-svg .label {{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:{text};}}\
|
||||
#my-svg .label text, #my-svg span {{fill:{text};color:{text};}}\
|
||||
#my-svg .node rect, #my-svg .node circle, #my-svg .node ellipse, #my-svg .node polygon {{fill:{node_fill};stroke:{node_stroke};stroke-width:1px;}}\
|
||||
#my-svg .divider {{stroke:{node_stroke};stroke-width:1;}}\
|
||||
</style>",
|
||||
font_size = FONT_SIZE,
|
||||
text = theme.text_color,
|
||||
node_fill = theme.node_fill,
|
||||
node_stroke = theme.node_stroke,
|
||||
edge = theme.edge_color,
|
||||
));
|
||||
|
||||
// ER-specific SVG marker definitions
|
||||
svg.push_str("<defs>");
|
||||
render_er_markers(&mut svg, theme);
|
||||
svg.push_str("</defs>");
|
||||
|
||||
svg.push_str("<g>");
|
||||
|
||||
// Edges (paths)
|
||||
svg.push_str("<g class=\"edgePaths\">");
|
||||
for edge in &layout.edges {
|
||||
render_edge_path(&mut svg, edge);
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
// Edge labels
|
||||
svg.push_str("<g class=\"edgeLabels\">");
|
||||
for edge in &layout.edges {
|
||||
render_edge_label(&mut svg, edge, theme);
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
// Entity nodes
|
||||
svg.push_str("<g class=\"nodes\">");
|
||||
for entity in layout.entities.values() {
|
||||
render_entity_node(&mut svg, entity, theme);
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("</g></svg>");
|
||||
|
||||
svg
|
||||
}
|
||||
|
||||
fn render_er_markers(svg: &mut String, theme: &MermaidTheme) {
|
||||
let edge_color = &theme.edge_color;
|
||||
|
||||
// onlyOne markers: two perpendicular bars
|
||||
svg.push_str(&format!(
|
||||
"<marker id=\"my-svg_er-onlyOneStart\" class=\"marker onlyOne\" \
|
||||
refX=\"0\" refY=\"9\" markerWidth=\"18\" markerHeight=\"18\" orient=\"auto\">\
|
||||
<path d=\"M9,0 L9,18 M15,0 L15,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
|
||||
</marker>"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<marker id=\"my-svg_er-onlyOneEnd\" class=\"marker onlyOne\" \
|
||||
refX=\"18\" refY=\"9\" markerWidth=\"18\" markerHeight=\"18\" orient=\"auto\">\
|
||||
<path d=\"M3,0 L3,18 M9,0 L9,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
|
||||
</marker>"
|
||||
));
|
||||
|
||||
// zeroOrOne markers: circle + perpendicular bar
|
||||
svg.push_str(&format!(
|
||||
"<marker id=\"my-svg_er-zeroOrOneStart\" class=\"marker zeroOrOne\" \
|
||||
refX=\"0\" refY=\"9\" markerWidth=\"30\" markerHeight=\"18\" orient=\"auto\">\
|
||||
<circle fill=\"white\" cx=\"21\" cy=\"9\" r=\"6\" stroke=\"{edge_color}\"/>\
|
||||
<path d=\"M9,0 L9,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
|
||||
</marker>"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<marker id=\"my-svg_er-zeroOrOneEnd\" class=\"marker zeroOrOne\" \
|
||||
refX=\"30\" refY=\"9\" markerWidth=\"30\" markerHeight=\"18\" orient=\"auto\">\
|
||||
<circle fill=\"white\" cx=\"9\" cy=\"9\" r=\"6\" stroke=\"{edge_color}\"/>\
|
||||
<path d=\"M21,0 L21,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
|
||||
</marker>"
|
||||
));
|
||||
|
||||
// oneOrMore markers: crow's foot + perpendicular bar
|
||||
svg.push_str(&format!(
|
||||
"<marker id=\"my-svg_er-oneOrMoreStart\" class=\"marker oneOrMore\" \
|
||||
refX=\"18\" refY=\"18\" markerWidth=\"45\" markerHeight=\"36\" orient=\"auto\">\
|
||||
<path d=\"M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27\" stroke=\"{edge_color}\" fill=\"none\"/>\
|
||||
</marker>"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<marker id=\"my-svg_er-oneOrMoreEnd\" class=\"marker oneOrMore\" \
|
||||
refX=\"27\" refY=\"18\" markerWidth=\"45\" markerHeight=\"36\" orient=\"auto\">\
|
||||
<path d=\"M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
|
||||
</marker>"
|
||||
));
|
||||
|
||||
// zeroOrMore markers: circle + crow's foot
|
||||
svg.push_str(&format!(
|
||||
"<marker id=\"my-svg_er-zeroOrMoreStart\" class=\"marker zeroOrMore\" \
|
||||
refX=\"18\" refY=\"18\" markerWidth=\"57\" markerHeight=\"36\" orient=\"auto\">\
|
||||
<circle fill=\"white\" cx=\"48\" cy=\"18\" r=\"6\" stroke=\"{edge_color}\"/>\
|
||||
<path d=\"M0,18 Q18,0 36,18 Q18,36 0,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
|
||||
</marker>"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<marker id=\"my-svg_er-zeroOrMoreEnd\" class=\"marker zeroOrMore\" \
|
||||
refX=\"39\" refY=\"18\" markerWidth=\"57\" markerHeight=\"36\" orient=\"auto\">\
|
||||
<circle fill=\"white\" cx=\"9\" cy=\"18\" r=\"6\" stroke=\"{edge_color}\"/>\
|
||||
<path d=\"M21,18 Q39,0 57,18 Q39,36 21,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
|
||||
</marker>"
|
||||
));
|
||||
}
|
||||
|
||||
fn render_edge_path(svg: &mut String, edge: &EdgeLayout) {
|
||||
let d = points_to_path_d(&edge.points);
|
||||
let dash = if edge.rel_spec.rel_type == Identification::NonIdentifying {
|
||||
" stroke-dasharray=\"8,8\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
// In mermaid.js ER: arrowTypeStart = cardA, arrowTypeEnd = cardB
|
||||
// card_a is the cardinality at entity_a's side, card_b is at entity_b's side.
|
||||
// marker-start decorates the start of the path (entity_a), marker-end the end (entity_b).
|
||||
let marker_start_name = edge.rel_spec.card_a.marker_name();
|
||||
let marker_end_name = edge.rel_spec.card_b.marker_name();
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<path class=\"edge-thickness-normal relationshipLine\" \
|
||||
d=\"{d}\" \
|
||||
marker-start=\"url(#my-svg_er-{marker_start_name}Start)\" \
|
||||
marker-end=\"url(#my-svg_er-{marker_end_name}End)\" \
|
||||
style=\"fill:none;\"{dash}/>",
|
||||
));
|
||||
}
|
||||
|
||||
fn render_edge_label(svg: &mut String, edge: &EdgeLayout, theme: &MermaidTheme) {
|
||||
if edge.role.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((x, y)) = edge.label_pos else {
|
||||
return;
|
||||
};
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<g transform=\"translate({x}, {y})\" class=\"edgeLabel\">"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"middle\" \
|
||||
fill=\"{color}\" style=\"font-size:14px\">{label}</text>",
|
||||
color = theme.text_color,
|
||||
label = escape_xml(&edge.role)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
fn render_entity_node(svg: &mut String, entity: &EntityLayout, theme: &MermaidTheme) {
|
||||
let x_offset = -entity.width / 2.0;
|
||||
let y_offset = -entity.height / 2.0;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<g transform=\"translate({x},{y})\" id=\"entity-{id}\" class=\"node default\">",
|
||||
x = entity.x,
|
||||
y = entity.y,
|
||||
id = escape_xml(&entity.id)
|
||||
));
|
||||
|
||||
// Outer rectangle (entity box)
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" class=\"entityBox\" rx=\"0\" ry=\"0\"/>",
|
||||
x = x_offset,
|
||||
y = y_offset,
|
||||
w = entity.width,
|
||||
h = entity.height
|
||||
));
|
||||
|
||||
// Header text (entity name)
|
||||
let header_text_y = y_offset + entity.header_height / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"0\" y=\"{y}\" text-anchor=\"middle\" dominant-baseline=\"middle\" \
|
||||
fill=\"{color}\" class=\"er entityLabel\">{label}</text>",
|
||||
y = header_text_y,
|
||||
color = theme.text_color,
|
||||
label = escape_xml(&entity.id)
|
||||
));
|
||||
|
||||
if !entity.attributes.is_empty() {
|
||||
// Horizontal divider between header and attributes
|
||||
let divider_y = y_offset + entity.header_height;
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{x1}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" class=\"divider\"/>",
|
||||
x1 = x_offset,
|
||||
x2 = x_offset + entity.width,
|
||||
y = divider_y
|
||||
));
|
||||
|
||||
let col_divider_x = x_offset + entity.max_type_width;
|
||||
let attr_start_y = y_offset + entity.header_height;
|
||||
let attr_end_y = y_offset + entity.height;
|
||||
|
||||
// Attribute rows
|
||||
let mut current_y = attr_start_y;
|
||||
|
||||
// Compute alternating row fill colors matching mermaid.js erBox behaviour.
|
||||
// Light themes keep the upstream values: rowOdd = lighten(primary, 75)
|
||||
// ≈ #ffffff, rowEven = slightly lighter than node_fill. Dark themes
|
||||
// derive both stripes from the background instead (as theme-dark does),
|
||||
// so attribute text keeps its contrast instead of white-on-white.
|
||||
let (row_odd_fill, row_even_fill) = if is_dark_hex(&theme.background) {
|
||||
(
|
||||
lighten_hex(&theme.background, 0.08),
|
||||
lighten_hex(&theme.background, 0.16),
|
||||
)
|
||||
} else {
|
||||
(String::from("#ffffff"), lighten_hex(&theme.node_fill, 0.25))
|
||||
};
|
||||
|
||||
for (i, attr) in entity.attributes.iter().enumerate() {
|
||||
let row_h = entity
|
||||
.row_heights
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(LINE_HEIGHT + TEXT_PADDING);
|
||||
let text_y = current_y + row_h / 2.0;
|
||||
|
||||
// Alternating row background rect (zebra striping)
|
||||
// Mermaid uses contentRowIndex = i + 1; isEven when contentRowIndex % 2 == 0 && i > 0
|
||||
let is_even = (i + 1) % 2 == 0 && i > 0;
|
||||
let row_fill = if is_even {
|
||||
row_even_fill.as_str()
|
||||
} else {
|
||||
row_odd_fill.as_str()
|
||||
};
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" \
|
||||
style=\"fill:{fill};stroke:{stroke}\" class=\"er attributeBox{parity}\"/>",
|
||||
x = x_offset,
|
||||
y = current_y,
|
||||
w = entity.width,
|
||||
h = row_h,
|
||||
fill = row_fill,
|
||||
stroke = theme.node_stroke,
|
||||
parity = if is_even { "Even" } else { "Odd" },
|
||||
));
|
||||
|
||||
// Horizontal line between attribute rows (faint separator)
|
||||
if i > 0 {
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{x1}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" class=\"divider\" style=\"stroke-opacity:0.3\"/>",
|
||||
x1 = x_offset,
|
||||
x2 = x_offset + entity.width,
|
||||
y = current_y
|
||||
));
|
||||
}
|
||||
|
||||
// Type text (left column)
|
||||
let type_text_x = x_offset + COLUMN_TEXT_PADDING;
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{x}\" y=\"{y}\" text-anchor=\"start\" dominant-baseline=\"middle\" \
|
||||
fill=\"{color}\" class=\"er entityLabel\">{text}</text>",
|
||||
x = type_text_x,
|
||||
y = text_y,
|
||||
color = theme.text_color,
|
||||
text = escape_xml(&attr.attr_type)
|
||||
));
|
||||
|
||||
// Name text (right column)
|
||||
let name_text_x = col_divider_x + COLUMN_TEXT_PADDING;
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{x}\" y=\"{y}\" text-anchor=\"start\" dominant-baseline=\"middle\" \
|
||||
fill=\"{color}\" class=\"er entityLabel\">{text}</text>",
|
||||
x = name_text_x,
|
||||
y = text_y,
|
||||
color = theme.text_color,
|
||||
text = escape_xml(&attr.name)
|
||||
));
|
||||
|
||||
current_y += row_h;
|
||||
}
|
||||
|
||||
// Vertical divider between type and name columns.
|
||||
// Drawn after the row background rects so it is not covered by their fill.
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{x}\" y1=\"{y1}\" x2=\"{x}\" y2=\"{y2}\" class=\"divider\"/>",
|
||||
x = col_divider_x,
|
||||
y1 = attr_start_y,
|
||||
y2 = attr_end_y
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
fn points_to_path_d(points: &[(f64, f64)]) -> String {
|
||||
if points.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut d = String::new();
|
||||
let (x0, y0) = points[0];
|
||||
d.push_str(&format!("M{x0},{y0}"));
|
||||
for (x, y) in &points[1..] {
|
||||
d.push_str(&format!("L{x},{y}"));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Lighten a hex color by blending it toward white.
|
||||
/// `amount` is 0.0 (no change) to 1.0 (white).
|
||||
fn is_dark_hex(hex: &str) -> bool {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() < 6 {
|
||||
return false;
|
||||
}
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(255) as f64;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(255) as f64;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(255) as f64;
|
||||
0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0
|
||||
}
|
||||
|
||||
fn lighten_hex(hex: &str, amount: f64) -> String {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() < 6 {
|
||||
return format!("#{hex}");
|
||||
}
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||||
let lr = r as f64 + (255.0 - r as f64) * amount;
|
||||
let lg = g as f64 + (255.0 - g as f64) * amount;
|
||||
let lb = b as f64 + (255.0 - b as f64) * amount;
|
||||
format!("#{:02X}{:02X}{:02X}", lr as u8, lg as u8, lb as u8)
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MermaidError {
|
||||
#[error("Parse error at line {line}: {message}")]
|
||||
ParseError { line: usize, message: String },
|
||||
|
||||
#[error("Invalid graph direction: {0}")]
|
||||
InvalidDirection(String),
|
||||
|
||||
#[error("Invalid node shape: {0}")]
|
||||
InvalidNodeShape(String),
|
||||
|
||||
#[error("DOT generation error: {0}")]
|
||||
DotGenerationError(String),
|
||||
|
||||
#[error("SVG rendering error: {0}")]
|
||||
RenderError(String),
|
||||
|
||||
#[error("Unsupported diagram type: {0}")]
|
||||
UnsupportedDiagramType(String),
|
||||
}
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Mermaid 11.12.2 gantt config defaults (from config.schema.yaml)
|
||||
const BAR_HEIGHT: f64 = 20.0;
|
||||
const BAR_GAP: f64 = 4.0;
|
||||
const TOP_PADDING: f64 = 50.0;
|
||||
const LEFT_PADDING: f64 = 75.0;
|
||||
const RIGHT_PADDING: f64 = 75.0;
|
||||
const GRID_LINE_START_PADDING: f64 = 35.0;
|
||||
const FONT_SIZE: f64 = 11.0;
|
||||
const SECTION_FONT_SIZE: f64 = 11.0;
|
||||
const TITLE_TOP_MARGIN: f64 = 25.0;
|
||||
const BOTTOM_AXIS_HEIGHT: f64 = 50.0;
|
||||
const RX: f64 = 3.0;
|
||||
const RY: f64 = 3.0;
|
||||
|
||||
/// Default theme colors from Mermaid 11.12.2 theme-default.js
|
||||
const SECTION_BKG_COLOR: &str = "rgba(102,102,255,0.49)";
|
||||
const ALT_SECTION_BKG_COLOR: &str = "white";
|
||||
const TASK_BKG_COLOR: &str = "#8a90dd";
|
||||
const TASK_BORDER_COLOR: &str = "#534fbc";
|
||||
const TASK_TEXT_COLOR: &str = "white";
|
||||
const TASK_TEXT_DARK_COLOR: &str = "black";
|
||||
const GRID_COLOR: &str = "#333";
|
||||
const TITLE_COLOR: &str = "#333";
|
||||
const FONT_FAMILY: &str = "'trebuchet ms', verdana, arial, sans-serif";
|
||||
|
||||
pub fn render_gantt_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
_theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let chart = parse_gantt_diagram(mermaid_source)?;
|
||||
|
||||
// Collect unique categories (section types) in order
|
||||
let mut categories: Vec<String> = Vec::new();
|
||||
for task in &chart.tasks {
|
||||
let cat = task.section.clone().unwrap_or_default();
|
||||
if !categories.contains(&cat) {
|
||||
categories.push(cat);
|
||||
}
|
||||
}
|
||||
|
||||
// Category heights (count of tasks per category)
|
||||
let mut category_heights: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for task in &chart.tasks {
|
||||
let cat = task.section.clone().unwrap_or_default();
|
||||
*category_heights.entry(cat).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let num_tasks = chart.tasks.len();
|
||||
let gap = BAR_HEIGHT + BAR_GAP;
|
||||
let h = 2.0 * TOP_PADDING + num_tasks as f64 * gap;
|
||||
|
||||
// Compute time domain
|
||||
let mut min_day = i32::MAX;
|
||||
let mut max_day = i32::MIN;
|
||||
for task in &chart.tasks {
|
||||
min_day = min_day.min(task.start_day);
|
||||
max_day = max_day.max(task.start_day + task.duration_days);
|
||||
}
|
||||
|
||||
let w = 784.0_f64;
|
||||
let plot_width = w - LEFT_PADDING - RIGHT_PADDING;
|
||||
|
||||
// Time scale: maps day offset to pixel x
|
||||
let span_days = (max_day - min_day).max(1) as f64;
|
||||
let px_per_day = plot_width / span_days;
|
||||
|
||||
let mut svg = String::new();
|
||||
svg.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {w} {h}\">"
|
||||
));
|
||||
|
||||
// Embedded <style> matching Mermaid 11.12.2 gantt styles
|
||||
svg.push_str("<style>");
|
||||
svg.push_str(&format!(
|
||||
".section {{ stroke: none; opacity: 0.2; }}\
|
||||
.section0 {{ fill: {SECTION_BKG_COLOR}; }}\
|
||||
.section1 {{ fill: {ALT_SECTION_BKG_COLOR}; opacity: 0.2; }}\
|
||||
.grid .tick line {{ stroke: {GRID_COLOR}; opacity: 0.8; shape-rendering: crispEdges; }}\
|
||||
.grid .tick text {{ font-family: {FONT_FAMILY}; fill: #000; font-size: 10px; }}\
|
||||
.grid path {{ stroke-width: 0; }}\
|
||||
.task {{ stroke-width: 2; }}\
|
||||
.task0 {{ fill: {TASK_BKG_COLOR}; stroke: {TASK_BORDER_COLOR}; }}\
|
||||
.taskText {{ text-anchor: middle; font-family: {FONT_FAMILY}; }}\
|
||||
.taskText0 {{ fill: {TASK_TEXT_COLOR}; }}\
|
||||
.taskTextOutsideRight {{ fill: {TASK_TEXT_DARK_COLOR}; text-anchor: start; font-family: {FONT_FAMILY}; }}\
|
||||
.taskTextOutsideLeft {{ fill: {TASK_TEXT_DARK_COLOR}; text-anchor: end; }}\
|
||||
.titleText {{ text-anchor: middle; font-size: 18px; font-family: {FONT_FAMILY}; fill: {TITLE_COLOR}; }}\
|
||||
.sectionTitle {{ text-anchor: start; font-family: {FONT_FAMILY}; font-size: {SECTION_FONT_SIZE}px; }}\
|
||||
.sectionTitle0, .sectionTitle1 {{ fill: {TITLE_COLOR}; }}"
|
||||
));
|
||||
svg.push_str("</style>");
|
||||
|
||||
// 1. Section background bands
|
||||
{
|
||||
let mut task_idx = 0;
|
||||
for (cat_order, cat) in categories.iter().enumerate() {
|
||||
let count = category_heights.get(cat).copied().unwrap_or(0);
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let y = task_idx as f64 * gap + TOP_PADDING - 2.0;
|
||||
let rect_h = count as f64 * gap;
|
||||
let section_class = format!("section section{}", cat_order % 2);
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"0\" y=\"{y:.1}\" width=\"{w_rect:.1}\" height=\"{rect_h:.1}\" class=\"{section_class}\"/>",
|
||||
w_rect = w - RIGHT_PADDING / 2.0
|
||||
));
|
||||
task_idx += count;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Grid lines and bottom axis
|
||||
{
|
||||
let axis_y = h - BOTTOM_AXIS_HEIGHT;
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"grid\" transform=\"translate({LEFT_PADDING}, {axis_y})\">"
|
||||
));
|
||||
|
||||
let total_days = (max_day - min_day) as usize;
|
||||
for d in 0..=total_days {
|
||||
let x = d as f64 * px_per_day;
|
||||
// Matches D3.js: tickSize(-h + topPadding + gridLineStartPadding)
|
||||
let tick_top = -h + TOP_PADDING + GRID_LINE_START_PADDING;
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"tick\" transform=\"translate({x:.2}, 0)\">\
|
||||
<line y2=\"{tick_top:.1}\"/>\
|
||||
<text dy=\"1em\" text-anchor=\"middle\">{label}</text>\
|
||||
</g>",
|
||||
label = day_to_ymd_str(min_day + d as i32)
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
// 3. Task bars
|
||||
for (i, task) in chart.tasks.iter().enumerate() {
|
||||
let x = (task.start_day - min_day) as f64 * px_per_day + LEFT_PADDING;
|
||||
let bar_w = task.duration_days as f64 * px_per_day;
|
||||
let y = i as f64 * gap + TOP_PADDING;
|
||||
|
||||
let sec_num = task
|
||||
.section
|
||||
.as_ref()
|
||||
.and_then(|s| categories.iter().position(|c| c == s))
|
||||
.unwrap_or(0)
|
||||
% 4;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<rect rx=\"{RX}\" ry=\"{RY}\" x=\"{x:.2}\" y=\"{y:.2}\" width=\"{bar_w:.2}\" height=\"{BAR_HEIGHT}\" \
|
||||
class=\"task task{sec_num}\"/>"
|
||||
));
|
||||
}
|
||||
|
||||
// 4. Task text (inside bars, or outside if text doesn't fit)
|
||||
for (i, task) in chart.tasks.iter().enumerate() {
|
||||
let start_x = (task.start_day - min_day) as f64 * px_per_day;
|
||||
let end_x = start_x + task.duration_days as f64 * px_per_day;
|
||||
let bar_w = end_x - start_x;
|
||||
|
||||
// Estimate text width (Mermaid uses getBBox, we approximate)
|
||||
let text_width = task.name.len() as f64 * FONT_SIZE * 0.6;
|
||||
|
||||
let (tx, text_class) = if text_width > bar_w {
|
||||
if end_x + text_width + 1.5 * LEFT_PADDING > w - LEFT_PADDING {
|
||||
(start_x + LEFT_PADDING - 5.0, "taskTextOutsideLeft")
|
||||
} else {
|
||||
(end_x + LEFT_PADDING + 5.0, "taskTextOutsideRight")
|
||||
}
|
||||
} else {
|
||||
(bar_w / 2.0 + start_x + LEFT_PADDING, "taskText taskText0")
|
||||
};
|
||||
|
||||
let ty = i as f64 * gap + BAR_HEIGHT / 2.0 + (FONT_SIZE / 2.0 - 2.0) + TOP_PADDING;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{tx:.2}\" y=\"{ty:.2}\" font-size=\"{FONT_SIZE}\" class=\"{text_class}\">{}</text>",
|
||||
escape_xml(&task.name)
|
||||
));
|
||||
}
|
||||
|
||||
// 5. Section labels (vertLabels)
|
||||
{
|
||||
let ordered_cats: Vec<(String, usize)> = categories
|
||||
.iter()
|
||||
.map(|c| (c.clone(), category_heights.get(c).copied().unwrap_or(0)))
|
||||
.collect();
|
||||
|
||||
let mut prev_total = 0_usize;
|
||||
for (i, (cat_name, count)) in ordered_cats.iter().enumerate() {
|
||||
if cat_name.is_empty() {
|
||||
prev_total += count;
|
||||
continue;
|
||||
}
|
||||
let y = if i > 0 {
|
||||
(*count as f64 * gap) / 2.0 + prev_total as f64 * gap + TOP_PADDING
|
||||
} else {
|
||||
(*count as f64 * gap) / 2.0 + TOP_PADDING
|
||||
};
|
||||
|
||||
let sec_num = i % 4;
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"10\" y=\"{y:.2}\" font-size=\"{SECTION_FONT_SIZE}\" class=\"sectionTitle sectionTitle{sec_num}\">{}</text>",
|
||||
escape_xml(cat_name)
|
||||
));
|
||||
prev_total += count;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Title
|
||||
if let Some(title) = &chart.title {
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{x:.1}\" y=\"{TITLE_TOP_MARGIN}\" class=\"titleText\">{}</text>",
|
||||
escape_xml(title),
|
||||
x = w / 2.0
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</svg>");
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GanttChart {
|
||||
title: Option<String>,
|
||||
tasks: Vec<GanttTask>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GanttTask {
|
||||
section: Option<String>,
|
||||
name: String,
|
||||
start_day: i32,
|
||||
duration_days: i32,
|
||||
}
|
||||
|
||||
fn parse_gantt_diagram(input: &str) -> Result<GanttChart, MermaidError> {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
|
||||
let mut i = 0_usize;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.split_whitespace().next() == Some("gantt") {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
return Err(MermaidError::ParseError {
|
||||
line: i + 1,
|
||||
message: "Expected 'gantt' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut title: Option<String> = None;
|
||||
let mut current_section: Option<String> = None;
|
||||
let mut tasks: Vec<GanttTask> = Vec::new();
|
||||
let mut tasks_by_id: BTreeMap<String, (i32, i32)> = BTreeMap::new();
|
||||
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
let line = raw.trim();
|
||||
let line_no = i + 1;
|
||||
i += 1;
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("title ") {
|
||||
let t = rest.trim();
|
||||
if !t.is_empty() {
|
||||
title = Some(t.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with("dateFormat ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("section ") {
|
||||
let name = rest.trim();
|
||||
current_section = if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name.to_string())
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((name_raw, spec_raw)) = line.split_once(':') else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid gantt task line: {line}"),
|
||||
});
|
||||
};
|
||||
|
||||
let name = name_raw.trim();
|
||||
let spec_parts: Vec<&str> = spec_raw
|
||||
.split(',')
|
||||
.map(|p| p.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
if spec_parts.len() < 3 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid gantt task spec: {spec_raw}"),
|
||||
});
|
||||
}
|
||||
|
||||
let id = spec_parts[0].to_string();
|
||||
let start_spec = spec_parts[1];
|
||||
let duration_spec = spec_parts[2];
|
||||
|
||||
let duration_days =
|
||||
parse_duration_days(duration_spec).map_err(|message| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message,
|
||||
})?;
|
||||
|
||||
let start_day = if let Some(after) = start_spec.strip_prefix("after ") {
|
||||
let ref_id = after.trim();
|
||||
let Some((ref_start, ref_dur)) = tasks_by_id.get(ref_id).copied() else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Unknown gantt dependency id: {ref_id}"),
|
||||
});
|
||||
};
|
||||
ref_start + ref_dur
|
||||
} else {
|
||||
parse_ymd_to_day(start_spec).map_err(|message| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message,
|
||||
})?
|
||||
};
|
||||
|
||||
let task = GanttTask {
|
||||
section: current_section.clone(),
|
||||
name: name.to_string(),
|
||||
start_day,
|
||||
duration_days,
|
||||
};
|
||||
|
||||
tasks_by_id.insert(id, (start_day, duration_days));
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
if tasks.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Gantt diagram requires at least one task".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(GanttChart { title, tasks })
|
||||
}
|
||||
|
||||
fn parse_duration_days(spec: &str) -> Result<i32, String> {
|
||||
let spec = spec.trim();
|
||||
if spec.is_empty() {
|
||||
return Err("Empty duration".to_string());
|
||||
}
|
||||
|
||||
let (num_str, unit) = spec.split_at(spec.len().saturating_sub(1));
|
||||
let n: i32 = num_str
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid duration: {spec}"))?;
|
||||
match unit {
|
||||
"d" | "D" => Ok(n),
|
||||
"w" | "W" => Ok(n * 7),
|
||||
_ => Err(format!("Unsupported duration unit: {spec}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ymd_to_day(s: &str) -> Result<i32, String> {
|
||||
let parts: Vec<&str> = s.trim().split('-').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(format!("Invalid date: {s}"));
|
||||
}
|
||||
|
||||
let y: i32 = parts[0].parse().map_err(|_| format!("Invalid year: {s}"))?;
|
||||
let m: i32 = parts[1]
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid month: {s}"))?;
|
||||
let d: i32 = parts[2].parse().map_err(|_| format!("Invalid day: {s}"))?;
|
||||
|
||||
Ok(days_from_civil(y, m, d))
|
||||
}
|
||||
|
||||
/// Convert a day number back to (year, month, day).
|
||||
fn day_to_ymd(day_number: i32) -> (i32, i32, i32) {
|
||||
let z = day_number + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn day_to_ymd_str(day_number: i32) -> String {
|
||||
let (y, m, d) = day_to_ymd(day_number);
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
fn days_from_civil(y: i32, m: i32, d: i32) -> i32 {
|
||||
let y = y - if m <= 2 { 1 } else { 0 };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146097 + doe - 719468
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+576
@@ -0,0 +1,576 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::MermaidError;
|
||||
use crate::text_wrap::line_width;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
// Matches Mermaid 11.12.2 gitGraphRenderer.ts constants.
|
||||
const LAYOUT_OFFSET: f64 = 10.0;
|
||||
const COMMIT_STEP: f64 = 40.0;
|
||||
const PX: f64 = 4.0;
|
||||
const PY: f64 = 2.0;
|
||||
|
||||
// Gitgraph-specific char width for "trebuchet ms" at 16px.
|
||||
// Browser getBBox measures ~10.0 px/char for "main" and ~9.1 for "develop";
|
||||
// 9.5 is a good average that closes the gap vs the global DEFAULT_CHAR_WIDTH=8.0.
|
||||
const GITGRAPH_CHAR_WIDTH: f64 = 9.5;
|
||||
|
||||
// Branch spacing: 50 + 40 (rotateCommitLabel) = 90.
|
||||
const BRANCH_Y_GAP: f64 = 90.0;
|
||||
|
||||
const COMMIT_RADIUS: f64 = 10.0;
|
||||
const MERGE_OUTER_RADIUS: f64 = 9.0;
|
||||
const MERGE_INNER_RADIUS: f64 = 6.0;
|
||||
|
||||
const ARROW_STROKE_WIDTH: f64 = 8.0;
|
||||
const TURN_RADIUS: f64 = 20.0;
|
||||
const THEME_COLOR_LIMIT: usize = 8;
|
||||
|
||||
// Branch label: rect width = bbox.width + 18, x = -(bbox.width + 34).
|
||||
const BRANCH_LABEL_BG_PADDING: f64 = 18.0;
|
||||
const BRANCH_LABEL_BG_X_TRANSLATE: f64 = -19.0;
|
||||
const BRANCH_LABEL_BG_Y: f64 = -1.5;
|
||||
const BRANCH_LABEL_BG_HEIGHT: f64 = 23.0;
|
||||
|
||||
const VIEWBOX_MARGIN: f64 = 8.0;
|
||||
|
||||
// Commit label constants from Mermaid 11.12.2.
|
||||
const COMMIT_LABEL_FONT_SIZE: f64 = 10.0;
|
||||
const COMMIT_LABEL_RECT_HEIGHT: f64 = 15.0;
|
||||
const COMMIT_LABEL_RECT_Y_OFFSET: f64 = 13.5;
|
||||
const COMMIT_LABEL_TEXT_Y_OFFSET: f64 = 25.0;
|
||||
|
||||
pub fn render_gitgraph_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let graph = parse_gitgraph(mermaid_source)?;
|
||||
|
||||
let mut branch_order: Vec<String> = graph.branch_order.clone();
|
||||
if branch_order.is_empty() {
|
||||
branch_order.push(graph.main_branch.clone());
|
||||
}
|
||||
|
||||
// Mermaid 11.12.2 setBranchPosition: pos += 50 + (rotateCommitLabel ? 40 : 0).
|
||||
let mut y_for_branch: HashMap<&str, f64> = HashMap::new();
|
||||
for (idx, b) in branch_order.iter().enumerate() {
|
||||
y_for_branch.insert(b.as_str(), idx as f64 * BRANCH_Y_GAP);
|
||||
}
|
||||
|
||||
// Mermaid 11.12.2 drawCommits: pos starts at 0, increments by COMMIT_STEP + LAYOUT_OFFSET.
|
||||
// posWithOffset = pos + LAYOUT_OFFSET. So commit x values: 10, 60, 110, 160, ...
|
||||
// After last commit, pos increments once more, giving maxPos.
|
||||
let num_commits = graph.commits.len();
|
||||
let max_pos = if num_commits == 0 {
|
||||
0.0
|
||||
} else {
|
||||
num_commits as f64 * (COMMIT_STEP + LAYOUT_OFFSET)
|
||||
};
|
||||
|
||||
// Compute branch label bbox widths (approximation of browser getBBox).
|
||||
let bbox_height = 19.0; // Typical text bbox height at 16px.
|
||||
let branch_bbox_widths: Vec<f64> = branch_order
|
||||
.iter()
|
||||
.map(|b| line_width(b, GITGRAPH_CHAR_WIDTH))
|
||||
.collect();
|
||||
|
||||
// Compute viewBox bounds from branch labels.
|
||||
let mut min_x: f64 = 0.0;
|
||||
let mut min_y: f64 = 0.0;
|
||||
|
||||
for (idx, branch) in branch_order.iter().enumerate() {
|
||||
let y = y_for_branch.get(branch.as_str()).copied().unwrap_or(0.0);
|
||||
let text_w = branch_bbox_widths[idx];
|
||||
// Mermaid 11.12.2 drawBranches: bkg rect x = -(bbox.width + 4 + 30),
|
||||
// transform = translate(-19, pos - bbox.height/2).
|
||||
let bg_x = -(text_w + PX + 30.0);
|
||||
let bg_translate_y = y - bbox_height / 2.0;
|
||||
let label_left = BRANCH_LABEL_BG_X_TRANSLATE + bg_x;
|
||||
let label_top = bg_translate_y + BRANCH_LABEL_BG_Y;
|
||||
min_x = min_x.min(label_left);
|
||||
min_y = min_y.min(label_top);
|
||||
}
|
||||
|
||||
let max_x = max_pos;
|
||||
let y_end = (branch_order.len().saturating_sub(1) as f64) * BRANCH_Y_GAP;
|
||||
let mut max_y = y_end + COMMIT_RADIUS;
|
||||
|
||||
// Account for commit labels below commits.
|
||||
for commit in &graph.commits {
|
||||
if commit.kind != CommitKind::Normal {
|
||||
continue;
|
||||
}
|
||||
let Some(y) = y_for_branch.get(commit.branch.as_str()).copied() else {
|
||||
continue;
|
||||
};
|
||||
let label_text = commit_label_text(commit.seq);
|
||||
let text_w = line_width(&label_text, GITGRAPH_CHAR_WIDTH) * (COMMIT_LABEL_FONT_SIZE / 16.0);
|
||||
let r_y = 10.0 + text_w / 25.0 * 8.5;
|
||||
let label_bottom = y + r_y + COMMIT_LABEL_RECT_Y_OFFSET + COMMIT_LABEL_RECT_HEIGHT;
|
||||
max_y = max_y.max(label_bottom);
|
||||
}
|
||||
|
||||
let vb_x = min_x - VIEWBOX_MARGIN;
|
||||
let vb_y = min_y - VIEWBOX_MARGIN;
|
||||
let vb_w = (max_x - min_x) + VIEWBOX_MARGIN * 2.0;
|
||||
let vb_h = (max_y - min_y) + VIEWBOX_MARGIN * 2.0;
|
||||
|
||||
let mut svg = String::new();
|
||||
svg.push_str(&format!(
|
||||
"<svg id=\"my-svg\" width=\"100%\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" style=\"max-width: {vb_w}px; background-color: {};\" viewBox=\"{vb_x} {vb_y} {vb_w} {vb_h}\" role=\"graphics-document document\" aria-roledescription=\"gitGraph\">",
|
||||
theme.background
|
||||
));
|
||||
|
||||
// Emit style block matching Mermaid 11.12.2 CSS.
|
||||
emit_style_block(&mut svg, theme);
|
||||
|
||||
svg.push_str("<g/>");
|
||||
svg.push_str("<g class=\"commit-bullets\"/>");
|
||||
svg.push_str("<g class=\"commit-labels\"/>");
|
||||
|
||||
// --- Branches + labels ---
|
||||
svg.push_str("<g>");
|
||||
for (idx, branch) in branch_order.iter().enumerate() {
|
||||
let y = y_for_branch.get(branch.as_str()).copied().unwrap_or(0.0);
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"0\" y1=\"{y}\" x2=\"{max_pos}\" y2=\"{y}\" class=\"branch branch{idx}\"/>",
|
||||
));
|
||||
|
||||
let text_w = branch_bbox_widths[idx];
|
||||
// Mermaid 11.12.2: rect x = -(bbox.width + 4 + 30), width = bbox.width + 18,
|
||||
// y = -bbox.height/2 + 8, height = bbox.height + 4,
|
||||
// transform = translate(-19, pos - bbox.height/2).
|
||||
let bg_w = text_w + BRANCH_LABEL_BG_PADDING;
|
||||
let bg_x = -(text_w + PX + 30.0);
|
||||
let bg_translate_y = y - bbox_height / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<rect class=\"branchLabelBkg label{idx}\" rx=\"4\" ry=\"4\" x=\"{bg_x}\" y=\"{BRANCH_LABEL_BG_Y}\" width=\"{bg_w}\" height=\"{BRANCH_LABEL_BG_HEIGHT}\" transform=\"translate({BRANCH_LABEL_BG_X_TRANSLATE}, {bg_translate_y})\"/>",
|
||||
));
|
||||
|
||||
// Mermaid 11.12.2: label translate(-(bbox.width + 14 + 30), pos - bbox.height/2 - 1).
|
||||
let label_x = -(text_w + 14.0 + 30.0);
|
||||
let label_y = y - bbox_height / 2.0 - 1.0;
|
||||
svg.push_str("<g class=\"branchLabel\">");
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"label branch-label{idx}\" transform=\"translate({label_x}, {label_y})\"><text><tspan xml:space=\"preserve\" dy=\"1em\" x=\"0\" class=\"row\">{}</tspan></text></g>",
|
||||
escape_xml(branch)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
// --- Arrows ---
|
||||
svg.push_str("<g class=\"commit-arrows\">");
|
||||
for commit in &graph.commits {
|
||||
let x = commit_x(commit.seq);
|
||||
let y = y_for_branch
|
||||
.get(commit.branch.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let commit_branch_idx = branch_order
|
||||
.iter()
|
||||
.position(|b| b == &commit.branch)
|
||||
.unwrap_or(0);
|
||||
|
||||
for parent in &commit.parents {
|
||||
let Some(parent_commit) = graph
|
||||
.commit_by_id
|
||||
.get(parent)
|
||||
.and_then(|idx| graph.commits.get(*idx))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let px = commit_x(parent_commit.seq);
|
||||
let py = y_for_branch
|
||||
.get(parent_commit.branch.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let arrow_idx = if commit.kind == CommitKind::Merge {
|
||||
branch_order
|
||||
.iter()
|
||||
.position(|b| b == &parent_commit.branch)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
commit_branch_idx
|
||||
};
|
||||
|
||||
let class = format!("arrow arrow{arrow_idx}");
|
||||
|
||||
if (py - y).abs() < 0.1 {
|
||||
svg.push_str(&format!(
|
||||
"<path d=\"M {px} {py} L {x} {y}\" class=\"{class}\"/>",
|
||||
));
|
||||
} else if y > py {
|
||||
// Branch down: from parent → vertical → arc → horizontal to commit.
|
||||
let bend_y = y - TURN_RADIUS;
|
||||
let arc_end_x = px + TURN_RADIUS;
|
||||
svg.push_str(&format!(
|
||||
"<path d=\"M {px} {py} L {px} {bend_y} A {TURN_RADIUS} {TURN_RADIUS}, 0, 0, 0, {arc_end_x} {y} L {x} {y}\" class=\"{class}\"/>",
|
||||
));
|
||||
} else {
|
||||
// Merge up: from parent → horizontal → arc → vertical to commit.
|
||||
let bend_x = x - TURN_RADIUS;
|
||||
let arc_end_y = py - TURN_RADIUS;
|
||||
svg.push_str(&format!(
|
||||
"<path d=\"M {px} {py} L {bend_x} {py} A {TURN_RADIUS} {TURN_RADIUS}, 0, 0, 0, {x} {arc_end_y} L {x} {y}\" class=\"{class}\"/>",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
// --- Commit bullets ---
|
||||
svg.push_str("<g class=\"commit-bullets\">");
|
||||
for commit in &graph.commits {
|
||||
let x = commit_x(commit.seq);
|
||||
let y = y_for_branch
|
||||
.get(commit.branch.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let branch_idx = branch_order
|
||||
.iter()
|
||||
.position(|b| b == &commit.branch)
|
||||
.unwrap_or(0);
|
||||
|
||||
let id_class = commit_id_class(commit.seq);
|
||||
|
||||
match commit.kind {
|
||||
CommitKind::Merge => {
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{MERGE_OUTER_RADIUS}\" class=\"commit {id_class} commit{branch_idx}\"/>",
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{MERGE_INNER_RADIUS}\" class=\"commit commit-merge {id_class} commit{branch_idx}\"/>",
|
||||
));
|
||||
}
|
||||
CommitKind::Normal => {
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{COMMIT_RADIUS}\" class=\"commit {id_class} commit{branch_idx}\"/>",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
// --- Commit labels ---
|
||||
svg.push_str("<g class=\"commit-labels\">");
|
||||
for commit in &graph.commits {
|
||||
if commit.kind != CommitKind::Normal {
|
||||
continue;
|
||||
}
|
||||
|
||||
let x = commit_x(commit.seq);
|
||||
let pos = commit.seq as f64 * (COMMIT_STEP + LAYOUT_OFFSET);
|
||||
let Some(y) = y_for_branch.get(commit.branch.as_str()).copied() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let label = commit_label_text(commit.seq);
|
||||
|
||||
// Approximate bbox of commit label text at font-size 10px.
|
||||
let text_w = line_width(&label, GITGRAPH_CHAR_WIDTH) * (COMMIT_LABEL_FONT_SIZE / 16.0);
|
||||
let pos_with_offset = x;
|
||||
|
||||
// Mermaid 11.12.2: rect x = posWithOffset - bbox.width/2 - PY,
|
||||
// rect width = bbox.width + 2*PY, rect height = bbox.height + 2*PY.
|
||||
let rect_w = text_w + 2.0 * PY;
|
||||
let rect_x = pos_with_offset - text_w / 2.0 - PY;
|
||||
let rect_y = y + COMMIT_LABEL_RECT_Y_OFFSET;
|
||||
let text_x = pos_with_offset - text_w / 2.0;
|
||||
let text_y = y + COMMIT_LABEL_TEXT_Y_OFFSET;
|
||||
|
||||
// Mermaid 11.12.2: r_x = -7.5 - (bbox.width + 10) / 25 * 9.5,
|
||||
// r_y = 10 + bbox.width / 25 * 8.5,
|
||||
// wrapper transform = translate(r_x, r_y) rotate(-45, pos, y).
|
||||
let r_x = -7.5 - (text_w + 10.0) / 25.0 * 9.5;
|
||||
let r_y = 10.0 + text_w / 25.0 * 8.5;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<g transform=\"translate({r_x}, {r_y}) rotate(-45, {pos}, {y})\">",
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<rect class=\"commit-label-bkg\" x=\"{rect_x}\" y=\"{rect_y}\" width=\"{rect_w}\" height=\"{COMMIT_LABEL_RECT_HEIGHT}\"/>",
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{text_x}\" y=\"{text_y}\" class=\"commit-label\">{}</text>",
|
||||
escape_xml(&label)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("</svg>");
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
/// Compute the x position of a commit (posWithOffset in Mermaid 11.12.2).
|
||||
fn commit_x(seq: usize) -> f64 {
|
||||
seq as f64 * (COMMIT_STEP + LAYOUT_OFFSET) + LAYOUT_OFFSET
|
||||
}
|
||||
|
||||
/// Generate the commit label text (deterministic hash from seq).
|
||||
fn commit_label_text(seq: usize) -> String {
|
||||
let hash = ((seq as u32).wrapping_mul(0x9E37_79B9) ^ 0x079A_D076) & 0x0FFF_FFFF;
|
||||
format!("{seq}-{hash:07x}")
|
||||
}
|
||||
|
||||
/// Generate the commit CSS id class.
|
||||
fn commit_id_class(seq: usize) -> String {
|
||||
commit_label_text(seq)
|
||||
}
|
||||
|
||||
/// Emit the CSS style block matching Mermaid 11.12.2.
|
||||
fn emit_style_block(svg: &mut String, theme: &MermaidTheme) {
|
||||
svg.push_str(&format!(
|
||||
"<style>#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{};}}",
|
||||
theme.text_color
|
||||
));
|
||||
// Mermaid 11.12.2 always emits commit-id, commit-msg, branch-label base styles.
|
||||
svg.push_str(
|
||||
"#my-svg .commit-id,#my-svg .commit-msg,#my-svg .branch-label{fill:lightgrey;color:lightgrey;font-family:'trebuchet ms',verdana,arial,sans-serif;font-family:var(--mermaid-font-family);}"
|
||||
);
|
||||
// Always emit all 8 branch color sets (THEME_COLOR_LIMIT) to match the reference.
|
||||
for i in 0..THEME_COLOR_LIMIT {
|
||||
let color = branch_color(i);
|
||||
let label_color = branch_label_color(i);
|
||||
svg.push_str(&format!("#my-svg .branch-label{i}{{fill:{label_color};}}"));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .commit{i}{{stroke:{color};fill:{color};}}"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .commit-highlight{i}{{stroke:{color};fill:{color};}}"
|
||||
));
|
||||
svg.push_str(&format!("#my-svg .label{i}{{fill:{color};}}"));
|
||||
svg.push_str(&format!("#my-svg .arrow{i}{{stroke:{color};}}"));
|
||||
}
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .branch{{stroke-width:1;stroke:{};stroke-dasharray:2;}}",
|
||||
theme.edge_color
|
||||
));
|
||||
svg.push_str("#my-svg .commit-label{font-size:10px;fill:#000021;}");
|
||||
svg.push_str("#my-svg .commit-label-bkg{font-size:10px;fill:#ffffde;opacity:0.5;}");
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .tag-label{{font-size:10px;fill:{tag_label_color};}}",
|
||||
tag_label_color = "#131300"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .tag-label-bkg{{fill:{};stroke:hsl(240, 60%, 86.2745098039%);}}",
|
||||
theme.node_fill
|
||||
));
|
||||
svg.push_str(&format!("#my-svg .tag-hole{{fill:{};}}", theme.text_color));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .commit-merge{{stroke:{};fill:{};}}",
|
||||
theme.node_fill, theme.node_fill
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .commit-reverse{{stroke:{};fill:{};stroke-width:3;}}",
|
||||
theme.node_fill, theme.node_fill
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .commit-highlight-inner{{stroke:{};fill:{};}}",
|
||||
theme.node_fill, theme.node_fill
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .arrow{{stroke-width:{ARROW_STROKE_WIDTH};stroke-linecap:round;fill:none;}}"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .gitTitleText{{text-anchor:middle;font-size:18px;fill:{};}}",
|
||||
theme.text_color
|
||||
));
|
||||
svg.push_str("</style>");
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CommitKind {
|
||||
Normal,
|
||||
Merge,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Commit {
|
||||
seq: usize,
|
||||
branch: String,
|
||||
parents: Vec<String>,
|
||||
kind: CommitKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GitGraph {
|
||||
main_branch: String,
|
||||
branch_order: Vec<String>,
|
||||
commits: Vec<Commit>,
|
||||
commit_by_id: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
fn parse_gitgraph(input: &str) -> Result<GitGraph, MermaidError> {
|
||||
let mut found_header = false;
|
||||
|
||||
let main_branch = "main".to_string();
|
||||
let mut branch_order: Vec<String> = vec![main_branch.clone()];
|
||||
|
||||
let mut branches: HashMap<String, Option<String>> = HashMap::new();
|
||||
branches.insert(main_branch.clone(), None);
|
||||
|
||||
let mut current_branch = main_branch.clone();
|
||||
let mut head: Option<String> = None;
|
||||
|
||||
let mut commits: Vec<Commit> = Vec::new();
|
||||
let mut commit_by_id: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
for (idx, raw) in input.lines().enumerate() {
|
||||
let line_no = idx + 1;
|
||||
let line = raw.trim();
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
if line.split_whitespace().next() != Some("gitGraph") {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected 'gitGraph' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
found_header = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut parts = line.split_whitespace();
|
||||
let Some(cmd) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match cmd {
|
||||
"commit" => {
|
||||
let seq = commits.len();
|
||||
let id = format!("{seq}");
|
||||
let parents = head.clone().into_iter().collect();
|
||||
let commit = Commit {
|
||||
seq,
|
||||
branch: current_branch.clone(),
|
||||
parents,
|
||||
kind: CommitKind::Normal,
|
||||
};
|
||||
commit_by_id.insert(id.clone(), seq);
|
||||
commits.push(commit);
|
||||
head = Some(id.clone());
|
||||
branches.insert(current_branch.clone(), Some(id));
|
||||
}
|
||||
"branch" => {
|
||||
let Some(name) = parts.next() else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected branch name".to_string(),
|
||||
});
|
||||
};
|
||||
let name = name.to_string();
|
||||
if !branches.contains_key(&name) {
|
||||
branches.insert(name.clone(), head.clone());
|
||||
branch_order.push(name.clone());
|
||||
}
|
||||
}
|
||||
"checkout" => {
|
||||
let Some(name) = parts.next() else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected branch name".to_string(),
|
||||
});
|
||||
};
|
||||
let name = name.to_string();
|
||||
current_branch = name.clone();
|
||||
head = branches.get(&name).cloned().flatten();
|
||||
if !branches.contains_key(&name) {
|
||||
branches.insert(name.clone(), head.clone());
|
||||
branch_order.push(name);
|
||||
}
|
||||
}
|
||||
"merge" => {
|
||||
let Some(other) = parts.next() else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected branch name".to_string(),
|
||||
});
|
||||
};
|
||||
let other = other.to_string();
|
||||
let other_head = branches.get(&other).cloned().flatten();
|
||||
let mut parents: Vec<String> = Vec::new();
|
||||
if let Some(h) = head.clone() {
|
||||
parents.push(h);
|
||||
}
|
||||
if let Some(oh) = other_head {
|
||||
parents.push(oh);
|
||||
}
|
||||
let seq = commits.len();
|
||||
let id = format!("{seq}");
|
||||
let commit = Commit {
|
||||
seq,
|
||||
branch: current_branch.clone(),
|
||||
parents,
|
||||
kind: CommitKind::Merge,
|
||||
};
|
||||
commit_by_id.insert(id.clone(), seq);
|
||||
commits.push(commit);
|
||||
head = Some(id.clone());
|
||||
branches.insert(current_branch.clone(), Some(id));
|
||||
}
|
||||
_ => {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Unrecognized gitGraph command: {cmd}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Expected 'gitGraph' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(GitGraph {
|
||||
main_branch,
|
||||
branch_order,
|
||||
commits,
|
||||
commit_by_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn branch_color(order: usize) -> &'static str {
|
||||
match order {
|
||||
0 => "hsl(240, 100%, 46.2745098039%)",
|
||||
1 => "hsl(60, 100%, 43.5294117647%)",
|
||||
2 => "hsl(80, 100%, 46.2745098039%)",
|
||||
3 => "hsl(210, 100%, 46.2745098039%)",
|
||||
4 => "hsl(180, 100%, 46.2745098039%)",
|
||||
5 => "hsl(150, 100%, 46.2745098039%)",
|
||||
6 => "hsl(300, 100%, 46.2745098039%)",
|
||||
7 => "hsl(0, 100%, 46.2745098039%)",
|
||||
_ => "hsl(180, 100%, 46.2745098039%)",
|
||||
}
|
||||
}
|
||||
|
||||
fn branch_label_color(order: usize) -> &'static str {
|
||||
// Mermaid 11.12.2 default theme: branch-label0 = #ffffff, rest = black.
|
||||
match order {
|
||||
0 => "#ffffff",
|
||||
3 => "#ffffff",
|
||||
_ => "black",
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
const INFO_WIDTH: f64 = 400.0;
|
||||
const INFO_HEIGHT: f64 = 150.0;
|
||||
|
||||
const PINNED_MERMAID_VERSION: &str = "11.12.2";
|
||||
|
||||
pub fn render_info_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
if first_diagram_type_token(mermaid_source) != Some("info") {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Expected 'info' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let background_color = if theme.background == "#ffffff" {
|
||||
"white"
|
||||
} else {
|
||||
theme.background.as_str()
|
||||
};
|
||||
let text_color = if theme.text_color == "#333333" {
|
||||
"#333"
|
||||
} else {
|
||||
theme.text_color.as_str()
|
||||
};
|
||||
|
||||
let mut svg = String::new();
|
||||
svg.push_str(&format!(
|
||||
"<svg aria-roledescription=\"info\" role=\"graphics-document document\" viewBox=\"0 0 {INFO_WIDTH} {INFO_HEIGHT}\" style=\"max-width: {INFO_WIDTH}px; background-color: {background_color};\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">"
|
||||
));
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<style>#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{text_color};}}@keyframes edge-animation-frame{{from{{stroke-dashoffset:0;}}}}@keyframes dash{{to{{stroke-dashoffset:0;}}}}#my-svg .edge-animation-slow{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}}#my-svg .edge-animation-fast{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}}#my-svg .error-icon{{fill:#552222;}}#my-svg .error-text{{fill:#552222;stroke:#552222;}}#my-svg .edge-thickness-normal{{stroke-width:1px;}}#my-svg .edge-thickness-thick{{stroke-width:3.5px;}}#my-svg .edge-pattern-solid{{stroke-dasharray:0;}}#my-svg .edge-thickness-invisible{{stroke-width:0;fill:none;}}#my-svg .edge-pattern-dashed{{stroke-dasharray:3;}}#my-svg .edge-pattern-dotted{{stroke-dasharray:2;}}#my-svg .marker{{fill:{edge};stroke:{edge};}}#my-svg .marker.cross{{stroke:{edge};}}#my-svg svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}}#my-svg p{{margin:0;}}#my-svg :root{{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;}}</style>",
|
||||
edge = theme.edge_color
|
||||
));
|
||||
|
||||
svg.push_str("<g/>");
|
||||
svg.push_str(&format!(
|
||||
"<g><text style=\"text-anchor: middle;\" font-size=\"32\" class=\"version\" y=\"40\" x=\"100\">v{PINNED_MERMAID_VERSION}</text></g>"
|
||||
));
|
||||
svg.push_str("</svg>");
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
fn first_diagram_type_token(input: &str) -> Option<&str> {
|
||||
input
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|l| !l.is_empty() && !l.starts_with("%%"))
|
||||
.and_then(|l| l.split_whitespace().next())
|
||||
}
|
||||
+563
@@ -0,0 +1,563 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
// --- Mermaid 11.12.2 journey config defaults ---
|
||||
const DIAGRAM_MARGIN_X: f64 = 50.0;
|
||||
const DIAGRAM_MARGIN_Y: f64 = 10.0;
|
||||
const LEFT_MARGIN: f64 = 150.0;
|
||||
const TASK_WIDTH: f64 = 150.0;
|
||||
const TASK_HEIGHT: f64 = 50.0;
|
||||
const TASK_MARGIN: f64 = 50.0;
|
||||
const SECTION_Y: f64 = 50.0;
|
||||
const FACE_RADIUS: f64 = 15.0;
|
||||
const ACTOR_CIRCLE_R: f64 = 7.0;
|
||||
const MAX_FACE_Y: f64 = 300.0;
|
||||
const FACE_Y_PER_SCORE: f64 = 30.0;
|
||||
const TASK_LINE_BOTTOM: f64 = MAX_FACE_Y + 5.0 * FACE_Y_PER_SCORE; // 450
|
||||
const ARROW_Y_MULTIPLIER: f64 = 4.0; // conf.height * 4 = 200
|
||||
const FONT_FAMILY: &str = "'trebuchet ms', verdana, arial, sans-serif";
|
||||
const TASK_FONT_SIZE: f64 = 14.0;
|
||||
const TASK_FONT_FAMILY: &str = "'Open Sans', sans-serif";
|
||||
const TITLE_FONT_SIZE: &str = "4ex";
|
||||
|
||||
// CSS section/task fill colors from Mermaid default theme
|
||||
const SECTION_FILLS: &[&str] = &[
|
||||
"#ECECFF",
|
||||
"#ffffde",
|
||||
"hsl(304, 100%, 96.2745098039%)",
|
||||
"hsl(124, 100%, 93.5294117647%)",
|
||||
"hsl(176, 100%, 96.2745098039%)",
|
||||
"hsl(-4, 100%, 93.5294117647%)",
|
||||
"hsl(8, 100%, 96.2745098039%)",
|
||||
"hsl(188, 100%, 93.5294117647%)",
|
||||
];
|
||||
|
||||
// SVG fill attributes for section rects (from sectionFills config)
|
||||
const SECTION_SVG_FILLS: &[&str] = &[
|
||||
"#191970", "#8B008B", "#4B0082", "#2F4F4F", "#800000", "#8B4513", "#00008B",
|
||||
];
|
||||
|
||||
const ACTOR_COLOURS: &[&str] = &[
|
||||
"#8FBC8F", "#7CFC00", "#00FFFF", "#20B2AA", "#B0E0E6", "#FFFFE0",
|
||||
];
|
||||
|
||||
pub fn render_journey_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
_theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let journey = parse_journey_diagram(mermaid_source)?;
|
||||
|
||||
// Collect unique actors in order of first appearance
|
||||
let mut actors: Vec<String> = Vec::new();
|
||||
for row in &journey.rows {
|
||||
if let JourneyRow::Task(task) = row {
|
||||
for actor in &task.actors {
|
||||
if !actors.contains(actor) {
|
||||
actors.push(actor.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In mermaid.js: leftMargin = conf.leftMargin + maxWidth
|
||||
// maxWidth is from actor legend text measurement; for simple cases it's 0
|
||||
let left_margin = LEFT_MARGIN;
|
||||
|
||||
// Flatten tasks with their section assignments
|
||||
let mut flat_tasks: Vec<FlatTask> = Vec::new();
|
||||
let mut current_section: Option<String> = None;
|
||||
for row in &journey.rows {
|
||||
match row {
|
||||
JourneyRow::Section(name) => {
|
||||
current_section = Some(name.clone());
|
||||
}
|
||||
JourneyRow::Task(task) => {
|
||||
flat_tasks.push(FlatTask {
|
||||
name: task.name.clone(),
|
||||
score: task.score,
|
||||
actors: task.actors.clone(),
|
||||
section: current_section.clone().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let num_tasks = flat_tasks.len();
|
||||
if num_tasks == 0 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Journey requires at least one task".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Compute section info
|
||||
let mut sections: Vec<SectionInfo> = Vec::new();
|
||||
{
|
||||
let mut last_section = String::new();
|
||||
let mut section_idx: usize = 0;
|
||||
for (i, task) in flat_tasks.iter().enumerate() {
|
||||
if task.section != last_section {
|
||||
// Count tasks in this section
|
||||
let count = flat_tasks[i..]
|
||||
.iter()
|
||||
.take_while(|t| t.section == task.section)
|
||||
.count();
|
||||
let section_num = section_idx % SECTION_SVG_FILLS.len();
|
||||
sections.push(SectionInfo {
|
||||
name: task.section.clone(),
|
||||
first_task_idx: i,
|
||||
task_count: count,
|
||||
section_num,
|
||||
});
|
||||
last_section = task.section.clone();
|
||||
section_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute task positions: task.x = i * taskMargin + i * width + leftMargin
|
||||
let task_positions: Vec<f64> = (0..num_tasks)
|
||||
.map(|i| i as f64 * TASK_MARGIN + i as f64 * TASK_WIDTH + left_margin)
|
||||
.collect();
|
||||
|
||||
// Section vertical height for task y position
|
||||
let section_v_height = TASK_HEIGHT * 2.0 + DIAGRAM_MARGIN_Y; // 110
|
||||
let task_y = section_v_height; // 110 (0 + sectionVHeight)
|
||||
|
||||
// Arrow y: conf.height * 4 = 50 * 4 = 200
|
||||
let arrow_y = TASK_HEIGHT * ARROW_Y_MULTIPLIER;
|
||||
|
||||
// Compute overall dimensions using mermaid.js bounds logic:
|
||||
// bounds.insert(task.x, task.y, task.x + task.width + taskMargin, 300+5*30)
|
||||
// where task.width = diagramMarginX (50), NOT the visual TASK_WIDTH (150)
|
||||
let last_task_x = task_positions.last().copied().unwrap_or(left_margin);
|
||||
let bounds_stopx = last_task_x + DIAGRAM_MARGIN_X + TASK_MARGIN; // 50 + 50 = 100
|
||||
|
||||
let width = left_margin + bounds_stopx + 2.0 * DIAGRAM_MARGIN_X;
|
||||
// height = stopy - starty + 2 * diagramMarginY; starty=0, stopy=450
|
||||
let height = TASK_LINE_BOTTOM + 2.0 * DIAGRAM_MARGIN_Y;
|
||||
|
||||
let has_title = journey.title.is_some();
|
||||
let extra_vert_for_title = if has_title { 70.0 } else { 0.0 };
|
||||
let viewbox_height = height + extra_vert_for_title;
|
||||
let svg_height = height + extra_vert_for_title + 25.0;
|
||||
|
||||
// Arrow endpoint: width - leftMargin - 4
|
||||
let arrow_x2 = width - left_margin - 4.0;
|
||||
|
||||
let mut svg = String::new();
|
||||
|
||||
// SVG header
|
||||
svg.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
|
||||
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
|
||||
style=\"max-width: {width:.0}px;\" \
|
||||
width=\"100%\" \
|
||||
viewBox=\"0 -25 {width:.0} {vh:.0}\" \
|
||||
preserveAspectRatio=\"xMinYMin meet\" \
|
||||
height=\"{sh:.0}\" \
|
||||
role=\"graphics-document document\" \
|
||||
aria-roledescription=\"journey\">",
|
||||
vh = viewbox_height,
|
||||
sh = svg_height,
|
||||
));
|
||||
|
||||
// CSS styles matching mermaid.js default theme
|
||||
svg.push_str("<style>");
|
||||
svg.push_str(&format!(
|
||||
"svg {{font-family:{FONT_FAMILY};font-size:16px;fill:#333;}}"
|
||||
));
|
||||
svg.push_str(".mouth{stroke:#666;}");
|
||||
svg.push_str("line{stroke:#333;}");
|
||||
svg.push_str(&format!(".legend{{fill:#333;font-family:{FONT_FAMILY};}}"));
|
||||
svg.push_str(".label text{fill:#333;}");
|
||||
svg.push_str(".face{fill:#FFF8DC;stroke:#999;}");
|
||||
|
||||
// Section/task type fills
|
||||
for (i, fill) in SECTION_FILLS.iter().enumerate() {
|
||||
svg.push_str(&format!(".task-type-{i},.section-type-{i}{{fill:{fill};}}"));
|
||||
}
|
||||
|
||||
// Actor colors
|
||||
for (i, color) in ACTOR_COLOURS.iter().enumerate() {
|
||||
svg.push_str(&format!(".actor-{i}{{fill:{color};}}"));
|
||||
}
|
||||
|
||||
svg.push_str("</style>");
|
||||
|
||||
// Arrowhead marker definition
|
||||
svg.push_str("<defs><marker id=\"arrowhead\" refX=\"5\" refY=\"2\" markerWidth=\"6\" markerHeight=\"4\" orient=\"auto\"><path d=\"M 0,0 V 4 L6,2 Z\"/></marker></defs>");
|
||||
|
||||
// Actor legend
|
||||
let mut actor_y = 60.0_f64;
|
||||
for (pos, actor) in actors.iter().enumerate() {
|
||||
let color = ACTOR_COLOURS[pos % ACTOR_COLOURS.len()];
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"20\" cy=\"{actor_y:.0}\" class=\"actor-{pos}\" fill=\"{color}\" stroke=\"#000\" r=\"{ACTOR_CIRCLE_R}\"/>"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"40\" y=\"{ty:.0}\" class=\"legend\"><tspan x=\"50\">{}</tspan></text>",
|
||||
escape_xml(actor),
|
||||
ty = actor_y + 7.0,
|
||||
));
|
||||
actor_y += 20.0;
|
||||
}
|
||||
|
||||
// Draw sections
|
||||
for section in §ions {
|
||||
let section_x = task_positions[section.first_task_idx];
|
||||
let section_width = TASK_WIDTH * section.task_count as f64
|
||||
+ DIAGRAM_MARGIN_X * (section.task_count as f64 - 1.0);
|
||||
let fill = SECTION_SVG_FILLS[section.section_num % SECTION_SVG_FILLS.len()];
|
||||
let num = section.section_num;
|
||||
|
||||
svg.push_str("<g>");
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{section_x:.0}\" y=\"{SECTION_Y:.0}\" fill=\"{fill}\" stroke=\"#666\" \
|
||||
width=\"{section_width:.0}\" height=\"{TASK_HEIGHT:.0}\" rx=\"3\" ry=\"3\" \
|
||||
class=\"journey-section section-type-{num}\"/>"
|
||||
));
|
||||
|
||||
// Section label using foreignObject with tspan fallback
|
||||
let center_x = section_x + section_width / 2.0;
|
||||
let center_y = SECTION_Y + TASK_HEIGHT / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<switch>\
|
||||
<foreignObject x=\"{section_x:.0}\" y=\"{SECTION_Y:.0}\" width=\"{section_width:.0}\" height=\"{TASK_HEIGHT:.0}\" \
|
||||
requiredExtensions=\"http://www.w3.org/1999/xhtml\">\
|
||||
<div class=\"journey-section section-type-{num}\" xmlns=\"http://www.w3.org/1999/xhtml\" \
|
||||
style=\"display: table; height: 100%; width: 100%;\">\
|
||||
<div class=\"label\" style=\"display: table-cell; text-align: center; vertical-align: middle;\">\
|
||||
{}</div></div></foreignObject>\
|
||||
<text x=\"{center_x:.0}\" y=\"{center_y:.0}\" dominant-baseline=\"central\" \
|
||||
alignment-baseline=\"central\" class=\"journey-section\" \
|
||||
style=\"text-anchor: middle; font-size: {TASK_FONT_SIZE}px; font-family: {TASK_FONT_FAMILY};\">\
|
||||
<tspan x=\"{center_x:.0}\" dy=\"0\">{}</tspan></text></switch>",
|
||||
escape_xml(§ion.name),
|
||||
escape_xml(§ion.name),
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
// Draw tasks
|
||||
let mut current_section_num = 0_usize;
|
||||
let mut current_section_name = String::new();
|
||||
let mut section_idx = 0_usize;
|
||||
for (i, task) in flat_tasks.iter().enumerate() {
|
||||
let task_counter = i;
|
||||
// Track section
|
||||
if task.section != current_section_name {
|
||||
if section_idx < sections.len() {
|
||||
current_section_num = sections[section_idx].section_num;
|
||||
section_idx += 1;
|
||||
}
|
||||
current_section_name = task.section.clone();
|
||||
}
|
||||
|
||||
let tx = task_positions[i];
|
||||
let center = tx + TASK_WIDTH / 2.0;
|
||||
let fill = SECTION_SVG_FILLS[current_section_num % SECTION_SVG_FILLS.len()];
|
||||
let num = current_section_num;
|
||||
|
||||
svg.push_str("<g>");
|
||||
|
||||
// Dashed task line
|
||||
svg.push_str(&format!(
|
||||
"<line id=\"task{task_counter}\" x1=\"{center:.0}\" y1=\"{task_y:.0}\" \
|
||||
x2=\"{center:.0}\" y2=\"{TASK_LINE_BOTTOM:.0}\" class=\"task-line\" \
|
||||
stroke-width=\"1px\" stroke-dasharray=\"4 2\" stroke=\"#666\"/>"
|
||||
));
|
||||
|
||||
// Face icon
|
||||
let face_cy = MAX_FACE_Y + (5.0 - task.score as f64) * FACE_Y_PER_SCORE;
|
||||
draw_face(&mut svg, center, face_cy, task.score);
|
||||
|
||||
// Task rectangle
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{tx:.0}\" y=\"{task_y:.0}\" fill=\"{fill}\" stroke=\"#666\" \
|
||||
width=\"{TASK_WIDTH:.0}\" height=\"{TASK_HEIGHT:.0}\" rx=\"3\" ry=\"3\" \
|
||||
class=\"task task-type-{num}\"/>"
|
||||
));
|
||||
|
||||
// Actor circles on the task
|
||||
let mut x_pos = tx + 14.0;
|
||||
for actor_name in &task.actors {
|
||||
if let Some(pos) = actors.iter().position(|a| a == actor_name) {
|
||||
let color = ACTOR_COLOURS[pos % ACTOR_COLOURS.len()];
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{x_pos:.0}\" cy=\"{task_y:.0}\" class=\"actor-{pos}\" \
|
||||
fill=\"{color}\" stroke=\"#000\" r=\"{ACTOR_CIRCLE_R}\"><title>{}</title></circle>",
|
||||
escape_xml(actor_name)
|
||||
));
|
||||
x_pos += 10.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Task label using foreignObject with tspan fallback
|
||||
let task_center_x = tx + TASK_WIDTH / 2.0;
|
||||
let task_center_y = task_y + TASK_HEIGHT / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<switch>\
|
||||
<foreignObject x=\"{tx:.0}\" y=\"{task_y:.0}\" width=\"{TASK_WIDTH:.0}\" height=\"{TASK_HEIGHT:.0}\" \
|
||||
requiredExtensions=\"http://www.w3.org/1999/xhtml\">\
|
||||
<div class=\"task\" xmlns=\"http://www.w3.org/1999/xhtml\" \
|
||||
style=\"display: table; height: 100%; width: 100%;\">\
|
||||
<div class=\"label\" style=\"display: table-cell; text-align: center; vertical-align: middle;\">\
|
||||
{}</div></div></foreignObject>\
|
||||
<text x=\"{task_center_x:.0}\" y=\"{task_center_y:.0}\" dominant-baseline=\"central\" \
|
||||
alignment-baseline=\"central\" class=\"task\" \
|
||||
style=\"text-anchor: middle; font-size: {TASK_FONT_SIZE}px; font-family: {TASK_FONT_FAMILY};\">\
|
||||
<tspan x=\"{task_center_x:.0}\" dy=\"0\">{}</tspan></text></switch>",
|
||||
escape_xml(&task.name),
|
||||
escape_xml(&task.name),
|
||||
));
|
||||
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
// Title
|
||||
if let Some(title) = &journey.title {
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{left_margin:.0}\" font-size=\"{TITLE_FONT_SIZE}\" \
|
||||
font-weight=\"bold\" y=\"25\" fill=\"#333\" \
|
||||
font-family=\"{FONT_FAMILY}\">{}</text>",
|
||||
escape_xml(title),
|
||||
));
|
||||
}
|
||||
|
||||
// Horizontal arrow
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{left_margin:.0}\" y1=\"{arrow_y:.0}\" x2=\"{arrow_x2:.0}\" y2=\"{arrow_y:.0}\" \
|
||||
stroke-width=\"4\" stroke=\"black\" marker-end=\"url(#arrowhead)\"/>"
|
||||
));
|
||||
|
||||
svg.push_str("</svg>");
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
fn draw_face(svg: &mut String, cx: f64, cy: f64, score: i32) {
|
||||
// Face circle
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{cx:.0}\" cy=\"{cy:.0}\" class=\"face\" r=\"{FACE_RADIUS}\" \
|
||||
stroke-width=\"2\" overflow=\"visible\"/>"
|
||||
));
|
||||
|
||||
svg.push_str("<g>");
|
||||
|
||||
// Eyes
|
||||
let eye_y = cy - FACE_RADIUS / 3.0;
|
||||
let left_eye_x = cx - FACE_RADIUS / 3.0;
|
||||
let right_eye_x = cx + FACE_RADIUS / 3.0;
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{left_eye_x:.0}\" cy=\"{eye_y:.0}\" r=\"1.5\" stroke-width=\"2\" fill=\"#666\" stroke=\"#666\"/>"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{right_eye_x:.0}\" cy=\"{eye_y:.0}\" r=\"1.5\" stroke-width=\"2\" fill=\"#666\" stroke=\"#666\"/>"
|
||||
));
|
||||
|
||||
// Mouth based on score
|
||||
if score > 3 {
|
||||
// Happy: smile arc
|
||||
let inner_r = FACE_RADIUS / 2.0;
|
||||
let outer_r = FACE_RADIUS / 2.2;
|
||||
let arc_path = generate_smile_arc(inner_r, outer_r);
|
||||
svg.push_str(&format!(
|
||||
"<path class=\"mouth\" d=\"{arc_path}\" transform=\"translate({cx:.0},{ty:.0})\"/>",
|
||||
ty = cy + 2.0,
|
||||
));
|
||||
} else if score < 3 {
|
||||
// Sad: frown arc
|
||||
let inner_r = FACE_RADIUS / 2.0;
|
||||
let outer_r = FACE_RADIUS / 2.2;
|
||||
let arc_path = generate_sad_arc(inner_r, outer_r);
|
||||
svg.push_str(&format!(
|
||||
"<path class=\"mouth\" d=\"{arc_path}\" transform=\"translate({cx:.0},{ty:.0})\"/>",
|
||||
ty = cy + 7.0,
|
||||
));
|
||||
} else {
|
||||
// Neutral: straight line
|
||||
svg.push_str(&format!(
|
||||
"<line class=\"mouth\" stroke=\"#666\" x1=\"{x1:.0}\" y1=\"{y1:.0}\" \
|
||||
x2=\"{x2:.0}\" y2=\"{y1:.0}\" stroke-width=\"1px\"/>",
|
||||
x1 = cx - 5.0,
|
||||
y1 = cy + 7.0,
|
||||
x2 = cx + 5.0,
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
/// Generate the smile arc path matching d3.arc with
|
||||
/// startAngle=PI/2, endAngle=3*PI/2, innerRadius=r/2, outerRadius=r/2.2
|
||||
fn generate_smile_arc(inner_r: f64, outer_r: f64) -> String {
|
||||
// The exact path from the reference SVG is:
|
||||
// M7.5,0A7.5,7.5,0,1,1,-7.5,0L-6.818,0A6.818,6.818,0,1,0,6.818,0Z
|
||||
format!(
|
||||
"M{or},0A{or},{or},0,1,1,-{or},0L-{ir},0A{ir},{ir},0,1,0,{ir},0Z",
|
||||
or = format_num(outer_r),
|
||||
ir = format_num(inner_r),
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate the sad arc path matching d3.arc with
|
||||
/// startAngle=3*PI/2, endAngle=5*PI/2, innerRadius=r/2, outerRadius=r/2.2
|
||||
fn generate_sad_arc(inner_r: f64, outer_r: f64) -> String {
|
||||
format!(
|
||||
"M-{or},0A{or},{or},0,1,1,{or},0L{ir},0A{ir},{ir},0,1,0,-{ir},0Z",
|
||||
or = format_num(outer_r),
|
||||
ir = format_num(inner_r),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_num(n: f64) -> String {
|
||||
let s = format!("{n:.3}");
|
||||
if s.contains('.') {
|
||||
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct JourneyDiagram {
|
||||
title: Option<String>,
|
||||
rows: Vec<JourneyRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum JourneyRow {
|
||||
Section(String),
|
||||
Task(JourneyTask),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct JourneyTask {
|
||||
name: String,
|
||||
score: i32,
|
||||
actors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FlatTask {
|
||||
name: String,
|
||||
score: i32,
|
||||
actors: Vec<String>,
|
||||
section: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SectionInfo {
|
||||
name: String,
|
||||
first_task_idx: usize,
|
||||
task_count: usize,
|
||||
section_num: usize,
|
||||
}
|
||||
|
||||
fn parse_journey_diagram(input: &str) -> Result<JourneyDiagram, MermaidError> {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
|
||||
let mut i = 0_usize;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.split_whitespace().next() == Some("journey") {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
return Err(MermaidError::ParseError {
|
||||
line: i + 1,
|
||||
message: "Expected 'journey' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut title: Option<String> = None;
|
||||
let mut rows: Vec<JourneyRow> = Vec::new();
|
||||
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
let line = raw.trim();
|
||||
let line_no = i + 1;
|
||||
i += 1;
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("title ") {
|
||||
let t = rest.trim();
|
||||
if !t.is_empty() {
|
||||
title = Some(t.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("section ") {
|
||||
let name = rest.trim();
|
||||
if !name.is_empty() {
|
||||
rows.push(JourneyRow::Section(name.to_string()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line
|
||||
.split(':')
|
||||
.map(|p| p.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid journey task line: {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
let name = parts[0].to_string();
|
||||
let score: i32 = parts[1].parse().map_err(|_| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid journey score: {line}"),
|
||||
})?;
|
||||
|
||||
let actors: Vec<String> = if parts.len() >= 3 {
|
||||
// Actors field may contain comma-separated names (e.g., "Alice, Bob")
|
||||
// Join remaining parts (in case actor names contain colons) then split by comma
|
||||
parts[2..]
|
||||
.join(": ")
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
rows.push(JourneyRow::Task(JourneyTask {
|
||||
name,
|
||||
score,
|
||||
actors,
|
||||
}));
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Journey requires at least one section/task".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(JourneyDiagram { title, rows })
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+506
@@ -0,0 +1,506 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
use serde_yaml::{Mapping, Value};
|
||||
|
||||
const COLUMN_WIDTH: f64 = 200.0;
|
||||
const COLUMN_GAP: f64 = 5.0;
|
||||
|
||||
const HEADER_HEIGHT: f64 = 25.0;
|
||||
const BOTTOM_PADDING: f64 = 10.0;
|
||||
|
||||
const TASK_WIDTH: f64 = 185.0;
|
||||
const TASK_HEIGHT: f64 = 44.0;
|
||||
const TASK_HEIGHT_WITH_ASSIGNED: f64 = 56.0;
|
||||
const TASK_GAP: f64 = 5.0;
|
||||
const TASK_TEXT_LINE_HEIGHT: f64 = 24.0;
|
||||
const TASK_INNER_PADDING: f64 = 10.0;
|
||||
|
||||
const DIAGRAM_PADDING: f64 = 10.0;
|
||||
|
||||
/// Per the reference SVG, the priority line is inset 2px from the card rect's left edge,
|
||||
/// and 2px from the top/bottom of the rect.
|
||||
const PRIORITY_LINE_INSET_X: f64 = 2.0;
|
||||
const PRIORITY_LINE_INSET_Y: f64 = 2.0;
|
||||
const PRIORITY_LINE_STROKE_WIDTH: f64 = 4.0;
|
||||
|
||||
pub fn render_kanban_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let board = parse_kanban(mermaid_source)?;
|
||||
|
||||
let columns_count = board.columns.len().max(1) as f64;
|
||||
let total_width =
|
||||
DIAGRAM_PADDING * 2.0 + columns_count * COLUMN_WIDTH + (columns_count - 1.0) * COLUMN_GAP;
|
||||
|
||||
let mut max_col_height: f64 = 0.0;
|
||||
for col in &board.columns {
|
||||
let tasks_h = tasks_stack_height(&col.tasks);
|
||||
let h = HEADER_HEIGHT + tasks_h + BOTTOM_PADDING;
|
||||
max_col_height = max_col_height.max(h);
|
||||
}
|
||||
|
||||
let total_height = DIAGRAM_PADDING * 2.0 + max_col_height;
|
||||
|
||||
let mut svg = String::new();
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<svg id=\"my-svg\" width=\"100%\" xmlns=\"http://www.w3.org/2000/svg\" \
|
||||
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
|
||||
style=\"max-width: {total_width}px; background-color: white;\" \
|
||||
viewBox=\"0 0 {total_width} {total_height}\" \
|
||||
role=\"graphics-document document\" aria-roledescription=\"kanban\">"
|
||||
));
|
||||
|
||||
// Emit CSS matching Mermaid 11.12.2 reference
|
||||
svg.push_str("<style>");
|
||||
svg.push_str(&format!(
|
||||
"#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{};}}",
|
||||
theme.text_color
|
||||
));
|
||||
svg.push_str("#my-svg p{margin:0;}");
|
||||
|
||||
// Section-specific CSS (sections 0-10)
|
||||
for i in 0..=10i32 {
|
||||
let (fill_hue, fill_sat, fill_light) = section_hsl(i);
|
||||
let text_fill = section_text_color(i);
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .section-{i} rect,#my-svg .section-{i} path,\
|
||||
#my-svg .section-{i} circle,#my-svg .section-{i} polygon,\
|
||||
#my-svg .section-{i} path\
|
||||
{{fill:hsl({fill_hue}, {fill_sat}%, {fill_light}%);\
|
||||
stroke:hsl({fill_hue}, {fill_sat}%, {fill_light}%);}}",
|
||||
));
|
||||
svg.push_str(&format!("#my-svg .section-{i} text{{fill:{text_fill};}}",));
|
||||
}
|
||||
|
||||
// Node styling (matches reference exactly)
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,\
|
||||
#my-svg .node polygon,#my-svg .node path\
|
||||
{{fill:white;stroke:{};stroke-width:1px;}}",
|
||||
theme.node_stroke
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .kanban-ticket-link{{fill:white;stroke:{};text-decoration:underline;}}",
|
||||
theme.node_stroke
|
||||
));
|
||||
|
||||
// Cluster-label and label styling — makes header text #333 via CSS
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .cluster-label,#my-svg .label{{color:{};fill:{};}}",
|
||||
theme.text_color, theme.text_color
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .cluster-label text{{fill:{};font-size:16px;}}",
|
||||
theme.text_color
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .label text{{fill:{};font-size:16px;}}",
|
||||
theme.text_color
|
||||
));
|
||||
|
||||
// Kanban-label class
|
||||
svg.push_str(
|
||||
"#my-svg .kanban-label{dy:1em;alignment-baseline:middle;\
|
||||
text-anchor:middle;dominant-baseline:middle;text-align:center;}",
|
||||
);
|
||||
|
||||
svg.push_str("</style>");
|
||||
|
||||
// Background rect
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"0\" y=\"0\" width=\"{total_width}\" height=\"{total_height}\" fill=\"white\"/>"
|
||||
));
|
||||
|
||||
// Empty g (matches reference structure)
|
||||
svg.push_str("<g/>");
|
||||
|
||||
// === Sections (columns) ===
|
||||
svg.push_str("<g class=\"sections\">");
|
||||
for (col_idx, col) in board.columns.iter().enumerate() {
|
||||
let section_idx = col_idx + 1;
|
||||
let col_x = DIAGRAM_PADDING + col_idx as f64 * (COLUMN_WIDTH + COLUMN_GAP);
|
||||
let col_y = DIAGRAM_PADDING;
|
||||
|
||||
let tasks_h = tasks_stack_height(&col.tasks);
|
||||
let col_h = HEADER_HEIGHT + tasks_h + BOTTOM_PADDING;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"cluster undefined section-{section_idx}\" id=\"{}\" data-look=\"classic\">",
|
||||
escape_xml(&col.title)
|
||||
));
|
||||
// Section rect — no inline fill/stroke; CSS handles it via .section-N
|
||||
svg.push_str(&format!(
|
||||
"<rect style=\"\" rx=\"5\" ry=\"5\" x=\"{col_x}\" y=\"{col_y}\" \
|
||||
width=\"{COLUMN_WIDTH}\" height=\"{col_h}\"/>"
|
||||
));
|
||||
|
||||
// Cluster label using SVG <text> for universal renderer compatibility.
|
||||
// Centered horizontally within the column, vertically within the header.
|
||||
let label_x = col_x;
|
||||
let label_y = col_y;
|
||||
let text_x = COLUMN_WIDTH / 2.0;
|
||||
let text_y = HEADER_HEIGHT / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"cluster-label\" transform=\"translate({label_x}, {label_y})\">"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{text_x}\" y=\"{text_y}\" \
|
||||
text-anchor=\"middle\" dominant-baseline=\"central\" \
|
||||
font-family=\"'trebuchet ms', verdana, arial, sans-serif\">{}</text>",
|
||||
escape_xml(&col.title)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
// === Items (task cards) ===
|
||||
svg.push_str("<g class=\"items\">");
|
||||
for (col_idx, col) in board.columns.iter().enumerate() {
|
||||
let col_x = DIAGRAM_PADDING + col_idx as f64 * (COLUMN_WIDTH + COLUMN_GAP);
|
||||
let col_y = DIAGRAM_PADDING;
|
||||
let col_center_x = col_x + COLUMN_WIDTH / 2.0;
|
||||
|
||||
if col.tasks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// First task center is HEADER_HEIGHT below column top, plus half the task height
|
||||
let mut task_center_y = col_y + HEADER_HEIGHT + task_height(&col.tasks[0]) / 2.0;
|
||||
|
||||
for (task_idx, task) in col.tasks.iter().enumerate() {
|
||||
if task_idx > 0 {
|
||||
let prev_h = task_height(&col.tasks[task_idx - 1]);
|
||||
let curr_h = task_height(task);
|
||||
task_center_y += prev_h / 2.0 + TASK_GAP + curr_h / 2.0;
|
||||
}
|
||||
|
||||
let t_h = task_height(task);
|
||||
let half_w = TASK_WIDTH / 2.0;
|
||||
let half_h = t_h / 2.0;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"node undefined\" id=\"{}\" \
|
||||
transform=\"translate({col_center_x}, {task_center_y})\">",
|
||||
escape_xml(&task.label)
|
||||
));
|
||||
|
||||
// Card rect (centered at origin of the transform)
|
||||
svg.push_str(&format!(
|
||||
"<rect class=\"basic label-container\" style=\"\" rx=\"5\" ry=\"5\" \
|
||||
x=\"{x}\" y=\"{y}\" width=\"{TASK_WIDTH}\" height=\"{t_h}\"/>",
|
||||
x = -half_w,
|
||||
y = -half_h,
|
||||
));
|
||||
|
||||
// Task label — positioned as foreignObject
|
||||
let label_tx = -half_w + TASK_INNER_PADDING;
|
||||
let label_ty = if task.assigned.is_some() {
|
||||
-half_h + 4.0 // 4px from top when assigned
|
||||
} else {
|
||||
-(TASK_TEXT_LINE_HEIGHT / 2.0)
|
||||
};
|
||||
|
||||
emit_label_text(&mut svg, label_tx, label_ty, &task.label);
|
||||
|
||||
// Assigned/empty placeholders
|
||||
match &task.assigned {
|
||||
Some(assigned) => {
|
||||
// Empty middle-left placeholder
|
||||
emit_empty_label(&mut svg, label_tx, 0.0);
|
||||
|
||||
// Assigned name — bottom-right
|
||||
let assigned_w = estimate_text_width(assigned);
|
||||
let assigned_tx = half_w - TASK_INNER_PADDING - assigned_w;
|
||||
emit_label_text(&mut svg, assigned_tx, 0.0, assigned);
|
||||
}
|
||||
None => {
|
||||
// Two empty label placeholders (matches reference for non-assigned)
|
||||
let ph_y = TASK_TEXT_LINE_HEIGHT / 2.0;
|
||||
emit_empty_label(&mut svg, label_tx, ph_y);
|
||||
|
||||
let right_tx = half_w - TASK_INNER_PADDING;
|
||||
emit_empty_label(&mut svg, right_tx, ph_y);
|
||||
}
|
||||
}
|
||||
|
||||
// Priority indicator line
|
||||
if let Some(priority_color) = task.priority.as_deref().and_then(color_from_priority) {
|
||||
let line_x = -half_w + PRIORITY_LINE_INSET_X;
|
||||
let line_y1 = -half_h + PRIORITY_LINE_INSET_Y;
|
||||
let line_y2 = half_h - PRIORITY_LINE_INSET_Y;
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{line_x}\" y1=\"{line_y1}\" x2=\"{line_x}\" y2=\"{line_y2}\" \
|
||||
stroke-width=\"{PRIORITY_LINE_STROKE_WIDTH}\" stroke=\"{priority_color}\"/>"
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("</svg>");
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
/// Emit a native SVG `<text>` label inside `<g class="label">`.
|
||||
/// Text is left-aligned (text-anchor="start") and vertically centred within
|
||||
/// one line height so it sits in the same place the old foreignObject did.
|
||||
fn emit_label_text(svg: &mut String, tx: f64, ty: f64, text: &str) {
|
||||
let text_y = TASK_TEXT_LINE_HEIGHT / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"label\" transform=\"translate({tx}, {ty})\">\
|
||||
<text x=\"0\" y=\"{text_y}\" \
|
||||
text-anchor=\"start\" dominant-baseline=\"central\" \
|
||||
font-family=\"'trebuchet ms', verdana, arial, sans-serif\">\
|
||||
{}</text></g>",
|
||||
escape_xml(text),
|
||||
));
|
||||
}
|
||||
|
||||
/// Emit an empty placeholder `<g class="label">` (no visible content).
|
||||
fn emit_empty_label(svg: &mut String, tx: f64, ty: f64) {
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"label\" transform=\"translate({tx}, {ty})\"/>",
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct KanbanBoard {
|
||||
columns: Vec<KanbanColumn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct KanbanColumn {
|
||||
title: String,
|
||||
tasks: Vec<KanbanTask>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct KanbanTask {
|
||||
label: String,
|
||||
assigned: Option<String>,
|
||||
priority: Option<String>,
|
||||
ticket: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_kanban(input: &str) -> Result<KanbanBoard, MermaidError> {
|
||||
let lines = input.lines().enumerate();
|
||||
|
||||
let mut found_header = false;
|
||||
let mut columns: Vec<KanbanColumn> = Vec::new();
|
||||
let mut current_idx: Option<usize> = None;
|
||||
|
||||
for (idx, raw) in lines {
|
||||
let line_no = idx + 1;
|
||||
let line = raw.trim_end_matches(['\r', '\n']).to_string();
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed.is_empty() || trimmed.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
if trimmed.split_whitespace().next() != Some("kanban") {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected 'kanban' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
found_header = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_indented = raw.chars().next().is_some_and(|c| c.is_whitespace());
|
||||
if !is_indented {
|
||||
columns.push(KanbanColumn {
|
||||
title: trimmed.to_string(),
|
||||
tasks: Vec::new(),
|
||||
});
|
||||
current_idx = Some(columns.len() - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(cur) = current_idx else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Task found before any kanban column".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
let task = parse_kanban_task(trimmed, line_no)?;
|
||||
columns[cur].tasks.push(task);
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Expected 'kanban' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(KanbanBoard { columns })
|
||||
}
|
||||
|
||||
fn task_height(task: &KanbanTask) -> f64 {
|
||||
if task.assigned.is_some() {
|
||||
TASK_HEIGHT_WITH_ASSIGNED
|
||||
} else {
|
||||
TASK_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
fn tasks_stack_height(tasks: &[KanbanTask]) -> f64 {
|
||||
let mut total = 0.0;
|
||||
for (i, task) in tasks.iter().enumerate() {
|
||||
if i > 0 {
|
||||
total += TASK_GAP;
|
||||
}
|
||||
total += task_height(task);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
fn parse_kanban_task(line: &str, line_no: usize) -> Result<KanbanTask, MermaidError> {
|
||||
let (label, shape_data) = split_label_and_shape_data(line);
|
||||
|
||||
let mut task = KanbanTask {
|
||||
label,
|
||||
assigned: None,
|
||||
priority: None,
|
||||
ticket: None,
|
||||
};
|
||||
|
||||
let Some(shape_data) = shape_data else {
|
||||
return Ok(task);
|
||||
};
|
||||
|
||||
apply_shape_data(&mut task, &shape_data, line_no)?;
|
||||
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
fn split_label_and_shape_data(line: &str) -> (String, Option<String>) {
|
||||
let Some(start) = line.find("@{") else {
|
||||
return (line.to_string(), None);
|
||||
};
|
||||
|
||||
if !line.ends_with('}') {
|
||||
return (line.to_string(), None);
|
||||
}
|
||||
|
||||
let (label, rest) = line.split_at(start);
|
||||
let shape_data = rest.strip_prefix("@{").unwrap_or(rest);
|
||||
let shape_data = shape_data.strip_suffix('}').unwrap_or(shape_data);
|
||||
|
||||
(
|
||||
label.trim_end().to_string(),
|
||||
Some(shape_data.trim().to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_shape_data(
|
||||
task: &mut KanbanTask,
|
||||
shape_data: &str,
|
||||
line_no: usize,
|
||||
) -> Result<(), MermaidError> {
|
||||
let yaml_data = if shape_data.contains('\n') {
|
||||
format!("{shape_data}\n")
|
||||
} else {
|
||||
format!("{{\n{shape_data}\n}}")
|
||||
};
|
||||
|
||||
let doc: Value = serde_yaml::from_str(&yaml_data).map_err(|e| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid kanban metadata: {e}"),
|
||||
})?;
|
||||
|
||||
let Value::Mapping(map) = doc else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Invalid kanban metadata: expected a YAML mapping".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
if let Some(label) = yaml_get_string(&map, "label") {
|
||||
task.label = label;
|
||||
}
|
||||
|
||||
if let Some(assigned) = yaml_get_string(&map, "assigned") {
|
||||
task.assigned = Some(assigned);
|
||||
}
|
||||
|
||||
if let Some(priority) = yaml_get_string(&map, "priority") {
|
||||
task.priority = Some(priority);
|
||||
}
|
||||
|
||||
if let Some(ticket) = yaml_get_string(&map, "ticket") {
|
||||
task.ticket = Some(ticket);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn yaml_get_string(map: &Mapping, key: &str) -> Option<String> {
|
||||
let value = map.get(Value::String(key.to_string()))?;
|
||||
match value {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn color_from_priority(priority: &str) -> Option<&'static str> {
|
||||
match priority {
|
||||
"Very High" => Some("red"),
|
||||
"High" => Some("orange"),
|
||||
"Medium" => None,
|
||||
"Low" => Some("blue"),
|
||||
"Very Low" => Some("lightblue"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns (hue, saturation, lightness) for a given section index.
|
||||
/// Matches the Mermaid 11.12.2 CSS section color scheme.
|
||||
fn section_hsl(idx: i32) -> (i32, f64, f64) {
|
||||
let hues: [i32; 12] = [60, 80, 270, 300, 330, 0, 30, 90, 150, 180, 210, 240];
|
||||
let wrapped = idx.rem_euclid(12);
|
||||
let hue = hues[wrapped as usize];
|
||||
if idx == 0 {
|
||||
(hue, 100.0, 83.5294117647)
|
||||
} else {
|
||||
(hue, 100.0, 86.2745098039)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the text fill color for a given section index.
|
||||
fn section_text_color(idx: i32) -> &'static str {
|
||||
// From the reference CSS: section-2 and section--1 use #ffffff; most others use black.
|
||||
// Note: .cluster-label CSS overrides this for header text to #333.
|
||||
match idx {
|
||||
-1 | 2 => "#ffffff",
|
||||
_ => "black",
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate text width in SVG units using a simple char-width heuristic.
|
||||
/// Uses a slightly wider estimate (9px/char) to prevent clipping in foreignObject,
|
||||
/// since the inner div's max-width CSS handles the real constraint.
|
||||
fn estimate_text_width(text: &str) -> f64 {
|
||||
let char_width = 9.5;
|
||||
text.len() as f64 * char_width
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+3375
File diff suppressed because it is too large
Load Diff
+1069
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::{Edge, Graph};
|
||||
|
||||
pub type DagreGraph = Graph<GraphConfig, GraphNode, GraphEdge>;
|
||||
|
||||
pub struct ExtractedCluster {
|
||||
pub graph: DagreGraph,
|
||||
pub children: HashMap<String, ExtractedCluster>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ClusterDbEntry {
|
||||
anchor_id: String,
|
||||
external_connections: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct AdjustState {
|
||||
cluster_db: HashMap<String, ClusterDbEntry>,
|
||||
descendants: HashMap<String, HashSet<String>>,
|
||||
}
|
||||
|
||||
pub fn adjust_clusters_and_edges(graph: &mut DagreGraph) -> HashMap<String, ExtractedCluster> {
|
||||
let mut state = AdjustState::default();
|
||||
let mut parents: HashMap<String, String> = HashMap::new();
|
||||
|
||||
let nodes = graph.nodes();
|
||||
for id in &nodes {
|
||||
if graph.children(id).is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let descendants = extract_descendants(id, graph, &mut parents);
|
||||
state.descendants.insert(id.clone(), descendants);
|
||||
|
||||
let anchor_id = find_non_cluster_child(id, graph, id).unwrap_or_else(|| id.clone());
|
||||
state.cluster_db.insert(
|
||||
id.clone(),
|
||||
ClusterDbEntry {
|
||||
anchor_id,
|
||||
external_connections: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let edges = graph.edges();
|
||||
for id in &nodes {
|
||||
if graph.children(id).is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for edge in &edges {
|
||||
let d1 = is_descendant(&edge.v, id, &state);
|
||||
let d2 = is_descendant(&edge.w, id, &state);
|
||||
if d1 != d2 {
|
||||
if let Some(entry) = state.cluster_db.get_mut(id) {
|
||||
entry.external_connections = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cluster_ids: Vec<String> = state.cluster_db.keys().cloned().collect();
|
||||
for id in cluster_ids {
|
||||
let Some(non_cluster_child) = state
|
||||
.cluster_db
|
||||
.get(&id)
|
||||
.map(|entry| entry.anchor_id.clone())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(parent) = graph.parent(&non_cluster_child) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if parent == &id {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(parent_entry) = state.cluster_db.get(parent) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !parent_entry.external_connections {
|
||||
if let Some(entry) = state.cluster_db.get_mut(&id) {
|
||||
entry.anchor_id = parent.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let edge_objs = graph.edges();
|
||||
for edge_obj in edge_objs {
|
||||
if !state.cluster_db.contains_key(&edge_obj.v)
|
||||
&& !state.cluster_db.contains_key(&edge_obj.w)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(edge_label) = graph.edge_with_obj(&edge_obj).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let v = get_anchor_id(&edge_obj.v, &state);
|
||||
let w = get_anchor_id(&edge_obj.w, &state);
|
||||
|
||||
graph.remove_edge_with_obj(&edge_obj);
|
||||
|
||||
if v != edge_obj.v {
|
||||
if let Some(parent) = graph.parent(&v) {
|
||||
if let Some(entry) = state.cluster_db.get_mut(parent) {
|
||||
entry.external_connections = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if w != edge_obj.w {
|
||||
if let Some(parent) = graph.parent(&w) {
|
||||
if let Some(entry) = state.cluster_db.get_mut(parent) {
|
||||
entry.external_connections = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = graph.set_edge(&v, &w, Some(edge_label), edge_obj.name.clone());
|
||||
}
|
||||
|
||||
extractor(graph, &state, 0)
|
||||
}
|
||||
|
||||
fn extract_descendants(
|
||||
id: &String,
|
||||
graph: &DagreGraph,
|
||||
parents: &mut HashMap<String, String>,
|
||||
) -> HashSet<String> {
|
||||
let children = graph.children(id);
|
||||
let mut res: HashSet<String> = children.iter().cloned().collect();
|
||||
for child in children {
|
||||
parents.insert(child.clone(), id.clone());
|
||||
res.extend(extract_descendants(&child, graph, parents));
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
fn is_descendant(id: &String, ancestor_id: &String, state: &AdjustState) -> bool {
|
||||
state
|
||||
.descendants
|
||||
.get(ancestor_id)
|
||||
.is_some_and(|desc| desc.contains(id))
|
||||
}
|
||||
|
||||
fn edge_in_cluster(edge: &Edge, cluster_id: &String, state: &AdjustState) -> bool {
|
||||
if &edge.v == cluster_id || &edge.w == cluster_id {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(cluster_descendants) = state.descendants.get(cluster_id) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
cluster_descendants.contains(&edge.v) || cluster_descendants.contains(&edge.w)
|
||||
}
|
||||
|
||||
fn find_common_edges(graph: &DagreGraph, id1: &String, id2: &String) -> Vec<(String, String)> {
|
||||
let edges = graph.edges();
|
||||
|
||||
let edges1: Vec<&Edge> = edges
|
||||
.iter()
|
||||
.filter(|edge| &edge.v == id1 || &edge.w == id1)
|
||||
.collect();
|
||||
let edges2: Vec<&Edge> = edges
|
||||
.iter()
|
||||
.filter(|edge| &edge.v == id2 || &edge.w == id2)
|
||||
.collect();
|
||||
|
||||
let edges1_prim: Vec<(String, String)> = edges1
|
||||
.into_iter()
|
||||
.map(|edge| {
|
||||
let v = if &edge.v == id1 {
|
||||
id2.clone()
|
||||
} else {
|
||||
edge.v.clone()
|
||||
};
|
||||
let w = if &edge.w == id1 {
|
||||
id1.clone()
|
||||
} else {
|
||||
edge.w.clone()
|
||||
};
|
||||
(v, w)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let edges2_prim: Vec<(String, String)> = edges2
|
||||
.into_iter()
|
||||
.map(|edge| (edge.v.clone(), edge.w.clone()))
|
||||
.collect();
|
||||
|
||||
edges1_prim
|
||||
.into_iter()
|
||||
.filter(|(v, w)| edges2_prim.iter().any(|(v2, w2)| v == v2 && w == w2))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_non_cluster_child(id: &String, graph: &DagreGraph, cluster_id: &String) -> Option<String> {
|
||||
let children = graph.children(id);
|
||||
if children.is_empty() {
|
||||
return Some(id.clone());
|
||||
}
|
||||
|
||||
let mut reserve: Option<String> = None;
|
||||
for child in children {
|
||||
let Some(candidate) = find_non_cluster_child(&child, graph, cluster_id) else {
|
||||
continue;
|
||||
};
|
||||
let candidate_id = candidate.clone();
|
||||
let common_edges = find_common_edges(graph, cluster_id, &candidate_id);
|
||||
if !common_edges.is_empty() {
|
||||
reserve = Some(candidate);
|
||||
} else {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
reserve
|
||||
}
|
||||
|
||||
fn get_anchor_id(id: &String, state: &AdjustState) -> String {
|
||||
let Some(entry) = state.cluster_db.get(id) else {
|
||||
return id.clone();
|
||||
};
|
||||
|
||||
if !entry.external_connections {
|
||||
return id.clone();
|
||||
}
|
||||
|
||||
entry.anchor_id.clone()
|
||||
}
|
||||
|
||||
fn new_cluster_graph(rankdir: &str) -> DagreGraph {
|
||||
let dir = if rankdir == "tb" { "lr" } else { "tb" };
|
||||
|
||||
let mut g: DagreGraph = Graph::new(Some(graphlib_rust::GraphOption {
|
||||
directed: Some(true),
|
||||
multigraph: Some(true),
|
||||
compound: Some(true),
|
||||
}));
|
||||
|
||||
g.set_graph(GraphConfig {
|
||||
rankdir: Some(dir.to_string()),
|
||||
nodesep: Some(50.0),
|
||||
ranksep: Some(50.0),
|
||||
marginx: Some(8.0),
|
||||
marginy: Some(8.0),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
g
|
||||
}
|
||||
|
||||
fn copy(
|
||||
cluster_id: &String,
|
||||
graph: &mut DagreGraph,
|
||||
new_graph: &mut DagreGraph,
|
||||
root_id: &String,
|
||||
state: &AdjustState,
|
||||
) {
|
||||
let mut nodes = graph.children(cluster_id);
|
||||
if cluster_id != root_id {
|
||||
nodes.push(cluster_id.clone());
|
||||
}
|
||||
|
||||
for node in nodes {
|
||||
if !graph.children(&node).is_empty() {
|
||||
copy(&node, graph, new_graph, root_id, state);
|
||||
} else {
|
||||
let Some(data) = graph.node(&node).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
new_graph.set_node(node.clone(), Some(data));
|
||||
|
||||
if let Some(parent) = graph.parent(&node) {
|
||||
if root_id != parent {
|
||||
let _ = new_graph.set_parent(&node, Some(parent.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if cluster_id != root_id && node != *cluster_id {
|
||||
let _ = new_graph.set_parent(&node, Some(cluster_id.clone()));
|
||||
}
|
||||
|
||||
let edge_objs: Vec<Edge> = graph
|
||||
.edges()
|
||||
.into_iter()
|
||||
.filter(|e| e.v == node || e.w == node)
|
||||
.collect();
|
||||
|
||||
for edge_obj in edge_objs {
|
||||
let Some(edge_label) = graph.edge_with_obj(&edge_obj).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if edge_in_cluster(&edge_obj, root_id, state) {
|
||||
let _ = new_graph.set_edge(
|
||||
&edge_obj.v,
|
||||
&edge_obj.w,
|
||||
Some(edge_label),
|
||||
edge_obj.name.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
graph.remove_node(&node);
|
||||
}
|
||||
}
|
||||
|
||||
fn extractor(
|
||||
graph: &mut DagreGraph,
|
||||
state: &AdjustState,
|
||||
depth: usize,
|
||||
) -> HashMap<String, ExtractedCluster> {
|
||||
if depth > 10 {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
let nodes = graph.nodes();
|
||||
if !nodes.iter().any(|node| !graph.children(node).is_empty()) {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
let mut extracted: HashMap<String, ExtractedCluster> = HashMap::new();
|
||||
|
||||
let rankdir = graph
|
||||
.graph()
|
||||
.rankdir
|
||||
.clone()
|
||||
.unwrap_or_else(|| "tb".to_string());
|
||||
|
||||
for node in nodes {
|
||||
if graph.node(&node).is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if graph.children(&node).is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(entry) = state.cluster_db.get(&node) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if entry.external_connections {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut cluster_graph = new_cluster_graph(&rankdir);
|
||||
copy(&node, graph, &mut cluster_graph, &node, state);
|
||||
let children = extractor(&mut cluster_graph, state, depth + 1);
|
||||
extracted.insert(
|
||||
node.clone(),
|
||||
ExtractedCluster {
|
||||
graph: cluster_graph,
|
||||
children,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
extracted
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::ast::{EdgeStyle, FlowchartGraph, GraphDirection, NodeShape};
|
||||
use crate::config::RenderConfig;
|
||||
use crate::layout::{LayoutEdge, LayoutNode, LayoutResult, LayoutSubgraph};
|
||||
use crate::text_wrap::{
|
||||
measure_wrapped_lines_with_font_size, scale_char_width, wrap_text_lines, DEFAULT_CHAR_WIDTH,
|
||||
DEFAULT_FONT_SIZE, DEFAULT_WRAP_WIDTH,
|
||||
};
|
||||
use dagre_rust::layout::layout as dagre_layout;
|
||||
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::Graph;
|
||||
|
||||
use super::cluster_adjust::{adjust_clusters_and_edges, ExtractedCluster};
|
||||
use super::{flow_data, flow_db};
|
||||
|
||||
const FLOWCHART_PADDING: f64 = 15.0;
|
||||
const EDGE_LABEL_PADDING: f64 = 2.0;
|
||||
const SUBGRAPH_PADDING: f64 = 8.0;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NodeMeta {
|
||||
label: String,
|
||||
shape: NodeShape,
|
||||
width: f64,
|
||||
height: f64,
|
||||
fill_color: Option<String>,
|
||||
stroke_color: Option<String>,
|
||||
is_group: bool,
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EdgeMeta {
|
||||
label: Option<String>,
|
||||
style: EdgeStyle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct LocalLayout {
|
||||
nodes: HashMap<String, LayoutNode>,
|
||||
edges: Vec<LayoutEdge>,
|
||||
subgraphs: Vec<LayoutSubgraph>,
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct PortLayoutOptions {
|
||||
node_spacing: f64,
|
||||
rank_spacing: f64,
|
||||
padding: f64,
|
||||
wrapping_width: f64,
|
||||
font_size: f64,
|
||||
}
|
||||
|
||||
impl Default for PortLayoutOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
node_spacing: 50.0,
|
||||
rank_spacing: 50.0,
|
||||
padding: FLOWCHART_PADDING,
|
||||
wrapping_width: DEFAULT_WRAP_WIDTH,
|
||||
font_size: DEFAULT_FONT_SIZE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PortLayoutOptions {
|
||||
fn from_render_config(config: &RenderConfig) -> Self {
|
||||
let default = Self::default();
|
||||
Self {
|
||||
node_spacing: config
|
||||
.flowchart
|
||||
.node_spacing
|
||||
.map(f64::from)
|
||||
.unwrap_or(default.node_spacing),
|
||||
rank_spacing: config
|
||||
.flowchart
|
||||
.rank_spacing
|
||||
.map(f64::from)
|
||||
.unwrap_or(default.rank_spacing),
|
||||
padding: config
|
||||
.flowchart
|
||||
.padding
|
||||
.map(f64::from)
|
||||
.unwrap_or(default.padding),
|
||||
wrapping_width: config
|
||||
.flowchart
|
||||
.wrapping_width
|
||||
.map(f64::from)
|
||||
.unwrap_or(default.wrapping_width),
|
||||
font_size: config.font_size_px().unwrap_or(default.font_size),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_layout_ported(flowchart: &FlowchartGraph) -> LayoutResult {
|
||||
compute_layout_ported_with_config(flowchart, &RenderConfig::default())
|
||||
}
|
||||
|
||||
pub fn compute_layout_ported_with_config(
|
||||
flowchart: &FlowchartGraph,
|
||||
config: &RenderConfig,
|
||||
) -> LayoutResult {
|
||||
let options = PortLayoutOptions::from_render_config(config);
|
||||
let db = flow_db::from_flowchart_graph(flowchart);
|
||||
let data = flow_data::get_data(&db);
|
||||
|
||||
let rankdir = match db.direction {
|
||||
GraphDirection::TopToBottom => "tb",
|
||||
GraphDirection::BottomToTop => "bt",
|
||||
GraphDirection::LeftToRight => "lr",
|
||||
GraphDirection::RightToLeft => "rl",
|
||||
};
|
||||
|
||||
let mut node_meta: HashMap<String, NodeMeta> = HashMap::new();
|
||||
for node in &data.nodes {
|
||||
let (fill_color, stroke_color) =
|
||||
node.styles.iter().fold((None, None), |mut acc, (k, v)| {
|
||||
if k == "fill" {
|
||||
acc.0 = Some(v.clone());
|
||||
}
|
||||
if k == "stroke" {
|
||||
acc.1 = Some(v.clone());
|
||||
}
|
||||
acc
|
||||
});
|
||||
|
||||
let (width, height) = if node.is_group {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
measure_node(&node.label, node.shape, &options)
|
||||
};
|
||||
|
||||
node_meta.insert(
|
||||
node.id.clone(),
|
||||
NodeMeta {
|
||||
label: node.label.clone(),
|
||||
shape: node.shape,
|
||||
width,
|
||||
height,
|
||||
fill_color,
|
||||
stroke_color,
|
||||
is_group: node.is_group,
|
||||
title: if node.is_group {
|
||||
Some(node.label.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut edge_meta: HashMap<(String, String), EdgeMeta> = HashMap::new();
|
||||
|
||||
let mut g: Graph<GraphConfig, GraphNode, GraphEdge> =
|
||||
Graph::new(Some(graphlib_rust::GraphOption {
|
||||
directed: Some(true),
|
||||
multigraph: Some(true),
|
||||
compound: Some(true),
|
||||
}));
|
||||
g.set_graph(GraphConfig {
|
||||
rankdir: Some(rankdir.to_string()),
|
||||
nodesep: Some(options.node_spacing as f32),
|
||||
ranksep: Some(options.rank_spacing as f32),
|
||||
marginx: Some(8.0),
|
||||
marginy: Some(8.0),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
for node in &data.nodes {
|
||||
if node.is_group {
|
||||
g.set_node(
|
||||
node.id.clone(),
|
||||
Some(GraphNode {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
padding: Some(SUBGRAPH_PADDING as f32),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
let Some(meta) = node_meta.get(&node.id) else {
|
||||
continue;
|
||||
};
|
||||
g.set_node(
|
||||
node.id.clone(),
|
||||
Some(GraphNode {
|
||||
width: meta.width as f32,
|
||||
height: meta.height as f32,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(parent_id) = &node.parent_id {
|
||||
let _ = g.set_parent(&node.id, Some(parent_id.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
for edge in &data.edges {
|
||||
let mut edge_label = GraphEdge {
|
||||
labelpos: Some("c".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(label) = &edge.label {
|
||||
if let Some((width, height)) = edge_label_dimensions(label, &options) {
|
||||
edge_label.width = Some(width as f32);
|
||||
edge_label.height = Some(height as f32);
|
||||
}
|
||||
}
|
||||
|
||||
edge_meta.insert(
|
||||
(edge.start.clone(), edge.end.clone()),
|
||||
EdgeMeta {
|
||||
label: edge.label.clone(),
|
||||
style: edge.style,
|
||||
},
|
||||
);
|
||||
|
||||
let _ = g.set_edge(&edge.start, &edge.end, Some(edge_label), None);
|
||||
}
|
||||
|
||||
let mut extracted = adjust_clusters_and_edges(&mut g);
|
||||
apply_options_to_extracted(&mut extracted, &options);
|
||||
|
||||
let layout = layout_recursive(&mut g, &mut extracted, &node_meta, &edge_meta);
|
||||
|
||||
LayoutResult {
|
||||
nodes: layout.nodes,
|
||||
edges: layout.edges,
|
||||
subgraphs: layout.subgraphs,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_options_to_extracted(
|
||||
extracted: &mut HashMap<String, ExtractedCluster>,
|
||||
options: &PortLayoutOptions,
|
||||
) {
|
||||
for cluster in extracted.values_mut() {
|
||||
let graph_config = cluster.graph.graph_mut();
|
||||
graph_config.nodesep = Some(options.node_spacing as f32);
|
||||
graph_config.ranksep = Some(options.rank_spacing as f32);
|
||||
apply_options_to_extracted(&mut cluster.children, options);
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_recursive(
|
||||
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
extracted: &mut HashMap<String, ExtractedCluster>,
|
||||
node_meta: &HashMap<String, NodeMeta>,
|
||||
edge_meta: &HashMap<(String, String), EdgeMeta>,
|
||||
) -> LocalLayout {
|
||||
let mut child_layouts: HashMap<String, LocalLayout> = HashMap::new();
|
||||
|
||||
for (cluster_id, cluster) in extracted.iter_mut() {
|
||||
let layout = layout_recursive(
|
||||
&mut cluster.graph,
|
||||
&mut cluster.children,
|
||||
node_meta,
|
||||
edge_meta,
|
||||
);
|
||||
if let Some(node) = graph.node_mut(cluster_id) {
|
||||
node.width = layout.width as f32;
|
||||
node.height = layout.height as f32;
|
||||
}
|
||||
child_layouts.insert(cluster_id.clone(), layout);
|
||||
}
|
||||
|
||||
dagre_layout(graph);
|
||||
|
||||
let mut layout = extract_local_layout(graph, node_meta, edge_meta);
|
||||
|
||||
for (cluster_id, mut child_layout) in child_layouts {
|
||||
let Some(cluster_node) = graph.node(&cluster_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let dx = cluster_node.x as f64 - cluster_node.width as f64 / 2.0;
|
||||
let dy = cluster_node.y as f64 - cluster_node.height as f64 / 2.0;
|
||||
shift_layout(&mut child_layout, dx, dy);
|
||||
|
||||
for (id, node) in child_layout.nodes {
|
||||
layout.nodes.insert(id, node);
|
||||
}
|
||||
layout.edges.extend(child_layout.edges);
|
||||
layout.subgraphs.extend(child_layout.subgraphs);
|
||||
}
|
||||
|
||||
layout
|
||||
}
|
||||
|
||||
fn extract_local_layout(
|
||||
graph: &Graph<GraphConfig, GraphNode, GraphEdge>,
|
||||
node_meta: &HashMap<String, NodeMeta>,
|
||||
edge_meta: &HashMap<(String, String), EdgeMeta>,
|
||||
) -> LocalLayout {
|
||||
let mut nodes: HashMap<String, LayoutNode> = HashMap::new();
|
||||
let mut subgraphs: Vec<LayoutSubgraph> = Vec::new();
|
||||
|
||||
for node_id in graph.nodes() {
|
||||
let Some(meta) = node_meta.get(&node_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(node) = graph.node(&node_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if meta.is_group {
|
||||
let width = node.width as f64;
|
||||
let height = node.height as f64;
|
||||
if width <= 0.0 || height <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
subgraphs.push(LayoutSubgraph {
|
||||
id: node_id.clone(),
|
||||
title: meta.title.clone(),
|
||||
x: node.x as f64 - width / 2.0,
|
||||
y: node.y as f64 - height / 2.0,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
} else {
|
||||
nodes.insert(
|
||||
node_id.clone(),
|
||||
LayoutNode {
|
||||
id: node_id.clone(),
|
||||
x: node.x as f64,
|
||||
y: node.y as f64,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
shape: meta.shape,
|
||||
label: meta.label.clone(),
|
||||
fill_color: meta.fill_color.clone(),
|
||||
stroke_color: meta.stroke_color.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut edges: Vec<LayoutEdge> = Vec::new();
|
||||
for edge_obj in graph.edges() {
|
||||
let Some(edge_label) = graph.edge_with_obj(&edge_obj) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(meta) = edge_meta.get(&(edge_obj.v.clone(), edge_obj.w.clone())) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let points: Vec<(f64, f64)> = edge_label
|
||||
.points
|
||||
.as_ref()
|
||||
.map(|pts| pts.iter().map(|p| (p.x as f64, p.y as f64)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let label_pos = meta.label.as_ref().and_then(|label| {
|
||||
if label.trim().is_empty() {
|
||||
None
|
||||
} else if edge_label.width.unwrap_or(0.0) > 0.0
|
||||
|| edge_label.height.unwrap_or(0.0) > 0.0
|
||||
{
|
||||
Some((edge_label.x as f64, edge_label.y as f64))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
edges.push(LayoutEdge {
|
||||
from: edge_obj.v,
|
||||
to: edge_obj.w,
|
||||
label: meta.label.clone(),
|
||||
style: meta.style,
|
||||
points,
|
||||
label_pos,
|
||||
});
|
||||
}
|
||||
|
||||
let graph_width = graph.graph().width as f64;
|
||||
let graph_height = graph.graph().height as f64;
|
||||
|
||||
LocalLayout {
|
||||
nodes,
|
||||
edges,
|
||||
subgraphs,
|
||||
width: graph_width,
|
||||
height: graph_height,
|
||||
}
|
||||
}
|
||||
|
||||
fn shift_layout(layout: &mut LocalLayout, dx: f64, dy: f64) {
|
||||
for node in layout.nodes.values_mut() {
|
||||
node.x += dx;
|
||||
node.y += dy;
|
||||
}
|
||||
|
||||
for edge in &mut layout.edges {
|
||||
for point in &mut edge.points {
|
||||
point.0 += dx;
|
||||
point.1 += dy;
|
||||
}
|
||||
if let Some((x, y)) = edge.label_pos.as_mut() {
|
||||
*x += dx;
|
||||
*y += dy;
|
||||
}
|
||||
}
|
||||
|
||||
for sg in &mut layout.subgraphs {
|
||||
sg.x += dx;
|
||||
sg.y += dy;
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_node(label: &str, shape: NodeShape, options: &PortLayoutOptions) -> (f64, f64) {
|
||||
let char_width = scale_char_width(DEFAULT_CHAR_WIDTH, options.font_size);
|
||||
let lines = wrap_text_lines(label, options.wrapping_width, char_width);
|
||||
let (text_width, text_height) =
|
||||
measure_wrapped_lines_with_font_size(&lines, char_width, options.font_size);
|
||||
let padding = options.padding;
|
||||
|
||||
match shape {
|
||||
NodeShape::Rectangle => (text_width + padding * 4.0, text_height + padding * 2.0),
|
||||
NodeShape::RoundedRectangle => (text_width + padding * 2.0, text_height + padding * 2.0),
|
||||
NodeShape::Subroutine => {
|
||||
let w = text_width + padding;
|
||||
let h = text_height + padding;
|
||||
(w + 16.0, h)
|
||||
}
|
||||
NodeShape::Asymmetric => {
|
||||
let w = text_width + padding;
|
||||
let h = text_height + padding;
|
||||
(w + h / 4.0, h)
|
||||
}
|
||||
NodeShape::Hexagon => {
|
||||
let h = text_height + padding;
|
||||
let w = text_width + padding * 2.5;
|
||||
(w * 7.0 / 6.0, h)
|
||||
}
|
||||
NodeShape::Diamond => {
|
||||
let w = text_width + padding;
|
||||
let h = text_height + padding;
|
||||
let s = w + h;
|
||||
(s, s)
|
||||
}
|
||||
NodeShape::Circle => {
|
||||
let diameter = text_width + padding;
|
||||
(diameter, diameter)
|
||||
}
|
||||
NodeShape::StartState => (14.0, 14.0),
|
||||
NodeShape::EndState => (20.0, 20.0),
|
||||
NodeShape::ForkJoin => (70.0, 10.0),
|
||||
NodeShape::Stadium => {
|
||||
let h = text_height + padding;
|
||||
let w = text_width + h / 4.0 + padding;
|
||||
(w, h)
|
||||
}
|
||||
NodeShape::Cylinder => {
|
||||
let w = text_width + padding;
|
||||
let rx = w / 2.0;
|
||||
let ry = rx / (2.5 + w / 50.0);
|
||||
let h = text_height + ry + padding;
|
||||
(w, h + 2.0 * ry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_label_dimensions(label: &str, options: &PortLayoutOptions) -> Option<(f64, f64)> {
|
||||
if label.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let char_width = scale_char_width(DEFAULT_CHAR_WIDTH, options.font_size);
|
||||
let lines = wrap_text_lines(label, options.wrapping_width, char_width);
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (text_width, text_height) =
|
||||
measure_wrapped_lines_with_font_size(&lines, char_width, options.font_size);
|
||||
let width = text_width + EDGE_LABEL_PADDING * 2.0;
|
||||
let height = text_height + EDGE_LABEL_PADDING * 2.0;
|
||||
Some((width, height))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ast::{Edge, FlowchartGraph, GraphDirection, Statement};
|
||||
use crate::config::FlowchartConfig;
|
||||
|
||||
#[test]
|
||||
fn ported_layout_uses_spacing_config() {
|
||||
let graph = FlowchartGraph {
|
||||
direction: GraphDirection::TopToBottom,
|
||||
statements: vec![Statement::Edge(Edge {
|
||||
from: "A".to_string(),
|
||||
to: "B".to_string(),
|
||||
label: None,
|
||||
style: EdgeStyle::Arrow,
|
||||
})],
|
||||
};
|
||||
|
||||
let default_layout = compute_layout_ported(&graph);
|
||||
let config = RenderConfig {
|
||||
flowchart: FlowchartConfig {
|
||||
rank_spacing: Some(140),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let configured_layout = compute_layout_ported_with_config(&graph, &config);
|
||||
|
||||
let default_delta = default_layout.nodes["B"].y - default_layout.nodes["A"].y;
|
||||
let configured_delta = configured_layout.nodes["B"].y - configured_layout.nodes["A"].y;
|
||||
|
||||
assert!(configured_delta > default_delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ported_layout_uses_padding_and_wrapping_config() {
|
||||
let default = PortLayoutOptions::default();
|
||||
let config = RenderConfig {
|
||||
flowchart: FlowchartConfig {
|
||||
padding: Some(4),
|
||||
wrapping_width: Some(70),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let configured = PortLayoutOptions::from_render_config(&config);
|
||||
let default_size = measure_node(
|
||||
"Long label that wraps across several rendered lines",
|
||||
NodeShape::Rectangle,
|
||||
&default,
|
||||
);
|
||||
let configured_size = measure_node(
|
||||
"Long label that wraps across several rendered lines",
|
||||
NodeShape::Rectangle,
|
||||
&configured,
|
||||
);
|
||||
|
||||
assert!(configured_size.1 > default_size.1);
|
||||
assert!(configured_size.0 < default_size.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ported_layout_uses_font_size_config() {
|
||||
let default = PortLayoutOptions::default();
|
||||
let config = RenderConfig {
|
||||
font_size: Some("32px".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let configured = PortLayoutOptions::from_render_config(&config);
|
||||
let default_size = measure_node("Font", NodeShape::Rectangle, &default);
|
||||
let configured_size = measure_node("Font", NodeShape::Rectangle, &configured);
|
||||
let (default_label_width, default_label_height) =
|
||||
edge_label_dimensions("Edge", &default).expect("label should have dimensions");
|
||||
let (configured_label_width, configured_label_height) =
|
||||
edge_label_dimensions("Edge", &configured).expect("label should have dimensions");
|
||||
|
||||
assert!(configured_size.0 > default_size.0);
|
||||
assert!(configured_size.1 > default_size.1);
|
||||
assert!(configured_label_width > default_label_width);
|
||||
assert!(configured_label_height > default_label_height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use crate::ast::{EdgeStyle, NodeShape};
|
||||
|
||||
use super::flow_db::{FlowDb, FlowEdge, FlowVertex};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowData {
|
||||
pub nodes: Vec<FlowDataNode>,
|
||||
pub edges: Vec<FlowDataEdge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowDataNode {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub shape: NodeShape,
|
||||
pub parent_id: Option<String>,
|
||||
pub styles: Vec<(String, String)>,
|
||||
pub is_group: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowDataEdge {
|
||||
pub start: String,
|
||||
pub end: String,
|
||||
pub label: Option<String>,
|
||||
pub style: EdgeStyle,
|
||||
}
|
||||
|
||||
pub fn get_data(db: &FlowDb) -> FlowData {
|
||||
let mut nodes: Vec<FlowDataNode> = Vec::new();
|
||||
|
||||
for sg in db.subgraphs.iter().rev() {
|
||||
nodes.push(FlowDataNode {
|
||||
id: sg.id.clone(),
|
||||
label: sg.title.clone().unwrap_or_else(|| sg.id.clone()),
|
||||
shape: NodeShape::Rectangle,
|
||||
parent_id: sg.parent_id.clone(),
|
||||
styles: Vec::new(),
|
||||
is_group: true,
|
||||
});
|
||||
}
|
||||
|
||||
for id in &db.vertex_order {
|
||||
if let Some(v) = db.vertices.get(id) {
|
||||
nodes.push(make_node_data(db, v));
|
||||
}
|
||||
}
|
||||
|
||||
let edges: Vec<FlowDataEdge> = db.edges.iter().map(make_edge_data).collect();
|
||||
|
||||
FlowData { nodes, edges }
|
||||
}
|
||||
|
||||
fn make_node_data(db: &FlowDb, v: &FlowVertex) -> FlowDataNode {
|
||||
let parent_id = db.node_to_subgraph.get(&v.id).cloned();
|
||||
let styles = db.node_styles.get(&v.id).cloned().unwrap_or_default();
|
||||
|
||||
FlowDataNode {
|
||||
id: v.id.clone(),
|
||||
label: v.label.clone(),
|
||||
shape: v.shape,
|
||||
parent_id,
|
||||
styles,
|
||||
is_group: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_edge_data(e: &FlowEdge) -> FlowDataEdge {
|
||||
FlowDataEdge {
|
||||
start: e.start.clone(),
|
||||
end: e.end.clone(),
|
||||
label: e.label.clone(),
|
||||
style: e.style,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::ast::{EdgeStyle, FlowchartGraph, GraphDirection, NodeShape, Statement};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowDb {
|
||||
pub direction: GraphDirection,
|
||||
pub vertices: HashMap<String, FlowVertex>,
|
||||
pub vertex_order: Vec<String>,
|
||||
pub edges: Vec<FlowEdge>,
|
||||
pub subgraphs: Vec<FlowSubgraph>,
|
||||
pub node_to_subgraph: HashMap<String, String>,
|
||||
pub node_styles: HashMap<String, Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowVertex {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub shape: NodeShape,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowEdge {
|
||||
pub start: String,
|
||||
pub end: String,
|
||||
pub label: Option<String>,
|
||||
pub style: EdgeStyle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowSubgraph {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
pub fn from_flowchart_graph(graph: &FlowchartGraph) -> FlowDb {
|
||||
let mut db = FlowDb {
|
||||
direction: graph.direction,
|
||||
vertices: HashMap::new(),
|
||||
vertex_order: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
subgraphs: Vec::new(),
|
||||
node_to_subgraph: HashMap::new(),
|
||||
node_styles: HashMap::new(),
|
||||
};
|
||||
|
||||
collect_statements(&mut db, &graph.statements, None);
|
||||
db
|
||||
}
|
||||
|
||||
fn collect_statements(db: &mut FlowDb, statements: &[Statement], current_subgraph: Option<&str>) {
|
||||
for stmt in statements {
|
||||
match stmt {
|
||||
Statement::Node(node) => {
|
||||
ensure_vertex(db, &node.id, node.label.as_deref(), node.shape);
|
||||
maybe_assign_to_subgraph(db, &node.id, current_subgraph);
|
||||
}
|
||||
Statement::Edge(edge) => {
|
||||
ensure_vertex(db, &edge.from, None, NodeShape::Rectangle);
|
||||
ensure_vertex(db, &edge.to, None, NodeShape::Rectangle);
|
||||
maybe_assign_to_subgraph(db, &edge.from, current_subgraph);
|
||||
maybe_assign_to_subgraph(db, &edge.to, current_subgraph);
|
||||
|
||||
db.edges.push(FlowEdge {
|
||||
start: edge.from.clone(),
|
||||
end: edge.to.clone(),
|
||||
label: edge.label.clone(),
|
||||
style: edge.style,
|
||||
});
|
||||
}
|
||||
Statement::Subgraph(subgraph) => {
|
||||
collect_statements(db, &subgraph.statements, Some(&subgraph.id));
|
||||
|
||||
db.subgraphs.push(FlowSubgraph {
|
||||
id: subgraph.id.clone(),
|
||||
title: subgraph.title.clone().or_else(|| Some(subgraph.id.clone())),
|
||||
parent_id: current_subgraph.map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
Statement::Style(style) => {
|
||||
ensure_vertex(db, &style.node_id, None, NodeShape::Rectangle);
|
||||
maybe_assign_to_subgraph(db, &style.node_id, current_subgraph);
|
||||
db.node_styles
|
||||
.entry(style.node_id.clone())
|
||||
.or_default()
|
||||
.extend(style.properties.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_vertex(db: &mut FlowDb, id: &str, label: Option<&str>, shape: NodeShape) {
|
||||
if is_subgraph_id(db, id) {
|
||||
return;
|
||||
}
|
||||
let id = id.to_string();
|
||||
|
||||
match db.vertices.get_mut(&id) {
|
||||
Some(v) => {
|
||||
if let Some(label) = label {
|
||||
v.label = label.to_string();
|
||||
v.shape = shape;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let label = label.unwrap_or(id.as_str()).to_string();
|
||||
db.vertex_order.push(id.clone());
|
||||
db.vertices
|
||||
.insert(id.clone(), FlowVertex { id, label, shape });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_assign_to_subgraph(db: &mut FlowDb, node_id: &str, current_subgraph: Option<&str>) {
|
||||
if is_subgraph_id(db, node_id) {
|
||||
return;
|
||||
}
|
||||
let Some(subgraph_id) = current_subgraph else {
|
||||
return;
|
||||
};
|
||||
|
||||
db.node_to_subgraph
|
||||
.entry(node_id.to_string())
|
||||
.or_insert_with(|| subgraph_id.to_string());
|
||||
}
|
||||
|
||||
fn is_subgraph_id(db: &FlowDb, id: &str) -> bool {
|
||||
db.subgraphs.iter().any(|subgraph| subgraph.id == id)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use crate::ast::FlowchartGraph;
|
||||
use crate::error::MermaidError;
|
||||
|
||||
pub fn parse_flowchart(mermaid_source: &str) -> Result<FlowchartGraph, MermaidError> {
|
||||
crate::parser::parse_mermaid(mermaid_source)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
mod cluster_adjust;
|
||||
mod dagre_layout_port;
|
||||
mod flow_data;
|
||||
mod flow_db;
|
||||
mod flow_parser;
|
||||
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
use crate::RenderConfig;
|
||||
|
||||
pub fn render_mermaid_to_svg_ported(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
config: &RenderConfig,
|
||||
) -> Result<String, MermaidError> {
|
||||
let graph = flow_parser::parse_flowchart(mermaid_source)?;
|
||||
let layout_result = dagre_layout_port::compute_layout_ported_with_config(&graph, config);
|
||||
Ok(crate::svg_renderer::render_with_config(
|
||||
&layout_result,
|
||||
theme,
|
||||
config,
|
||||
))
|
||||
}
|
||||
|
||||
// HERMETIC VENDORING PATCH: the experimental dagre flowchart "port" is disabled
|
||||
// unconditionally. Upstream gated it on the `MERMAID_TO_SVG_USE_PORT` env var;
|
||||
// reading the environment makes rendering non-deterministic over untrusted
|
||||
// input, and the port mis-routes back-edges on cyclic flowcharts (detached
|
||||
// arrowheads) — the exact defect this engine was adopted to fix. The default
|
||||
// `layout::compute_layout` path routes cycles correctly.
|
||||
pub fn is_enabled() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn compute_layout_ported(
|
||||
flowchart: &crate::ast::FlowchartGraph,
|
||||
) -> crate::layout::LayoutResult {
|
||||
dagre_layout_port::compute_layout_ported(flowchart)
|
||||
}
|
||||
+670
@@ -0,0 +1,670 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
/// Node type in the mindmap, following Mermaid 11.12.2 nodeType enum.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum MindmapNodeType {
|
||||
Default, // no-border — "rounded" shape
|
||||
Rect, // [text]
|
||||
RoundedRect, // (text)
|
||||
Circle, // ((text))
|
||||
#[allow(dead_code)]
|
||||
Cloud, // )text(
|
||||
Bang, // ))text((
|
||||
Hexagon, // {{text}}
|
||||
}
|
||||
|
||||
/// A node in the mindmap tree.
|
||||
#[derive(Debug, Clone)]
|
||||
struct MindmapNode {
|
||||
id: String,
|
||||
label: String,
|
||||
node_type: MindmapNodeType,
|
||||
children: Vec<MindmapNode>,
|
||||
section: Option<usize>,
|
||||
}
|
||||
|
||||
/// Colors for a section (branch) of the mindmap.
|
||||
struct SectionColors {
|
||||
fill: &'static str,
|
||||
text: &'static str,
|
||||
edge: &'static str,
|
||||
}
|
||||
|
||||
/// Root fill: hsl(240, 100%, 46.27%) from reference SVG.
|
||||
const ROOT_FILL: &str = "hsl(240, 100%, 46.27%)";
|
||||
const ROOT_TEXT: &str = "#ffffff";
|
||||
|
||||
/// Mermaid 11.12.2 default theme section colors, extracted from reference SVG.
|
||||
/// Each section-N fill is hsl(H, 100%, ~73-76%).
|
||||
const SECTION_COLORS: &[SectionColors] = &[
|
||||
// Section 0: hsl(60, 100%, 73.53%) — yellow
|
||||
SectionColors {
|
||||
fill: "hsl(60, 100%, 73.53%)",
|
||||
text: "black",
|
||||
edge: "hsl(60, 100%, 73.53%)",
|
||||
},
|
||||
// Section 1: hsl(80, 100%, 76.27%) — yellow-green
|
||||
SectionColors {
|
||||
fill: "hsl(80, 100%, 76.27%)",
|
||||
text: "black",
|
||||
edge: "hsl(80, 100%, 76.27%)",
|
||||
},
|
||||
// Section 2: hsl(270, 100%, 76.27%) — purple
|
||||
SectionColors {
|
||||
fill: "hsl(270, 100%, 76.27%)",
|
||||
text: "#ffffff",
|
||||
edge: "hsl(270, 100%, 76.27%)",
|
||||
},
|
||||
// Section 3: hsl(300, 100%, 76.27%) — magenta
|
||||
SectionColors {
|
||||
fill: "hsl(300, 100%, 76.27%)",
|
||||
text: "black",
|
||||
edge: "hsl(300, 100%, 76.27%)",
|
||||
},
|
||||
// Section 4: hsl(330, 100%, 76.27%) — pink
|
||||
SectionColors {
|
||||
fill: "hsl(330, 100%, 76.27%)",
|
||||
text: "black",
|
||||
edge: "hsl(330, 100%, 76.27%)",
|
||||
},
|
||||
// Section 5: hsl(0, 100%, 76.27%) — red
|
||||
SectionColors {
|
||||
fill: "hsl(0, 100%, 76.27%)",
|
||||
text: "black",
|
||||
edge: "hsl(0, 100%, 76.27%)",
|
||||
},
|
||||
// Section 6: hsl(30, 100%, 76.27%) — orange
|
||||
SectionColors {
|
||||
fill: "hsl(30, 100%, 76.27%)",
|
||||
text: "black",
|
||||
edge: "hsl(30, 100%, 76.27%)",
|
||||
},
|
||||
// Section 7: hsl(90, 100%, 76.27%) — lime
|
||||
SectionColors {
|
||||
fill: "hsl(90, 100%, 76.27%)",
|
||||
text: "black",
|
||||
edge: "hsl(90, 100%, 76.27%)",
|
||||
},
|
||||
];
|
||||
|
||||
fn section_color(section: usize) -> &'static SectionColors {
|
||||
&SECTION_COLORS[section % SECTION_COLORS.len()]
|
||||
}
|
||||
|
||||
/// Layout result for a placed node.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlacedNode {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
label: String,
|
||||
node_type: MindmapNodeType,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
section: Option<usize>,
|
||||
is_root: bool,
|
||||
}
|
||||
|
||||
/// Layout result for an edge.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlacedEdge {
|
||||
from_x: f64,
|
||||
from_y: f64,
|
||||
to_x: f64,
|
||||
to_y: f64,
|
||||
section: Option<usize>,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
// --- Parsing ---
|
||||
|
||||
fn parse_mindmap_tree(input: &str) -> Result<MindmapNode, MermaidError> {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
let mut i = 0_usize;
|
||||
|
||||
// Find "mindmap" declaration
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if line.split_whitespace().next() == Some("mindmap") {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
return Err(MermaidError::ParseError {
|
||||
line: i + 1,
|
||||
message: "Expected 'mindmap' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut stack: Vec<(usize, MindmapNode)> = Vec::new();
|
||||
let mut next_id = 0_usize;
|
||||
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
i += 1;
|
||||
|
||||
if raw.trim().is_empty() || raw.trim_start().starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let indent = raw.chars().take_while(|c| *c == ' ' || *c == '\t').count();
|
||||
let text = raw.trim();
|
||||
|
||||
// Skip decoration lines like ::icon(...)
|
||||
if text.starts_with("::") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (label, node_type) = extract_label_and_type(text);
|
||||
let node_id = format!("n{next_id}");
|
||||
next_id += 1;
|
||||
|
||||
let is_root = stack.is_empty();
|
||||
|
||||
// Pop stack entries with indent >= current
|
||||
while let Some((d, _)) = stack.last() {
|
||||
if *d >= indent {
|
||||
let (_, child) = stack.pop().unwrap();
|
||||
if let Some((_, parent)) = stack.last_mut() {
|
||||
parent.children.push(child);
|
||||
} else {
|
||||
// This was the root — re-push it
|
||||
stack.push((indent, child));
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let node = MindmapNode {
|
||||
id: node_id,
|
||||
label,
|
||||
node_type: if is_root && node_type == MindmapNodeType::Default {
|
||||
MindmapNodeType::Circle
|
||||
} else {
|
||||
node_type
|
||||
},
|
||||
children: Vec::new(),
|
||||
section: None,
|
||||
};
|
||||
|
||||
stack.push((indent, node));
|
||||
}
|
||||
|
||||
// Collapse stack to get root
|
||||
while stack.len() > 1 {
|
||||
let (_, child) = stack.pop().unwrap();
|
||||
if let Some((_, parent)) = stack.last_mut() {
|
||||
parent.children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
stack.pop().map(|(_, n)| n).ok_or(MermaidError::ParseError {
|
||||
line: 0,
|
||||
message: "No nodes found in mindmap".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_label_and_type(text: &str) -> (String, MindmapNodeType) {
|
||||
let t = text.trim();
|
||||
|
||||
// (( ... )) → Circle
|
||||
if let Some(start) = t.find("((") {
|
||||
if t.ends_with("))") && start + 2 < t.len().saturating_sub(2) {
|
||||
let label = t[start + 2..t.len() - 2].trim().to_string();
|
||||
return (label, MindmapNodeType::Circle);
|
||||
}
|
||||
}
|
||||
|
||||
// {{ ... }} → Hexagon
|
||||
if let Some(start) = t.find("{{") {
|
||||
if t.ends_with("}}") && start + 2 < t.len().saturating_sub(2) {
|
||||
let label = t[start + 2..t.len() - 2].trim().to_string();
|
||||
return (label, MindmapNodeType::Hexagon);
|
||||
}
|
||||
}
|
||||
|
||||
// )) ... (( → Bang
|
||||
if let Some(start) = t.find("))") {
|
||||
if t.ends_with("((") && start + 2 < t.len().saturating_sub(2) {
|
||||
let label = t[start + 2..t.len() - 2].trim().to_string();
|
||||
return (label, MindmapNodeType::Bang);
|
||||
}
|
||||
}
|
||||
|
||||
// [ ... ] → Rect
|
||||
if let Some(start) = t.find('[') {
|
||||
if t.ends_with(']') && start + 1 < t.len().saturating_sub(1) {
|
||||
let label = t[start + 1..t.len() - 1].trim().to_string();
|
||||
return (label, MindmapNodeType::Rect);
|
||||
}
|
||||
}
|
||||
|
||||
// ( ... ) → RoundedRect
|
||||
if let Some(start) = t.find('(') {
|
||||
if t.ends_with(')') && start + 1 < t.len().saturating_sub(1) {
|
||||
let label = t[start + 1..t.len() - 1].trim().to_string();
|
||||
return (label, MindmapNodeType::RoundedRect);
|
||||
}
|
||||
}
|
||||
|
||||
// Default (no delimiter)
|
||||
(t.to_string(), MindmapNodeType::Default)
|
||||
}
|
||||
|
||||
// --- Section assignment ---
|
||||
|
||||
fn assign_sections(node: &mut MindmapNode, section: Option<usize>) {
|
||||
node.section = section;
|
||||
for (i, child) in node.children.iter_mut().enumerate() {
|
||||
let child_section = if section.is_none() {
|
||||
// Direct children of root get their own section number
|
||||
Some(i)
|
||||
} else {
|
||||
section
|
||||
};
|
||||
assign_sections(child, child_section);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Node sizing ---
|
||||
|
||||
const FONT_SIZE: f64 = 16.0;
|
||||
const NODE_PADDING: f64 = 15.0;
|
||||
const ROOT_PADDING: f64 = 20.0;
|
||||
|
||||
/// Estimate text width using display-width units × average character width.
|
||||
/// We use this instead of `text_wrap::line_width` because in sandbox/CI
|
||||
/// environments the font measurer may return 0 (no real fonts loaded).
|
||||
fn estimate_text_width(text: &str) -> f64 {
|
||||
// Average character width for 16px Trebuchet MS is roughly 8.5px
|
||||
let avg_char_width = 8.5;
|
||||
crate::text_wrap::display_width_units(text) * avg_char_width
|
||||
}
|
||||
|
||||
fn measure_node(node: &MindmapNode) -> (f64, f64) {
|
||||
let text_width = estimate_text_width(&node.label);
|
||||
let text_height = FONT_SIZE;
|
||||
|
||||
match node.node_type {
|
||||
MindmapNodeType::Circle => {
|
||||
let diameter = (text_width.max(text_height) + ROOT_PADDING * 2.0).max(60.0);
|
||||
(diameter, diameter)
|
||||
}
|
||||
MindmapNodeType::Rect | MindmapNodeType::RoundedRect | MindmapNodeType::Default => {
|
||||
let w = text_width + NODE_PADDING * 2.0;
|
||||
let h = text_height + NODE_PADDING * 2.0;
|
||||
(w.max(40.0), h.max(36.0))
|
||||
}
|
||||
MindmapNodeType::Hexagon => {
|
||||
let w = text_width + NODE_PADDING * 3.0;
|
||||
let h = text_height + NODE_PADDING * 2.0;
|
||||
(w.max(50.0), h.max(40.0))
|
||||
}
|
||||
MindmapNodeType::Cloud | MindmapNodeType::Bang => {
|
||||
let w = text_width + NODE_PADDING * 2.5;
|
||||
let h = text_height + NODE_PADDING * 2.5;
|
||||
(w.max(50.0), h.max(40.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layout ---
|
||||
|
||||
/// Simple radial mindmap layout.
|
||||
/// Root is placed at center. Children of root are distributed radially.
|
||||
/// Deeper nodes extend outward from their parent.
|
||||
fn layout_mindmap(root: &MindmapNode) -> (Vec<PlacedNode>, Vec<PlacedEdge>) {
|
||||
let mut placed_nodes = Vec::new();
|
||||
let mut placed_edges = Vec::new();
|
||||
|
||||
let (root_w, root_h) = measure_node(root);
|
||||
|
||||
// Place root at origin (will be shifted later)
|
||||
placed_nodes.push(PlacedNode {
|
||||
id: root.id.clone(),
|
||||
label: root.label.clone(),
|
||||
node_type: root.node_type,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: root_w,
|
||||
height: root_h,
|
||||
section: root.section,
|
||||
is_root: true,
|
||||
});
|
||||
|
||||
let n_children = root.children.len();
|
||||
if n_children == 0 {
|
||||
return (placed_nodes, placed_edges);
|
||||
}
|
||||
|
||||
// Calculate total subtree "weight" for each branch
|
||||
let weights: Vec<f64> = root.children.iter().map(subtree_weight).collect();
|
||||
let total_weight: f64 = weights.iter().sum();
|
||||
|
||||
// Distribute branches around the root
|
||||
let start_angle: f64 = -std::f64::consts::FRAC_PI_2; // top
|
||||
let mut current_angle = start_angle;
|
||||
|
||||
let base_radius = 120.0 + (n_children as f64) * 20.0;
|
||||
|
||||
for (i, child) in root.children.iter().enumerate() {
|
||||
let weight_fraction = weights[i] / total_weight;
|
||||
let sweep = std::f64::consts::TAU * weight_fraction;
|
||||
let mid_angle = current_angle + sweep / 2.0;
|
||||
|
||||
layout_subtree(
|
||||
child,
|
||||
0.0,
|
||||
0.0,
|
||||
mid_angle,
|
||||
base_radius,
|
||||
1,
|
||||
&mut placed_nodes,
|
||||
&mut placed_edges,
|
||||
);
|
||||
|
||||
current_angle += sweep;
|
||||
}
|
||||
|
||||
// Normalize positions
|
||||
let padding = 20.0;
|
||||
let min_x = placed_nodes
|
||||
.iter()
|
||||
.map(|n| n.x - n.width / 2.0)
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let min_y = placed_nodes
|
||||
.iter()
|
||||
.map(|n| n.y - n.height / 2.0)
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
|
||||
let shift_x = -min_x + padding;
|
||||
let shift_y = -min_y + padding;
|
||||
|
||||
for node in &mut placed_nodes {
|
||||
node.x += shift_x;
|
||||
node.y += shift_y;
|
||||
}
|
||||
|
||||
for edge in &mut placed_edges {
|
||||
edge.from_x += shift_x;
|
||||
edge.from_y += shift_y;
|
||||
edge.to_x += shift_x;
|
||||
edge.to_y += shift_y;
|
||||
}
|
||||
|
||||
(placed_nodes, placed_edges)
|
||||
}
|
||||
|
||||
fn subtree_weight(node: &MindmapNode) -> f64 {
|
||||
if node.children.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
let child_weight: f64 = node.children.iter().map(subtree_weight).sum();
|
||||
child_weight.max(1.0)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layout_subtree(
|
||||
node: &MindmapNode,
|
||||
parent_x: f64,
|
||||
parent_y: f64,
|
||||
angle: f64,
|
||||
radius: f64,
|
||||
depth: usize,
|
||||
placed_nodes: &mut Vec<PlacedNode>,
|
||||
placed_edges: &mut Vec<PlacedEdge>,
|
||||
) {
|
||||
let (node_w, node_h) = measure_node(node);
|
||||
|
||||
let x = parent_x + angle.cos() * radius;
|
||||
let y = parent_y + angle.sin() * radius;
|
||||
|
||||
placed_nodes.push(PlacedNode {
|
||||
id: node.id.clone(),
|
||||
label: node.label.clone(),
|
||||
node_type: node.node_type,
|
||||
x,
|
||||
y,
|
||||
width: node_w,
|
||||
height: node_h,
|
||||
section: node.section,
|
||||
is_root: false,
|
||||
});
|
||||
|
||||
placed_edges.push(PlacedEdge {
|
||||
from_x: parent_x,
|
||||
from_y: parent_y,
|
||||
to_x: x,
|
||||
to_y: y,
|
||||
section: node.section,
|
||||
depth,
|
||||
});
|
||||
|
||||
let n_children = node.children.len();
|
||||
if n_children == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let weights: Vec<f64> = node.children.iter().map(subtree_weight).collect();
|
||||
let total_weight: f64 = weights.iter().sum();
|
||||
|
||||
// Fan out children around the parent→child direction
|
||||
let fan_spread = std::f64::consts::FRAC_PI_2.min(0.8 * (n_children as f64).sqrt());
|
||||
let child_radius = 100.0 + (depth as f64) * 10.0;
|
||||
|
||||
let mut current_angle = angle - fan_spread / 2.0;
|
||||
|
||||
for (i, child) in node.children.iter().enumerate() {
|
||||
let weight_fraction = weights[i] / total_weight;
|
||||
let sweep = fan_spread * weight_fraction;
|
||||
let mid_angle = current_angle + sweep / 2.0;
|
||||
|
||||
layout_subtree(
|
||||
child,
|
||||
x,
|
||||
y,
|
||||
mid_angle,
|
||||
child_radius,
|
||||
depth + 1,
|
||||
placed_nodes,
|
||||
placed_edges,
|
||||
);
|
||||
|
||||
current_angle += sweep;
|
||||
}
|
||||
}
|
||||
|
||||
// --- SVG Rendering ---
|
||||
|
||||
pub fn render_mindmap_to_svg(input: &str, _theme: &MermaidTheme) -> Result<String, MermaidError> {
|
||||
let mut root = parse_mindmap_tree(input)?;
|
||||
assign_sections(&mut root, None);
|
||||
|
||||
let (placed_nodes, placed_edges) = layout_mindmap(&root);
|
||||
|
||||
let max_x = placed_nodes
|
||||
.iter()
|
||||
.map(|n| n.x + n.width / 2.0)
|
||||
.fold(0.0_f64, f64::max);
|
||||
let max_y = placed_nodes
|
||||
.iter()
|
||||
.map(|n| n.y + n.height / 2.0)
|
||||
.fold(0.0_f64, f64::max);
|
||||
|
||||
let padding = 20.0;
|
||||
let svg_width = max_x + padding;
|
||||
let svg_height = max_y + padding;
|
||||
|
||||
let mut svg = String::new();
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
|
||||
width=\"{}\" height=\"{}\" \
|
||||
viewBox=\"0 0 {} {}\" \
|
||||
aria-roledescription=\"mindmap\" \
|
||||
role=\"graphics-document document\" \
|
||||
style=\"max-width: 100%;\">",
|
||||
svg_width, svg_height, svg_width, svg_height,
|
||||
));
|
||||
|
||||
// Render edges first (behind nodes)
|
||||
for edge in &placed_edges {
|
||||
render_edge(&mut svg, edge);
|
||||
}
|
||||
|
||||
// Render nodes
|
||||
for node in &placed_nodes {
|
||||
render_node(&mut svg, node);
|
||||
}
|
||||
|
||||
svg.push_str("</svg>");
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
fn render_edge(svg: &mut String, edge: &PlacedEdge) {
|
||||
let color = match edge.section {
|
||||
Some(s) => section_color(s).edge,
|
||||
None => "#333333",
|
||||
};
|
||||
|
||||
// Stroke width based on depth: 17 - 3*depth, minimum 2
|
||||
let stroke_width = (17.0 - 3.0 * edge.depth as f64).max(2.0);
|
||||
|
||||
// Curved edge using quadratic bezier
|
||||
let mx = (edge.from_x + edge.to_x) / 2.0;
|
||||
let my = (edge.from_y + edge.to_y) / 2.0;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<path d=\"M {:.1},{:.1} Q {:.1},{:.1} {:.1},{:.1}\" \
|
||||
fill=\"none\" stroke=\"{}\" stroke-width=\"{:.1}\" \
|
||||
stroke-linecap=\"round\" />",
|
||||
edge.from_x, edge.from_y, mx, my, edge.to_x, edge.to_y, color, stroke_width,
|
||||
));
|
||||
}
|
||||
|
||||
fn render_node(svg: &mut String, node: &PlacedNode) {
|
||||
let (fill, text_color) = if node.is_root {
|
||||
(ROOT_FILL.to_string(), ROOT_TEXT.to_string())
|
||||
} else {
|
||||
match node.section {
|
||||
Some(s) => {
|
||||
let sc = section_color(s);
|
||||
(sc.fill.to_string(), sc.text.to_string())
|
||||
}
|
||||
None => ("#ECECFF".to_string(), "#333333".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let cx = node.x;
|
||||
let cy = node.y;
|
||||
|
||||
match node.node_type {
|
||||
MindmapNodeType::Circle => {
|
||||
let r = node.width / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{:.1}\" \
|
||||
fill=\"{}\" stroke=\"none\" />",
|
||||
cx, cy, r, fill,
|
||||
));
|
||||
}
|
||||
MindmapNodeType::Rect => {
|
||||
let x = cx - node.width / 2.0;
|
||||
let y = cy - node.height / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" \
|
||||
rx=\"0\" ry=\"0\" fill=\"{}\" stroke=\"none\" />",
|
||||
x, y, node.width, node.height, fill,
|
||||
));
|
||||
}
|
||||
MindmapNodeType::RoundedRect | MindmapNodeType::Default => {
|
||||
let x = cx - node.width / 2.0;
|
||||
let y = cy - node.height / 2.0;
|
||||
// Corner radius = 5 matching Mermaid reference SVG path data
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" \
|
||||
rx=\"5\" ry=\"5\" fill=\"{}\" stroke=\"none\" />",
|
||||
x, y, node.width, node.height, fill,
|
||||
));
|
||||
}
|
||||
MindmapNodeType::Hexagon => {
|
||||
let x = cx - node.width / 2.0;
|
||||
let y = cy - node.height / 2.0;
|
||||
let inset = node.height / 4.0;
|
||||
let points = format!(
|
||||
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||
x + inset,
|
||||
y,
|
||||
x + node.width - inset,
|
||||
y,
|
||||
x + node.width,
|
||||
cy,
|
||||
x + node.width - inset,
|
||||
y + node.height,
|
||||
x + inset,
|
||||
y + node.height,
|
||||
x,
|
||||
cy,
|
||||
);
|
||||
svg.push_str(&format!(
|
||||
"<polygon points=\"{}\" fill=\"{}\" stroke=\"none\" />",
|
||||
points, fill,
|
||||
));
|
||||
}
|
||||
MindmapNodeType::Cloud | MindmapNodeType::Bang => {
|
||||
let rx = node.width / 2.0;
|
||||
let ry = node.height / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<ellipse cx=\"{:.1}\" cy=\"{:.1}\" rx=\"{:.1}\" ry=\"{:.1}\" \
|
||||
fill=\"{}\" stroke=\"none\" />",
|
||||
cx, cy, rx, ry, fill,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Render underline decoration (non-root nodes get a colored line below)
|
||||
if !node.is_root {
|
||||
match node.node_type {
|
||||
MindmapNodeType::Circle => {}
|
||||
_ => {
|
||||
let x1 = cx - node.width / 2.0;
|
||||
let x2 = cx + node.width / 2.0;
|
||||
let line_y = cy + node.height / 2.0 + 5.0;
|
||||
// Underline uses the complementary/inverted hue color from CSS
|
||||
// (section-N line stroke in reference is the hue+180 version)
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" \
|
||||
stroke=\"{}\" stroke-width=\"3\" />",
|
||||
x1, line_y, x2, line_y, fill,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render text
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{:.1}\" y=\"{:.1}\" \
|
||||
text-anchor=\"middle\" dominant-baseline=\"central\" \
|
||||
font-family=\"'trebuchet ms', verdana, arial, sans-serif\" \
|
||||
font-size=\"{}\" fill=\"{}\">{}</text>",
|
||||
cx,
|
||||
cy,
|
||||
FONT_SIZE,
|
||||
text_color,
|
||||
html_escape(&node.label),
|
||||
));
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
const DEFAULT_ROW_HEIGHT: f64 = 32.0;
|
||||
const DEFAULT_BIT_WIDTH: f64 = 32.0;
|
||||
const DEFAULT_BITS_PER_ROW: u32 = 32;
|
||||
const DEFAULT_SHOW_BITS: bool = true;
|
||||
const DEFAULT_PADDING_X: f64 = 5.0;
|
||||
const DEFAULT_PADDING_Y: f64 = 5.0;
|
||||
|
||||
pub fn render_packet_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
_theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let diagram = parse_packet_diagram(mermaid_source)?;
|
||||
|
||||
let padding_y = DEFAULT_PADDING_Y + if DEFAULT_SHOW_BITS { 10.0 } else { 0.0 };
|
||||
let total_row_height = DEFAULT_ROW_HEIGHT + padding_y;
|
||||
|
||||
let svg_width = DEFAULT_BIT_WIDTH * (DEFAULT_BITS_PER_ROW as f64) + 2.0;
|
||||
let svg_height = total_row_height * ((diagram.rows.len() + 1) as f64)
|
||||
- if diagram.title.is_some() {
|
||||
0.0
|
||||
} else {
|
||||
DEFAULT_ROW_HEIGHT
|
||||
};
|
||||
|
||||
let mut svg = String::new();
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {svg_width} {svg_height}\">"
|
||||
));
|
||||
svg.push_str(
|
||||
"<style>\
|
||||
.packetByte{font-size:10px;}\
|
||||
.packetByte.start{fill:black;}\
|
||||
.packetByte.end{fill:black;}\
|
||||
.packetLabel{fill:black;font-size:12px;}\
|
||||
.packetTitle{fill:black;font-size:14px;}\
|
||||
.packetBlock{stroke:black;stroke-width:1;fill:#efefef;}\
|
||||
</style>",
|
||||
);
|
||||
|
||||
svg.push_str("<g>");
|
||||
for (row_idx, row) in diagram.rows.iter().enumerate() {
|
||||
let word_y = row_idx as f64 * total_row_height + padding_y;
|
||||
|
||||
for block in row {
|
||||
let block_x = 1.0 + (block.start % DEFAULT_BITS_PER_ROW) as f64 * DEFAULT_BIT_WIDTH;
|
||||
let width =
|
||||
(block.end - block.start + 1) as f64 * DEFAULT_BIT_WIDTH - DEFAULT_PADDING_X;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<rect class=\"packetBlock\" x=\"{block_x}\" y=\"{word_y}\" width=\"{width}\" height=\"{DEFAULT_ROW_HEIGHT}\"/>"
|
||||
));
|
||||
|
||||
let label_x = block_x + width / 2.0;
|
||||
let label_y = word_y + DEFAULT_ROW_HEIGHT / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<text class=\"packetLabel\" x=\"{label_x}\" y=\"{label_y}\" text-anchor=\"middle\" dominant-baseline=\"middle\">{}</text>",
|
||||
escape_xml(&block.label)
|
||||
));
|
||||
|
||||
if DEFAULT_SHOW_BITS {
|
||||
let bit_y = word_y - 2.0;
|
||||
if block.start == block.end {
|
||||
svg.push_str(&format!(
|
||||
"<text class=\"packetByte start\" x=\"{label_x}\" y=\"{bit_y}\" text-anchor=\"middle\" dominant-baseline=\"auto\">{}</text>",
|
||||
block.start
|
||||
));
|
||||
} else {
|
||||
let end_x = block_x + width;
|
||||
svg.push_str(&format!(
|
||||
"<text class=\"packetByte start\" x=\"{block_x}\" y=\"{bit_y}\" text-anchor=\"start\" dominant-baseline=\"auto\">{}</text>",
|
||||
block.start
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<text class=\"packetByte end\" x=\"{end_x}\" y=\"{bit_y}\" text-anchor=\"end\" dominant-baseline=\"auto\">{}</text>",
|
||||
block.end
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
let title_x = svg_width / 2.0;
|
||||
let title_y = svg_height - total_row_height / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<text class=\"packetTitle\" x=\"{title_x}\" y=\"{title_y}\" text-anchor=\"middle\" dominant-baseline=\"middle\">{}</text>",
|
||||
diagram
|
||||
.title
|
||||
.as_deref()
|
||||
.map(escape_xml)
|
||||
.unwrap_or_default()
|
||||
));
|
||||
|
||||
svg.push_str("</svg>");
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PacketDiagram {
|
||||
title: Option<String>,
|
||||
rows: Vec<Vec<PacketBlock>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PacketBlock {
|
||||
start: u32,
|
||||
end: u32,
|
||||
label: String,
|
||||
}
|
||||
|
||||
fn parse_packet_diagram(input: &str) -> Result<PacketDiagram, MermaidError> {
|
||||
let lines = input.lines().enumerate();
|
||||
|
||||
let mut found_header = false;
|
||||
let mut title: Option<String> = None;
|
||||
let mut blocks: Vec<PacketBlock> = Vec::new();
|
||||
|
||||
for (idx, raw) in lines {
|
||||
let line_no = idx + 1;
|
||||
let line = raw.trim();
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
if line.split_whitespace().next() != Some("packet-beta") {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected 'packet-beta' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
found_header = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("title ") {
|
||||
let t = rest.trim();
|
||||
if !t.is_empty() {
|
||||
title = Some(t.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((range_raw, label_raw)) = line.split_once(':') else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid packet block: {line}"),
|
||||
});
|
||||
};
|
||||
|
||||
let (start, end) = parse_range(range_raw.trim(), line_no)?;
|
||||
let label = parse_label(label_raw.trim(), line_no)?;
|
||||
|
||||
blocks.push(PacketBlock { start, end, label });
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Expected 'packet-beta' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if blocks.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Packet diagram requires at least one block".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
ensure_contiguous(&blocks)?;
|
||||
let rows = split_into_rows(blocks, DEFAULT_BITS_PER_ROW);
|
||||
|
||||
Ok(PacketDiagram { title, rows })
|
||||
}
|
||||
|
||||
fn parse_range(s: &str, line: usize) -> Result<(u32, u32), MermaidError> {
|
||||
let s = s.trim();
|
||||
|
||||
if let Some((start_str, end_str)) = s.split_once('-') {
|
||||
let start: u32 = start_str
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid packet start: {start_str}"),
|
||||
})?;
|
||||
let end: u32 = end_str
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid packet end: {end_str}"),
|
||||
})?;
|
||||
|
||||
if end < start {
|
||||
return Err(MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Packet block {start}-{end} is invalid (end < start)"),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok((start, end));
|
||||
}
|
||||
|
||||
let start: u32 = s.parse().map_err(|_| MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid packet bit index: {s}"),
|
||||
})?;
|
||||
|
||||
Ok((start, start))
|
||||
}
|
||||
|
||||
fn parse_label(s: &str, line: usize) -> Result<String, MermaidError> {
|
||||
let s = s.trim();
|
||||
|
||||
if let Some(stripped) = s.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
|
||||
return Ok(stripped.to_string());
|
||||
}
|
||||
|
||||
if let Some(stripped) = s.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
|
||||
return Ok(stripped.to_string());
|
||||
}
|
||||
|
||||
if s.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line,
|
||||
message: "Packet block label cannot be empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
fn ensure_contiguous(blocks: &[PacketBlock]) -> Result<(), MermaidError> {
|
||||
let mut last: Option<u32> = None;
|
||||
|
||||
for block in blocks {
|
||||
if let Some(last_bit) = last {
|
||||
if block.start != last_bit + 1 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: format!(
|
||||
"Packet block {}-{} is not contiguous. It should start from {}.",
|
||||
block.start,
|
||||
block.end,
|
||||
last_bit + 1
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
last = Some(block.end);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_into_rows(blocks: Vec<PacketBlock>, bits_per_row: u32) -> Vec<Vec<PacketBlock>> {
|
||||
let mut rows: Vec<Vec<PacketBlock>> = Vec::new();
|
||||
let mut word: Vec<PacketBlock> = Vec::new();
|
||||
let mut row = 1_u32;
|
||||
|
||||
for block in blocks {
|
||||
let mut cur = block;
|
||||
|
||||
loop {
|
||||
let (fitting, remainder) = split_block_at_row_boundary(&cur, row, bits_per_row);
|
||||
word.push(fitting);
|
||||
|
||||
if word
|
||||
.last()
|
||||
.is_some_and(|b| b.end.saturating_add(1) == row.saturating_mul(bits_per_row))
|
||||
{
|
||||
rows.push(std::mem::take(&mut word));
|
||||
row = row.saturating_add(1);
|
||||
}
|
||||
|
||||
let Some(next) = remainder else {
|
||||
break;
|
||||
};
|
||||
|
||||
cur = next;
|
||||
}
|
||||
}
|
||||
|
||||
if !word.is_empty() {
|
||||
rows.push(word);
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
fn split_block_at_row_boundary(
|
||||
block: &PacketBlock,
|
||||
row: u32,
|
||||
bits_per_row: u32,
|
||||
) -> (PacketBlock, Option<PacketBlock>) {
|
||||
let row_end_exclusive = row.saturating_mul(bits_per_row);
|
||||
|
||||
if block.end.saturating_add(1) <= row_end_exclusive {
|
||||
return (block.clone(), None);
|
||||
}
|
||||
|
||||
let first = PacketBlock {
|
||||
start: block.start,
|
||||
end: row_end_exclusive.saturating_sub(1),
|
||||
label: block.label.clone(),
|
||||
};
|
||||
|
||||
let second = PacketBlock {
|
||||
start: row_end_exclusive,
|
||||
end: block.end,
|
||||
label: block.label.clone(),
|
||||
};
|
||||
|
||||
(first, Some(second))
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+659
@@ -0,0 +1,659 @@
|
||||
use crate::ast::{
|
||||
Edge, EdgeStyle, FlowchartGraph, GraphDirection, Node, NodeShape, Statement, StyleStatement,
|
||||
Subgraph,
|
||||
};
|
||||
use crate::error::MermaidError;
|
||||
|
||||
pub fn parse_mermaid(input: &str) -> Result<FlowchartGraph, MermaidError> {
|
||||
if let Some(first_line) = first_non_empty_non_comment_line(input) {
|
||||
let first_token = first_line.split_whitespace().next().unwrap_or("");
|
||||
|
||||
if first_token != "graph"
|
||||
&& first_token != "flowchart"
|
||||
&& is_known_mermaid_type(first_token)
|
||||
{
|
||||
return Err(MermaidError::UnsupportedDiagramType(
|
||||
first_token.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut parser = Parser::new(input);
|
||||
parser.parse()
|
||||
}
|
||||
|
||||
fn normalize_label(label: &str) -> String {
|
||||
let label = strip_wrapping_quotes(label.trim());
|
||||
decode_html_entities(label)
|
||||
.replace("\\n", "\n")
|
||||
.replace("<br/>", "\n")
|
||||
.replace("<br />", "\n")
|
||||
.replace("<br>", "\n")
|
||||
.replace("<BR/>", "\n")
|
||||
.replace("<BR />", "\n")
|
||||
.replace("<BR>", "\n")
|
||||
}
|
||||
|
||||
fn decode_html_entities(label: &str) -> String {
|
||||
label
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
fn strip_wrapping_quotes(label: &str) -> &str {
|
||||
let bytes = label.as_bytes();
|
||||
if bytes.len() >= 2
|
||||
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|
||||
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
|
||||
{
|
||||
&label[1..label.len() - 1]
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
fn first_non_empty_non_comment_line(input: &str) -> Option<&str> {
|
||||
input
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|l| !l.is_empty() && !l.starts_with("%%"))
|
||||
}
|
||||
|
||||
fn is_known_mermaid_type(token: &str) -> bool {
|
||||
matches!(
|
||||
token,
|
||||
"sequenceDiagram"
|
||||
| "classDiagram"
|
||||
| "classDiagram-v2"
|
||||
| "stateDiagram"
|
||||
| "stateDiagram-v2"
|
||||
| "erDiagram"
|
||||
| "journey"
|
||||
| "gantt"
|
||||
| "pie"
|
||||
| "mindmap"
|
||||
| "timeline"
|
||||
| "info"
|
||||
| "kanban"
|
||||
| "gitGraph"
|
||||
| "requirementDiagram"
|
||||
| "C4Context"
|
||||
| "C4Container"
|
||||
| "C4Component"
|
||||
| "C4Dynamic"
|
||||
| "C4Deployment"
|
||||
| "sankey-beta"
|
||||
| "packet-beta"
|
||||
| "xychart-beta"
|
||||
| "radar-beta"
|
||||
| "block-beta"
|
||||
| "flowchart-elk"
|
||||
| "quadrantChart"
|
||||
)
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
lines: Vec<&'a str>,
|
||||
current_line: usize,
|
||||
next_subgraph_index: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn new(input: &'a str) -> Self {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
Self {
|
||||
lines,
|
||||
current_line: 0,
|
||||
next_subgraph_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(&mut self) -> Result<FlowchartGraph, MermaidError> {
|
||||
let direction = self.parse_graph_declaration()?;
|
||||
let statements = self.parse_statements()?;
|
||||
|
||||
Ok(FlowchartGraph {
|
||||
direction,
|
||||
statements,
|
||||
})
|
||||
}
|
||||
|
||||
fn current_line_content(&self) -> Option<&'a str> {
|
||||
self.lines.get(self.current_line).map(|s| s.trim())
|
||||
}
|
||||
|
||||
fn advance(&mut self) {
|
||||
self.current_line += 1;
|
||||
}
|
||||
|
||||
fn skip_empty_lines(&mut self) {
|
||||
while let Some(line) = self.current_line_content() {
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
self.advance();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_graph_declaration(&mut self) -> Result<GraphDirection, MermaidError> {
|
||||
self.skip_empty_lines();
|
||||
|
||||
let line = self
|
||||
.current_line_content()
|
||||
.ok_or_else(|| MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected graph declaration".to_string(),
|
||||
})?;
|
||||
|
||||
let direction = if line.starts_with("graph ") || line.starts_with("flowchart ") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected direction after 'graph' or 'flowchart'".to_string(),
|
||||
});
|
||||
}
|
||||
self.parse_direction(parts[1])?
|
||||
} else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected 'graph' or 'flowchart' declaration".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
self.advance();
|
||||
Ok(direction)
|
||||
}
|
||||
|
||||
fn parse_direction(&self, dir: &str) -> Result<GraphDirection, MermaidError> {
|
||||
match dir.to_uppercase().as_str() {
|
||||
"TD" | "TB" => Ok(GraphDirection::TopToBottom),
|
||||
"BT" => Ok(GraphDirection::BottomToTop),
|
||||
"LR" => Ok(GraphDirection::LeftToRight),
|
||||
"RL" => Ok(GraphDirection::RightToLeft),
|
||||
_ => Err(MermaidError::InvalidDirection(dir.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_statements(&mut self) -> Result<Vec<Statement>, MermaidError> {
|
||||
let mut statements = Vec::new();
|
||||
|
||||
while self.current_line_content().is_some() {
|
||||
self.skip_empty_lines();
|
||||
|
||||
let Some(line) = self.current_line_content() else {
|
||||
break;
|
||||
};
|
||||
|
||||
if line.is_empty() {
|
||||
self.advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if line == "end" {
|
||||
break;
|
||||
}
|
||||
|
||||
if line.starts_with("subgraph ") {
|
||||
statements.push(Statement::Subgraph(self.parse_subgraph()?));
|
||||
} else if line.starts_with("style ") {
|
||||
statements.push(Statement::Style(self.parse_style()?));
|
||||
} else if self.line_contains_edge(line) {
|
||||
let edge_statements = self.parse_edge_chain(line)?;
|
||||
statements.extend(edge_statements);
|
||||
self.advance();
|
||||
} else {
|
||||
if let Some(node) = self.try_parse_node(line) {
|
||||
statements.push(Statement::Node(node));
|
||||
}
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(statements)
|
||||
}
|
||||
|
||||
fn line_contains_edge(&self, line: &str) -> bool {
|
||||
self.find_edge_start(line).is_some()
|
||||
}
|
||||
|
||||
fn parse_edge_chain(&mut self, line: &str) -> Result<Vec<Statement>, MermaidError> {
|
||||
let mut statements = Vec::new();
|
||||
let mut remaining = line.trim();
|
||||
let mut collected_nodes: Vec<(String, Option<Node>)> = Vec::new();
|
||||
|
||||
let first_node_end = self.find_edge_start(remaining).unwrap_or(remaining.len());
|
||||
let first_node_str = remaining[..first_node_end].trim();
|
||||
if let Some(node) = self.try_parse_node(first_node_str) {
|
||||
collected_nodes.push((node.id.clone(), Some(node)));
|
||||
} else {
|
||||
let id = self.extract_node_id(first_node_str);
|
||||
collected_nodes.push((id, None));
|
||||
}
|
||||
remaining = &remaining[first_node_end..];
|
||||
|
||||
while !remaining.is_empty() {
|
||||
let (edge_style, label, edge_len) = self.parse_edge_syntax(remaining)?;
|
||||
remaining = remaining[edge_len..].trim_start();
|
||||
|
||||
let next_node_end = self.find_edge_start(remaining).unwrap_or(remaining.len());
|
||||
let next_node_str = remaining[..next_node_end].trim();
|
||||
|
||||
if next_node_str.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let (next_id, next_node) = if let Some(node) = self.try_parse_node(next_node_str) {
|
||||
(node.id.clone(), Some(node))
|
||||
} else {
|
||||
let id = self.extract_node_id(next_node_str);
|
||||
(id, None)
|
||||
};
|
||||
|
||||
if let Some((from_id, _)) = collected_nodes.last() {
|
||||
statements.push(Statement::Edge(Edge {
|
||||
from: from_id.clone(),
|
||||
to: next_id.clone(),
|
||||
label,
|
||||
style: edge_style,
|
||||
}));
|
||||
}
|
||||
|
||||
collected_nodes.push((next_id, next_node));
|
||||
remaining = &remaining[next_node_end..];
|
||||
}
|
||||
|
||||
let mut node_statements: Vec<Statement> = collected_nodes
|
||||
.into_iter()
|
||||
.filter_map(|(_, node_opt)| node_opt.map(Statement::Node))
|
||||
.collect();
|
||||
node_statements.append(&mut statements);
|
||||
statements = node_statements;
|
||||
|
||||
Ok(statements)
|
||||
}
|
||||
|
||||
/// Byte index where the first edge token starts, ignoring tokens inside
|
||||
/// bracket/quote-delimited node labels (`[..]`, `(..)`, `{..}`, `".."`).
|
||||
fn find_edge_start(&self, s: &str) -> Option<usize> {
|
||||
const PATTERNS: [&str; 9] = ["-.->", "-.-", "-->", "---", "==>", "===", "--", "==", "-."];
|
||||
let bytes = s.as_bytes();
|
||||
let mut depth: usize = 0;
|
||||
let mut in_quote = false;
|
||||
for i in 0..bytes.len() {
|
||||
let b = bytes[i];
|
||||
if in_quote {
|
||||
if b == b'"' {
|
||||
in_quote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'"' => in_quote = true,
|
||||
b'[' | b'(' | b'{' => depth += 1,
|
||||
b']' | b')' | b'}' => depth = depth.saturating_sub(1),
|
||||
_ if depth == 0 => {
|
||||
if PATTERNS
|
||||
.iter()
|
||||
.any(|p| bytes[i..].starts_with(p.as_bytes()))
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_edge_syntax(
|
||||
&self,
|
||||
s: &str,
|
||||
) -> Result<(EdgeStyle, Option<String>, usize), MermaidError> {
|
||||
let s = s.trim_start();
|
||||
|
||||
let edge_patterns: &[(&str, EdgeStyle, &str)] = &[
|
||||
("-->|", EdgeStyle::Arrow, "|"),
|
||||
("---|", EdgeStyle::Line, "|"),
|
||||
("-.->|", EdgeStyle::DottedArrow, "|"),
|
||||
("-.-|", EdgeStyle::DottedLine, "|"),
|
||||
("==>|", EdgeStyle::ThickArrow, "|"),
|
||||
("===|", EdgeStyle::ThickLine, "|"),
|
||||
("-->", EdgeStyle::Arrow, ""),
|
||||
("---", EdgeStyle::Line, ""),
|
||||
("-.->", EdgeStyle::DottedArrow, ""),
|
||||
("-.-", EdgeStyle::DottedLine, ""),
|
||||
("==>", EdgeStyle::ThickArrow, ""),
|
||||
("===", EdgeStyle::ThickLine, ""),
|
||||
];
|
||||
|
||||
for (pattern, style, label_end) in edge_patterns {
|
||||
if let Some(after_pattern) = s.strip_prefix(pattern) {
|
||||
if !label_end.is_empty() {
|
||||
if let Some(end_idx) = after_pattern.find(label_end) {
|
||||
let label = normalize_label(&after_pattern[..end_idx]);
|
||||
let total_len = pattern.len() + end_idx + label_end.len();
|
||||
return Ok((*style, Some(label), total_len));
|
||||
}
|
||||
} else {
|
||||
return Ok((*style, None, pattern.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open-label forms: `-- text -->`, `-- text ---`, `== text ==>`,
|
||||
// `== text ===`, `-. text .->`, `-. text .-`.
|
||||
let open_patterns: &[(&str, &[(&str, EdgeStyle)])] = &[
|
||||
("--", &[("-->", EdgeStyle::Arrow), ("---", EdgeStyle::Line)]),
|
||||
(
|
||||
"==",
|
||||
&[
|
||||
("==>", EdgeStyle::ThickArrow),
|
||||
("===", EdgeStyle::ThickLine),
|
||||
],
|
||||
),
|
||||
(
|
||||
"-.",
|
||||
&[
|
||||
(".->", EdgeStyle::DottedArrow),
|
||||
(".-", EdgeStyle::DottedLine),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (opener, closers) in open_patterns {
|
||||
let Some(after) = s.strip_prefix(opener) else {
|
||||
continue;
|
||||
};
|
||||
let mut best: Option<(usize, &str, EdgeStyle)> = None;
|
||||
for (closer, style) in *closers {
|
||||
if let Some(idx) = after.find(closer) {
|
||||
let better = match best {
|
||||
Some((best_idx, best_closer, _)) => {
|
||||
idx < best_idx || (idx == best_idx && closer.len() > best_closer.len())
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if better {
|
||||
best = Some((idx, closer, *style));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((idx, closer, style)) = best {
|
||||
let label = normalize_label(&after[..idx]);
|
||||
let total_len = opener.len() + idx + closer.len();
|
||||
return Ok((style, Some(label), total_len));
|
||||
}
|
||||
}
|
||||
|
||||
Err(MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: format!("Invalid edge syntax: {}", s),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_node_id(&self, s: &str) -> String {
|
||||
let s = s.trim();
|
||||
for (open, _close) in [('[', ']'), ('(', ')'), ('{', '}'), ('<', '>')] {
|
||||
if let Some(idx) = s.find(open) {
|
||||
return s[..idx].trim().to_string();
|
||||
}
|
||||
}
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
fn try_parse_node(&self, s: &str) -> Option<Node> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(paren_paren_start) = s.find("((") {
|
||||
if s.ends_with("))") {
|
||||
let id = s[..paren_paren_start].trim().to_string();
|
||||
let label = normalize_label(&s[paren_paren_start + 2..s.len() - 2]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Circle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bracket_paren_start) = s.find("([") {
|
||||
if s.ends_with("])") {
|
||||
let id = s[..bracket_paren_start].trim().to_string();
|
||||
let label = normalize_label(&s[bracket_paren_start + 2..s.len() - 2]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Stadium,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(paren_bracket_start) = s.find("[(") {
|
||||
if s.ends_with(")]") {
|
||||
let id = s[..paren_bracket_start].trim().to_string();
|
||||
let label = normalize_label(&s[paren_bracket_start + 2..s.len() - 2]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Cylinder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bracket_bracket_start) = s.find("[[") {
|
||||
if s.ends_with("]]") {
|
||||
let id = s[..bracket_bracket_start].trim().to_string();
|
||||
let label = normalize_label(&s[bracket_bracket_start + 2..s.len() - 2]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Subroutine,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(brace_brace_start) = s.find("{{") {
|
||||
if s.ends_with("}}") {
|
||||
let id = s[..brace_brace_start].trim().to_string();
|
||||
let label = normalize_label(&s[brace_brace_start + 2..s.len() - 2]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Hexagon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bracket_start) = s.find('[') {
|
||||
if s.ends_with(']') {
|
||||
let id = s[..bracket_start].trim().to_string();
|
||||
let label = normalize_label(&s[bracket_start + 1..s.len() - 1]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Rectangle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(paren_start) = s.find('(') {
|
||||
if s.ends_with(')') && !s.ends_with("))") {
|
||||
let id = s[..paren_start].trim().to_string();
|
||||
let label = normalize_label(&s[paren_start + 1..s.len() - 1]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::RoundedRectangle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(brace_start) = s.find('{') {
|
||||
if s.ends_with('}') && !s.ends_with("}}") {
|
||||
let id = s[..brace_start].trim().to_string();
|
||||
let label = normalize_label(&s[brace_start + 1..s.len() - 1]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Diamond,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if s.contains('>') && s.ends_with(']') {
|
||||
if let Some(gt_idx) = s.find('>') {
|
||||
let id = s[..gt_idx].trim().to_string();
|
||||
let label = normalize_label(&s[gt_idx + 1..s.len() - 1]);
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Asymmetric,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if s.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||
return Some(Node {
|
||||
id: s.to_string(),
|
||||
label: None,
|
||||
shape: NodeShape::Rectangle,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_subgraph(&mut self) -> Result<Subgraph, MermaidError> {
|
||||
let line = self
|
||||
.current_line_content()
|
||||
.ok_or_else(|| MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected subgraph".to_string(),
|
||||
})?;
|
||||
|
||||
let after_keyword = line.strip_prefix("subgraph ").unwrap_or("").trim();
|
||||
|
||||
let (id, title) = if let Some(bracket_start) = after_keyword.find('[') {
|
||||
if after_keyword.ends_with(']') {
|
||||
let id = after_keyword[..bracket_start].trim().to_string();
|
||||
let title =
|
||||
normalize_label(&after_keyword[bracket_start + 1..after_keyword.len() - 1]);
|
||||
(id, Some(title))
|
||||
} else {
|
||||
(after_keyword.to_string(), None)
|
||||
}
|
||||
} else if after_keyword.split_whitespace().count() > 1 {
|
||||
let id = format!("subGraph{}", self.next_subgraph_index);
|
||||
self.next_subgraph_index += 1;
|
||||
(id, Some(normalize_label(after_keyword)))
|
||||
} else {
|
||||
let id = after_keyword.to_string();
|
||||
(id, None)
|
||||
};
|
||||
|
||||
self.advance();
|
||||
|
||||
let statements = self.parse_statements()?;
|
||||
|
||||
if self.current_line_content() == Some("end") {
|
||||
self.advance();
|
||||
}
|
||||
|
||||
Ok(Subgraph {
|
||||
id,
|
||||
title,
|
||||
statements,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_style(&mut self) -> Result<StyleStatement, MermaidError> {
|
||||
let line = self
|
||||
.current_line_content()
|
||||
.ok_or_else(|| MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected style statement".to_string(),
|
||||
})?;
|
||||
|
||||
let after_keyword = line.strip_prefix("style ").unwrap_or("").trim();
|
||||
let parts: Vec<&str> = after_keyword.splitn(2, ' ').collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected node id after 'style'".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let node_id = parts[0].to_string();
|
||||
let properties = if parts.len() > 1 {
|
||||
parts[1]
|
||||
.split(',')
|
||||
.filter_map(|prop| {
|
||||
let kv: Vec<&str> = prop.splitn(2, ':').collect();
|
||||
if kv.len() == 2 {
|
||||
Some((kv[0].trim().to_string(), kv[1].trim().to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
self.advance();
|
||||
|
||||
Ok(StyleStatement {
|
||||
node_id,
|
||||
properties,
|
||||
})
|
||||
}
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Mermaid 11.12.2 default pie chart colors (from the default theme).
|
||||
/// pie1 = primaryColor (#ECECFF), pie2 = secondaryColor (#ffffde),
|
||||
/// pie3–pie12 computed via adjust/darken on primary/secondary/tertiary.
|
||||
const MERMAID_PIE_COLORS: [&str; 12] = [
|
||||
"#ECECFF", // pie1 – primaryColor
|
||||
"#ffffde", // pie2 – secondaryColor
|
||||
"hsl(80, 100%, 56.2745098039%)", // pie3 – adjust(tertiaryColor, l:-40)
|
||||
"hsl(240, 60%, 86.2745098039%)", // pie4 – adjust(primaryColor, l:-10)
|
||||
"hsl(120, 100%, 66.2745098039%)", // pie5 – adjust(secondaryColor, l:-30)
|
||||
"hsl(80, 100%, 76.2745098039%)", // pie6 – adjust(tertiaryColor, l:-20)
|
||||
"hsl(300, 60%, 76.2745098039%)", // pie7 – adjust(primaryColor, h:60, l:-20)
|
||||
"hsl(180, 60%, 56.2745098039%)", // pie8 – adjust(primaryColor, h:-60, l:-40)
|
||||
"hsl(0, 60%, 56.2745098039%)", // pie9 – adjust(primaryColor, h:120, l:-40)
|
||||
"hsl(300, 60%, 56.2745098039%)", // pie10 – adjust(primaryColor, h:60, l:-40)
|
||||
"hsl(150, 60%, 56.2745098039%)", // pie11 – adjust(primaryColor, h:-90, l:-40)
|
||||
"hsl(0, 60%, 66.2745098039%)", // pie12 – adjust(primaryColor, h:120, l:-30)
|
||||
];
|
||||
|
||||
// Mermaid 11.12.2 pie chart constants (from pieRenderer.ts and default config).
|
||||
const PIE_HEIGHT: f64 = 450.0;
|
||||
const PIE_WIDTH: f64 = 450.0;
|
||||
const MARGIN: f64 = 40.0;
|
||||
const RADIUS: f64 = (PIE_WIDTH / 2.0) - MARGIN; // 185
|
||||
const OUTER_STROKE_WIDTH: f64 = 2.0;
|
||||
const OUTER_RADIUS: f64 = RADIUS + OUTER_STROKE_WIDTH / 2.0; // 186
|
||||
const TEXT_POSITION: f64 = 0.75;
|
||||
const LEGEND_RECT_SIZE: f64 = 18.0;
|
||||
const LEGEND_SPACING: f64 = 4.0;
|
||||
|
||||
const FONT_FAMILY: &str = "trebuchet ms,verdana,arial,sans-serif";
|
||||
|
||||
pub fn render_pie_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
_theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let chart = parse_pie_diagram(mermaid_source)?;
|
||||
|
||||
let total: f64 = chart.slices.iter().map(|s| s.value).sum();
|
||||
if total <= 0.0 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Pie diagram total must be > 0".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Filter slices ≥1% and sort descending by value (matches d3.pie() default).
|
||||
let mut slices: Vec<&PieSlice> = chart
|
||||
.slices
|
||||
.iter()
|
||||
.filter(|s| s.value / total * 100.0 >= 1.0)
|
||||
.collect();
|
||||
slices.sort_by(|a, b| b.value.partial_cmp(&a.value).unwrap());
|
||||
|
||||
// All slices for the legend (unfiltered, original order).
|
||||
let all_slices: Vec<&PieSlice> = chart.slices.iter().collect();
|
||||
|
||||
// Center of the pie in the translated group coordinate system is (0, 0).
|
||||
let cx = PIE_WIDTH / 2.0;
|
||||
let cy = PIE_HEIGHT / 2.0;
|
||||
|
||||
// Estimate legend text width (rough: 10px per char at 17px font).
|
||||
let longest_label_len = all_slices
|
||||
.iter()
|
||||
.map(|s| {
|
||||
if chart.show_data {
|
||||
format!("{} [{}]", s.label, s.value).len()
|
||||
} else {
|
||||
s.label.len()
|
||||
}
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let legend_text_width = longest_label_len as f64 * 10.0;
|
||||
let total_width = PIE_WIDTH + MARGIN + LEGEND_RECT_SIZE + LEGEND_SPACING + legend_text_width;
|
||||
|
||||
let mut svg = String::new();
|
||||
|
||||
// Mermaid uses a CSS style block for pie chart classes.
|
||||
svg.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {total_width:.4} {PIE_HEIGHT}\" \
|
||||
style=\"max-width: {total_width:.3}px; background-color: white;\" \
|
||||
role=\"graphics-document document\" aria-roledescription=\"pie\">"
|
||||
));
|
||||
|
||||
// Inline CSS matching Mermaid 11.12.2 pieStyles.
|
||||
svg.push_str(&format!(
|
||||
"<style>\
|
||||
.pieCircle{{stroke:black;stroke-width:2px;opacity:0.7;}}\
|
||||
.pieOuterCircle{{stroke:black;stroke-width:2px;fill:none;}}\
|
||||
.pieTitleText{{text-anchor:middle;font-size:25px;fill:black;font-family:{FONT_FAMILY};}}\
|
||||
.slice{{font-family:{FONT_FAMILY};fill:#333;font-size:17px;}}\
|
||||
.legend text{{fill:black;font-family:{FONT_FAMILY};font-size:17px;}}\
|
||||
</style>"
|
||||
));
|
||||
|
||||
// Group translated to pie center (matches mermaid: translate(pieWidth/2, height/2)).
|
||||
svg.push_str(&format!("<g transform=\"translate({cx},{cy})\">"));
|
||||
|
||||
// Outer circle.
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"0\" cy=\"0\" r=\"{OUTER_RADIUS}\" class=\"pieOuterCircle\"/>"
|
||||
));
|
||||
|
||||
// Draw pie slices.
|
||||
// d3.pie() default: startAngle=0 (12 o'clock), endAngle=2π, clockwise.
|
||||
// In SVG with translate to center, angle 0 points up (-y).
|
||||
let mut angle = -PI / 2.0;
|
||||
for (idx, slice) in slices.iter().enumerate() {
|
||||
let pct = (slice.value / total * 100.0).round() as i64;
|
||||
if pct == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let frac = slice.value / total;
|
||||
let sweep = frac * 2.0 * PI;
|
||||
let next = angle + sweep;
|
||||
|
||||
let (x0, y0) = polar(0.0, 0.0, RADIUS, angle);
|
||||
let (x1, y1) = polar(0.0, 0.0, RADIUS, next);
|
||||
let large_arc = if sweep > PI { 1 } else { 0 };
|
||||
|
||||
let fill = MERMAID_PIE_COLORS[idx % MERMAID_PIE_COLORS.len()];
|
||||
|
||||
// Slice path.
|
||||
svg.push_str(&format!(
|
||||
"<path d=\"M0,0L{x0:.3},{y0:.3}A{RADIUS},{RADIUS},0,{large_arc},1,{x1:.3},{y1:.3}Z\" \
|
||||
fill=\"{fill}\" class=\"pieCircle\"/>"
|
||||
));
|
||||
|
||||
// Percentage label inside the slice at textPosition (0.75) of radius.
|
||||
let label_r = RADIUS * TEXT_POSITION;
|
||||
let mid = angle + sweep / 2.0;
|
||||
let (lx, ly) = polar(0.0, 0.0, label_r, mid);
|
||||
svg.push_str(&format!(
|
||||
"<text transform=\"translate({lx:.3},{ly:.3})\" class=\"slice\" \
|
||||
style=\"text-anchor: middle;\">{pct}%</text>"
|
||||
));
|
||||
|
||||
angle = next;
|
||||
}
|
||||
|
||||
// Title (positioned above the pie).
|
||||
if let Some(title) = &chart.title {
|
||||
let title_y = -((PIE_HEIGHT - 50.0) / 2.0);
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"0\" y=\"{title_y:.0}\" class=\"pieTitleText\">{}</text>",
|
||||
escape_xml(title)
|
||||
));
|
||||
}
|
||||
|
||||
// Legend (to the right of the pie).
|
||||
let legend_h = LEGEND_RECT_SIZE + LEGEND_SPACING;
|
||||
let legend_offset = legend_h * all_slices.len() as f64 / 2.0;
|
||||
let legend_x = 12.0 * LEGEND_RECT_SIZE; // 216
|
||||
|
||||
// Build a color map that assigns colors to labels in the same order as the
|
||||
// sorted/filtered slices (matching d3.scaleOrdinal behavior).
|
||||
let mut color_map: Vec<(&str, &str)> = Vec::new();
|
||||
for (idx, slice) in slices.iter().enumerate() {
|
||||
color_map.push((
|
||||
&slice.label,
|
||||
MERMAID_PIE_COLORS[idx % MERMAID_PIE_COLORS.len()],
|
||||
));
|
||||
}
|
||||
|
||||
for (legend_idx, slice) in all_slices.iter().enumerate() {
|
||||
let vert = legend_idx as f64 * legend_h - legend_offset;
|
||||
let color = color_map
|
||||
.iter()
|
||||
.find(|(label, _)| *label == slice.label)
|
||||
.map(|(_, c)| *c)
|
||||
.unwrap_or(MERMAID_PIE_COLORS[legend_idx % MERMAID_PIE_COLORS.len()]);
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"legend\" transform=\"translate({legend_x},{vert})\">"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<rect width=\"{LEGEND_RECT_SIZE}\" height=\"{LEGEND_RECT_SIZE}\" \
|
||||
style=\"fill: {color}; stroke: {color};\"/>"
|
||||
));
|
||||
|
||||
let label_text = if chart.show_data {
|
||||
format!("{} [{}]", slice.label, slice.value)
|
||||
} else {
|
||||
slice.label.clone()
|
||||
};
|
||||
let text_x = LEGEND_RECT_SIZE + LEGEND_SPACING;
|
||||
let text_y = LEGEND_RECT_SIZE - LEGEND_SPACING;
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{text_x}\" y=\"{text_y}\">{}</text>",
|
||||
escape_xml(&label_text)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
svg.push_str("</g>"); // close main group
|
||||
svg.push_str("</svg>");
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PieChart {
|
||||
title: Option<String>,
|
||||
show_data: bool,
|
||||
slices: Vec<PieSlice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PieSlice {
|
||||
label: String,
|
||||
value: f64,
|
||||
}
|
||||
|
||||
fn parse_pie_diagram(input: &str) -> Result<PieChart, MermaidError> {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
|
||||
let mut i = 0_usize;
|
||||
let mut show_data = false;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut tokens = line.split_whitespace();
|
||||
let first = tokens.next().unwrap_or("");
|
||||
if first == "pie" {
|
||||
show_data = tokens.any(|t| t == "showData");
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
return Err(MermaidError::ParseError {
|
||||
line: i + 1,
|
||||
message: "Expected 'pie' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut title: Option<String> = None;
|
||||
let mut slices: Vec<PieSlice> = Vec::new();
|
||||
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
let line = raw.trim();
|
||||
let line_no = i + 1;
|
||||
i += 1;
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("title ") {
|
||||
let t = rest.trim();
|
||||
if !t.is_empty() {
|
||||
title = Some(t.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((label_raw, value_raw)) = line.split_once(':') else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid pie slice: {line}"),
|
||||
});
|
||||
};
|
||||
|
||||
let mut label = label_raw.trim().to_string();
|
||||
if let Some(stripped) = label.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
|
||||
label = stripped.to_string();
|
||||
}
|
||||
|
||||
let value_str = value_raw.trim();
|
||||
let value: f64 = value_str.parse().map_err(|_| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid pie value: {value_str}"),
|
||||
})?;
|
||||
|
||||
slices.push(PieSlice { label, value });
|
||||
}
|
||||
|
||||
if slices.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Pie diagram requires at least one slice".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(PieChart {
|
||||
title,
|
||||
show_data,
|
||||
slices,
|
||||
})
|
||||
}
|
||||
|
||||
fn polar(cx: f64, cy: f64, r: f64, angle: f64) -> (f64, f64) {
|
||||
(cx + r * angle.cos(), cy + r * angle.sin())
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+540
@@ -0,0 +1,540 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Quadrant chart theme colors, derived from the mermaid.js default theme.
|
||||
/// Mermaid uses `primaryColor = "#ECECFF"` and derives fills by adjusting RGB channels.
|
||||
struct QuadrantTheme<'a> {
|
||||
quadrant1_fill: &'a str,
|
||||
quadrant2_fill: &'a str,
|
||||
quadrant3_fill: &'a str,
|
||||
quadrant4_fill: &'a str,
|
||||
border_stroke: &'a str,
|
||||
title_fill: &'a str,
|
||||
axis_text_fill: &'a str,
|
||||
point_fill: &'a str,
|
||||
point_text_fill: &'a str,
|
||||
quadrant_text_fill: &'a str,
|
||||
}
|
||||
|
||||
fn quadrant_theme_for(theme: &MermaidTheme) -> QuadrantTheme<'static> {
|
||||
let is_dark = theme.background.starts_with("#1") || theme.background.starts_with("#0");
|
||||
if is_dark {
|
||||
QuadrantTheme {
|
||||
quadrant1_fill: "#1f2020",
|
||||
quadrant2_fill: "#242525",
|
||||
quadrant3_fill: "#292a2a",
|
||||
quadrant4_fill: "#2e2f2f",
|
||||
border_stroke: "#e0dfdf",
|
||||
title_fill: "#ccc",
|
||||
axis_text_fill: "#ccc",
|
||||
point_fill: "#ccc",
|
||||
point_text_fill: "#ccc",
|
||||
quadrant_text_fill: "#ccc",
|
||||
}
|
||||
} else {
|
||||
// Default (light) theme: primaryColor = "#ECECFF"
|
||||
// quadrant fills = primaryColor + adjust({r: N, g: N, b: N}) for N in 0,5,10,15
|
||||
// border = mkBorder("#ECECFF", false) = adjust("#ECECFF", {s:-40, l:-10}) = #C7C7F1
|
||||
// point fill = darken("#ECECFF") ≈ #333333 (text color in practice)
|
||||
QuadrantTheme {
|
||||
quadrant1_fill: "#ECECFF",
|
||||
quadrant2_fill: "#F1F1FF",
|
||||
quadrant3_fill: "#F6F6FF",
|
||||
quadrant4_fill: "#FBFBFF",
|
||||
border_stroke: "#C7C7F1",
|
||||
title_fill: "#333333",
|
||||
axis_text_fill: "#333333",
|
||||
point_fill: "#333333",
|
||||
point_text_fill: "#333333",
|
||||
quadrant_text_fill: "#333333",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mermaid.js default config values for quadrant charts.
|
||||
const CHART_WIDTH: f64 = 500.0;
|
||||
const CHART_HEIGHT: f64 = 500.0;
|
||||
const TITLE_FONT_SIZE: f64 = 20.0;
|
||||
const TITLE_PADDING: f64 = 10.0;
|
||||
const QUADRANT_PADDING: f64 = 5.0;
|
||||
const X_AXIS_LABEL_PADDING: f64 = 5.0;
|
||||
const Y_AXIS_LABEL_PADDING: f64 = 5.0;
|
||||
const X_AXIS_LABEL_FONT_SIZE: f64 = 16.0;
|
||||
const Y_AXIS_LABEL_FONT_SIZE: f64 = 16.0;
|
||||
const QUADRANT_LABEL_FONT_SIZE: f64 = 16.0;
|
||||
const QUADRANT_TEXT_TOP_PADDING: f64 = 5.0;
|
||||
const POINT_TEXT_PADDING: f64 = 5.0;
|
||||
const POINT_LABEL_FONT_SIZE: f64 = 12.0;
|
||||
const POINT_RADIUS: f64 = 5.0;
|
||||
const INTERNAL_BORDER_STROKE_WIDTH: f64 = 1.0;
|
||||
const EXTERNAL_BORDER_STROKE_WIDTH: f64 = 2.0;
|
||||
|
||||
const FONT_FAMILY: &str = "trebuchet ms,verdana,arial,sans-serif";
|
||||
|
||||
pub fn render_quadrant_chart_to_svg(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let chart = parse_quadrant_chart(mermaid_source)?;
|
||||
let qt = quadrant_theme_for(theme);
|
||||
|
||||
let has_points = !chart.points.is_empty();
|
||||
let show_title = chart.title.is_some();
|
||||
let show_x_axis = chart.x_axis.is_some();
|
||||
let show_y_axis = chart.y_axis.is_some();
|
||||
|
||||
// x-axis goes to bottom when points exist, top otherwise
|
||||
let x_axis_bottom = has_points;
|
||||
|
||||
// Space calculations (matches mermaid.js QuadrantBuilder.calculateSpace)
|
||||
let x_axis_space = if show_x_axis {
|
||||
X_AXIS_LABEL_PADDING * 2.0 + X_AXIS_LABEL_FONT_SIZE
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let y_axis_space_left = if show_y_axis {
|
||||
Y_AXIS_LABEL_PADDING * 2.0 + Y_AXIS_LABEL_FONT_SIZE
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let title_space_top = if show_title {
|
||||
TITLE_FONT_SIZE + TITLE_PADDING * 2.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let x_axis_top = if !x_axis_bottom { x_axis_space } else { 0.0 };
|
||||
let x_axis_bot = if x_axis_bottom { x_axis_space } else { 0.0 };
|
||||
|
||||
let quadrant_left = QUADRANT_PADDING + y_axis_space_left;
|
||||
let quadrant_top = QUADRANT_PADDING + x_axis_top + title_space_top;
|
||||
let quadrant_width = CHART_WIDTH - QUADRANT_PADDING * 2.0 - y_axis_space_left;
|
||||
let quadrant_height =
|
||||
CHART_HEIGHT - QUADRANT_PADDING * 2.0 - x_axis_top - x_axis_bot - title_space_top;
|
||||
let half_w = quadrant_width / 2.0;
|
||||
let half_h = quadrant_height / 2.0;
|
||||
let half_ext = EXTERNAL_BORDER_STROKE_WIDTH / 2.0;
|
||||
|
||||
let mut svg = String::new();
|
||||
svg.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {CHART_WIDTH} {CHART_HEIGHT}\">"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"0\" y=\"0\" width=\"{CHART_WIDTH}\" height=\"{CHART_HEIGHT}\" fill=\"{}\"/>",
|
||||
theme.background
|
||||
));
|
||||
|
||||
// --- Quadrant fill rects (draw FIRST, behind everything else) ---
|
||||
let q1_text = chart.quadrants.get(&1).cloned().unwrap_or_default();
|
||||
let q2_text = chart.quadrants.get(&2).cloned().unwrap_or_default();
|
||||
let q3_text = chart.quadrants.get(&3).cloned().unwrap_or_default();
|
||||
let q4_text = chart.quadrants.get(&4).cloned().unwrap_or_default();
|
||||
|
||||
// Mermaid quadrant layout:
|
||||
// Q1 = top-right, Q2 = top-left, Q3 = bottom-left, Q4 = bottom-right
|
||||
let quadrant_rects: [(f64, f64, &str); 4] = [
|
||||
(quadrant_left + half_w, quadrant_top, qt.quadrant1_fill), // Q1 top-right
|
||||
(quadrant_left, quadrant_top, qt.quadrant2_fill), // Q2 top-left
|
||||
(quadrant_left, quadrant_top + half_h, qt.quadrant3_fill), // Q3 bottom-left
|
||||
(
|
||||
quadrant_left + half_w,
|
||||
quadrant_top + half_h,
|
||||
qt.quadrant4_fill,
|
||||
), // Q4 bottom-right
|
||||
];
|
||||
for (rx, ry, fill) in &quadrant_rects {
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{rx:.1}\" y=\"{ry:.1}\" width=\"{half_w:.1}\" height=\"{half_h:.1}\" fill=\"{fill}\"/>"
|
||||
));
|
||||
}
|
||||
|
||||
// --- Quadrant labels ---
|
||||
let quadrant_labels: [(&str, f64, f64); 4] = [
|
||||
(
|
||||
&q1_text,
|
||||
quadrant_left + half_w + half_w / 2.0,
|
||||
quadrant_top,
|
||||
),
|
||||
(&q2_text, quadrant_left + half_w / 2.0, quadrant_top),
|
||||
(
|
||||
&q3_text,
|
||||
quadrant_left + half_w / 2.0,
|
||||
quadrant_top + half_h,
|
||||
),
|
||||
(
|
||||
&q4_text,
|
||||
quadrant_left + half_w + half_w / 2.0,
|
||||
quadrant_top + half_h,
|
||||
),
|
||||
];
|
||||
for (text, tx, ty_base) in &quadrant_labels {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// When points exist, labels go to top of quadrant; otherwise center
|
||||
let ty = if has_points {
|
||||
ty_base + QUADRANT_TEXT_TOP_PADDING
|
||||
} else {
|
||||
ty_base + half_h / 2.0
|
||||
};
|
||||
let dominant_baseline = if has_points { "hanging" } else { "middle" };
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{tx:.1}\" y=\"{ty:.1}\" text-anchor=\"middle\" dominant-baseline=\"{dominant_baseline}\" \
|
||||
font-family=\"{FONT_FAMILY}\" font-size=\"{QUADRANT_LABEL_FONT_SIZE}\" \
|
||||
fill=\"{fill}\">{}</text>",
|
||||
escape_xml(text),
|
||||
fill = qt.quadrant_text_fill
|
||||
));
|
||||
}
|
||||
|
||||
// --- Border lines (external + internal, all solid) ---
|
||||
// External border: 4 lines forming the outer rectangle
|
||||
let ext_lines: [(f64, f64, f64, f64); 4] = [
|
||||
// top
|
||||
(
|
||||
quadrant_left - half_ext,
|
||||
quadrant_top,
|
||||
quadrant_left + quadrant_width + half_ext,
|
||||
quadrant_top,
|
||||
),
|
||||
// right
|
||||
(
|
||||
quadrant_left + quadrant_width,
|
||||
quadrant_top + half_ext,
|
||||
quadrant_left + quadrant_width,
|
||||
quadrant_top + quadrant_height - half_ext,
|
||||
),
|
||||
// bottom
|
||||
(
|
||||
quadrant_left - half_ext,
|
||||
quadrant_top + quadrant_height,
|
||||
quadrant_left + quadrant_width + half_ext,
|
||||
quadrant_top + quadrant_height,
|
||||
),
|
||||
// left
|
||||
(
|
||||
quadrant_left,
|
||||
quadrant_top + half_ext,
|
||||
quadrant_left,
|
||||
quadrant_top + quadrant_height - half_ext,
|
||||
),
|
||||
];
|
||||
for (x1, y1, x2, y2) in &ext_lines {
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" \
|
||||
stroke=\"{stroke}\" stroke-width=\"{EXTERNAL_BORDER_STROKE_WIDTH}\"/>",
|
||||
stroke = qt.border_stroke
|
||||
));
|
||||
}
|
||||
|
||||
// Internal dividers (solid lines, no dash)
|
||||
// Vertical
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{x:.1}\" y1=\"{y1:.1}\" x2=\"{x:.1}\" y2=\"{y2:.1}\" \
|
||||
stroke=\"{stroke}\" stroke-width=\"{INTERNAL_BORDER_STROKE_WIDTH}\"/>",
|
||||
x = quadrant_left + half_w,
|
||||
y1 = quadrant_top + half_ext,
|
||||
y2 = quadrant_top + quadrant_height - half_ext,
|
||||
stroke = qt.border_stroke
|
||||
));
|
||||
// Horizontal
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{x1:.1}\" y1=\"{y:.1}\" x2=\"{x2:.1}\" y2=\"{y:.1}\" \
|
||||
stroke=\"{stroke}\" stroke-width=\"{INTERNAL_BORDER_STROKE_WIDTH}\"/>",
|
||||
x1 = quadrant_left + half_ext,
|
||||
y = quadrant_top + half_h,
|
||||
x2 = quadrant_left + quadrant_width - half_ext,
|
||||
stroke = qt.border_stroke
|
||||
));
|
||||
|
||||
// --- Axis labels ---
|
||||
let draw_x_labels_in_middle = chart
|
||||
.x_axis
|
||||
.as_ref()
|
||||
.is_some_and(|(_, high)| !high.is_empty());
|
||||
let draw_y_labels_in_middle = chart
|
||||
.y_axis
|
||||
.as_ref()
|
||||
.is_some_and(|(_, high)| !high.is_empty());
|
||||
|
||||
if let Some((low, high)) = &chart.x_axis {
|
||||
let x_axis_y = if x_axis_bottom {
|
||||
X_AXIS_LABEL_PADDING + quadrant_top + quadrant_height + QUADRANT_PADDING
|
||||
} else {
|
||||
X_AXIS_LABEL_PADDING + title_space_top
|
||||
};
|
||||
|
||||
let low_x = quadrant_left
|
||||
+ if draw_x_labels_in_middle {
|
||||
half_w / 2.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let text_anchor_low = if draw_x_labels_in_middle {
|
||||
"middle"
|
||||
} else {
|
||||
"start"
|
||||
};
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{low_x:.1}\" y=\"{x_axis_y:.1}\" text-anchor=\"{text_anchor_low}\" dominant-baseline=\"hanging\" \
|
||||
font-family=\"{FONT_FAMILY}\" font-size=\"{X_AXIS_LABEL_FONT_SIZE}\" \
|
||||
fill=\"{fill}\">{}</text>",
|
||||
escape_xml(low),
|
||||
fill = qt.axis_text_fill
|
||||
));
|
||||
|
||||
if !high.is_empty() {
|
||||
let high_x = quadrant_left
|
||||
+ half_w
|
||||
+ if draw_x_labels_in_middle {
|
||||
half_w / 2.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{high_x:.1}\" y=\"{x_axis_y:.1}\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
|
||||
font-family=\"{FONT_FAMILY}\" font-size=\"{X_AXIS_LABEL_FONT_SIZE}\" \
|
||||
fill=\"{fill}\">{}</text>",
|
||||
escape_xml(high),
|
||||
fill = qt.axis_text_fill
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((low, high)) = &chart.y_axis {
|
||||
let y_axis_x = Y_AXIS_LABEL_PADDING;
|
||||
|
||||
// Bottom label (low value) — rotated -90° at (y_axis_x, low_y)
|
||||
let low_y = quadrant_top + quadrant_height
|
||||
- if draw_y_labels_in_middle {
|
||||
half_h / 2.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
|
||||
font-family=\"{FONT_FAMILY}\" font-size=\"{Y_AXIS_LABEL_FONT_SIZE}\" \
|
||||
fill=\"{fill}\" transform=\"translate({y_axis_x:.1}, {low_y:.1}) rotate(-90)\">{}</text>",
|
||||
escape_xml(low),
|
||||
fill = qt.axis_text_fill
|
||||
));
|
||||
|
||||
// Top label (high value) — rotated -90° at (y_axis_x, high_y)
|
||||
if !high.is_empty() {
|
||||
let high_y = quadrant_top + half_h
|
||||
- if draw_y_labels_in_middle {
|
||||
half_h / 2.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
|
||||
font-family=\"{FONT_FAMILY}\" font-size=\"{Y_AXIS_LABEL_FONT_SIZE}\" \
|
||||
fill=\"{fill}\" transform=\"translate({y_axis_x:.1}, {high_y:.1}) rotate(-90)\">{}</text>",
|
||||
escape_xml(high),
|
||||
fill = qt.axis_text_fill
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Title ---
|
||||
if let Some(title) = &chart.title {
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{x:.1}\" y=\"{y:.1}\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
|
||||
font-family=\"{FONT_FAMILY}\" font-size=\"{TITLE_FONT_SIZE}\" \
|
||||
fill=\"{fill}\">{}</text>",
|
||||
escape_xml(title),
|
||||
x = CHART_WIDTH / 2.0,
|
||||
y = TITLE_PADDING,
|
||||
fill = qt.title_fill
|
||||
));
|
||||
}
|
||||
|
||||
// --- Data points ---
|
||||
for point in &chart.points {
|
||||
let px = quadrant_left + point.x.clamp(0.0, 1.0) * quadrant_width;
|
||||
let py = quadrant_top + (1.0 - point.y.clamp(0.0, 1.0)) * quadrant_height;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{px:.1}\" cy=\"{py:.1}\" r=\"{POINT_RADIUS}\" \
|
||||
fill=\"{fill}\" stroke=\"{fill}\" stroke-width=\"0\"/>",
|
||||
fill = qt.point_fill
|
||||
));
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
|
||||
font-family=\"{FONT_FAMILY}\" font-size=\"{POINT_LABEL_FONT_SIZE}\" \
|
||||
fill=\"{fill}\" transform=\"translate({px:.1}, {ty:.1})\">{}</text>",
|
||||
escape_xml(&point.label),
|
||||
fill = qt.point_text_fill,
|
||||
ty = py + POINT_TEXT_PADDING
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</svg>");
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct QuadrantChart {
|
||||
title: Option<String>,
|
||||
x_axis: Option<(String, String)>,
|
||||
y_axis: Option<(String, String)>,
|
||||
quadrants: BTreeMap<i32, String>,
|
||||
points: Vec<QuadrantPoint>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct QuadrantPoint {
|
||||
label: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
fn parse_quadrant_chart(input: &str) -> Result<QuadrantChart, MermaidError> {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
|
||||
let mut i = 0_usize;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.split_whitespace().next() == Some("quadrantChart") {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
return Err(MermaidError::ParseError {
|
||||
line: i + 1,
|
||||
message: "Expected 'quadrantChart' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut title: Option<String> = None;
|
||||
let mut x_axis: Option<(String, String)> = None;
|
||||
let mut y_axis: Option<(String, String)> = None;
|
||||
let mut quadrants: BTreeMap<i32, String> = BTreeMap::new();
|
||||
let mut points: Vec<QuadrantPoint> = Vec::new();
|
||||
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
let line = raw.trim();
|
||||
let line_no = i + 1;
|
||||
i += 1;
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("title ") {
|
||||
let t = rest.trim();
|
||||
if !t.is_empty() {
|
||||
title = Some(t.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("x-axis ") {
|
||||
x_axis = Some(parse_axis(rest.trim(), line_no)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("y-axis ") {
|
||||
y_axis = Some(parse_axis(rest.trim(), line_no)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("quadrant-") {
|
||||
let Some((n_str, label)) = rest.split_once(' ') else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid quadrant label: {line}"),
|
||||
});
|
||||
};
|
||||
let n: i32 = n_str.trim().parse().map_err(|_| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid quadrant label: {line}"),
|
||||
})?;
|
||||
quadrants.insert(n, label.trim().to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((label_raw, coords_raw)) = line.split_once(':') else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid quadrant point: {line}"),
|
||||
});
|
||||
};
|
||||
|
||||
let mut label = label_raw.trim().to_string();
|
||||
if let Some(stripped) = label.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
|
||||
label = stripped.to_string();
|
||||
}
|
||||
|
||||
let coords = coords_raw.trim();
|
||||
let coords = coords
|
||||
.strip_prefix('[')
|
||||
.and_then(|s| s.strip_suffix(']'))
|
||||
.ok_or_else(|| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid quadrant point: {line}"),
|
||||
})?;
|
||||
|
||||
let parts: Vec<&str> = coords.split(',').map(|p| p.trim()).collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid quadrant point: {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
let x: f64 = parts[0].parse().map_err(|_| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid quadrant point: {line}"),
|
||||
})?;
|
||||
let y: f64 = parts[1].parse().map_err(|_| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid quadrant point: {line}"),
|
||||
})?;
|
||||
|
||||
points.push(QuadrantPoint { label, x, y });
|
||||
}
|
||||
|
||||
if points.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Quadrant chart requires at least one point".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(QuadrantChart {
|
||||
title,
|
||||
x_axis,
|
||||
y_axis,
|
||||
quadrants,
|
||||
points,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_axis(s: &str, line_no: usize) -> Result<(String, String), MermaidError> {
|
||||
let Some((a, b)) = s.split_once("-->") else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid axis: {s}"),
|
||||
});
|
||||
};
|
||||
Ok((a.trim().to_string(), b.trim().to_string()))
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
const WIDTH: f64 = 600.0;
|
||||
const HEIGHT: f64 = 600.0;
|
||||
const MARGIN: f64 = 50.0;
|
||||
|
||||
const AXIS_SCALE_FACTOR: f64 = 1.0;
|
||||
const AXIS_LABEL_FACTOR: f64 = 1.05;
|
||||
const CURVE_TENSION: f64 = 0.17;
|
||||
|
||||
const DEFAULT_TICKS: usize = 5;
|
||||
const DEFAULT_MIN: f64 = 0.0;
|
||||
|
||||
const AXIS_COLOR: &str = "#333333";
|
||||
const GRATICULE_COLOR: &str = "#DEDEDE";
|
||||
const GRATICULE_OPACITY: f64 = 0.3;
|
||||
|
||||
const CURVE_COLOR_0: &str = "hsl(240, 100%, 76.2745098039%)";
|
||||
|
||||
pub fn render_radar_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let diagram = parse_radar(mermaid_source)?;
|
||||
|
||||
if diagram.axes.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "radar diagram requires at least one axis".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let max_value = diagram
|
||||
.curves
|
||||
.iter()
|
||||
.flat_map(|c| c.values.iter().copied())
|
||||
.fold(0.0, f64::max)
|
||||
.max(1.0);
|
||||
|
||||
let total_width = WIDTH + 2.0 * MARGIN;
|
||||
let total_height = HEIGHT + 2.0 * MARGIN;
|
||||
|
||||
let center_x = MARGIN + WIDTH / 2.0;
|
||||
let center_y = MARGIN + HEIGHT / 2.0;
|
||||
|
||||
let radius = WIDTH.min(HEIGHT) / 2.0;
|
||||
|
||||
let mut svg = String::new();
|
||||
svg.push_str(&format!(
|
||||
"<svg aria-roledescription=\"radar\" role=\"graphics-document document\" height=\"{total_height}\" viewBox=\"0 0 {total_width} {total_height}\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"{total_width}\" id=\"my-svg\" style=\"background-color: {};\">",
|
||||
theme.background
|
||||
));
|
||||
|
||||
svg.push_str("<style>");
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .radarAxisLine{{stroke:{AXIS_COLOR};stroke-width:2;}}"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .radarAxisLabel{{dominant-baseline:middle;text-anchor:middle;font-size:12px;color:{AXIS_COLOR};}}"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .radarGraticule{{fill:{GRATICULE_COLOR};fill-opacity:{GRATICULE_OPACITY};stroke:{GRATICULE_COLOR};stroke-width:1;}}"
|
||||
));
|
||||
svg.push_str(
|
||||
"#my-svg .radarLegendText{text-anchor:start;font-size:12px;dominant-baseline:hanging;}",
|
||||
);
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .radarCurve-0{{color:{CURVE_COLOR_0};fill:{CURVE_COLOR_0};fill-opacity:0.5;stroke:{CURVE_COLOR_0};stroke-width:2;}}"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"#my-svg .radarLegendBox-0{{fill:{CURVE_COLOR_0};fill-opacity:0.5;stroke:{CURVE_COLOR_0};}}"
|
||||
));
|
||||
svg.push_str("</style>");
|
||||
|
||||
svg.push_str("<g/>");
|
||||
svg.push_str(&format!(
|
||||
"<g transform=\"translate({center_x}, {center_y})\">"
|
||||
));
|
||||
|
||||
for i in 0..DEFAULT_TICKS {
|
||||
let r = radius * (i as f64 + 1.0) / (DEFAULT_TICKS as f64);
|
||||
svg.push_str(&format!("<circle class=\"radarGraticule\" r=\"{r}\"/>"));
|
||||
}
|
||||
|
||||
let n_axes = diagram.axes.len();
|
||||
for (i, axis_label) in diagram.axes.iter().enumerate() {
|
||||
let angle = 2.0 * (i as f64) * PI / (n_axes as f64) - PI / 2.0;
|
||||
let x2 = radius * AXIS_SCALE_FACTOR * angle.cos();
|
||||
let y2 = radius * AXIS_SCALE_FACTOR * angle.sin();
|
||||
svg.push_str(&format!(
|
||||
"<line class=\"radarAxisLine\" y2=\"{y2}\" x2=\"{x2}\" y1=\"0\" x1=\"0\"/>"
|
||||
));
|
||||
|
||||
let lx = radius * AXIS_LABEL_FACTOR * angle.cos();
|
||||
let ly = radius * AXIS_LABEL_FACTOR * angle.sin();
|
||||
svg.push_str(&format!(
|
||||
"<text class=\"radarAxisLabel\" y=\"{ly}\" x=\"{lx}\">{}</text>",
|
||||
escape_xml(axis_label)
|
||||
));
|
||||
}
|
||||
|
||||
for (curve_idx, curve) in diagram.curves.iter().enumerate() {
|
||||
if curve.values.len() != n_axes {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut points = Vec::with_capacity(n_axes);
|
||||
for (i, v) in curve.values.iter().copied().enumerate() {
|
||||
let angle = 2.0 * (i as f64) * PI / (n_axes as f64) - PI / 2.0;
|
||||
let r = radius * ((v.max(DEFAULT_MIN)).min(max_value) - DEFAULT_MIN)
|
||||
/ (max_value - DEFAULT_MIN);
|
||||
points.push((r * angle.cos(), r * angle.sin()));
|
||||
}
|
||||
|
||||
let d = closed_round_curve(&points, CURVE_TENSION);
|
||||
svg.push_str(&format!(
|
||||
"<path class=\"radarCurve-{curve_idx}\" d=\"{d}\"/>"
|
||||
));
|
||||
|
||||
let legend_x = (WIDTH / 2.0 + MARGIN) * 3.0 / 4.0;
|
||||
let legend_y = -(HEIGHT / 2.0 + MARGIN) * 3.0 / 4.0;
|
||||
let item_y = legend_y + curve_idx as f64 * 20.0;
|
||||
svg.push_str(&format!(
|
||||
"<g transform=\"translate({legend_x}, {item_y})\">"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<rect class=\"radarLegendBox-{curve_idx}\" height=\"12\" width=\"12\"/>"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<text class=\"radarLegendText\" y=\"0\" x=\"16\">{}</text>",
|
||||
escape_xml(&curve.name)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
svg.push_str("<text y=\"-350\" x=\"0\" class=\"radarTitle\"/>");
|
||||
svg.push_str("</g></svg>");
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RadarDiagram {
|
||||
axes: Vec<String>,
|
||||
curves: Vec<RadarCurve>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RadarCurve {
|
||||
name: String,
|
||||
values: Vec<f64>,
|
||||
}
|
||||
|
||||
fn parse_radar(input: &str) -> Result<RadarDiagram, MermaidError> {
|
||||
let mut found_header = false;
|
||||
|
||||
let mut axes: Vec<String> = Vec::new();
|
||||
let mut curves: Vec<RadarCurve> = Vec::new();
|
||||
|
||||
for (idx, raw) in input.lines().enumerate() {
|
||||
let line_no = idx + 1;
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
if line.split_whitespace().next() != Some("radar-beta") {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected 'radar-beta' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
found_header = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("axis ") {
|
||||
axes = rest
|
||||
.split(',')
|
||||
.map(|p| p.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("curve ") {
|
||||
let (name, values) = parse_curve(rest.trim(), line_no)?;
|
||||
curves.push(RadarCurve { name, values });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Expected 'radar-beta' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RadarDiagram { axes, curves })
|
||||
}
|
||||
|
||||
fn parse_curve(s: &str, line: usize) -> Result<(String, Vec<f64>), MermaidError> {
|
||||
let Some((name, rest)) = s.split_once('{') else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid curve: {s}"),
|
||||
});
|
||||
};
|
||||
|
||||
let name = name.trim();
|
||||
let inner = rest
|
||||
.strip_suffix('}')
|
||||
.ok_or_else(|| MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid curve: {s}"),
|
||||
})?
|
||||
.trim();
|
||||
|
||||
let mut values: Vec<f64> = Vec::new();
|
||||
for part in inner.split(',') {
|
||||
let p = part.trim();
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let v: f64 = p.parse().map_err(|_| MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid curve value: {p}"),
|
||||
})?;
|
||||
values.push(v);
|
||||
}
|
||||
|
||||
Ok((name.to_string(), values))
|
||||
}
|
||||
|
||||
fn closed_round_curve(points: &[(f64, f64)], tension: f64) -> String {
|
||||
if points.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let n = points.len();
|
||||
let mut d = String::new();
|
||||
d.push_str(&format!("M{},{}", points[0].0, points[0].1));
|
||||
|
||||
for i in 0..n {
|
||||
let p0 = points[(i + n - 1) % n];
|
||||
let p1 = points[i];
|
||||
let p2 = points[(i + 1) % n];
|
||||
let p3 = points[(i + 2) % n];
|
||||
|
||||
let cp1 = (
|
||||
p1.0 + (p2.0 - p0.0) * tension,
|
||||
p1.1 + (p2.1 - p0.1) * tension,
|
||||
);
|
||||
let cp2 = (
|
||||
p2.0 - (p3.0 - p1.0) * tension,
|
||||
p2.1 - (p3.1 - p1.1) * tension,
|
||||
);
|
||||
|
||||
d.push_str(&format!(
|
||||
" C{},{} {},{} {},{}",
|
||||
cp1.0, cp1.1, cp2.0, cp2.1, p2.0, p2.1
|
||||
));
|
||||
}
|
||||
|
||||
d.push_str(" Z");
|
||||
d
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::MermaidError;
|
||||
use crate::text_wrap::{line_width, DEFAULT_CHAR_WIDTH};
|
||||
use crate::theme::MermaidTheme;
|
||||
use dagre_rust::layout::layout as dagre_layout;
|
||||
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
|
||||
use graphlib_rust::Graph;
|
||||
|
||||
const BOX_PADDING: f64 = 20.0;
|
||||
const BOX_GAP: f64 = 20.0;
|
||||
const LINE_HEIGHT: f64 = 24.0;
|
||||
|
||||
const NODE_SEP: f64 = 50.0;
|
||||
const RANK_SEP: f64 = 50.0;
|
||||
const GRAPH_MARGIN: f64 = 8.0;
|
||||
|
||||
pub fn render_requirement_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let diagram = parse_requirement_diagram(mermaid_source)?;
|
||||
let layout = compute_layout(&diagram);
|
||||
Ok(render_svg(&layout, theme))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Direction {
|
||||
Tb,
|
||||
Bt,
|
||||
Lr,
|
||||
Rl,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
fn as_rankdir(self) -> &'static str {
|
||||
match self {
|
||||
Direction::Tb => "tb",
|
||||
Direction::Bt => "bt",
|
||||
Direction::Lr => "lr",
|
||||
Direction::Rl => "rl",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RequirementDiagram {
|
||||
direction: Direction,
|
||||
nodes: HashMap<String, ReqNode>,
|
||||
relations: Vec<Relation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum ReqNode {
|
||||
Requirement(RequirementNode),
|
||||
Element(ElementNode),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RequirementNode {
|
||||
name: String,
|
||||
requirement_id: String,
|
||||
text: String,
|
||||
risk: String,
|
||||
verify_method: String,
|
||||
req_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ElementNode {
|
||||
name: String,
|
||||
element_type: String,
|
||||
doc_ref: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Relation {
|
||||
src: String,
|
||||
dst: String,
|
||||
rel_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NodeLayout {
|
||||
id: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
labels: Vec<LabelLayout>,
|
||||
divider_y: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EdgeLayout {
|
||||
id: String,
|
||||
rel_type: String,
|
||||
label: String,
|
||||
points: Vec<(f64, f64)>,
|
||||
label_pos: Option<(f64, f64)>,
|
||||
label_width: f64,
|
||||
label_height: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DiagramLayout {
|
||||
nodes: HashMap<String, NodeLayout>,
|
||||
edges: Vec<EdgeLayout>,
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LabelLayout {
|
||||
text: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
anchor: &'static str,
|
||||
bold: bool,
|
||||
}
|
||||
|
||||
fn parse_requirement_diagram(input: &str) -> Result<RequirementDiagram, MermaidError> {
|
||||
let mut found_header = false;
|
||||
let mut direction = Direction::Tb;
|
||||
|
||||
let mut nodes: HashMap<String, ReqNode> = HashMap::new();
|
||||
let mut relations: Vec<Relation> = Vec::new();
|
||||
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
let mut i = 0_usize;
|
||||
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
let line_no = i + 1;
|
||||
let line = raw.trim();
|
||||
i += 1;
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
if first_diagram_type_token(line) != Some("requirementDiagram") {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected 'requirementDiagram' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
found_header = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("direction ") {
|
||||
direction = parse_direction(rest.trim(), line_no)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((node, new_i)) = try_parse_requirement_or_element(&lines, i - 1)? {
|
||||
let id = match &node {
|
||||
ReqNode::Requirement(r) => r.name.clone(),
|
||||
ReqNode::Element(e) => e.name.clone(),
|
||||
};
|
||||
nodes.insert(id, node);
|
||||
i = new_i;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rel) = try_parse_relation(line, line_no)? {
|
||||
relations.push(rel);
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Unrecognized requirementDiagram line: {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Expected 'requirementDiagram' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RequirementDiagram {
|
||||
direction,
|
||||
nodes,
|
||||
relations,
|
||||
})
|
||||
}
|
||||
|
||||
fn first_diagram_type_token(line: &str) -> Option<&str> {
|
||||
line.split_whitespace().next()
|
||||
}
|
||||
|
||||
fn parse_direction(s: &str, line: usize) -> Result<Direction, MermaidError> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"TB" | "TD" => Ok(Direction::Tb),
|
||||
"BT" => Ok(Direction::Bt),
|
||||
"LR" => Ok(Direction::Lr),
|
||||
"RL" => Ok(Direction::Rl),
|
||||
_ => Err(MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid direction: {s}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_parse_requirement_or_element(
|
||||
lines: &[&str],
|
||||
start_idx: usize,
|
||||
) -> Result<Option<(ReqNode, usize)>, MermaidError> {
|
||||
let line = lines[start_idx].trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (kind, name, has_open_brace) = if let Some((kw, rest)) = split_once_ws(line) {
|
||||
let kind = kw;
|
||||
let (name_raw, tail) = split_once_ws(rest).unwrap_or((rest, ""));
|
||||
let name = name_raw.trim();
|
||||
let has_open_brace = tail.contains('{') || name.ends_with('{');
|
||||
(kind, name.trim_end_matches('{').trim(), has_open_brace)
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let kind_lower = kind.to_lowercase();
|
||||
if kind_lower != "element"
|
||||
&& kind_lower != "requirement"
|
||||
&& kind_lower != "functionalrequirement"
|
||||
&& kind_lower != "interfacerequirement"
|
||||
&& kind_lower != "performancerequirement"
|
||||
&& kind_lower != "physicalrequirement"
|
||||
&& kind_lower != "designconstraint"
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if name.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: start_idx + 1,
|
||||
message: format!("Expected name after '{kind}'"),
|
||||
});
|
||||
}
|
||||
|
||||
let mut i = start_idx + 1;
|
||||
if !has_open_brace {
|
||||
while i < lines.len() {
|
||||
let l = lines[i].trim();
|
||||
if l.is_empty() || l.starts_with("%%") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if l.starts_with('{') {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
let mut props: HashMap<String, String> = HashMap::new();
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
let line_no = i + 1;
|
||||
let l = raw.trim();
|
||||
i += 1;
|
||||
|
||||
if l.is_empty() || l.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
if l.starts_with('}') {
|
||||
break;
|
||||
}
|
||||
|
||||
let Some((k, v)) = l.split_once(':') else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid property line: {l}"),
|
||||
});
|
||||
};
|
||||
let key = k.trim().to_string();
|
||||
let mut value = v.trim().trim_end_matches(',').trim().to_string();
|
||||
value = strip_quotes(&value);
|
||||
props.insert(key, value);
|
||||
}
|
||||
|
||||
if kind_lower == "element" {
|
||||
let node = ReqNode::Element(ElementNode {
|
||||
name: name.to_string(),
|
||||
element_type: props.get("type").cloned().unwrap_or_else(String::new),
|
||||
doc_ref: props
|
||||
.get("docref")
|
||||
.or_else(|| props.get("docRef"))
|
||||
.cloned()
|
||||
.unwrap_or_else(String::new),
|
||||
});
|
||||
return Ok(Some((node, i)));
|
||||
}
|
||||
|
||||
let req_type = match kind_lower.as_str() {
|
||||
"functionalrequirement" => "Functional Requirement",
|
||||
"interfacerequirement" => "Interface Requirement",
|
||||
"performancerequirement" => "Performance Requirement",
|
||||
"physicalrequirement" => "Physical Requirement",
|
||||
"designconstraint" => "Design Constraint",
|
||||
_ => "Requirement",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let risk = props.get("risk").cloned().unwrap_or_else(String::new);
|
||||
let verify_method = props
|
||||
.get("verifyMethod")
|
||||
.or_else(|| props.get("verifymethod"))
|
||||
.cloned()
|
||||
.unwrap_or_else(String::new);
|
||||
|
||||
let node = ReqNode::Requirement(RequirementNode {
|
||||
name: name.to_string(),
|
||||
requirement_id: props.get("id").cloned().unwrap_or_else(String::new),
|
||||
text: props.get("text").cloned().unwrap_or_else(String::new),
|
||||
risk: normalize_risk(&risk),
|
||||
verify_method: normalize_verify_method(&verify_method),
|
||||
req_type,
|
||||
});
|
||||
|
||||
Ok(Some((node, i)))
|
||||
}
|
||||
|
||||
fn normalize_risk(s: &str) -> String {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"low" => "Low".to_string(),
|
||||
"medium" => "Medium".to_string(),
|
||||
"high" => "High".to_string(),
|
||||
_ => s.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_verify_method(s: &str) -> String {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"analysis" => "Analysis".to_string(),
|
||||
"demonstration" => "Demonstration".to_string(),
|
||||
"inspection" => "Inspection".to_string(),
|
||||
"test" => "Test".to_string(),
|
||||
_ => s.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_quotes(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
if let Some(inner) = s.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
|
||||
return inner.to_string();
|
||||
}
|
||||
if let Some(inner) = s.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
|
||||
return inner.to_string();
|
||||
}
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
fn split_once_ws(s: &str) -> Option<(&str, &str)> {
|
||||
let mut it = s.splitn(2, char::is_whitespace);
|
||||
let a = it.next()?;
|
||||
let b = it.next().unwrap_or("");
|
||||
Some((a, b.trim()))
|
||||
}
|
||||
|
||||
fn try_parse_relation(line: &str, line_no: usize) -> Result<Option<Relation>, MermaidError> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some((lhs, rhs)) = line.split_once("->") {
|
||||
let dst = rhs.trim();
|
||||
let tokens: Vec<&str> = lhs.split_whitespace().filter(|t| *t != "-").collect();
|
||||
if tokens.len() < 2 || dst.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid relationship: {line}"),
|
||||
});
|
||||
}
|
||||
let src = tokens[0].trim();
|
||||
let rel = tokens[1].trim();
|
||||
if src.is_empty() || rel.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid relationship: {line}"),
|
||||
});
|
||||
}
|
||||
return Ok(Some(Relation {
|
||||
src: src.to_string(),
|
||||
dst: dst.to_string(),
|
||||
rel_type: rel.to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some((lhs, rhs)) = line.split_once("<-") {
|
||||
let dst = lhs.trim();
|
||||
let tokens: Vec<&str> = rhs.split_whitespace().filter(|t| *t != "-").collect();
|
||||
if tokens.len() < 2 || dst.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid relationship: {line}"),
|
||||
});
|
||||
}
|
||||
let rel = tokens[0].trim();
|
||||
let src = tokens[1].trim();
|
||||
if src.is_empty() || rel.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid relationship: {line}"),
|
||||
});
|
||||
}
|
||||
return Ok(Some(Relation {
|
||||
src: src.to_string(),
|
||||
dst: dst.to_string(),
|
||||
rel_type: rel.to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn compute_layout(diagram: &RequirementDiagram) -> DiagramLayout {
|
||||
let mut node_metrics: HashMap<String, (f64, f64, Vec<LabelLayout>, Option<f64>)> =
|
||||
HashMap::new();
|
||||
for (id, node) in &diagram.nodes {
|
||||
let metrics = compute_requirement_box_layout(node);
|
||||
node_metrics.insert(id.clone(), metrics);
|
||||
}
|
||||
|
||||
type DagreGraph = Graph<GraphConfig, GraphNode, GraphEdge>;
|
||||
|
||||
let mut g: DagreGraph = Graph::new(Some(graphlib_rust::GraphOption {
|
||||
directed: Some(true),
|
||||
multigraph: Some(true),
|
||||
compound: Some(false),
|
||||
}));
|
||||
|
||||
g.set_graph(GraphConfig {
|
||||
rankdir: Some(diagram.direction.as_rankdir().to_string()),
|
||||
nodesep: Some(NODE_SEP as f32),
|
||||
ranksep: Some(RANK_SEP as f32),
|
||||
edgesep: Some(20.0),
|
||||
marginx: Some(GRAPH_MARGIN as f32),
|
||||
marginy: Some(GRAPH_MARGIN as f32),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
for (id, (w, h, _, _)) in &node_metrics {
|
||||
g.set_node(
|
||||
id.clone(),
|
||||
Some(GraphNode {
|
||||
width: *w as f32,
|
||||
height: *h as f32,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let mut edge_keys: Vec<(String, String)> = Vec::new();
|
||||
for rel in &diagram.relations {
|
||||
let edge_label_text = format!("<<{}>>", rel.rel_type);
|
||||
let label_width = line_width(&edge_label_text, DEFAULT_CHAR_WIDTH);
|
||||
let label_height = LINE_HEIGHT;
|
||||
|
||||
let edge_label = GraphEdge {
|
||||
labelpos: Some("c".to_string()),
|
||||
width: Some(label_width as f32),
|
||||
height: Some(label_height as f32),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let _ = g.set_edge(&rel.src, &rel.dst, Some(edge_label), None);
|
||||
edge_keys.push((rel.src.clone(), rel.dst.clone()));
|
||||
}
|
||||
|
||||
dagre_layout(&mut g);
|
||||
|
||||
let mut positions: HashMap<String, (f64, f64)> = HashMap::new();
|
||||
for node_id in g.nodes() {
|
||||
if let Some(node) = g.node(&node_id) {
|
||||
positions.insert(node_id, (node.x as f64, node.y as f64));
|
||||
}
|
||||
}
|
||||
|
||||
let mut edges: Vec<EdgeLayout> = Vec::new();
|
||||
for (idx, (from, to)) in edge_keys.iter().enumerate() {
|
||||
let Some(edge) = g.edge(from, to, None) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let points: Vec<(f64, f64)> = edge
|
||||
.points
|
||||
.as_ref()
|
||||
.map(|pts| pts.iter().map(|p| (p.x as f64, p.y as f64)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let label_pos = if edge.width.unwrap_or(0.0) > 0.0 || edge.height.unwrap_or(0.0) > 0.0 {
|
||||
Some((edge.x as f64, edge.y as f64))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rel_type = diagram
|
||||
.relations
|
||||
.get(idx)
|
||||
.map(|r| r.rel_type.clone())
|
||||
.unwrap_or_default();
|
||||
let label = format!("<<{}>>", rel_type);
|
||||
let label_width = edge.width.unwrap_or(0.0) as f64;
|
||||
let label_height = edge.height.unwrap_or(0.0) as f64;
|
||||
|
||||
edges.push(EdgeLayout {
|
||||
id: format!("{from}-{to}-{idx}"),
|
||||
rel_type,
|
||||
label,
|
||||
points,
|
||||
label_pos,
|
||||
label_width,
|
||||
label_height,
|
||||
});
|
||||
}
|
||||
|
||||
let mut layout_nodes: HashMap<String, NodeLayout> = HashMap::new();
|
||||
for (id, (x, y)) in &positions {
|
||||
let Some((w, h, labels, divider_y)) = node_metrics.get(id).cloned() else {
|
||||
continue;
|
||||
};
|
||||
layout_nodes.insert(
|
||||
id.clone(),
|
||||
NodeLayout {
|
||||
id: id.clone(),
|
||||
x: *x,
|
||||
y: *y,
|
||||
width: w,
|
||||
height: h,
|
||||
labels,
|
||||
divider_y,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let (min_x, min_y, max_x, max_y) = compute_bounds(&layout_nodes, &edges);
|
||||
let dx = GRAPH_MARGIN - min_x;
|
||||
let dy = GRAPH_MARGIN - min_y;
|
||||
|
||||
for node in layout_nodes.values_mut() {
|
||||
node.x += dx;
|
||||
node.y += dy;
|
||||
}
|
||||
for edge in &mut edges {
|
||||
for p in &mut edge.points {
|
||||
p.0 += dx;
|
||||
p.1 += dy;
|
||||
}
|
||||
if let Some((x, y)) = edge.label_pos {
|
||||
edge.label_pos = Some((x + dx, y + dy));
|
||||
}
|
||||
}
|
||||
|
||||
let width = (max_x - min_x) + GRAPH_MARGIN * 2.0;
|
||||
let height = (max_y - min_y) + GRAPH_MARGIN * 2.0;
|
||||
|
||||
DiagramLayout {
|
||||
nodes: layout_nodes,
|
||||
edges,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_bounds(
|
||||
nodes: &HashMap<String, NodeLayout>,
|
||||
edges: &[EdgeLayout],
|
||||
) -> (f64, f64, f64, f64) {
|
||||
let mut min_x = f64::INFINITY;
|
||||
let mut min_y = f64::INFINITY;
|
||||
let mut max_x = f64::NEG_INFINITY;
|
||||
let mut max_y = f64::NEG_INFINITY;
|
||||
|
||||
for node in nodes.values() {
|
||||
let left = node.x - node.width / 2.0;
|
||||
let right = node.x + node.width / 2.0;
|
||||
let top = node.y - node.height / 2.0;
|
||||
let bottom = node.y + node.height / 2.0;
|
||||
min_x = min_x.min(left);
|
||||
min_y = min_y.min(top);
|
||||
max_x = max_x.max(right);
|
||||
max_y = max_y.max(bottom);
|
||||
}
|
||||
|
||||
for edge in edges {
|
||||
for (x, y) in &edge.points {
|
||||
min_x = min_x.min(*x);
|
||||
min_y = min_y.min(*y);
|
||||
max_x = max_x.max(*x);
|
||||
max_y = max_y.max(*y);
|
||||
}
|
||||
if let Some((x, y)) = edge.label_pos {
|
||||
let left = x - edge.label_width / 2.0;
|
||||
let right = x + edge.label_width / 2.0;
|
||||
let top = y - edge.label_height / 2.0;
|
||||
let bottom = y + edge.label_height / 2.0;
|
||||
min_x = min_x.min(left);
|
||||
min_y = min_y.min(top);
|
||||
max_x = max_x.max(right);
|
||||
max_y = max_y.max(bottom);
|
||||
}
|
||||
}
|
||||
|
||||
if !min_x.is_finite() {
|
||||
min_x = 0.0;
|
||||
max_x = 0.0;
|
||||
}
|
||||
if !min_y.is_finite() {
|
||||
min_y = 0.0;
|
||||
max_y = 0.0;
|
||||
}
|
||||
|
||||
(min_x, min_y, max_x, max_y)
|
||||
}
|
||||
|
||||
fn compute_requirement_box_layout(node: &ReqNode) -> (f64, f64, Vec<LabelLayout>, Option<f64>) {
|
||||
let (type_line, name_line, body_lines) = match node {
|
||||
ReqNode::Requirement(r) => {
|
||||
let mut body = Vec::new();
|
||||
if !r.requirement_id.is_empty() {
|
||||
body.push(format!("ID: {}", r.requirement_id));
|
||||
}
|
||||
if !r.text.is_empty() {
|
||||
body.push(format!("Text: {}", r.text));
|
||||
}
|
||||
if !r.risk.is_empty() {
|
||||
body.push(format!("Risk: {}", r.risk));
|
||||
}
|
||||
if !r.verify_method.is_empty() {
|
||||
body.push(format!("Verification: {}", r.verify_method));
|
||||
}
|
||||
(format!("<<{}>>", r.req_type), r.name.clone(), body)
|
||||
}
|
||||
ReqNode::Element(e) => {
|
||||
let mut body = Vec::new();
|
||||
if !e.element_type.is_empty() {
|
||||
body.push(format!("Type: {}", e.element_type));
|
||||
}
|
||||
if !e.doc_ref.is_empty() {
|
||||
body.push(format!("Doc Ref: {}", e.doc_ref));
|
||||
}
|
||||
("<<Element>>".to_string(), e.name.clone(), body)
|
||||
}
|
||||
};
|
||||
|
||||
let type_width = line_width(&type_line, DEFAULT_CHAR_WIDTH);
|
||||
let name_width = line_width(&name_line, DEFAULT_CHAR_WIDTH);
|
||||
let mut max_width = type_width.max(name_width);
|
||||
|
||||
for line in &body_lines {
|
||||
max_width = max_width.max(line_width(line, DEFAULT_CHAR_WIDTH));
|
||||
}
|
||||
|
||||
let content_height =
|
||||
LINE_HEIGHT + LINE_HEIGHT + BOX_GAP + body_lines.len() as f64 * LINE_HEIGHT;
|
||||
|
||||
let total_width = max_width + BOX_PADDING;
|
||||
let total_height = content_height + BOX_PADDING;
|
||||
|
||||
let mut labels: Vec<LabelLayout> = Vec::new();
|
||||
labels.push(LabelLayout {
|
||||
text: type_line,
|
||||
x: 0.0,
|
||||
y: 0.0 - content_height / 2.0 + BOX_PADDING / 2.0,
|
||||
anchor: "middle",
|
||||
bold: false,
|
||||
});
|
||||
labels.push(LabelLayout {
|
||||
text: name_line,
|
||||
x: 0.0,
|
||||
y: LINE_HEIGHT - content_height / 2.0 + BOX_PADDING / 2.0,
|
||||
anchor: "middle",
|
||||
bold: true,
|
||||
});
|
||||
|
||||
let left_x = -total_width / 2.0 + BOX_PADDING / 2.0;
|
||||
|
||||
let mut y_offset = LINE_HEIGHT + LINE_HEIGHT + BOX_GAP;
|
||||
for line in body_lines {
|
||||
labels.push(LabelLayout {
|
||||
text: line,
|
||||
x: left_x,
|
||||
y: y_offset - content_height / 2.0 + BOX_PADDING / 2.0,
|
||||
anchor: "start",
|
||||
bold: false,
|
||||
});
|
||||
y_offset += LINE_HEIGHT;
|
||||
}
|
||||
|
||||
let divider_y = if y_offset > LINE_HEIGHT + LINE_HEIGHT + BOX_GAP {
|
||||
Some(-total_height / 2.0 + (LINE_HEIGHT + LINE_HEIGHT + BOX_GAP))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(total_width, total_height, labels, divider_y)
|
||||
}
|
||||
|
||||
fn render_svg(layout: &DiagramLayout, theme: &MermaidTheme) -> String {
|
||||
let mut svg = String::new();
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<svg aria-roledescription=\"requirement\" role=\"graphics-document document\" viewBox=\"0 0 {w} {h}\" style=\"max-width: {w}px; background-color: {};\" class=\"requirementDiagram\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">",
|
||||
theme.background,
|
||||
w = layout.width,
|
||||
h = layout.height
|
||||
));
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<style>#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{};}}#my-svg .relationshipLine{{stroke:{};stroke-width:1;}}#my-svg .node rect{{fill:{};stroke:{};stroke-width:1.3;}}#my-svg .label{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:{};}}#my-svg .label text,#my-svg span{{fill:{};color:{};}}#my-svg .labelBkg{{background-color:rgba(232,232,232, 0.8);}}</style>",
|
||||
theme.text_color,
|
||||
theme.edge_color,
|
||||
theme.node_fill,
|
||||
theme.node_stroke,
|
||||
theme.text_color,
|
||||
theme.text_color,
|
||||
theme.text_color
|
||||
));
|
||||
|
||||
svg.push_str("<g>");
|
||||
svg.push_str(&format!(
|
||||
"<defs><marker orient=\"auto\" markerHeight=\"20\" markerWidth=\"20\" refY=\"10\" refX=\"0\" id=\"my-svg_requirement-requirement_containsStart\"><g fill=\"none\" stroke=\"{}\" stroke-width=\"1\"><circle r=\"9\" cy=\"10\" cx=\"10\"/><line y2=\"10\" y1=\"10\" x2=\"19\" x1=\"1\"/><line x2=\"10\" x1=\"10\" y2=\"19\" y1=\"1\"/></g></marker></defs>",
|
||||
theme.edge_color
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<defs><marker orient=\"auto\" markerHeight=\"20\" markerWidth=\"20\" refY=\"10\" refX=\"20\" id=\"my-svg_requirement-requirement_arrowEnd\"><path d=\"M0,0 L20,10 M20,10 L0,20\" fill=\"none\" stroke=\"{}\" stroke-width=\"1\"/></marker></defs>",
|
||||
theme.edge_color
|
||||
));
|
||||
|
||||
svg.push_str("<g class=\"root\">");
|
||||
svg.push_str("<g class=\"clusters\"/>");
|
||||
|
||||
svg.push_str("<g class=\"edgePaths\">");
|
||||
for edge in &layout.edges {
|
||||
let d = points_to_path_d(&edge.points);
|
||||
let is_contains = edge.rel_type == "contains";
|
||||
let dash = if is_contains {
|
||||
""
|
||||
} else {
|
||||
"stroke-dasharray: 10,7;"
|
||||
};
|
||||
let marker_end = if is_contains {
|
||||
""
|
||||
} else {
|
||||
" marker-end=\"url(#my-svg_requirement-requirement_arrowEnd)\""
|
||||
};
|
||||
svg.push_str(&format!(
|
||||
"<path{marker_end} style=\"fill:none;{dash}\" class=\"edge-thickness-normal edge-pattern-dashed relationshipLine\" id=\"{}\" d=\"{}\"/>",
|
||||
escape_xml(&edge.id),
|
||||
d
|
||||
));
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("<g class=\"edgeLabels\">");
|
||||
for edge in &layout.edges {
|
||||
let Some((x, y)) = edge.label_pos else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let x2 = -edge.label_width / 2.0;
|
||||
let y2 = -edge.label_height / 2.0;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<g transform=\"translate({x}, {y})\" class=\"edgeLabel\">",
|
||||
x = x,
|
||||
y = y,
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{x2}\" y=\"{y2}\" width=\"{w}\" height=\"{h}\" fill=\"#E8E8E8\" fill-opacity=\"0.8\" stroke=\"none\"/>",
|
||||
x2 = x2,
|
||||
y2 = y2,
|
||||
w = edge.label_width,
|
||||
h = edge.label_height,
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"middle\" fill=\"{}\">{}</text>",
|
||||
theme.text_color,
|
||||
escape_xml(&edge.label)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("<g class=\"nodes\">");
|
||||
let mut node_ids: Vec<&String> = layout.nodes.keys().collect();
|
||||
node_ids.sort();
|
||||
for node_id in node_ids {
|
||||
let Some(node_layout) = layout.nodes.get(node_id) else {
|
||||
continue;
|
||||
};
|
||||
let x2 = -node_layout.width / 2.0;
|
||||
let y2 = -node_layout.height / 2.0;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<g transform=\"translate({x},{y})\" id=\"{id}\" class=\"node default\">",
|
||||
x = node_layout.x,
|
||||
y = node_layout.y,
|
||||
id = escape_xml(&node_layout.id)
|
||||
));
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{x2}\" y=\"{y2}\" width=\"{w}\" height=\"{h}\"/>",
|
||||
x2 = x2,
|
||||
y2 = y2,
|
||||
w = node_layout.width,
|
||||
h = node_layout.height
|
||||
));
|
||||
|
||||
if let Some(divider_y) = node_layout.divider_y {
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{x1}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" stroke=\"{}\" stroke-width=\"1.3\"/>",
|
||||
theme.node_stroke,
|
||||
x1 = x2,
|
||||
x2 = x2 + node_layout.width,
|
||||
y = divider_y
|
||||
));
|
||||
}
|
||||
|
||||
for label in &node_layout.labels {
|
||||
let font_weight = if label.bold {
|
||||
" font-weight=\"bold\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{x}\" y=\"{y}\" text-anchor=\"{anchor}\" dominant-baseline=\"middle\" fill=\"{}\"{font_weight}>{}</text>",
|
||||
theme.text_color,
|
||||
escape_xml(&label.text),
|
||||
x = label.x,
|
||||
y = label.y,
|
||||
anchor = label.anchor,
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("</g></g></svg>");
|
||||
|
||||
svg
|
||||
}
|
||||
|
||||
fn points_to_path_d(points: &[(f64, f64)]) -> String {
|
||||
if points.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut d = String::new();
|
||||
let (x0, y0) = points[0];
|
||||
d.push_str(&format!("M{x0},{y0}"));
|
||||
for (x, y) in &points[1..] {
|
||||
d.push_str(&format!("L{x},{y}"));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+436
@@ -0,0 +1,436 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
const WIDTH: f64 = 600.0;
|
||||
const HEIGHT: f64 = 400.0;
|
||||
const NODE_WIDTH: f64 = 10.0;
|
||||
const NODE_PADDING: f64 = 25.0;
|
||||
const LABEL_OFFSET: f64 = 6.0;
|
||||
|
||||
const NODE_COLORS: [&str; 10] = [
|
||||
"#4e79a7", "#f28e2c", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#9c755f",
|
||||
"#bab0ab", "#ff9da7",
|
||||
];
|
||||
|
||||
pub fn render_sankey_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let diagram = parse_sankey(mermaid_source)?;
|
||||
|
||||
let layout = compute_layout(&diagram);
|
||||
|
||||
let mut svg = String::new();
|
||||
svg.push_str(&format!(
|
||||
"<svg aria-roledescription=\"sankey\" role=\"graphics-document document\" viewBox=\"0 0 {WIDTH} {HEIGHT}\" style=\"max-width: {WIDTH}px; background-color: {};\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100%\" id=\"my-svg\">",
|
||||
theme.background
|
||||
));
|
||||
|
||||
svg.push_str("<g/>");
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"0\" y=\"0\" width=\"{WIDTH}\" height=\"{HEIGHT}\" fill=\"{}\"/>",
|
||||
theme.background
|
||||
));
|
||||
|
||||
svg.push_str("<defs>");
|
||||
for (idx, link) in layout.links.iter().enumerate() {
|
||||
let grad_id = format!("linearGradient-{idx}");
|
||||
svg.push_str(&format!(
|
||||
"<linearGradient id=\"{grad_id}\" gradientUnits=\"userSpaceOnUse\" x1=\"{x1}\" x2=\"{x2}\">",
|
||||
x1 = link.x0,
|
||||
x2 = link.x1,
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<stop offset=\"0%\" stop-color=\"{}\"/>",
|
||||
escape_xml(&link.source_color)
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<stop offset=\"100%\" stop-color=\"{}\"/>",
|
||||
escape_xml(&link.target_color)
|
||||
));
|
||||
svg.push_str("</linearGradient>");
|
||||
}
|
||||
svg.push_str("</defs>");
|
||||
|
||||
svg.push_str("<g class=\"nodes\">");
|
||||
for node in &layout.nodes {
|
||||
svg.push_str(&format!(
|
||||
"<g class=\"node\" id=\"{}\" transform=\"translate({},{})\" x=\"{}\" y=\"{}\">",
|
||||
escape_xml(&node.dom_id),
|
||||
node.x,
|
||||
node.y,
|
||||
node.x,
|
||||
node.y
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<rect height=\"{}\" width=\"{}\" fill=\"{}\"/>",
|
||||
node.height,
|
||||
NODE_WIDTH,
|
||||
escape_xml(&node.color)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("<g class=\"node-labels\" font-size=\"14\">");
|
||||
for node in &layout.nodes {
|
||||
let center_y = node.y + node.height / 2.0;
|
||||
if node.depth == layout.max_depth {
|
||||
let x = node.x - LABEL_OFFSET;
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{x}\" y=\"{center_y}\" dy=\"0em\" text-anchor=\"end\">{}</text>",
|
||||
escape_xml(&node.display_label)
|
||||
));
|
||||
} else {
|
||||
let x = node.x + NODE_WIDTH + LABEL_OFFSET;
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{x}\" y=\"{center_y}\" dy=\"0em\" text-anchor=\"start\">{}</text>",
|
||||
escape_xml(&node.display_label)
|
||||
));
|
||||
}
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("<g class=\"links\" fill=\"none\" stroke-opacity=\"0.5\">");
|
||||
for (idx, link) in layout.links.iter().enumerate() {
|
||||
let grad_id = format!("linearGradient-{idx}");
|
||||
let mx = (link.x0 + link.x1) / 2.0;
|
||||
let d = format!(
|
||||
"M{sx},{sy}C{mx},{sy},{mx},{ty},{tx},{ty}",
|
||||
sx = link.x0,
|
||||
sy = link.y0,
|
||||
mx = mx,
|
||||
tx = link.x1,
|
||||
ty = link.y1,
|
||||
);
|
||||
svg.push_str("<g class=\"link\" style=\"mix-blend-mode: multiply;\">");
|
||||
svg.push_str(&format!(
|
||||
"<path d=\"{d}\" stroke=\"url(#{grad_id})\" stroke-width=\"{}\"/>",
|
||||
link.thickness
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("</svg>");
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SankeyDiagram {
|
||||
links: Vec<SankeyLink>,
|
||||
node_order: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SankeyLink {
|
||||
source: String,
|
||||
target: String,
|
||||
value: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SankeyNodeLayout {
|
||||
name: String,
|
||||
display_label: String,
|
||||
dom_id: String,
|
||||
depth: usize,
|
||||
x: f64,
|
||||
y: f64,
|
||||
height: f64,
|
||||
color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SankeyLinkLayout {
|
||||
x0: f64,
|
||||
y0: f64,
|
||||
x1: f64,
|
||||
y1: f64,
|
||||
thickness: f64,
|
||||
source_color: String,
|
||||
target_color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SankeyLayout {
|
||||
nodes: Vec<SankeyNodeLayout>,
|
||||
links: Vec<SankeyLinkLayout>,
|
||||
max_depth: usize,
|
||||
}
|
||||
|
||||
fn parse_sankey(input: &str) -> Result<SankeyDiagram, MermaidError> {
|
||||
let mut found_header = false;
|
||||
let mut links: Vec<SankeyLink> = Vec::new();
|
||||
let mut node_seen: BTreeSet<String> = BTreeSet::new();
|
||||
let mut node_order: Vec<String> = Vec::new();
|
||||
|
||||
for (idx, raw) in input.lines().enumerate() {
|
||||
let line_no = idx + 1;
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
if line.split_whitespace().next() != Some("sankey-beta") {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected 'sankey-beta' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
found_header = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.split(',').map(|p| p.trim()).collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid sankey link: {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
let source = parts[0].to_string();
|
||||
let target = parts[1].to_string();
|
||||
let value: f64 = parts[2].parse().map_err(|_| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid sankey value: {}", parts[2]),
|
||||
})?;
|
||||
|
||||
if node_seen.insert(source.clone()) {
|
||||
node_order.push(source.clone());
|
||||
}
|
||||
if node_seen.insert(target.clone()) {
|
||||
node_order.push(target.clone());
|
||||
}
|
||||
|
||||
links.push(SankeyLink {
|
||||
source,
|
||||
target,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Expected 'sankey-beta' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if links.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "sankey diagram requires at least one link".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SankeyDiagram { links, node_order })
|
||||
}
|
||||
|
||||
fn compute_layout(diagram: &SankeyDiagram) -> SankeyLayout {
|
||||
let mut in_sum: HashMap<&str, f64> = HashMap::new();
|
||||
let mut out_sum: HashMap<&str, f64> = HashMap::new();
|
||||
|
||||
for link in &diagram.links {
|
||||
*out_sum.entry(link.source.as_str()).or_insert(0.0) += link.value;
|
||||
*in_sum.entry(link.target.as_str()).or_insert(0.0) += link.value;
|
||||
}
|
||||
|
||||
let mut value_by_node: HashMap<&str, f64> = HashMap::new();
|
||||
for node in &diagram.node_order {
|
||||
let v_in = *in_sum.get(node.as_str()).unwrap_or(&0.0);
|
||||
let v_out = *out_sum.get(node.as_str()).unwrap_or(&0.0);
|
||||
value_by_node.insert(node.as_str(), v_in.max(v_out));
|
||||
}
|
||||
|
||||
let mut preds: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
for link in &diagram.links {
|
||||
preds
|
||||
.entry(link.target.as_str())
|
||||
.or_default()
|
||||
.push(link.source.as_str());
|
||||
preds.entry(link.source.as_str()).or_default();
|
||||
}
|
||||
|
||||
let mut depth: HashMap<&str, usize> = HashMap::new();
|
||||
for node in &diagram.node_order {
|
||||
depth.insert(node.as_str(), 0);
|
||||
}
|
||||
|
||||
let mut changed = true;
|
||||
for _ in 0..diagram.node_order.len().saturating_mul(2) {
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
changed = false;
|
||||
for node in &diagram.node_order {
|
||||
let node = node.as_str();
|
||||
let p = preds.get(node).map(|v| v.as_slice()).unwrap_or(&[]);
|
||||
let mut d = 0_usize;
|
||||
for &pred in p {
|
||||
d = d.max(depth.get(pred).copied().unwrap_or(0).saturating_add(1));
|
||||
}
|
||||
if depth.get(node).copied().unwrap_or(0) != d {
|
||||
depth.insert(node, d);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let max_depth = depth.values().copied().max().unwrap_or(0);
|
||||
let layers = max_depth.max(1) + 1;
|
||||
|
||||
let mut nodes_by_depth: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
|
||||
for node in &diagram.node_order {
|
||||
let d = depth.get(node.as_str()).copied().unwrap_or(0);
|
||||
nodes_by_depth.entry(d).or_default().push(node.as_str());
|
||||
}
|
||||
|
||||
let mut ky = f64::INFINITY;
|
||||
for nodes in nodes_by_depth.values() {
|
||||
let sum: f64 = nodes
|
||||
.iter()
|
||||
.map(|n| value_by_node.get(n).copied().unwrap_or(0.0))
|
||||
.sum();
|
||||
if sum <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let n = nodes.len() as f64;
|
||||
let available = HEIGHT - (n - 1.0).max(0.0) * NODE_PADDING;
|
||||
ky = ky.min(available / sum);
|
||||
}
|
||||
if !ky.is_finite() {
|
||||
ky = 1.0;
|
||||
}
|
||||
|
||||
let mut node_layout: HashMap<&str, SankeyNodeLayout> = HashMap::new();
|
||||
|
||||
for (d, nodes) in &nodes_by_depth {
|
||||
let sum: f64 = nodes
|
||||
.iter()
|
||||
.map(|n| value_by_node.get(n).copied().unwrap_or(0.0))
|
||||
.sum();
|
||||
let used = sum * ky + (nodes.len().saturating_sub(1) as f64) * NODE_PADDING;
|
||||
let mut y = (HEIGHT - used) / 2.0;
|
||||
|
||||
let x = if layers <= 1 {
|
||||
0.0
|
||||
} else {
|
||||
(WIDTH - NODE_WIDTH) * (*d as f64) / ((layers - 1) as f64)
|
||||
};
|
||||
|
||||
for &name in nodes {
|
||||
let v = value_by_node.get(name).copied().unwrap_or(0.0);
|
||||
let h = v * ky;
|
||||
|
||||
let global_idx = diagram
|
||||
.node_order
|
||||
.iter()
|
||||
.position(|n| n == name)
|
||||
.unwrap_or(0);
|
||||
let dom_id = format!("node-{}", global_idx + 1);
|
||||
let color = NODE_COLORS
|
||||
.get(global_idx)
|
||||
.copied()
|
||||
.unwrap_or(NODE_COLORS[0])
|
||||
.to_string();
|
||||
|
||||
node_layout.insert(
|
||||
name,
|
||||
SankeyNodeLayout {
|
||||
name: name.to_string(),
|
||||
display_label: format_sankey_node_label(name, v),
|
||||
dom_id,
|
||||
depth: *d,
|
||||
x,
|
||||
y,
|
||||
height: h,
|
||||
color,
|
||||
},
|
||||
);
|
||||
y += h + NODE_PADDING;
|
||||
}
|
||||
}
|
||||
|
||||
let mut out_offset: HashMap<&str, f64> = HashMap::new();
|
||||
let mut in_offset: HashMap<&str, f64> = HashMap::new();
|
||||
|
||||
for node in &diagram.node_order {
|
||||
out_offset.insert(node.as_str(), 0.0);
|
||||
in_offset.insert(node.as_str(), 0.0);
|
||||
}
|
||||
|
||||
let mut link_layouts = Vec::new();
|
||||
for link in &diagram.links {
|
||||
let Some(source_node) = node_layout.get(link.source.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(target_node) = node_layout.get(link.target.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let thickness = link.value * ky;
|
||||
|
||||
let so = *out_offset.get(link.source.as_str()).unwrap_or(&0.0);
|
||||
let ti = *in_offset.get(link.target.as_str()).unwrap_or(&0.0);
|
||||
|
||||
let y0 = source_node.y + so + thickness / 2.0;
|
||||
let y1 = target_node.y + ti + thickness / 2.0;
|
||||
|
||||
out_offset.insert(link.source.as_str(), so + thickness);
|
||||
in_offset.insert(link.target.as_str(), ti + thickness);
|
||||
|
||||
let x0 = source_node.x + NODE_WIDTH;
|
||||
let x1 = target_node.x;
|
||||
|
||||
link_layouts.push(SankeyLinkLayout {
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
thickness,
|
||||
source_color: source_node.color.clone(),
|
||||
target_color: target_node.color.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut nodes_vec: Vec<SankeyNodeLayout> = diagram
|
||||
.node_order
|
||||
.iter()
|
||||
.filter_map(|n| node_layout.get(n.as_str()).cloned())
|
||||
.collect();
|
||||
|
||||
nodes_vec.sort_by(|a, b| {
|
||||
a.depth
|
||||
.cmp(&b.depth)
|
||||
.then_with(|| a.y.total_cmp(&b.y))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
|
||||
SankeyLayout {
|
||||
nodes: nodes_vec,
|
||||
links: link_layouts,
|
||||
max_depth,
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn format_sankey_node_label(name: &str, value: f64) -> String {
|
||||
if value.fract().abs() < f64::EPSILON {
|
||||
format!("{name} {}", value as i64)
|
||||
} else {
|
||||
format!("{name} {value}")
|
||||
}
|
||||
}
|
||||
+1326
File diff suppressed because it is too large
Load Diff
+172
@@ -0,0 +1,172 @@
|
||||
use crate::ast::{Edge, EdgeStyle, FlowchartGraph, GraphDirection, Node, NodeShape, Statement};
|
||||
use crate::error::MermaidError;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn parse_state_diagram(input: &str) -> Result<FlowchartGraph, MermaidError> {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
|
||||
let mut i = 0_usize;
|
||||
let mut header: Option<&str> = None;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let token = line.split_whitespace().next().unwrap_or("");
|
||||
if token == "stateDiagram" || token == "stateDiagram-v2" {
|
||||
header = Some(token);
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
return Err(MermaidError::ParseError {
|
||||
line: i + 1,
|
||||
message: "Expected 'stateDiagram' or 'stateDiagram-v2' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if header.is_none() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Expected 'stateDiagram' or 'stateDiagram-v2' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut nodes: BTreeMap<String, NodeShape> = BTreeMap::new();
|
||||
let mut node_order: Vec<String> = Vec::new();
|
||||
let mut edges: Vec<(String, String, Option<String>)> = Vec::new();
|
||||
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
let line = raw.trim();
|
||||
let line_no = i + 1;
|
||||
i += 1;
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("state ") {
|
||||
let rest = rest.trim();
|
||||
if rest.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected state name after 'state'".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let name = rest.split_whitespace().next().unwrap_or("").to_string();
|
||||
let shape = if rest.contains("<<choice>>") {
|
||||
NodeShape::Diamond
|
||||
} else if rest.contains("<<fork>>") || rest.contains("<<join>>") {
|
||||
NodeShape::ForkJoin
|
||||
} else {
|
||||
NodeShape::RoundedRectangle
|
||||
};
|
||||
|
||||
if !nodes.contains_key(&name) {
|
||||
node_order.push(name.clone());
|
||||
}
|
||||
nodes.insert(name, shape);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((from_raw, rhs)) = line.split_once("-->") {
|
||||
let from_raw = from_raw.trim();
|
||||
let rhs = rhs.trim();
|
||||
|
||||
let (to_raw, label) = match rhs.split_once(':') {
|
||||
Some((a, b)) => {
|
||||
let label = b.trim();
|
||||
(
|
||||
a.trim(),
|
||||
if label.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(label.to_string())
|
||||
},
|
||||
)
|
||||
}
|
||||
None => (rhs, None),
|
||||
};
|
||||
|
||||
let from = normalize_state_id(from_raw, true);
|
||||
let to = normalize_state_id(to_raw, false);
|
||||
|
||||
ensure_state_node(&mut nodes, &mut node_order, &from);
|
||||
ensure_state_node(&mut nodes, &mut node_order, &to);
|
||||
edges.push((from, to, label));
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Unrecognized stateDiagram line: {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
let mut statements: Vec<Statement> = Vec::new();
|
||||
for id in node_order {
|
||||
let Some(shape) = nodes.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
let label = match shape {
|
||||
NodeShape::StartState | NodeShape::EndState | NodeShape::ForkJoin => None,
|
||||
_ => Some(id.clone()),
|
||||
};
|
||||
|
||||
statements.push(Statement::Node(Node {
|
||||
id: id.clone(),
|
||||
label,
|
||||
shape: *shape,
|
||||
}));
|
||||
}
|
||||
|
||||
for (from, to, label) in edges {
|
||||
statements.push(Statement::Edge(Edge {
|
||||
from,
|
||||
to,
|
||||
label,
|
||||
style: EdgeStyle::Arrow,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(FlowchartGraph {
|
||||
direction: GraphDirection::TopToBottom,
|
||||
statements,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_state_id(raw: &str, is_from: bool) -> String {
|
||||
let raw = raw.trim();
|
||||
if raw == "[*]" {
|
||||
if is_from {
|
||||
"__start".to_string()
|
||||
} else {
|
||||
"__end".to_string()
|
||||
}
|
||||
} else {
|
||||
raw.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_state_node(
|
||||
nodes: &mut BTreeMap<String, NodeShape>,
|
||||
node_order: &mut Vec<String>,
|
||||
id: &str,
|
||||
) {
|
||||
if nodes.contains_key(id) {
|
||||
return;
|
||||
}
|
||||
|
||||
node_order.push(id.to_string());
|
||||
let shape = match id {
|
||||
"__start" => NodeShape::StartState,
|
||||
"__end" => NodeShape::EndState,
|
||||
_ => NodeShape::RoundedRectangle,
|
||||
};
|
||||
nodes.insert(id.to_string(), shape);
|
||||
}
|
||||
+990
@@ -0,0 +1,990 @@
|
||||
use crate::ast::{EdgeStyle, NodeShape};
|
||||
use crate::config::RenderConfig;
|
||||
use crate::layout::{LayoutEdge, LayoutNode, LayoutResult, LayoutSubgraph};
|
||||
use crate::text_wrap::{
|
||||
line_width_words, measure_wrapped_lines_with_font_size, scale_char_width, wrap_text_lines,
|
||||
wrapped_text_height_with_font_size, DEFAULT_CHAR_WIDTH, DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_WRAP_WIDTH,
|
||||
};
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
/// The arrowhead marker has refX="5" with viewBox 0..10 and markerWidth 8.
|
||||
/// The tip at viewBox x=10 extends (10−5)/10 × 8 = 4 px past the reference point.
|
||||
/// We shorten each arrowed edge by this amount so the tip lands exactly on the
|
||||
/// target node border — matching how mermaid.js renders edges.
|
||||
const EDGE_ARROWHEAD_OFFSET: f64 = 4.0;
|
||||
const EDGE_ARROWHEAD_OFFSET_THICK: f64 = 5.5; // markerWidth 11 × (10−5)/10
|
||||
|
||||
const EDGE_LABEL_CHAR_WIDTH: f64 = DEFAULT_CHAR_WIDTH;
|
||||
const EDGE_LABEL_PADDING_H: f64 = 2.0;
|
||||
const EDGE_LABEL_PADDING_V: f64 = 2.0;
|
||||
const EDGE_LABEL_BG_OPACITY: f64 = 0.8;
|
||||
const SUBGRAPH_TITLE_TOP_MARGIN: f64 = 0.0;
|
||||
const STATE_CHAR_WIDTH: f64 = 6.7;
|
||||
const DEFAULT_FONT_FAMILY: &str = "Trebuchet MS, verdana, arial, sans-serif";
|
||||
|
||||
pub fn render(layout: &LayoutResult, theme: &MermaidTheme) -> String {
|
||||
render_with_config(layout, theme, &RenderConfig::default())
|
||||
}
|
||||
|
||||
pub fn render_with_config(
|
||||
layout: &LayoutResult,
|
||||
theme: &MermaidTheme,
|
||||
config: &RenderConfig,
|
||||
) -> String {
|
||||
let is_state_diagram = layout.nodes.values().any(|node| {
|
||||
matches!(
|
||||
node.shape,
|
||||
NodeShape::StartState | NodeShape::EndState | NodeShape::ForkJoin
|
||||
)
|
||||
});
|
||||
let mut svg = SvgRenderer::new(
|
||||
layout.width,
|
||||
layout.height,
|
||||
theme,
|
||||
is_state_diagram,
|
||||
SvgRenderOptions::from_render_config(config),
|
||||
);
|
||||
svg.render(layout)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EdgeCurve {
|
||||
Basis,
|
||||
Linear,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SvgRenderOptions {
|
||||
font_family: String,
|
||||
font_size: f64,
|
||||
wrapping_width: f64,
|
||||
edge_curve: EdgeCurve,
|
||||
}
|
||||
|
||||
impl Default for SvgRenderOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
font_family: DEFAULT_FONT_FAMILY.to_string(),
|
||||
font_size: DEFAULT_FONT_SIZE,
|
||||
wrapping_width: DEFAULT_WRAP_WIDTH,
|
||||
edge_curve: EdgeCurve::Basis,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SvgRenderOptions {
|
||||
fn from_render_config(config: &RenderConfig) -> Self {
|
||||
let default = Self::default();
|
||||
Self {
|
||||
font_family: config.font_family.clone().unwrap_or(default.font_family),
|
||||
font_size: config.font_size_px().unwrap_or(default.font_size),
|
||||
wrapping_width: config
|
||||
.flowchart
|
||||
.wrapping_width
|
||||
.map(f64::from)
|
||||
.unwrap_or(default.wrapping_width),
|
||||
edge_curve: config
|
||||
.flowchart
|
||||
.curve
|
||||
.as_deref()
|
||||
.map(EdgeCurve::from_mermaid_name)
|
||||
.unwrap_or(default.edge_curve),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EdgeCurve {
|
||||
fn from_mermaid_name(name: &str) -> Self {
|
||||
if name.eq_ignore_ascii_case("linear") {
|
||||
Self::Linear
|
||||
} else {
|
||||
Self::Basis
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SvgRenderer<'a> {
|
||||
width: f64,
|
||||
height: f64,
|
||||
theme: &'a MermaidTheme,
|
||||
is_state_diagram: bool,
|
||||
options: SvgRenderOptions,
|
||||
output: String,
|
||||
}
|
||||
|
||||
impl<'a> SvgRenderer<'a> {
|
||||
fn new(
|
||||
width: f64,
|
||||
height: f64,
|
||||
theme: &'a MermaidTheme,
|
||||
is_state_diagram: bool,
|
||||
options: SvgRenderOptions,
|
||||
) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
theme,
|
||||
is_state_diagram,
|
||||
options,
|
||||
output: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, layout: &LayoutResult) -> String {
|
||||
self.write_header();
|
||||
self.write_defs();
|
||||
|
||||
for subgraph in &layout.subgraphs {
|
||||
self.render_subgraph_background(subgraph);
|
||||
}
|
||||
|
||||
for edge in &layout.edges {
|
||||
self.render_edge_line(edge);
|
||||
}
|
||||
|
||||
let mut nodes: Vec<&LayoutNode> = layout.nodes.values().collect();
|
||||
nodes.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
for node in nodes {
|
||||
self.render_node(node);
|
||||
}
|
||||
|
||||
for subgraph in &layout.subgraphs {
|
||||
self.render_subgraph_title(subgraph);
|
||||
}
|
||||
|
||||
self.render_edge_labels(&layout.edges);
|
||||
|
||||
self.write_footer();
|
||||
std::mem::take(&mut self.output)
|
||||
}
|
||||
|
||||
/// Matches mermaid's SVG behavior: sizing via setupGraphViewbox.js and background via SVG style
|
||||
/// (mermaid-cli src/index.js sets svg.style.backgroundColor), with a background rect for rasterizers.
|
||||
fn write_header(&mut self) {
|
||||
self.output.push_str(&format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="{:.0}" height="{:.0}" viewBox="0 0 {:.0} {:.0}" xmlns="http://www.w3.org/2000/svg" style="background-color: {};">
|
||||
<rect x="0" y="0" width="{:.0}" height="{:.0}" fill="{}" stroke="none"/>
|
||||
"#,
|
||||
self.width,
|
||||
self.height,
|
||||
self.width,
|
||||
self.height,
|
||||
self.theme.background,
|
||||
self.width,
|
||||
self.height,
|
||||
self.theme.background
|
||||
));
|
||||
}
|
||||
|
||||
fn write_defs(&mut self) {
|
||||
self.output.push_str(&format!(
|
||||
r#"<defs>
|
||||
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="5" refY="5" orient="auto" markerUnits="userSpaceOnUse" viewBox="0 0 10 10">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
</marker>
|
||||
<marker id="arrowhead-thick" markerWidth="11" markerHeight="11" refX="5" refY="5" orient="auto" markerUnits="userSpaceOnUse" viewBox="0 0 10 10">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="{0}" stroke="{0}" stroke-width="1"/>
|
||||
</marker>
|
||||
</defs>
|
||||
"#,
|
||||
self.theme.edge_color, self.theme.edge_color
|
||||
));
|
||||
}
|
||||
|
||||
fn write_footer(&mut self) {
|
||||
self.output.push_str("</svg>\n");
|
||||
}
|
||||
|
||||
fn render_subgraph_background(&mut self, subgraph: &LayoutSubgraph) {
|
||||
self.output.push_str(&format!(
|
||||
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>\n"#,
|
||||
subgraph.x, subgraph.y, subgraph.width, subgraph.height,
|
||||
self.theme.subgraph_fill, self.theme.subgraph_stroke
|
||||
));
|
||||
}
|
||||
|
||||
fn render_subgraph_title(&mut self, subgraph: &LayoutSubgraph) {
|
||||
if let Some(title) = &subgraph.title {
|
||||
let char_width = scale_char_width(DEFAULT_CHAR_WIDTH, self.options.font_size);
|
||||
let lines = wrap_text_lines(title, self.options.wrapping_width, char_width);
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (_, text_height) =
|
||||
measure_wrapped_lines_with_font_size(&lines, char_width, self.options.font_size);
|
||||
let title_x = subgraph.x + subgraph.width / 2.0;
|
||||
let title_y = subgraph.y + SUBGRAPH_TITLE_TOP_MARGIN + text_height / 2.0;
|
||||
self.render_text_lines(
|
||||
title_x,
|
||||
title_y,
|
||||
&lines,
|
||||
self.options.font_size,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
&self.theme.text_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_node(&mut self, node: &LayoutNode) {
|
||||
match node.shape {
|
||||
NodeShape::Rectangle => self.render_rectangle(node, 0.0),
|
||||
NodeShape::RoundedRectangle => self.render_rectangle(node, 5.0),
|
||||
NodeShape::Stadium => self.render_rectangle(node, node.height / 2.0),
|
||||
NodeShape::Diamond => self.render_diamond(node),
|
||||
NodeShape::Circle => self.render_circle(node),
|
||||
NodeShape::StartState => self.render_start_state(node),
|
||||
NodeShape::EndState => self.render_end_state(node),
|
||||
NodeShape::ForkJoin => self.render_fork_join(node),
|
||||
NodeShape::Hexagon => self.render_hexagon(node),
|
||||
NodeShape::Cylinder => self.render_cylinder(node),
|
||||
NodeShape::Subroutine => self.render_subroutine(node),
|
||||
NodeShape::Asymmetric => self.render_asymmetric(node),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_rectangle(&mut self, node: &LayoutNode, rx: f64) {
|
||||
let x = node.x - node.width / 2.0;
|
||||
let y = node.y - node.height / 2.0;
|
||||
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
|
||||
let stroke = node
|
||||
.stroke_color
|
||||
.as_ref()
|
||||
.unwrap_or(&self.theme.node_stroke);
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
x, y, node.width, node.height, rx, fill, stroke
|
||||
));
|
||||
|
||||
self.render_text(node.x, node.y, &node.label);
|
||||
}
|
||||
|
||||
fn render_start_state(&mut self, node: &LayoutNode) {
|
||||
let r = node.width.min(node.height) / 2.0;
|
||||
self.output.push_str(&format!(
|
||||
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="{}" stroke-width="1.5"/>
|
||||
"#,
|
||||
node.x, node.y, r, self.theme.edge_color, self.theme.edge_color
|
||||
));
|
||||
}
|
||||
|
||||
fn render_end_state(&mut self, node: &LayoutNode) {
|
||||
let outer_r = node.width.min(node.height) / 2.0;
|
||||
let inner_r = (outer_r - 4.0).max(outer_r * 0.55).min(outer_r - 2.0);
|
||||
self.output.push_str(&format!(
|
||||
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
node.x, node.y, outer_r, self.theme.node_stroke, self.theme.background
|
||||
));
|
||||
self.output.push_str(&format!(
|
||||
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="none"/>
|
||||
"#,
|
||||
node.x, node.y, inner_r, self.theme.background
|
||||
));
|
||||
}
|
||||
|
||||
fn render_fork_join(&mut self, node: &LayoutNode) {
|
||||
let x = node.x - node.width / 2.0;
|
||||
let y = node.y - node.height / 2.0;
|
||||
self.output.push_str(&format!(
|
||||
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="1" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
x, y, node.width, node.height, self.theme.edge_color, self.theme.edge_color
|
||||
));
|
||||
}
|
||||
|
||||
fn render_diamond(&mut self, node: &LayoutNode) {
|
||||
let hw = node.width / 2.0;
|
||||
let hh = node.height / 2.0;
|
||||
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
|
||||
let stroke = node
|
||||
.stroke_color
|
||||
.as_ref()
|
||||
.unwrap_or(&self.theme.node_stroke);
|
||||
|
||||
let points = format!(
|
||||
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||
node.x,
|
||||
node.y - hh,
|
||||
node.x + hw,
|
||||
node.y,
|
||||
node.x,
|
||||
node.y + hh,
|
||||
node.x - hw,
|
||||
node.y
|
||||
);
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<polygon points="{}" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
points, fill, stroke
|
||||
));
|
||||
|
||||
self.render_text(node.x, node.y, &node.label);
|
||||
}
|
||||
|
||||
fn render_circle(&mut self, node: &LayoutNode) {
|
||||
let r = node.width.min(node.height) / 2.0;
|
||||
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
|
||||
let stroke = node
|
||||
.stroke_color
|
||||
.as_ref()
|
||||
.unwrap_or(&self.theme.node_stroke);
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
node.x, node.y, r, fill, stroke
|
||||
));
|
||||
|
||||
self.render_text(node.x, node.y, &node.label);
|
||||
}
|
||||
|
||||
fn render_hexagon(&mut self, node: &LayoutNode) {
|
||||
let hw = node.width / 2.0;
|
||||
let hh = node.height / 2.0;
|
||||
let inset = node.height / 3.0;
|
||||
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
|
||||
let stroke = node
|
||||
.stroke_color
|
||||
.as_ref()
|
||||
.unwrap_or(&self.theme.node_stroke);
|
||||
|
||||
let points = format!(
|
||||
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||
node.x - hw + inset,
|
||||
node.y - hh,
|
||||
node.x + hw - inset,
|
||||
node.y - hh,
|
||||
node.x + hw,
|
||||
node.y,
|
||||
node.x + hw - inset,
|
||||
node.y + hh,
|
||||
node.x - hw + inset,
|
||||
node.y + hh,
|
||||
node.x - hw,
|
||||
node.y
|
||||
);
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<polygon points="{}" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
points, fill, stroke
|
||||
));
|
||||
|
||||
self.render_text(node.x, node.y, &node.label);
|
||||
}
|
||||
|
||||
fn render_cylinder(&mut self, node: &LayoutNode) {
|
||||
let hw = node.width / 2.0;
|
||||
let hh = node.height / 2.0;
|
||||
let ellipse_ry = (hw / 4.0).min(hh / 2.0);
|
||||
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
|
||||
let stroke = node
|
||||
.stroke_color
|
||||
.as_ref()
|
||||
.unwrap_or(&self.theme.node_stroke);
|
||||
|
||||
let x = node.x - hw;
|
||||
let y = node.y - hh;
|
||||
let body_top = y + ellipse_ry;
|
||||
let body_bottom = node.y + hh - ellipse_ry;
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<path d="M {:.1} {:.1} L {:.1} {:.1} A {:.1} {:.1} 0 0 0 {:.1} {:.1} L {:.1} {:.1} A {:.1} {:.1} 0 0 0 {:.1} {:.1} Z" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
x,
|
||||
body_top,
|
||||
x,
|
||||
body_bottom,
|
||||
hw,
|
||||
ellipse_ry,
|
||||
node.x + hw,
|
||||
body_bottom,
|
||||
node.x + hw,
|
||||
body_top,
|
||||
hw,
|
||||
ellipse_ry,
|
||||
x,
|
||||
body_top,
|
||||
fill,
|
||||
stroke
|
||||
));
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<ellipse cx="{:.1}" cy="{:.1}" rx="{:.1}" ry="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
node.x, body_top, hw, ellipse_ry, fill, stroke
|
||||
));
|
||||
|
||||
// Center text in the cylinder body (below the top ellipse cap)
|
||||
let body_center_y = (body_top + body_bottom) / 2.0;
|
||||
self.render_text(node.x, body_center_y, &node.label);
|
||||
}
|
||||
|
||||
fn render_subroutine(&mut self, node: &LayoutNode) {
|
||||
let x = node.x - node.width / 2.0;
|
||||
let y = node.y - node.height / 2.0;
|
||||
let bar_inset = 8.0;
|
||||
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
|
||||
let stroke = node
|
||||
.stroke_color
|
||||
.as_ref()
|
||||
.unwrap_or(&self.theme.node_stroke);
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
x, y, node.width, node.height, fill, stroke
|
||||
));
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
x + bar_inset,
|
||||
y,
|
||||
x + bar_inset,
|
||||
y + node.height,
|
||||
stroke
|
||||
));
|
||||
self.output.push_str(&format!(
|
||||
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
x + node.width - bar_inset,
|
||||
y,
|
||||
x + node.width - bar_inset,
|
||||
y + node.height,
|
||||
stroke
|
||||
));
|
||||
|
||||
self.render_text(node.x, node.y, &node.label);
|
||||
}
|
||||
|
||||
fn render_asymmetric(&mut self, node: &LayoutNode) {
|
||||
let hw = node.width / 2.0;
|
||||
let hh = node.height / 2.0;
|
||||
let point_offset = hh;
|
||||
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
|
||||
let stroke = node
|
||||
.stroke_color
|
||||
.as_ref()
|
||||
.unwrap_or(&self.theme.node_stroke);
|
||||
|
||||
// Mermaid's `>text]` flag shape: indent (V-notch) on the LEFT, flat RIGHT.
|
||||
let points = format!(
|
||||
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||
node.x - hw + point_offset,
|
||||
node.y - hh,
|
||||
node.x + hw,
|
||||
node.y - hh,
|
||||
node.x + hw,
|
||||
node.y + hh,
|
||||
node.x - hw + point_offset,
|
||||
node.y + hh,
|
||||
node.x - hw,
|
||||
node.y,
|
||||
);
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<polygon points="{}" fill="{}" stroke="{}" stroke-width="1"/>
|
||||
"#,
|
||||
points, fill, stroke
|
||||
));
|
||||
|
||||
self.render_text(node.x + point_offset / 4.0, node.y, &node.label);
|
||||
}
|
||||
|
||||
fn render_text(&mut self, x: f64, y: f64, text: &str) {
|
||||
let char_width = if self.is_state_diagram {
|
||||
scale_char_width(STATE_CHAR_WIDTH, self.options.font_size)
|
||||
} else {
|
||||
scale_char_width(DEFAULT_CHAR_WIDTH, self.options.font_size)
|
||||
};
|
||||
let lines = wrap_text_lines(text, self.options.wrapping_width, char_width);
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.render_text_lines(
|
||||
x,
|
||||
y,
|
||||
&lines,
|
||||
self.options.font_size,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
&self.theme.text_color,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_text_lines(
|
||||
&mut self,
|
||||
x: f64,
|
||||
y: f64,
|
||||
lines: &[Vec<String>],
|
||||
font_size: f64,
|
||||
line_height: f64,
|
||||
color: &str,
|
||||
) {
|
||||
let line_height_px = font_size * line_height;
|
||||
// With dominant-baseline="central", the y attribute positions the vertical
|
||||
// center of the text glyph. We distribute n lines evenly around the center y.
|
||||
let start_y = y - (lines.len() as f64 - 1.0) * line_height_px / 2.0;
|
||||
|
||||
let font_family = Self::escape_xml(&self.options.font_family);
|
||||
self.output.push_str(&format!(
|
||||
r#"<text text-anchor="middle" dominant-baseline="central" font-family="{}" font-size="{:.0}" fill="{}">
|
||||
"#,
|
||||
font_family, font_size, color
|
||||
));
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let line_y = start_y + (i as f64 * line_height_px);
|
||||
let line_text = line.join(" ");
|
||||
self.output.push_str(&format!(
|
||||
r#"<tspan x="{:.1}" y="{:.1}">{}</tspan>"#,
|
||||
x,
|
||||
line_y,
|
||||
Self::escape_xml(&line_text)
|
||||
));
|
||||
self.output.push('\n');
|
||||
}
|
||||
|
||||
self.output.push_str("</text>\n");
|
||||
}
|
||||
|
||||
/// Matches Mermaid flowchart edge thickness/pattern defaults
|
||||
/// (see packages/mermaid/src/diagrams/flowchart/styles.ts and rendering-elements/edges.js).
|
||||
fn render_edge_line(&mut self, edge: &LayoutEdge) {
|
||||
if edge.points.len() < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let has_arrow = matches!(
|
||||
edge.style,
|
||||
EdgeStyle::Arrow | EdgeStyle::DottedArrow | EdgeStyle::ThickArrow
|
||||
);
|
||||
let is_dotted = matches!(edge.style, EdgeStyle::DottedArrow | EdgeStyle::DottedLine);
|
||||
let is_thick = matches!(edge.style, EdgeStyle::ThickArrow | EdgeStyle::ThickLine);
|
||||
|
||||
let marker = match (has_arrow, is_thick) {
|
||||
(true, true) => r#" marker-end="url(#arrowhead-thick)""#,
|
||||
(true, false) => r#" marker-end="url(#arrowhead)""#,
|
||||
_ => "",
|
||||
};
|
||||
let stroke_width = if is_thick { 3.5 } else { 1.0 };
|
||||
let dash_array = if is_dotted {
|
||||
// Match mermaid's dotted style: round-capped short dashes for dot look
|
||||
r#" stroke-dasharray="3 3""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let mut points = edge.points.clone();
|
||||
if has_arrow {
|
||||
let offset = if is_thick {
|
||||
EDGE_ARROWHEAD_OFFSET_THICK
|
||||
} else {
|
||||
EDGE_ARROWHEAD_OFFSET
|
||||
};
|
||||
Self::shorten_end_for_marker(&mut points, offset);
|
||||
}
|
||||
|
||||
let d = self.edge_path_d(&points);
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<path d="{}" fill="none" stroke="{}" stroke-width="{:.1}" stroke-linecap="round" stroke-linejoin="round"{}{}/>
|
||||
"#,
|
||||
d, self.theme.edge_color, stroke_width, dash_array, marker
|
||||
));
|
||||
}
|
||||
|
||||
fn shorten_end_for_marker(points: &mut [(f64, f64)], offset: f64) {
|
||||
if points.len() < 2 || offset <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let last_idx = points.len() - 1;
|
||||
let prev = points[last_idx - 1];
|
||||
let last = points[last_idx];
|
||||
|
||||
let dx = last.0 - prev.0;
|
||||
let dy = last.1 - prev.1;
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len <= offset {
|
||||
return;
|
||||
}
|
||||
|
||||
let ux = dx / len;
|
||||
let uy = dy / len;
|
||||
points[last_idx] = (last.0 - ux * offset, last.1 - uy * offset);
|
||||
}
|
||||
|
||||
fn edge_path_d(&self, points: &[(f64, f64)]) -> String {
|
||||
match self.options.edge_curve {
|
||||
EdgeCurve::Basis => {
|
||||
let points = Self::fix_corners(points);
|
||||
Self::basis_spline_path_d(&points)
|
||||
}
|
||||
EdgeCurve::Linear => Self::linear_path_d(points),
|
||||
}
|
||||
}
|
||||
|
||||
fn linear_path_d(points: &[(f64, f64)]) -> String {
|
||||
let Some((first_x, first_y)) = points.first().copied() else {
|
||||
return String::new();
|
||||
};
|
||||
let mut d = format!("M{first_x:.1},{first_y:.1}");
|
||||
for (x, y) in points.iter().skip(1) {
|
||||
d.push_str(&format!("L{x:.1},{y:.1}"));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn basis_spline_path_d(points: &[(f64, f64)]) -> String {
|
||||
if points.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut d = String::new();
|
||||
|
||||
let mut x0 = f64::NAN;
|
||||
let mut y0 = f64::NAN;
|
||||
let mut x1 = f64::NAN;
|
||||
let mut y1 = f64::NAN;
|
||||
let mut point_state = 0;
|
||||
|
||||
for &(x, y) in points {
|
||||
match point_state {
|
||||
0 => {
|
||||
point_state = 1;
|
||||
d.push_str(&format!("M{x:.1},{y:.1}"));
|
||||
}
|
||||
1 => {
|
||||
point_state = 2;
|
||||
}
|
||||
2 => {
|
||||
point_state = 3;
|
||||
d.push_str(&format!(
|
||||
"L{:.1},{:.1}",
|
||||
(5.0 * x0 + x1) / 6.0,
|
||||
(5.0 * y0 + y1) / 6.0
|
||||
));
|
||||
d.push_str(&Self::basis_point(x0, y0, x1, y1, x, y));
|
||||
}
|
||||
_ => {
|
||||
d.push_str(&Self::basis_point(x0, y0, x1, y1, x, y));
|
||||
}
|
||||
}
|
||||
|
||||
x0 = x1;
|
||||
x1 = x;
|
||||
y0 = y1;
|
||||
y1 = y;
|
||||
}
|
||||
|
||||
match point_state {
|
||||
3 => {
|
||||
d.push_str(&Self::basis_point(x0, y0, x1, y1, x1, y1));
|
||||
d.push_str(&format!("L{x1:.1},{y1:.1}"));
|
||||
}
|
||||
2 => {
|
||||
d.push_str(&format!("L{x1:.1},{y1:.1}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
d
|
||||
}
|
||||
|
||||
fn basis_point(x0: f64, y0: f64, x1: f64, y1: f64, x: f64, y: f64) -> String {
|
||||
format!(
|
||||
"C{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||
(2.0 * x0 + x1) / 3.0,
|
||||
(2.0 * y0 + y1) / 3.0,
|
||||
(x0 + 2.0 * x1) / 3.0,
|
||||
(y0 + 2.0 * y1) / 3.0,
|
||||
(x0 + 4.0 * x1 + x) / 6.0,
|
||||
(y0 + 4.0 * y1 + y) / 6.0,
|
||||
)
|
||||
}
|
||||
|
||||
fn fix_corners(points: &[(f64, f64)]) -> Vec<(f64, f64)> {
|
||||
let corner_positions = Self::corner_positions(points);
|
||||
let mut new_points = Vec::new();
|
||||
for (idx, point) in points.iter().enumerate() {
|
||||
if corner_positions.contains(&idx) {
|
||||
let prev_point = points[idx - 1];
|
||||
let next_point = points[idx + 1];
|
||||
let corner_point = *point;
|
||||
let new_prev = Self::find_adjacent_point(prev_point, corner_point, 5.0);
|
||||
let new_next = Self::find_adjacent_point(next_point, corner_point, 5.0);
|
||||
let x_diff = new_next.0 - new_prev.0;
|
||||
let y_diff = new_next.1 - new_prev.1;
|
||||
let mut new_corner = corner_point;
|
||||
let a = (2.0_f64).sqrt() * 2.0;
|
||||
if (next_point.0 - prev_point.0).abs() > 10.0
|
||||
&& (next_point.1 - prev_point.1).abs() >= 10.0
|
||||
{
|
||||
if (corner_point.0 - new_prev.0).abs() < f64::EPSILON {
|
||||
new_corner = (
|
||||
if x_diff < 0.0 {
|
||||
new_prev.0 - 5.0 + a
|
||||
} else {
|
||||
new_prev.0 + 5.0 - a
|
||||
},
|
||||
if y_diff < 0.0 {
|
||||
new_prev.1 - a
|
||||
} else {
|
||||
new_prev.1 + a
|
||||
},
|
||||
);
|
||||
} else {
|
||||
new_corner = (
|
||||
if x_diff < 0.0 {
|
||||
new_prev.0 - a
|
||||
} else {
|
||||
new_prev.0 + a
|
||||
},
|
||||
if y_diff < 0.0 {
|
||||
new_prev.1 - 5.0 + a
|
||||
} else {
|
||||
new_prev.1 + 5.0 - a
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
new_points.push(new_prev);
|
||||
new_points.push(new_corner);
|
||||
new_points.push(new_next);
|
||||
} else {
|
||||
new_points.push(*point);
|
||||
}
|
||||
}
|
||||
new_points
|
||||
}
|
||||
|
||||
fn corner_positions(points: &[(f64, f64)]) -> Vec<usize> {
|
||||
let mut positions = Vec::new();
|
||||
if points.len() < 3 {
|
||||
return positions;
|
||||
}
|
||||
for i in 1..points.len() - 1 {
|
||||
let prev = points[i - 1];
|
||||
let curr = points[i];
|
||||
let next = points[i + 1];
|
||||
if ((prev.0 - curr.0).abs() < f64::EPSILON
|
||||
&& (curr.1 - next.1).abs() < f64::EPSILON
|
||||
&& (curr.0 - next.0).abs() > 5.0
|
||||
&& (curr.1 - prev.1).abs() > 5.0)
|
||||
|| ((prev.1 - curr.1).abs() < f64::EPSILON
|
||||
&& (curr.0 - next.0).abs() < f64::EPSILON
|
||||
&& (curr.0 - prev.0).abs() > 5.0
|
||||
&& (curr.1 - next.1).abs() > 5.0)
|
||||
{
|
||||
positions.push(i);
|
||||
}
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
fn find_adjacent_point(a: (f64, f64), b: (f64, f64), distance: f64) -> (f64, f64) {
|
||||
let x_diff = b.0 - a.0;
|
||||
let y_diff = b.1 - a.1;
|
||||
let length = (x_diff * x_diff + y_diff * y_diff).sqrt();
|
||||
if length == 0.0 {
|
||||
return a;
|
||||
}
|
||||
let ratio = distance / length;
|
||||
(b.0 - ratio * x_diff, b.1 - ratio * y_diff)
|
||||
}
|
||||
|
||||
fn render_edge_labels(&mut self, edges: &[LayoutEdge]) {
|
||||
struct LabelInfo {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
lines: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
let mut labels: Vec<LabelInfo> = Vec::new();
|
||||
let char_width = if self.is_state_diagram {
|
||||
scale_char_width(STATE_CHAR_WIDTH, self.options.font_size)
|
||||
} else {
|
||||
scale_char_width(EDGE_LABEL_CHAR_WIDTH, self.options.font_size)
|
||||
};
|
||||
for edge in edges {
|
||||
let Some(label) = &edge.label else {
|
||||
continue;
|
||||
};
|
||||
if label.trim().is_empty() || (edge.label_pos.is_none() && edge.points.len() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (label_x, label_y) = if let Some((x, y)) = edge.label_pos {
|
||||
if x > 0.0 && y > 0.0 {
|
||||
(x, y)
|
||||
} else {
|
||||
let label_points = Self::fix_corners(&edge.points);
|
||||
Self::label_position(&label_points)
|
||||
}
|
||||
} else {
|
||||
let label_points = Self::fix_corners(&edge.points);
|
||||
Self::label_position(&label_points)
|
||||
};
|
||||
|
||||
let lines = wrap_text_lines(label, self.options.wrapping_width, char_width);
|
||||
if lines.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let max_line_width = lines
|
||||
.iter()
|
||||
.map(|line| line_width_words(line, char_width))
|
||||
.fold(0.0, f64::max);
|
||||
let total_height =
|
||||
wrapped_text_height_with_font_size(lines.len(), self.options.font_size);
|
||||
let rect_width = max_line_width + EDGE_LABEL_PADDING_H * 2.0;
|
||||
let rect_height = total_height + EDGE_LABEL_PADDING_V * 2.0;
|
||||
|
||||
labels.push(LabelInfo {
|
||||
x: label_x,
|
||||
y: label_y,
|
||||
width: rect_width,
|
||||
height: rect_height,
|
||||
lines,
|
||||
});
|
||||
}
|
||||
|
||||
const MIN_SEPARATION: f64 = 8.0;
|
||||
const MAX_ITERATIONS: usize = 10;
|
||||
|
||||
for _ in 0..MAX_ITERATIONS {
|
||||
let mut any_collision = false;
|
||||
|
||||
for i in 0..labels.len() {
|
||||
for j in (i + 1)..labels.len() {
|
||||
let a_left = labels[i].x - labels[i].width / 2.0 - MIN_SEPARATION;
|
||||
let a_right = labels[i].x + labels[i].width / 2.0 + MIN_SEPARATION;
|
||||
let a_top = labels[i].y - labels[i].height / 2.0 - MIN_SEPARATION;
|
||||
let a_bottom = labels[i].y + labels[i].height / 2.0 + MIN_SEPARATION;
|
||||
|
||||
let b_left = labels[j].x - labels[j].width / 2.0 - MIN_SEPARATION;
|
||||
let b_right = labels[j].x + labels[j].width / 2.0 + MIN_SEPARATION;
|
||||
let b_top = labels[j].y - labels[j].height / 2.0 - MIN_SEPARATION;
|
||||
let b_bottom = labels[j].y + labels[j].height / 2.0 + MIN_SEPARATION;
|
||||
|
||||
let overlap_x = a_right > b_left && b_right > a_left;
|
||||
let overlap_y = a_bottom > b_top && b_bottom > a_top;
|
||||
|
||||
if overlap_x && overlap_y {
|
||||
any_collision = true;
|
||||
let dx = labels[j].x - labels[i].x;
|
||||
let dy = labels[j].y - labels[i].y;
|
||||
|
||||
let overlap_amount_x = (a_right - b_left).min(b_right - a_left);
|
||||
let overlap_amount_y = (a_bottom - b_top).min(b_bottom - a_top);
|
||||
|
||||
if overlap_amount_x < overlap_amount_y {
|
||||
let shift = overlap_amount_x / 2.0;
|
||||
if dx >= 0.0 {
|
||||
labels[i].x -= shift;
|
||||
labels[j].x += shift;
|
||||
} else {
|
||||
labels[i].x += shift;
|
||||
labels[j].x -= shift;
|
||||
}
|
||||
} else {
|
||||
let shift = overlap_amount_y / 2.0;
|
||||
if dy >= 0.0 {
|
||||
labels[i].y -= shift;
|
||||
labels[j].y += shift;
|
||||
} else {
|
||||
labels[i].y += shift;
|
||||
labels[j].y -= shift;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !any_collision {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for info in &labels {
|
||||
let rect_x = info.x - info.width / 2.0;
|
||||
let rect_y = info.y - info.height / 2.0;
|
||||
|
||||
self.output.push_str(&format!(
|
||||
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="rgba(232,232,232,{})" rx="2"/>
|
||||
"#,
|
||||
rect_x, rect_y, info.width, info.height, EDGE_LABEL_BG_OPACITY
|
||||
));
|
||||
|
||||
self.render_text_lines(
|
||||
info.x,
|
||||
info.y,
|
||||
&info.lines,
|
||||
self.options.font_size,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
&self.theme.text_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn label_position(points: &[(f64, f64)]) -> (f64, f64) {
|
||||
if points.len() < 2 {
|
||||
return points.first().copied().unwrap_or((0.0, 0.0));
|
||||
}
|
||||
|
||||
let mut segment_lengths = Vec::with_capacity(points.len() - 1);
|
||||
let mut total_length = 0.0;
|
||||
|
||||
for i in 0..points.len() - 1 {
|
||||
let dx = points[i + 1].0 - points[i].0;
|
||||
let dy = points[i + 1].1 - points[i].1;
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
segment_lengths.push(len);
|
||||
total_length += len;
|
||||
}
|
||||
|
||||
if total_length < 0.001 {
|
||||
return points[0];
|
||||
}
|
||||
|
||||
let target_distance = total_length * 0.5;
|
||||
let mut accumulated = 0.0;
|
||||
|
||||
for (i, &seg_len) in segment_lengths.iter().enumerate() {
|
||||
if accumulated + seg_len >= target_distance {
|
||||
let remaining = target_distance - accumulated;
|
||||
let t = if seg_len > 0.001 {
|
||||
remaining / seg_len
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let x = points[i].0 + t * (points[i + 1].0 - points[i].0);
|
||||
let y = points[i].1 + t * (points[i + 1].1 - points[i].1);
|
||||
return (x, y);
|
||||
}
|
||||
accumulated += seg_len;
|
||||
}
|
||||
|
||||
let last = points.len() - 1;
|
||||
(
|
||||
(points[0].0 + points[last].0) / 2.0,
|
||||
(points[0].1 + points[last].1) / 2.0,
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
}
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub const DEFAULT_FONT_SIZE: f64 = 16.0;
|
||||
pub const DEFAULT_LINE_HEIGHT: f64 = 1.1;
|
||||
pub const DEFAULT_WRAP_WIDTH: f64 = 200.0;
|
||||
pub const DEFAULT_CHAR_WIDTH: f64 = 8.0;
|
||||
pub const DEFAULT_TEXT_HEIGHT: f64 = 24.0;
|
||||
|
||||
/// A single unbreakable token is kept whole (its box widens to fit it, matching
|
||||
/// mermaid's default `htmlLabels`) unless it is wider than this many wrap-widths.
|
||||
/// ~5x keeps the worst-case whole-token box near one target-width frame, so the
|
||||
/// downstream rasterizer's scale-to-`target_width_px` stays ~1x and text stays
|
||||
/// legible; memory is bounded separately by the consuming crate's raster caps.
|
||||
const SINGLE_TOKEN_WIDTH_CAP_FACTOR: f64 = 5.0;
|
||||
|
||||
/// Identifier-boundary characters preferred as break points when an over-cap
|
||||
/// token must be split.
|
||||
const TOKEN_BREAK_CHARS: [char; 4] = ['_', '-', '.', '/'];
|
||||
|
||||
/// Display width of `text` in narrow-character units (East Asian wide
|
||||
/// characters count as two).
|
||||
pub fn display_width_units(text: &str) -> f64 {
|
||||
UnicodeWidthStr::width(text) as f64
|
||||
}
|
||||
|
||||
/// Mirrors mermaid.js splitText.ts splitLineToFitWidth behavior for non-markdown labels.
|
||||
/// Source: packages/mermaid/src/rendering-util/splitText.ts.
|
||||
pub fn wrap_text_lines(text: &str, max_width: f64, char_width: f64) -> Vec<Vec<String>> {
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let max_width = if max_width.is_finite() {
|
||||
max_width
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for raw_line in text.split('\n') {
|
||||
let trimmed = raw_line.trim();
|
||||
if trimmed.is_empty() {
|
||||
lines.push(vec![String::new()]);
|
||||
continue;
|
||||
}
|
||||
let words = split_line_to_words(trimmed);
|
||||
let wrapped = split_line_to_fit_width(words, max_width, char_width);
|
||||
lines.extend(wrapped);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Matches mermaid.js createText.ts line-width checks using display-width
|
||||
/// estimation.
|
||||
pub fn line_width(line: &str, char_width: f64) -> f64 {
|
||||
if line.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
display_width_units(line) * char_width
|
||||
}
|
||||
|
||||
pub fn measure_wrapped_lines_with_font_size(
|
||||
lines: &[Vec<String>],
|
||||
char_width: f64,
|
||||
font_size: f64,
|
||||
) -> (f64, f64) {
|
||||
let max_width = lines
|
||||
.iter()
|
||||
.map(|line| line_width_words(line, char_width))
|
||||
.fold(0.0, f64::max);
|
||||
(
|
||||
max_width,
|
||||
wrapped_text_height_with_font_size(lines.len(), font_size),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn wrapped_text_height_with_font_size(line_count: usize, font_size: f64) -> f64 {
|
||||
if line_count == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let font_size = normalized_font_size(font_size);
|
||||
let text_height = DEFAULT_TEXT_HEIGHT * font_size / DEFAULT_FONT_SIZE;
|
||||
let line_spacing = font_size * DEFAULT_LINE_HEIGHT;
|
||||
text_height + (line_count.saturating_sub(1)) as f64 * line_spacing
|
||||
}
|
||||
|
||||
pub fn scale_char_width(char_width: f64, font_size: f64) -> f64 {
|
||||
char_width * normalized_font_size(font_size) / DEFAULT_FONT_SIZE
|
||||
}
|
||||
|
||||
fn normalized_font_size(font_size: f64) -> f64 {
|
||||
if font_size.is_finite() && font_size > 0.0 {
|
||||
font_size
|
||||
} else {
|
||||
DEFAULT_FONT_SIZE
|
||||
}
|
||||
}
|
||||
fn split_line_to_words(text: &str) -> Vec<String> {
|
||||
let mut words = Vec::new();
|
||||
for word in text.split_whitespace() {
|
||||
words.push(word.to_string());
|
||||
}
|
||||
if words.is_empty() {
|
||||
words.push(String::new());
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
fn split_line_to_fit_width(
|
||||
words: Vec<String>,
|
||||
max_width: f64,
|
||||
char_width: f64,
|
||||
) -> Vec<Vec<String>> {
|
||||
let mut remaining = std::collections::VecDeque::from(words);
|
||||
let mut lines: Vec<Vec<String>> = Vec::new();
|
||||
let mut current: Vec<String> = Vec::new();
|
||||
|
||||
loop {
|
||||
if remaining.is_empty() {
|
||||
if !current.is_empty() {
|
||||
lines.push(current);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let next_word = remaining.pop_front().unwrap_or_default();
|
||||
|
||||
let mut line_with_next = current.clone();
|
||||
line_with_next.push(next_word.clone());
|
||||
|
||||
if check_fit(&line_with_next, max_width, char_width) {
|
||||
current = line_with_next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
lines.push(current);
|
||||
current = Vec::new();
|
||||
remaining.push_front(next_word);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !next_word.is_empty() {
|
||||
// Keep an unbreakable token whole so its box can widen (see const doc).
|
||||
let cap = max_width * SINGLE_TOKEN_WIDTH_CAP_FACTOR;
|
||||
if line_width(&next_word, char_width) <= cap {
|
||||
lines.push(vec![next_word]);
|
||||
} else {
|
||||
let (first, rest) = split_token_at_cap(&next_word, cap, char_width);
|
||||
lines.push(vec![first]);
|
||||
if !rest.is_empty() {
|
||||
remaining.push_front(rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn check_fit(words: &[String], max_width: f64, char_width: f64) -> bool {
|
||||
line_width_words(words, char_width) <= max_width
|
||||
}
|
||||
|
||||
fn split_word_to_fit_width(word: &str, max_width: f64, char_width: f64) -> (String, String) {
|
||||
let graphemes: Vec<&str> = word.graphemes(true).collect();
|
||||
if graphemes.is_empty() {
|
||||
return (String::new(), String::new());
|
||||
}
|
||||
|
||||
let mut used = Vec::new();
|
||||
let mut remaining_start = graphemes.len();
|
||||
for (idx, grapheme) in graphemes.iter().enumerate() {
|
||||
let mut candidate = used.clone();
|
||||
candidate.push(*grapheme);
|
||||
let candidate_str = candidate.concat();
|
||||
if line_width(&candidate_str, char_width) <= max_width || used.is_empty() {
|
||||
used = candidate;
|
||||
continue;
|
||||
}
|
||||
remaining_start = idx;
|
||||
break;
|
||||
}
|
||||
|
||||
if used.is_empty() {
|
||||
used.push(graphemes[0]);
|
||||
remaining_start = 1;
|
||||
}
|
||||
|
||||
let remaining = if remaining_start < graphemes.len() {
|
||||
graphemes[remaining_start..].concat()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
(used.concat(), remaining)
|
||||
}
|
||||
|
||||
/// Splits an over-cap token: prefers the last identifier boundary (`_`, `-`,
|
||||
/// `.`, `/`) within the cap-fitting prefix, otherwise falls back to the grapheme
|
||||
/// break used elsewhere. Break points are identifier-char granular, so long
|
||||
/// URLs/paths break at a separator instead of mid-segment.
|
||||
fn split_token_at_cap(word: &str, cap: f64, char_width: f64) -> (String, String) {
|
||||
// Grapheme prefix that fits the cap; also guarantees forward progress, so it
|
||||
// is always a strict prefix here (the whole word is wider than the cap).
|
||||
let (graphemic_first, graphemic_rest) = split_word_to_fit_width(word, cap, char_width);
|
||||
// Break chars are single-byte ASCII, so the rfind byte index + 1 is a valid
|
||||
// char boundary that keeps the separator on the first line.
|
||||
if let Some(boundary) = graphemic_first.rfind(|c| TOKEN_BREAK_CHARS.contains(&c)) {
|
||||
let pos = boundary + 1;
|
||||
return (word[..pos].to_string(), word[pos..].to_string());
|
||||
}
|
||||
(graphemic_first, graphemic_rest)
|
||||
}
|
||||
|
||||
pub fn line_width_words(words: &[String], char_width: f64) -> f64 {
|
||||
let joined = join_words(words);
|
||||
line_width(&joined, char_width)
|
||||
}
|
||||
|
||||
fn join_words(words: &[String]) -> String {
|
||||
let mut out = String::new();
|
||||
for (idx, word) in words.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(word);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wraps_long_single_token_whole_without_slicing() {
|
||||
// A single long identifier stays whole on one line (mermaid htmlLabels
|
||||
// behavior), instead of being hard-sliced mid-identifier.
|
||||
let label = "mark_filter_restore_context";
|
||||
let lines = wrap_text_lines(label, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
|
||||
assert_eq!(lines, vec![vec![label.to_string()]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_single_token_measures_wider_than_wrap_cap() {
|
||||
// Keeping the token whole means the measured text width is no longer
|
||||
// clamped to the wrap cap, so the node box widens to fit it.
|
||||
let lines = wrap_text_lines(
|
||||
"mark_filter_restore_context",
|
||||
DEFAULT_WRAP_WIDTH,
|
||||
DEFAULT_CHAR_WIDTH,
|
||||
);
|
||||
let (width, _height) =
|
||||
measure_wrapped_lines_with_font_size(&lines, DEFAULT_CHAR_WIDTH, DEFAULT_FONT_SIZE);
|
||||
assert!(
|
||||
width > DEFAULT_WRAP_WIDTH,
|
||||
"measured width {width} must exceed wrap cap {DEFAULT_WRAP_WIDTH}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_token_with_trailing_words_keeps_token_on_first_line() {
|
||||
// The long leading token stays whole on its own line; the trailing
|
||||
// words wrap onto a following line instead of being merged into it.
|
||||
let lines = wrap_text_lines(
|
||||
"_render_sidebar_for_active column mgmt",
|
||||
DEFAULT_WRAP_WIDTH,
|
||||
DEFAULT_CHAR_WIDTH,
|
||||
);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
vec!["_render_sidebar_for_active".to_string()],
|
||||
vec!["column".to_string(), "mgmt".to_string()],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_word_label_still_wraps_at_spaces() {
|
||||
// Regression guard: a normal multi-word label that exceeds the wrap
|
||||
// width still wraps at spaces, with every word kept intact.
|
||||
let phrase = "the quick brown fox jumps over the lazy dog";
|
||||
let lines = wrap_text_lines(phrase, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
|
||||
assert!(lines.len() >= 2, "long phrase must wrap: {lines:?}");
|
||||
let flat: Vec<String> = lines.iter().flatten().cloned().collect();
|
||||
let words: Vec<String> = phrase.split(' ').map(str::to_string).collect();
|
||||
assert_eq!(flat, words);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathologically_long_token_breaks_on_identifier_boundary() {
|
||||
// A token wider than the cap is force-broken, but the break lands on an
|
||||
// identifier boundary ('_'), not mid-segment, and loses no graphemes.
|
||||
let token = "segment_".repeat(25);
|
||||
let cap = SINGLE_TOKEN_WIDTH_CAP_FACTOR * DEFAULT_WRAP_WIDTH;
|
||||
assert!(line_width(&token, DEFAULT_CHAR_WIDTH) > cap);
|
||||
let lines = wrap_text_lines(&token, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
|
||||
assert!(
|
||||
lines.len() >= 2,
|
||||
"over-cap token must be force-broken: {lines:?}"
|
||||
);
|
||||
assert_eq!(lines[0].len(), 1, "each broken piece is a single word");
|
||||
assert!(
|
||||
lines[0][0].ends_with('_'),
|
||||
"first break must land on an identifier boundary, got {:?}",
|
||||
lines[0][0]
|
||||
);
|
||||
let rejoined: String = lines.iter().flatten().cloned().collect();
|
||||
assert_eq!(rejoined, token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_cap_token_without_break_char_falls_back_to_grapheme_break() {
|
||||
// No identifier boundary: the grapheme-break fallback still bounds each
|
||||
// line to the cap and loses no graphemes.
|
||||
let token = "a".repeat(200);
|
||||
let cap = SINGLE_TOKEN_WIDTH_CAP_FACTOR * DEFAULT_WRAP_WIDTH;
|
||||
assert!(line_width(&token, DEFAULT_CHAR_WIDTH) > cap);
|
||||
let lines = wrap_text_lines(&token, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
|
||||
assert!(lines.len() >= 2, "over-cap token must be broken: {lines:?}");
|
||||
assert!(line_width(&lines[0].concat(), DEFAULT_CHAR_WIDTH) <= cap);
|
||||
let rejoined: String = lines.iter().flatten().cloned().collect();
|
||||
assert_eq!(rejoined, token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_cap_cjk_token_breaks_on_boundary_and_counts_wide_chars() {
|
||||
// Wide chars count as two narrow units; an over-cap CJK token with
|
||||
// separators still breaks at a `_`, never panics, and rejoins losslessly.
|
||||
assert_eq!(display_width_units("中"), 2.0);
|
||||
let token = "中文_".repeat(50);
|
||||
let cap = SINGLE_TOKEN_WIDTH_CAP_FACTOR * DEFAULT_WRAP_WIDTH;
|
||||
assert!(line_width(&token, DEFAULT_CHAR_WIDTH) > cap);
|
||||
let lines = wrap_text_lines(&token, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
|
||||
assert!(lines.len() >= 2, "over-cap CJK token must break: {lines:?}");
|
||||
assert!(
|
||||
lines[0][0].ends_with('_'),
|
||||
"CJK break must land on a boundary, got {:?}",
|
||||
lines[0][0]
|
||||
);
|
||||
let rejoined: String = lines.iter().flatten().cloned().collect();
|
||||
assert_eq!(rejoined, token);
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct MermaidTheme {
|
||||
pub background: String,
|
||||
pub node_fill: String,
|
||||
pub node_stroke: String,
|
||||
pub text_color: String,
|
||||
pub edge_color: String,
|
||||
pub subgraph_fill: String,
|
||||
pub subgraph_stroke: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MermaidThemePreset {
|
||||
Default,
|
||||
Base,
|
||||
Dark,
|
||||
Forest,
|
||||
Neutral,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub struct MermaidThemeVariables {
|
||||
pub background: Option<String>,
|
||||
pub node_fill: Option<String>,
|
||||
pub node_stroke: Option<String>,
|
||||
pub text_color: Option<String>,
|
||||
pub edge_color: Option<String>,
|
||||
pub subgraph_fill: Option<String>,
|
||||
pub subgraph_stroke: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MermaidTheme {
|
||||
fn default() -> Self {
|
||||
Self::light()
|
||||
}
|
||||
}
|
||||
|
||||
impl MermaidThemePreset {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"default" => Some(Self::Default),
|
||||
"base" => Some(Self::Base),
|
||||
"dark" => Some(Self::Dark),
|
||||
"forest" => Some(Self::Forest),
|
||||
"neutral" => Some(Self::Neutral),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_theme(self) -> MermaidTheme {
|
||||
match self {
|
||||
Self::Default => MermaidTheme::light(),
|
||||
Self::Base => MermaidTheme::base(),
|
||||
Self::Dark => MermaidTheme::dark(),
|
||||
Self::Forest => MermaidTheme::forest(),
|
||||
Self::Neutral => MermaidTheme::neutral(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MermaidThemeVariables {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.background.is_none()
|
||||
&& self.node_fill.is_none()
|
||||
&& self.node_stroke.is_none()
|
||||
&& self.text_color.is_none()
|
||||
&& self.edge_color.is_none()
|
||||
&& self.subgraph_fill.is_none()
|
||||
&& self.subgraph_stroke.is_none()
|
||||
}
|
||||
|
||||
pub fn apply_mermaid_alias(&mut self, key: &str, value: String) -> bool {
|
||||
match key {
|
||||
"background" => self.background = Some(value),
|
||||
"primaryColor" | "mainBkg" => self.node_fill = Some(value),
|
||||
"primaryBorderColor" | "nodeBorder" => self.node_stroke = Some(value),
|
||||
"primaryTextColor" | "nodeTextColor" | "textColor" => self.text_color = Some(value),
|
||||
"lineColor" | "defaultLinkColor" => self.edge_color = Some(value),
|
||||
"clusterBkg" => self.subgraph_fill = Some(value),
|
||||
"clusterBorder" => self.subgraph_stroke = Some(value),
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn apply_to(&self, theme: &mut MermaidTheme) {
|
||||
if let Some(value) = &self.background {
|
||||
theme.background.clone_from(value);
|
||||
}
|
||||
if let Some(value) = &self.node_fill {
|
||||
theme.node_fill.clone_from(value);
|
||||
}
|
||||
if let Some(value) = &self.node_stroke {
|
||||
theme.node_stroke.clone_from(value);
|
||||
}
|
||||
if let Some(value) = &self.text_color {
|
||||
theme.text_color.clone_from(value);
|
||||
}
|
||||
if let Some(value) = &self.edge_color {
|
||||
theme.edge_color.clone_from(value);
|
||||
}
|
||||
if let Some(value) = &self.subgraph_fill {
|
||||
theme.subgraph_fill.clone_from(value);
|
||||
}
|
||||
if let Some(value) = &self.subgraph_stroke {
|
||||
theme.subgraph_stroke.clone_from(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MermaidTheme {
|
||||
pub fn light() -> Self {
|
||||
Self {
|
||||
background: "#ffffff".to_string(),
|
||||
node_fill: "#ECECFF".to_string(),
|
||||
node_stroke: "#9370DB".to_string(),
|
||||
text_color: "#333333".to_string(),
|
||||
edge_color: "#333333".to_string(),
|
||||
subgraph_fill: "#ffffde".to_string(),
|
||||
subgraph_stroke: "#aaaa33".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dark() -> Self {
|
||||
Self {
|
||||
background: "#1e1e1e".to_string(),
|
||||
node_fill: "#2d2d2d".to_string(),
|
||||
node_stroke: "#888888".to_string(),
|
||||
text_color: "#ffffff".to_string(),
|
||||
edge_color: "#888888".to_string(),
|
||||
subgraph_fill: "#3a3a20".to_string(),
|
||||
subgraph_stroke: "#888844".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base() -> Self {
|
||||
Self::light()
|
||||
}
|
||||
|
||||
pub fn forest() -> Self {
|
||||
Self {
|
||||
background: "#f4f4f4".to_string(),
|
||||
node_fill: "#cde498".to_string(),
|
||||
node_stroke: "#13540c".to_string(),
|
||||
text_color: "#333333".to_string(),
|
||||
edge_color: "#333333".to_string(),
|
||||
subgraph_fill: "#cde498".to_string(),
|
||||
subgraph_stroke: "#13540c".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn neutral() -> Self {
|
||||
Self {
|
||||
background: "#ffffff".to_string(),
|
||||
node_fill: "#eeeeee".to_string(),
|
||||
node_stroke: "#999999".to_string(),
|
||||
text_color: "#333333".to_string(),
|
||||
edge_color: "#333333".to_string(),
|
||||
subgraph_fill: "#eeeeee".to_string(),
|
||||
subgraph_stroke: "#999999".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
// --- Mermaid 11.12.2 timeline layout constants ---
|
||||
const LEFT_MARGIN: f64 = 50.0;
|
||||
const INITIAL_MASTER_X: f64 = 50.0 + LEFT_MARGIN; // 100
|
||||
const INITIAL_MASTER_Y: f64 = 50.0;
|
||||
const NODE_BASE_WIDTH: f64 = 150.0;
|
||||
const NODE_PADDING: f64 = 20.0;
|
||||
const NODE_WIDTH: f64 = NODE_BASE_WIDTH + 2.0 * NODE_PADDING; // 190
|
||||
const NODE_STEP: f64 = 200.0;
|
||||
const FONT_SIZE: f64 = 16.0;
|
||||
const EVENT_VERTICAL_GAP: f64 = 100.0;
|
||||
const DASHED_LINE_EXTENSION: f64 = 100.0;
|
||||
const NODE_CORNER_RADIUS: f64 = 5.0;
|
||||
const MAX_SECTIONS: usize = 12;
|
||||
const ARROW_STROKE_WIDTH: f64 = 4.0;
|
||||
const CONNECTOR_STROKE_WIDTH: f64 = 2.0;
|
||||
const NODE_LINE_STROKE_WIDTH: f64 = 3.0;
|
||||
|
||||
const FONT_FAMILY: &str = r#""trebuchet ms", verdana, arial, sans-serif"#;
|
||||
const TASK_FONT_SIZE: f64 = 14.0;
|
||||
const TASK_FONT_FAMILY: &str = "'Open Sans', sans-serif";
|
||||
|
||||
// Approximate character width for text measurement at 16px
|
||||
const CHAR_WIDTH: f64 = 9.0;
|
||||
|
||||
// Mermaid 11.12.2 default theme cScale colors (after darken by 10)
|
||||
const CSCALE_FILLS: &[&str] = &[
|
||||
"#BABAFF", // cScale0: periwinkle (primaryColor #ECECFF)
|
||||
"#FFFFAC", // cScale1: yellow (secondaryColor #ffffde)
|
||||
"#E8FFB9", // cScale2: lime green (tertiaryColor)
|
||||
"#D4BAFF", // cScale3
|
||||
"#FFBAFF", // cScale4
|
||||
"#FFBADC", // cScale5
|
||||
"#BAFFBA", // cScale6
|
||||
"#BAFFDC", // cScale7
|
||||
"#BAFFFF", // cScale8
|
||||
"#BABAFF", // cScale9
|
||||
"#DCBAFF", // cScale10
|
||||
"#FFBAEF", // cScale11
|
||||
];
|
||||
|
||||
// cScaleInv = hue-shifted by 180° from cScale (for node bottom line stroke)
|
||||
const CSCALE_INV: &[&str] = &[
|
||||
"#FFFFAC", "#BABAFF", "#FFB9E8", "#BAFFD4", "#BAFF9A", "#BAFFDC", "#FFBA9A", "#FFBADC",
|
||||
"#FFBABA", "#FFFFBA", "#BAFFBA", "#BAFFE0",
|
||||
];
|
||||
|
||||
// cScaleLabel text colors (cScaleLabel0 and cScaleLabel3 = white, rest = black)
|
||||
const CSCALE_LABEL: &[&str] = &[
|
||||
"#ffffff", "#000000", "#000000", "#ffffff", "#000000", "#000000", "#000000", "#000000",
|
||||
"#000000", "#000000", "#000000", "#000000",
|
||||
];
|
||||
|
||||
pub fn render_timeline_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
_theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let timeline = parse_timeline_diagram(mermaid_source)?;
|
||||
|
||||
let has_sections = !timeline.sections.is_empty();
|
||||
let tasks = &timeline.tasks;
|
||||
if tasks.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Timeline requires at least one entry".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Compute layout metrics ---
|
||||
let mut max_section_height = 0.0_f64;
|
||||
if has_sections {
|
||||
for section_name in &timeline.sections {
|
||||
let h = estimate_node_height(section_name, NODE_PADDING, 0.0);
|
||||
max_section_height = max_section_height.max(h + 20.0);
|
||||
}
|
||||
}
|
||||
|
||||
let mut max_task_height = 0.0_f64;
|
||||
let mut max_event_line_length = 0.0_f64;
|
||||
|
||||
for task in tasks {
|
||||
let h = estimate_node_height(&task.period, NODE_PADDING, 0.0);
|
||||
max_task_height = max_task_height.max(h + 20.0);
|
||||
|
||||
let mut event_line_len = 0.0_f64;
|
||||
for event in &task.events {
|
||||
event_line_len += estimate_node_height(event, NODE_PADDING, 50.0);
|
||||
}
|
||||
if task.events.len() > 1 {
|
||||
event_line_len += (task.events.len() - 1) as f64 * 10.0;
|
||||
}
|
||||
max_event_line_length = max_event_line_length.max(event_line_len);
|
||||
}
|
||||
|
||||
// --- Build SVG body ---
|
||||
let mut svg = String::with_capacity(4096);
|
||||
let mut content_right = 0.0_f64;
|
||||
let mut content_bottom = 0.0_f64;
|
||||
|
||||
// CSS styles matching mermaid.js timeline default theme
|
||||
let mut css = String::new();
|
||||
css.push_str(&format!(
|
||||
"svg{{font-family:{FONT_FAMILY};font-size:{FONT_SIZE}px;fill:#333;}}"
|
||||
));
|
||||
for i in 0..MAX_SECTIONS {
|
||||
let si = i as isize - 1;
|
||||
let fill = CSCALE_FILLS[i % CSCALE_FILLS.len()];
|
||||
let label = CSCALE_LABEL[i % CSCALE_LABEL.len()];
|
||||
let inv = CSCALE_INV[i % CSCALE_INV.len()];
|
||||
css.push_str(&format!(
|
||||
".section-{si} rect,.section-{si} path,.section-{si} circle{{fill:{fill};}}"
|
||||
));
|
||||
css.push_str(&format!(".section-{si} text{{fill:{label};}}"));
|
||||
css.push_str(&format!(
|
||||
".section-{si} line{{stroke:{inv};stroke-width:{NODE_LINE_STROKE_WIDTH};}}"
|
||||
));
|
||||
}
|
||||
css.push_str(".eventWrapper{filter:brightness(120%);}");
|
||||
css.push_str(".lineWrapper line{stroke:black;}");
|
||||
|
||||
let defs = "<defs><marker id=\"arrowhead\" refX=\"5\" refY=\"2\" markerWidth=\"6\" \
|
||||
markerHeight=\"4\" orient=\"auto\"><path d=\"M 0,0 V 4 L6,2 Z\"/></marker></defs>";
|
||||
|
||||
let mut body = String::new();
|
||||
|
||||
// --- Draw tasks and events ---
|
||||
let mut master_x = INITIAL_MASTER_X;
|
||||
let master_y = INITIAL_MASTER_Y;
|
||||
let section_begin_y = INITIAL_MASTER_Y;
|
||||
let mut section_number: usize = 0;
|
||||
|
||||
if has_sections {
|
||||
for section_name in &timeline.sections {
|
||||
let tasks_for_section: Vec<&TimelineTask> = tasks
|
||||
.iter()
|
||||
.filter(|t| t.section.as_deref() == Some(section_name.as_str()))
|
||||
.collect();
|
||||
|
||||
let section_width = 200.0 * (tasks_for_section.len().max(1)) as f64 - 50.0;
|
||||
let section_idx = section_number % MAX_SECTIONS;
|
||||
let section_css_idx = section_idx as isize - 1;
|
||||
|
||||
body.push_str(&format!(
|
||||
"<g class=\"timeline-node section-{section_css_idx}\" \
|
||||
transform=\"translate({master_x},{section_begin_y})\">"
|
||||
));
|
||||
render_node_background(&mut body, section_width, max_section_height);
|
||||
render_node_text(&mut body, section_name, section_width);
|
||||
body.push_str("</g>");
|
||||
|
||||
let task_y = section_begin_y + max_section_height + 50.0;
|
||||
|
||||
if !tasks_for_section.is_empty() {
|
||||
render_tasks(
|
||||
&mut body,
|
||||
&tasks_for_section,
|
||||
section_number,
|
||||
&mut master_x,
|
||||
task_y,
|
||||
max_task_height,
|
||||
max_event_line_length,
|
||||
&mut content_right,
|
||||
&mut content_bottom,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
master_x += 200.0 * (tasks_for_section.len().max(1)) as f64;
|
||||
section_number += 1;
|
||||
}
|
||||
} else {
|
||||
let task_refs: Vec<&TimelineTask> = tasks.iter().collect();
|
||||
render_tasks(
|
||||
&mut body,
|
||||
&task_refs,
|
||||
section_number,
|
||||
&mut master_x,
|
||||
master_y,
|
||||
max_task_height,
|
||||
max_event_line_length,
|
||||
&mut content_right,
|
||||
&mut content_bottom,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Horizontal arrow ---
|
||||
let depth_y = if has_sections {
|
||||
max_section_height + max_task_height + 150.0
|
||||
} else {
|
||||
max_task_height + 100.0
|
||||
};
|
||||
|
||||
// In mermaid.js, box.width is computed from SVG bounding box BEFORE arrow/title
|
||||
let nodes_box_width = content_right;
|
||||
|
||||
let arrow_x1 = LEFT_MARGIN;
|
||||
let arrow_x2 = nodes_box_width + 3.0 * LEFT_MARGIN;
|
||||
content_right = content_right.max(arrow_x2 + 10.0);
|
||||
|
||||
body.push_str(&format!(
|
||||
"<g class=\"lineWrapper\"><line x1=\"{arrow_x1:.1}\" y1=\"{depth_y:.1}\" \
|
||||
x2=\"{arrow_x2:.1}\" y2=\"{depth_y:.1}\" \
|
||||
stroke-width=\"{ARROW_STROKE_WIDTH}\" stroke=\"black\" \
|
||||
marker-end=\"url(#arrowhead)\"/></g>"
|
||||
));
|
||||
|
||||
// --- Title ---
|
||||
// Position uses node bounding box width (before arrow), matching mermaid.js
|
||||
let title_content = if let Some(title) = &timeline.title {
|
||||
let title_x = nodes_box_width / 2.0 - LEFT_MARGIN;
|
||||
// Estimate title width to expand viewBox if needed
|
||||
let approx_title_width = title.len() as f64 * 24.0; // ~24px/char at 4ex
|
||||
content_right = content_right.max(title_x + approx_title_width + 20.0);
|
||||
format!(
|
||||
"<text x=\"{title_x:.1}\" y=\"20\" font-size=\"4ex\" \
|
||||
font-weight=\"bold\" fill=\"#333\">{}</text>",
|
||||
escape_xml(title)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
content_bottom = content_bottom.max(depth_y + 20.0);
|
||||
|
||||
// --- Assemble final SVG ---
|
||||
let vb_padding = 50.0;
|
||||
let vb_width = content_right + vb_padding;
|
||||
let vb_height = content_bottom + vb_padding;
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
|
||||
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
|
||||
style=\"max-width: {vb_width:.0}px;\" \
|
||||
width=\"100%\" \
|
||||
viewBox=\"0 -25 {vb_width:.0} {vh:.0}\" \
|
||||
preserveAspectRatio=\"xMinYMin meet\" \
|
||||
height=\"{sh:.0}\" \
|
||||
role=\"graphics-document document\" \
|
||||
aria-roledescription=\"timeline\">",
|
||||
vh = vb_height + 25.0,
|
||||
sh = vb_height + 50.0,
|
||||
));
|
||||
svg.push_str(&format!("<style>{css}</style>"));
|
||||
svg.push_str(defs);
|
||||
svg.push_str(&title_content);
|
||||
svg.push_str(&body);
|
||||
svg.push_str("</svg>");
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tasks(
|
||||
body: &mut String,
|
||||
tasks: &[&TimelineTask],
|
||||
initial_section_color: usize,
|
||||
master_x: &mut f64,
|
||||
master_y: f64,
|
||||
max_task_height: f64,
|
||||
max_event_line_length: f64,
|
||||
content_right: &mut f64,
|
||||
content_bottom: &mut f64,
|
||||
is_multicolor: bool,
|
||||
) {
|
||||
let mut section_color = initial_section_color;
|
||||
|
||||
for task in tasks {
|
||||
let section_idx = section_color % MAX_SECTIONS;
|
||||
let section_css_idx = section_idx as isize - 1;
|
||||
|
||||
// Draw task (period) node
|
||||
body.push_str(&format!(
|
||||
"<g class=\"taskWrapper\"><g class=\"timeline-node section-{section_css_idx}\" \
|
||||
transform=\"translate({mx},{my})\">",
|
||||
mx = *master_x,
|
||||
my = master_y,
|
||||
));
|
||||
render_node_background(body, NODE_WIDTH, max_task_height);
|
||||
render_node_text(body, &task.period, NODE_WIDTH);
|
||||
body.push_str("</g></g>");
|
||||
|
||||
*content_right = (*content_right).max(*master_x + NODE_WIDTH);
|
||||
|
||||
// Draw events below
|
||||
if !task.events.is_empty() {
|
||||
let mut event_y = master_y + EVENT_VERTICAL_GAP + EVENT_VERTICAL_GAP;
|
||||
|
||||
for event in &task.events {
|
||||
let event_height = estimate_event_height(event);
|
||||
|
||||
body.push_str(&format!(
|
||||
"<g class=\"eventWrapper\"><g class=\"timeline-node section-{section_css_idx}\" \
|
||||
transform=\"translate({mx},{ey})\">",
|
||||
mx = *master_x,
|
||||
ey = event_y,
|
||||
));
|
||||
render_node_background(body, NODE_WIDTH, event_height);
|
||||
render_node_text(body, event, NODE_WIDTH);
|
||||
body.push_str("</g></g>");
|
||||
|
||||
event_y += event_height + 10.0;
|
||||
}
|
||||
|
||||
// Dashed vertical connector line with arrowhead
|
||||
let line_x = *master_x + NODE_WIDTH / 2.0;
|
||||
let line_y1 = master_y + max_task_height;
|
||||
let line_y2 = master_y
|
||||
+ max_task_height
|
||||
+ EVENT_VERTICAL_GAP
|
||||
+ max_event_line_length
|
||||
+ DASHED_LINE_EXTENSION;
|
||||
|
||||
body.push_str(&format!(
|
||||
"<g class=\"lineWrapper\"><line x1=\"{line_x:.1}\" y1=\"{line_y1:.1}\" \
|
||||
x2=\"{line_x:.1}\" y2=\"{line_y2:.1}\" \
|
||||
stroke-width=\"{CONNECTOR_STROKE_WIDTH}\" stroke=\"black\" \
|
||||
marker-end=\"url(#arrowhead)\" stroke-dasharray=\"5,5\"/></g>"
|
||||
));
|
||||
|
||||
*content_bottom = (*content_bottom).max(line_y2 + 10.0);
|
||||
}
|
||||
|
||||
*master_x += NODE_STEP;
|
||||
if is_multicolor {
|
||||
section_color += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_text_height(text: &str) -> f64 {
|
||||
let text_width = text.len() as f64 * CHAR_WIDTH;
|
||||
let num_lines = (text_width / NODE_BASE_WIDTH).ceil().max(1.0);
|
||||
num_lines * FONT_SIZE * 1.2
|
||||
}
|
||||
|
||||
fn estimate_node_height(text: &str, padding: f64, max_height: f64) -> f64 {
|
||||
let text_h = estimate_text_height(text);
|
||||
let h = text_h + FONT_SIZE * 1.1 * 0.5 + padding;
|
||||
h.max(max_height)
|
||||
}
|
||||
|
||||
fn estimate_event_height(text: &str) -> f64 {
|
||||
let text_h = estimate_text_height(text);
|
||||
let h = text_h + FONT_SIZE * 1.1 * 0.5 + NODE_PADDING;
|
||||
h.max(50.0)
|
||||
}
|
||||
|
||||
/// Render the node background shape: rounded top corners, flat bottom with a line.
|
||||
/// Matches mermaid.js `defaultBkg` function.
|
||||
fn render_node_background(svg: &mut String, width: f64, height: f64) {
|
||||
let rd = NODE_CORNER_RADIUS;
|
||||
svg.push_str(&format!(
|
||||
"<g><path class=\"node-bkg\" d=\"M0 {h_rd:.1} v{up:.1} q0,-{rd} {rd},-{rd} \
|
||||
h{across:.1} q{rd},0 {rd},{rd} v{down:.1} H0 Z\"/>",
|
||||
h_rd = height - rd,
|
||||
up = -(height - 2.0 * rd),
|
||||
rd = rd,
|
||||
across = width - 2.0 * rd,
|
||||
down = height - rd,
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"0\" y1=\"{height:.1}\" x2=\"{width:.1}\" y2=\"{height:.1}\"/>"
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
/// Render centered text inside a node.
|
||||
fn render_node_text(svg: &mut String, text: &str, width: f64) {
|
||||
let x = width / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<g transform=\"translate({x:.1},{ty:.1})\">\
|
||||
<text x=\"0\" y=\"0\" dy=\"1em\" \
|
||||
alignment-baseline=\"middle\" dominant-baseline=\"middle\" \
|
||||
text-anchor=\"middle\" \
|
||||
style=\"font-size:{TASK_FONT_SIZE}px;font-family:{TASK_FONT_FAMILY};\">\
|
||||
{}</text></g>",
|
||||
escape_xml(text),
|
||||
ty = NODE_PADDING / 2.0,
|
||||
));
|
||||
}
|
||||
|
||||
// --- Data model ---
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TimelineDiagram {
|
||||
title: Option<String>,
|
||||
sections: Vec<String>,
|
||||
tasks: Vec<TimelineTask>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TimelineTask {
|
||||
period: String,
|
||||
events: Vec<String>,
|
||||
section: Option<String>,
|
||||
}
|
||||
|
||||
// --- Parser ---
|
||||
|
||||
fn parse_timeline_diagram(input: &str) -> Result<TimelineDiagram, MermaidError> {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
|
||||
let mut i = 0_usize;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.split_whitespace().next() == Some("timeline") {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
return Err(MermaidError::ParseError {
|
||||
line: i + 1,
|
||||
message: "Expected 'timeline' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut title: Option<String> = None;
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
let mut tasks: Vec<TimelineTask> = Vec::new();
|
||||
let mut current_section: Option<String> = None;
|
||||
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
let line = raw.trim();
|
||||
i += 1;
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Title directive
|
||||
if let Some(rest) = line.strip_prefix("title ") {
|
||||
let t = rest.trim();
|
||||
if !t.is_empty() {
|
||||
title = Some(t.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Section directive
|
||||
if let Some(rest) = line.strip_prefix("section ") {
|
||||
let s = rest.trim();
|
||||
if !s.is_empty() {
|
||||
current_section = Some(s.to_string());
|
||||
if !sections.contains(&s.to_string()) {
|
||||
sections.push(s.to_string());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Event line (starts with ": " — additional event for the previous task)
|
||||
if let Some(event_text) = line.strip_prefix(": ") {
|
||||
let event_text = event_text.trim();
|
||||
if !event_text.is_empty() {
|
||||
if let Some(last_task) = tasks.last_mut() {
|
||||
last_task.events.push(event_text.to_string());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Period with optional event: "period : event" or just "period"
|
||||
if let Some((period, event)) = line.split_once(':') {
|
||||
let period = period.trim();
|
||||
let event = event.trim();
|
||||
if !period.is_empty() {
|
||||
let events = if event.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![event.to_string()]
|
||||
};
|
||||
tasks.push(TimelineTask {
|
||||
period: period.to_string(),
|
||||
events,
|
||||
section: current_section.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Period without event (bare text line)
|
||||
if !line.is_empty() {
|
||||
tasks.push(TimelineTask {
|
||||
period: line.to_string(),
|
||||
events: vec![],
|
||||
section: current_section.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TimelineDiagram {
|
||||
title,
|
||||
sections,
|
||||
tasks,
|
||||
})
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+867
@@ -0,0 +1,867 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
const CHART_WIDTH: f64 = 700.0;
|
||||
const CHART_HEIGHT: f64 = 500.0;
|
||||
|
||||
const CHART_TITLE_FONT_SIZE: f64 = 20.0;
|
||||
const CHART_TITLE_PADDING: f64 = 10.0;
|
||||
|
||||
const AXIS_LABEL_FONT_SIZE: f64 = 14.0;
|
||||
const AXIS_LABEL_PADDING: f64 = 5.0;
|
||||
|
||||
const AXIS_TITLE_FONT_SIZE: f64 = 16.0;
|
||||
const AXIS_TITLE_PADDING: f64 = 5.0;
|
||||
|
||||
const AXIS_TICK_LENGTH: f64 = 5.0;
|
||||
const AXIS_TICK_WIDTH: f64 = 2.0;
|
||||
|
||||
const AXIS_LINE_WIDTH: f64 = 2.0;
|
||||
|
||||
const DEFAULT_TICK_COUNT: usize = 10;
|
||||
|
||||
/// Floor for the auto-shrunk categorical x-axis label font (this port has no
|
||||
/// label rotation, so a busy axis shrinks-to-fit down to here, then overflows).
|
||||
const MIN_X_LABEL_FONT_SIZE: f64 = 8.0;
|
||||
|
||||
const PLOT_RIGHT_MARGIN: f64 = 12.0;
|
||||
|
||||
/// Per-series colors (Tableau 10), cycled by series index. Mid-tone hues stay
|
||||
/// legible on both the light and dark surfaces this engine renders onto.
|
||||
const SERIES_PALETTE: [&str; 10] = [
|
||||
"#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#ff9da7",
|
||||
"#9c755f", "#bab0ac",
|
||||
];
|
||||
|
||||
pub fn render_xychart_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let chart = parse_xychart(mermaid_source)?;
|
||||
|
||||
// Theme text color (not a fixed near-black) so axes stay visible on dark.
|
||||
let axis_color = theme.text_color.as_str();
|
||||
|
||||
let y_ticks = d3_ticks(chart.y_min, chart.y_max, DEFAULT_TICK_COUNT);
|
||||
let y_tick_labels: Vec<String> = y_ticks.iter().map(|v| format_tick(*v)).collect();
|
||||
let label_text_height = approx_text_height(AXIS_LABEL_FONT_SIZE);
|
||||
let y_label_max_width = y_tick_labels
|
||||
.iter()
|
||||
.map(|s| approx_text_width(s, AXIS_LABEL_FONT_SIZE))
|
||||
.fold(0.0, f64::max);
|
||||
|
||||
let title_height = if chart.title.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
approx_text_height(CHART_TITLE_FONT_SIZE) + 2.0 * CHART_TITLE_PADDING
|
||||
};
|
||||
let y_title_width = if chart.y_title.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
approx_text_height(AXIS_TITLE_FONT_SIZE) + 2.0 * AXIS_TITLE_PADDING
|
||||
};
|
||||
let x_title_height = if chart.x_title.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
approx_text_height(AXIS_TITLE_FONT_SIZE) + 2.0 * AXIS_TITLE_PADDING
|
||||
};
|
||||
|
||||
let left_axis_width =
|
||||
AXIS_LINE_WIDTH + AXIS_TICK_LENGTH + (y_label_max_width + 2.0 * AXIS_LABEL_PADDING);
|
||||
let plot_x = y_title_width + left_axis_width;
|
||||
let plot_y = title_height;
|
||||
let plot_w = (CHART_WIDTH - plot_x - PLOT_RIGHT_MARGIN).max(1.0);
|
||||
|
||||
let point_count = chart.series.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let x = layout_x_axis(&chart.x_axis, plot_x, plot_w, point_count);
|
||||
|
||||
// Bottom band depends on the resolved (possibly shrunk) x-label font.
|
||||
let x_label_height = approx_text_height(x.label_font);
|
||||
let bottom_axis_height = AXIS_LINE_WIDTH
|
||||
+ AXIS_TICK_LENGTH
|
||||
+ (x_label_height + 2.0 * AXIS_LABEL_PADDING)
|
||||
+ x_title_height;
|
||||
let plot_h = (CHART_HEIGHT - plot_y - bottom_axis_height).max(1.0);
|
||||
|
||||
let y_outer_padding = (label_text_height / 2.0).min(0.2 * plot_h);
|
||||
let y_top = plot_y + y_outer_padding;
|
||||
let y_bottom = plot_y + plot_h - y_outer_padding;
|
||||
|
||||
let y_at = |v: f64| scale_linear(v, chart.y_min, chart.y_max, y_bottom, y_top);
|
||||
|
||||
let mut svg = String::new();
|
||||
|
||||
svg.push_str(&format!(
|
||||
"<svg aria-roledescription=\"xychart\" role=\"graphics-document document\" viewBox=\"0 0 {CHART_WIDTH} {CHART_HEIGHT}\" style=\"max-width: {CHART_WIDTH}px; background-color: {};\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100%\" id=\"my-svg\">",
|
||||
theme.background
|
||||
));
|
||||
|
||||
svg.push_str("<g/><g class=\"main\">");
|
||||
svg.push_str(&format!(
|
||||
"<rect fill=\"{}\" class=\"background\" height=\"{CHART_HEIGHT}\" width=\"{CHART_WIDTH}\"/>",
|
||||
theme.background
|
||||
));
|
||||
|
||||
if !chart.title.is_empty() {
|
||||
let title_y = title_height / 2.0;
|
||||
let title_x = CHART_WIDTH / 2.0;
|
||||
svg.push_str("<g class=\"chart-title\">");
|
||||
svg.push_str(&format!(
|
||||
"<text transform=\"translate({title_x}, {title_y}) rotate(0)\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-size=\"{CHART_TITLE_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
|
||||
escape_xml(&chart.title)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
svg.push_str("<g class=\"plot\">");
|
||||
for (idx, values) in chart.series.iter().enumerate() {
|
||||
let points: Vec<(f64, f64)> = values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| (x.series_point_x(i), y_at(*v)))
|
||||
.collect();
|
||||
if points.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let d = points_to_path_d(&points);
|
||||
let stroke = SERIES_PALETTE[idx % SERIES_PALETTE.len()];
|
||||
svg.push_str(&format!("<g class=\"line-plot-{idx}\">"));
|
||||
svg.push_str(&format!(
|
||||
"<path stroke-width=\"2\" stroke=\"{stroke}\" fill=\"none\" d=\"{d}\"/>",
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
let bottom_axis_y = plot_y + plot_h;
|
||||
svg.push_str("<g class=\"bottom-axis\">");
|
||||
svg.push_str("<g class=\"axis-line\">");
|
||||
svg.push_str(&format!(
|
||||
"<path stroke-width=\"{AXIS_LINE_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {plot_x},{y} L {x_end},{y}\"/>",
|
||||
y = bottom_axis_y + AXIS_LINE_WIDTH / 2.0,
|
||||
x_end = plot_x + plot_w,
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("<g class=\"label\">");
|
||||
let x_label_y = bottom_axis_y + AXIS_LABEL_PADDING + AXIS_TICK_LENGTH + AXIS_LINE_WIDTH;
|
||||
for (pos, label) in x.tick_positions.iter().zip(x.tick_labels.iter()) {
|
||||
svg.push_str(&format!(
|
||||
"<text transform=\"translate({pos}, {x_label_y}) rotate(0)\" text-anchor=\"middle\" dominant-baseline=\"text-before-edge\" font-size=\"{font}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
|
||||
escape_xml(label),
|
||||
font = x.label_font,
|
||||
));
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("<g class=\"ticks\">");
|
||||
let tick_y0 = bottom_axis_y + AXIS_LINE_WIDTH;
|
||||
let tick_y1 = tick_y0 + AXIS_TICK_LENGTH;
|
||||
for pos in &x.tick_positions {
|
||||
svg.push_str(&format!(
|
||||
"<path stroke-width=\"{AXIS_TICK_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {pos},{tick_y0} L {pos},{tick_y1}\"/>",
|
||||
));
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("<g class=\"left-axis\">");
|
||||
svg.push_str("<g class=\"axisl-line\">");
|
||||
let axis_x = plot_x - AXIS_LINE_WIDTH / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<path stroke-width=\"{AXIS_LINE_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {axis_x},{plot_y} L {axis_x},{y1}\"/>",
|
||||
y1 = plot_y + plot_h,
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("<g class=\"label\">");
|
||||
let y_label_x = plot_x - AXIS_LABEL_PADDING - AXIS_TICK_LENGTH - AXIS_LINE_WIDTH;
|
||||
for (tick_value, tick_label) in y_ticks.iter().zip(y_tick_labels.iter()) {
|
||||
let y = y_at(*tick_value);
|
||||
svg.push_str(&format!(
|
||||
"<text transform=\"translate({y_label_x}, {y}) rotate(0)\" text-anchor=\"end\" dominant-baseline=\"middle\" font-size=\"{AXIS_LABEL_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
|
||||
escape_xml(tick_label)
|
||||
));
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
|
||||
svg.push_str("<g class=\"ticks\">");
|
||||
let tick_x0 = plot_x - AXIS_LINE_WIDTH;
|
||||
let tick_x1 = tick_x0 - AXIS_TICK_LENGTH;
|
||||
for tick_value in &y_ticks {
|
||||
let y = y_at(*tick_value);
|
||||
svg.push_str(&format!(
|
||||
"<path stroke-width=\"{AXIS_TICK_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {tick_x0},{y} L {tick_x1},{y}\"/>",
|
||||
));
|
||||
}
|
||||
svg.push_str("</g>");
|
||||
svg.push_str("</g>");
|
||||
|
||||
if !chart.x_title.is_empty() {
|
||||
let tx = plot_x + plot_w / 2.0;
|
||||
let ty = CHART_HEIGHT - x_title_height / 2.0;
|
||||
svg.push_str("<g class=\"x-axis-title\">");
|
||||
svg.push_str(&format!(
|
||||
"<text transform=\"translate({tx}, {ty}) rotate(0)\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-size=\"{AXIS_TITLE_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
|
||||
escape_xml(&chart.x_title)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
if !chart.y_title.is_empty() {
|
||||
let tx = y_title_width / 2.0;
|
||||
let ty = plot_y + plot_h / 2.0;
|
||||
svg.push_str("<g class=\"y-axis-title\">");
|
||||
svg.push_str(&format!(
|
||||
"<text transform=\"translate({tx}, {ty}) rotate(-90)\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-size=\"{AXIS_TITLE_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
|
||||
escape_xml(&chart.y_title)
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
svg.push_str("</g><g class=\"mermaid-tmp-group\"/></svg>");
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
/// Either evenly-spaced named categories (`x-axis [a, b]`) or a numeric range.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum XAxis {
|
||||
Numeric { min: f64, max: f64 },
|
||||
Category(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct XyChart {
|
||||
title: String,
|
||||
x_title: String,
|
||||
y_title: String,
|
||||
x_axis: XAxis,
|
||||
y_min: f64,
|
||||
y_max: f64,
|
||||
series: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
fn parse_xychart(input: &str) -> Result<XyChart, MermaidError> {
|
||||
let mut found_header = false;
|
||||
|
||||
let mut title = String::new();
|
||||
let mut x_title = String::new();
|
||||
let mut y_title = String::new();
|
||||
let mut x_axis: Option<XAxis> = None;
|
||||
let mut y_range: Option<(f64, f64)> = None;
|
||||
let mut series: Vec<Vec<f64>> = Vec::new();
|
||||
|
||||
for (idx, raw) in input.lines().enumerate() {
|
||||
let line_no = idx + 1;
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
if line.split_whitespace().next() != Some("xychart-beta") {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: "Expected 'xychart-beta' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
found_header = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("title ") {
|
||||
title = unquote(rest);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("x-axis ") {
|
||||
let (label, axis) = parse_x_axis(rest.trim(), line_no)?;
|
||||
x_title = label;
|
||||
x_axis = Some(axis);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("y-axis ") {
|
||||
let (label, range) = parse_y_axis(rest.trim(), line_no)?;
|
||||
y_title = label;
|
||||
if let Some(range) = range {
|
||||
y_range = Some(range);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(values) = parse_series_line(line, line_no)? {
|
||||
series.push(values);
|
||||
continue;
|
||||
}
|
||||
// Unknown lines (e.g. an unsupported `bar` series) are ignored.
|
||||
}
|
||||
|
||||
if !found_header {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Expected 'xychart-beta' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if series.iter().all(|values| values.is_empty()) {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "xychart requires at least one plot".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let x_axis = x_axis.unwrap_or(XAxis::Numeric { min: 0.0, max: 0.0 });
|
||||
let (y_min, y_max) = y_range.unwrap_or_else(|| auto_y_range(&series));
|
||||
|
||||
Ok(XyChart {
|
||||
title,
|
||||
x_title,
|
||||
y_title,
|
||||
x_axis,
|
||||
y_min,
|
||||
y_max,
|
||||
series,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an `x-axis` body: optional title plus a category list or numeric range.
|
||||
fn parse_x_axis(rest: &str, line: usize) -> Result<(String, XAxis), MermaidError> {
|
||||
if let Some(open) = rest.find('[') {
|
||||
let title = unquote(rest[..open].trim());
|
||||
let close =
|
||||
rest.rfind(']')
|
||||
.filter(|c| *c > open)
|
||||
.ok_or_else(|| MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid x-axis categories: {rest}"),
|
||||
})?;
|
||||
let categories = parse_category_list(&rest[open + 1..close]);
|
||||
Ok((title, XAxis::Category(categories)))
|
||||
} else if rest.contains("-->") {
|
||||
let (title, min, max) = parse_labeled_range(rest, line)?;
|
||||
Ok((title, XAxis::Numeric { min, max }))
|
||||
} else {
|
||||
Ok((unquote(rest), XAxis::Category(Vec::new())))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `y-axis` body; a title without a range auto-ranges from the data.
|
||||
fn parse_y_axis(rest: &str, line: usize) -> Result<(String, Option<(f64, f64)>), MermaidError> {
|
||||
if rest.contains("-->") {
|
||||
let (title, min, max) = parse_labeled_range(rest, line)?;
|
||||
Ok((title, Some((min, max))))
|
||||
} else {
|
||||
Ok((unquote(rest), None))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `[title] min --> max` body; the title is everything left of `min`.
|
||||
fn parse_labeled_range(s: &str, line: usize) -> Result<(String, f64, f64), MermaidError> {
|
||||
let (left, right) = s
|
||||
.split_once("-->")
|
||||
.ok_or_else(|| MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid axis range: {s}"),
|
||||
})?;
|
||||
|
||||
let max: f64 = right.trim().parse().map_err(|_| MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid axis max: {}", right.trim()),
|
||||
})?;
|
||||
|
||||
let left = left.trim();
|
||||
let (title, min_str) = match left.rsplit_once(char::is_whitespace) {
|
||||
Some((title, min)) => (title.trim(), min.trim()),
|
||||
None => ("", left),
|
||||
};
|
||||
let min: f64 = min_str.parse().map_err(|_| MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid axis min: {min_str}"),
|
||||
})?;
|
||||
|
||||
Ok((unquote(title), min, max))
|
||||
}
|
||||
|
||||
/// Parse a `line [..]` series; non-`line` declarations return `None`.
|
||||
fn parse_series_line(line: &str, line_no: usize) -> Result<Option<Vec<f64>>, MermaidError> {
|
||||
let Some(rest) = strip_keyword(line, "line") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let values = parse_bracketed_number_list(rest.trim(), line_no)?;
|
||||
Ok(Some(values))
|
||||
}
|
||||
|
||||
/// Strip `keyword` only when it stands alone (end / whitespace / `[` follows),
|
||||
/// so `line` matches but `linear` does not.
|
||||
fn strip_keyword<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
|
||||
let rest = line.strip_prefix(keyword)?;
|
||||
match rest.chars().next() {
|
||||
None => Some(rest),
|
||||
Some(c) if c.is_whitespace() || c == '[' => Some(rest),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Split on top-level (quote-aware) commas, then unquote/trim each entry.
|
||||
fn parse_category_list(inner: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut quote: Option<char> = None;
|
||||
for c in inner.chars() {
|
||||
match quote {
|
||||
Some(q) => {
|
||||
if c == q {
|
||||
quote = None;
|
||||
}
|
||||
current.push(c);
|
||||
}
|
||||
None => match c {
|
||||
'"' | '\'' => {
|
||||
quote = Some(c);
|
||||
current.push(c);
|
||||
}
|
||||
',' => out.push(std::mem::take(&mut current)),
|
||||
_ => current.push(c),
|
||||
},
|
||||
}
|
||||
}
|
||||
out.push(current);
|
||||
|
||||
out.into_iter()
|
||||
.map(|s| unquote(s.trim()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Strip one pair of matching surrounding quotes (`"…"` or `'…'`).
|
||||
fn unquote(s: &str) -> String {
|
||||
let t = s.trim();
|
||||
let bytes = t.as_bytes();
|
||||
if t.len() >= 2 {
|
||||
let first = bytes[0];
|
||||
let last = bytes[t.len() - 1];
|
||||
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
|
||||
return t[1..t.len() - 1].to_string();
|
||||
}
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
|
||||
fn auto_y_range(series: &[Vec<f64>]) -> (f64, f64) {
|
||||
let mut min = f64::INFINITY;
|
||||
let mut max = f64::NEG_INFINITY;
|
||||
for values in series {
|
||||
for &v in values {
|
||||
min = min.min(v);
|
||||
max = max.max(v);
|
||||
}
|
||||
}
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
if (max - min).abs() < f64::EPSILON {
|
||||
return (min - 1.0, max + 1.0);
|
||||
}
|
||||
(min, max)
|
||||
}
|
||||
|
||||
struct XAxisLayout {
|
||||
tick_positions: Vec<f64>,
|
||||
tick_labels: Vec<String>,
|
||||
label_font: f64,
|
||||
/// Longest series length, shared by every series so the same index maps to
|
||||
/// the same x across series (overlaid lines stay on one x domain).
|
||||
point_count: usize,
|
||||
geom: XGeom,
|
||||
}
|
||||
|
||||
enum XGeom {
|
||||
/// Band scale: points/ticks at band centers.
|
||||
Category { plot_left: f64, band_w: f64 },
|
||||
/// Linear scale: points evenly distributed across `[x0, x1]`.
|
||||
Numeric { x0: f64, x1: f64 },
|
||||
}
|
||||
|
||||
impl XAxisLayout {
|
||||
fn series_point_x(&self, i: usize) -> f64 {
|
||||
match self.geom {
|
||||
XGeom::Category { plot_left, band_w } => plot_left + (i as f64 + 0.5) * band_w,
|
||||
XGeom::Numeric { x0, x1 } => {
|
||||
if self.point_count <= 1 {
|
||||
x0
|
||||
} else {
|
||||
x0 + (i as f64) / ((self.point_count - 1) as f64) * (x1 - x0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_x_axis(x_axis: &XAxis, plot_x: f64, plot_w: f64, point_count: usize) -> XAxisLayout {
|
||||
match x_axis {
|
||||
XAxis::Category(categories) => {
|
||||
// Size bands to whichever is larger so every series point lands in a
|
||||
// band (a series longer than the category list still stays on-plot).
|
||||
let n = categories.len().max(point_count).max(1);
|
||||
let band_w = plot_w / n as f64;
|
||||
// Shrink the font so the widest category fits its band (to a floor).
|
||||
let widest_units = categories
|
||||
.iter()
|
||||
.map(|c| crate::text_wrap::display_width_units(c))
|
||||
.fold(0.0, f64::max);
|
||||
let label_font = if widest_units > 0.0 {
|
||||
let fit = (band_w * 0.95) / (widest_units * 0.525);
|
||||
AXIS_LABEL_FONT_SIZE.min(fit).max(MIN_X_LABEL_FONT_SIZE)
|
||||
} else {
|
||||
AXIS_LABEL_FONT_SIZE
|
||||
};
|
||||
let tick_positions = (0..categories.len())
|
||||
.map(|i| plot_x + (i as f64 + 0.5) * band_w)
|
||||
.collect();
|
||||
XAxisLayout {
|
||||
tick_positions,
|
||||
tick_labels: categories.clone(),
|
||||
label_font,
|
||||
point_count,
|
||||
geom: XGeom::Category {
|
||||
plot_left: plot_x,
|
||||
band_w,
|
||||
},
|
||||
}
|
||||
}
|
||||
XAxis::Numeric { min, max } => {
|
||||
let ticks = d3_ticks(*min, *max, DEFAULT_TICK_COUNT);
|
||||
let labels: Vec<String> = ticks.iter().map(|v| format_tick(*v)).collect();
|
||||
let label_max_width = labels
|
||||
.iter()
|
||||
.map(|s| approx_text_width(s, AXIS_LABEL_FONT_SIZE))
|
||||
.fold(0.0, f64::max);
|
||||
let outer = (label_max_width / 2.0).min(0.2 * plot_w);
|
||||
let x0 = plot_x + outer;
|
||||
let x1 = plot_x + plot_w - outer;
|
||||
let tick_positions = ticks
|
||||
.iter()
|
||||
.map(|v| scale_linear(*v, *min, *max, x0, x1))
|
||||
.collect();
|
||||
XAxisLayout {
|
||||
tick_positions,
|
||||
tick_labels: labels,
|
||||
label_font: AXIS_LABEL_FONT_SIZE,
|
||||
point_count,
|
||||
geom: XGeom::Numeric { x0, x1 },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bracketed_number_list(s: &str, line: usize) -> Result<Vec<f64>, MermaidError> {
|
||||
let Some(start) = s.find('[') else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid plot data: {s}"),
|
||||
});
|
||||
};
|
||||
let Some(end) = s.rfind(']') else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid plot data: {s}"),
|
||||
});
|
||||
};
|
||||
|
||||
let inner = &s[start + 1..end];
|
||||
let mut out = Vec::new();
|
||||
|
||||
for part in inner.split(',') {
|
||||
let p = part.trim();
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let v: f64 = p.parse().map_err(|_| MermaidError::ParseError {
|
||||
line,
|
||||
message: format!("Invalid plot value: {p}"),
|
||||
})?;
|
||||
out.push(v);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn points_to_path_d(points: &[(f64, f64)]) -> String {
|
||||
if points.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut d = String::new();
|
||||
if let Some((x, y)) = points.first().copied() {
|
||||
d.push_str(&format!("M{x},{y}"));
|
||||
}
|
||||
|
||||
for &(x, y) in points.iter().skip(1) {
|
||||
d.push_str(&format!("L{x},{y}"));
|
||||
}
|
||||
|
||||
d
|
||||
}
|
||||
|
||||
fn scale_linear(
|
||||
value: f64,
|
||||
domain_min: f64,
|
||||
domain_max: f64,
|
||||
range_min: f64,
|
||||
range_max: f64,
|
||||
) -> f64 {
|
||||
if (domain_max - domain_min).abs() < f64::EPSILON {
|
||||
return range_min;
|
||||
}
|
||||
|
||||
let t = (value - domain_min) / (domain_max - domain_min);
|
||||
range_min + t * (range_max - range_min)
|
||||
}
|
||||
|
||||
fn d3_ticks(start: f64, stop: f64, count: usize) -> Vec<f64> {
|
||||
if count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
if !start.is_finite() || !stop.is_finite() {
|
||||
return Vec::new();
|
||||
}
|
||||
if start == stop {
|
||||
return vec![start];
|
||||
}
|
||||
|
||||
let reverse = stop < start;
|
||||
let (a, b) = if reverse {
|
||||
(stop, start)
|
||||
} else {
|
||||
(start, stop)
|
||||
};
|
||||
|
||||
let step = tick_step(a, b, count as f64);
|
||||
if !step.is_finite() || step == 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let start0 = (a / step).ceil();
|
||||
let stop0 = (b / step).floor();
|
||||
|
||||
let n = (stop0 - start0 + 1.0).max(0.0) as i64;
|
||||
let mut ticks = Vec::with_capacity(n as usize);
|
||||
|
||||
for i in 0..n {
|
||||
ticks.push((start0 + i as f64) * step);
|
||||
}
|
||||
|
||||
if reverse {
|
||||
ticks.reverse();
|
||||
}
|
||||
|
||||
ticks
|
||||
}
|
||||
|
||||
fn tick_step(start: f64, stop: f64, count: f64) -> f64 {
|
||||
let step0 = (stop - start).abs() / count.max(1.0);
|
||||
let step1 = 10.0_f64.powf(step0.log10().floor());
|
||||
let error = step0 / step1;
|
||||
|
||||
let e10 = 50.0_f64.sqrt();
|
||||
let e5 = 10.0_f64.sqrt();
|
||||
let e2 = 2.0_f64.sqrt();
|
||||
|
||||
let step = if error >= e10 {
|
||||
step1 * 10.0
|
||||
} else if error >= e5 {
|
||||
step1 * 5.0
|
||||
} else if error >= e2 {
|
||||
step1 * 2.0
|
||||
} else {
|
||||
step1
|
||||
};
|
||||
|
||||
if stop < start {
|
||||
-step
|
||||
} else {
|
||||
step
|
||||
}
|
||||
}
|
||||
|
||||
fn format_tick(value: f64) -> String {
|
||||
let rounded = value.round();
|
||||
if (value - rounded).abs() < 1e-9 {
|
||||
return format!("{:.0}", rounded);
|
||||
}
|
||||
|
||||
let s = format!("{value:.6}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
fn approx_text_width(text: &str, font_size: f64) -> f64 {
|
||||
let n = crate::text_wrap::display_width_units(text);
|
||||
n * font_size * 0.525
|
||||
}
|
||||
|
||||
fn approx_text_height(font_size: f64) -> f64 {
|
||||
(font_size * 1.15).round()
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Categorical x-axis (no `-->`), labeled+ranged y-axis, two `line` series:
|
||||
// the case the numeric-only parser rejected ("opening image ... fails").
|
||||
const SAMPLE: &str = r#"xychart-beta
|
||||
title "Weekly active users by region"
|
||||
x-axis ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
y-axis "% of users" 0 --> 40
|
||||
line [20.3, 22.6, 24.2, 24.3, 26.2, 27.2, 32.4, 31.9, 31.4, 31.1, 33.6, 34.3]
|
||||
line [3.2, 6.3, 10.0, 9.4, 11.1, 10.7, 15.3, 13.4, 13.5, 12.5, 15.4, 15.8]"#;
|
||||
|
||||
#[test]
|
||||
fn parses_categorical_axis_labels_and_two_series() {
|
||||
let chart = parse_xychart(SAMPLE).expect("must parse");
|
||||
assert_eq!(chart.title, "Weekly active users by region");
|
||||
assert_eq!(chart.y_title, "% of users");
|
||||
assert!(chart.x_title.is_empty());
|
||||
assert_eq!((chart.y_min, chart.y_max), (0.0, 40.0));
|
||||
match &chart.x_axis {
|
||||
XAxis::Category(cats) => {
|
||||
assert_eq!(cats.len(), 12);
|
||||
assert_eq!(cats[0], "Jan");
|
||||
assert_eq!(cats[11], "Dec");
|
||||
}
|
||||
other => panic!("expected categorical x-axis, got {other:?}"),
|
||||
}
|
||||
assert_eq!(chart.series.len(), 2);
|
||||
assert_eq!(chart.series[0].len(), 12);
|
||||
assert_eq!(chart.series[1].len(), 12);
|
||||
assert_eq!(chart.series[1][0], 3.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categorical_x_axis_with_two_lines_renders() {
|
||||
let svg = render_xychart_diagram_to_svg(SAMPLE, &MermaidTheme::light())
|
||||
.expect("categorical xychart with two line series must render");
|
||||
assert!(svg.contains("<svg"));
|
||||
assert!(svg.contains("</svg>"));
|
||||
assert!(svg.contains(">Jan<"));
|
||||
assert!(svg.contains(">Dec<"));
|
||||
assert!(svg.contains(">% of users<"));
|
||||
assert_eq!(svg.matches("class=\"line-plot-").count(), 2);
|
||||
assert!(svg.contains(SERIES_PALETTE[0]));
|
||||
assert!(svg.contains(SERIES_PALETTE[1]));
|
||||
assert!(svg.contains("Weekly active users by region"));
|
||||
assert!(!svg.contains(""")); // title/label quotes stripped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_x_axis_range_still_renders() {
|
||||
let src = "xychart-beta\n title Demo\n x-axis 0 --> 10\n y-axis 0 --> 100\n line [5, 10, 20, 40]";
|
||||
let svg = render_xychart_diagram_to_svg(src, &MermaidTheme::light())
|
||||
.expect("numeric x-axis must still render");
|
||||
assert!(svg.contains("<svg"));
|
||||
assert!(svg.contains("Demo"));
|
||||
assert_eq!(svg.matches("class=\"line-plot-").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_text_color_drives_axis_and_labels() {
|
||||
let svg = render_xychart_diagram_to_svg(SAMPLE, &MermaidTheme::dark())
|
||||
.expect("dark theme must render");
|
||||
assert!(svg.contains(&format!("fill=\"{}\"", MermaidTheme::dark().text_color)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn y_axis_label_only_auto_ranges_from_data() {
|
||||
let src = "xychart-beta\n x-axis [a, b, c]\n y-axis \"score\"\n line [10, 20, 30]";
|
||||
let chart = parse_xychart(src).expect("must parse");
|
||||
assert_eq!(chart.y_title, "score");
|
||||
assert_eq!((chart.y_min, chart.y_max), (10.0, 30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_plot_is_rejected() {
|
||||
let src = "xychart-beta\n x-axis [a, b]\n y-axis 0 --> 10";
|
||||
assert!(render_xychart_diagram_to_svg(src, &MermaidTheme::light()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_xychart_source_is_rejected() {
|
||||
assert!(parse_xychart("flowchart TD\n A --> B").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_line_series_is_rejected() {
|
||||
// `line []` declares a series with no values: still no plottable data.
|
||||
assert!(parse_xychart("xychart-beta\n x-axis [a, b]\n line []").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categorical_points_stay_on_plot() {
|
||||
let (plot_x, plot_w) = (60.0, 600.0);
|
||||
// A series longer than the category list, and an empty category list:
|
||||
// every point must still land within [plot_x, plot_x + plot_w].
|
||||
for axis in [
|
||||
XAxis::Category(vec!["a".to_string(), "b".to_string()]),
|
||||
XAxis::Category(Vec::new()),
|
||||
] {
|
||||
let layout = layout_x_axis(&axis, plot_x, plot_w, 4);
|
||||
for i in 0..4 {
|
||||
let x = layout.series_point_x(i);
|
||||
assert!(
|
||||
(plot_x..=plot_x + plot_w).contains(&x),
|
||||
"point {i} at {x} escaped the plot for {axis:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_single_point_sits_at_left_edge() {
|
||||
let (plot_x, plot_w) = (60.0, 600.0);
|
||||
let layout = layout_x_axis(
|
||||
&XAxis::Numeric {
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
},
|
||||
plot_x,
|
||||
plot_w,
|
||||
1,
|
||||
);
|
||||
let XGeom::Numeric { x0, .. } = layout.geom else {
|
||||
panic!("expected numeric geometry");
|
||||
};
|
||||
assert_eq!(layout.series_point_x(0), x0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_series_share_x_domain_across_lengths() {
|
||||
// Built with the longest series' length (4); `series_point_x` ignores any
|
||||
// individual series length, so every series maps index -> x identically.
|
||||
let (plot_x, plot_w) = (60.0, 600.0);
|
||||
let layout = layout_x_axis(
|
||||
&XAxis::Numeric {
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
},
|
||||
plot_x,
|
||||
plot_w,
|
||||
4,
|
||||
);
|
||||
let XGeom::Numeric { x0, x1 } = layout.geom else {
|
||||
panic!("expected numeric geometry");
|
||||
};
|
||||
// Spacing uses the shared count (4 -> denominator 3), not a per-series one.
|
||||
assert_eq!(layout.series_point_x(0), x0);
|
||||
assert_eq!(layout.series_point_x(3), x1);
|
||||
assert!((layout.series_point_x(1) - (x0 + (x1 - x0) / 3.0)).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Vendored third-party crate.
|
||||
#
|
||||
# Upstream: ordered_hashmap 0.0.3 (Apache-2.0)
|
||||
# Source of truth: https://crates.io/crates/ordered_hashmap/0.0.3
|
||||
#
|
||||
# Why vendored: transitive dependency of the vendored `graphlib_rust` and
|
||||
# `dagre_rust` layout crates (the Warp `mermaid-to-svg` engine stack). It is a
|
||||
# young single-author crate on the untrusted-input render path, so vendoring
|
||||
# gives a full audit surface and immunity to upstream yanks. See
|
||||
# crates/codegen/xai-grok-mermaid and third_party/mermaid-to-svg.
|
||||
#
|
||||
# ============================================================================
|
||||
# VENDORING NOTES — re-apply / re-bless ALL of the following on every upgrade:
|
||||
# ============================================================================
|
||||
# Local modifications to the upstream source:
|
||||
# 1. NOT VENDORED: the upstream `tests/` integration test (library-only
|
||||
# vendoring; the tests add no layout/render coverage).
|
||||
# 2. `cargo fmt` (rustfmt) applied to `src/lib.rs` so the crate satisfies the
|
||||
# workspace fmt gate; no semantic change.
|
||||
#
|
||||
# Re-audit checklist:
|
||||
# - UNSAFE: src/lib.rs has two `unsafe` blocks in `iter_mut`/`values_mut`
|
||||
# (raw-pointer re-borrow to hand out `&mut` while iterating `keys`). Sound
|
||||
# today (the keys vec and the map are disjoint fields, never aliased); the
|
||||
# `[lints.clippy] all = allow` below does NOT relax unsafe review — re-verify
|
||||
# on every upgrade.
|
||||
[package]
|
||||
name = "ordered_hashmap"
|
||||
version = "0.0.3"
|
||||
edition = "2021"
|
||||
description = "Ordered HashMap preserving insertion order (vendored, library-only)"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/r3alst/ordered-hashmap"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "ordered_hashmap"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
# Vendored third-party code is not restyled to our lint standards, so silence
|
||||
# the lints it trips under the workspace's `-D warnings` (the source is kept
|
||||
# verbatim — only this manifest carries the allows). Our own crate
|
||||
# (xai-grok-mermaid) is still fully linted.
|
||||
[lints.rust]
|
||||
mismatched_lifetime_syntaxes = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
all = { level = "allow", priority = -1 }
|
||||
Vendored
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2023 Ameer Hamza
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
use std::collections::hash_map::Entry as StdEntry;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
|
||||
/// A custom `OrderedHashMap` struct that maintains the order of keys.
|
||||
/// It wraps a `Vec` to store keys and a `HashMap` to store key-value pairs.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OrderedHashMap<K: Eq + Hash + Clone, V> {
|
||||
pub keys: Vec<K>,
|
||||
pub map: HashMap<K, V>,
|
||||
}
|
||||
|
||||
impl<K: Eq + Hash + Clone, V> OrderedHashMap<K, V> {
|
||||
/// Creates a new empty `OrderedHashMap`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let ordered_map: OrderedHashMap<String, i32> = OrderedHashMap::new();
|
||||
/// ```
|
||||
pub fn new() -> Self {
|
||||
OrderedHashMap {
|
||||
keys: Vec::new(),
|
||||
map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the map contains a value for the specified key.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// ordered_map.insert("key1".to_string(), 42);
|
||||
/// assert!(ordered_map.contains_key(&"key1".to_string()));
|
||||
/// ```
|
||||
pub fn contains_key(&self, k: &K) -> bool {
|
||||
self.map.contains_key(k)
|
||||
}
|
||||
|
||||
/// Inserts a key-value pair into the map. If the map did not have this key present, `None` is returned.
|
||||
/// If the map did have this key present, the value is updated, and the old value is returned.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// assert_eq!(ordered_map.insert("key1".to_string(), 42), None);
|
||||
/// assert_eq!(ordered_map.insert("key1".to_string(), 99), Some(42));
|
||||
/// ```
|
||||
pub fn insert(&mut self, key: K, value: V) -> Option<V> {
|
||||
if !self.map.contains_key(&key) {
|
||||
self.keys.push(key.clone());
|
||||
}
|
||||
self.map.insert(key, value)
|
||||
}
|
||||
|
||||
/// Removes a key from the map, returning the value at the key if the key was previously in the map.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// ordered_map.insert("key1".to_string(), 42);
|
||||
/// assert_eq!(ordered_map.remove(&"key1".to_string()), Some(42));
|
||||
/// ```
|
||||
pub fn remove(&mut self, key: &K) -> Option<V> {
|
||||
self.keys.retain(|k| k != key);
|
||||
self.map.remove(key)
|
||||
}
|
||||
|
||||
/// Gets the value of the specified key.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// ordered_map.insert("key1".to_string(), 42);
|
||||
/// assert_eq!(ordered_map.get(&"key1".to_string()), Some(&42));
|
||||
/// ```
|
||||
pub fn get(&self, key: &K) -> Option<&V> {
|
||||
self.map.get(key)
|
||||
}
|
||||
|
||||
/// Gets a mutable reference to the value of the specified key.
|
||||
//////
|
||||
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// ordered_map.insert("key1".to_string(), 42);
|
||||
/// *ordered_map.get_mut(&"key1".to_string()).unwrap() += 1;
|
||||
/// assert_eq!(ordered_map.get(&"key1".to_string()), Some(&43));
|
||||
/// ```
|
||||
pub fn get_mut(&mut self, key: &K) -> Option<&mut V> {
|
||||
self.map.get_mut(key)
|
||||
}
|
||||
|
||||
/// Returns an iterator over the values in the ordered hash map.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// ordered_map.insert("key1".to_string(), 42);
|
||||
/// ordered_map.insert("key2".to_string(), 24);
|
||||
/// let values: Vec<_> = ordered_map.values().collect();
|
||||
/// assert_eq!(values, vec![&42, &24]);
|
||||
/// ```
|
||||
pub fn values(&self) -> impl Iterator<Item = &V> {
|
||||
self.keys.iter().filter_map(|k| self.map.get(k))
|
||||
}
|
||||
|
||||
/// Returns a mutable iterator over the values in the ordered hash map.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// ordered_map.insert("key1".to_string(), 42);
|
||||
/// ordered_map.insert("key2".to_string(), 24);
|
||||
/// ordered_map.values_mut().for_each(|value| *value += 1);
|
||||
/// assert_eq!(ordered_map.get(&"key1".to_string()), Some(&43));
|
||||
/// assert_eq!(ordered_map.get(&"key2".to_string()), Some(&25));
|
||||
/// ```
|
||||
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut V> {
|
||||
let map_ptr = &mut self.map as *mut HashMap<K, V>;
|
||||
self.keys
|
||||
.iter()
|
||||
.filter_map(move |k| unsafe { (*map_ptr).get_mut(k) })
|
||||
}
|
||||
|
||||
/// Returns an iterator over the keys in the ordered hash map.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// ordered_map.insert("key1".to_string(), 42);
|
||||
/// ordered_map.insert("key2".to_string(), 24);
|
||||
/// let keys: Vec<_> = ordered_map.keys().collect();
|
||||
/// assert_eq!(keys, vec![&"key1".to_string(), &"key2".to_string()]);
|
||||
/// ```
|
||||
pub fn keys(&self) -> impl Iterator<Item = &K> {
|
||||
self.keys.iter()
|
||||
}
|
||||
|
||||
/// Returns an iterator over the key-value pairs in the ordered hash map.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// ordered_map.insert("key1".to_string(), 42);
|
||||
/// ordered_map.insert("key2".to_string(), 24);
|
||||
/// let pairs: Vec<_> = ordered_map.iter().collect();
|
||||
/// assert_eq!(pairs, vec![(&"key1".to_string(), &42), (&"key2".to_string(), &24)]);
|
||||
/// ```
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&K, &V)> {
|
||||
self.keys
|
||||
.iter()
|
||||
.filter_map(move |k| self.map.get(k).map(|v| (k, v)))
|
||||
}
|
||||
|
||||
/// Returns an `Entry` for the given key, allowing for more complex manipulation of the stored values.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// ordered_map.insert("key1".to_string(), 42);
|
||||
/// ordered_map.entry("key1".to_string()).or_insert(99);
|
||||
/// assert_eq!(ordered_map.get(&"key1".to_string()), Some(&42));
|
||||
/// ```
|
||||
pub fn entry(&mut self, key: K) -> Entry<K, V> {
|
||||
match self.map.entry(key.clone()) {
|
||||
StdEntry::Occupied(occupied) => Entry::Occupied(occupied),
|
||||
StdEntry::Vacant(vacant) => {
|
||||
self.keys.push(key.clone());
|
||||
Entry::Vacant(vacant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a mutable iterator over the key-value pairs in the ordered hash map.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
/// let mut ordered_map = OrderedHashMap::new();
|
||||
/// ordered_map.insert("key1".to_string(), 42);
|
||||
/// ordered_map.insert("key2".to_string(), 24);
|
||||
/// ordered_map.iter_mut().for_each(|(key, value)| *value += 1);
|
||||
/// assert_eq!(ordered_map.get(&"key1".to_string()), Some(&43));
|
||||
/// assert_eq!(ordered_map.get(&"key2".to_string()), Some(&25));
|
||||
/// ```
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (&K, &mut V)> {
|
||||
let map_ptr = &mut self.map as *mut HashMap<K, V>;
|
||||
self.keys
|
||||
.iter()
|
||||
.filter_map(move |k| unsafe { (*map_ptr).get_mut(k).map(|v| (k, v)) })
|
||||
}
|
||||
|
||||
/// Returns a vector of the values in the map, in the order corresponding to their keys.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
///
|
||||
/// let mut map = OrderedHashMap::new();
|
||||
/// map.insert(1, "one");
|
||||
/// map.insert(2, "two");
|
||||
/// map.insert(3, "three");
|
||||
///
|
||||
/// let values = map.into_values();
|
||||
/// assert_eq!(values, vec!["one", "two", "three"]);
|
||||
/// ```
|
||||
pub fn into_values(self) -> Vec<V> {
|
||||
let mut extracted_items: Vec<(K, V)> = self.map.into_iter().collect();
|
||||
self.keys
|
||||
.into_iter()
|
||||
.filter_map(move |k| {
|
||||
if let Some(index) = extracted_items.iter().position(|(key, _)| *key == k) {
|
||||
Some(extracted_items.remove(index).1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// Adds all key-value pairs from another `OrderedHashMap` to this one, without replacing any existing pairs.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
///
|
||||
/// let mut map1 = OrderedHashMap::new();
|
||||
/// map1.insert(1, "one");
|
||||
///
|
||||
/// let mut map2 = OrderedHashMap::new();
|
||||
/// map2.insert(2, "two");
|
||||
///
|
||||
/// map1.extend(map2);
|
||||
///
|
||||
/// assert_eq!(map1.get(&1), Some(&"one"));
|
||||
/// assert_eq!(map1.get(&2), Some(&"two"));
|
||||
/// ```
|
||||
pub fn extend(&mut self, slice: OrderedHashMap<K, V>) {
|
||||
slice.keys.into_iter().for_each(|k| {
|
||||
if !self.keys.contains(&k) {
|
||||
self.keys.push(k);
|
||||
}
|
||||
});
|
||||
self.map.extend(slice.map);
|
||||
}
|
||||
|
||||
/// Returns the number of key-value pairs in the map.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ordered_hashmap::OrderedHashMap;
|
||||
///
|
||||
/// let mut map = OrderedHashMap::new();
|
||||
/// assert_eq!(map.len(), 0);
|
||||
///
|
||||
/// map.insert(1, "one");
|
||||
/// assert_eq!(map.len(), 1);
|
||||
///
|
||||
/// map.insert(2, "two");
|
||||
/// assert_eq!(map.len(), 2);
|
||||
/// ```
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Entry<'a, K: 'a, V: 'a> {
|
||||
Occupied(std::collections::hash_map::OccupiedEntry<'a, K, V>),
|
||||
Vacant(std::collections::hash_map::VacantEntry<'a, K, V>),
|
||||
}
|
||||
|
||||
impl<'a, K: Eq + Hash, V> Entry<'a, K, V> {
|
||||
pub fn or_insert(self, default: V) -> &'a mut V {
|
||||
match self {
|
||||
Entry::Occupied(occupied) => occupied.into_mut(),
|
||||
Entry::Vacant(vacant) => vacant.insert(default),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn or_insert_with<F: FnOnce() -> V>(self, default: F) -> &'a mut V {
|
||||
match self {
|
||||
Entry::Occupied(occupied) => occupied.into_mut(),
|
||||
Entry::Vacant(vacant) => vacant.insert(default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Eq + Hash + Clone, V> IntoIterator for OrderedHashMap<K, V> {
|
||||
type Item = (K, V);
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
let mut extracted_items: Vec<(K, V)> = self.map.into_iter().collect();
|
||||
self.keys
|
||||
.into_iter()
|
||||
.filter_map(move |k| {
|
||||
if let Some(index) = extracted_items.iter().position(|(key, _)| *key == k) {
|
||||
Some(extracted_items.remove(index))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user