mermaid-js / mermaid

Generation of diagrams like flowcharts or sequence diagrams from text in a similar manner as markdown
https://mermaid.js.org
MIT License
68.99k stars 6.11k forks source link

Add different crossing styles #5181

Open iainhallam opened 6 months ago

iainhallam commented 6 months ago

Description

334 was closed by a bot, but it's a key feature for serious diagramming work. Lines that cross should be able to be visually indicated, ideally in a user-selected choice of things like little arcs, or a break in the rear-most line.

Steps to reproduce

Create any diagram with crossing lines. See the slightly misleading crosses.

Screenshots

No response

Code Sample

No response

Setup

Suggested Solutions

A config option for the diagram on how to show crossing lines.

Additional Context

No response

nnmrts commented 3 months ago

Sorry for derailing this a bit, but for anyone looking for a quick workaround, you can style edges with filter: drop-shadow(...) to symbolize very basic line crossings, like so:

.flowchart-link {
    filter:
        drop-shadow(0px 2px 0px black)
        drop-shadow(2px 0px 0px black)
        drop-shadow(0px -2px 0px black)
        drop-shadow(-2px 0px 0px black)
}

Bildschirmfoto 2024-03-25 um 21 15 28

The color of the shadow of course needs to be the same as the background, which in this case is black.

But note that is a very bad workaround, adding css filters to a graph with already like 15 edges (at least for me) hurts performance when the graph gets animated for example. It's just not a good idea in general but the best "easy" fix I could come up for now. And if you need more space between the lines at the crossings, you have to add even more shadows, like so for 4 pixels for example:

.flowchart-link {
    filter:
        drop-shadow(0px 2px 0px black)
        drop-shadow(2px 0px 0px black)
        drop-shadow(0px -2px 0px black)
        drop-shadow(-2px 0px 0px black)
        drop-shadow(0px 4px 0px black)
        drop-shadow(4px 0px 0px black)
        drop-shadow(0px -4px 0px black)
        drop-shadow(-4px 0px 0px black)
}

Bildschirmfoto 2024-03-25 um 21 23 00

But making it thicker obviously leads to problems like these:

Bildschirmfoto 2024-03-25 um 21 23 47

And obviously this approach is utterly useless for graphs on backgrounds that are anything but a solid color.

Because this is not a long-term solution, I'm currently working on a way to detect line crossings from the output SVG and then changing those paths to get "actual" crossings - should be possible, technically...

nnmrts commented 3 months ago

Sorry again but ahh, I did it! Obviously still a hack, but a way better one.

Assume the following graph and look at all these crosssings:

%%{init:{"flowchart": { "defaultRenderer": "elk" }}}%%
graph TD
    Amy --- Bob
    Amy --- Christine
    Amy --- Daniel
    Amy --- Elizabeth
    Amy --- Frank
    Amy --- Gloria
    Bob --- Christine
    Bob --- Daniel
    Bob --- Elizabeth
    Bob --- Frank
    Bob --- Gloria
    Christine --- Daniel
    Christine --- Elizabeth
    Christine --- Frank
    Christine --- Gloria
    Daniel --- Elizabeth
    Daniel --- Frank
    Daniel --- Gloria
    Elizabeth --- Frank
    Elizabeth --- Gloria
    Frank --- Gloria

Then render the graph definition and run the SVG string through my very badly written function addCrossings before adding it to the DOM, like so:

import mermaid from "mermaid";
import SVGPathCommander from "svg-path-commander";
import { DOMParser, XMLSerializer } from "xmldom";

const getIntersectionOfSegments = (
    [{ x: xA, y: yA }, { x: xB, y: yB }],
    [{ x: xC, y: yC }, { x: xD, y: yD }]
) => {
    const divisor = (((xA - xB) * (yC - yD)) - ((yA - yB) * (xC - xD)));

    const firstNormalizedIntersection = (
        (((xA - xC) * (yC - yD)) - ((yA - yC) * (xC - xD))) /
        divisor
    );

    const secondNormalizedIntersecion = -(
        (((xA - xB) * (yA - yC)) - ((yA - yB) * (xA - xC))) /
        divisor
    );

    if (
        firstNormalizedIntersection >= 0 &&
        firstNormalizedIntersection <= 1 &&
        secondNormalizedIntersecion >= 0 &&
        secondNormalizedIntersecion <= 1
    ) {
        const intersectionX = (xA + (firstNormalizedIntersection * (xB - xA)));
        const intersectionY = (yA + (firstNormalizedIntersection * (yB - yA)));

        return {
            x: intersectionX,
            y: intersectionY
        };
    }

    return null;
};

