Skip to content

Commit c3d3ae2

Browse files
authored
feat(media): flow_diagram renders 16:9 by default, scales to fit (#2900)
The template previously sized the canvas to content, producing a thin banner that's awkward as a featured image. It now renders onto a fixed target canvas (default: 16:9 twitter_card 1200x675) and centers the diagram within it, scaling the grid down uniformly to fit. Callers can override with 'preset' or explicit width/height; very wide flows still grow the canvas rather than clip. Smoke test extended to assert the 16:9 default and preset override.
1 parent dba3b9d commit c3d3ae2

3 files changed

Lines changed: 139 additions & 32 deletions

File tree

docs/CHANGELOG.md

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

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

5+
## [Unreleased]
6+
7+
### Changed
8+
- `flow_diagram` template now renders onto a fixed target canvas and centers the diagram within it, defaulting to a 16:9 card (twitter_card, 1200x675) that works as a blog featured image or social share. The diagram grid scales down uniformly to fit the canvas, and callers can override via `preset` or explicit `width`/`height`. Very wide flows still grow the canvas rather than clip.
9+
510
## [0.163.1] - 2026-07-13
611

712
### Fixed

inc/Abilities/Media/Templates/FlowDiagramTemplate.php

Lines changed: 121 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
namespace DataMachine\Abilities\Media\Templates;
3838

3939
use DataMachine\Abilities\Media\GDRenderer;
40+
use DataMachine\Abilities\Media\PlatformPresets;
4041
use DataMachine\Abilities\Media\TemplateInterface;
4142

4243
if ( ! defined( 'ABSPATH' ) ) {
@@ -96,8 +97,14 @@ public function get_fields(): array {
9697
);
9798
}
9899

100+
/**
101+
* Default canvas: twitter_card is 1200x675, a true 16:9 card that works well
102+
* as a blog featured image / social share. The diagram is centered within it.
103+
*/
104+
private const DEFAULT_PRESET = 'twitter_card';
105+
99106
public function get_default_preset(): string {
100-
return 'open_graph';
107+
return self::DEFAULT_PRESET;
101108
}
102109

103110
/**
@@ -131,17 +138,46 @@ public function render( array $data, GDRenderer $renderer, array $options = arra
131138
break;
132139
}
133140
}
134-
$gap = $has_edge_labels ? self::GAP + 90 : self::GAP;
135141

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;
142+
// Resolve the target canvas first (default is a 16:9 card). The diagram
143+
// grid is then scaled to fit inside it and centered, so a bare render
144+
// always produces a clean, correctly-proportioned image.
145+
$canvas = $this->resolve_canvas( $options );
146+
$width = $canvas['width'];
147+
$height = $canvas['height'];
148+
149+
// Base (unscaled) grid metrics.
150+
$node_w = self::NODE_WIDTH;
151+
$node_h = self::NODE_HEIGHT;
152+
$gap = $has_edge_labels ? self::GAP + 90 : self::GAP;
153+
$margin = self::MARGIN;
154+
155+
$content_w = $this->grid_width( $direction, $count, $node_w, $gap, $margin );
156+
$content_h = $this->grid_height( $direction, $count, $node_h, $gap, $margin, $title_band );
157+
158+
// Scale the whole grid down uniformly if it overflows the canvas in
159+
// either axis. Never scale up past the base size (keeps small diagrams
160+
// from ballooning on a large canvas).
161+
$scale = min( 1.0, $width / max( 1, $content_w ), $height / max( 1, $content_h ) );
162+
if ( $scale < 1.0 ) {
163+
// floor() (not round()) guarantees the scaled grid never exceeds the
164+
// target canvas by a rounding pixel, so preset dimensions stay exact.
165+
$node_w = (int) floor( $node_w * $scale );
166+
$node_h = (int) floor( $node_h * $scale );
167+
$gap = (int) floor( $gap * $scale );
168+
$margin = (int) floor( $margin * $scale );
169+
$title_band = (int) floor( $title_band * $scale );
170+
$content_w = $this->grid_width( $direction, $count, $node_w, $gap, $margin );
171+
$content_h = $this->grid_height( $direction, $count, $node_h, $gap, $margin, $title_band );
143172
}
144173

174+
// If content is still larger than the canvas (single very wide node),
175+
// grow the canvas so nothing clips.
176+
$width = max( $width, $content_w );
177+
$height = max( $height, $content_h );
178+
$off_x = intdiv( $width - $content_w, 2 );
179+
$off_y = intdiv( $height - $content_h, 2 );
180+
145181
$renderer->create_canvas( $width, $height );
146182

147183
// Fonts: theme fonts if present, DejaVu fallback otherwise (handled by register_font).
@@ -159,20 +195,22 @@ public function render( array $data, GDRenderer $renderer, array $options = arra
159195
$renderer->fill( $bg );
160196

161197
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' );
198+
$title_fs = max( 16, (int) round( 30 * ( $title_band / self::TITLE_BAND ) ) );
199+
$renderer->filled_rect( 0, $off_y, $width, $off_y + $title_band, $surface );
200+
$renderer->draw_text_centered( $title, $title_fs, $off_y + $margin + 6, $title_c, 'title' );
164201
}
165202

166203
// Position nodes on a single row/column and index by id for edge routing.
204+
// Offsets center the content grid within the (possibly larger) canvas.
167205
$positions = array();
168-
$top0 = self::MARGIN + $title_band;
206+
$top0 = $off_y + $margin + $title_band;
169207

170208
foreach ( $nodes as $i => $node ) {
171209
if ( 'vertical' === $direction ) {
172-
$x = self::MARGIN;
173-
$y = $top0 + $i * ( self::NODE_HEIGHT + $gap );
210+
$x = $off_x + $margin;
211+
$y = $top0 + $i * ( $node_h + $gap );
174212
} else {
175-
$x = self::MARGIN + $i * ( self::NODE_WIDTH + $gap );
213+
$x = $off_x + $margin + $i * ( $node_w + $gap );
176214
$y = $top0;
177215
}
178216

@@ -181,10 +219,10 @@ public function render( array $data, GDRenderer $renderer, array $options = arra
181219
$positions[ $id ] = array(
182220
'x' => $x,
183221
'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,
222+
'cx' => $x + intdiv( $node_w, 2 ),
223+
'cy' => $y + intdiv( $node_h, 2 ),
224+
'right' => $x + $node_w,
225+
'bottom' => $y + $node_h,
188226
);
189227
}
190228

@@ -240,45 +278,99 @@ public function render( array $data, GDRenderer $renderer, array $options = arra
240278
$label = isset( $node['label'] ) ? (string) $node['label'] : '';
241279
$label = str_replace( '\n', "\n", $label );
242280

281+
$radius = max( 6, (int) round( 16 * ( $node_h / self::NODE_HEIGHT ) ) );
282+
243283
switch ( $shape ) {
244284
case 'diamond':
245-
$renderer->draw_diamond( $pos['cx'], $pos['cy'], self::NODE_WIDTH, self::NODE_HEIGHT, $fill, true );
285+
$renderer->draw_diamond( $pos['cx'], $pos['cy'], $node_w, $node_h, $fill, true );
246286
break;
247287
case 'oval':
248-
$renderer->draw_oval( $pos['cx'], $pos['cy'], self::NODE_WIDTH, self::NODE_HEIGHT, $fill, true );
288+
$renderer->draw_oval( $pos['cx'], $pos['cy'], $node_w, $node_h, $fill, true );
249289
break;
250290
default:
251-
$renderer->draw_rounded_rect( $pos['x'], $pos['y'], self::NODE_WIDTH, self::NODE_HEIGHT, $fill, 16 );
291+
$renderer->draw_rounded_rect( $pos['x'], $pos['y'], $node_w, $node_h, $fill, $radius );
252292
break;
253293
}
254294

255-
$this->draw_node_label( $renderer, $label, $pos, $text_c );
295+
$this->draw_node_label( $renderer, $label, $pos, $text_c, $node_w );
256296
}
257297

258298
$path = $renderer->save_temp( $format );
259299

260300
return $path ? array( $path ) : array();
261301
}
262302

303+
/**
304+
* Resolve the target output canvas size.
305+
*
306+
* Precedence:
307+
* 1. Explicit options[width] + options[height]
308+
* 2. options[preset] resolved via PlatformPresets
309+
* 3. The template default preset (16:9 twitter_card, 1200x675)
310+
*
311+
* @param array $options Render options (preset, width, height).
312+
* @return array{width: int, height: int}
313+
*/
314+
private function resolve_canvas( array $options ): array {
315+
if ( ! empty( $options['width'] ) && ! empty( $options['height'] ) ) {
316+
return array(
317+
'width' => (int) $options['width'],
318+
'height' => (int) $options['height'],
319+
);
320+
}
321+
322+
$preset = ! empty( $options['preset'] ) ? (string) $options['preset'] : self::DEFAULT_PRESET;
323+
$dims = PlatformPresets::dimensions( $preset );
324+
if ( ! $dims ) {
325+
$dims = PlatformPresets::dimensions( self::DEFAULT_PRESET );
326+
}
327+
328+
return array(
329+
'width' => (int) ( $dims['width'] ?? 1200 ),
330+
'height' => (int) ( $dims['height'] ?? 675 ),
331+
);
332+
}
333+
334+
/**
335+
* Total grid width for the given metrics.
336+
*/
337+
private function grid_width( string $direction, int $count, int $node_w, int $gap, int $margin ): int {
338+
if ( 'vertical' === $direction ) {
339+
return $margin * 2 + $node_w;
340+
}
341+
return $margin * 2 + $count * $node_w + ( $count - 1 ) * $gap;
342+
}
343+
344+
/**
345+
* Total grid height for the given metrics.
346+
*/
347+
private function grid_height( string $direction, int $count, int $node_h, int $gap, int $margin, int $title_band ): int {
348+
if ( 'vertical' === $direction ) {
349+
return $margin * 2 + $title_band + $count * $node_h + ( $count - 1 ) * $gap;
350+
}
351+
return $margin * 2 + $title_band + $node_h;
352+
}
353+
263354
/**
264355
* Draw a node's (possibly multi-line, possibly wrapped) label centered in the node box.
265356
*
266357
* @param GDRenderer $renderer Renderer.
267358
* @param string $label Label text (may contain "\n").
268359
* @param array<string, int> $pos Node position record.
269360
* @param int $color Text color id.
361+
* @param int $node_w Node width (scaled).
270362
*/
271-
private function draw_node_label( GDRenderer $renderer, string $label, array $pos, int $color ): void {
363+
private function draw_node_label( GDRenderer $renderer, string $label, array $pos, int $color, int $node_w ): void {
272364
if ( '' === $label ) {
273365
return;
274366
}
275367

276-
$inner = self::NODE_WIDTH - 40;
368+
$inner = $node_w - 40;
277369

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;
370+
// Base font scales with the node, then auto-shrinks until the longest
371+
// single token fits the inner width. Handles long unbroken strings
372+
// (e.g. "ai-provider-for-claude-code") that word-wrapping can't break.
373+
$font_size = max( 11, (int) round( 20 * ( $node_w / self::NODE_WIDTH ) ) );
282374
$longest = '';
283375
foreach ( preg_split( '/\s+/', str_replace( "\n", ' ', $label ) ) as $token ) {
284376
if ( strlen( $token ) > strlen( $longest ) ) {

tests/flow-diagram-template-smoke.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@
3636
// Source-string assertions (hold in every backend, incl. real WordPress).
3737
// -------------------------------------------------------------------------
3838

39-
$bootstrap = (string) file_get_contents( $plugin_root . '/inc/bootstrap.php' );
40-
$gd_source = (string) file_get_contents( $plugin_root . '/inc/Abilities/Media/GDRenderer.php' );
41-
$tmpl_source = (string) file_get_contents( $plugin_root . '/inc/Abilities/Media/Templates/FlowDiagramTemplate.php' );
39+
$bootstrap = (string) file_get_contents( $plugin_root . '/inc/bootstrap.php' );
40+
$gd_source = (string) file_get_contents( $plugin_root . '/inc/Abilities/Media/GDRenderer.php' );
41+
$tmpl_source = (string) file_get_contents( $plugin_root . '/inc/Abilities/Media/Templates/FlowDiagramTemplate.php' );
4242

4343
$assert(
4444
'bootstrap registers flow_diagram on the core template filter',
@@ -135,9 +135,19 @@ function get_template_directory() {
135135
$info = getimagesize( $path );
136136
$assert( 'output is a valid PNG', is_array( $info ) && $info['mime'] === 'image/png' );
137137
$assert( 'output has sane dimensions', is_array( $info ) && $info[0] > 100 && $info[1] > 100 );
138+
// Default canvas is a 16:9 twitter_card (1200x675).
139+
$assert( 'default render is 16:9 (1200x675)', is_array( $info ) && $info[0] === 1200 && $info[1] === 675 );
138140
wp_delete_file( $path );
139141
}
140142

143+
// Explicit preset override is honored.
144+
$og = $template->render( $spec, new GDRenderer(), array( 'preset' => 'open_graph' ) );
145+
if ( ! empty( $og[0] ) && file_exists( $og[0] ) ) {
146+
$oginfo = getimagesize( $og[0] );
147+
$assert( 'preset override honored (open_graph 1200x630)', is_array( $oginfo ) && $oginfo[0] === 1200 && $oginfo[1] === 630 );
148+
wp_delete_file( $og[0] );
149+
}
150+
141151
// Empty nodes must fail gracefully (no fatal, empty result).
142152
$empty = $template->render( array( 'nodes' => array() ), new GDRenderer() );
143153
$assert( 'empty nodes returns empty array without fatal', is_array( $empty ) && count( $empty ) === 0 );

0 commit comments

Comments
 (0)