feat(graph): deterministic force-directed layout solver (T-323)

Fruchterman-Reingold (clide-owned, no layout package): nodes repel, edges
attract, cooled over iterations into a readable layout. Deterministic — a
fixed circular seed, no RNG — so the graph view is stable across rebuilds
and the solver is unit-tested (6 cases). Flutter-free; the interactive graph
pane (render, pan/zoom, hover, filter, pql wiring) builds on this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 00:03:44 +02:00
co-authored by Claude Opus 4.8
parent c167a37ea7
commit bcef3a6fca
4 changed files with 281 additions and 0 deletions
+67
View File
@@ -9085,3 +9085,70 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
DONE (2026-06-29): SVG-substrate compare card landed (approach chosen by user) + tests green. compareTemplateHandler lowers an images array [{path,label,description}] to side-by-side <image> cells, each with the T-318 data-label/description caption + data-lightbox; paths resolved to absolute up front (injected, honest DrawErr on a miss). _paintImage now aspect-fits (contain, centered) so differing shapes don''t distort. Draw card path loads <image> hrefs -> ui.Image via loadDrawingImages (decoder injectable for tests); renderer + lightbox both paint through the resolver. Commits a651c9e0 (template+painter) + 1f6d31ac (image-loading+register).', NULL, '2026-06-29 13:02:44', '2026-06-29 13:02:44.687', '2026-06-29 13:02:44.687', NULL, '61cd7ded0058d2727055ab9805712180', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ESCR4V6V07CRH18FBCDN8', 'status', 'in_progress', 'done', NULL, '2026-06-29 13:02:44', '2026-06-29 13:02:44.726', '2026-06-29 13:02:44.726', NULL, 'a03f0481fb84e46fa45077355a64a47b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 16:44:18', '2026-06-29 16:44:18.342', '2026-06-29 16:44:18.342', NULL, '0d5c93289262d7527ea7471e96f8d02b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'description', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`.
## Why
Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file <path.json>` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume.
## Where the work lives
clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart).
## Scope / decisions
- Generic infra, not icon/image specific once landed, any command opts in via its CommandSchema.
- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence.
- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY).
- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures.
- D-6 parity: document the stdin convention alongside the other CLI verbs.
## Acceptance
- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload.
- `clide icon show --stdin` (T-313) and `clide image show <path> --stdin` (T-316) both consume it.
- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY.
- Malformed JSON returns a userError with a helpful message.
Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this.
Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever.', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`.
## Why
Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file <path.json>` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume.
## Where the work lives
clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart).
## Scope / decisions
- Generic infra, not icon/image specific once landed, any command opts in via its CommandSchema.
- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence.
- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY).
- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures.
- D-6 parity: document the stdin convention alongside the other CLI verbs.
## Acceptance
- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload.
- `clide icon show --stdin` (T-313) and `clide image show <path> --stdin` (T-316) both consume it.
- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY.
- Malformed JSON returns a userError with a helpful message.
Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this.
Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever.
DONE (2026-06-29): --stdin piped payloads landed + tests green, C client builds clean. C client (clide.c): slurp stdin on --stdin, strip the flag, ship args.stdin (bounded 32KB + envelope size guard). Dart unwrap (argv_dispatch) folds args.stdin into the inner request as a flag -> named arg (undeclared keys pass the schema). icon.show + image.show read the stdin payload as the peer of --file (stdin wins). Tests: unwrap fold + both handlers'' stdin path. Commit 64410e80. NOTE: the fold is generic, so draw could adopt --stdin trivially if wanted.', NULL, '2026-06-29 21:56:46', '2026-06-29 21:56:46.563', '2026-06-29 21:56:46.563', NULL, '649bcf844d6f04552c9c854b2bca84f2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'status', 'in_progress', 'done', NULL, '2026-06-29 21:56:46', '2026-06-29 21:56:46.597', '2026-06-29 21:56:46.597', NULL, '5abc8eaf2d572c6eba1a69f9c85147b8', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'description', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it.', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it.
DECOUPLED from T-323 (2026-06-29): the graph CARD is static display-only (D-78) it lowers a nodes/edges JSON to the SVG substrate (T-320) like the compare/d2 templates, so it does NOT need the interactive force-directed pane (T-323). T-323 remains a separate from-scratch pane. Building as a Flutter-free ''graph'' drawing template: deterministic circular layout -> <circle> nodes + <line> edges + <text> labels.', NULL, '2026-06-29 21:58:13', '2026-06-29 21:58:13.945', '2026-06-29 21:58:13.945', NULL, '77d68f4254bf06af680c330d4817a960', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 21:58:13', '2026-06-29 21:58:13.987', '2026-06-29 21:58:13.987', NULL, '6ad6abee6bb3fe32a0267e9a299ce8ba', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'status', 'in_progress', 'done', NULL, '2026-06-29 22:02:01', '2026-06-29 22:02:01.070', '2026-06-29 22:02:01.070', NULL, 'f810703df30dce71610d7cb15c456987', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'description', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).', NULL, '2026-06-29 22:02:01', '2026-06-29 22:02:01.103', '2026-06-29 22:02:01.103', NULL, '6805996f31597f6175f5bf5d4fb1da0f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 22:02:01', '2026-06-29 22:02:01.132', '2026-06-29 22:02:01.132', NULL, 'cdeea9aebcb4acc1242d3e1354756020', 2) ON CONFLICT(hash) DO NOTHING;
+73
View File
@@ -10993,3 +10993,76 @@ clide''s IPC server runs in-process and the `clide` CLI is a thin client that se
Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this.
Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-29 16:44:18.342', NULL, 'c6331517a674a7e66a02bac032bc6cff', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide CLI: accept a JSON payload on stdin for structured commands', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`.
## Why
Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file <path.json>` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume.
## Where the work lives
clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart).
## Scope / decisions
- Generic infra, not icon/image specific once landed, any command opts in via its CommandSchema.
- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence.
- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY).
- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures.
- D-6 parity: document the stdin convention alongside the other CLI verbs.
## Acceptance
- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload.
- `clide icon show --stdin` (T-313) and `clide image show <path> --stdin` (T-316) both consume it.
- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY.
- Malformed JSON returns a userError with a helpful message.
Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this.
Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever.
DONE (2026-06-29): --stdin piped payloads landed + tests green, C client builds clean. C client (clide.c): slurp stdin on --stdin, strip the flag, ship args.stdin (bounded 32KB + envelope size guard). Dart unwrap (argv_dispatch) folds args.stdin into the inner request as a flag -> named arg (undeclared keys pass the schema). icon.show + image.show read the stdin payload as the peer of --file (stdin wins). Tests: unwrap fold + both handlers'' stdin path. Commit 64410e80. NOTE: the fold is generic, so draw could adopt --stdin trivially if wanted.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-29 21:56:46.563', NULL, '6e56c46124a95bc0abe57407d46ca351', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide CLI: accept a JSON payload on stdin for structured commands', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`.
## Why
Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file <path.json>` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume.
## Where the work lives
clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart).
## Scope / decisions
- Generic infra, not icon/image specific once landed, any command opts in via its CommandSchema.
- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence.
- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY).
- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures.
- D-6 parity: document the stdin convention alongside the other CLI verbs.
## Acceptance
- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload.
- `clide icon show --stdin` (T-313) and `clide image show <path> --stdin` (T-316) both consume it.
- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY.
- Malformed JSON returns a userError with a helpful message.
Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this.
Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever.
DONE (2026-06-29): --stdin piped payloads landed + tests green, C client builds clean. C client (clide.c): slurp stdin on --stdin, strip the flag, ship args.stdin (bounded 32KB + envelope size guard). Dart unwrap (argv_dispatch) folds args.stdin into the inner request as a flag -> named arg (undeclared keys pass the schema). icon.show + image.show read the stdin payload as the peer of --file (stdin wins). Tests: unwrap fold + both handlers'' stdin path. Commit 64410e80. NOTE: the fold is generic, so draw could adopt --stdin trivially if wanted.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-29 21:56:46.597', NULL, '8394145b7847882dc0e01be0dc9d9658', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: graph render', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it.
DECOUPLED from T-323 (2026-06-29): the graph CARD is static display-only (D-78) it lowers a nodes/edges JSON to the SVG substrate (T-320) like the compare/d2 templates, so it does NOT need the interactive force-directed pane (T-323). T-323 remains a separate from-scratch pane. Building as a Flutter-free ''graph'' drawing template: deterministic circular layout -> <circle> nodes + <line> edges + <text> labels.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 11:11:59', '2026-06-29 21:58:13.945', NULL, '1b823b1adfcea41b041b99267f169eea', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: graph render', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it.
DECOUPLED from T-323 (2026-06-29): the graph CARD is static display-only (D-78) it lowers a nodes/edges JSON to the SVG substrate (T-320) like the compare/d2 templates, so it does NOT need the interactive force-directed pane (T-323). T-323 remains a separate from-scratch pane. Building as a Flutter-free ''graph'' drawing template: deterministic circular layout -> <circle> nodes + <line> edges + <text> labels.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-10 11:11:59', '2026-06-29 21:58:13.986', NULL, '3cf15954664b5753f44a88bdbd354ca7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: graph render', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it.
DECOUPLED from T-323 (2026-06-29): the graph CARD is static display-only (D-78) it lowers a nodes/edges JSON to the SVG substrate (T-320) like the compare/d2 templates, so it does NOT need the interactive force-directed pane (T-323). T-323 remains a separate from-scratch pane. Building as a Flutter-free ''graph'' drawing template: deterministic circular layout -> <circle> nodes + <line> edges + <text> labels.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 11:11:59', '2026-06-29 22:02:01.070', NULL, '652525a97c7352f5bef99280f6641d40', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.
SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-06-29 22:02:01.103', NULL, 'ed8d62db18af33012affaba64e15471f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-06-29 22:02:01.132', NULL, 'b89270f930759e93fea42054d4629cf7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+99
View File
@@ -0,0 +1,99 @@
/// Force-directed graph layout (T-323) — a clide-owned Fruchterman-Reingold
/// solver (own-the-rendering-stack: no layout package).
///
/// Nodes repel each other (an inverse-distance "Coulomb" force); edges pull
/// their endpoints together (a "spring"). Iterating with a cooling temperature
/// settles the graph into a readable layout. DETERMINISTIC — a fixed circular
/// seed (no RNG) means the same graph always lays out identically, so the view
/// is stable across rebuilds and the solver is unit-testable.
///
/// Flutter-free: pure Dart (dart:math), runs under `dart test`. The graph PANE
/// (rendering, pan/zoom, hover, filter) builds on top of this.
library;
import 'dart:math' as math;
/// A laid-out 2D point.
typedef GraphPoint = ({double x, double y});
class _Vec {
_Vec(this.x, this.y);
double x, y;
}
class ForceLayout {
/// Lay out [nodeIds] connected by [edges] (pairs of node ids) in a
/// [width]×[height] area over [iterations] steps. Edges referencing an unknown
/// node are ignored. Returns each node's settled position, clamped to the area.
static Map<String, GraphPoint> compute(List<String> nodeIds, List<(String, String)> edges, {double width = 800, double height = 600, int iterations = 200}) {
final n = nodeIds.length;
if (n == 0) return const {};
final cx = width / 2, cy = height / 2;
if (n == 1) return {nodeIds.first: (x: cx, y: cy)};
// Deterministic circular seed.
final pos = <String, _Vec>{};
for (var i = 0; i < n; i++) {
final a = 2 * math.pi * i / n;
pos[nodeIds[i]] = _Vec(cx + math.cos(a) * width / 4, cy + math.sin(a) * height / 4);
}
final valid = edges.where((e) => pos.containsKey(e.$1) && pos.containsKey(e.$2) && e.$1 != e.$2).toList();
final k = math.sqrt(width * height / n); // ideal edge length
var temp = width / 10;
for (var iter = 0; iter < iterations; iter++) {
final disp = {for (final id in nodeIds) id: _Vec(0, 0)};
// Repulsion between every pair.
for (var i = 0; i < n; i++) {
for (var j = i + 1; j < n; j++) {
final a = pos[nodeIds[i]]!, b = pos[nodeIds[j]]!;
var dx = a.x - b.x, dy = a.y - b.y;
var dist = math.sqrt(dx * dx + dy * dy);
if (dist < 0.01) {
dx = 0.01 * (i.isEven ? 1 : -1);
dy = 0.01;
dist = 0.01;
}
final force = k * k / dist;
final ux = dx / dist, uy = dy / dist;
disp[nodeIds[i]]!
..x += ux * force
..y += uy * force;
disp[nodeIds[j]]!
..x -= ux * force
..y -= uy * force;
}
}
// Attraction along edges.
for (final e in valid) {
final a = pos[e.$1]!, b = pos[e.$2]!;
final dx = a.x - b.x, dy = a.y - b.y;
final dist = math.max(0.01, math.sqrt(dx * dx + dy * dy));
final force = dist * dist / k;
final ux = dx / dist, uy = dy / dist;
disp[e.$1]!
..x -= ux * force
..y -= uy * force;
disp[e.$2]!
..x += ux * force
..y += uy * force;
}
// Apply, capped by the temperature, clamped to the area.
for (final id in nodeIds) {
final d = disp[id]!;
final len = math.max(0.01, math.sqrt(d.x * d.x + d.y * d.y));
final step = math.min(len, temp);
final p = pos[id]!;
p.x = (p.x + d.x / len * step).clamp(0.0, width);
p.y = (p.y + d.y / len * step).clamp(0.0, height);
}
temp *= 0.95; // cool
}
return {for (final id in nodeIds) id: (x: pos[id]!.x, y: pos[id]!.y)};
}
}
+42
View File
@@ -0,0 +1,42 @@
import 'dart:math' as math;
import 'package:clide/src/graph/force_layout.dart';
import 'package:test/test.dart';
double _dist(GraphPoint a, GraphPoint b) => math.sqrt(math.pow(a.x - b.x, 2) + math.pow(a.y - b.y, 2));
void main() {
test('an empty graph lays out to nothing', () {
expect(ForceLayout.compute(const [], const []), isEmpty);
});
test('a single node sits at the centre', () {
expect(ForceLayout.compute(['a'], const [], width: 800, height: 600), {'a': (x: 400.0, y: 300.0)});
});
test('every node settles inside the area', () {
final p = ForceLayout.compute(['a', 'b', 'c', 'd'], const [('a', 'b'), ('b', 'c'), ('c', 'd')], width: 800, height: 600);
expect(p, hasLength(4));
for (final pt in p.values) {
expect(pt.x, inInclusiveRange(0, 800));
expect(pt.y, inInclusiveRange(0, 600));
}
});
test('it is deterministic — same input, same layout', () {
final a = ForceLayout.compute(['a', 'b', 'c'], const [('a', 'b'), ('b', 'c')]);
final b = ForceLayout.compute(['a', 'b', 'c'], const [('a', 'b'), ('b', 'c')]);
expect(a, b);
});
test('two disconnected nodes repel farther apart than two connected ones', () {
final connected = ForceLayout.compute(['a', 'b'], const [('a', 'b')]);
final apart = ForceLayout.compute(['a', 'b'], const []);
expect(_dist(apart['a']!, apart['b']!), greaterThan(_dist(connected['a']!, connected['b']!)));
});
test('an edge to an unknown node is ignored, not a crash', () {
final p = ForceLayout.compute(['a', 'b'], const [('a', 'ghost')]);
expect(p.keys, containsAll(['a', 'b']));
});
}