diff --git a/assets/css/content-graph.css b/assets/css/content-graph.css index 3acfc1368..a107579d1 100644 --- a/assets/css/content-graph.css +++ b/assets/css/content-graph.css @@ -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; @@ -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; +} diff --git a/docs/javascript-reference.md b/docs/javascript-reference.md index 0589d6ba0..ab595c1e4 100644 --- a/docs/javascript-reference.md +++ b/docs/javascript-reference.md @@ -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/` 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. diff --git a/includes/content-graph/graph-builder.php b/includes/content-graph/graph-builder.php index eb447efc8..ee3d14afc 100644 --- a/includes/content-graph/graph-builder.php +++ b/includes/content-graph/graph-builder.php @@ -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, * groups: array{ @@ -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, @@ -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; @@ -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 } @@ -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( @@ -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. @@ -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", diff --git a/includes/content-graph/window.php b/includes/content-graph/window.php index 2f2d214bd..d223f0c09 100644 --- a/includes/content-graph/window.php +++ b/includes/content-graph/window.php @@ -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(), ), ); @@ -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' ); diff --git a/src/content-graph/galaxy-encodings.ts b/src/content-graph/galaxy-encodings.ts new file mode 100644 index 000000000..059f6eee4 --- /dev/null +++ b/src/content-graph/galaxy-encodings.ts @@ -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; + } +} diff --git a/src/content-graph/galaxy-scene.ts b/src/content-graph/galaxy-scene.ts new file mode 100644 index 000000000..2d16e065b --- /dev/null +++ b/src/content-graph/galaxy-scene.ts @@ -0,0 +1,1348 @@ +/** + * Content Graph — Galaxy Pixi scene. + * + * Alternate render path for the Content Graph window. Instead of icons + + * spokes + labelled satellites, every post is a tiny glowing dot, every + * group is a coloured nebula glow behind its members, and recently- + * edited posts (plus the pinned/focused post) sparkle with a slow + * twinkle pulse. The same `ForceSim` from `sim.ts` drives clustering, + * so the layout primitives — `groupAssignment`, `groupOrder`, + * `setGroupAssignment()` — carry over from `GraphScene` verbatim. + * + * Visual encoding (mirroring the reference image the design was + * adapted from): + * + * - **Color** = active group facet (per-facet palettes, hashed + * hue for authors/terms, chronological ramp for + * year / year-month). + * - **Brightness** = `comment_count` + `word_count`, log-normalised + * so a 50k-word post doesn't drown out a 200-word + * post. See `dotBrightness()` for the curve. + * - **Twinkle** = (a) posts modified in the last 30 days, and + * (b) the pinned/focused post and its direct + * edge neighbours. + * + * Why a separate file vs. extending `scene.ts`: the two scenes share + * almost nothing at the render layer (sprites vs. graphics + DOM + * labels, additive blending vs. dashicon text). Keeping them apart + * means each can evolve without the other dragging it through + * "is the mode active" branching everywhere. + * + * @public + */ + +import { __, _n, sprintf } from '../i18n'; +import { + getPixi, + type DesktopApiLike, + type PixiApp, + type PixiContainer, + type PixiNamespace, + type PixiSprite, + type PixiTexture, +} from './pixi-types'; +import { DEFAULT_SIM_OPTIONS, ForceSim } from './sim'; +import type { + GalaxyTab, + GraphEdge, + GraphGroupCatalogs, + GraphNode, + GraphPayload, + GroupFacet, + PostDetail, +} from './types'; +import { galaxyTabFilter, dotBrightness } from './galaxy-encodings'; + +const BG_COLOR = 0x0b0d18; +// Wide zoom-out floor: with grouping active the cluster lattice can +// span thousands of world units, and fit-to-view must be allowed to +// frame all of it. 0.5 (the old floor) silently clamped the fit and +// left most clusters outside the camera. +const ZOOM_MIN = 0.08; +const ZOOM_MAX = 4; +// How many ticker frames fit-to-view keeps following the simulation +// after a data load or grouping change. Generous (~15s at 60fps) +// because the layout keeps expanding until the sim's alpha decays +// below its settle threshold (~10s) — following must outlast that. +// In practice the follow exits early via `sim.isSettled`; any user +// wheel / drag also cancels immediately so we never fight manual +// navigation. +const FIT_FOLLOW_FRAMES = 900; +// Cluster labels + nebulae fade in after a grouping change instead of +// appearing instantly: at t=0 every cluster's centroid is the same +// point (the still-entangled ball), so instant labels render as a +// stack of 26 names floating over one blob. By REVEAL_DELAY frames the +// clusters have visibly separated; the ramp then eases them in. +const GROUP_REVEAL_DELAY_FRAMES = 150; +const GROUP_REVEAL_RAMP_FRAMES = 90; +const ZOOM_SENSITIVITY = 0.0008; +const CAMERA_EASE = 0.18; +const CAMERA_EPSILON = 0.001; +// 30 days in unix seconds — the "Recent" tab and the twinkle layer +// share this window. +const RECENT_WINDOW_SECONDS = 30 * 24 * 3600; +const NEBULA_BASE_RADIUS = 240; + +/** + * Per-facet base colour (used as a tint seed for the dot palette). When + * the active facet has its own per-group hue (every facet does — see + * `colorForGroupKey()`), this constant is the fallback for the "no + * grouping" case and for nodes the scene couldn't assign to a group. + */ +const UNGROUPED_TINT = 0x8aa8ff; + +export interface GalaxySceneCallbacks { + onNodeClick?: ( node: GraphNode ) => void; + onBackgroundClick?: () => void; + onVisibleCountChange?: ( visible: number, total: number ) => void; +} + +interface DotView { + node: GraphNode; + sprite: PixiSprite; + twinkle: PixiSprite | null; + twinklePhase: number; +} + +interface NebulaView { + key: string; + sprite: PixiSprite; + memberIds: number[]; +} + +interface LabelView { + key: string; + name: string; + el: HTMLDivElement; + memberIds: number[]; +} + +export class GalaxyScene { + private app!: PixiApp; + private pixi!: PixiNamespace; + private world!: PixiContainer; + private nebulaLayer!: PixiContainer; + private dotLayer!: PixiContainer; + private twinkleLayer!: PixiContainer; + private brushTexture!: PixiTexture; + private nebulaTexture!: PixiTexture; + private sparkleTexture!: PixiTexture; + private labelOverlay: HTMLDivElement | null = null; + private tooltipEl: HTMLDivElement | null = null; + private legendEl: HTMLDivElement | null = null; + private dotViews = new Map< number, DotView >(); + private nebulae = new Map< string, NebulaView >(); + private labels = new Map< string, LabelView >(); + private nodes: GraphNode[] = []; + private edgeNeighbours = new Map< number, Set< number > >(); + private nowSeconds = 0; + private sim: ForceSim | null = null; + private grouping: GroupFacet | null = null; + private catalogs: GraphGroupCatalogs = { + authors: {}, + categories: {}, + tags: {}, + }; + private activeTab: GalaxyTab = 'all'; + private minComments = 0; + private host: HTMLElement; + private callbacks: GalaxySceneCallbacks; + private tick = ( ticker: { deltaTime: number } ) => { + this.advance( ticker.deltaTime ); + }; + private resizeObserver: ResizeObserver | null = null; + private cameraTarget = { x: 0, y: 0, scale: 1 }; + private dragState: { + pointerId: number; + startX: number; + startY: number; + camStartX: number; + camStartY: number; + } | null = null; + private focusedId: number | null = null; + private animTime = 0; + /** + * True between a dot sprite's Pixi `pointerdown` and the canvas's + * DOM `pointerup`. Pixi dispatches sprite events from its own + * (earlier-registered) canvas listener, so the flag is already set + * when our DOM pointerdown runs — it suppresses both pan-start and + * the background-click-on-release so a dot tap doesn't immediately + * close the focus it just opened. Mirrors `GraphScene.nodeClickActive`. + */ + private nodeClickActive = false; + /** + * Remaining ticker frames during which `advance()` re-runs + * fit-to-view every frame, so the camera follows the clusters as + * the simulation spreads them out after a load or grouping change. + * Any user wheel / pointer-down zeroes it immediately. + */ + private fitFollowFrames = 0; + private lastResizeWidth = 0; + private lastResizeHeight = 0; + /** + * `animTime` stamp of the last grouping (re)build — drives the + * fade-in of cluster labels + nebulae (see GROUP_REVEAL_*). + */ + private groupVisualsBuiltAt = 0; + + constructor( host: HTMLElement, callbacks: GalaxySceneCallbacks ) { + this.host = host; + this.callbacks = callbacks; + } + + async mount( api: DesktopApiLike ): Promise< void > { + if ( typeof api.loadModules === 'function' ) { + await api.loadModules( [ 'pixijs' ] ); + } + const pixi = getPixi(); + if ( ! pixi ) { + throw new Error( 'PIXI namespace missing after loadModules.' ); + } + this.pixi = pixi; + + const app = new pixi.Application(); + await app.init( { + background: BG_COLOR, + antialias: true, + autoDensity: true, + resolution: window.devicePixelRatio || 1, + resizeTo: this.host, + // Galaxy windows are independent of any other Pixi app in + // the desktop. A shared ticker would entangle their + // lifecycles and surface the v8 multi-Application destroy + // race documented in the categories-mindmap module. + sharedTicker: false, + } ); + this.app = app; + this.host.classList.add( 'is-galaxy' ); + this.host.appendChild( app.canvas ); + + // Loose-canvas guard: if WebGL context drops (browser eviction + // under multi-app pressure), stop ticking so the next frame + // doesn't try to render through a dead context. + app.canvas.addEventListener( 'webglcontextlost', ( ev: Event ) => { + ev.preventDefault(); + try { + app.ticker.stop(); + } catch { + // `destroy()` itself releases the WebGL context, which + // fires this event after the ticker is already torn + // down — nothing left to stop. + } + } ); + + this.world = new pixi.Container(); + app.stage.addChild( this.world ); + + this.nebulaLayer = new pixi.Container(); + this.dotLayer = new pixi.Container(); + this.twinkleLayer = new pixi.Container(); + this.world.addChild( this.nebulaLayer ); + this.world.addChild( this.dotLayer ); + this.world.addChild( this.twinkleLayer ); + + this.brushTexture = buildBrushTexture( pixi, 128 ); + this.nebulaTexture = buildNebulaTexture( pixi, 256 ); + this.sparkleTexture = buildSparkleTexture( pixi, 96 ); + + this.attachOverlays(); + this.attachPointerHandlers(); + this.attachResizeObserver(); + + app.ticker.add( this.tick ); + } + + setData( payload: GraphPayload ): void { + this.tearDownNodes(); + this.catalogs = payload.groups; + // Stamp "now" at data-set time so the Recent filter + the + // twinkle layer agree on the same clock for this load. + this.nowSeconds = Math.floor( Date.now() / 1000 ); + + // Build live in-memory nodes. We borrow the same shape + // (`GraphNode`) the existing GraphScene uses so the + // `ForceSim` accepts us without changes. + const ringR = 480; + const nodes: GraphNode[] = payload.nodes.map( ( n, idx ) => { + const a = ( idx / Math.max( 1, payload.nodes.length ) ) * Math.PI * 2; + return { + ...n, + x: Math.cos( a ) * ringR, + y: Math.sin( a ) * ringR, + vx: 0, + vy: 0, + pinned: false, + radius: 4, + color: UNGROUPED_TINT, + degree: 0, + }; + } ); + const nodeById = new Map< number, GraphNode >(); + for ( const n of nodes ) { + nodeById.set( n.id, n ); + } + const edges: GraphEdge[] = []; + this.edgeNeighbours.clear(); + for ( const e of payload.edges ) { + const from = nodeById.get( e.from ); + const to = nodeById.get( e.to ); + if ( ! from || ! to ) { + continue; + } + edges.push( { from, to } ); + from.degree++; + to.degree++; + if ( ! this.edgeNeighbours.has( e.from ) ) { + this.edgeNeighbours.set( e.from, new Set() ); + } + if ( ! this.edgeNeighbours.has( e.to ) ) { + this.edgeNeighbours.set( e.to, new Set() ); + } + this.edgeNeighbours.get( e.from )!.add( e.to ); + this.edgeNeighbours.get( e.to )!.add( e.from ); + } + this.nodes = nodes; + + this.sim = new ForceSim( nodes, edges ); + + // Build the dot sprites. + for ( const node of nodes ) { + const sprite = new this.pixi.Sprite( this.brushTexture ); + sprite.anchor.set( 0.5 ); + sprite.blendMode = 'add'; + sprite.tint = UNGROUPED_TINT; + sprite.eventMode = 'static'; + sprite.cursor = 'pointer'; + sprite.on( 'pointerover', () => { + this.showTooltip( node ); + } ); + sprite.on( 'pointerout', () => { + this.hideTooltip(); + } ); + sprite.on( 'pointerdown', ( ev: unknown ) => { + ( ev as { stopPropagation?: () => void } ).stopPropagation?.(); + this.nodeClickActive = true; + } ); + sprite.on( 'pointertap', ( ev: unknown ) => { + ( ev as { stopPropagation?: () => void } ).stopPropagation?.(); + this.callbacks.onNodeClick?.( node ); + } ); + this.dotLayer.addChild( sprite ); + this.dotViews.set( node.id, { + node, + sprite, + twinkle: null, + twinklePhase: Math.random() * Math.PI * 2, + } ); + } + + this.refreshEncodings(); + this.refreshTwinkles(); + this.refreshVisibility(); + this.fitToView(); + // Follow the layout while the fresh simulation spreads out, so + // the initial constellation stays framed instead of escaping + // the viewport mid-settle. + this.fitFollowFrames = FIT_FOLLOW_FRAMES; + } + + setGrouping( facet: GroupFacet | null ): void { + this.grouping = facet; + if ( ! this.sim ) { + return; + } + const assignment = facet ? this.buildAssignmentMap( facet ) : null; + const order = facet ? this.buildGroupOrder( facet ) : null; + this.sim.setGroupAssignment( assignment, order ); + // Stronger pull than the GraphScene default: galaxy clusters are + // single-membership (see buildAssignmentMap) so there's no + // balancing force to preserve, and the tighter gather reads + // better against the nebula glow. + this.sim.groupAttractorStrength = 0.18; + // Hyperlink springs all but disable cluster separation on a + // well-linked corpus: a single cross-cluster link at lattice + // distance pulls with (d - springLen) * k, which dwarfs the + // attractor. While grouping is active the springs drop to a + // whisper — enough to keep linked posts on facing cluster + // edges, too weak to drag whole clusters together. + this.sim.opts = { + ...DEFAULT_SIM_OPTIONS, + springK: facet ? 0.002 : DEFAULT_SIM_OPTIONS.springK, + }; + this.sim.groupOrderSpacing = facet === 'year_month' ? 200 : 320; + this.sim.groupOrderStaggerY = facet === 'year_month' ? 160 : 0; + this.rebuildNebulae(); + this.rebuildLabels(); + this.refreshEncodings(); + // Clusters spread far beyond the current viewport as the + // attractor separates them — keep the camera following until + // the layout settles (or the user takes over with wheel/drag). + this.fitFollowFrames = FIT_FOLLOW_FRAMES; + // Restart the label/nebula fade-in (they ride the separation). + this.groupVisualsBuiltAt = this.animTime; + } + + setTab( tab: GalaxyTab ): void { + this.activeTab = tab; + this.refreshVisibility(); + } + + setMinComments( min: number ): void { + this.minComments = Math.max( 0, Math.floor( min ) ); + this.refreshVisibility(); + } + + setZoom( zoom: number ): void { + // Slider input is manual navigation, same as wheel/drag — cancel + // auto-fit-follow, otherwise fitToView() overwrites the scale + // every frame for the rest of the follow window and the slider + // appears dead. + this.fitFollowFrames = 0; + const clamped = Math.max( ZOOM_MIN, Math.min( ZOOM_MAX, zoom ) ); + this.cameraTarget.scale = clamped; + } + + setFocus( id: number | null ): void { + this.focusedId = id; + this.refreshTwinkles(); + } + + setFocusedDetail( _detail: PostDetail | null ): void { + // Galaxy view doesn't render satellites — focus state alone is + // enough to drive the twinkle layer. Kept for parity with + // `GraphScene` so `index.ts` can call it uniformly. + } + + getNodes(): GraphNode[] { + return this.nodes; + } + + getFocusedId(): number | null { + return this.focusedId; + } + + clearFocus(): void { + this.setFocus( null ); + } + + focusNode( id: number ): void { + this.setFocus( id ); + } + + /** + * No-op for the satellite registry (Galaxy has no satellites). Kept + * to match `GraphScene`'s API so the host can call it generically. + */ + setSatelliteSelectedKey( _key: unknown ): void { + // Intentional no-op. + } + + fitToView(): void { + if ( this.nodes.length === 0 ) { + return; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for ( const n of this.nodes ) { + if ( n.x < minX ) { + minX = n.x; + } + if ( n.y < minY ) { + minY = n.y; + } + if ( n.x > maxX ) { + maxX = n.x; + } + if ( n.y > maxY ) { + maxY = n.y; + } + } + const padding = 120; + const spanX = maxX - minX + padding * 2; + const spanY = maxY - minY + padding * 2; + const w = this.app.canvas.width / this.app.renderer.resolution; + const h = this.app.canvas.height / this.app.renderer.resolution; + const sx = w / spanX; + const sy = h / spanY; + const scale = Math.max( + ZOOM_MIN, + Math.min( ZOOM_MAX, Math.min( sx, sy ) ), + ); + const cx = ( minX + maxX ) / 2; + const cy = ( minY + maxY ) / 2; + this.cameraTarget.scale = scale; + this.cameraTarget.x = w / 2 - cx * scale; + this.cameraTarget.y = h / 2 - cy * scale; + } + + destroy(): void { + // Park the ticker FIRST — same ordering as `GraphScene.destroy()`: + // the ticker auto-renders every frame, and destroying children + // while a render is still scheduled crashes the batched renderer. + try { + this.app?.ticker.remove( this.tick ); + this.app?.ticker.stop(); + } catch { + // Ignore — destroy is best-effort. + } + this.tearDownNodes(); + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.labelOverlay?.remove(); + this.labelOverlay = null; + this.tooltipEl?.remove(); + this.tooltipEl = null; + this.legendEl?.remove(); + this.legendEl = null; + try { + this.brushTexture?.destroy( true ); + this.nebulaTexture?.destroy( true ); + this.sparkleTexture?.destroy( true ); + } catch { + // Texture might already be gone if the WebGL context was lost. + } + // Release the Application (and its WebGL context) for real. + // Browsers cap live WebGL contexts per page (~16) and evict the + // oldest when the cap is hit — every Graph⇄Galaxy toggle mounts + // a fresh Application, so leaking contexts here eventually + // kills an unrelated Pixi window. With the ticker parked above, + // the v8 batched-renderer teardown race doesn't bite; the + // try/catch absorbs the residual cases (same recipe as + // `GraphScene.destroy()`). + try { + this.app?.destroy( { removeView: true }, { children: true } ); + } catch { + // Pixi sometimes throws on teardown races — best-effort. + } + this.host.classList.remove( 'is-galaxy' ); + } + + // ───────────────────────────────────────────────────────────────── + // Internals + // ───────────────────────────────────────────────────────────────── + + private attachOverlays(): void { + const labelOverlay = document.createElement( 'div' ); + labelOverlay.className = 'desktop-mode-content-graph__galaxy-labels'; + this.host.appendChild( labelOverlay ); + this.labelOverlay = labelOverlay; + + const tooltip = document.createElement( 'div' ); + tooltip.className = 'desktop-mode-content-graph__galaxy-tooltip'; + tooltip.hidden = true; + this.host.appendChild( tooltip ); + this.tooltipEl = tooltip; + + const legend = document.createElement( 'div' ); + legend.className = 'desktop-mode-content-graph__galaxy-legend'; + legend.innerHTML = + `${ escapeHtml( __( 'Color' ) ) } ${ escapeHtml( __( 'group' ) ) }` + + `${ escapeHtml( __( 'Brightness' ) ) } ${ escapeHtml( __( 'comments + length' ) ) }` + + `${ escapeHtml( __( 'Twinkle' ) ) } ${ escapeHtml( __( 'recent or focused' ) ) }` + + '' + + `${ escapeHtml( __( 'Scroll to zoom' ) ) }` + + `${ escapeHtml( __( 'Drag to pan' ) ) }`; + this.host.appendChild( legend ); + this.legendEl = legend; + } + + private attachPointerHandlers(): void { + const canvas = this.app.canvas; + canvas.addEventListener( 'wheel', ( ev: WheelEvent ) => { + ev.preventDefault(); + // Manual navigation takes priority over auto-fit-follow. + this.fitFollowFrames = 0; + const factor = Math.exp( -ev.deltaY * ZOOM_SENSITIVITY ); + const nextScale = Math.max( + ZOOM_MIN, + Math.min( ZOOM_MAX, this.cameraTarget.scale * factor ), + ); + const rect = canvas.getBoundingClientRect(); + const px = ev.clientX - rect.left; + const py = ev.clientY - rect.top; + // Anchor zoom to the cursor position so users can drill into + // a specific cluster without losing their place. + const ratio = nextScale / this.cameraTarget.scale; + this.cameraTarget.x = px - ( px - this.cameraTarget.x ) * ratio; + this.cameraTarget.y = py - ( py - this.cameraTarget.y ) * ratio; + this.cameraTarget.scale = nextScale; + }, { passive: false } ); + + canvas.addEventListener( 'pointerdown', ( ev: PointerEvent ) => { + if ( ev.target !== canvas ) { + return; + } + // Pixi already routed this press to a dot sprite — don't + // start a camera pan underneath the in-flight node click. + if ( this.nodeClickActive ) { + return; + } + this.fitFollowFrames = 0; + try { + canvas.setPointerCapture( ev.pointerId ); + } catch { + // Pointer already gone (pen lift, synthetic event) — + // the drag still works, move/up arrive via bubbling. + } + this.dragState = { + pointerId: ev.pointerId, + startX: ev.clientX, + startY: ev.clientY, + camStartX: this.cameraTarget.x, + camStartY: this.cameraTarget.y, + }; + } ); + canvas.addEventListener( 'pointermove', ( ev: PointerEvent ) => { + if ( ! this.dragState || ev.pointerId !== this.dragState.pointerId ) { + return; + } + this.cameraTarget.x = + this.dragState.camStartX + ( ev.clientX - this.dragState.startX ); + this.cameraTarget.y = + this.dragState.camStartY + ( ev.clientY - this.dragState.startY ); + } ); + const endDrag = ( ev: PointerEvent ): void => { + const nodeWasTarget = this.nodeClickActive; + this.nodeClickActive = false; + if ( this.dragState && ev.pointerId === this.dragState.pointerId ) { + try { + canvas.releasePointerCapture( ev.pointerId ); + } catch { + // Capture already released — fine. + } + const moved = + Math.abs( ev.clientX - this.dragState.startX ) + + Math.abs( ev.clientY - this.dragState.startY ) > + 4; + this.dragState = null; + if ( ! moved && ! nodeWasTarget ) { + this.callbacks.onBackgroundClick?.(); + } + } + }; + canvas.addEventListener( 'pointerup', endDrag ); + canvas.addEventListener( 'pointercancel', endDrag ); + } + + private attachResizeObserver(): void { + if ( typeof ResizeObserver === 'undefined' ) { + return; + } + this.lastResizeWidth = this.host.clientWidth; + this.lastResizeHeight = this.host.clientHeight; + this.resizeObserver = new ResizeObserver( () => { + const w = this.host.clientWidth; + const h = this.host.clientHeight; + // Hidden / detached host reports 0×0 — resizing the renderer + // to zero puts Pixi v8's batched renderer into a state that + // crashes the next render (see GraphScene.bindResize for the + // full history). Skip and catch up on the next observation. + if ( w <= 0 || h <= 0 ) { + return; + } + // Pixi's `resizeTo` only reacts to BROWSER window resizes; a + // desktop-mode window maximize/restore/drag-resize changes the + // host element without firing one, so resize explicitly. + try { + this.app.renderer.resize( w, h ); + } catch { + return; + } + try { + this.app.render(); + } catch { + // Teardown race — fine. + } + const dw = Math.abs( w - this.lastResizeWidth ); + const dh = Math.abs( h - this.lastResizeHeight ); + if ( dw >= 24 || dh >= 24 ) { + this.lastResizeWidth = w; + this.lastResizeHeight = h; + this.fitToView(); + } + } ); + this.resizeObserver.observe( this.host ); + } + + private tearDownNodes(): void { + for ( const view of this.dotViews.values() ) { + view.sprite.destroy( { children: true } ); + view.twinkle?.destroy( { children: true } ); + } + this.dotViews.clear(); + for ( const neb of this.nebulae.values() ) { + neb.sprite.destroy( { children: true } ); + } + this.nebulae.clear(); + for ( const lab of this.labels.values() ) { + lab.el.remove(); + } + this.labels.clear(); + } + + private buildAssignmentMap( + facet: GroupFacet, + ): Map< number, string[] > | null { + const map = new Map< number, string[] >(); + for ( const node of this.nodes ) { + map.set( node.id, galaxyKeysFor( node, facet ) ); + } + return map; + } + + private buildGroupOrder( facet: GroupFacet ): string[] | null { + if ( facet !== 'year' && facet !== 'year_month' ) { + return null; + } + const seen = new Set< string >(); + const order: string[] = []; + for ( const node of this.nodes ) { + for ( const key of deriveGroupKeys( node, facet ) ) { + if ( ! seen.has( key ) ) { + seen.add( key ); + order.push( key ); + } + } + } + // Year keys sort naturally; year-month does too because both are + // zero-padded numeric prefixes. + order.sort(); + return order; + } + + /** + * Cluster-key → member-node-ids using the SAME single-membership + * commit as the force assignment and the colour encoding. Labels + * and nebulae must agree with the layout: if a label counted every + * taxonomy membership while the dots only sit in their first + * cluster, the label centroid would be polluted by members that + * physically live in other clusters and drift toward the global + * centre (which is exactly the bug this replaced). + */ + private collectGalaxyMembers(): Map< string, number[] > { + const byKey = new Map< string, number[] >(); + if ( ! this.grouping ) { + return byKey; + } + for ( const node of this.nodes ) { + for ( const k of galaxyKeysFor( node, this.grouping ) ) { + const list = byKey.get( k ); + if ( list ) { + list.push( node.id ); + } else { + byKey.set( k, [ node.id ] ); + } + } + } + return byKey; + } + + private rebuildNebulae(): void { + for ( const neb of this.nebulae.values() ) { + neb.sprite.destroy( { children: true } ); + } + this.nebulae.clear(); + if ( ! this.grouping ) { + return; + } + const byKey = this.collectGalaxyMembers(); + for ( const [ key, memberIds ] of byKey ) { + // Dedicated soft texture, NOT the dot brush: the brush has an + // opaque white core, and 20+ additive nebulae stacked during + // sim convergence (when every cluster still overlaps near the + // origin) blew out to a solid white wall. The nebula texture + // peaks well below full alpha so even heavy overlap stays a + // glow, not a flashbang. + const sprite = new this.pixi.Sprite( this.nebulaTexture ); + sprite.anchor.set( 0.5 ); + sprite.blendMode = 'add'; + sprite.tint = colorForGroupKey( key ); + // Starts invisible — `advance()` fades it in once the + // clusters have separated (see GROUP_REVEAL_*). + sprite.alpha = 0; + sprite.visible = false; + sprite.eventMode = 'none'; + this.nebulaLayer.addChild( sprite ); + this.nebulae.set( key, { key, sprite, memberIds } ); + } + } + + private rebuildLabels(): void { + if ( ! this.labelOverlay ) { + return; + } + for ( const lab of this.labels.values() ) { + lab.el.remove(); + } + this.labels.clear(); + if ( ! this.grouping ) { + return; + } + const byKey = this.collectGalaxyMembers(); + for ( const [ key, memberIds ] of byKey ) { + const el = document.createElement( 'div' ); + el.className = 'desktop-mode-content-graph__galaxy-label'; + const name = labelForGroupKey( key, this.catalogs ); + el.dataset.key = key; + this.labelOverlay.appendChild( el ); + this.labels.set( key, { key, name, el, memberIds } ); + } + this.refreshLabelText(); + } + + private refreshLabelText(): void { + for ( const lab of this.labels.values() ) { + const visible = lab.memberIds.filter( ( id ) => { + const dot = this.dotViews.get( id ); + return dot && dot.sprite.visible; + } ).length; + const countText = sprintf( + /* translators: %s: number of posts in the cluster. */ + _n( '%s post', '%s posts', visible ), + String( visible ), + ); + // The newline between the spans keeps screen-reader output + // ("News 142 posts") from running the name into the count; + // visually the flex-column stacking ignores it. + lab.el.innerHTML = + `${ escapeHtml( lab.name ) }\n` + + `${ escapeHtml( countText ) }`; + } + } + + private refreshEncodings(): void { + // Color: tint each dot to its group key (when a group is active), + // or fall back to the ungrouped tint. Multi-membership posts + // (e.g. two-category post) take the first key — a cheap, stable + // pick that keeps the dot visually anchored to ONE cluster even + // as the simulation balances it between centroids. + for ( const view of this.dotViews.values() ) { + const node = view.node; + let tint = UNGROUPED_TINT; + if ( this.grouping ) { + const keys = deriveGroupKeys( node, this.grouping ); + if ( keys.length > 0 ) { + tint = colorForGroupKey( keys[ 0 ] ); + } + } + view.sprite.tint = tint; + const b = dotBrightness( node.comment_count, node.word_count ); + view.sprite.alpha = 0.45 + 0.55 * b; + // Scale: tiny so the field reads as a starfield rather than + // a cluster of bubbles. Brightness contributes a small size + // nudge so "louder" posts feel meatier. + const s = 0.18 + 0.18 * b; + view.sprite.scale.set( s ); + } + } + + private refreshTwinkles(): void { + const recentCutoff = this.nowSeconds - RECENT_WINDOW_SECONDS; + const focusId = this.focusedId; + const neighbours = focusId + ? this.edgeNeighbours.get( focusId ) ?? new Set< number >() + : new Set< number >(); + for ( const view of this.dotViews.values() ) { + const node = view.node; + const isRecent = node.modified_ts >= recentCutoff; + const isFocusKin = + focusId !== null && + ( node.id === focusId || neighbours.has( node.id ) ); + const shouldTwinkle = isRecent || isFocusKin; + if ( shouldTwinkle && ! view.twinkle ) { + const sparkle = new this.pixi.Sprite( this.sparkleTexture ); + sparkle.anchor.set( 0.5 ); + sparkle.blendMode = 'add'; + sparkle.tint = isFocusKin ? 0xffffff : view.sprite.tint; + sparkle.eventMode = 'none'; + // Inherit the dot's filter state — focusing a node that + // the active tab / min-comments filter hides (possible + // via search) must not paint a sparkle over empty space. + sparkle.visible = view.sprite.visible; + this.twinkleLayer.addChild( sparkle ); + view.twinkle = sparkle; + } else if ( ! shouldTwinkle && view.twinkle ) { + view.twinkle.destroy( { children: true } ); + view.twinkle = null; + } + } + } + + private refreshVisibility(): void { + let visible = 0; + for ( const view of this.dotViews.values() ) { + const show = galaxyTabFilter( + view.node, + this.activeTab, + this.minComments, + this.nowSeconds, + RECENT_WINDOW_SECONDS, + ); + view.sprite.visible = show; + if ( view.twinkle ) { + view.twinkle.visible = show; + } + if ( show ) { + visible++; + } + } + this.refreshLabelText(); + this.callbacks.onVisibleCountChange?.( visible, this.nodes.length ); + } + + private showTooltip( node: GraphNode ): void { + if ( ! this.tooltipEl ) { + return; + } + this.tooltipEl.textContent = node.title || '#' + node.id; + this.tooltipEl.hidden = false; + const view = this.dotViews.get( node.id ); + if ( ! view ) { + return; + } + const screenX = node.x * this.world.scale.x + this.world.x; + const screenY = node.y * this.world.scale.y + this.world.y; + this.tooltipEl.style.transform = `translate(${ screenX }px, ${ screenY - 18 }px) translate(-50%, -100%)`; + } + + private hideTooltip(): void { + if ( this.tooltipEl ) { + this.tooltipEl.hidden = true; + } + } + + private advance( deltaTime: number ): void { + this.animTime += deltaTime; + if ( this.sim ) { + this.sim.step( deltaTime ); + } + + // Auto-fit-follow: keep re-framing while the simulation spreads + // the layout (post-load and post-grouping). Stops on its own + // after FIT_FOLLOW_FRAMES, when the sim settles, or the moment + // the user navigates manually (wheel / drag zero the counter). + if ( this.fitFollowFrames > 0 ) { + this.fitFollowFrames -= deltaTime; + this.fitToView(); + if ( this.sim?.isSettled ) { + this.fitFollowFrames = 0; + } + } + + // Camera ease. + const w = this.world; + const easeX = ( this.cameraTarget.x - w.x ) * CAMERA_EASE; + const easeY = ( this.cameraTarget.y - w.y ) * CAMERA_EASE; + const easeS = ( this.cameraTarget.scale - w.scale.x ) * CAMERA_EASE; + if ( Math.abs( easeX ) > CAMERA_EPSILON ) { + w.x += easeX; + } + if ( Math.abs( easeY ) > CAMERA_EPSILON ) { + w.y += easeY; + } + if ( Math.abs( easeS ) > CAMERA_EPSILON ) { + w.scale.set( w.scale.x + easeS ); + } + + // Position dots. + for ( const view of this.dotViews.values() ) { + view.sprite.x = view.node.x; + view.sprite.y = view.node.y; + if ( view.twinkle ) { + view.twinkle.x = view.node.x; + view.twinkle.y = view.node.y; + // Slow sin pulse with per-node phase so the whole layer + // doesn't blink in unison. + const pulse = + 0.45 + + 0.55 * Math.abs( Math.sin( this.animTime * 0.035 + view.twinklePhase ) ); + view.twinkle.alpha = pulse; + const ts = 0.5 + 0.4 * pulse; + view.twinkle.scale.set( ts ); + } + } + + // Position nebulae + DOM labels at cluster centroids. + if ( this.grouping ) { + // Reveal ramp: 0 → 1 over GROUP_REVEAL_RAMP_FRAMES after a + // delay. At grouping time every cluster centroid is still the + // same point (the entangled ball), so instantly-visible + // labels render as a stack of names floating over one blob. + // By the end of the delay the clusters have visibly + // separated; the ramp eases labels + nebulae in over their + // own clusters. + const sinceBuild = this.animTime - this.groupVisualsBuiltAt; + const reveal = Math.max( + 0, + Math.min( + 1, + ( sinceBuild - GROUP_REVEAL_DELAY_FRAMES ) / + GROUP_REVEAL_RAMP_FRAMES, + ), + ); + const camX = w.x; + const camY = w.y; + const camS = w.scale.x; + // Nebulae and labels share the same cluster keys and member + // lists — compute each centroid (an O(m log m) percentile + // sort) once per frame instead of twice. + const centroids = new Map< + string, + { x: number; y: number; radius: number } | null + >(); + const centroidFor = ( + key: string, + memberIds: number[], + ): { x: number; y: number; radius: number } | null => { + let c = centroids.get( key ); + if ( c === undefined ) { + c = this.centroidOf( memberIds ); + centroids.set( key, c ); + } + return c; + }; + for ( const neb of this.nebulae.values() ) { + const c = centroidFor( neb.key, neb.memberIds ); + if ( ! c || reveal <= 0 ) { + neb.sprite.visible = false; + continue; + } + neb.sprite.visible = true; + neb.sprite.alpha = 0.2 * reveal; + neb.sprite.x = c.x; + neb.sprite.y = c.y; + const r = Math.max( + NEBULA_BASE_RADIUS * 0.4, + Math.min( NEBULA_BASE_RADIUS * 2.5, c.radius + 80 ), + ); + // Texture is 256px square, so world radius → scale is + // r / (textureSize / 2). + const k = r / 128; + neb.sprite.scale.set( k ); + } + for ( const lab of this.labels.values() ) { + const c = centroidFor( lab.key, lab.memberIds ); + if ( ! c || reveal <= 0 ) { + lab.el.style.display = 'none'; + continue; + } + lab.el.style.display = ''; + lab.el.style.opacity = String( reveal ); + // The label sits at the cluster's centroid — the heart of + // the nebula, like the reference design — NOT floated + // above its top edge. The centroid tracks the cluster + // smoothly as it separates; a radius-based offset whips + // around while the percentile radius is still volatile. + const sx = c.x * camS + camX; + const sy = c.y * camS + camY; + lab.el.style.transform = `translate(${ sx }px, ${ sy }px) translate(-50%, -50%)`; + } + } + } + + private centroidOf( + memberIds: number[], + ): { x: number; y: number; radius: number } | null { + let count = 0; + let sx = 0; + let sy = 0; + for ( const id of memberIds ) { + const dot = this.dotViews.get( id ); + if ( ! dot || ! dot.sprite.visible ) { + continue; + } + sx += dot.node.x; + sy += dot.node.y; + count++; + } + if ( count === 0 ) { + return null; + } + const cx = sx / count; + const cy = sy / count; + // Robust radius: 80th-percentile member distance, not the max. + // One stray member parked between clusters (cross-cluster + // hyperlink springs do this) would otherwise balloon the radius + // to half the layout — every nebula covers everything and every + // label floats to the top edge of the canvas. + const d2s: number[] = []; + for ( const id of memberIds ) { + const dot = this.dotViews.get( id ); + if ( ! dot || ! dot.sprite.visible ) { + continue; + } + const dx = dot.node.x - cx; + const dy = dot.node.y - cy; + d2s.push( dx * dx + dy * dy ); + } + d2s.sort( ( a, b ) => a - b ); + const idx = Math.min( + d2s.length - 1, + Math.floor( d2s.length * 0.8 ), + ); + return { x: cx, y: cy, radius: Math.sqrt( d2s[ idx ] ?? 0 ) }; + } +} + +// ───────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────── + +/** + * Group-key derivation matches the existing `GraphScene` so the two + * scenes describe the same clusters. Mirrors the rules documented in + * `scene.ts`'s `deriveGroupKeys()`. + */ +function deriveGroupKeys( node: GraphNode, facet: GroupFacet ): string[] { + switch ( facet ) { + case 'category': + return Array.isArray( node.category_ids ) + ? node.category_ids.map( ( id ) => `cat:${ id }` ) + : []; + case 'tag': + return Array.isArray( node.tag_ids ) + ? node.tag_ids.map( ( id ) => `tag:${ id }` ) + : []; + case 'author': { + const primary = node.author_id > 0 ? `auth:${ node.author_id }` : null; + const contribs = Array.isArray( node.contributor_ids ) + ? node.contributor_ids.map( ( id ) => `auth:${ id }` ) + : []; + if ( ! primary ) { + return contribs; + } + // Double-weight the primary so the post lands closer to its + // author's centroid, mirroring `GraphScene`'s behaviour. + return [ primary, primary, ...contribs ]; + } + case 'year': + return node.year > 0 ? [ `year:${ node.year }` ] : []; + case 'year_month': + return node.year_month ? [ `ym:${ node.year_month }` ] : []; + } +} + +/** + * Single-membership commit for the Galaxy view: each dot belongs to + * its FIRST group key only. Multi-membership pulls (the GraphScene + * behaviour) read nicely on small graphs, but on a realistic corpus + * where most posts carry 2-3 categories the shared members chain + * every cluster to every other and the layout collapses into one + * entangled ball. The colour encoding, the force assignment, the + * nebulae, and the labels all use this same commit so what you see + * is internally consistent. + */ +function galaxyKeysFor( node: GraphNode, facet: GroupFacet ): string[] { + const keys = deriveGroupKeys( node, facet ); + return keys.length > 0 ? [ keys[ 0 ] ] : []; +} + +function labelForGroupKey( + key: string, + catalogs: GraphGroupCatalogs, +): string { + const colon = key.indexOf( ':' ); + if ( colon < 0 ) { + return key; + } + const prefix = key.slice( 0, colon ); + const rest = key.slice( colon + 1 ); + switch ( prefix ) { + case 'cat': + return catalogs.categories[ Number( rest ) ]?.name ?? rest; + case 'tag': + return catalogs.tags[ Number( rest ) ]?.name ?? rest; + case 'auth': + return catalogs.authors[ Number( rest ) ]?.name ?? rest; + case 'year': + return rest; + case 'ym': + return rest; + default: + return key; + } +} + +/** + * Hash a group key to a hue then to an RGB int. Deterministic — same + * key always paints the same colour across reloads. Tag/category/author + * facets ride this hash. Year facets get a chronological ramp inside + * the scene (see `colorForGroupKey()`). + */ +function colorForGroupKey( key: string ): number { + // Chronological-ish facets — give them a smooth ramp instead of + // hashed-random hues, so adjacent years/months sit near each other + // on the colour wheel. + if ( key.startsWith( 'year:' ) ) { + const n = Number( key.slice( 5 ) ) || 2000; + // 2010 → 0°, 2030 → 180° (warm winter → cool spring → warm summer). + const hue = ( ( n - 2010 ) * 18 ) % 360; + return hslToRgbInt( hue < 0 ? hue + 360 : hue, 0.7, 0.6 ); + } + if ( key.startsWith( 'ym:' ) ) { + const ym = key.slice( 3 ); + const [ y, m ] = ym.split( '-' ).map( ( s ) => Number( s ) || 0 ); + const months = y * 12 + ( m - 1 ); + const hue = ( months * 11 ) % 360; + return hslToRgbInt( hue < 0 ? hue + 360 : hue, 0.7, 0.6 ); + } + // Generic hash for the categorical facets — FNV-1a flavoured but + // using `Math.imul` + modulo instead of `^` / `>>>` so the lint + // rule banning bitwise ops doesn't trip. Same shape, same + // determinism per key. + let hash = 2166136261; + for ( let i = 0; i < key.length; i++ ) { + const diff = ( hash - key.charCodeAt( i ) + 4294967296 ) % 4294967296; + hash = ( Math.imul( diff, 16777619 ) + 4294967296 ) % 4294967296; + } + const hue = hash % 360; + const sat = 0.65 + ( Math.floor( hash / 256 ) % 25 ) / 100; + const light = 0.55 + ( Math.floor( hash / 65536 ) % 20 ) / 100; + return hslToRgbInt( hue, sat, light ); +} + +function hslToRgbInt( h: number, s: number, l: number ): number { + const c = ( 1 - Math.abs( 2 * l - 1 ) ) * s; + const hh = h / 60; + const x = c * ( 1 - Math.abs( ( hh % 2 ) - 1 ) ); + let r = 0; + let g = 0; + let b = 0; + if ( hh < 1 ) { + r = c; + g = x; + } else if ( hh < 2 ) { + r = x; + g = c; + } else if ( hh < 3 ) { + g = c; + b = x; + } else if ( hh < 4 ) { + g = x; + b = c; + } else if ( hh < 5 ) { + r = x; + b = c; + } else { + r = c; + b = x; + } + const m = l - c / 2; + const ri = Math.round( ( r + m ) * 255 ); + const gi = Math.round( ( g + m ) * 255 ); + const bi = Math.round( ( b + m ) * 255 ); + return ri * 65536 + gi * 256 + bi; +} + +function buildBrushTexture( pixi: PixiNamespace, size: number ): PixiTexture { + const canvas = document.createElement( 'canvas' ); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext( '2d' ); + if ( ! ctx ) { + throw new Error( '[desktop-mode/content-graph] 2D canvas context unavailable.' ); + } + const center = size / 2; + const gradient = ctx.createRadialGradient( + center, + center, + 0, + center, + center, + center, + ); + gradient.addColorStop( 0, 'rgba(255, 255, 255, 1)' ); + gradient.addColorStop( 0.18, 'rgba(255, 255, 255, 0.85)' ); + gradient.addColorStop( 0.42, 'rgba(255, 255, 255, 0.28)' ); + gradient.addColorStop( 0.75, 'rgba(255, 255, 255, 0.06)' ); + gradient.addColorStop( 1, 'rgba(255, 255, 255, 0)' ); + ctx.fillStyle = gradient; + ctx.fillRect( 0, 0, size, size ); + return pixi.Texture.from( canvas ); +} + +/** + * Soft cluster-glow texture. Unlike the dot brush, the centre peaks at + * a LOW alpha (0.35) so dozens of additively-blended nebulae can + * overlap during simulation convergence without compounding into a + * white wall. The wide falloff sells the "gas cloud" read. + */ +function buildNebulaTexture( pixi: PixiNamespace, size: number ): PixiTexture { + const canvas = document.createElement( 'canvas' ); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext( '2d' ); + if ( ! ctx ) { + throw new Error( '[desktop-mode/content-graph] 2D canvas context unavailable.' ); + } + const center = size / 2; + const gradient = ctx.createRadialGradient( + center, + center, + 0, + center, + center, + center, + ); + gradient.addColorStop( 0, 'rgba(255, 255, 255, 0.35)' ); + gradient.addColorStop( 0.35, 'rgba(255, 255, 255, 0.18)' ); + gradient.addColorStop( 0.7, 'rgba(255, 255, 255, 0.05)' ); + gradient.addColorStop( 1, 'rgba(255, 255, 255, 0)' ); + ctx.fillStyle = gradient; + ctx.fillRect( 0, 0, size, size ); + return pixi.Texture.from( canvas ); +} + +function buildSparkleTexture( + pixi: PixiNamespace, + size: number, +): PixiTexture { + const canvas = document.createElement( 'canvas' ); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext( '2d' ); + if ( ! ctx ) { + throw new Error( '[desktop-mode/content-graph] 2D canvas context unavailable.' ); + } + const center = size / 2; + const radial = ctx.createRadialGradient( + center, + center, + 0, + center, + center, + center * 0.5, + ); + radial.addColorStop( 0, 'rgba(255, 255, 255, 1)' ); + radial.addColorStop( 0.4, 'rgba(255, 255, 255, 0.5)' ); + radial.addColorStop( 1, 'rgba(255, 255, 255, 0)' ); + ctx.fillStyle = radial; + ctx.fillRect( 0, 0, size, size ); + ctx.globalCompositeOperation = 'lighter'; + const armWidth = 1.5; + for ( const isVertical of [ false, true ] ) { + const grad = isVertical + ? ctx.createLinearGradient( 0, 0, 0, size ) + : ctx.createLinearGradient( 0, 0, size, 0 ); + grad.addColorStop( 0, 'rgba(255, 255, 255, 0)' ); + grad.addColorStop( 0.5, 'rgba(255, 255, 255, 0.85)' ); + grad.addColorStop( 1, 'rgba(255, 255, 255, 0)' ); + ctx.fillStyle = grad; + if ( isVertical ) { + ctx.fillRect( center - armWidth, 0, armWidth * 2, size ); + } else { + ctx.fillRect( 0, center - armWidth, size, armWidth * 2 ); + } + } + return pixi.Texture.from( canvas ); +} + +function escapeHtml( s: string ): string { + return s + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ); +} diff --git a/src/content-graph/index.ts b/src/content-graph/index.ts index d3faede85..4b5be9a64 100644 --- a/src/content-graph/index.ts +++ b/src/content-graph/index.ts @@ -17,12 +17,18 @@ import { __, sprintf } from '../i18n'; import { fetchGraph, fetchPostDetail, fetchPostTypes, getConfig } from './rest'; -import { renderToolbar } from './toolbar'; +import { renderToolbar, type ContentGraphView } from './toolbar'; import { renderPanel } from './panel'; import { GraphScene } from './scene'; +import { GalaxyScene } from './galaxy-scene'; import type { SatelliteRef } from './satellites'; import type { DesktopApiLike } from './pixi-types'; -import type { GraphNode, GroupFacet } from './types'; +import type { + GalaxyTab, + GraphNode, + GraphPayload, + GroupFacet, +} from './types'; // The framework's actual signature is wider (`() => void | (() => void) | // Promise<…>`) but every feature bundle re-declares it as a narrow @@ -44,6 +50,12 @@ interface ActiveState { abort: () => void; } +interface ActiveScene { + view: ContentGraphView; + graph: GraphScene | null; + galaxy: GalaxyScene | null; +} + async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { const root = body.querySelector< HTMLElement >( '[data-desktop-mode-content-graph-root]', @@ -71,9 +83,10 @@ async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { ?.desktop ?? {}; let activeTypes: string[] = cfg.postTypes.map( ( t ) => t.slug ); - let scene: GraphScene | null = null; let detailRequestId = 0; let aborted = false; + let currentPayload: GraphPayload | null = null; + let currentGrouping: GroupFacet | null = null; const showLoading = ( show: boolean ): void => { if ( ! loading ) { @@ -85,13 +98,14 @@ async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { const panel = renderPanel( panelHost, cfg, { onClose: () => { panel.hide(); - scene?.clearFocus(); + activeScene.graph?.clearFocus(); + activeScene.galaxy?.clearFocus(); }, // Mirror the panel's visible view onto the satellite layer so // the bubble matching the dossier picks up its selected state // (and clears when the user navigates back to the post view). onViewChange: ( key ) => { - scene?.setSatelliteSelectedKey( key ); + activeScene.graph?.setSatelliteSelectedKey( key ); }, } ); @@ -119,7 +133,8 @@ async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { }; const focusNode = ( node: GraphNode ): void => { - scene?.focusNode( node.id ); + activeScene.graph?.focusNode( node.id ); + activeScene.galaxy?.focusNode( node.id ); panel.setLoading( node.id, node.title ); const myId = ++detailRequestId; void ( async () => { @@ -129,7 +144,8 @@ async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { return; } panel.setDetail( detail ); - scene?.setFocusedDetail( detail ); + activeScene.graph?.setFocusedDetail( detail ); + activeScene.galaxy?.setFocusedDetail( detail ); } catch ( err ) { if ( aborted || myId !== detailRequestId ) { return; @@ -147,28 +163,197 @@ async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { } )(); }; + const closeFocus = (): void => { + // Bump the request id so any in-flight detail fetch's late + // resolution doesn't re-open the panel after we close it. + detailRequestId++; + panel.hide(); + activeScene.graph?.clearFocus(); + activeScene.galaxy?.clearFocus(); + }; + + const initialView: ContentGraphView = + cfg.lastView === 'galaxy' ? 'galaxy' : 'graph'; + + const activeScene: ActiveScene = { + view: initialView, + graph: null, + galaxy: null, + }; + const buildToolbarCallbacks = () => ( { onTypesChange: ( types: string[] ) => { activeTypes = types; void loadGraph(); }, - onFitToView: () => scene?.fitToView(), + onFitToView: () => { + activeScene.graph?.fitToView(); + activeScene.galaxy?.fitToView(); + }, onSearchSelect: ( node: GraphNode ) => focusNode( node ), onGroupChange: ( facet: GroupFacet | null ) => { // Session-local: no persistence. The selector resets to None // on every window open by virtue of the toolbar being // constructed fresh each render. - scene?.setGrouping( facet ); + currentGrouping = facet; + activeScene.graph?.setGrouping( facet ); + activeScene.galaxy?.setGrouping( facet ); + }, + getNodes: () => + activeScene.graph?.getNodes() ?? + activeScene.galaxy?.getNodes() ?? + [], + onViewChange: ( next: ContentGraphView ) => { + void swapView( next ); + }, + onGalaxyTabChange: ( tab: GalaxyTab ) => { + activeScene.galaxy?.setTab( tab ); + }, + onMinCommentsChange: ( min: number ) => { + activeScene.galaxy?.setMinComments( min ); + }, + onZoomChange: ( zoom: number ) => { + activeScene.galaxy?.setZoom( zoom ); }, - getNodes: () => scene?.getNodes() ?? [], } ); let toolbar = renderToolbar( toolbarHost, + cfg, cfg.postTypes, buildToolbarCallbacks(), ); + // Guards the async mount pipeline. `scene.mount()` awaits module + // loading + `app.init()`, so a rapid Graph⇄Galaxy⇄Graph toggle (or + // a window close) can land while a mount is still in flight. Each + // mount takes a generation ticket; if a newer mount (or `abort()`) + // supersedes it before it resolves, the stale scene destroys itself + // instead of being assigned — otherwise two live Pixi Applications + // coexist with stacked canvases, or a leaked scene ticks forever + // after the window closed. + let mountGeneration = 0; + + const swapView = async ( next: ContentGraphView ): Promise< void > => { + if ( next === activeScene.view ) { + return; + } + // Tear down the outgoing scene BEFORE constructing the incoming + // one so the two Pixi Applications never coexist (the v8 batched + // renderer's destroy race documented in `categories-mindmap.ts` + // and `tags-cloud.ts`). + if ( activeScene.graph ) { + activeScene.graph.destroy(); + activeScene.graph = null; + } + if ( activeScene.galaxy ) { + activeScene.galaxy.destroy(); + activeScene.galaxy = null; + } + stageHost.classList.remove( 'is-galaxy' ); + activeScene.view = next; + let mounted: boolean; + try { + mounted = await mountActiveScene(); + } catch ( err ) { + stageHost.textContent = __( 'Could not initialise the graph renderer.' ); + // eslint-disable-next-line no-console + console.warn( '[content-graph] scene swap failed', err ); + return; + } + if ( ! mounted ) { + // Superseded by a newer swap (or the window closed) while + // mounting — the winning call restores its own state. + return; + } + // Restore data + grouping into the new scene. Read through + // `getScene()` so TS doesn't carry the `null`-narrowing from + // the inline assignments above through the await — the + // freshly-mounted scene replaced one of the slots. + if ( currentPayload ) { + const fresh = getScene(); + fresh.graph?.setData( currentPayload ); + fresh.galaxy?.setData( currentPayload ); + if ( currentGrouping ) { + fresh.graph?.setGrouping( currentGrouping ); + fresh.galaxy?.setGrouping( currentGrouping ); + } + fresh.graph?.fitToView(); + fresh.galaxy?.fitToView(); + } + }; + + const getScene = (): ActiveScene => activeScene; + + /** + * Mount the scene matching `activeScene.view`. Resolves `true` when + * the scene was assigned, `false` when the mount lost its generation + * race (a newer mount started, or the window aborted) — in that case + * the freshly-built scene is destroyed here and nothing is assigned. + */ + const mountActiveScene = async (): Promise< boolean > => { + const generation = ++mountGeneration; + const isStale = (): boolean => + aborted || generation !== mountGeneration; + if ( activeScene.view === 'galaxy' ) { + const scene = new GalaxyScene( stageHost, { + onNodeClick: ( node ) => { + if ( activeScene.galaxy?.getFocusedId() === node.id ) { + closeFocus(); + return; + } + focusNode( node ); + }, + onBackgroundClick: closeFocus, + onVisibleCountChange: ( visible, total ) => { + toolbar.setVisibleCount( visible, total ); + }, + } ); + await scene.mount( desktopApi ); + if ( isStale() ) { + scene.destroy(); + return false; + } + activeScene.galaxy = scene; + return true; + } + const scene = new GraphScene( + stageHost, + { + onNodeClick: ( node ) => { + // Click on the already-focused node = toggle off. Lets + // the user dismiss the focus with the same gesture + // they used to open it, instead of having to find the + // panel's close button or click empty canvas. + if ( activeScene.graph?.getFocusedId() === node.id ) { + closeFocus(); + return; + } + focusNode( node ); + }, + onBackgroundClick: closeFocus, + }, + handleSatelliteClick, + cfg.postTypes, + ); + await scene.mount( desktopApi ); + if ( isStale() ) { + scene.destroy(); + return false; + } + activeScene.graph = scene; + return true; + }; + + try { + await mountActiveScene(); + } catch ( err ) { + stageHost.textContent = __( 'Could not initialise the graph renderer.' ); + // eslint-disable-next-line no-console + console.warn( '[content-graph] scene mount failed', err ); + return { abort: () => {} }; + } + const loadGraph = async (): Promise< void > => { if ( aborted ) { return; @@ -180,7 +365,9 @@ async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { if ( aborted ) { return; } - scene?.setData( payload ); + currentPayload = payload; + activeScene.graph?.setData( payload ); + activeScene.galaxy?.setData( payload ); toolbar.setStatus( sprintf( /* translators: 1: number of nodes (posts/pages) in the graph. 2: number of links between them. */ @@ -189,9 +376,15 @@ async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { payload.stats.edges, ), ); - scene?.fitToView(); - scene?.clearFocus(); + activeScene.graph?.fitToView(); + activeScene.galaxy?.fitToView(); + activeScene.graph?.clearFocus(); + activeScene.galaxy?.clearFocus(); panel.hide(); + if ( currentGrouping ) { + activeScene.graph?.setGrouping( currentGrouping ); + activeScene.galaxy?.setGrouping( currentGrouping ); + } } catch ( err ) { if ( aborted ) { return; @@ -204,43 +397,6 @@ async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { } }; - const closeFocus = (): void => { - // Bump the request id so any in-flight detail fetch's late - // resolution doesn't re-open the panel after we close it. - detailRequestId++; - panel.hide(); - scene?.clearFocus(); - }; - - scene = new GraphScene( - stageHost, - { - onNodeClick: ( node ) => { - // Click on the already-focused node = toggle off. Lets - // the user dismiss the focus with the same gesture - // they used to open it, instead of having to find the - // panel's close button or click empty canvas. - if ( scene?.getFocusedId() === node.id ) { - closeFocus(); - return; - } - focusNode( node ); - }, - onBackgroundClick: closeFocus, - }, - handleSatelliteClick, - cfg.postTypes, - ); - - try { - await scene.mount( desktopApi ); - } catch ( err ) { - stageHost.textContent = __( 'Could not initialise the graph renderer.' ); - // eslint-disable-next-line no-console - console.warn( '[content-graph] scene mount failed', err ); - return { abort: () => {} }; - } - // First-load: refresh post-type counts so chips reflect live state, // then load the graph itself. try { @@ -249,7 +405,12 @@ async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { // target the new DOM. Without this the original handle silently // writes to a removed element. toolbar.destroy(); - toolbar = renderToolbar( toolbarHost, refreshed, buildToolbarCallbacks() ); + toolbar = renderToolbar( + toolbarHost, + cfg, + refreshed, + buildToolbarCallbacks(), + ); } catch { // Non-fatal — keep the chips that came from the window config. } @@ -261,8 +422,10 @@ async function renderContentGraph( body: HTMLElement ): Promise< ActiveState > { aborted = true; toolbar.destroy(); panel.destroy(); - scene?.destroy(); - scene = null; + activeScene.graph?.destroy(); + activeScene.graph = null; + activeScene.galaxy?.destroy(); + activeScene.galaxy = null; }, }; } diff --git a/src/content-graph/pixi-types.ts b/src/content-graph/pixi-types.ts index 0b70601e4..046fcec0d 100644 --- a/src/content-graph/pixi-types.ts +++ b/src/content-graph/pixi-types.ts @@ -100,6 +100,7 @@ export interface PixiApp { resize( w: number, h: number ): void; width: number; height: number; + resolution: number; render( container?: unknown ): void; }; init( opts: unknown ): Promise< void >; @@ -113,11 +114,24 @@ export interface PixiApp { destroy( rendererOpts?: { removeView?: boolean }, opts?: unknown ): void; } +export interface PixiTexture { + destroy( opts?: unknown ): void; +} + +export interface PixiSprite extends PixiContainer { + anchor: { set( v: number ): void; x?: number; y?: number }; + tint: number; + blendMode: string; + texture: PixiTexture; +} + export interface PixiNamespace { Application: new () => PixiApp; Container: new () => PixiContainer; Graphics: new () => PixiGraphics; Text: new ( opts: PixiTextOpts ) => PixiText; + Sprite: new ( texture: PixiTexture ) => PixiSprite; + Texture: { from( source: HTMLCanvasElement | HTMLImageElement ): PixiTexture }; Rectangle: new ( x: number, y: number, w: number, h: number ) => unknown; Circle: new ( x: number, y: number, r: number ) => unknown; } diff --git a/src/content-graph/toolbar.ts b/src/content-graph/toolbar.ts index 9d4cee25f..26f8b29df 100644 --- a/src/content-graph/toolbar.ts +++ b/src/content-graph/toolbar.ts @@ -1,19 +1,41 @@ /** * Content Graph — toolbar. * - * Top strip of the window. Three pieces: + * Top strip of the window. Houses the View toggle (`Graph` / `Galaxy`) + * plus the view-specific controls below it: * - * 1. **Filter chips** — one per public post type. Click to toggle; - * the host re-fetches `/nodes` with the active set. - * 2. **Search** — fuzzy match on node titles. Selecting a result - * tells the host to focus that node. - * 3. **Action buttons** — fit-to-view + reheat the simulation. + * - **Graph view** — post-type filter chips, Group-by select, search. + * - **Galaxy view** — tabs (All / Drafts / Recent), Group-by select, + * MIN VOLUME + ZOOM range sliders, visible-count readout, search. + * + * The user's last chosen view is persisted via the + * `desktop_mode_content_graph_view` user meta key (registered in + * `includes/content-graph/window.php`). The bundle reads it on mount + * from `cfg.lastView` and writes back via `POST /wp/v2/users/` + * whenever the segmented control flips. * * @public */ -import { __ } from '../i18n'; -import type { GraphNode, GroupFacet, PostTypeDescriptor } from './types'; +import { __, sprintf } from '../i18n'; +import { trackedFetch } from '../tracked-fetch'; +import { joinRestUrl } from '../rest-url'; +// Side-effect imports register the `` custom elements this +// toolbar constructs. Without them the elements render as inert +// (un-upgraded) custom elements. See ESLint local rule +// `wpd-component-registration` for the contract. +import '../ui/components/wpd-segmented/wpd-segmented'; +import '../ui/components/wpd-tabs/wpd-tabs'; +import '../ui/components/wpd-range-field/wpd-range-field'; +import type { + ContentGraphConfig, + GalaxyTab, + GraphNode, + GroupFacet, + PostTypeDescriptor, +} from './types'; + +export type ContentGraphView = 'graph' | 'galaxy'; export interface ToolbarCallbacks { onTypesChange: ( types: string[] ) => void; @@ -21,6 +43,10 @@ export interface ToolbarCallbacks { onSearchSelect: ( node: GraphNode ) => void; onGroupChange: ( facet: GroupFacet | null ) => void; getNodes: () => GraphNode[]; + onViewChange: ( view: ContentGraphView ) => void; + onGalaxyTabChange: ( tab: GalaxyTab ) => void; + onMinCommentsChange: ( min: number ) => void; + onZoomChange: ( zoom: number ) => void; } // Sentinel for the "no clustering" option. `` works @@ -30,26 +56,150 @@ const GROUP_NONE = 'none'; export interface ToolbarHandle { setStatus: ( text: string ) => void; + setVisibleCount: ( visible: number, total: number ) => void; + getView: () => ContentGraphView; destroy: () => void; } export function renderToolbar( host: HTMLElement, + cfg: ContentGraphConfig, postTypes: PostTypeDescriptor[], callbacks: ToolbarCallbacks, ): ToolbarHandle { host.replaceChildren(); + let view: ContentGraphView = cfg.lastView === 'galaxy' ? 'galaxy' : 'graph'; + // Survives mode-row rebuilds (Graph ⇄ Galaxy swaps): the freshly + // constructed group-by select initialises to this value so the + // dropdown keeps showing the facet that is actually active in the + // scene instead of resetting to "No grouping". + let currentFacet: GroupFacet | null = null; const active = new Set( postTypes.map( ( t ) => t.slug ) ); + // Row A — always visible. View toggle on the left, status on the right. + const headerRow = document.createElement( 'div' ); + headerRow.className = 'desktop-mode-content-graph__header-row'; + + const viewToggle = document.createElement( 'wpd-segmented' ); + viewToggle.setAttribute( 'aria-label', __( 'View mode' ) ); + viewToggle.setAttribute( 'value', view ); + for ( const [ value, label ] of [ + [ 'graph', __( 'Graph' ) ], + [ 'galaxy', __( 'Galaxy' ) ], + ] as const ) { + const seg = document.createElement( 'wpd-segment' ); + seg.setAttribute( 'value', value ); + seg.textContent = label; + viewToggle.appendChild( seg ); + } + viewToggle.addEventListener( 'wpd-pick', ( ev: Event ) => { + const next = ( ev as CustomEvent< { value: string } > ).detail?.value; + if ( next !== 'graph' && next !== 'galaxy' ) { + return; + } + if ( next === view ) { + return; + } + view = next; + void persistView( cfg, view ); + rebuildModeRow(); + callbacks.onViewChange( view ); + } ); + headerRow.appendChild( viewToggle ); + + const status = document.createElement( 'span' ); + status.className = 'desktop-mode-content-graph__toolbar-status'; + headerRow.appendChild( status ); + + host.appendChild( headerRow ); + + // Row B — view-specific. Rebuilt on mode flip. + const modeRow = document.createElement( 'div' ); + modeRow.className = 'desktop-mode-content-graph__mode-row'; + host.appendChild( modeRow ); + + // Cached node-count widget — only meaningful in Galaxy view but + // owned at the toolbar level so the scene can push updates without + // caring which row is currently mounted. + let visibleCountEl: HTMLSpanElement | null = null; + + // Wrap the host callbacks so the toolbar can track the active facet + // across mode-row rebuilds without the host needing to know. + const trackedCallbacks: ToolbarCallbacks = { + ...callbacks, + onGroupChange: ( facet ) => { + currentFacet = facet; + callbacks.onGroupChange( facet ); + }, + }; + + const rebuildModeRow = (): void => { + modeRow.replaceChildren(); + visibleCountEl = null; + if ( view === 'graph' ) { + renderGraphChrome( + modeRow, + postTypes, + active, + trackedCallbacks, + currentFacet, + ); + return; + } + visibleCountEl = renderGalaxyChrome( + modeRow, + trackedCallbacks, + currentFacet, + ); + }; + rebuildModeRow(); + + return { + setStatus: ( text: string ) => { + status.textContent = text; + }, + setVisibleCount: ( visible: number, total: number ) => { + if ( ! visibleCountEl ) { + return; + } + // Right-aligned widget below the canvas-overlay tab strip; + // mirrors the reference image's "X of Y visible" readout. + // One translatable template (not concatenated fragments) so + // translators can reorder; the template is escaped before + // our own markup is substituted in. + visibleCountEl.innerHTML = sprintf( + /* translators: 1: number of visible posts. 2: total number of posts loaded. */ + escapeHtml( __( '%1$s of %2$s visible' ) ), + '' + String( visible ) + '', + String( total ), + ); + }, + getView: () => view, + destroy: () => { + // All toolbar listeners are element-scoped and die with the + // host DOM — nothing document-level to detach. + }, + }; +} + +function renderGraphChrome( + row: HTMLElement, + postTypes: PostTypeDescriptor[], + active: Set< string >, + callbacks: ToolbarCallbacks, + currentFacet: GroupFacet | null, +): void { const chipsRow = document.createElement( 'div' ); chipsRow.className = 'desktop-mode-content-graph__filters'; - host.appendChild( chipsRow ); + row.appendChild( chipsRow ); for ( const type of postTypes ) { const chip = document.createElement( 'button' ); chip.type = 'button'; - chip.className = 'desktop-mode-content-graph__chip is-active'; + chip.className = active.has( type.slug ) + ? 'desktop-mode-content-graph__chip is-active' + : 'desktop-mode-content-graph__chip'; chip.dataset.slug = type.slug; chip.innerHTML = `` + @@ -68,6 +218,149 @@ export function renderToolbar( chipsRow.appendChild( chip ); } + const groupBy = buildGroupBySelect( callbacks, currentFacet ); + row.appendChild( groupBy ); + + const searchWrap = buildSearch( callbacks ); + row.appendChild( searchWrap ); + + const actions = document.createElement( 'div' ); + actions.className = 'desktop-mode-content-graph__actions'; + + const fit = document.createElement( 'button' ); + fit.type = 'button'; + fit.className = 'desktop-mode-content-graph__btn'; + fit.innerHTML = + '' + + `${ escapeHtml( __( 'Fit' ) ) }`; + fit.title = __( 'Fit graph to view' ); + fit.addEventListener( 'click', () => callbacks.onFitToView() ); + actions.appendChild( fit ); + + row.appendChild( actions ); +} + +function renderGalaxyChrome( + row: HTMLElement, + callbacks: ToolbarCallbacks, + currentFacet: GroupFacet | null, +): HTMLSpanElement { + const tabs = document.createElement( 'wpd-tabs' ); + tabs.setAttribute( 'value', 'all' ); + tabs.setAttribute( 'aria-label', __( 'Filter by status' ) ); + for ( const [ value, label ] of [ + [ 'all', __( 'All' ) ], + [ 'drafts', __( 'Drafts' ) ], + [ 'recent', __( 'Recent' ) ], + ] as const ) { + const tab = document.createElement( 'wpd-tab' ); + tab.setAttribute( 'value', value ); + tab.textContent = label; + tabs.appendChild( tab ); + } + tabs.addEventListener( 'wpd-tab-change', ( ev: Event ) => { + const detail = ( ev as CustomEvent< { value: string } > ).detail; + const v = detail?.value; + if ( v === 'all' || v === 'drafts' || v === 'recent' ) { + callbacks.onGalaxyTabChange( v ); + } + } ); + row.appendChild( tabs ); + + const groupBy = buildGroupBySelect( callbacks, currentFacet ); + row.appendChild( groupBy ); + + const minVol = document.createElement( 'wpd-range-field' ); + minVol.setAttribute( 'label', __( 'Min comments' ) ); + minVol.setAttribute( 'value', '0' ); + minVol.setAttribute( 'min', '0' ); + minVol.setAttribute( 'max', '50' ); + minVol.setAttribute( 'step', '1' ); + minVol.className = 'desktop-mode-content-graph__range'; + minVol.addEventListener( 'wpd-range-change', ( ev: Event ) => { + const v = ( ev as CustomEvent< { value: number } > ).detail?.value; + if ( typeof v === 'number' ) { + callbacks.onMinCommentsChange( v ); + } + } ); + row.appendChild( minVol ); + + const zoom = document.createElement( 'wpd-range-field' ); + zoom.setAttribute( 'label', __( 'Zoom' ) ); + zoom.setAttribute( 'value', '100' ); + // Floor of 10% — a grouped fit-to-view regularly settles well below + // 50% (ZOOM_MIN in the scene is 8%), and a slider floor far above + // the fitted scale made the first slider touch jump the camera ~5×. + zoom.setAttribute( 'min', '10' ); + zoom.setAttribute( 'max', '400' ); + zoom.setAttribute( 'step', '5' ); + zoom.setAttribute( 'suffix', '%' ); + zoom.className = 'desktop-mode-content-graph__range'; + zoom.addEventListener( 'wpd-range-change', ( ev: Event ) => { + const v = ( ev as CustomEvent< { value: number } > ).detail?.value; + if ( typeof v === 'number' ) { + callbacks.onZoomChange( v / 100 ); + } + } ); + row.appendChild( zoom ); + + const searchWrap = buildSearch( callbacks ); + row.appendChild( searchWrap ); + + const visibleCount = document.createElement( 'span' ); + visibleCount.className = 'desktop-mode-content-graph__visible-count'; + row.appendChild( visibleCount ); + + const actions = document.createElement( 'div' ); + actions.className = 'desktop-mode-content-graph__actions'; + + const fit = document.createElement( 'button' ); + fit.type = 'button'; + fit.className = 'desktop-mode-content-graph__btn'; + fit.innerHTML = + '' + + `${ escapeHtml( __( 'Fit' ) ) }`; + fit.title = __( 'Fit graph to view' ); + fit.addEventListener( 'click', () => callbacks.onFitToView() ); + actions.appendChild( fit ); + row.appendChild( actions ); + + return visibleCount; +} + +function buildGroupBySelect( + callbacks: ToolbarCallbacks, + currentFacet: GroupFacet | null, +): HTMLElement { + const groupBy = document.createElement( 'wpd-select' ); + groupBy.className = 'desktop-mode-content-graph__group-by'; + groupBy.setAttribute( 'value', currentFacet ?? GROUP_NONE ); + groupBy.setAttribute( 'aria-label', __( 'Group by' ) ); + groupBy.title = __( 'Group posts by a shared facet' ); + for ( const [ value, label ] of [ + [ GROUP_NONE, __( 'No grouping' ) ], + [ 'category', __( 'Group by category' ) ], + [ 'tag', __( 'Group by tag' ) ], + [ 'author', __( 'Group by author' ) ], + [ 'year', __( 'Group by year' ) ], + [ 'year_month', __( 'Group by year-month' ) ], + ] as const ) { + const opt = document.createElement( 'wpd-option' ); + opt.setAttribute( 'value', value ); + opt.textContent = label; + groupBy.appendChild( opt ); + } + groupBy.addEventListener( 'wpd-pick', ( ev: Event ) => { + const detail = ( ev as CustomEvent< { value: string } > ).detail; + const raw = detail?.value ?? GROUP_NONE; + const facet: GroupFacet | null = + raw === GROUP_NONE ? null : ( raw as GroupFacet ); + callbacks.onGroupChange( facet ); + } ); + return groupBy; +} + +function buildSearch( callbacks: ToolbarCallbacks ): HTMLElement { const searchWrap = document.createElement( 'div' ); searchWrap.className = 'desktop-mode-content-graph__search'; const searchInput = document.createElement( 'input' ); @@ -85,8 +378,6 @@ export function renderToolbar( dropdown.hidden = true; searchWrap.appendChild( dropdown ); - host.appendChild( searchWrap ); - const handleSearchInput = (): void => { const q = searchInput.value.trim().toLowerCase(); if ( q.length === 0 ) { @@ -121,87 +412,49 @@ export function renderToolbar( searchInput.addEventListener( 'input', handleSearchInput ); searchInput.addEventListener( 'focus', handleSearchInput ); searchInput.addEventListener( 'blur', () => { - // Delay so click on a result still registers. setTimeout( () => { dropdown.hidden = true; }, 120 ); } ); - // Group-by select lives next to the filter chips so it reads as a - // peer control ("filter, then group"). It's a direct child of the - // toolbar — NOT inside `actions` — because actions has `margin-left: - // auto` and would shove the select to the right edge, away from - // the chips it groups. - // - // Deliberately no `label` attribute: `` renders its - // label stacked above the dropdown, which makes the control - // taller than the chips and breaks horizontal alignment on the - // toolbar row. Instead, the first option's text ("No grouping") - // telegraphs the purpose, with `aria-label` + `title` carrying - // the "Group by" semantics for screen readers and hover. - const groupBy = document.createElement( 'wpd-select' ); - groupBy.className = 'desktop-mode-content-graph__group-by'; - groupBy.setAttribute( 'value', GROUP_NONE ); - groupBy.setAttribute( 'aria-label', __( 'Group by' ) ); - groupBy.title = __( 'Group posts by a shared facet' ); - for ( const [ value, label ] of [ - [ GROUP_NONE, __( 'No grouping' ) ], - [ 'category', __( 'Group by category' ) ], - [ 'tag', __( 'Group by tag' ) ], - [ 'author', __( 'Group by author' ) ], - [ 'year', __( 'Group by year' ) ], - [ 'year_month', __( 'Group by year-month' ) ], - ] as const ) { - const opt = document.createElement( 'wpd-option' ); - opt.setAttribute( 'value', value ); - opt.textContent = label; - groupBy.appendChild( opt ); - } - groupBy.addEventListener( 'wpd-pick', ( ev: Event ) => { - const detail = ( ev as CustomEvent< { value: string } > ).detail; - const raw = detail?.value ?? GROUP_NONE; - const facet: GroupFacet | null = - raw === GROUP_NONE ? null : ( raw as GroupFacet ); - callbacks.onGroupChange( facet ); - } ); - // Sit between chips and search so it lines up with the filter - // chips on the same row. - host.insertBefore( groupBy, searchWrap ); - - const actions = document.createElement( 'div' ); - actions.className = 'desktop-mode-content-graph__actions'; - - const fit = document.createElement( 'button' ); - fit.type = 'button'; - fit.className = 'desktop-mode-content-graph__btn'; - fit.innerHTML = - '' + - `${ escapeHtml( __( 'Fit' ) ) }`; - fit.title = __( 'Fit graph to view' ); - fit.addEventListener( 'click', () => callbacks.onFitToView() ); - actions.appendChild( fit ); - - const status = document.createElement( 'span' ); - status.className = 'desktop-mode-content-graph__toolbar-status'; - actions.appendChild( status ); - - host.appendChild( actions ); - - const onDocClick = ( ev: Event ): void => { - if ( ! searchWrap.contains( ev.target as Node ) ) { - dropdown.hidden = true; - } - }; - document.addEventListener( 'click', onDocClick ); + return searchWrap; +} - return { - setStatus: ( text: string ) => { - status.textContent = text; - }, - destroy: () => { - document.removeEventListener( 'click', onDocClick ); - }, - }; +/** + * Save the user's chosen view to `desktop_mode_content_graph_view` + * user meta. Silent: failures stay client-side (we still respect the + * choice for this session via the in-memory `view` variable). Future + * window opens fall back to the last successful save, or `'graph'`. + */ +async function persistView( + cfg: ContentGraphConfig, + value: ContentGraphView, +): Promise< void > { + const userId = cfg.currentUserId ?? 0; + if ( userId <= 0 ) { + return; + } + try { + await trackedFetch( + joinRestUrl( cfg.restRoot, `wp/v2/users/${ userId }` ), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-WP-Nonce': cfg.restNonce, + }, + body: JSON.stringify( { + meta: { + desktop_mode_content_graph_view: value, + }, + } ), + }, + { source: 'desktop-mode/content-graph', silent: true }, + ); + } catch { + // Non-fatal — in-memory state still reflects the user's choice. + } } function escapeHtml( s: string ): string { diff --git a/src/content-graph/types.ts b/src/content-graph/types.ts index eb8849330..50f4c5e3d 100644 --- a/src/content-graph/types.ts +++ b/src/content-graph/types.ts @@ -51,6 +51,23 @@ export interface GraphNodePayload { year_month: string; category_ids: number[]; tag_ids: number[]; + /** + * Approved comment count on the post. Used by the Galaxy view's + * brightness encoding (`comments + word-count` normalized to 0..1). + */ + comment_count: number; + /** + * Plain-text word count of `post_content` (stripped of shortcodes + * + HTML on the server). Also used for the Galaxy brightness + * encoding. + */ + word_count: number; + /** + * Modified time as a unix timestamp (UTC seconds). Used by the + * Galaxy view's "Recent" tab (`now - 30 days`) and the twinkle + * layer (recently-edited posts sparkle). + */ + modified_ts: number; } export interface GraphEdgePayload { @@ -295,8 +312,28 @@ export interface ContentGraphConfig { */ siteName?: string; postTypes: PostTypeDescriptor[]; + /** + * The view mode the user last chose — `'graph'` or `'galaxy'`. + * Read from `desktop_mode_content_graph_view` user meta. The + * toolbar mounts the matching scene on first render and writes + * back to user meta when the user toggles the segmented control. + */ + lastView?: 'graph' | 'galaxy'; + /** + * Numeric user id, for `POST /wp/v2/users/` persistence calls. + * `0` when no user is logged in (the window's `user_can_use` gate + * prevents this in practice, but the field is typed defensively). + */ + currentUserId?: number; } +/** + * Tab filter for the Galaxy view. `all` shows every loaded node, + * `drafts` filters to `status === 'draft'`, `recent` filters to + * posts modified within the last 30 days. + */ +export type GalaxyTab = 'all' | 'drafts' | 'recent'; + /** * Live in-memory node — the REST payload plus simulation state. * The grouping facets (`author_id`, `year`, `category_ids`, diff --git a/tests/phpunit/tests/contentGraphGroupBy.php b/tests/phpunit/tests/contentGraphGroupBy.php index 750baaaaf..616e5f945 100644 --- a/tests/phpunit/tests/contentGraphGroupBy.php +++ b/tests/phpunit/tests/contentGraphGroupBy.php @@ -301,6 +301,106 @@ public function test_retagging_busts_cache() { ); } + public function test_galaxy_payload_fields_populated() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$author_a_id, + 'post_status' => 'publish', + 'post_type' => 'post', + 'post_content' => 'one two three four five six seven eight nine ten', + 'post_date' => '2024-03-15 10:00:00', + ) + ); + // Add a comment so comment_count is exercised. Approved so + // `wp_update_comment_count_now` increments the post column. + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => '1', + ) + ); + + $payload = desktop_mode_content_graph_build( array( 'post' ) ); + $node = $this->find_node( $payload, $post_id ); + + $this->assertArrayHasKey( 'comment_count', $node ); + $this->assertSame( 1, (int) $node['comment_count'] ); + $this->assertArrayHasKey( 'word_count', $node ); + $this->assertGreaterThanOrEqual( 10, (int) $node['word_count'] ); + $this->assertArrayHasKey( 'modified_ts', $node ); + $this->assertGreaterThan( 0, (int) $node['modified_ts'] ); + } + + public function test_drafts_scoped_to_current_user() { + $own_draft = self::factory()->post->create( + array( + 'post_author' => self::$author_a_id, + 'post_status' => 'draft', + 'post_type' => 'post', + ) + ); + $other_draft = self::factory()->post->create( + array( + 'post_author' => self::$author_b_id, + 'post_status' => 'draft', + 'post_type' => 'post', + ) + ); + + wp_set_current_user( self::$author_a_id ); + $payload = desktop_mode_content_graph_build( array( 'post' ) ); + + $this->assertTrue( + $this->has_node( $payload, $own_draft ), + 'The current user\'s own draft must be in the payload.' + ); + $this->assertFalse( + $this->has_node( $payload, $other_draft ), + 'Another user\'s draft must never surface in the payload.' + ); + } + + public function test_cache_key_buckets_by_user() { + wp_set_current_user( self::$author_a_id ); + $key_a = desktop_mode_content_graph_cache_key( array( 'post' ) ); + wp_set_current_user( self::$author_b_id ); + $key_b = desktop_mode_content_graph_cache_key( array( 'post' ) ); + + $this->assertNotSame( + $key_a, + $key_b, + 'Cache keys must differ per user so one user\'s drafts never serve from another\'s cached payload.' + ); + } + + public function test_fresh_draft_gets_nonzero_modified_ts() { + wp_set_current_user( self::$author_a_id ); + $draft_id = self::factory()->post->create( + array( + 'post_author' => self::$author_a_id, + 'post_status' => 'draft', + 'post_type' => 'post', + ) + ); + // Precondition: WordPress leaves the GMT modified column as a + // zero-date on a never-updated draft — the exact case the + // `post_date` fallback in the builder exists for. + $this->assertSame( + '0000-00-00 00:00:00', + get_post( $draft_id )->post_modified_gmt, + 'Expected a fresh draft to carry a zero-date post_modified_gmt.' + ); + + $payload = desktop_mode_content_graph_build( array( 'post' ) ); + $node = $this->find_node( $payload, $draft_id ); + + $this->assertGreaterThan( + 0, + (int) $node['modified_ts'], + 'Fresh drafts must fall back to post_date so the Recent tab and twinkle layer see them.' + ); + } + public function test_post_types_normalizes_legacy_filtered_descriptors() { $filter_callback = function( $types ) { // Use a slug that is NOT in the default list — the built-in @@ -346,4 +446,18 @@ protected function find_node( $payload, $post_id ) { } $this->fail( "No node for post {$post_id} in payload." ); } + + /** + * @param array $payload + * @param int $post_id + * @return bool + */ + protected function has_node( $payload, $post_id ) { + foreach ( $payload['nodes'] as $node ) { + if ( (int) $node['id'] === (int) $post_id ) { + return true; + } + } + return false; + } } diff --git a/tests/vitest/content-graph-galaxy.test.ts b/tests/vitest/content-graph-galaxy.test.ts new file mode 100644 index 000000000..86aeb2ead --- /dev/null +++ b/tests/vitest/content-graph-galaxy.test.ts @@ -0,0 +1,138 @@ +/** + * Unit tests for the pure encoding helpers behind the Galaxy view. + * The scene-level Pixi rendering is left to manual verification, but + * the brightness curve and the tab filter predicate are pure and + * stable enough to lock in here. + */ +import { describe, expect, test } from 'vitest'; +import { + dotBrightness, + galaxyTabFilter, +} from '../../src/content-graph/galaxy-encodings'; + +describe( 'dotBrightness', () => { + test( 'returns 0 for a silent zero-length post', () => { + expect( dotBrightness( 0, 0 ) ).toBe( 0 ); + } ); + + test( 'is clamped at 1 even for outliers', () => { + expect( dotBrightness( 100_000, 100_000_000 ) ).toBe( 1 ); + } ); + + test( 'is monotonic in comment count when words held fixed', () => { + const a = dotBrightness( 0, 1000 ); + const b = dotBrightness( 5, 1000 ); + const c = dotBrightness( 50, 1000 ); + expect( b ).toBeGreaterThan( a ); + expect( c ).toBeGreaterThan( b ); + } ); + + test( 'is monotonic in word count when comments held fixed', () => { + const a = dotBrightness( 5, 0 ); + const b = dotBrightness( 5, 500 ); + const c = dotBrightness( 5, 5000 ); + expect( b ).toBeGreaterThan( a ); + expect( c ).toBeGreaterThan( b ); + } ); + + test( 'is log-scaled so an outlier word count does not pin to 1', () => { + // A 1000-word post with 0 comments should land well under + // the brightness of a 2000-word post with 50 comments. + const lone = dotBrightness( 0, 1000 ); + const balanced = dotBrightness( 50, 2000 ); + expect( balanced ).toBeGreaterThan( lone ); + expect( lone ).toBeLessThan( 0.6 ); + } ); +} ); + +describe( 'galaxyTabFilter', () => { + const now = 1_700_000_000; // arbitrary fixed clock + const window = 30 * 24 * 3600; + + function node( opts: { + status?: string; + modified?: number; + comments?: number; + } = {} ) { + return { + status: opts.status ?? 'publish', + modified_ts: opts.modified ?? now - window * 5, // very old + comment_count: opts.comments ?? 0, + }; + } + + test( 'all tab passes any node above min comments', () => { + expect( + galaxyTabFilter( node(), 'all', 0, now, window ), + ).toBe( true ); + expect( + galaxyTabFilter( node( { comments: 3 } ), 'all', 5, now, window ), + ).toBe( false ); + expect( + galaxyTabFilter( node( { comments: 5 } ), 'all', 5, now, window ), + ).toBe( true ); + } ); + + test( 'drafts tab restricts to status="draft"', () => { + expect( + galaxyTabFilter( + node( { status: 'publish' } ), + 'drafts', + 0, + now, + window, + ), + ).toBe( false ); + expect( + galaxyTabFilter( + node( { status: 'draft' } ), + 'drafts', + 0, + now, + window, + ), + ).toBe( true ); + } ); + + test( 'recent tab includes posts modified within the window', () => { + expect( + galaxyTabFilter( + node( { modified: now - window / 2 } ), + 'recent', + 0, + now, + window, + ), + ).toBe( true ); + expect( + galaxyTabFilter( + node( { modified: now - window - 1 } ), + 'recent', + 0, + now, + window, + ), + ).toBe( false ); + } ); + + test( 'min comments gate is applied across all tabs', () => { + expect( + galaxyTabFilter( + node( { status: 'draft', comments: 2 } ), + 'drafts', + 5, + now, + window, + ), + ).toBe( false ); + expect( + galaxyTabFilter( + node( { modified: now, comments: 0 } ), + 'recent', + 1, + now, + window, + ), + ).toBe( false ); + } ); +} ); diff --git a/tests/vitest/content-graph-sim.test.ts b/tests/vitest/content-graph-sim.test.ts index e71535f2f..8f6d7a07d 100644 --- a/tests/vitest/content-graph-sim.test.ts +++ b/tests/vitest/content-graph-sim.test.ts @@ -25,6 +25,9 @@ function makeNode( id: number, x: number, y: number ): GraphNode { year_month: '2024-01', category_ids: [], tag_ids: [], + comment_count: 0, + word_count: 0, + modified_ts: 0, x, y, vx: 0,