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');
}
}