feat(content-graph): group-by selector + click-to-deselect + focused-icon centering#224
Merged
Merged
Conversation
…on centering
Three changes wrapped in one PR because they share the same files
and shipped in the same iteration.
1. Group-by selector
- New <wpd-select> on the toolbar next to the post-type chips.
Options: No grouping / Category / Tag / Author / Year / Year-month.
Per-facet self-describing option text ("Group by category", …)
so the control reads as a single-line peer of the filter chips
instead of a stacked label + select.
- Session-local state — selector resets to None on each window
open (no per-user persistence in v1; that's a follow-up if the
ask surfaces).
- REST /nodes payload extended with per-node author_id, year,
year_month, category_ids, tag_ids and a top-level groups
catalog of { authors, categories, tags } scoped to entries
referenced by visible nodes. Bulk wp_term_relationships JOIN
populates the per-post arrays in a single query.
- New cluster-attractor force in ForceSim: emergent centroids
(running average of member positions per group key); each
non-pinned node receives a 1/N-weighted pull per membership,
so multi-term posts settle at the force balance between
centroids instead of committing to one. Date facets pin
centroid X to a chronological lattice so year / year-month
clusters lay out left-to-right oldest-to-newest.
- New groupLabelLayer in GraphScene paints a translucent
per-facet-tinted pill at each non-empty cluster centroid:
Category=blue, Tag=green, Author=purple, Year/Year-month=
orange. White text + slight transparency so node titles
behind the pill remain visible and cluster labels can't be
confused with node labels (dark text on white).
- Picking a facet pre-builds per-node target positions on a
polar seed (unordered facets) or chronological lattice
(date facets), then lerps each node to its target over
~450ms with an ease-out cubic. The sim is paused during the
tween so the layout mechanisms don't fight, then a small
reheat lets the cluster force polish the final positions.
Camera fit-to-view runs against the target bounds so the
zoom animates in parallel with the layout flow. No more
"everything jammed in the middle until you drag a node".
- Cache invalidation extended with set_object_terms so
retagging a post outside the post-edit path doesn't leave
stale clusters behind the 6h TTL.
2. Click-to-deselect
- Second click on the focused node now clears the focus, hides
the panel, and lets the layout relax. Mirrors the gesture
used to open it instead of forcing a trip to the close button
or empty canvas.
3. Focused-post icon centering
- The admin-post dashicon's bbox-centred anchor placed its visible
pushpin glyph in the upper-left of the focused-node disc. New
ICON_NUDGE table applies a per-icon fractional offset
(admin-post = +6% x, +6% y of fontSize) so the glyph reads as
centred at every node radius.
Tests
- Vitest: 6 cases in content-graph-sim.test.ts (convergence,
separation, multi-membership force balance, null disables,
pinned exempt, chronological lattice). Suite green: 1283/1283.
- PHPUnit: 6 cases in contentGraphGroupBy.php (per-node fields,
empty-arrays, multi-term, catalog scoping, author names,
retag cache-bust). Not run locally — port 8890 was held by
a sibling worktree's wp-env — CI will catch any regression.
Contributor
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
Polish + a real bug fix on top of the group-by feature.
Bug: Pixi v8 multi-Application destroy race
Repro: open Content Graph → open Posts → click the Categories tab
(loads the categories mindmap, a second Pixi.Application on the page)
→ close Posts. Result: CG canvas froze + the console looped forever
with "Cannot read properties of null (reading 'clear')" thrown from
`Batcher.break()` inside Pixi every frame.
Diagnosis: tearing down one `Pixi.Application` while another live one
is on the page corrupts the surviving app's batcher pipe map. Tried
and rejected: `destroy()` without `texture: true`, deferred
`setTimeout` destroy, `sharedTicker: false` on the CG app — the race
lives below all of them.
Fix: don't call `app.destroy()` at all on the secondary apps. Stop
their ticker, detach the canvas, let GC reclaim. Applied to
categories-mindmap, tags-cloud, and the heartbeat widget. Leaks
~hundreds of KB per open/close cycle — acceptable.
Belt-and-braces on Content Graph
- `sharedTicker: false` so other bundles' Pixi apps can't drive CG's
renderer.
- `webglcontextlost` listener on the CG canvas → stops the ticker if
Chrome ever drops the GL context.
- Wrap `app.renderer.render` in try/catch → if a future race slips
past the above, we stop the ticker and log one warn instead of
looping rAF forever.
- Zero-dimension guard on the resize observer so `renderer.resize(0,
0)` (which happens during window layout shuffles) can't put the
renderer into the broken state.
- Cluster labels rendered as DOM elements over the canvas, not as
Pixi children — sidesteps the batched-renderer fragility for our
own per-frame label painting too.
Polish
- Satellite icons: split the `term` kind so categories render with
the folder icon and tags with the tag icon (both were showing the
tag glyph and reading as the same kind).
- Year-month grouping: alternate clusters above/below the
chronological axis (`groupOrderStaggerY`) so ~60 month buckets
don't crowd into each other on a single line.
- Chronological lattice for date facets: `groupOrder` array on the
sim pins centroid X to a left-to-right oldest-to-newest layout.
- Various smaller hardening touches on `setGrouping` / `destroy` /
`clearGroupViews`.
Removes docs/brainstorms/content-graph-group-by.md and docs/plans/2026-05-15-001-feat-content-graph-group-by-plan.md. These were scratch planning artifacts and don't belong in the shipped tree.
Claude Code's skill loader scans `.claude/skills/` but not `.agents/skills/`, where the shared skill library (currently `pixijs`) lives. The user's global `~/.gitignore` ignores `.claude/`, so a repo override is needed: re-include `.claude/skills` only, keeping `.claude/settings.local.json` and other local state ignored. The symlink itself is relative (`skills -> ../.agents/skills`) so it stays valid across worktrees and clones.
…lidates too
CI surfaced a failure in `Tests_DesktopMode_ContentGraphGroupBy::test_retagging_busts_cache`. The raw `$wpdb->query("DELETE FROM
{$wpdb->options} WHERE option_name LIKE …")` flush was clearing the
table row but leaving the matching entry in WP's in-memory options
cache, so the next `get_transient()` in the same process kept
returning the stale pre-retag payload.
This bit the `set_object_terms` invalidation path specifically because
that hook doesn't bump `post_modified_gmt` — so the build's cache key
stayed identical between calls, and the (still-warm) cache hit
returned the old `category_ids`. The `save_post` path masked the same
bug because `post_modified_gmt` changes there, producing a new cache
key on the rebuild.
Fix: enumerate the matching `_transient_*` option names, then route
each through `delete_transient()` so WP's transient/options cache
invalidation runs alongside the row delete. One query for the
enumeration + one delete_transient per matched key (n is small —
this is a per-Content-Graph-feature transient prefix).
…re author clusters
Three changes on top of the group-by feature.
1. Cluster labels
- Append member count, e.g. "Recipe (15)" / "Untagged (3)".
- Keep the smooth-glide centroid positioning (no collision push,
no top-anchoring). Fluidity beats strict non-overlap; an
occasional pass-through is acceptable.
2. Auto-fit-follow after a grouping change
- After the initial seed-target fit, the cluster force still
refines members and they could drift past the framed viewport.
New per-tick loop checks peak velocity: while it's >= 1.0
px/frame the camera refits to current bounds; once motion
drops below threshold (or 3s elapsed) the loop disarms.
- A "saw motion" gate avoids disarming on the very first tick
after the tween — the tween zeroes velocities by design, so
without this the loop would shut off before the sim has a
chance to run a step.
- Skipped during the tween itself (velocities are 0, initial
fit already framed the targets).
3. Contributor-weighted author clusters
- PHP: new `desktop_mode_content_graph_collect_post_contributors()`
bulk-queries distinct revision authors per in-scope post. Each
node carries `contributor_ids: int[]` (excluding the primary).
- Client: when grouping by author, `deriveGroupKeys` emits the
primary author key TWICE plus one entry per contributor. The
cluster-attractor force settles the post at the weighted
balance point — between the authors but closer to the primary.
Defensive `Array.isArray` guard so stale-cache payloads that
predate the field don't throw.
- Members map dedupes by node id so the duplicated primary key
doesn't inflate the cluster's "(15)" count or pull the centroid.
- Cache prefix bumped (`desktop_mode_cg_` → `desktop_mode_cg2_`)
so existing transients (missing `contributor_ids`) get invalidated
on upgrade instead of feeding the client a half-shaped payload.
Tests
- PHPUnit: assert `contributor_ids` is `[]` on a fresh post; new
`test_contributors_from_revision_authors` drives a revision via
an author swap and confirms the secondary author appears in
`contributor_ids`, the primary is filtered out, and the
`groups.authors` catalog resolves the contributor's display name.
- Vitest: extended `makeNode` with `contributor_ids: []`; existing
6 sim cases still pass.
… relying on wp_update_post `test_contributors_from_revision_authors` was failing in CI because the test created the contributor revision via `wp_update_post(['post_author' => B, ...])`. WP's `wp_save_post_revision` captures the CURRENT user as the revision's author, not whatever is in the post_author field of the update payload — and in the unit-test environment no current user is set, so the revision ended up authored by `0`. The cluster pipeline correctly excluded user `0`, the test then expected user `B` in `contributor_ids`, and the assertion failed. Switching to a direct `wp_insert_post` of a revision row with `post_author => B` matches the shape `desktop_mode_content_graph_collect_post_contributors` reads from and keeps the test independent of WP's revision-deduplication heuristics. Behaviour under test is unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Dashboard.WordPress.Develop.WordPress.2026-05-15.19-01-56.mp4
admin-postpushpin glyph was bbox-anchored in the upper-left of the focused-node disc; newICON_NUDGEtable corrects per-icon (currentlyadmin-postonly).What changed
PHP (
includes/content-graph/):/nodespayload extended with per-nodeauthor_id,year,year_month,category_ids[],tag_ids[]and a top-levelgroupscatalog ({authors, categories, tags}) scoped to entries actually referenced by visible nodes.wp_term_relationshipsJOIN to populate per-post arrays in one query.set_object_termsso retagging outside the post-edit path doesn't leave stale clusters behind the 6h TTL.TS (
src/content-graph/):ForceSim: new cluster-attractor force (two-pass per tick — emergent centroid, then per-membership1/N-weighted pull). OptionalgroupOrderpins centroid X to a horizontal lattice for date facets while leaving Y emergent.GraphScene: newgroupLabelLayer+setGrouping(). Pre-builds seeded targets (polar for unordered facets, lattice for date facets), tweens each node to its target while the sim is paused, fits camera against the target bounds in parallel.toolbar.ts:<wpd-select>group-by control, single-line peer of the post-type chips.draw().CSS: small spacing tweak for the new select.
Docs: brainstorm + plan landed under
docs/brainstorms/anddocs/plans/(mirrored to the warehouse).Test plan
tests/vitest/content-graph-sim.test.ts(convergence, separation, multi-membership force balance, null disables, pinned exempt, chronological lattice). Full suite green: 1283/1283.tests/phpunit/tests/contentGraphGroupBy.php(per-node fields, empty arrays, multi-term, catalog scoping, author names, retag cache-bust). Not run locally — port 8890 was held by a sibling worktree's wp-env. CI to verify.Known limitations / follow-ups (deferred from the brainstorm)
category+post_tag. Arbitrary public taxonomies are part of the warehouse Galaxy-lens plan.1/Nweighted pull rather than a "primary term" pick.