Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 118 additions & 2 deletions assets/css/content-graph.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,37 @@

.desktop-mode-content-graph__toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-direction: column;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var( --wpd-border, #e2e6ec );
background: var( --wpd-surface-elevated, #fff );
}

.desktop-mode-content-graph__header-row {
display: flex;
align-items: center;
gap: 12px;
}

.desktop-mode-content-graph__mode-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}

.desktop-mode-content-graph__visible-count {
font-size: 12px;
color: var( --wpd-text-muted, #5a6473 );
margin-left: auto;
white-space: nowrap;
}

.desktop-mode-content-graph__range {
min-width: 140px;
}

.desktop-mode-content-graph__filters {
display: flex;
align-items: center;
Expand Down Expand Up @@ -1042,3 +1065,96 @@
text-overflow: ellipsis;
white-space: nowrap;
}

/* ----- Galaxy view ----- */

/*
* Galaxy mode flips the stage canvas to a starfield aesthetic: dark
* background, additive-blended sprite dots, per-cluster nebula glow,
* DOM-overlay cluster labels, and a small explanatory legend along
* the bottom edge. The `.is-galaxy` modifier is added by
* GalaxyScene.mount() so the same `.desktop-mode-content-graph__stage`
* shell can host either renderer.
*/
.desktop-mode-content-graph__stage.is-galaxy {
background: #0b0d18;
color: #e4e7ef;
}

.desktop-mode-content-graph__galaxy-labels {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
overflow: hidden;
}

.desktop-mode-content-graph__galaxy-label {
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
color: #f7f9ff;
font-family: var( --wpd-font, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif );
text-shadow: 0 1px 6px rgba( 0, 0, 0, 0.85 );
pointer-events: none;
will-change: transform;
}

.desktop-mode-content-graph__galaxy-label-name {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.2px;
}

.desktop-mode-content-graph__galaxy-label-count {
font-size: 11px;
opacity: 0.7;
}

.desktop-mode-content-graph__galaxy-tooltip {
position: absolute;
top: 0;
left: 0;
padding: 5px 9px;
border-radius: 6px;
background: rgba( 14, 18, 32, 0.92 );
color: #f7f9ff;
font-size: 12px;
pointer-events: none;
z-index: 3;
box-shadow: 0 8px 20px rgba( 0, 0, 0, 0.4 );
max-width: 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
will-change: transform;
}

.desktop-mode-content-graph__galaxy-legend {
position: absolute;
left: 14px;
right: 14px;
bottom: 10px;
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
font-size: 11px;
color: rgba( 231, 236, 248, 0.65 );
pointer-events: none;
z-index: 2;
}

.desktop-mode-content-graph__galaxy-legend strong {
color: rgba( 231, 236, 248, 0.95 );
font-weight: 600;
margin-right: 4px;
}

.desktop-mode-content-graph__galaxy-legend-spacer {
flex: 1 1 auto;
}
12 changes: 12 additions & 0 deletions docs/javascript-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -5783,6 +5783,18 @@ maps to `media`, pages are detected via `bridgePayload.postType`) and

---

## User meta keys

Per-user preferences the framework persists over REST. These are part
of the JS contract because the shell reads/writes them from bundles:

