feat(draw): graph card template — circular node/edge layout (T-321)
graphTemplateHandler lowers a {nodes,edges} payload to SVG: a deterministic
circular layout of labelled <circle> nodes with <line> edges, self-contained
(own light backdrop + content colors, like a d2 card) and painted by the
shared renderer. Display-only (D-78); decoupled from the interactive graph
pane (T-323) since a static card only needs a layout->SVG. Honest error on
a duplicate id or an edge to an unknown node.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- **Graph cards.** `clide draw --file graph.json` (template `graph`) renders a
|
||||
nodes/edges graph in the conversation as a circular layout — labelled nodes,
|
||||
lines between them. Honest error on a duplicate id or an edge to an unknown
|
||||
node. (T-321)
|
||||
- **Piped `--stdin` payloads.** `cat icons.json | clide icon show --stdin` (and
|
||||
`image show`) accept a JSON payload on stdin — the ergonomic peer of `--file`
|
||||
for structured commands. (T-315)
|
||||
|
||||
@@ -43,6 +43,7 @@ import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/draw_commands.dart';
|
||||
import 'package:clide/src/draw/compare_template.dart' show compareTemplateHandler;
|
||||
import 'package:clide/src/draw/d2_template.dart' show d2TemplateHandler;
|
||||
import 'package:clide/src/draw/graph_template.dart' show graphTemplateHandler;
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
@@ -405,6 +406,7 @@ Future<void> main() async {
|
||||
() => kernelMessages?.publish,
|
||||
registry: DrawingRegistry()
|
||||
..register('d2', d2TemplateHandler())
|
||||
..register('graph', graphTemplateHandler())
|
||||
..register(
|
||||
'compare',
|
||||
compareTemplateHandler(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/// The `graph` drawing-card template (T-321 / D-91 / D-103).
|
||||
///
|
||||
/// Lowers a `{nodes:[{id,label}], edges:[{from,to}]}` payload to an SVG the
|
||||
/// shared renderer (T-320) paints: a deterministic circular layout — nodes on a
|
||||
/// ring as labelled `<circle>`s, edges as `<line>`s between them. Display-only
|
||||
/// per D-78; the card-level label/description (T-318) renders beneath. Distinct
|
||||
/// from the interactive force-directed graph PANE (T-323) — this is a static
|
||||
/// graph dropped into the conversation.
|
||||
///
|
||||
/// Self-contained like a d2 diagram: it carries its own light backdrop + content
|
||||
/// colors (the graph is content, not clide chrome — its own palette). Honest
|
||||
/// [DrawErr] on an empty node set, a duplicate id, or an edge to an unknown node.
|
||||
///
|
||||
/// Flutter-free: pure Dart, runs under `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'draw_dispatch.dart';
|
||||
|
||||
const _nodeR = 9.0; // node circle radius
|
||||
const _ringR = 140.0; // layout ring radius
|
||||
const _pad = 52.0; // room for labels around the ring
|
||||
|
||||
DrawingTemplateHandler graphTemplateHandler() {
|
||||
return (doc) async {
|
||||
final nodesRaw = doc.fields['nodes'];
|
||||
if (nodesRaw is! List || nodesRaw.isEmpty) {
|
||||
return const DrawErr('the graph template needs a non-empty "nodes" array of {id,label}');
|
||||
}
|
||||
|
||||
final ids = <String>[];
|
||||
final labels = <String>[];
|
||||
for (final node in nodesRaw) {
|
||||
if (node is! Map) return const DrawErr('each graph node must be a JSON object {id,label}');
|
||||
final id = _str(node['id']);
|
||||
if (id == null) return const DrawErr('each graph node needs an "id"');
|
||||
if (ids.contains(id)) return DrawErr('duplicate node id: $id');
|
||||
ids.add(id);
|
||||
labels.add(_str(node['label']) ?? id);
|
||||
}
|
||||
|
||||
final n = ids.length;
|
||||
final cx = _ringR + _pad, cy = _ringR + _pad;
|
||||
final px = List<double>.filled(n, cx), py = List<double>.filled(n, cy);
|
||||
for (var i = 0; i < n && n > 1; i++) {
|
||||
final a = -math.pi / 2 + 2 * math.pi * i / n; // start at top, clockwise
|
||||
px[i] = cx + _ringR * math.cos(a);
|
||||
py[i] = cy + _ringR * math.sin(a);
|
||||
}
|
||||
final index = {for (var i = 0; i < n; i++) ids[i]: i};
|
||||
|
||||
// Edges first so nodes paint on top of them.
|
||||
final edges = StringBuffer();
|
||||
final edgesRaw = doc.fields['edges'];
|
||||
if (edgesRaw is List) {
|
||||
for (final edge in edgesRaw) {
|
||||
if (edge is! Map) return const DrawErr('each graph edge must be a JSON object {from,to}');
|
||||
final from = _str(edge['from']), to = _str(edge['to']);
|
||||
if (from == null || to == null) return const DrawErr('each graph edge needs a "from" and a "to"');
|
||||
final fi = index[from], ti = index[to];
|
||||
if (fi == null) return DrawErr('edge references unknown node: $from');
|
||||
if (ti == null) return DrawErr('edge references unknown node: $to');
|
||||
edges.write('<line x1="${_fmt(px[fi])}" y1="${_fmt(py[fi])}" x2="${_fmt(px[ti])}" y2="${_fmt(py[ti])}" stroke="#9aa4b2" stroke-width="1.5"/>');
|
||||
}
|
||||
}
|
||||
|
||||
final nodes = StringBuffer();
|
||||
for (var i = 0; i < n; i++) {
|
||||
nodes.write('<circle cx="${_fmt(px[i])}" cy="${_fmt(py[i])}" r="$_nodeR" fill="#4a90d9"/>');
|
||||
nodes.write('<text x="${_fmt(px[i])}" y="${_fmt(py[i] - _nodeR - 5)}" text-anchor="middle" font-size="13" fill="#33373d">${_esc(labels[i])}</text>');
|
||||
}
|
||||
|
||||
final size = 2 * (_ringR + _pad);
|
||||
return DrawOk(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${_fmt(size)} ${_fmt(size)}">'
|
||||
'<rect width="${_fmt(size)}" height="${_fmt(size)}" fill="#fafafa"/>'
|
||||
'$edges$nodes</svg>',
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;
|
||||
|
||||
String _fmt(double v) => v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(2);
|
||||
|
||||
String _esc(String s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:clide/src/draw/draw_dispatch.dart';
|
||||
import 'package:clide/src/draw/draw_doc.dart';
|
||||
import 'package:clide/src/draw/graph_template.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
final handler = graphTemplateHandler();
|
||||
DrawingCardDoc doc(Map<String, Object?> fields) => DrawingCardDoc(template: 'graph', fields: {'template': 'graph', ...fields});
|
||||
|
||||
test('lays out nodes as labelled circles and edges as lines', () async {
|
||||
final r = await handler(
|
||||
doc({
|
||||
'nodes': [
|
||||
{'id': 'a', 'label': 'Alpha'},
|
||||
{'id': 'b', 'label': 'Beta'},
|
||||
],
|
||||
'edges': [
|
||||
{'from': 'a', 'to': 'b'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
final svg = (r as DrawOk).svg;
|
||||
expect('<circle'.allMatches(svg).length, 2);
|
||||
expect('<line'.allMatches(svg).length, 1);
|
||||
expect(svg, contains('>Alpha<'));
|
||||
expect(svg, contains('>Beta<'));
|
||||
});
|
||||
|
||||
test('a node label defaults to its id', () async {
|
||||
final r = await handler(
|
||||
doc({
|
||||
'nodes': [
|
||||
{'id': 'solo'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect((r as DrawOk).svg, contains('>solo<'));
|
||||
});
|
||||
|
||||
test('empty or missing nodes is an honest error', () async {
|
||||
expect(await handler(doc({'nodes': const []})), isA<DrawErr>());
|
||||
expect(await handler(doc(const {})), isA<DrawErr>());
|
||||
});
|
||||
|
||||
test('a duplicate node id is an error', () async {
|
||||
final r = await handler(
|
||||
doc({
|
||||
'nodes': [
|
||||
{'id': 'x'},
|
||||
{'id': 'x'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect((r as DrawErr).message, contains('duplicate'));
|
||||
});
|
||||
|
||||
test('an edge to an unknown node is an error', () async {
|
||||
final r = await handler(
|
||||
doc({
|
||||
'nodes': [
|
||||
{'id': 'a'},
|
||||
],
|
||||
'edges': [
|
||||
{'from': 'a', 'to': 'ghost'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect((r as DrawErr).message, contains('ghost'));
|
||||
});
|
||||
|
||||
test('escapes label text', () async {
|
||||
final r = await handler(
|
||||
doc({
|
||||
'nodes': [
|
||||
{'id': 'a', 'label': '<b>&'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect((r as DrawOk).svg, contains('<b>&'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user