const parser = new DOMParser();
const serializer = new XMLSerializer();

const padding = 5;

const addCrossings = (svgString) => {
    const svgDocument = parser.parseFromString(svgString, "text/xml");

    const edgeNodes = [
        ...[...svgDocument.childNodes[0].childNodes]
            .find((childNode) => childNode.tagName === "g" && childNode.getAttribute("class") === "edges edgePath").childNodes
    ]
        .filter(({ tagName }) => tagName === "path");

    for (const [index, edgeNode1] of edgeNodes.entries()) {
        const pathString1 = edgeNode1.getAttribute("d");
        const path1 = new SVGPathCommander(pathString1);

        for (const edgeNode2 of edgeNodes.slice(index + 1)) {
            const pathString2 = edgeNode2.getAttribute("d");
            const path2 = new SVGPathCommander(pathString2);

            const {
                segments: points1
            } = path1;

            const {
                segments: points2
            } = path2;

            const paddedPoints1 = [];

            for (const [point1Index, point1] of points1.slice(0, -1).entries()) {
                const [
                    command1,
                    x1,
                    y1
                ] = point1;

                const [
                    command2,
                    x2,
                    y2
                ] = points1[point1Index + 1];

                const orientation1 = x1 === x2 ? "vertical" : "horizontal";

                const direction1 = orientation1 === "vertical"
                    ? (
                        y1 <= y2
                            ? "down"
                            : "up"
                    )
                    : (
                        x1 <= x2
                            ? "right"
                            : "left"
                    );

                paddedPoints1.push(point1);

                for (const [point2Index, point2] of points2.slice(0, -1).entries()) {
                    const [
                        command3,
                        x3,
                        y3
                    ] = point2;

                    const [
                        command4,
                        x4,
                        y4
                    ] = points2[point2Index + 1];

                    const intersection = getIntersectionOfSegments(
                        [
                            {
                                x: x1,
                                y: y1
                            },
                            {
                                x: x2,
                                y: y2
                            }
                        ],
                        [
                            {
                                x: x3,
                                y: y3
                            },
                            {
                                x: x4,
                                y: y4
                            }
                        ]
                    );

                    if (intersection) {
                        paddedPoints1.push(
                            [
                                "L",
                                intersection.x -
                                (
                                    orientation1 === "horizontal"
                                        ? padding * (direction1 === "left" ? -1 : 1)
                                        : 0
                                ),
                                intersection.y -
                                (
                                    orientation1 === "vertical"
                                        ? padding * (direction1 === "up" ? -1 : 1)
                                        : 0
                                )
                            ],
                            [
                                "M",
                                intersection.x +
                                (
                                    orientation1 === "horizontal"
                                        ? padding * (direction1 === "left" ? -1 : 1)
                                        : 0
                                ),
                                intersection.y +
                                (
                                    orientation1 === "vertical"
                                        ? padding * (direction1 === "up" ? -1 : 1)
                                        : 0
                                )
                            ]
                        );
                    }
                }
            }

            paddedPoints1.push(points1.at(-1));

            path1.segments = paddedPoints1;

            edgeNode1.setAttribute("d", path1.toString());
        }
    }

    return serializer.serializeToString(svgDocument);
};

const graphDefinition = `...`;

const { svg: renderedSvgString } = await mermaid.render("graph", graphDefinition);

const svgStringWithCrossings = addCrossings(renderedSvgString);

Bildschirmfoto 2024-03-26 um 00 38 21

This was done with the elk renderer in mind because I use it, so it probably needs some tinkering to make it more generic, but feel free to yank this code and do something better with it.