← Code-Übersicht

SvgFluss.php

Pfad: src/Infrastructure/Flow/SvgFluss.php

Ext: php

Größe: 6332 Bytes

Geändert: 2026-07-16 00:13:39+02

<?php
declare(strict_types=1);

namespace Demo\Infrastructure\Flow;

use Demo\Domain\Prozess\ProzessDetail;
use Demo\Domain\Prozess\Schritt;

/**
 * Rendert den Prozess-Fluss aus Layout-Koordinaten als sicheres Inline-SVG.
 * Vertikal (oben nach unten); Aufruf-Schritte verlinken auf den Teilprozess.
 * Alle Texte sind escaped.
 */
final class SvgFluss
{
    private const NW = 240;
    private const NH = 56;
    private const GX = 36;
    private const GY = 48;
    private const PAD = 16;

    /**
     * Baut das SVG fuer einen Prozess.
     *
     * @param ProzessDetail $detail Prozess-Sicht.
     * @param array{pos:array<int,array{rang:int,spalte:int}>,maxRang:int,maxSpalten:int} $layout Layout.
     * @return string Inline-SVG oder Hinweis, wenn leer.
     */
    public function render(ProzessDetail $detail, array $layout): string
    {
        $pos = $layout['pos'];
        if ($pos === []) {
            return '<p class="fluss-leer">Dieser Prozess hat noch keine Schritte.</p>';
        }
        $proRang = [];
        foreach ($pos as $p) {
            $proRang[$p['rang']] = ($proRang[$p['rang']] ?? 0) + 1;
        }
        $voll = self::breite($layout['maxSpalten']);
        $w = $voll + self::PAD * 2;
        $h = self::PAD * 2 + ($layout['maxRang'] + 1) * self::NH + $layout['maxRang'] * self::GY;

        $teile = [];
        foreach ($detail->kanten as $kante) {
            if (!isset($pos[$kante->vorgaengerId], $pos[$kante->schrittId])) {
                continue;
            }
            [$xv, $yv] = $this->ecke($pos, $proRang, $voll, $kante->vorgaengerId);
            [$xs, $ys] = $this->ecke($pos, $proRang, $voll, $kante->schrittId);
            $halb = intdiv(self::NW, 2);
            $teile[] = $this->kanteSvg($xv + $halb, $yv + self::NH, $xs + $halb, $ys, $kante->ausgang);
        }
        foreach ($detail->schritte as $schritt) {
            if (!isset($pos[$schritt->id])) {
                continue;
            }
            [$x, $y] = $this->ecke($pos, $proRang, $voll, $schritt->id);
            $teile[] = $this->knotenSvg($schritt, $x, $y);
        }

        return '<svg class="fluss" viewBox="0 0 ' . $w . ' ' . $h . '" width="' . $w
            . '" height="' . $h . '" role="img" xmlns="http://www.w3.org/2000/svg">'
            . '<defs><marker id="pfeil" markerWidth="8" markerHeight="8" refX="7" refY="4" '
            . 'orient="auto"><path d="M0,0 L8,4 L0,8 z" class="fluss-pfeil"/></marker></defs>'
            . implode('', $teile) . '</svg>';
    }

    /**
     * Gesamtbreite einer Ebene mit n Knoten (auch fuer die breiteste Ebene).
     *
     * @param int $n Knotenzahl.
     * @return int Breite in Pixeln.
     */
    private static function breite(int $n): int
    {
        return $n < 1 ? 0 : $n * self::NW + ($n - 1) * self::GX;
    }

    /**
     * Obere-linke Ecke eines Knotens (Ebene horizontal zentriert).
     *
     * @param array<int,array{rang:int,spalte:int}> $pos     Positionen.
     * @param array<int,int>                         $proRang Knoten je Ebene.
     * @param int                                    $voll    Gesamtbreite.
     * @param int                                    $id      Schritt-Id.
     * @return array{0:int,1:int} x und y.
     */
    private function ecke(array $pos, array $proRang, int $voll, int $id): array
    {
        $rang = $pos[$id]['rang'];
        $startX = self::PAD + intdiv($voll - self::breite($proRang[$rang]), 2);

        return [
            $startX + $pos[$id]['spalte'] * (self::NW + self::GX),
            self::PAD + $rang * (self::NH + self::GY),
        ];
    }

    /**
     * SVG einer Kante inkl. optionalem Ausgang-Label.
     *
     * @param int         $x1      Start-x.
     * @param int         $y1      Start-y.
     * @param int         $x2      Ziel-x.
     * @param int         $y2      Ziel-y.
     * @param string|null $ausgang Kantenbeschriftung.
     * @return string SVG-Fragment.
     */
    private function kanteSvg(int $x1, int $y1, int $x2, int $y2, ?string $ausgang): string
    {
        $c1 = $y1 + intdiv(self::GY, 2);
        $c2 = $y2 - intdiv(self::GY, 2);
        $pfad = '<path class="fluss-kante" marker-end="url(#pfeil)" fill="none" d="M' . $x1 . ',' . $y1
            . ' C' . $x1 . ',' . $c1 . ' ' . $x2 . ',' . $c2 . ' ' . $x2 . ',' . $y2 . '"/>';
        if ($ausgang === null || $ausgang === '') {
            return $pfad;
        }

        return $pfad . '<text class="fluss-ausgang" x="' . intdiv($x1 + $x2, 2) . '" y="'
            . intdiv($y1 + $y2, 2) . '">' . $this->e($ausgang) . '</text>';
    }

    /**
     * SVG eines Knotens als Link (Teilprozess oder Sprung zur Schritt-Karte).
     *
     * @param Schritt $schritt Schritt.
     * @param int     $x       Ecke x.
     * @param int     $y       Ecke y.
     * @return string SVG-Fragment.
     */
    private function knotenSvg(Schritt $schritt, int $x, int $y): string
    {
        $aufruf = $schritt->istAufruf();
        $href = $aufruf ? '/prozesse/' . rawurlencode((string) $schritt->ruftSlug) : '#schritt-' . $schritt->id;
        $klasse = $aufruf ? 'fluss-knoten fluss-aufruf' : 'fluss-knoten';
        $meta = $aufruf ? 'Teilprozess: ' . (string) $schritt->ruftTitel : $schritt->akteur . ' / ' . $schritt->system;

        return '<a href="' . $this->e($href) . '"><rect class="' . $klasse . '" x="' . $x . '" y="' . $y
            . '" rx="6" width="' . self::NW . '" height="' . self::NH . '"/>'
            . '<text class="fluss-name" x="' . ($x + 12) . '" y="' . ($y + 24) . '">'
            . $this->e($this->kurz($schritt->name)) . '</text>'
            . '<text class="fluss-meta" x="' . ($x + 12) . '" y="' . ($y + 42) . '">'
            . $this->e($this->kurz($meta)) . '</text></a>';
    }

    /**
     * Kuerzt lange Beschriftungen fuer die feste Knotenbreite.
     *
     * @param string $text Ausgangstext.
     * @return string Gekuerzter Text.
     */
    private function kurz(string $text): string
    {
        return mb_strlen($text) > 38 ? mb_substr($text, 0, 37) . "\u{2026}" : $text;
    }

    /**
     * Escaped Text fuer die SVG-Ausgabe.
     *
     * @param string $text Rohtext.
     * @return string Escaped.
     */
    private function e(string $text): string
    {
        return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
    }
}