Skip to content

Commit f52c748

Browse files
authored
feat(media): add brand-agnostic flow_diagram core template (#2898)
* feat(media): add brand-agnostic flow_diagram core template Adds a generic, spec-driven flow diagram template rendered server-side via GDRenderer. Describe a graph as { nodes, edges } and get a deterministic PNG/JPEG back — box/diamond/oval node shapes, directional arrows, optional edge labels, horizontal or vertical layout, auto-fit labels. Registered on the core datamachine/image_generation/templates filter, so it's composable for free through the existing datamachine/render-image-template ability (CLI, REST, MCP, PHP). Core intentionally ships only generic structural templates; domain templates (event cards, quote cards) remain downstream. Also fixes a latent GDRenderer bug: draw_rounded_rect() called imagefilledroundedrectangle() unconditionally, but that builtin is PHP 8.4+. The plugin requires PHP 8.0, so rounded nodes fataled on 8.0-8.3. Added a manual rounded-rect fallback. Includes a self-contained smoke test (renders a spec, asserts a valid PNG, exercises the polyfill path). * style: align assignments in FlowDiagramTemplate for phpcs Fixes 25 WordPress.Arrays.MultipleStatementAlignment warnings flagged by the release lint gate. Whitespace-only; no logic change.
1 parent d112d3c commit f52c748

5 files changed

Lines changed: 520 additions & 2 deletions

File tree

docs/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to Data Machine will be documented in this file.
44

5+
## [Unreleased]
6+
7+
### Added
8+
- Core `flow_diagram` image template — a brand-agnostic, spec-driven flow diagram (nodes + edges) rendered server-side via GDRenderer. Composable through the existing `datamachine/render-image-template` ability surface (CLI, REST, MCP, PHP): `wp datamachine image render --template_id=flow_diagram --data='{"nodes":[...],"edges":[...]}'`. Core ships only generic structural templates; domain templates remain downstream.
9+
10+
### Fixed
11+
- `GDRenderer::draw_rounded_rect()` no longer fatals on PHP 8.0–8.3. It called `imagefilledroundedrectangle()` unconditionally, but that builtin is PHP 8.4+; added a manual rounded-rect fallback so rounded nodes render on all supported PHP versions.
12+
513
## [0.162.3] - 2026-07-13
614

715
### Fixed

inc/Abilities/Media/GDRenderer.php

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -717,8 +717,33 @@ public function draw_rounded_rect( int $x, int $y, int $width, int $height, int
717717
return $this;
718718
}
719719

720-
// Draw filled rectangle with rounded corners using arc.
721-
imagefilledroundedrectangle( $this->image, $x, $y, $x + $width, $y + $height, $radius, $color );
720+
$x2 = $x + $width;
721+
$y2 = $y + $height;
722+
723+
// imagefilledroundedrectangle() is PHP 8.4+. Fall back to a manual
724+
// composition (rects + corner arcs) so rounded nodes render on 8.0–8.3.
725+
if ( function_exists( 'imagefilledroundedrectangle' ) ) {
726+
imagefilledroundedrectangle( $this->image, $x, $y, $x2, $y2, $radius, $color );
727+
return $this;
728+
}
729+
730+
$radius = max( 0, min( $radius, intdiv( $width, 2 ), intdiv( $height, 2 ) ) );
731+
732+
if ( 0 === $radius ) {
733+
imagefilledrectangle( $this->image, $x, $y, $x2, $y2, $color );
734+
return $this;
735+
}
736+
737+
// Center cross of the rounded rect.
738+
imagefilledrectangle( $this->image, $x + $radius, $y, $x2 - $radius, $y2, $color );
739+
imagefilledrectangle( $this->image, $x, $y + $radius, $x2, $y2 - $radius, $color );
740+
741+
$d = $radius * 2;
742+
// Four corner quarter-circles.
743+
imagefilledellipse( $this->image, $x + $radius, $y + $radius, $d, $d, $color );
744+
imagefilledellipse( $this->image, $x2 - $radius, $y + $radius, $d, $d, $color );
745+
imagefilledellipse( $this->image, $x + $radius, $y2 - $radius, $d, $d, $color );
746+
imagefilledellipse( $this->image, $x2 - $radius, $y2 - $radius, $d, $d, $color );
722747

723748
return $this;
724749
}
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
<?php
2+
/**
3+
* Flow Diagram Template
4+
*
5+
* A brand-agnostic, spec-driven flow diagram. Renders a set of labelled
6+
* nodes connected by directional arrows from a declarative JSON payload —
7+
* no domain logic, no external services, pure GDRenderer.
8+
*
9+
* Unlike domain templates (event cards, quote cards), this is a generic
10+
* structural primitive: describe a graph as data and get a deterministic
11+
* PNG/JPEG back. Any consumer of the `datamachine/render-image-template`
12+
* ability — CLI, REST, MCP, or PHP — can generate flow diagrams for free.
13+
*
14+
* Data spec:
15+
* {
16+
* "title": "Optional heading",
17+
* "direction": "horizontal" | "vertical", // default: horizontal
18+
* "nodes": [
19+
* { "id": "a", "label": "First step", "shape": "box", "color": "#2d6cdf" },
20+
* { "id": "b", "label": "Decision?", "shape": "diamond" },
21+
* { "id": "c", "label": "Done", "shape": "oval" }
22+
* ],
23+
* "edges": [
24+
* { "from": "a", "to": "b", "label": "next" },
25+
* { "from": "b", "to": "c" }
26+
* ]
27+
* }
28+
*
29+
* `label` supports "\n" for manual line breaks; long labels also wrap to the
30+
* node width automatically. `shape` is one of box (default), diamond, oval.
31+
* Per-node `color` (hex) overrides the default node fill.
32+
*
33+
* @package DataMachine\Abilities\Media\Templates
34+
* @since 0.163.0
35+
*/
36+
37+
namespace DataMachine\Abilities\Media\Templates;
38+
39+
use DataMachine\Abilities\Media\GDRenderer;
40+
use DataMachine\Abilities\Media\TemplateInterface;
41+
42+
if ( ! defined( 'ABSPATH' ) ) {
43+
exit;
44+
}
45+
46+
class FlowDiagramTemplate implements TemplateInterface {
47+
48+
private const DEFAULT_BG = '#0f1117';
49+
private const DEFAULT_SURFACE = '#1b1f2a';
50+
private const DEFAULT_NODE = '#2d6cdf';
51+
private const DEFAULT_EDGE = '#8a93a6';
52+
private const DEFAULT_TEXT = '#ffffff';
53+
private const DEFAULT_TITLE = '#ffffff';
54+
private const DEFAULT_MUTED = '#8a93a6';
55+
56+
private const NODE_WIDTH = 260;
57+
private const NODE_HEIGHT = 120;
58+
private const GAP = 90;
59+
private const MARGIN = 60;
60+
private const TITLE_BAND = 96;
61+
62+
public function get_id(): string {
63+
return 'flow_diagram';
64+
}
65+
66+
public function get_name(): string {
67+
return 'Flow Diagram';
68+
}
69+
70+
public function get_description(): string {
71+
return 'Brand-agnostic flow diagram. Renders labelled nodes (box, diamond, oval) connected by directional arrows from a declarative nodes/edges spec. Composable via CLI, REST, MCP, or PHP.';
72+
}
73+
74+
public function get_fields(): array {
75+
return array(
76+
'title' => array(
77+
'label' => 'Title',
78+
'type' => 'string',
79+
'required' => false,
80+
),
81+
'direction' => array(
82+
'label' => 'Layout Direction',
83+
'type' => 'string',
84+
'required' => false,
85+
),
86+
'nodes' => array(
87+
'label' => 'Nodes',
88+
'type' => 'array',
89+
'required' => true,
90+
),
91+
'edges' => array(
92+
'label' => 'Edges',
93+
'type' => 'array',
94+
'required' => false,
95+
),
96+
);
97+
}
98+
99+
public function get_default_preset(): string {
100+
return 'open_graph';
101+
}
102+
103+
/**
104+
* @param array $data Diagram spec (title, direction, nodes, edges).
105+
* @param GDRenderer $renderer Shared GD renderer.
106+
* @param array $options preset, format, context, colors overrides.
107+
* @return string[] Rendered file paths.
108+
*/
109+
public function render( array $data, GDRenderer $renderer, array $options = array() ): array {
110+
$nodes = isset( $data['nodes'] ) && is_array( $data['nodes'] ) ? array_values( $data['nodes'] ) : array();
111+
if ( empty( $nodes ) ) {
112+
do_action( 'datamachine_log', 'error', 'FlowDiagramTemplate: no nodes provided' );
113+
return array();
114+
}
115+
116+
$edges = isset( $data['edges'] ) && is_array( $data['edges'] ) ? $data['edges'] : array();
117+
$title = isset( $data['title'] ) ? (string) $data['title'] : '';
118+
$direction = ( ( $data['direction'] ?? 'horizontal' ) === 'vertical' ) ? 'vertical' : 'horizontal';
119+
$colors = isset( $options['colors'] ) && is_array( $options['colors'] ) ? $options['colors'] : array();
120+
$format = $options['format'] ?? 'png';
121+
122+
$count = count( $nodes );
123+
$title_band = '' !== $title ? self::TITLE_BAND : 0;
124+
125+
// Widen the inter-node gap when any edge carries a label so the label
126+
// chip has room to sit clear of both node fills.
127+
$has_edge_labels = false;
128+
foreach ( $edges as $edge ) {
129+
if ( ! empty( $edge['label'] ) ) {
130+
$has_edge_labels = true;
131+
break;
132+
}
133+
}
134+
$gap = $has_edge_labels ? self::GAP + 90 : self::GAP;
135+
136+
// Compute canvas from the node grid (ignore preset; diagrams size to content).
137+
if ( 'vertical' === $direction ) {
138+
$width = self::MARGIN * 2 + self::NODE_WIDTH;
139+
$height = self::MARGIN * 2 + $title_band + $count * self::NODE_HEIGHT + ( $count - 1 ) * $gap;
140+
} else {
141+
$width = self::MARGIN * 2 + $count * self::NODE_WIDTH + ( $count - 1 ) * $gap;
142+
$height = self::MARGIN * 2 + $title_band + self::NODE_HEIGHT;
143+
}
144+
145+
$renderer->create_canvas( $width, $height );
146+
147+
// Fonts: theme fonts if present, DejaVu fallback otherwise (handled by register_font).
148+
$renderer->register_font( 'title', 'Inter-Bold.ttf' );
149+
$renderer->register_font( 'label', 'Inter-Regular.ttf' );
150+
151+
$bg = $renderer->color_hex( 'bg', $colors['background'] ?? self::DEFAULT_BG );
152+
$surface = $renderer->color_hex( 'surface', $colors['surface'] ?? self::DEFAULT_SURFACE );
153+
$node_c = $renderer->color_hex( 'node', $colors['node'] ?? self::DEFAULT_NODE );
154+
$edge_c = $renderer->color_hex( 'edge', $colors['edge'] ?? self::DEFAULT_EDGE );
155+
$text_c = $renderer->color_hex( 'text', $colors['text'] ?? self::DEFAULT_TEXT );
156+
$title_c = $renderer->color_hex( 'title', $colors['title'] ?? self::DEFAULT_TITLE );
157+
$muted_c = $renderer->color_hex( 'muted', $colors['muted'] ?? self::DEFAULT_MUTED );
158+
159+
$renderer->fill( $bg );
160+
161+
if ( '' !== $title ) {
162+
$renderer->filled_rect( 0, 0, $width, $title_band, $surface );
163+
$renderer->draw_text_centered( $title, 30, self::MARGIN + 6, $title_c, 'title' );
164+
}
165+
166+
// Position nodes on a single row/column and index by id for edge routing.
167+
$positions = array();
168+
$top0 = self::MARGIN + $title_band;
169+
170+
foreach ( $nodes as $i => $node ) {
171+
if ( 'vertical' === $direction ) {
172+
$x = self::MARGIN;
173+
$y = $top0 + $i * ( self::NODE_HEIGHT + $gap );
174+
} else {
175+
$x = self::MARGIN + $i * ( self::NODE_WIDTH + $gap );
176+
$y = $top0;
177+
}
178+
179+
$id = isset( $node['id'] ) ? (string) $node['id'] : (string) $i;
180+
181+
$positions[ $id ] = array(
182+
'x' => $x,
183+
'y' => $y,
184+
'cx' => $x + intdiv( self::NODE_WIDTH, 2 ),
185+
'cy' => $y + intdiv( self::NODE_HEIGHT, 2 ),
186+
'right' => $x + self::NODE_WIDTH,
187+
'bottom' => $y + self::NODE_HEIGHT,
188+
);
189+
}
190+
191+
// Draw edges first so arrows sit behind node fills.
192+
foreach ( $edges as $edge ) {
193+
$from = isset( $edge['from'] ) ? (string) $edge['from'] : '';
194+
$to = isset( $edge['to'] ) ? (string) $edge['to'] : '';
195+
196+
if ( ! isset( $positions[ $from ], $positions[ $to ] ) ) {
197+
continue;
198+
}
199+
200+
$a = $positions[ $from ];
201+
$b = $positions[ $to ];
202+
203+
if ( 'vertical' === $direction ) {
204+
$x1 = $a['cx'];
205+
$y1 = $a['bottom'];
206+
$x2 = $b['cx'];
207+
$y2 = $b['y'];
208+
} else {
209+
$x1 = $a['right'];
210+
$y1 = $a['cy'];
211+
$x2 = $b['x'];
212+
$y2 = $b['cy'];
213+
}
214+
215+
$renderer->draw_arrow( $x1, $y1, $x2, $y2, $edge_c, 3 );
216+
217+
$label = isset( $edge['label'] ) ? (string) $edge['label'] : '';
218+
if ( '' !== $label ) {
219+
$mid_x = intdiv( $x1 + $x2, 2 );
220+
$mid_y = intdiv( $y1 + $y2, 2 );
221+
$fs = 14;
222+
$lw = $renderer->measure_text_width( $label, $fs, 'label' );
223+
$pad = 8;
224+
$chip_h = $fs + $pad;
225+
// Chip sits above the connector so it never overlaps node fills.
226+
$chip_x1 = $mid_x - intdiv( $lw, 2 ) - $pad;
227+
$chip_y1 = $mid_y - $chip_h - 6;
228+
$renderer->draw_rounded_rect( $chip_x1, $chip_y1, $lw + $pad * 2, $chip_h, $surface, 6 );
229+
$renderer->draw_text( $label, $fs, $mid_x - intdiv( $lw, 2 ), $chip_y1 + $fs + intdiv( $pad, 2 ) - 2, $muted_c, 'label' );
230+
}
231+
}
232+
233+
// Draw nodes.
234+
foreach ( $nodes as $i => $node ) {
235+
$id = isset( $node['id'] ) ? (string) $node['id'] : (string) $i;
236+
$pos = $positions[ $id ];
237+
238+
$shape = $node['shape'] ?? 'box';
239+
$fill = isset( $node['color'] ) ? $renderer->color_hex( 'node_' . $id, (string) $node['color'] ) : $node_c;
240+
$label = isset( $node['label'] ) ? (string) $node['label'] : '';
241+
$label = str_replace( '\n', "\n", $label );
242+
243+
switch ( $shape ) {
244+
case 'diamond':
245+
$renderer->draw_diamond( $pos['cx'], $pos['cy'], self::NODE_WIDTH, self::NODE_HEIGHT, $fill, true );
246+
break;
247+
case 'oval':
248+
$renderer->draw_oval( $pos['cx'], $pos['cy'], self::NODE_WIDTH, self::NODE_HEIGHT, $fill, true );
249+
break;
250+
default:
251+
$renderer->draw_rounded_rect( $pos['x'], $pos['y'], self::NODE_WIDTH, self::NODE_HEIGHT, $fill, 16 );
252+
break;
253+
}
254+
255+
$this->draw_node_label( $renderer, $label, $pos, $text_c );
256+
}
257+
258+
$path = $renderer->save_temp( $format );
259+
260+
return $path ? array( $path ) : array();
261+
}
262+
263+
/**
264+
* Draw a node's (possibly multi-line, possibly wrapped) label centered in the node box.
265+
*
266+
* @param GDRenderer $renderer Renderer.
267+
* @param string $label Label text (may contain "\n").
268+
* @param array<string, int> $pos Node position record.
269+
* @param int $color Text color id.
270+
*/
271+
private function draw_node_label( GDRenderer $renderer, string $label, array $pos, int $color ): void {
272+
if ( '' === $label ) {
273+
return;
274+
}
275+
276+
$inner = self::NODE_WIDTH - 40;
277+
278+
// Auto-shrink the font until the longest single token fits the node
279+
// width. Handles long unbroken strings (e.g. "ai-provider-for-claude-code")
280+
// that word-wrapping alone can't break.
281+
$font_size = 20;
282+
$longest = '';
283+
foreach ( preg_split( '/\s+/', str_replace( "\n", ' ', $label ) ) as $token ) {
284+
if ( strlen( $token ) > strlen( $longest ) ) {
285+
$longest = $token;
286+
}
287+
}
288+
while ( $font_size > 11 && $renderer->measure_text_width( $longest, $font_size, 'label' ) > $inner ) {
289+
--$font_size;
290+
}
291+
292+
// Expand manual line breaks, then wrap each to the inner width.
293+
$lines = array();
294+
foreach ( explode( "\n", $label ) as $segment ) {
295+
foreach ( $renderer->wrap_text( $segment, $font_size, 'label', $inner ) as $wrapped ) {
296+
$lines[] = $wrapped;
297+
}
298+
}
299+
300+
$line_h = (int) ( $font_size * 1.35 );
301+
$block_h = count( $lines ) * $line_h;
302+
$start_y = $pos['cy'] - intdiv( $block_h, 2 );
303+
304+
foreach ( $lines as $n => $line ) {
305+
$lw = $renderer->measure_text_width( $line, $font_size, 'label' );
306+
$lx = $pos['cx'] - intdiv( $lw, 2 );
307+
$ly = $start_y + $n * $line_h + $font_size;
308+
$renderer->draw_text( $line, $font_size, $lx, $ly, $color, 'label' );
309+
}
310+
}
311+
}

inc/bootstrap.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,22 @@ static function (): bool {
144144
}
145145
);
146146

147+
/*
148+
|--------------------------------------------------------------------------
149+
| Core image templates
150+
|--------------------------------------------------------------------------
151+
| Core ships only brand-agnostic, structural templates (generic primitives).
152+
| Domain-specific templates (event cards, quote cards) belong downstream.
153+
*/
154+
add_filter(
155+
// phpcs:ignore WordPress.NamingConventions.ValidHookName -- Intentional slash-separated hook namespace.
156+
'datamachine/image_generation/templates',
157+
static function ( array $templates ): array {
158+
$templates['flow_diagram'] ??= \DataMachine\Abilities\Media\Templates\FlowDiagramTemplate::class;
159+
return $templates;
160+
}
161+
);
162+
147163
/*
148164
|--------------------------------------------------------------------------
149165
| Iteration budget registrations

0 commit comments

Comments
 (0)