| Meta key | Values | Written via | Status |
|---|---|---|---|
| `desktop_mode_content_graph_view` | `'graph'` \| `'galaxy'` (default `'graph'`) | `POST /wp/v2/users/<id>` with `{ meta: { … } }` when the Content Graph view toggle flips; read server-side into the window config (`lastView`). Registered with `show_in_rest`; writes require `edit_posts`. | Internal *(since 0.9.2)* |
| `dockRailRenderer` | any `sanitize_key()`-clean renderer id | `/wp-json/desktop-mode/v1/os-settings` (see [`registerDockRailRenderer`](#registerdockrailrenderer-def---stable-since-0180)) | Stable *(since 0.18.0)* |

---

## See also

- [Hooks Reference](./hooks-reference.md) — the PHP side of the API.
Expand Down
53 changes: 45 additions & 8 deletions includes/content-graph/graph-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
* slug: string, edit_url: string,
* author_id: int, contributor_ids: int[],
* year: int, year_month: string,
* category_ids: int[], tag_ids: int[]
* category_ids: int[], tag_ids: int[],
* comment_count: int, word_count: int, modified_ts: int
* }>,
* edges: array<int, array{ from: int, to: int }>,
* groups: array{
Expand Down Expand Up @@ -148,6 +149,27 @@ static function ( $cid ) use ( $author_id ) {
);
}

// Word count for the Galaxy view's brightness encoding. Strip
// shortcodes + tags first so a 200-word post drowning in HTML
// markup doesn't read as a 2000-word post. `str_word_count` is
// locale-aware enough for our needs (we only use it as a relative
// brightness signal, not for editorial display).
$plain = wp_strip_all_tags( strip_shortcodes( (string) $row->post_content ), true );
$word_count = $plain === '' ? 0 : (int) str_word_count( $plain );
// Modified-time as unix ts (GMT). The Galaxy view's "Recent" tab
// filters on `now - 30 days`; comparing seconds is faster + safer
// across the wire than parsing a date string client-side.
// Never-updated drafts carry a zero-date `post_modified_gmt`
// (WordPress only stamps the GMT columns on update), so fall
// back to the local `post_date` — for a fresh draft the two
// describe the same instant. Without this, brand-new drafts
// never twinkle and never qualify for the "Recent" tab.
$modified_gmt = isset( $row->post_modified_gmt ) ? (string) $row->post_modified_gmt : '';
if ( '' === $modified_gmt || '0000-00-00 00:00:00' === $modified_gmt ) {
$modified_gmt = (string) get_gmt_from_date( (string) $row->post_date );
}
$modified_ts = (int) mysql2date( 'U', $modified_gmt . ' UTC', false );

$node = array(
'id' => $id,
'type' => (string) $row->post_type,
Expand All @@ -161,6 +183,9 @@ static function ( $cid ) use ( $author_id ) {
'year_month' => $year_month,
'category_ids' => $post_cats,
'tag_ids' => $post_tags,
'comment_count' => (int) ( isset( $row->comment_count ) ? $row->comment_count : 0 ),
'word_count' => $word_count,
'modified_ts' => $modified_ts,
);
$nodes[] = $node;
$nodes_by_id[ $id ] = true;
Expand Down Expand Up @@ -264,11 +289,12 @@ function desktop_mode_content_graph_normalize_types( array $types ) {
* only included when the user holds that type's `read_private_posts`
* capability; for the remaining types the user still sees their OWN
* private posts (mirroring core's `WP_Query` status semantics for
* logged-in users).
* logged-in users). Logged-in users additionally see their OWN drafts
* (the Galaxy view's "Drafts" tab); other users' drafts never surface.
*
* The `key` element encodes the resulting privilege tier (and, when
* the own-author clause is active, the user id) so cached payloads
* are never served across privilege levels.
* The `key` element encodes the resulting privilege tier (and, for
* logged-in users, the user id) so cached payloads are never served
* across privilege levels or between users.
*
* @param string[] $types Already normalized.
* @return array{ where: string, values: array, key: string }
Expand Down Expand Up @@ -304,6 +330,16 @@ function desktop_mode_content_graph_visibility_sql( array $types ) {
$key_parts[] = 'own=' . $user_id;
}

if ( $user_id > 0 ) {
// The Galaxy view's "Drafts" tab surfaces the viewer's own
// drafts; other users' unpublished work stays invisible. The
// key part buckets cached payloads per user so one editor's
// drafts never bleed into another's view.
$status_clauses[] = "( post_status = 'draft' AND post_author = %d )";
$values[] = $user_id;
$key_parts[] = 'drafts=' . $user_id;
}

$where = "post_type IN ( {$placeholders} ) AND ( " . implode( ' OR ', $status_clauses ) . ' )';

return array(
Expand Down Expand Up @@ -348,8 +384,9 @@ function desktop_mode_content_graph_cache_key( array $types ) {
* `get_post()` calls.
*
* Rows are scoped to what the current user can read: published posts,
* plus private posts only where the user holds the type's
* `read_private_posts` capability (or authored the post). See
* private posts only where the user holds the type's
* `read_private_posts` capability (or authored the post), plus the
* user's own drafts. See
* `desktop_mode_content_graph_visibility_sql()`.
*
* @param string[] $types Already normalized.
Expand All @@ -361,7 +398,7 @@ function desktop_mode_content_graph_fetch_rows( array $types ) {
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_type, post_status, post_title, post_name, post_content, post_author, post_date
"SELECT ID, post_type, post_status, post_title, post_name, post_content, post_author, post_date, post_modified_gmt, comment_count
FROM {$wpdb->posts}
WHERE {$visibility['where']}
ORDER BY post_date DESC",
Expand Down
37 changes: 37 additions & 0 deletions includes/content-graph/window.php
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,16 @@ function desktop_mode_content_graph_register_window() {
// which hands off to the site folder window.
'siteName' => desktop_mode_site_title(),
'postTypes' => desktop_mode_content_graph_post_types(),
// Last-chosen view mode persisted via user meta. Read here so
// the bundle can mount the right scene without an extra round
// trip on every window open; saved client-side via REST when
// the user toggles the segmented control.
'lastView' => (string) get_user_meta(
get_current_user_id(),
'desktop_mode_content_graph_view',
true
) ?: 'graph',
'currentUserId' => (int) get_current_user_id(),
),
);

Expand Down Expand Up @@ -255,3 +265,30 @@ function desktop_mode_content_graph_enqueue_styles() {
wp_enqueue_style( 'desktop-mode-content-graph' );
}
add_action( 'admin_enqueue_scripts', 'desktop_mode_content_graph_enqueue_styles', 30 );

/**
* Register the per-user view preference (`'graph'` or `'galaxy'`).
* Exposed over REST so the JS toolbar can `POST /wp/v2/users/me` to
* persist the user's last choice across sessions and devices.
*/
function desktop_mode_content_graph_register_view_meta() {
register_meta(
'user',
'desktop_mode_content_graph_view',
array(
'type' => 'string',
'single' => true,
'default' => 'graph',
'show_in_rest' => true,
'sanitize_callback' => static function ( $value ) {
return in_array( (string) $value, array( 'graph', 'galaxy' ), true )
? (string) $value
: 'graph';
},
'auth_callback' => static function () {
return current_user_can( 'edit_posts' );
},
)
);
}
add_action( 'init', 'desktop_mode_content_graph_register_view_meta' );
79 changes: 79 additions & 0 deletions src/content-graph/galaxy-encodings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Content Graph — Galaxy view pure encodings.
*
* Separated from `galaxy-scene.ts` so the brightness curve and the
* tab/min-volume filter predicate can be unit-tested without any
* Pixi dependency.
*
* @public
*/

import type { GalaxyTab, GraphNodePayload } from './types';

/**
* Normalise `(comment_count, word_count)` to a `[0, 1]` brightness
* scalar. Log-scaled so a 50k-word post doesn't drown out a 200-word
* post; weighted 50/50 between the two signals so a single dominant
* input can't pin a dot at full brightness on its own.
*
* The output is consumed twice in the scene: as the dot's alpha
* multiplier (so brighter posts read as more "active"), and as a
* small scale nudge (so brighter posts feel meatier in the field).
*/
export function dotBrightness(
commentCount: number,
wordCount: number,
): number {
const c = clamp01( log01( commentCount, 100 ) );
const w = clamp01( log01( wordCount, 5000 ) );
return clamp01( c * 0.5 + w * 0.5 );
}

function log01( value: number, ceiling: number ): number {
if ( value <= 0 || ceiling <= 0 ) {
return 0;
}
return Math.log( 1 + value ) / Math.log( 1 + ceiling );
}

function clamp01( v: number ): number {
if ( v < 0 ) {
return 0;
}
if ( v > 1 ) {
return 1;
}
return v;
}

/**
* Should this node be visible under the active Galaxy filters?
* `all` → only the MIN VOLUME (min comments) gate applies.
* `drafts` → status must be `'draft'`.
* `recent` → modified within the last `recentWindowSeconds`.
*
* `nowSeconds` is passed in so the function stays deterministic —
* tests can hand it a fixed clock instead of stubbing `Date.now`.
*/
export function galaxyTabFilter(
node: Pick<
GraphNodePayload,
'status' | 'modified_ts' | 'comment_count'
>,
tab: GalaxyTab,
minComments: number,
nowSeconds: number,
recentWindowSeconds: number,
): boolean {
if ( node.comment_count < minComments ) {
return false;
}
switch ( tab ) {
case 'all':
return true;
case 'drafts':
return node.status === 'draft';
case 'recent':
return node.modified_ts >= nowSeconds - recentWindowSeconds;
}
}
Loading
Loading