Skip to content

feat(content-graph): group-by selector + click-to-deselect + focused-icon centering#224

Merged
epeicher merged 8 commits into
trunkfrom
content-graph-icon-and-grouping-plan
May 18, 2026
Merged

feat(content-graph): group-by selector + click-to-deselect + focused-icon centering#224
epeicher merged 8 commits into
trunkfrom
content-graph-icon-and-grouping-plan

Conversation

@epeicher

@epeicher epeicher commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Dashboard.WordPress.Develop.WordPress.2026-05-15.19-01-56.mp4
  • Group-by selector on the Content Graph toolbar (Category / Tag / Author / Year / Year-month). Posts cluster around their group's emergent centroid; date facets lay out left-to-right oldest-to-newest on a chronological lattice. Picking a facet tweens nodes to seeded positions (~450ms ease-out cubic) with parallel camera fit, then a brief reheat lets the cluster force polish the layout. Per-facet-tinted translucent label pills (white text on coloured background) at each non-empty centroid so cluster labels never read the same as node titles.
  • Click-to-deselect — second click on the focused node clears focus + hides the panel. Mirrors the open gesture instead of forcing a trip to the close button or empty canvas.
  • Focused-post icon centeringadmin-post pushpin glyph was bbox-anchored in the upper-left of the focused-node disc; new ICON_NUDGE table corrects per-icon (currently admin-post only).

What changed

PHP (includes/content-graph/):

  • /nodes payload extended with per-node author_id, year, year_month, category_ids[], tag_ids[] and a top-level groups catalog ({authors, categories, tags}) scoped to entries actually referenced by visible nodes.
  • Bulk wp_term_relationships JOIN to populate per-post arrays in one query.
  • Cache invalidation extended with set_object_terms so 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-membership 1/N-weighted pull). Optional groupOrder pins centroid X to a horizontal lattice for date facets while leaving Y emergent.
  • GraphScene: new groupLabelLayer + 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.
  • Icon-centring nudge applied per-frame in draw().

CSS: small spacing tweak for the new select.

Docs: brainstorm + plan landed under docs/brainstorms/ and docs/plans/ (mirrored to the warehouse).

Test plan

  • Vitest: 6 new cases in tests/vitest/content-graph-sim.test.ts (convergence, separation, multi-membership force balance, null disables, pinned exempt, chronological lattice). Full suite green: 1283/1283.
  • Lint clean.
  • tsc clean.
  • PHPUnit: 6 new cases in 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.
  • Manual: opened Content Graph, walked every facet (Category / Tag / Author / Year / Year-month), confirmed clusters form within the tween window with auto-fit camera, labels colour-coded + translucent, focused-post pin centered, click-to-deselect works.

Known limitations / follow-ups (deferred from the brainstorm)

  • Session-local state (resets to None on each window open). Per-user persistence is a follow-up if the ask surfaces.
  • Hard-coded to WordPress's built-in category + post_tag. Arbitrary public taxonomies are part of the warehouse Galaxy-lens plan.
  • Year-only and year-month only for date bucketing; month-of-year deferred.
  • Multi-term posts use 1/N weighted pull rather than a "primary term" pick.
Open WordPress Playground Preview

…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.
@github-actions

github-actions Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

epeicher added 7 commits May 15, 2026 23:54
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.
@epeicher epeicher enabled auto-merge (squash) May 18, 2026 10:41
@epeicher epeicher merged commit 20665c5 into trunk May 18, 2026
5 checks passed
@epeicher epeicher deleted the content-graph-icon-and-grouping-plan branch May 18, 2026 10:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant