(function() { "use strict"; const TEXT_DOMAIN = "desktop-mode"; function i18n() { return window.wp?.i18n; } function __(text, domain = TEXT_DOMAIN) { return i18n()?.__(text, domain) ?? text; } function sprintf(format, ...args) { const impl = i18n()?.sprintf; if (impl) { return impl(format, ...args); } let i = 0; return format.replace(/%[sd]/g, () => String(args[i++] ?? "")); } const FALLBACK_BASE = "http://localhost/"; function joinRestUrl(restRoot, path) { const base = typeof window !== "undefined" && window.location ? window.location.href : FALLBACK_BASE; const url = new URL(restRoot, base); const trimmed = path.replace(/^\/+/, ""); const queryAt = trimmed.indexOf("?"); const route = queryAt === -1 ? trimmed : trimmed.slice(0, queryAt); const extraQuery = queryAt === -1 ? "" : trimmed.slice(queryAt + 1); if (url.searchParams.has("rest_route")) { const existing = url.searchParams.get("rest_route") ?? "/"; const prefix = existing.endsWith("/") ? existing : existing + "/"; url.searchParams.set("rest_route", prefix + route); } else { const pathname = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/"; url.pathname = pathname + route; } if (extraQuery) { const extras = new URLSearchParams(extraQuery); extras.forEach((value, key) => { url.searchParams.append(key, value); }); } return url.toString(); } const NONCE_HEADER = "X-WP-Nonce"; function injectRestNonce(input, init) { const nonce = readRestNonce(); if (!nonce) { return init; } const url = resolveUrl(input); if (!url || !isSameOriginRestUrl(url)) { return init; } const baseHeaders = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0); const headers = new Headers(baseHeaders ?? {}); if (headers.has(NONCE_HEADER)) { return init; } headers.set(NONCE_HEADER, nonce); return { ...init ?? {}, headers }; } function readRestNonce() { if (typeof window === "undefined") { return void 0; } const cfg = window.desktopModeConfig; const value = cfg?.restNonce; return typeof value === "string" && value.length > 0 ? value : void 0; } function resolveUrl(input) { try { const base = typeof window !== "undefined" && window.location ? window.location.href : void 0; if (typeof input === "string") { return new URL(input, base); } if (input instanceof URL) { return input; } if (typeof Request !== "undefined" && input instanceof Request) { return new URL(input.url, base); } return null; } catch { return null; } } function isSameOriginRestUrl(url) { if (typeof window === "undefined" || !window.location || url.origin !== window.location.origin) { return false; } if (url.pathname.includes("/wp-json/")) { return true; } if (url.searchParams.has("rest_route")) { return true; } return false; } function trackedFetch(input, init, opts = {}) { const fn = window.wp?.desktop?.fetch; if (typeof fn === "function") { return fn(input, init, opts); } const finalInit = injectRestNonce(input, init); return fetch(input, finalInit); } const WINDOW_ID$1 = "desktop-mode-content-graph"; const SOURCE = "desktop-mode/content-graph"; function getConfig() { const map = window.desktopModeWindowConfig ?? {}; const cfg = map[WINDOW_ID$1]; if (!cfg) { throw new Error( "Content Graph config missing — desktop_mode_register_window args lost in transit." ); } return cfg; } function authHeaders(cfg) { return { Accept: "application/json", "X-WP-Nonce": cfg.restNonce }; } async function fetchPostTypes(cfg) { const res = await trackedFetch( `${cfg.apiBase}/post-types`, { headers: authHeaders(cfg) }, { source: SOURCE, windowId: WINDOW_ID$1 } ); if (!res.ok) { throw new Error(`post-types: ${res.status}`); } return await res.json(); } async function fetchGraph(cfg, types) { const url = new URL(`${cfg.apiBase}/nodes`); if (types.length > 0) { url.searchParams.set("types", types.join(",")); } const res = await trackedFetch( url.toString(), { headers: authHeaders(cfg) }, { source: SOURCE, windowId: WINDOW_ID$1 } ); if (!res.ok) { throw new Error(`nodes: ${res.status}`); } return await res.json(); } async function fetchPostDetail(cfg, id) { const res = await trackedFetch( `${cfg.apiBase}/post/${id}`, { headers: authHeaders(cfg) }, { source: SOURCE, windowId: WINDOW_ID$1 } ); if (!res.ok) { throw new Error(`post/${id}: ${res.status}`); } return await res.json(); } async function fetchUserStats(cfg, userId) { const res = await trackedFetch( joinRestUrl(cfg.restRoot, `desktop-mode/v1/user-stats/${userId}`), { headers: authHeaders(cfg) }, { source: SOURCE, windowId: WINDOW_ID$1 } ); if (!res.ok) { throw new Error(`user-stats/${userId}: ${res.status}`); } return await res.json(); } async function fetchTermStats(cfg, taxonomy, termId) { const res = await trackedFetch( joinRestUrl( cfg.restRoot, `desktop-mode/v1/term-stats/${encodeURIComponent(taxonomy)}/${termId}` ), { headers: authHeaders(cfg) }, { source: SOURCE, windowId: WINDOW_ID$1 } ); if (!res.ok) { throw new Error( `term-stats/${taxonomy}/${termId}: ${res.status}` ); } return await res.json(); } async function fetchCommentStats(cfg, commentId) { const res = await trackedFetch( joinRestUrl(cfg.restRoot, `desktop-mode/v1/comment-stats/${commentId}`), { headers: authHeaders(cfg) }, { source: SOURCE, windowId: WINDOW_ID$1 } ); if (!res.ok) { throw new Error(`comment-stats/${commentId}: ${res.status}`); } return await res.json(); } const GROUP_NONE = "none"; function renderToolbar(host, postTypes, callbacks) { host.replaceChildren(); const active = new Set(postTypes.map((t) => t.slug)); const chipsRow = document.createElement("div"); chipsRow.className = "desktop-mode-content-graph__filters"; host.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.dataset.slug = type.slug; chip.innerHTML = `${escapeHtml$2(type.label)}${type.count}`; chip.addEventListener("click", () => { if (active.has(type.slug)) { active.delete(type.slug); chip.classList.remove("is-active"); } else { active.add(type.slug); chip.classList.add("is-active"); } callbacks.onTypesChange(Array.from(active)); }); chipsRow.appendChild(chip); } const searchWrap = document.createElement("div"); searchWrap.className = "desktop-mode-content-graph__search"; const searchInput = document.createElement("input"); searchInput.type = "search"; searchInput.className = "desktop-mode-content-graph__search-input"; searchInput.placeholder = __("Search nodes…"); searchInput.setAttribute( "aria-label", __("Search posts and pages in the graph") ); searchWrap.appendChild(searchInput); const dropdown = document.createElement("ul"); dropdown.className = "desktop-mode-content-graph__search-results"; dropdown.hidden = true; searchWrap.appendChild(dropdown); host.appendChild(searchWrap); const handleSearchInput = () => { const q = searchInput.value.trim().toLowerCase(); if (q.length === 0) { dropdown.hidden = true; dropdown.replaceChildren(); return; } const matches = callbacks.getNodes().filter((n) => n.title.toLowerCase().includes(q)).slice(0, 10); dropdown.replaceChildren(); for (const m of matches) { const li = document.createElement("li"); const btn = document.createElement("button"); btn.type = "button"; btn.className = "desktop-mode-content-graph__search-result"; btn.innerHTML = `${escapeHtml$2(m.title || "#" + m.id)}${escapeHtml$2(m.type)}`; btn.addEventListener("click", () => { searchInput.value = ""; dropdown.hidden = true; dropdown.replaceChildren(); callbacks.onSearchSelect(m); }); li.appendChild(btn); dropdown.appendChild(li); } dropdown.hidden = matches.length === 0; }; searchInput.addEventListener("input", handleSearchInput); searchInput.addEventListener("focus", handleSearchInput); searchInput.addEventListener("blur", () => { setTimeout(() => { dropdown.hidden = true; }, 120); }); 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")] ]) { const opt = document.createElement("wpd-option"); opt.setAttribute("value", value); opt.textContent = label; groupBy.appendChild(opt); } groupBy.addEventListener("wpd-pick", (ev) => { const detail = ev.detail; const raw = detail?.value ?? GROUP_NONE; const facet = raw === GROUP_NONE ? null : raw; callbacks.onGroupChange(facet); }); 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$2(__("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) => { if (!searchWrap.contains(ev.target)) { dropdown.hidden = true; } }; document.addEventListener("click", onDocClick); return { setStatus: (text) => { status.textContent = text; }, destroy: () => { document.removeEventListener("click", onDocClick); } }; } function escapeHtml$2(s) { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function escapeAttr$1(s) { return s.replace(/[^a-zA-Z0-9 _\-]/g, ""); } function renderPanel(host, cfg, callbacks) { host.replaceChildren(); host.hidden = false; host.classList.add("desktop-mode-content-graph__panel--closed"); const setPanelOpen = (open) => { host.classList.toggle( "desktop-mode-content-graph__panel--closed", !open ); }; let currentPost = null; let currentView = { kind: "post" }; let fetchSeq = 0; const frame = document.createElement("div"); frame.className = "desktop-mode-content-graph__panel-frame"; const breadcrumbHost = document.createElement("nav"); breadcrumbHost.className = "desktop-mode-content-graph__panel-breadcrumb"; breadcrumbHost.hidden = true; const head = document.createElement("header"); head.className = "desktop-mode-content-graph__panel-head"; const titleWrap = document.createElement("div"); titleWrap.className = "desktop-mode-content-graph__panel-title-wrap"; const title = document.createElement("h2"); title.className = "desktop-mode-content-graph__panel-title"; const meta = document.createElement("p"); meta.className = "desktop-mode-content-graph__panel-meta"; titleWrap.appendChild(title); titleWrap.appendChild(meta); const closeBtn = document.createElement("button"); closeBtn.type = "button"; closeBtn.className = "desktop-mode-content-graph__panel-close"; closeBtn.innerHTML = ''; closeBtn.title = __("Close panel"); closeBtn.addEventListener("click", () => callbacks.onClose()); head.appendChild(titleWrap); head.appendChild(closeBtn); const body = document.createElement("div"); body.className = "desktop-mode-content-graph__panel-body"; frame.appendChild(breadcrumbHost); frame.appendChild(head); frame.appendChild(body); host.appendChild(frame); let currentPage = createPage(); body.append(currentPage); let prevViewKind = null; let pageSeq = 0; function createPage() { const p = document.createElement("div"); p.className = "desktop-mode-content-graph__panel-page"; return p; } const swapPage = (prev, next, direction) => { body.append(next); if (prev === next || direction === "none") { if (prev !== next) { prev.remove(); } return; } const enter = direction === "forward" ? "page-from-right" : "page-from-left"; const exit = direction === "forward" ? "page-to-left" : "page-to-right"; next.classList.add(`desktop-mode-content-graph__${enter}`); void next.offsetWidth; const mySeq = ++pageSeq; requestAnimationFrame(() => { if (mySeq !== pageSeq) { return; } next.classList.remove( `desktop-mode-content-graph__${enter}` ); prev.classList.add( `desktop-mode-content-graph__${exit}` ); }); let cleaned = false; const cleanup = () => { if (cleaned) { return; } cleaned = true; prev.removeEventListener("transitionend", cleanup); prev.remove(); }; prev.addEventListener("transitionend", cleanup); setTimeout(cleanup, 360); }; const keyForView = (v) => { switch (v.kind) { case "post": return null; case "user": return `user:${v.user.id}`; case "term": return `term:${v.term.taxonomy}:${v.term.id}`; case "comment": return `comment:${v.comment.id}`; case "media": return `media:${v.media.id}`; case "revision": return `revision:${v.revision.id}`; } }; const desktopApi = () => { const wp = window.wp ?? {}; return wp.desktop ?? {}; }; const openAdminUrl = (href, labelText, icon, windowKey) => { const api = desktopApi(); if (!api.windowManager || !api.deriveWindowId || !href) { if (href) { window.location.href = href; } return; } const focused = api.windowManager.getFocused?.(); if (focused?.isFullscreen?.()) { focused.toggleFullscreen?.(); } let id = api.deriveWindowId(href); if (windowKey) { id = `${id}-${windowKey}`; } api.windowManager.open({ id, baseId: id, url: href, title: labelText, icon }); }; const renderBreadcrumb = () => { breadcrumbHost.replaceChildren(); if (currentView.kind === "post" || !currentPost) { breadcrumbHost.hidden = true; return; } breadcrumbHost.hidden = false; const back = document.createElement("button"); back.type = "button"; back.className = "desktop-mode-content-graph__panel-breadcrumb-back"; const arrow = document.createElement("span"); arrow.className = "dashicons dashicons-arrow-left-alt2 desktop-mode-content-graph__panel-breadcrumb-arrow"; arrow.setAttribute("aria-hidden", "true"); const backLabel = document.createElement("span"); backLabel.className = "desktop-mode-content-graph__panel-breadcrumb-label"; backLabel.textContent = currentPost.post.title || `#${currentPost.post.id}`; back.appendChild(arrow); back.appendChild(backLabel); back.title = __("Back to post"); back.addEventListener("click", () => { currentView = { kind: "post" }; renderCurrent(); }); breadcrumbHost.appendChild(back); }; const renderCurrent = () => { setPanelOpen(true); renderBreadcrumb(); const next = createPage(); const prev = currentPage; currentPage = next; switch (currentView.kind) { case "post": renderPostView(currentPost); break; case "user": renderUserView(currentView); break; case "term": renderTermView(currentView); break; case "comment": renderCommentView(currentView); break; case "media": renderMediaView(currentView.media); break; case "revision": renderRevisionView(currentView.revision); break; } let direction = "none"; if (prevViewKind !== null && prevViewKind !== currentView.kind) { if (currentView.kind === "post") { direction = "back"; } else { direction = "forward"; } } swapPage(prev, next, direction); callbacks.onViewChange?.(keyForView(currentView)); prevViewKind = currentView.kind; }; const renderPostView = (detail) => { if (!detail) { title.textContent = ""; meta.textContent = ""; return; } title.textContent = detail.post.title || `#${detail.post.id}`; meta.textContent = `${detail.post.type} · ${detail.post.status}`; currentPage.appendChild(renderAuthorBlock(detail)); currentPage.appendChild(renderDatesBlock(detail)); currentPage.appendChild(renderStatsGrid([ { label: __("Contributors"), value: detail.contributors.length }, { label: __("Comments"), value: detail.comments.length }, { label: __("Taxonomies"), value: detail.categories.length }, { label: __("Media"), value: detail.attached_media.length }, { label: __("Revisions"), value: detail.revisions.length } ])); currentPage.appendChild(renderPostActionsBlock(detail)); }; const renderAuthorBlock = (detail) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-author"; if (!detail.author) { wrap.hidden = true; return wrap; } const label = document.createElement("span"); label.className = "desktop-mode-content-graph__panel-section-label"; label.textContent = __("Author"); wrap.appendChild(label); const row = document.createElement("div"); row.className = "desktop-mode-content-graph__panel-author-row"; if (detail.author.avatar) { const img = document.createElement("img"); img.className = "desktop-mode-content-graph__panel-avatar"; img.src = detail.author.avatar; img.alt = ""; row.appendChild(img); } const name = document.createElement("span"); name.className = "desktop-mode-content-graph__panel-author-name"; name.textContent = detail.author.name; row.appendChild(name); wrap.appendChild(row); return wrap; }; const renderDatesBlock = (detail) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-dates"; const items = []; if (detail.post.date) { items.push({ label: __("Published"), iso: detail.post.date }); } if (detail.post.modified && detail.post.modified !== detail.post.date) { items.push({ label: __("Modified"), iso: detail.post.modified }); } if (items.length === 0) { wrap.hidden = true; return wrap; } for (const it of items) { const row = document.createElement("div"); row.className = "desktop-mode-content-graph__panel-date-row"; const labelEl = document.createElement("span"); labelEl.className = "desktop-mode-content-graph__panel-section-label"; labelEl.textContent = it.label; const valueEl = document.createElement("span"); valueEl.className = "desktop-mode-content-graph__panel-date-value"; valueEl.textContent = formatDate$1(it.iso); row.appendChild(labelEl); row.appendChild(valueEl); wrap.appendChild(row); } return wrap; }; const renderStatsGrid = (entries) => { const ul = document.createElement("ul"); ul.className = "desktop-mode-content-graph__panel-stats"; for (const e of entries) { const li = document.createElement("li"); li.className = "desktop-mode-content-graph__panel-stat"; const num = document.createElement("span"); num.className = "desktop-mode-content-graph__panel-stat-num"; num.textContent = typeof e.value === "number" ? formatNumber(e.value) : e.value; const labelEl = document.createElement("span"); labelEl.className = "desktop-mode-content-graph__panel-stat-label"; labelEl.textContent = e.label; li.appendChild(num); li.appendChild(labelEl); ul.appendChild(li); } return ul; }; const renderPostActionsBlock = (detail) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-actions"; const api = desktopApi(); if (api.myWordpress) { const myWp = button({ label: __("Open in My WordPress"), icon: "dashicons-wordpress", primary: true }); myWp.addEventListener("click", () => { const entityId = detail.post.type === "page" ? "pages" : "posts"; api.myWordpress.openDetail({ entityId, postId: detail.post.id, postTitle: detail.post.title || `#${detail.post.id}` }); }); wrap.appendChild(myWp); } if (detail.post.edit_url) { const edit = button({ label: __("Edit"), icon: "dashicons-edit" }); edit.addEventListener( "click", () => openAdminUrl( detail.post.edit_url, detail.post.title, "dashicons-admin-post", `post-${detail.post.id}` ) ); wrap.appendChild(edit); } if (detail.post.view_url) { const view = button({ label: __("View"), icon: "dashicons-external" }); view.addEventListener( "click", () => openAdminUrl( detail.post.view_url, detail.post.title, "dashicons-admin-post", `post-${detail.post.id}` ) ); wrap.appendChild(view); } return wrap; }; const renderUserView = (view) => { const { user, role, stats, loading } = view; title.textContent = stats?.profile.name ?? user.name; meta.textContent = role === "author" ? __("Author") : __("Contributor"); const head2 = document.createElement("div"); head2.className = "desktop-mode-content-graph__panel-detail-head"; const avatar = stats?.profile.avatarUrl || user.avatar; if (avatar) { const img = document.createElement("img"); img.className = "desktop-mode-content-graph__panel-detail-avatar desktop-mode-content-graph__panel-detail-avatar--lg"; img.src = avatar; img.alt = ""; head2.appendChild(img); } const handleEl = document.createElement("div"); handleEl.className = "desktop-mode-content-graph__panel-detail-handle"; handleEl.innerHTML = `${escapeHtml$1(stats?.profile.name ?? user.name)}@${escapeHtml$1(user.slug)}`; head2.appendChild(handleEl); currentPage.appendChild(head2); if (stats?.profile.roleLabels?.length) { currentPage.appendChild( renderBadges( __("Roles"), stats.profile.roleLabels ) ); } if (stats?.profile.description) { currentPage.appendChild( renderProse(__("About"), stats.profile.description) ); } if (stats?.profile.website) { currentPage.appendChild( renderLinkRow( __("Website"), stats.profile.website, stats.profile.website ) ); } if (stats) { const cs = stats.counts; currentPage.appendChild( renderStatsGrid([ { label: __("Posts"), value: cs.posts.total }, { label: __("Pages"), value: cs.pages.total }, { label: __("CPT"), value: cs.cpt }, { label: __("Comments received"), value: cs.commentsReceived }, { label: __("Comments left"), value: cs.commentsLeft } ]) ); } if (stats?.topTerms?.length) { currentPage.appendChild( renderTopTerms(__("Top topics"), stats.topTerms) ); } if (stats) { currentPage.appendChild( renderMilestones([ { label: __("First published"), iso: stats.milestones.firstPublished }, { label: __("Last published"), iso: stats.milestones.lastPublished } ]) ); } if (loading) { currentPage.appendChild(renderLoadingRow()); } currentPage.appendChild( renderActionRow({ label: __("Open in WordPress"), icon: "dashicons-admin-users", href: user.edit_url, title: user.name, primary: true, windowKey: `user-${user.id}` }) ); }; const renderTermView = (view) => { const { term, stats, loading } = view; title.textContent = stats?.profile.name ?? term.name; meta.textContent = `${stats?.profile.taxonomyLabel ?? term.tax_label} · ${stats?.profile.taxonomy ?? term.taxonomy}`; if (stats?.profile.parentName) { currentPage.appendChild( renderInlineMeta([ { label: __("Parent"), value: stats.profile.parentName } ]) ); } if (stats?.profile.description) { currentPage.appendChild( renderProse(__("Description"), stats.profile.description) ); } if (stats) { currentPage.appendChild( renderStatsGrid([ { label: __("Posts"), value: stats.counts.posts.total }, { label: __("Comments"), value: stats.counts.commentsReceived }, { label: __("Authors"), value: stats.counts.distinctAuthors } ]) ); } else { currentPage.appendChild( renderStatsGrid([ { label: __("Posts"), value: term.count } ]) ); } if (stats?.topAuthors?.length) { currentPage.appendChild( renderTopAuthors(__("Top authors"), stats.topAuthors) ); } if (stats?.coTerms?.length) { currentPage.appendChild( renderTopTerms( __("Co-occurring"), stats.coTerms.map((c) => ({ ...c, taxonomy: term.taxonomy })) ) ); } if (stats) { currentPage.appendChild( renderMilestones([ { label: __("First post"), iso: stats.milestones.firstPosted }, { label: __("Latest post"), iso: stats.milestones.lastPosted } ]) ); } if (loading) { currentPage.appendChild(renderLoadingRow()); } currentPage.appendChild( renderActionRow({ label: __("Open in WordPress"), icon: "dashicons-tag", href: term.edit_url, title: term.name, primary: true, windowKey: `term-${term.taxonomy}-${term.id}` }) ); }; const renderCommentView = (view) => { const { comment, stats, loading } = view; const authorName = stats?.author.name ?? comment.author; title.textContent = authorName || `#${comment.id}`; meta.textContent = formatDate$1(stats?.comment.date ?? comment.date); const avatar = stats?.author.avatarUrl; if (avatar) { const head2 = document.createElement("div"); head2.className = "desktop-mode-content-graph__panel-detail-head"; const img = document.createElement("img"); img.className = "desktop-mode-content-graph__panel-detail-avatar desktop-mode-content-graph__panel-detail-avatar--lg"; img.src = avatar; img.alt = ""; head2.appendChild(img); const handleEl = document.createElement("div"); handleEl.className = "desktop-mode-content-graph__panel-detail-handle"; handleEl.innerHTML = `${escapeHtml$1(authorName)}` + (stats?.author.totalApprovedComments ? `${formatNumber( stats.author.totalApprovedComments )} ${escapeHtml$1(__("comments"))}` : ""); head2.appendChild(handleEl); currentPage.appendChild(head2); } const status = stats?.comment.status ?? __("—"); currentPage.appendChild( renderBadges(__("Status"), [status], { accent: status === "1" || status === "approved" ? "green" : "amber" }) ); const content = stats?.comment.rendered; if (content) { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-detail-section"; const label = document.createElement("span"); label.className = "desktop-mode-content-graph__panel-section-label"; label.textContent = __("Comment"); wrap.appendChild(label); const html = document.createElement("div"); html.className = "desktop-mode-content-graph__panel-detail-html"; html.innerHTML = content; wrap.appendChild(html); currentPage.appendChild(wrap); } else if (comment.excerpt) { currentPage.appendChild( renderProse(__("Comment"), comment.excerpt) ); } if (stats?.parent) { const wrap = document.createElement("blockquote"); wrap.className = "desktop-mode-content-graph__panel-detail-quote"; wrap.innerHTML = `${escapeHtml$1(stats.parent.authorName)}${escapeHtml$1(stats.parent.excerpt)}`; currentPage.appendChild(wrap); } if (stats?.replies?.length) { currentPage.appendChild(renderReplies(stats.replies)); } if (loading) { currentPage.appendChild(renderLoadingRow()); } currentPage.appendChild( renderActionRow({ label: __("Open in WordPress"), icon: "dashicons-admin-comments", href: comment.edit_url, title: authorName || __("Comment"), primary: true, windowKey: `comment-${comment.id}` }) ); }; const renderMediaView = (media) => { title.textContent = media.title || `#${media.id}`; meta.textContent = media.mime || __("Media"); if (media.thumb) { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-detail-thumb"; const img = document.createElement("img"); img.src = media.thumb; img.alt = media.title || ""; wrap.appendChild(img); currentPage.appendChild(wrap); } currentPage.appendChild( renderInlineMeta([ { label: __("Type"), value: media.mime } ]) ); currentPage.appendChild( renderActionRow({ label: __("Open in WordPress"), icon: "dashicons-admin-media", href: media.edit_url, title: media.title, primary: true, windowKey: `media-${media.id}` }) ); }; const renderRevisionView = (revision) => { title.textContent = revision.author?.name ?? __("Revision"); meta.textContent = formatDate$1(revision.date); if (revision.author) { const head2 = document.createElement("div"); head2.className = "desktop-mode-content-graph__panel-detail-head"; if (revision.author.avatar) { const img = document.createElement("img"); img.className = "desktop-mode-content-graph__panel-detail-avatar desktop-mode-content-graph__panel-detail-avatar--lg"; img.src = revision.author.avatar; img.alt = ""; head2.appendChild(img); } const handleEl = document.createElement("div"); handleEl.className = "desktop-mode-content-graph__panel-detail-handle"; handleEl.innerHTML = `${escapeHtml$1(revision.author.name)}@${escapeHtml$1(revision.author.slug)}`; head2.appendChild(handleEl); currentPage.appendChild(head2); } currentPage.appendChild( renderInlineMeta([ { label: __("Saved"), value: formatDate$1(revision.date) } ]) ); currentPage.appendChild( renderActionRow({ label: __("Open revision in WordPress"), icon: "dashicons-backup", href: revision.edit_url, title: __("Revision"), primary: true, windowKey: `revision-${revision.id}` }) ); }; const renderProse = (label, text) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-detail-section"; const labelEl = document.createElement("span"); labelEl.className = "desktop-mode-content-graph__panel-section-label"; labelEl.textContent = label; const p = document.createElement("p"); p.className = "desktop-mode-content-graph__panel-detail-prose"; p.textContent = text; wrap.appendChild(labelEl); wrap.appendChild(p); return wrap; }; const renderBadges = (label, items, opts = {}) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-detail-section"; const labelEl = document.createElement("span"); labelEl.className = "desktop-mode-content-graph__panel-section-label"; labelEl.textContent = label; wrap.appendChild(labelEl); const list = document.createElement("div"); list.className = "desktop-mode-content-graph__panel-badges"; for (const it of items) { const badge = document.createElement("span"); badge.className = "desktop-mode-content-graph__panel-badge" + (opts.accent ? ` desktop-mode-content-graph__panel-badge--${opts.accent}` : ""); badge.textContent = it; list.appendChild(badge); } wrap.appendChild(list); return wrap; }; const renderInlineMeta = (items) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-inline-meta"; for (const it of items) { if (!it.value) { continue; } const row = document.createElement("div"); row.className = "desktop-mode-content-graph__panel-inline-meta-row"; const labelEl = document.createElement("span"); labelEl.className = "desktop-mode-content-graph__panel-section-label"; labelEl.textContent = it.label; const valueEl = document.createElement("span"); valueEl.className = "desktop-mode-content-graph__panel-date-value"; valueEl.textContent = it.value; row.appendChild(labelEl); row.appendChild(valueEl); wrap.appendChild(row); } return wrap; }; const renderLinkRow = (label, text, href) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-detail-section"; const labelEl = document.createElement("span"); labelEl.className = "desktop-mode-content-graph__panel-section-label"; labelEl.textContent = label; const link = document.createElement("a"); link.className = "desktop-mode-content-graph__panel-detail-link"; link.href = href; link.textContent = text; link.target = "_blank"; link.rel = "noopener noreferrer"; wrap.appendChild(labelEl); wrap.appendChild(link); return wrap; }; const renderTopTerms = (label, terms) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-detail-section"; const labelEl = document.createElement("span"); labelEl.className = "desktop-mode-content-graph__panel-section-label"; labelEl.textContent = label; wrap.appendChild(labelEl); const list = document.createElement("ul"); list.className = "desktop-mode-content-graph__panel-chips"; for (const t of terms.slice(0, 8)) { const li = document.createElement("li"); li.className = "desktop-mode-content-graph__panel-chip"; li.innerHTML = `${escapeHtml$1(t.name)}${formatNumber( t.count )}`; list.appendChild(li); } wrap.appendChild(list); return wrap; }; const renderTopAuthors = (label, authors) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-detail-section"; const labelEl = document.createElement("span"); labelEl.className = "desktop-mode-content-graph__panel-section-label"; labelEl.textContent = label; wrap.appendChild(labelEl); const list = document.createElement("ul"); list.className = "desktop-mode-content-graph__panel-author-list"; for (const a of authors.slice(0, 6)) { const li = document.createElement("li"); li.className = "desktop-mode-content-graph__panel-author-list-row"; li.innerHTML = (a.userAvatarUrl ? `` : "") + `${escapeHtml$1( a.userName )}${formatNumber( a.count )}`; list.appendChild(li); } wrap.appendChild(list); return wrap; }; const renderMilestones = (entries) => { const filtered = entries.filter((e) => e.iso); const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-detail-section"; if (filtered.length === 0) { wrap.hidden = true; return wrap; } const labelEl = document.createElement("span"); labelEl.className = "desktop-mode-content-graph__panel-section-label"; labelEl.textContent = __("Milestones"); wrap.appendChild(labelEl); const list = document.createElement("div"); list.className = "desktop-mode-content-graph__panel-milestones"; for (const e of filtered) { const row = document.createElement("div"); row.className = "desktop-mode-content-graph__panel-milestone-row"; const k = document.createElement("span"); k.className = "desktop-mode-content-graph__panel-milestone-key"; k.textContent = e.label; const v = document.createElement("span"); v.className = "desktop-mode-content-graph__panel-date-value"; v.textContent = formatDate$1(e.iso); row.appendChild(k); row.appendChild(v); list.appendChild(row); } wrap.appendChild(list); return wrap; }; const renderReplies = (replies) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-detail-section"; const labelEl = document.createElement("span"); labelEl.className = "desktop-mode-content-graph__panel-section-label"; labelEl.textContent = sprintf( /* translators: %d: number of comment replies. */ __("Replies (%d)"), replies.length ); wrap.appendChild(labelEl); const list = document.createElement("ul"); list.className = "desktop-mode-content-graph__panel-replies"; for (const r of replies.slice(0, 5)) { const li = document.createElement("li"); li.className = "desktop-mode-content-graph__panel-reply"; li.innerHTML = `
${escapeHtml$1(r.authorName)}${formatDate$1(r.date)}

${escapeHtml$1(r.excerpt)}

`; list.appendChild(li); } wrap.appendChild(list); return wrap; }; const renderLoadingRow = () => { const div = document.createElement("div"); div.className = "desktop-mode-content-graph__panel-detail-loading"; div.innerHTML = `${escapeHtml$1( __("Loading details…") )}`; return div; }; const renderActionRow = (opts) => { const wrap = document.createElement("div"); wrap.className = "desktop-mode-content-graph__panel-actions"; if (!opts.href) { wrap.hidden = true; return wrap; } const btn = button({ label: opts.label, icon: opts.icon, primary: opts.primary }); btn.addEventListener( "click", () => openAdminUrl(opts.href, opts.title, opts.icon, opts.windowKey) ); wrap.appendChild(btn); return wrap; }; const button = (opts) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "desktop-mode-content-graph__btn" + (opts.primary ? " desktop-mode-content-graph__btn--primary" : ""); btn.innerHTML = `${escapeHtml$1(opts.label)}`; return btn; }; return { setLoading: (id, fallbackTitle) => { setPanelOpen(true); breadcrumbHost.hidden = true; currentView = { kind: "post" }; prevViewKind = null; title.textContent = fallbackTitle ?? `#${id}`; meta.textContent = __("Loading…"); body.replaceChildren(); currentPage = createPage(); body.append(currentPage); const loading = document.createElement("div"); loading.className = "desktop-mode-content-graph__panel-loading"; loading.innerHTML = ""; currentPage.appendChild(loading); callbacks.onViewChange?.(null); }, setError: (message) => { setPanelOpen(true); breadcrumbHost.hidden = true; currentView = { kind: "post" }; prevViewKind = null; body.replaceChildren(); currentPage = createPage(); body.append(currentPage); const empty = document.createElement("p"); empty.className = "desktop-mode-content-graph__panel-empty"; empty.textContent = message; currentPage.appendChild(empty); callbacks.onViewChange?.(null); }, setDetail: (detail) => { currentPost = detail; currentView = { kind: "post" }; renderCurrent(); }, showUser: (userId) => { if (!currentPost) { return; } const isAuthor = currentPost.author?.id === userId; const user = isAuthor ? currentPost.author : currentPost.contributors.find((c) => c.id === userId); if (!user) { return; } currentView = { kind: "user", user, role: isAuthor ? "author" : "contributor", stats: null, loading: true }; renderCurrent(); const seq = ++fetchSeq; void fetchUserStats(cfg, userId).then((stats) => { if (seq !== fetchSeq || currentView.kind !== "user" || currentView.user.id !== userId) { return; } currentView = { ...currentView, stats, loading: false }; renderCurrent(); }).catch((err) => { if (seq !== fetchSeq || currentView.kind !== "user" || currentView.user.id !== userId) { return; } currentView = { ...currentView, loading: false }; renderCurrent(); console.warn("[content-graph] user-stats failed", err); }); }, showTerm: (termId, taxonomy) => { if (!currentPost) { return; } const term = currentPost.categories.find( (t) => t.id === termId && t.taxonomy === taxonomy ); if (!term) { return; } currentView = { kind: "term", term, stats: null, loading: true }; renderCurrent(); const seq = ++fetchSeq; void fetchTermStats(cfg, taxonomy, termId).then((stats) => { if (seq !== fetchSeq || currentView.kind !== "term" || currentView.term.id !== termId) { return; } currentView = { ...currentView, stats, loading: false }; renderCurrent(); }).catch((err) => { if (seq !== fetchSeq || currentView.kind !== "term" || currentView.term.id !== termId) { return; } currentView = { ...currentView, loading: false }; renderCurrent(); console.warn("[content-graph] term-stats failed", err); }); }, showComment: (commentId) => { if (!currentPost) { return; } const comment = currentPost.comments.find( (c) => c.id === commentId ); if (!comment) { return; } currentView = { kind: "comment", comment, stats: null, loading: true }; renderCurrent(); const seq = ++fetchSeq; void fetchCommentStats(cfg, commentId).then((stats) => { if (seq !== fetchSeq || currentView.kind !== "comment" || currentView.comment.id !== commentId) { return; } currentView = { ...currentView, stats, loading: false }; renderCurrent(); }).catch((err) => { if (seq !== fetchSeq || currentView.kind !== "comment" || currentView.comment.id !== commentId) { return; } currentView = { ...currentView, loading: false }; renderCurrent(); console.warn( "[content-graph] comment-stats failed", err ); }); }, showMedia: (mediaId) => { if (!currentPost) { return; } const media = currentPost.attached_media.find( (m) => m.id === mediaId ); if (!media) { return; } currentView = { kind: "media", media }; renderCurrent(); }, showRevision: (revisionId) => { if (!currentPost) { return; } const revision = currentPost.revisions.find( (r) => r.id === revisionId ); if (!revision) { return; } currentView = { kind: "revision", revision }; renderCurrent(); }, hide: () => { setPanelOpen(false); }, destroy: () => { host.replaceChildren(); } }; } function formatDate$1(iso) { if (!iso) { return ""; } try { return new Date(iso).toLocaleString(); } catch { return iso; } } function formatNumber(n) { try { return new Intl.NumberFormat().format(n); } catch { return String(n); } } function escapeHtml$1(s) { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function escapeAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } let _cache = null; function parseCssContentToChar(raw) { let value = raw.trim(); if (value === "") { return null; } if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { value = value.slice(1, -1); } const escaped = value.match(/^\\([0-9a-f]{1,6})\s?$/i); if (escaped) { return String.fromCodePoint(parseInt(escaped[1], 16)); } return value || null; } function buildMap() { const map = /* @__PURE__ */ new Map(); if (typeof document === "undefined") { return map; } const sheets = Array.from(document.styleSheets ?? []); for (const sheet of sheets) { let rules = null; try { rules = sheet.cssRules; } catch { continue; } if (!rules) { continue; } for (const rule of Array.from(rules)) { const styleRule = rule; if (!styleRule || !styleRule.selectorText) { continue; } const match = styleRule.selectorText.match( /\.dashicons-([a-z0-9-]+)::?before/i ); if (!match) { continue; } const content = styleRule.style?.content; if (!content) { continue; } const char = parseCssContentToChar(content); if (char) { map.set(match[1], char); } } } return map; } function resolveDashicon(name) { if (!_cache) { _cache = buildMap(); } const slug = name.startsWith("dashicons-") ? name.slice("dashicons-".length) : name; return _cache.get(slug) ?? null; } function getPixi() { const pixi = window.PIXI; return pixi ?? null; } const DEFAULT_SIM_OPTIONS = { // Tuned to mirror Obsidian's airy graph: longer springs, weaker // gravity, stronger repulsion, so disconnected components drift // to the periphery instead of pancaking onto the connected core. // Repulsion is intentionally generous, on a real-world dataset // like Obsidian's vault you'd see most nodes isolated from the // connected core, and we want them spread out enough that the // initial fit doesn't crush every label on top of every other. repulsion: 26e3, springK: 0.04, springLen: 200, gravity: 35e-4, damping: 0.86 }; const ALPHA_DECAY = 0.992; const ALPHA_MIN = 0.01; const ALPHA_REHEAT = 1; const MAX_VELOCITY = 12; class ForceSim { constructor(nodes, edges, opts = DEFAULT_SIM_OPTIONS) { this.dragOrigin = null; this.dragInfluenceRadius = 500; this.groupAssignment = null; this.groupOrder = null; this.groupAttractorStrength = 0.12; this.groupOrderSpacing = 320; this.groupOrderStaggerY = 0; this.alpha = ALPHA_REHEAT; this.nodes = nodes; this.edges = edges; this.opts = opts; } reheat(value = ALPHA_REHEAT, kick = true) { this.alpha = Math.max(this.alpha, value); if (!kick) { return; } const kickStrength = value * 3; for (const n of this.nodes) { if (n.pinned) { continue; } n.vx += (Math.random() - 0.5) * kickStrength; n.vy += (Math.random() - 0.5) * kickStrength; } } get isSettled() { return this.alpha < ALPHA_MIN; } /** * One simulation step. `dt` defaults to 1 (one frame at the * `Pixi.Ticker`'s native pace); pass a tick's `deltaTime` to * compensate for frame skips. */ step(dt = 1) { if (this.isSettled) { return; } const { repulsion, springK, springLen, gravity, damping } = this.opts; const nodes = this.nodes; const len = nodes.length; for (let i = 0; i < len; i++) { const a2 = nodes[i]; for (let j = i + 1; j < len; j++) { const b = nodes[j]; let dx = a2.x - b.x; let dy = a2.y - b.y; let d2 = dx * dx + dy * dy; if (d2 < 0.01) { dx = (i - j) * 0.5; dy = (i + j) * 0.5; d2 = dx * dx + dy * dy; } const f = repulsion / d2; const d = Math.sqrt(d2); const fx = dx / d * f; const fy = dy / d * f; const wa = a2.pinned ? 0 : 1; const wb = b.pinned ? 0 : 1; a2.vx += fx * wa; a2.vy += fy * wa; b.vx -= fx * wb; b.vy -= fy * wb; } } for (const e of this.edges) { const dx = e.to.x - e.from.x; const dy = e.to.y - e.from.y; const d = Math.sqrt(dx * dx + dy * dy) || 1e-4; const f = (d - springLen) * springK; const fx = dx / d * f; const fy = dy / d * f; if (!e.from.pinned) { e.from.vx += fx; e.from.vy += fy; } if (!e.to.pinned) { e.to.vx -= fx; e.to.vy -= fy; } } this.applyClusterAttractor(); const a = this.alpha; const drag = this.dragOrigin; const dragR = this.dragInfluenceRadius; const dragR2 = dragR * dragR; for (const n of nodes) { if (!n.pinned) { n.vx -= n.x * gravity; n.vy -= n.y * gravity; n.vx *= damping; n.vy *= damping; if (n.vx > MAX_VELOCITY) { n.vx = MAX_VELOCITY; } else if (n.vx < -MAX_VELOCITY) { n.vx = -MAX_VELOCITY; } if (n.vy > MAX_VELOCITY) { n.vy = MAX_VELOCITY; } else if (n.vy < -MAX_VELOCITY) { n.vy = -MAX_VELOCITY; } let nodeAlpha = a; if (drag) { const ddx = n.x - drag.x; const ddy = n.y - drag.y; const dd2 = ddx * ddx + ddy * ddy; if (dd2 >= dragR2) { nodeAlpha = 0; } else { const t = 1 - Math.sqrt(dd2) / dragR; nodeAlpha *= t * t * (3 - 2 * t); } } n.x += n.vx * nodeAlpha * dt; n.y += n.vy * nodeAlpha * dt; } } this.alpha *= ALPHA_DECAY; } /** * Hand each non-pinned node a pull toward its group centroid(s). * Centroids are emergent — recomputed from current member positions * each tick — so they drift with their cluster, rather than being * pinned to a fixed lattice. Multi-membership posts (a post in two * categories) receive `attractorStrength / membershipCount` of force * per group, summed across their groups, so the post settles at * the balance between centroids. * * No-op when `groupAssignment` is null. */ applyClusterAttractor() { const assignment = this.groupAssignment; if (!assignment) { return; } const k = this.groupAttractorStrength; if (k <= 0) { return; } const orderedX = /* @__PURE__ */ new Map(); const orderedY = /* @__PURE__ */ new Map(); if (this.groupOrder && this.groupOrder.length > 0) { const n = this.groupOrder.length; const spacing = this.groupOrderSpacing; const stagger = this.groupOrderStaggerY; for (let i = 0; i < n; i++) { const key = this.groupOrder[i]; orderedX.set(key, (i - (n - 1) / 2) * spacing); if (stagger > 0) { orderedY.set(key, i % 2 === 0 ? -stagger : stagger); } } } const centroids = /* @__PURE__ */ new Map(); for (const n of this.nodes) { const keys = assignment.get(n.id); if (!keys || keys.length === 0) { continue; } for (const key of keys) { const c = centroids.get(key); if (c) { c.sx += n.x; c.sy += n.y; c.count++; } else { centroids.set(key, { sx: n.x, sy: n.y, count: 1 }); } } } for (const n of this.nodes) { if (n.pinned) { continue; } const keys = assignment.get(n.id); if (!keys || keys.length === 0) { continue; } const perKey = k / keys.length; for (const key of keys) { const c = centroids.get(key); if (!c || c.count === 0) { continue; } const cx = orderedX.has(key) ? orderedX.get(key) : c.sx / c.count; const cy = orderedY.has(key) ? orderedY.get(key) : c.sy / c.count; n.vx += (cx - n.x) * perKey; n.vy += (cy - n.y) * perKey; } } } /** * Public hand-off used by the scene when the toolbar's group-by * selector changes. Pass `null` for the map to disable clustering * and fall back to the existing gravity-toward-origin layout. * The optional `order` argument pins listed group keys to a * left-to-right horizontal lattice (chronological for date * facets); keys not in `order` keep emergent centroids. * * The sim is reheated so the new force visibly settles instead * of waiting for the next external nudge. */ setGroupAssignment(map, order = null) { this.groupAssignment = map; this.groupOrder = map ? order : null; this.reheat(0.3, false); } } const KIND_COLOR = { user: 3829232, term: 2926970, comment: 15239482, media: 10510036, revision: 7042949 }; const KIND_DASHICON = { user: "admin-users", // Fallback for term kinds we don't have a specific icon for. // Per-taxonomy lookup (see `iconForTermRef`) overrides this for // the WordPress built-ins so categories don't share the tag's // visual identity. term: "tag", comment: "admin-comments", media: "admin-media", revision: "backup" }; function iconForTermRef(ref) { switch (ref.taxonomy) { case "category": return "category"; case "post_tag": return "tag"; default: return KIND_DASHICON.term; } } const KIND_ICON_NUDGE = { user: { x: 0, y: 3 }, term: { x: 0, y: 3 }, // The speech-bubble dashicon is intrinsically off-balance — even // after the bbox is centred, the visible bubble drifts toward the // upper-right because the tail-less side carries more glyph mass. // Tuned by eye against the rendered output: pull a bit left, push // a bit down. Don't pile on more correction without re-checking; // what looks centred at one zoom can over-shoot at another. comment: { x: 1, y: 4 }, media: { x: -1, y: 1 }, revision: { x: 0, y: 3 } }; const DISC_RADIUS = 14; class SatelliteLayer { constructor(pixi, satelliteParent, spokeParent, onClick, hostEl, claimPointer) { this.pixi = pixi; this.satelliteParent = satelliteParent; this.spokeParent = spokeParent; this.onClick = onClick; this.hostEl = hostEl; this.claimPointer = claimPointer; this.views = []; this.focused = null; this.rafId = null; this.selectedKey = null; this.linkGfx = new pixi.Graphics(); this.spokeParent.addChild(this.linkGfx); this.layer = new pixi.Container(); this.satelliteParent.addChild(this.layer); this.hoverEl = document.createElement("div"); this.hoverEl.className = "desktop-mode-content-graph__tooltip"; this.hoverEl.hidden = true; this.hostEl.appendChild(this.hoverEl); } clear() { this.linkGfx.clear(); this.layer.removeChildren(); this.views = []; this.focused = null; this.selectedKey = null; this.hideTooltip(); } drawLinks() { this.linkGfx.clear(); if (!this.focused || this.views.length === 0) { return; } const halo = this.focused.radius + 8; const fx = this.focused.x; const fy = this.focused.y; for (const v of this.views) { const dx = v.container.x - fx; const dy = v.container.y - fy; const d = Math.sqrt(dx * dx + dy * dy); if (d <= halo) { continue; } const t = halo / d; const sx = fx + dx * t; const sy = fy + dy * t; const color = KIND_COLOR[v.ref.kind]; this.linkGfx.moveTo(sx, sy).lineTo(v.container.x, v.container.y).stroke({ color, width: v.selected ? 1.8 : 1.4, alpha: v.selected ? 0.85 : 0.5 }); } } setFocused(focused, detail) { this.clear(); this.focused = focused; const refs = this.flattenDetail(detail); if (refs.length === 0) { return; } const baseR = focused.radius; const minSpacing = 36; const ringR = Math.max( baseR + 86, baseR + 70 + refs.length * minSpacing / (2 * Math.PI) ); const startAngle = -Math.PI / 2; const slice = 2 * Math.PI / refs.length; refs.forEach((ref, i) => { const angle = startAngle + i * slice; const tx = focused.x + Math.cos(angle) * ringR; const ty = focused.y + Math.sin(angle) * ringR; const view = this.buildSatellite(ref, focused.x, focused.y); view.targetX = tx; view.targetY = ty; this.views.push(view); }); this.animateIn(); } /** * Mark a satellite by its synthetic key (e.g. `user:123`, * `term:category:42`). Pass `null` to clear. The selected satellite * gets a thicker stroke + soft halo so the user can see which one * matches the panel content. Auto-cleared by `clear()` and on a * fresh `setFocused()`. */ setSelectedKey(key) { if (this.selectedKey === key) { return; } this.selectedKey = key; for (const v of this.views) { const next = v.key === key; if (next === v.selected) { continue; } v.selected = next; this.repaintDisc(v); } this.drawLinks(); } destroy() { if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; } this.clear(); this.layer.destroy({ children: true }); this.linkGfx.destroy(); this.hoverEl.remove(); } flattenDetail(detail) { const out = []; if (detail.author) { out.push({ kind: "user", userId: detail.author.id, label: detail.author.name, meta: __("Author"), avatar: detail.author.avatar }); } for (const u of detail.contributors.slice(0, 8)) { out.push({ kind: "user", userId: u.id, label: u.name, meta: __("Contributor"), avatar: u.avatar }); } for (const t of detail.categories.slice(0, 12)) { out.push({ kind: "term", termId: t.id, taxonomy: t.taxonomy, label: t.name, meta: sprintf( /* translators: 1: taxonomy label (e.g. Category, Tag). 2: post count for the term. */ __("%1$s · %2$d posts"), t.tax_label, t.count ) }); } for (const c of detail.comments.slice(0, 8)) { out.push({ kind: "comment", commentId: c.id, label: c.author, meta: c.excerpt || formatDate(c.date) }); } for (const m of detail.attached_media.slice(0, 12)) { out.push({ kind: "media", mediaId: m.id, label: m.title, meta: m.mime, thumb: m.thumb }); } for (const r of detail.revisions.slice(0, 8)) { out.push({ kind: "revision", revisionId: r.id, parentId: detail.post.id, label: r.author?.name ?? __("Revision"), meta: formatDate(r.date) }); } return out; } buildSatellite(ref, startX, startY) { const container = new this.pixi.Container(); container.x = startX; container.y = startY; container.alpha = 0; container.eventMode = "static"; container.cursor = "pointer"; const hitR = DISC_RADIUS + 4; container.hitArea = { contains: (x, y) => { return x >= -hitR && x <= hitR && y >= -hitR && y <= hitR + 18; } }; const disc = new this.pixi.Graphics(); container.addChild(disc); const dashName = ref.kind === "term" ? iconForTermRef(ref) : KIND_DASHICON[ref.kind]; const iconChar = resolveDashicon(dashName); const icon = new this.pixi.Text({ text: iconChar ?? "?", style: { fontFamily: iconChar ? "dashicons" : "sans-serif", fontSize: iconChar ? 20 : 13, fill: 16777215 }, resolution: 2, anchor: { x: 0.5, y: 0.5 } }); const nudge = KIND_ICON_NUDGE[ref.kind]; icon.x = nudge?.x ?? 0; icon.y = nudge?.y ?? 0; container.addChild(icon); const labelText = truncate(ref.label || "—", 28); const labelBg = new this.pixi.Graphics(); container.addChild(labelBg); const label = new this.pixi.Text({ text: labelText, style: { fill: 1711915, fontSize: 11, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', fontWeight: "500" }, resolution: 2, anchor: { x: 0.5, y: 0 } }); label.x = 0; label.y = DISC_RADIUS + 2; container.addChild(label); const padX = 6; const padY = 1; const lw = label.width + padX * 2; const lh = label.height + padY * 2; labelBg.roundRect(-lw / 2, label.y - padY, lw, lh, 4).fill({ color: 16777215, alpha: 0.92 }).stroke({ color: 0, alpha: 0.08, width: 1 }); container.on("pointerdown", (evt) => { const e = evt; e.stopPropagation?.(); this.claimPointer(); }); container.on("pointerover", (evt) => { disc.alpha = 1; const e = evt; this.showTooltip(ref, e.global); }); container.on("pointermove", (evt) => { const e = evt; this.showTooltip(ref, e.global); }); container.on("pointerout", () => { disc.alpha = 0.95; this.hideTooltip(); }); container.on("pointertap", (evt) => { const e = evt; e.stopPropagation?.(); this.hideTooltip(); this.setSelectedKey(keyForRef(ref)); this.onClick(ref); }); this.layer.addChild(container); const view = { ref, key: keyForRef(ref), container, disc, icon, label, targetX: startX, targetY: startY, selected: false }; this.repaintDisc(view); return view; } repaintDisc(v) { const fill = KIND_COLOR[v.ref.kind]; v.disc.clear(); if (v.selected) { v.disc.circle(0, 0, DISC_RADIUS + 6).fill({ color: fill, alpha: 0.18 }); } v.disc.circle(0, 0, DISC_RADIUS).fill({ color: fill, alpha: 0.95 }).stroke({ color: 16777215, width: v.selected ? 2.5 : 1.5, alpha: 1 }); } animateIn() { const t0 = performance.now(); const duration = 240; const starts = this.views.map((v) => ({ x: v.container.x, y: v.container.y })); const frame = (now) => { const t = Math.min(1, (now - t0) / duration); const k = 1 - Math.pow(1 - t, 3); for (let i = 0; i < this.views.length; i++) { const v = this.views[i]; const s = starts[i]; v.container.x = s.x + (v.targetX - s.x) * k; v.container.y = s.y + (v.targetY - s.y) * k; v.container.alpha = k; } this.drawLinks(); if (t < 1) { this.rafId = requestAnimationFrame(frame); } else { this.rafId = null; } }; this.rafId = requestAnimationFrame(frame); } showTooltip(ref, global) { this.hoverEl.hidden = false; this.hoverEl.innerHTML = `${escapeHtml(ref.label || "—")}` + (ref.meta ? `${escapeHtml(ref.meta)}` : ""); if (global) { this.hoverEl.style.left = `${global.x + 14}px`; this.hoverEl.style.top = `${global.y + 14}px`; } } hideTooltip() { this.hoverEl.hidden = true; } } function keyForRef(ref) { switch (ref.kind) { case "user": return `user:${ref.userId}`; case "term": return `term:${ref.taxonomy}:${ref.termId}`; case "comment": return `comment:${ref.commentId}`; case "media": return `media:${ref.mediaId}`; case "revision": return `revision:${ref.revisionId}`; } } function truncate(text, max) { if (text.length <= max) { return text; } return text.slice(0, max - 1).trimEnd() + "…"; } function formatDate(iso) { if (!iso) { return ""; } try { return new Date(iso).toLocaleString(); } catch { return iso; } } function escapeHtml(s) { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } const NODE_FILL = 4937059; const NODE_FILL_FOCUS = 2911205; const NODE_FILL_NEIGHBOUR = 5213171; const EDGE_BASE = 10135222; const EDGE_HOT = 2911205; const ICON_NUDGE_Y_ASCENT = 0; const ICON_NUDGE = { "admin-post": { x: 0.06, y: 0.06 } }; const GROUP_LABEL_COLOR = { category: "#2c6be5", tag: "#2ca97a", author: "#7c3aed", year: "#ea580c", year_month: "#ea580c" }; const ZOOM_MIN = 0.15; const ZOOM_MAX = 4; const ZOOM_SENSITIVITY = 8e-4; const CAMERA_EASE = 0.18; const CAMERA_EPSILON = 1e-3; const RESIZE_RECENTER_THRESHOLD = 24; class GraphScene { constructor(host, callbacks, onSatelliteClick, postTypes) { this.groupLabelOverlay = null; this.satellites = null; this.nodeViews = /* @__PURE__ */ new Map(); this.edgeViews = []; this.groupViews = /* @__PURE__ */ new Map(); this.currentGrouping = null; this.groupCatalogs = { authors: {}, categories: {}, tags: {} }; this.groupingTween = null; this.fitFollowActive = false; this.fitFollowStartedAt = 0; this.fitFollowSawMotion = false; this.fitFollowMaxDurationMs = 3e3; this.fitFollowVelocityThreshold = 1; this.nodes = []; this.edges = []; this.sim = null; this.focusedId = null; this.hoveredId = null; this.pressedNode = null; this.dragOffset = { x: 0, y: 0 }; this.isPanning = false; this.panStart = { x: 0, y: 0, wx: 0, wy: 0 }; this.nodeClickActive = false; this.destroyed = false; this.tickerCb = null; this.resizeObserver = null; this.lastResizeWidth = 0; this.lastResizeHeight = 0; this.targetScale = 1; this.targetX = 0; this.targetY = 0; this.host = host; this.callbacks = callbacks; this.onSatelliteClick = onSatelliteClick; const map = /* @__PURE__ */ new Map(); for (const t of postTypes) { map.set(t.slug, normalizeDashiconName(t.icon)); } this.postTypeIcon = (slug) => map.get(slug) ?? defaultIconForPostType(slug); } async mount(api) { 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; if (typeof document !== "undefined" && document.fonts) { try { await document.fonts.load("16px dashicons"); } catch { } } const app = new pixi.Application(); await app.init({ resizeTo: this.host, backgroundAlpha: 0, antialias: true, autoDensity: true, resolution: Math.min(window.devicePixelRatio || 1, 2), // Dedicated ticker, NOT the shared one. Other desktop-mode // bundles (posts-window, recycle-bin, …) also load Pixi via // `loadModules('pixijs')` — sharing `Ticker.shared` across // independent Application instances has bitten us: a render // triggered by another bundle's app would also drive our // renderer, sometimes while the browser had perturbed our // canvas (iframe mount, layout shift) and our pipes weren't // ready, producing the "Cannot read properties of null // (reading 'clear')" crash inside `Batcher.break()`. sharedTicker: false }); this.app = app; this.host.appendChild(app.canvas); app.canvas.classList.add("desktop-mode-content-graph__canvas"); this.world = new pixi.Container(); this.world.x = this.host.clientWidth / 2; this.world.y = this.host.clientHeight / 2; this.world.scale.set(1); this.targetX = this.world.x; this.targetY = this.world.y; this.targetScale = 1; app.stage.addChild(this.world); this.edgeLayer = new pixi.Container(); this.spokeLayer = new pixi.Container(); this.nodeLayer = new pixi.Container(); this.labelLayer = new pixi.Container(); this.world.addChild( this.edgeLayer, this.spokeLayer, this.nodeLayer, this.labelLayer ); this.groupLabelOverlay = document.createElement("div"); this.groupLabelOverlay.className = "desktop-mode-content-graph__group-labels"; this.host.appendChild(this.groupLabelOverlay); app.canvas.addEventListener( "webglcontextlost", (ev) => { ev.preventDefault(); try { this.app?.ticker?.stop(); } catch { } }, false ); const renderer = app.renderer; const origRender = renderer.render.bind(renderer); renderer.render = (...a) => { try { return origRender(...a); } catch (err) { try { this.app?.ticker?.stop(); } catch { } console.warn( "[content-graph] Pixi render threw, stopping ticker:", err ); return void 0; } }; this.satellites = new SatelliteLayer( pixi, this.world, this.spokeLayer, this.onSatelliteClick, this.host, () => { this.nodeClickActive = true; } ); this.bindStageInput(app.canvas); this.bindResize(); this.tickerCb = (ticker) => this.tick(ticker.deltaTime); app.ticker.add(this.tickerCb); } setData(payload) { const prev = /* @__PURE__ */ new Map(); for (const n of this.nodes) { prev.set(n.id, n); } this.groupCatalogs = payload.groups ?? { authors: {}, categories: {}, tags: {} }; const nodes = payload.nodes.map((p) => { const old = prev.get(p.id); const angle = Math.random() * Math.PI * 2; const r = 150 + Math.random() * 250; return { ...p, x: old?.x ?? Math.cos(angle) * r, y: old?.y ?? Math.sin(angle) * r, vx: 0, vy: 0, pinned: false, radius: 4, color: NODE_FILL, degree: 0 }; }); const byId = /* @__PURE__ */ new Map(); for (const n of nodes) { byId.set(n.id, n); } const edges = []; for (const e of payload.edges) { const f = byId.get(e.from); const t = byId.get(e.to); if (!f || !t) { continue; } f.degree++; t.degree++; edges.push({ from: f, to: t }); } for (const n of nodes) { n.radius = 8 + Math.min(8, Math.sqrt(n.degree) * 2.4); } this.nodes = nodes; this.edges = edges; this.rebuildSprites(); this.sim = new ForceSim(nodes, edges); this.sim.reheat(0.12, false); const warmupSteps = Math.min(90, 30 + nodes.length); for (let i = 0; i < warmupSteps; i++) { this.sim.step(1); } if (this.currentGrouping) { this.setGrouping(this.currentGrouping); } } rebuildSprites() { this.edgeLayer.removeChildren(); this.nodeLayer.removeChildren(); this.labelLayer.removeChildren(); this.nodeViews.clear(); this.edgeViews = []; for (const e of this.edges) { const gfx = new this.pixi.Graphics(); this.edgeLayer.addChild(gfx); this.edgeViews.push({ edge: e, gfx }); } for (const n of this.nodes) { const container = new this.pixi.Container(); container.eventMode = "static"; container.cursor = "pointer"; container.hitArea = new this.pixi.Circle( 0, 0, Math.max(18, n.radius + 8) ); this.bindNodeInput(container, n); this.nodeLayer.addChild(container); const halo = new this.pixi.Graphics(); container.addChild(halo); const iconName = this.postTypeIcon(n.type); const iconChar = resolveDashicon(iconName); const icon = new this.pixi.Text({ text: iconChar ?? "●", // black circle fallback style: { fontFamily: iconChar ? "dashicons" : "sans-serif", fontSize: 2 * n.radius, fill: NODE_FILL }, resolution: 2, anchor: { x: 0.5, y: 0.5 } }); container.addChild(icon); const labelBox = new this.pixi.Container(); this.labelLayer.addChild(labelBox); const labelBg = new this.pixi.Graphics(); labelBox.addChild(labelBg); const label = new this.pixi.Text({ text: this.truncate(n.title || `#${n.id}`, 32), style: { fill: 2042167, fontSize: 11, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', fontWeight: "500" }, resolution: 2, anchor: { x: 0.5, y: 0 } }); labelBox.addChild(label); const padX = 5; const padY = 1; const lw = label.width + padX * 2; const lh = label.height + padY * 2; labelBg.roundRect(-lw / 2, -padY, lw, lh, 4).fill({ color: 16777215, alpha: 0.78 }).stroke({ color: 0, alpha: 0.06, width: 1 }); this.nodeViews.set(n.id, { node: n, container, halo, icon, labelBox, labelBg, label, iconCharCode: iconChar, iconName }); } } truncate(text, max) { if (text.length <= max) { return text; } return text.slice(0, max - 1).trimEnd() + "…"; } bindNodeInput(gfx, node) { let downAt = { x: 0, y: 0 }; let isDragging = false; const DRAG_THRESHOLD_SQ = 36; gfx.on("pointerdown", (evt) => { const e = evt; e.stopPropagation?.(); downAt = { x: e.global.x, y: e.global.y }; this.nodeClickActive = true; this.pressedNode = node; isDragging = false; node.pinned = true; node.vx = 0; node.vy = 0; }); gfx.on("pointerover", () => { this.hoveredId = node.id; this.draw(); }); gfx.on("pointerout", () => { if (this.hoveredId === node.id) { this.hoveredId = null; this.draw(); } }); gfx.on("pointerup", (evt) => { const e = evt; const dx = e.global.x - downAt.x; const dy = e.global.y - downAt.y; if (!isDragging && dx * dx + dy * dy <= 256) { this.callbacks.onNodeClick?.(node); } node.pinned = this.focusedId === node.id; this.pressedNode = null; if (this.sim) { this.sim.dragOrigin = null; if (isDragging) { this.sim.reheat(0.35, false); } } isDragging = false; }); gfx.on("pointerupoutside", () => { node.pinned = this.focusedId === node.id; this.pressedNode = null; if (this.sim) { this.sim.dragOrigin = null; } isDragging = false; }); gfx.on("globalpointermove", (evt) => { if (this.pressedNode !== node) { return; } const e = evt; const dx = e.global.x - downAt.x; const dy = e.global.y - downAt.y; const d2 = dx * dx + dy * dy; if (!isDragging) { if (d2 < DRAG_THRESHOLD_SQ) { return; } isDragging = true; const w2 = this.toWorld(e.global.x, e.global.y); this.dragOffset = { x: node.x - w2.x, y: node.y - w2.y }; if (this.sim) { this.sim.dragOrigin = { x: node.x, y: node.y }; this.sim.reheat(0.3, false); } } const w = this.toWorld(e.global.x, e.global.y); node.x = w.x + this.dragOffset.x; node.y = w.y + this.dragOffset.y; node.vx = 0; node.vy = 0; if (this.sim) { this.sim.dragOrigin = { x: node.x, y: node.y }; } }); } bindStageInput(canvas) { canvas.addEventListener( "wheel", (ev) => { ev.preventDefault(); const factor = Math.exp(-ev.deltaY * ZOOM_SENSITIVITY); const nextScale = Math.max( ZOOM_MIN, Math.min(ZOOM_MAX, this.targetScale * factor) ); const rect = canvas.getBoundingClientRect(); const lx = ev.clientX - rect.left; const ly = ev.clientY - rect.top; const beforeWorldX = (lx - this.targetX) / this.targetScale; const beforeWorldY = (ly - this.targetY) / this.targetScale; this.targetScale = nextScale; this.targetX = lx - beforeWorldX * nextScale; this.targetY = ly - beforeWorldY * nextScale; }, { passive: false } ); canvas.addEventListener("pointerdown", (ev) => { if (ev.target !== canvas) { return; } if (this.nodeClickActive) { return; } this.isPanning = true; this.panStart = { x: ev.clientX, y: ev.clientY, wx: this.world.x, wy: this.world.y }; }); window.addEventListener("pointermove", (ev) => { if (!this.isPanning || this.nodeClickActive) { return; } const newX = this.panStart.wx + (ev.clientX - this.panStart.x); const newY = this.panStart.wy + (ev.clientY - this.panStart.y); this.world.x = newX; this.world.y = newY; this.targetX = newX; this.targetY = newY; }); window.addEventListener("pointerup", (ev) => { const nodeWasTarget = this.nodeClickActive; this.nodeClickActive = false; if (!this.isPanning) { return; } const dx = ev.clientX - this.panStart.x; const dy = ev.clientY - this.panStart.y; this.isPanning = false; if (!nodeWasTarget && dx * dx + dy * dy < 9) { this.callbacks.onBackgroundClick?.(); } }); } bindResize() { this.lastResizeWidth = this.host.clientWidth; this.lastResizeHeight = this.host.clientHeight; this.resizeObserver = new ResizeObserver(() => { if (this.destroyed) { return; } const w = this.host.clientWidth; const h = this.host.clientHeight; if (w <= 0 || h <= 0) { return; } try { this.app.renderer.resize(w, h); } catch { return; } try { this.app.render(); } catch { } const dw = Math.abs(w - this.lastResizeWidth); const dh = Math.abs(h - this.lastResizeHeight); if (dw >= RESIZE_RECENTER_THRESHOLD || dh >= RESIZE_RECENTER_THRESHOLD) { this.lastResizeWidth = w; this.lastResizeHeight = h; } }); this.resizeObserver.observe(this.host); } toWorld(clientX, clientY) { const rect = this.app.canvas.getBoundingClientRect(); const lx = clientX - rect.left; const ly = clientY - rect.top; return { x: (lx - this.world.x) / this.world.scale.x, y: (ly - this.world.y) / this.world.scale.y }; } tick(delta) { if (this.destroyed) { return; } if (!this.app || !this.world) { return; } if (this.groupingTween) { this.advanceGroupingTween(); } else { this.sim?.step(delta); } const k = 1 - Math.pow(1 - CAMERA_EASE, delta); const ds = this.targetScale - this.world.scale.x; if (Math.abs(ds) < CAMERA_EPSILON) { this.world.scale.set(this.targetScale); } else { this.world.scale.set(this.world.scale.x + ds * k); } const dxc = this.targetX - this.world.x; const dyc = this.targetY - this.world.y; if (Math.abs(dxc) < CAMERA_EPSILON) { this.world.x = this.targetX; } else { this.world.x += dxc * k; } if (Math.abs(dyc) < CAMERA_EPSILON) { this.world.y = this.targetY; } else { this.world.y += dyc * k; } this.draw(); this.drawGroupLabels(); this.satellites?.drawLinks(); this.advanceFitFollow(); } /** * Per-tick auto-fit while the layout is still moving after a * grouping change. Re-frames the camera at the current node * bounds whenever peak velocity is above the threshold; once * the layout has settled (or the hard cap has elapsed) the * loop disarms and the camera stays put. Skips while the * grouping tween is mid-lerp — velocities are zeroed there by * design, and the initial `fitToViewOfTargets` already framed * the target bounds. */ advanceFitFollow() { if (!this.fitFollowActive) { return; } if (performance.now() - this.fitFollowStartedAt > this.fitFollowMaxDurationMs) { this.fitFollowActive = false; return; } if (this.groupingTween) { return; } let peakVelocity = 0; for (const n of this.nodes) { if (n.pinned) { continue; } const v = Math.hypot(n.vx, n.vy); if (v > peakVelocity) { peakVelocity = v; } } if (peakVelocity >= this.fitFollowVelocityThreshold) { this.fitFollowSawMotion = true; this.fitToView(); } else if (this.fitFollowSawMotion) { this.fitFollowActive = false; } } deriveGroupKeys(n, facet) { switch (facet) { case "category": if (n.category_ids.length === 0) { return ["cat:uncat"]; } return n.category_ids.map((id) => `cat:${id}`); case "tag": if (n.tag_ids.length === 0) { return ["tag:untagged"]; } return n.tag_ids.map((id) => `tag:${id}`); case "author": { const primaryId = n.author_id || 0; const primaryKey = `author:${primaryId}`; const keys = [primaryKey, primaryKey]; const contribs = Array.isArray(n.contributor_ids) ? n.contributor_ids : []; for (const cid of contribs) { if (cid > 0 && cid !== primaryId) { keys.push(`author:${cid}`); } } return keys; } case "year": return [`year:${n.year || 0}`]; case "year_month": return [`ym:${n.year_month || "unknown"}`]; } } labelForGroupKey(key) { const idx = key.indexOf(":"); const facet = key.slice(0, idx); const rest = key.slice(idx + 1); switch (facet) { case "cat": { if (rest === "uncat") { return __("Uncategorized"); } const id = Number(rest); return this.groupCatalogs.categories[id]?.name ?? `#${id}`; } case "tag": { if (rest === "untagged") { return __("Untagged"); } const id = Number(rest); return this.groupCatalogs.tags[id]?.name ?? `#${id}`; } case "author": { const id = Number(rest); if (id <= 0) { return __("Unknown author"); } return this.groupCatalogs.authors[id]?.name ?? `#${id}`; } case "year": { const y = Number(rest); if (y <= 0) { return __("Undated"); } return String(y); } case "ym": { if (rest === "unknown" || rest === "") { return __("Undated"); } return formatYearMonth(rest); } } return key; } buildGroupViews(members, facet) { if (!this.groupLabelOverlay) { return; } const tint = GROUP_LABEL_COLOR[facet]; for (const [key, ids] of members) { if (ids.length === 0) { continue; } const label = `${this.labelForGroupKey(key)} (${ids.length})`; const el = document.createElement("div"); el.className = "desktop-mode-content-graph__group-label"; el.textContent = label; el.style.setProperty("--wpd-cg-cluster-color", tint); this.groupLabelOverlay.appendChild(el); this.groupViews.set(key, { key, label, el, members: ids }); } } clearGroupViews() { for (const v of this.groupViews.values()) { v.el.remove(); } this.groupViews.clear(); } /** * Per-frame paint of the cluster label markers. Centroid is the * running average of member positions, scale is inverse of world * scale (so labels stay legible across zoom), alpha fades the * labels OUT as you zoom past the focused-node range so they * don't clutter the close-up view. */ drawGroupLabels() { if (this.destroyed || this.groupViews.size === 0) { return; } const fade = 1 - smoothstep(1.2, 2.4, this.world.scale.x); const scale = this.world.scale.x; const ox = this.world.x; const oy = this.world.y; for (const v of this.groupViews.values()) { let sumX = 0; let sumY = 0; let count = 0; for (const id of v.members) { const node = this.nodeViews.get(id)?.node; if (!node) { continue; } sumX += node.x; sumY += node.y; count++; } if (count === 0 || fade <= 0.02) { v.el.style.display = "none"; continue; } const screenX = ox + sumX / count * scale; const screenY = oy + sumY / count * scale; v.el.style.display = ""; v.el.style.transform = `translate(${screenX}px, ${screenY}px) translate(-50%, -50%)`; v.el.style.opacity = String(fade); } } draw() { if (this.destroyed) { return; } const focusId = this.focusedId; const hoverId = this.hoveredId; const focusNeighbours = /* @__PURE__ */ new Set(); if (focusId !== null) { for (const e of this.edges) { if (e.from.id === focusId) { focusNeighbours.add(e.to.id); } if (e.to.id === focusId) { focusNeighbours.add(e.from.id); } } focusNeighbours.add(focusId); } const dimmed = focusId !== null; const edgeZoomFade = smoothstep(0.45, 1.1, this.world.scale.x); const edgeBaseAlpha = 0.2 + edgeZoomFade * 0.35; for (const v of this.edgeViews) { const { edge, gfx } = v; gfx.clear(); const isFocusEdge = focusId !== null && (edge.from.id === focusId || edge.to.id === focusId); const isHoverEdge = hoverId !== null && (edge.from.id === hoverId || edge.to.id === hoverId); let alpha; if (dimmed) { alpha = isFocusEdge ? 0.85 : 0; } else if (isHoverEdge) { alpha = 0.7; } else { alpha = edgeBaseAlpha; } const color = isFocusEdge || isHoverEdge ? EDGE_HOT : EDGE_BASE; const width = isFocusEdge || isHoverEdge ? 1.2 : 0.7; let sx = edge.from.x; let sy = edge.from.y; let ex = edge.to.x; let ey = edge.to.y; if (focusId !== null) { if (edge.from.id === focusId) { const p = pointOnSegment( edge.from.x, edge.from.y, edge.to.x, edge.to.y, edge.from.radius + 8 ); sx = p.x; sy = p.y; } if (edge.to.id === focusId) { const p = pointOnSegment( edge.to.x, edge.to.y, edge.from.x, edge.from.y, edge.to.radius + 8 ); ex = p.x; ey = p.y; } } gfx.moveTo(sx, sy).lineTo(ex, ey).stroke({ color, width, alpha }); } const inverseScale = 1 / this.world.scale.x; const zoomFade = smoothstep(0.55, 0.95, this.world.scale.x); for (const v of this.nodeViews.values()) { const { node, container, halo, icon, labelBox } = v; const isFocus = node.id === focusId; const isHover = node.id === hoverId; const isNeighbour = focusId !== null && focusNeighbours.has(node.id); const inFocus = focusId === null || isNeighbour; const baseAlpha = inFocus ? 1 : 0.25; container.x = node.x; container.y = node.y; container.alpha = baseAlpha; let fill = NODE_FILL; if (isFocus) { fill = NODE_FILL_FOCUS; } else if (isNeighbour) { fill = NODE_FILL_NEIGHBOUR; } halo.clear(); if (isFocus || isHover) { halo.circle(0, 0, node.radius + 8).fill({ color: fill, alpha: 0.18 }); } icon.style.fill = fill; const fontSize = 2 * node.radius; icon.style.fontSize = fontSize; const nudge = ICON_NUDGE[v.iconName]; icon.x = (nudge?.x ?? 0) * fontSize; icon.y = (nudge?.y ?? ICON_NUDGE_Y_ASCENT) * fontSize; labelBox.x = node.x; labelBox.y = node.y + node.radius + 4; labelBox.scale.set(inverseScale); let baseLabelAlpha; if (isFocus) { baseLabelAlpha = 1; } else if (inFocus) { baseLabelAlpha = 0.92; } else { baseLabelAlpha = 0.32; } labelBox.alpha = baseLabelAlpha * zoomFade; labelBox.visible = labelBox.alpha > 0.01; } } focusNode(id) { if (this.focusedId !== null) { const prev = this.nodeViews.get(this.focusedId); if (prev) { prev.node.pinned = false; } } this.focusedId = id; const view = this.nodeViews.get(id); if (view) { view.node.pinned = true; view.node.vx = 0; view.node.vy = 0; const target = view.node; const newScale = Math.max(this.targetScale, 1.6); this.targetScale = newScale; this.targetX = this.host.clientWidth / 2 - target.x * newScale; this.targetY = this.host.clientHeight / 2 - target.y * newScale; } this.draw(); } setFocusedDetail(detail) { if (!this.satellites) { return; } if (!detail || this.focusedId === null) { this.satellites.clear(); return; } const node = this.nodeViews.get(this.focusedId)?.node; if (!node) { this.satellites.clear(); return; } this.satellites.setFocused(node, detail); } /** * Swap the active clustering facet. Pass `null` to disable * clustering entirely. Computes the per-node group assignment from * the current node set, hands it to the sim (which reheats), and * rebuilds the per-cluster label markers in `groupLabelLayer`. * * Cheap to call repeatedly — there's no Pixi teardown beyond * destroying / recreating the small `GroupView` containers. */ setGrouping(facet) { this.currentGrouping = facet; this.clearGroupViews(); if (!facet || !this.sim) { this.sim?.setGroupAssignment(null); return; } const assignment = /* @__PURE__ */ new Map(); const members = /* @__PURE__ */ new Map(); for (const n of this.nodes) { const keys = this.deriveGroupKeys(n, facet); assignment.set(n.id, keys); const seen = /* @__PURE__ */ new Set(); for (const key of keys) { if (seen.has(key)) { continue; } seen.add(key); const list = members.get(key); if (list) { list.push(n.id); } else { members.set(key, [n.id]); } } } const order = this.chronologicalOrder(facet, members); this.sim.groupOrderStaggerY = facet === "year_month" ? 160 : 0; this.sim.setGroupAssignment(assignment, order); const targets = this.buildGroupSeedTargets(assignment, members, order); this.startGroupingTween(targets); this.fitToViewOfTargets(targets); this.buildGroupViews(members, facet); this.fitFollowActive = true; this.fitFollowStartedAt = performance.now(); this.fitFollowSawMotion = false; } /** * Drive one frame of the active grouping tween. Lerps each * non-pinned node from its captured start position to its target * with an ease-out cubic. Cleans up + resumes the sim when done. */ advanceGroupingTween() { if (this.destroyed) { return; } const tween = this.groupingTween; if (!tween) { return; } const t = Math.min(1, (performance.now() - tween.startTime) / tween.duration); const k = 1 - Math.pow(1 - t, 3); for (const [nodeId, start] of tween.starts) { const target = tween.targets.get(nodeId); if (!target) { continue; } const node = this.nodeViews.get(nodeId)?.node; if (!node || node.pinned) { continue; } node.x = start.x + (target.x - start.x) * k; node.y = start.y + (target.y - start.y) * k; node.vx = 0; node.vy = 0; } if (t >= 1) { this.groupingTween = null; this.sim?.reheat(0.18, false); } } /** * Compute a per-cluster seed position + per-node target on that * seed. The tween animates each node from its current position * to its target so the user sees a smooth flow into clusters * instead of an instant snap. * * Seeds: * - **Ordered facets** (year, year-month): a horizontal lattice * matching the order array, so chronological clusters land * in the same left-to-right slots the cluster force pins * them to. Unordered keys (e.g. `'ym:unknown'`) sit to the * right of the chronological range. * - **Unordered facets** (category, tag, author): polar * distribution around the origin, radius scaling with the * number of groups. Floor keeps small group counts (2–3) * visually distinct. * * Multi-membership posts (a post in two categories) target the * average of their group seeds so they start at the force-balance * midpoint instead of being arbitrarily assigned to one cluster. */ buildGroupSeedTargets(assignment, members, order) { const targets = /* @__PURE__ */ new Map(); if (!this.sim) { return targets; } const groupKeys = Array.from(members.keys()); const seeds = /* @__PURE__ */ new Map(); const spacing = this.sim.groupOrderSpacing; if (order && order.length > 0) { const n = order.length; const stagger = this.sim.groupOrderStaggerY; const staggerY = (idx) => { if (stagger <= 0) { return 0; } return idx % 2 === 0 ? -stagger : stagger; }; for (let i = 0; i < n; i++) { seeds.set(order[i], { x: (i - (n - 1) / 2) * spacing, y: staggerY(i) }); } let extra = n; for (const k of groupKeys) { if (seeds.has(k)) { continue; } seeds.set(k, { x: (extra - (n - 1) / 2) * spacing, y: staggerY(extra) }); extra++; } } else { const n = groupKeys.length; const radius = Math.max(220, 120 + n * 40); for (let i = 0; i < n; i++) { const angle = i / Math.max(1, n) * Math.PI * 2 - Math.PI / 2; seeds.set(groupKeys[i], { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }); } } const jitter = 40; for (const node of this.nodes) { if (node.pinned) { continue; } const keys = assignment.get(node.id); if (!keys || keys.length === 0) { continue; } let sx = 0; let sy = 0; let count = 0; for (const k of keys) { const s = seeds.get(k); if (!s) { continue; } sx += s.x; sy += s.y; count++; } if (count === 0) { continue; } targets.set(node.id, { x: sx / count + (Math.random() - 0.5) * jitter, y: sy / count + (Math.random() - 0.5) * jitter }); } return targets; } /** * Capture the current positions as the tween starts, set the * tween clock, and let `tick()` drive each frame from there. * Replaces any in-flight tween — picking a new facet mid-tween * just retargets from wherever the nodes currently sit. */ startGroupingTween(targets) { if (targets.size === 0) { this.groupingTween = null; return; } const starts = /* @__PURE__ */ new Map(); for (const nodeId of targets.keys()) { const node = this.nodeViews.get(nodeId)?.node; if (!node) { continue; } starts.set(nodeId, { x: node.x, y: node.y }); } this.groupingTween = { startTime: performance.now(), // Fast enough to feel responsive, long enough to read as // a real transition (not a snap). Tuned by feel; if it // looks sluggish on slow machines, drop to 350. duration: 450, starts, targets }; } /** * Frame the camera against the target bounds (not the current * node positions) so the zoom-out animates IN PARALLEL with the * layout tween instead of waiting for it to settle. */ fitToViewOfTargets(targets) { if (targets.size === 0) { return; } let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const t of targets.values()) { if (t.x < minX) { minX = t.x; } if (t.y < minY) { minY = t.y; } if (t.x > maxX) { maxX = t.x; } if (t.y > maxY) { maxY = t.y; } } const padding = 100; const w = maxX - minX + padding * 2; const h = maxY - minY + padding * 2; const sx = this.host.clientWidth / w; const sy = this.host.clientHeight / h; const s = Math.max(ZOOM_MIN, Math.min(1.5, Math.min(sx, sy))); const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; this.targetScale = s; this.targetX = this.host.clientWidth / 2 - cx * s; this.targetY = this.host.clientHeight / 2 - cy * s; } /** * For date facets, sort the keys oldest-to-newest so the cluster * attractor can lay them out left-to-right. For other facets, * returns `null` — those clusters stay fully emergent. * * `'year:'` and `'ym:unknown'` are skipped from the * order: an undated post shouldn't bias one end of the timeline. */ chronologicalOrder(facet, members) { if (facet !== "year" && facet !== "year_month") { return null; } const ordered = []; for (const key of members.keys()) { const idx = key.indexOf(":"); const rest = key.slice(idx + 1); if (facet === "year") { const y = Number(rest); if (!Number.isFinite(y) || y <= 0) { continue; } ordered.push({ key, sort: String(y).padStart(6, "0") }); } else { if (rest === "unknown" || rest === "") { continue; } ordered.push({ key, sort: rest }); } } ordered.sort((a, b) => { if (a.sort < b.sort) { return -1; } if (a.sort > b.sort) { return 1; } return 0; }); return ordered.map((e) => e.key); } clearFocus() { if (this.focusedId !== null) { const view = this.nodeViews.get(this.focusedId); if (view) { view.node.pinned = false; } } this.focusedId = null; this.satellites?.clear(); this.sim?.reheat(0.25, false); this.draw(); } /** * Mark a satellite by its synthetic key as selected (e.g. when * the side panel switches to that satellite's dossier). Pass * `null` to clear the selection — done when the panel navigates * back to the post view or closes entirely. */ setSatelliteSelectedKey(key) { this.satellites?.setSelectedKey(key); } getNode(id) { return this.nodes.find((n) => n.id === id); } getNodes() { return this.nodes; } /** * Currently focused node id, or `null` when nothing is focused. * Used by the host orchestrator to implement click-to-deselect: * if the user clicks the already-focused node, the host calls * `clearFocus()` instead of re-focusing. */ getFocusedId() { return this.focusedId; } fitToView() { 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 = 100; const w = maxX - minX + padding * 2; const h = maxY - minY + padding * 2; const sx = this.host.clientWidth / w; const sy = this.host.clientHeight / h; const s = Math.max(ZOOM_MIN, Math.min(1.5, Math.min(sx, sy))); const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; this.targetScale = s; this.targetX = this.host.clientWidth / 2 - cx * s; this.targetY = this.host.clientHeight / 2 - cy * s; } destroy() { this.destroyed = true; try { this.app?.ticker?.stop(); } catch { } if (this.tickerCb) { try { this.app?.ticker?.remove(this.tickerCb); } catch { } this.tickerCb = null; } this.resizeObserver?.disconnect(); this.resizeObserver = null; this.satellites?.destroy(); this.satellites = null; this.clearGroupViews(); this.groupLabelOverlay?.remove(); this.groupLabelOverlay = null; try { this.app.destroy(true, { children: true }); } catch { } } } function normalizeDashiconName(raw) { if (typeof raw !== "string" || raw === "") { return "admin-generic"; } if (raw.startsWith("http://") || raw.startsWith("https://")) { return "admin-generic"; } return raw.replace(/^dashicons-/, ""); } function pointOnSegment(fromX, fromY, toX, toY, distance) { const dx = toX - fromX; const dy = toY - fromY; const d = Math.sqrt(dx * dx + dy * dy); if (d === 0) { return { x: fromX, y: fromY }; } const t = Math.min(distance / d, 1); return { x: fromX + dx * t, y: fromY + dy * t }; } function smoothstep(a, b, x) { if (x <= a) { return 0; } if (x >= b) { return 1; } const t = (x - a) / (b - a); return t * t * (3 - 2 * t); } function formatYearMonth(token) { const m = /^(\d{4})-(\d{2})$/.exec(token); if (!m) { return token; } const monthIdx = Number(m[2]) - 1; if (monthIdx < 0 || monthIdx > 11) { return token; } const year = Number(m[1]); try { const d = new Date(Date.UTC(year, monthIdx, 1)); return new Intl.DateTimeFormat(void 0, { month: "short", year: "numeric", timeZone: "UTC" }).format(d); } catch { return token; } } function defaultIconForPostType(slug) { switch (slug) { case "post": return "admin-post"; case "page": return "admin-page"; case "attachment": return "admin-media"; default: return "admin-generic"; } } const WINDOW_ID = "desktop-mode-content-graph"; async function renderContentGraph(body) { const root = body.querySelector( "[data-desktop-mode-content-graph-root]" ); if (!root) { body.textContent = __("Content Graph container missing."); return { abort: () => { } }; } const cfg = getConfig(); const toolbarHost = root.querySelector( "[data-desktop-mode-content-graph-toolbar]" ); const stageHost = root.querySelector( "[data-desktop-mode-content-graph-stage]" ); const panelHost = root.querySelector( "[data-desktop-mode-content-graph-panel]" ); const loading = root.querySelector( "[data-desktop-mode-content-graph-loading]" ); const desktopApi = window.wp?.desktop ?? {}; let activeTypes = cfg.postTypes.map((t) => t.slug); let scene = null; let detailRequestId = 0; let aborted = false; const showLoading = (show) => { if (!loading) { return; } loading.hidden = !show; }; const panel = renderPanel(panelHost, cfg, { onClose: () => { panel.hide(); scene?.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); } }); const handleSatelliteClick = (ref) => { switch (ref.kind) { case "user": panel.showUser(ref.userId); break; case "term": panel.showTerm(ref.termId, ref.taxonomy); break; case "comment": panel.showComment(ref.commentId); break; case "media": panel.showMedia(ref.mediaId); break; case "revision": panel.showRevision(ref.revisionId); break; } }; const focusNode = (node) => { scene?.focusNode(node.id); panel.setLoading(node.id, node.title); const myId = ++detailRequestId; void (async () => { try { const detail = await fetchPostDetail(cfg, node.id); if (aborted || myId !== detailRequestId) { return; } panel.setDetail(detail); scene?.setFocusedDetail(detail); } catch (err) { if (aborted || myId !== detailRequestId) { return; } panel.setError( sprintf( /* translators: %d: numeric post id that failed to load. */ __("Could not load post #%d."), node.id ) ); console.warn("[content-graph] detail fetch failed", err); } })(); }; const buildToolbarCallbacks = () => ({ onTypesChange: (types) => { activeTypes = types; void loadGraph(); }, onFitToView: () => scene?.fitToView(), onSearchSelect: (node) => focusNode(node), onGroupChange: (facet) => { scene?.setGrouping(facet); }, getNodes: () => scene?.getNodes() ?? [] }); let toolbar = renderToolbar( toolbarHost, cfg.postTypes, buildToolbarCallbacks() ); const loadGraph = async () => { if (aborted) { return; } showLoading(true); toolbar.setStatus(__("Loading graph…")); try { const payload = await fetchGraph(cfg, activeTypes); if (aborted) { return; } scene?.setData(payload); toolbar.setStatus( sprintf( /* translators: 1: number of nodes (posts/pages) in the graph. 2: number of links between them. */ __("%1$d nodes · %2$d links"), payload.stats.nodes, payload.stats.edges ) ); scene?.fitToView(); scene?.clearFocus(); panel.hide(); } catch (err) { if (aborted) { return; } toolbar.setStatus(__("Failed to load graph.")); console.warn("[content-graph] graph fetch failed", err); } finally { showLoading(false); } }; const closeFocus = () => { detailRequestId++; panel.hide(); scene?.clearFocus(); }; scene = new GraphScene( stageHost, { onNodeClick: (node) => { 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."); console.warn("[content-graph] scene mount failed", err); return { abort: () => { } }; } try { const refreshed = await fetchPostTypes(cfg); toolbar.destroy(); toolbar = renderToolbar(toolbarHost, refreshed, buildToolbarCallbacks()); } catch { } await loadGraph(); return { abort: () => { aborted = true; toolbar.destroy(); panel.destroy(); scene?.destroy(); scene = null; } }; } const registry = window.desktopModeNativeWindows ?? (window.desktopModeNativeWindows = {}); registry[WINDOW_ID] = async (body) => { const state = await renderContentGraph(body); return state.abort; }; })();