From 3e43746c177ff4ef5d2e2f90466b1da17ae42044 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 22:35:12 +0200 Subject: [PATCH] feat(draw): per-object data-* caption overlay on the drawing card (T-318) DrawingCard stacks a caption layer over the SVG: each data-label / data-description annotation renders a themed caption just below its element, mapped to pixel space via a shared svgViewportFit (refactored out of the painter so the overlay and the paint use identical viewBox-fit math). Display-only (IgnorePointer). Widget-tested. data-lightbox is captured in the model; the tap-to-zoom interaction is the last follow-on. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/widgets/src/draw/drawing_card.dart | 50 ++++++++++++++++++++++-- lib/widgets/src/svg/svg_painter.dart | 26 +++++++++--- test/widgets/draw/drawing_card_test.dart | 10 +++++ 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/lib/widgets/src/draw/drawing_card.dart b/lib/widgets/src/draw/drawing_card.dart index 4391db65..1aea3e0e 100644 --- a/lib/widgets/src/draw/drawing_card.dart +++ b/lib/widgets/src/draw/drawing_card.dart @@ -2,9 +2,11 @@ /// /// Renders an [SvgDocument] — the substrate the SVG engine paints — inside a /// framed region, with an optional clide-themed caption (label + description) -/// beneath it. The SVG is *content* (its own palette); the frame and caption -/// are clide *chrome* and use `SurfaceTokens`. Display-only per D-78 — no -/// interaction lives on the card. +/// beneath it, plus per-object overlay captions anchored to SVG elements that +/// carry `data-label` / `data-description` (mapped to pixel space via the same +/// viewBox fit the painter uses). The SVG is *content* (its own palette); the +/// frame and captions are clide *chrome* and use `SurfaceTokens`. Display-only +/// per D-78 — no interaction lives on the card. /// /// Sizes the SVG to its intrinsic aspect ratio (viewBox / width-height) within /// a height cap, filling the available width. @@ -51,8 +53,24 @@ class DrawingCard extends StatelessWidget { Widget _svgRegion(SurfaceTokens tokens) { final view = SvgView(document: document, images: images); + final captioned = document.annotations.where((a) => _has(a.label) || _has(a.description)).toList(); + final content = captioned.isEmpty + ? view + : Stack( + fit: StackFit.expand, + children: [ + view, + LayoutBuilder( + builder: (ctx, c) { + final size = Size(c.maxWidth, c.maxHeight); + final vp = svgViewportFit(size, document); + return Stack(children: [for (final a in captioned) _caption(a, vp, tokens)]); + }, + ), + ], + ); final aspect = _aspect(document); - final sized = aspect != null ? AspectRatio(aspectRatio: aspect, child: view) : SizedBox(height: maxHeight, child: view); + final sized = aspect != null ? AspectRatio(aspectRatio: aspect, child: content) : SizedBox(height: maxHeight, child: content); return DecoratedBox( decoration: BoxDecoration( color: tokens.panelBackground, @@ -69,6 +87,30 @@ class DrawingCard extends StatelessWidget { ); } + /// A per-object caption (data-label / data-description) positioned just below + /// the annotated element's pixel bbox. Display-only (IgnorePointer). + Widget _caption(SvgAnnotation a, SvgViewport vp, SurfaceTokens tokens) { + final tl = vp.toPixel(a.x, a.y); + final br = vp.toPixel(a.x + a.width, a.y + a.height); + final w = (br.dx - tl.dx).abs(); + final h = (br.dy - tl.dy).abs(); + return Positioned( + left: tl.dx, + top: tl.dy + h + 2, + width: w < 48 ? 48.0 : w, + child: IgnorePointer( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_has(a.label)) ClideText(a.label!, fontSize: clideFontCaption, fontWeight: FontWeight.w600, color: tokens.globalForeground, maxLines: 2), + if (_has(a.description)) ClideText(a.description!, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 3), + ], + ), + ), + ); + } + static double? _aspect(SvgDocument d) { final w = d.viewBox?.width ?? d.width; final h = d.viewBox?.height ?? d.height; diff --git a/lib/widgets/src/svg/svg_painter.dart b/lib/widgets/src/svg/svg_painter.dart index 4723f01c..8f727d35 100644 --- a/lib/widgets/src/svg/svg_painter.dart +++ b/lib/widgets/src/svg/svg_painter.dart @@ -42,15 +42,31 @@ class _Ctx { final SvgImageResolver? images; } -void _applyViewport(ui.Canvas canvas, Size size, SvgDocument doc) { +/// The fit of [doc]'s viewBox into [size] — a uniform [scale] plus a [dx]/[dy] +/// offset (xMidYMid meet) and the viewBox origin. Shared by the painter and the +/// DrawingCard overlay so captions land exactly over the painted content. +class SvgViewport { + const SvgViewport(this.scale, this.dx, this.dy, this.minX, this.minY); + final double scale, dx, dy, minX, minY; + + /// Map a viewBox-space point to a pixel offset. + Offset toPixel(double x, double y) => Offset(dx + (x - minX) * scale, dy + (y - minY) * scale); +} + +SvgViewport svgViewportFit(Size size, SvgDocument doc) { final vb = doc.viewBox; final srcW = vb?.width ?? doc.width ?? size.width; final srcH = vb?.height ?? doc.height ?? size.height; - if (srcW <= 0 || srcH <= 0) return; + if (srcW <= 0 || srcH <= 0) return const SvgViewport(1, 0, 0, 0, 0); final scale = math.min(size.width / srcW, size.height / srcH); - canvas.translate((size.width - srcW * scale) / 2, (size.height - srcH * scale) / 2); - canvas.scale(scale); - if (vb != null) canvas.translate(-vb.minX, -vb.minY); + return SvgViewport(scale, (size.width - srcW * scale) / 2, (size.height - srcH * scale) / 2, vb?.minX ?? 0, vb?.minY ?? 0); +} + +void _applyViewport(ui.Canvas canvas, Size size, SvgDocument doc) { + final v = svgViewportFit(size, doc); + canvas.translate(v.dx, v.dy); + canvas.scale(v.scale); + canvas.translate(-v.minX, -v.minY); } void _paintNode(ui.Canvas canvas, SvgNode node, _Ctx ctx) { diff --git a/test/widgets/draw/drawing_card_test.dart b/test/widgets/draw/drawing_card_test.dart index e5ec0831..48fa42d0 100644 --- a/test/widgets/draw/drawing_card_test.dart +++ b/test/widgets/draw/drawing_card_test.dart @@ -33,4 +33,14 @@ void main() { expect(find.byType(SvgView), findsOneWidget); expect(find.text('Build pipeline'), findsNothing); }); + + testWidgets('renders per-object captions from data-* annotations (T-318)', (tester) async { + final doc = buildSvgDocument( + '', + ); + await tester.pumpWidget(anchoredHarness(f, SizedBox(width: 400, child: DrawingCard(document: doc)))); + await tester.pump(); + expect(find.text('Node A'), findsOneWidget); + expect(find.text('the entry point'), findsOneWidget); + }); }