PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.0
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / assets / js / content-graph.js

content-graph.js in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.0, at assets/js/content-graph.js

3,543 lines 113.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 (function() {
2 "use strict";
3 const TEXT_DOMAIN = "desktop-mode";
4 function i18n() {
5 return window.wp?.i18n;
6 }
7 function __(text, domain = TEXT_DOMAIN) {
8 return i18n()?.__(text, domain) ?? text;
9 }
10 function sprintf(format, ...args) {
11 const impl = i18n()?.sprintf;
12 if (impl) {
13 return impl(format, ...args);
14 }
15 let i = 0;
16 return format.replace(/%[sd]/g, () => String(args[i++] ?? ""));
17 }
18 const FALLBACK_BASE = "http://localhost/";
19 function joinRestUrl(restRoot, path) {
20 const base = typeof window !== "undefined" && window.location ? window.location.href : FALLBACK_BASE;
21 const url = new URL(restRoot, base);
22 const trimmed = path.replace(/^\/+/, "");
23 const queryAt = trimmed.indexOf("?");
24 const route = queryAt === -1 ? trimmed : trimmed.slice(0, queryAt);
25 const extraQuery = queryAt === -1 ? "" : trimmed.slice(queryAt + 1);
26 if (url.searchParams.has("rest_route")) {
27 const existing = url.searchParams.get("rest_route") ?? "/";
28 const prefix = existing.endsWith("/") ? existing : existing + "/";
29 url.searchParams.set("rest_route", prefix + route);
30 } else {
31 const pathname = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/";
32 url.pathname = pathname + route;
33 }
34 if (extraQuery) {
35 const extras = new URLSearchParams(extraQuery);
36 extras.forEach((value, key) => {
37 url.searchParams.append(key, value);
38 });
39 }
40 return url.toString();
41 }
42 const NONCE_HEADER = "X-WP-Nonce";
43 function injectRestNonce(input, init) {
44 const nonce = readRestNonce();
45 if (!nonce) {
46 return init;
47 }
48 const url = resolveUrl(input);
49 if (!url || !isSameOriginRestUrl(url)) {
50 return init;
51 }
52 const baseHeaders = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0);
53 const headers = new Headers(baseHeaders ?? {});
54 if (headers.has(NONCE_HEADER)) {
55 return init;
56 }
57 headers.set(NONCE_HEADER, nonce);
58 return { ...init ?? {}, headers };
59 }
60 function readRestNonce() {
61 if (typeof window === "undefined") {
62 return void 0;
63 }
64 const cfg = window.desktopModeConfig;
65 const value = cfg?.restNonce;
66 return typeof value === "string" && value.length > 0 ? value : void 0;
67 }
68 function resolveUrl(input) {
69 try {
70 const base = typeof window !== "undefined" && window.location ? window.location.href : void 0;
71 if (typeof input === "string") {
72 return new URL(input, base);
73 }
74 if (input instanceof URL) {
75 return input;
76 }
77 if (typeof Request !== "undefined" && input instanceof Request) {
78 return new URL(input.url, base);
79 }
80 return null;
81 } catch {
82 return null;
83 }
84 }
85 function isSameOriginRestUrl(url) {
86 if (typeof window === "undefined" || !window.location || url.origin !== window.location.origin) {
87 return false;
88 }
89 if (url.pathname.includes("/wp-json/")) {
90 return true;
91 }
92 if (url.searchParams.has("rest_route")) {
93 return true;
94 }
95 return false;
96 }
97 function trackedFetch(input, init, opts = {}) {
98 const fn = window.wp?.desktop?.fetch;
99 if (typeof fn === "function") {
100 return fn(input, init, opts);
101 }
102 const finalInit = injectRestNonce(input, init);
103 return fetch(input, finalInit);
104 }
105 const WINDOW_ID$1 = "desktop-mode-content-graph";
106 const SOURCE = "desktop-mode/content-graph";
107 function getConfig() {
108 const map = window.desktopModeWindowConfig ?? {};
109 const cfg = map[WINDOW_ID$1];
110 if (!cfg) {
111 throw new Error(
112 "Content Graph config missing — desktop_mode_register_window args lost in transit."
113 );
114 }
115 return cfg;
116 }
117 function authHeaders(cfg) {
118 return {
119 Accept: "application/json",
120 "X-WP-Nonce": cfg.restNonce
121 };
122 }
123 async function fetchPostTypes(cfg) {
124 const res = await trackedFetch(
125 `${cfg.apiBase}/post-types`,
126 { headers: authHeaders(cfg) },
127 { source: SOURCE, windowId: WINDOW_ID$1 }
128 );
129 if (!res.ok) {
130 throw new Error(`post-types: ${res.status}`);
131 }
132 return await res.json();
133 }
134 async function fetchGraph(cfg, types) {
135 const url = new URL(`${cfg.apiBase}/nodes`);
136 if (types.length > 0) {
137 url.searchParams.set("types", types.join(","));
138 }
139 const res = await trackedFetch(
140 url.toString(),
141 { headers: authHeaders(cfg) },
142 { source: SOURCE, windowId: WINDOW_ID$1 }
143 );
144 if (!res.ok) {
145 throw new Error(`nodes: ${res.status}`);
146 }
147 return await res.json();
148 }
149 async function fetchPostDetail(cfg, id) {
150 const res = await trackedFetch(
151 `${cfg.apiBase}/post/${id}`,
152 { headers: authHeaders(cfg) },
153 { source: SOURCE, windowId: WINDOW_ID$1 }
154 );
155 if (!res.ok) {
156 throw new Error(`post/${id}: ${res.status}`);
157 }
158 return await res.json();
159 }
160 async function fetchUserStats(cfg, userId) {
161 const res = await trackedFetch(
162 joinRestUrl(cfg.restRoot, `desktop-mode/v1/user-stats/${userId}`),
163 { headers: authHeaders(cfg) },
164 { source: SOURCE, windowId: WINDOW_ID$1 }
165 );
166 if (!res.ok) {
167 throw new Error(`user-stats/${userId}: ${res.status}`);
168 }
169 return await res.json();
170 }
171 async function fetchTermStats(cfg, taxonomy, termId) {
172 const res = await trackedFetch(
173 joinRestUrl(
174 cfg.restRoot,
175 `desktop-mode/v1/term-stats/${encodeURIComponent(taxonomy)}/${termId}`
176 ),
177 { headers: authHeaders(cfg) },
178 { source: SOURCE, windowId: WINDOW_ID$1 }
179 );
180 if (!res.ok) {
181 throw new Error(
182 `term-stats/${taxonomy}/${termId}: ${res.status}`
183 );
184 }
185 return await res.json();
186 }
187 async function fetchCommentStats(cfg, commentId) {
188 const res = await trackedFetch(
189 joinRestUrl(cfg.restRoot, `desktop-mode/v1/comment-stats/${commentId}`),
190 { headers: authHeaders(cfg) },
191 { source: SOURCE, windowId: WINDOW_ID$1 }
192 );
193 if (!res.ok) {
194 throw new Error(`comment-stats/${commentId}: ${res.status}`);
195 }
196 return await res.json();
197 }
198 const GROUP_NONE = "none";
199 function renderToolbar(host, postTypes, callbacks) {
200 host.replaceChildren();
201 const active = new Set(postTypes.map((t) => t.slug));
202 const chipsRow = document.createElement("div");
203 chipsRow.className = "desktop-mode-content-graph__filters";
204 host.appendChild(chipsRow);
205 for (const type of postTypes) {
206 const chip = document.createElement("button");
207 chip.type = "button";
208 chip.className = "desktop-mode-content-graph__chip is-active";
209 chip.dataset.slug = type.slug;
210 chip.innerHTML = `<span class="dashicons ${escapeAttr$1(type.icon)}" aria-hidden="true"></span><span class="desktop-mode-content-graph__chip-label">${escapeHtml$2(type.label)}</span><span class="desktop-mode-content-graph__chip-count">${type.count}</span>`;
211 chip.addEventListener("click", () => {
212 if (active.has(type.slug)) {
213 active.delete(type.slug);
214 chip.classList.remove("is-active");
215 } else {
216 active.add(type.slug);
217 chip.classList.add("is-active");
218 }
219 callbacks.onTypesChange(Array.from(active));
220 });
221 chipsRow.appendChild(chip);
222 }
223 const searchWrap = document.createElement("div");
224 searchWrap.className = "desktop-mode-content-graph__search";
225 const searchInput = document.createElement("input");
226 searchInput.type = "search";
227 searchInput.className = "desktop-mode-content-graph__search-input";
228 searchInput.placeholder = __("Search nodes…");
229 searchInput.setAttribute(
230 "aria-label",
231 __("Search posts and pages in the graph")
232 );
233 searchWrap.appendChild(searchInput);
234 const dropdown = document.createElement("ul");
235 dropdown.className = "desktop-mode-content-graph__search-results";
236 dropdown.hidden = true;
237 searchWrap.appendChild(dropdown);
238 host.appendChild(searchWrap);
239 const handleSearchInput = () => {
240 const q = searchInput.value.trim().toLowerCase();
241 if (q.length === 0) {
242 dropdown.hidden = true;
243 dropdown.replaceChildren();
244 return;
245 }
246 const matches = callbacks.getNodes().filter((n) => n.title.toLowerCase().includes(q)).slice(0, 10);
247 dropdown.replaceChildren();
248 for (const m of matches) {
249 const li = document.createElement("li");
250 const btn = document.createElement("button");
251 btn.type = "button";
252 btn.className = "desktop-mode-content-graph__search-result";
253 btn.innerHTML = `<span class="desktop-mode-content-graph__search-title">${escapeHtml$2(m.title || "#" + m.id)}</span><span class="desktop-mode-content-graph__search-type">${escapeHtml$2(m.type)}</span>`;
254 btn.addEventListener("click", () => {
255 searchInput.value = "";
256 dropdown.hidden = true;
257 dropdown.replaceChildren();
258 callbacks.onSearchSelect(m);
259 });
260 li.appendChild(btn);
261 dropdown.appendChild(li);
262 }
263 dropdown.hidden = matches.length === 0;
264 };
265 searchInput.addEventListener("input", handleSearchInput);
266 searchInput.addEventListener("focus", handleSearchInput);
267 searchInput.addEventListener("blur", () => {
268 setTimeout(() => {
269 dropdown.hidden = true;
270 }, 120);
271 });
272 const groupBy = document.createElement("wpd-select");
273 groupBy.className = "desktop-mode-content-graph__group-by";
274 groupBy.setAttribute("value", GROUP_NONE);
275 groupBy.setAttribute("aria-label", __("Group by"));
276 groupBy.title = __("Group posts by a shared facet");
277 for (const [value, label] of [
278 [GROUP_NONE, __("No grouping")],
279 ["category", __("Group by category")],
280 ["tag", __("Group by tag")],
281 ["author", __("Group by author")],
282 ["year", __("Group by year")],
283 ["year_month", __("Group by year-month")]
284 ]) {
285 const opt = document.createElement("wpd-option");
286 opt.setAttribute("value", value);
287 opt.textContent = label;
288 groupBy.appendChild(opt);
289 }
290 groupBy.addEventListener("wpd-pick", (ev) => {
291 const detail = ev.detail;
292 const raw = detail?.value ?? GROUP_NONE;
293 const facet = raw === GROUP_NONE ? null : raw;
294 callbacks.onGroupChange(facet);
295 });
296 host.insertBefore(groupBy, searchWrap);
297 const actions = document.createElement("div");
298 actions.className = "desktop-mode-content-graph__actions";
299 const fit = document.createElement("button");
300 fit.type = "button";
301 fit.className = "desktop-mode-content-graph__btn";
302 fit.innerHTML = `<span class="dashicons dashicons-editor-expand" aria-hidden="true"></span><span>${escapeHtml$2(__("Fit"))}</span>`;
303 fit.title = __("Fit graph to view");
304 fit.addEventListener("click", () => callbacks.onFitToView());
305 actions.appendChild(fit);
306 const status = document.createElement("span");
307 status.className = "desktop-mode-content-graph__toolbar-status";
308 actions.appendChild(status);
309 host.appendChild(actions);
310 const onDocClick = (ev) => {
311 if (!searchWrap.contains(ev.target)) {
312 dropdown.hidden = true;
313 }
314 };
315 document.addEventListener("click", onDocClick);
316 return {
317 setStatus: (text) => {
318 status.textContent = text;
319 },
320 destroy: () => {
321 document.removeEventListener("click", onDocClick);
322 }
323 };
324 }
325 function escapeHtml$2(s) {
326 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
327 }
328 function escapeAttr$1(s) {
329 return s.replace(/[^a-zA-Z0-9 _\-]/g, "");
330 }
331 function renderPanel(host, cfg, callbacks) {
332 host.replaceChildren();
333 host.hidden = false;
334 host.classList.add("desktop-mode-content-graph__panel--closed");
335 const setPanelOpen = (open) => {
336 host.classList.toggle(
337 "desktop-mode-content-graph__panel--closed",
338 !open
339 );
340 };
341 let currentPost = null;
342 let currentView = { kind: "post" };
343 let fetchSeq = 0;
344 const frame = document.createElement("div");
345 frame.className = "desktop-mode-content-graph__panel-frame";
346 const breadcrumbHost = document.createElement("nav");
347 breadcrumbHost.className = "desktop-mode-content-graph__panel-breadcrumb";
348 breadcrumbHost.hidden = true;
349 const head = document.createElement("header");
350 head.className = "desktop-mode-content-graph__panel-head";
351 const titleWrap = document.createElement("div");
352 titleWrap.className = "desktop-mode-content-graph__panel-title-wrap";
353 const title = document.createElement("h2");
354 title.className = "desktop-mode-content-graph__panel-title";
355 const meta = document.createElement("p");
356 meta.className = "desktop-mode-content-graph__panel-meta";
357 titleWrap.appendChild(title);
358 titleWrap.appendChild(meta);
359 const closeBtn = document.createElement("button");
360 closeBtn.type = "button";
361 closeBtn.className = "desktop-mode-content-graph__panel-close";
362 closeBtn.innerHTML = '<span class="dashicons dashicons-no-alt" aria-hidden="true"></span>';
363 closeBtn.title = __("Close panel");
364 closeBtn.addEventListener("click", () => callbacks.onClose());
365 head.appendChild(titleWrap);
366 head.appendChild(closeBtn);
367 const body = document.createElement("div");
368 body.className = "desktop-mode-content-graph__panel-body";
369 frame.appendChild(breadcrumbHost);
370 frame.appendChild(head);
371 frame.appendChild(body);
372 host.appendChild(frame);
373 let currentPage = createPage();
374 body.append(currentPage);
375 let prevViewKind = null;
376 let pageSeq = 0;
377 function createPage() {
378 const p = document.createElement("div");
379 p.className = "desktop-mode-content-graph__panel-page";
380 return p;
381 }
382 const swapPage = (prev, next, direction) => {
383 body.append(next);
384 if (prev === next || direction === "none") {
385 if (prev !== next) {
386 prev.remove();
387 }
388 return;
389 }
390 const enter = direction === "forward" ? "page-from-right" : "page-from-left";
391 const exit = direction === "forward" ? "page-to-left" : "page-to-right";
392 next.classList.add(`desktop-mode-content-graph__${enter}`);
393 void next.offsetWidth;
394 const mySeq = ++pageSeq;
395 requestAnimationFrame(() => {
396 if (mySeq !== pageSeq) {
397 return;
398 }
399 next.classList.remove(
400 `desktop-mode-content-graph__${enter}`
401 );
402 prev.classList.add(
403 `desktop-mode-content-graph__${exit}`
404 );
405 });
406 let cleaned = false;
407 const cleanup = () => {
408 if (cleaned) {
409 return;
410 }
411 cleaned = true;
412 prev.removeEventListener("transitionend", cleanup);
413 prev.remove();
414 };
415 prev.addEventListener("transitionend", cleanup);
416 setTimeout(cleanup, 360);
417 };
418 const keyForView = (v) => {
419 switch (v.kind) {
420 case "post":
421 return null;
422 case "user":
423 return `user:${v.user.id}`;
424 case "term":
425 return `term:${v.term.taxonomy}:${v.term.id}`;
426 case "comment":
427 return `comment:${v.comment.id}`;
428 case "media":
429 return `media:${v.media.id}`;
430 case "revision":
431 return `revision:${v.revision.id}`;
432 }
433 };
434 const desktopApi = () => {
435 const wp = window.wp ?? {};
436 return wp.desktop ?? {};
437 };
438 const openAdminUrl = (href, labelText, icon, windowKey) => {
439 const api = desktopApi();
440 if (!api.windowManager || !api.deriveWindowId || !href) {
441 if (href) {
442 window.location.href = href;
443 }
444 return;
445 }
446 const focused = api.windowManager.getFocused?.();
447 if (focused?.isFullscreen?.()) {
448 focused.toggleFullscreen?.();
449 }
450 let id = api.deriveWindowId(href);
451 if (windowKey) {
452 id = `${id}-${windowKey}`;
453 }
454 api.windowManager.open({
455 id,
456 baseId: id,
457 url: href,
458 title: labelText,
459 icon
460 });
461 };
462 const renderBreadcrumb = () => {
463 breadcrumbHost.replaceChildren();
464 if (currentView.kind === "post" || !currentPost) {
465 breadcrumbHost.hidden = true;
466 return;
467 }
468 breadcrumbHost.hidden = false;
469 const back = document.createElement("button");
470 back.type = "button";
471 back.className = "desktop-mode-content-graph__panel-breadcrumb-back";
472 const arrow = document.createElement("span");
473 arrow.className = "dashicons dashicons-arrow-left-alt2 desktop-mode-content-graph__panel-breadcrumb-arrow";
474 arrow.setAttribute("aria-hidden", "true");
475 const backLabel = document.createElement("span");
476 backLabel.className = "desktop-mode-content-graph__panel-breadcrumb-label";
477 backLabel.textContent = currentPost.post.title || `#${currentPost.post.id}`;
478 back.appendChild(arrow);
479 back.appendChild(backLabel);
480 back.title = __("Back to post");
481 back.addEventListener("click", () => {
482 currentView = { kind: "post" };
483 renderCurrent();
484 });
485 breadcrumbHost.appendChild(back);
486 };
487 const renderCurrent = () => {
488 setPanelOpen(true);
489 renderBreadcrumb();
490 const next = createPage();
491 const prev = currentPage;
492 currentPage = next;
493 switch (currentView.kind) {
494 case "post":
495 renderPostView(currentPost);
496 break;
497 case "user":
498 renderUserView(currentView);
499 break;
500 case "term":
501 renderTermView(currentView);
502 break;
503 case "comment":
504 renderCommentView(currentView);
505 break;
506 case "media":
507 renderMediaView(currentView.media);
508 break;
509 case "revision":
510 renderRevisionView(currentView.revision);
511 break;
512 }
513 let direction = "none";
514 if (prevViewKind !== null && prevViewKind !== currentView.kind) {
515 if (currentView.kind === "post") {
516 direction = "back";
517 } else {
518 direction = "forward";
519 }
520 }
521 swapPage(prev, next, direction);
522 callbacks.onViewChange?.(keyForView(currentView));
523 prevViewKind = currentView.kind;
524 };
525 const renderPostView = (detail) => {
526 if (!detail) {
527 title.textContent = "";
528 meta.textContent = "";
529 return;
530 }
531 title.textContent = detail.post.title || `#${detail.post.id}`;
532 meta.textContent = `${detail.post.type} · ${detail.post.status}`;
533 currentPage.appendChild(renderAuthorBlock(detail));
534 currentPage.appendChild(renderDatesBlock(detail));
535 currentPage.appendChild(renderStatsGrid([
536 { label: __("Contributors"), value: detail.contributors.length },
537 { label: __("Comments"), value: detail.comments.length },
538 { label: __("Taxonomies"), value: detail.categories.length },
539 { label: __("Media"), value: detail.attached_media.length },
540 { label: __("Revisions"), value: detail.revisions.length }
541 ]));
542 currentPage.appendChild(renderPostActionsBlock(detail));
543 };
544 const renderAuthorBlock = (detail) => {
545 const wrap = document.createElement("div");
546 wrap.className = "desktop-mode-content-graph__panel-author";
547 if (!detail.author) {
548 wrap.hidden = true;
549 return wrap;
550 }
551 const label = document.createElement("span");
552 label.className = "desktop-mode-content-graph__panel-section-label";
553 label.textContent = __("Author");
554 wrap.appendChild(label);
555 const row = document.createElement("div");
556 row.className = "desktop-mode-content-graph__panel-author-row";
557 if (detail.author.avatar) {
558 const img = document.createElement("img");
559 img.className = "desktop-mode-content-graph__panel-avatar";
560 img.src = detail.author.avatar;
561 img.alt = "";
562 row.appendChild(img);
563 }
564 const name = document.createElement("span");
565 name.className = "desktop-mode-content-graph__panel-author-name";
566 name.textContent = detail.author.name;
567 row.appendChild(name);
568 wrap.appendChild(row);
569 return wrap;
570 };
571 const renderDatesBlock = (detail) => {
572 const wrap = document.createElement("div");
573 wrap.className = "desktop-mode-content-graph__panel-dates";
574 const items = [];
575 if (detail.post.date) {
576 items.push({ label: __("Published"), iso: detail.post.date });
577 }
578 if (detail.post.modified && detail.post.modified !== detail.post.date) {
579 items.push({
580 label: __("Modified"),
581 iso: detail.post.modified
582 });
583 }
584 if (items.length === 0) {
585 wrap.hidden = true;
586 return wrap;
587 }
588 for (const it of items) {
589 const row = document.createElement("div");
590 row.className = "desktop-mode-content-graph__panel-date-row";
591 const labelEl = document.createElement("span");
592 labelEl.className = "desktop-mode-content-graph__panel-section-label";
593 labelEl.textContent = it.label;
594 const valueEl = document.createElement("span");
595 valueEl.className = "desktop-mode-content-graph__panel-date-value";
596 valueEl.textContent = formatDate$1(it.iso);
597 row.appendChild(labelEl);
598 row.appendChild(valueEl);
599 wrap.appendChild(row);
600 }
601 return wrap;
602 };
603 const renderStatsGrid = (entries) => {
604 const ul = document.createElement("ul");
605 ul.className = "desktop-mode-content-graph__panel-stats";
606 for (const e of entries) {
607 const li = document.createElement("li");
608 li.className = "desktop-mode-content-graph__panel-stat";
609 const num = document.createElement("span");
610 num.className = "desktop-mode-content-graph__panel-stat-num";
611 num.textContent = typeof e.value === "number" ? formatNumber(e.value) : e.value;
612 const labelEl = document.createElement("span");
613 labelEl.className = "desktop-mode-content-graph__panel-stat-label";
614 labelEl.textContent = e.label;
615 li.appendChild(num);
616 li.appendChild(labelEl);
617 ul.appendChild(li);
618 }
619 return ul;
620 };
621 const renderPostActionsBlock = (detail) => {
622 const wrap = document.createElement("div");
623 wrap.className = "desktop-mode-content-graph__panel-actions";
624 const api = desktopApi();
625 if (api.myWordpress) {
626 const myWp = button({
627 label: __("Open in My WordPress"),
628 icon: "dashicons-wordpress",
629 primary: true
630 });
631 myWp.addEventListener("click", () => {
632 const entityId = detail.post.type === "page" ? "pages" : "posts";
633 api.myWordpress.openDetail({
634 entityId,
635 postId: detail.post.id,
636 postTitle: detail.post.title || `#${detail.post.id}`
637 });
638 });
639 wrap.appendChild(myWp);
640 }
641 if (detail.post.edit_url) {
642 const edit = button({
643 label: __("Edit"),
644 icon: "dashicons-edit"
645 });
646 edit.addEventListener(
647 "click",
648 () => openAdminUrl(
649 detail.post.edit_url,
650 detail.post.title,
651 "dashicons-admin-post",
652 `post-${detail.post.id}`
653 )
654 );
655 wrap.appendChild(edit);
656 }
657 if (detail.post.view_url) {
658 const view = button({
659 label: __("View"),
660 icon: "dashicons-external"
661 });
662 view.addEventListener(
663 "click",
664 () => openAdminUrl(
665 detail.post.view_url,
666 detail.post.title,
667 "dashicons-admin-post",
668 `post-${detail.post.id}`
669 )
670 );
671 wrap.appendChild(view);
672 }
673 return wrap;
674 };
675 const renderUserView = (view) => {
676 const { user, role, stats, loading } = view;
677 title.textContent = stats?.profile.name ?? user.name;
678 meta.textContent = role === "author" ? __("Author") : __("Contributor");
679 const head2 = document.createElement("div");
680 head2.className = "desktop-mode-content-graph__panel-detail-head";
681 const avatar = stats?.profile.avatarUrl || user.avatar;
682 if (avatar) {
683 const img = document.createElement("img");
684 img.className = "desktop-mode-content-graph__panel-detail-avatar desktop-mode-content-graph__panel-detail-avatar--lg";
685 img.src = avatar;
686 img.alt = "";
687 head2.appendChild(img);
688 }
689 const handleEl = document.createElement("div");
690 handleEl.className = "desktop-mode-content-graph__panel-detail-handle";
691 handleEl.innerHTML = `<strong>${escapeHtml$1(stats?.profile.name ?? user.name)}</strong><span>@${escapeHtml$1(user.slug)}</span>`;
692 head2.appendChild(handleEl);
693 currentPage.appendChild(head2);
694 if (stats?.profile.roleLabels?.length) {
695 currentPage.appendChild(
696 renderBadges(
697 __("Roles"),
698 stats.profile.roleLabels
699 )
700 );
701 }
702 if (stats?.profile.description) {
703 currentPage.appendChild(
704 renderProse(__("About"), stats.profile.description)
705 );
706 }
707 if (stats?.profile.website) {
708 currentPage.appendChild(
709 renderLinkRow(
710 __("Website"),
711 stats.profile.website,
712 stats.profile.website
713 )
714 );
715 }
716 if (stats) {
717 const cs = stats.counts;
718 currentPage.appendChild(
719 renderStatsGrid([
720 { label: __("Posts"), value: cs.posts.total },
721 { label: __("Pages"), value: cs.pages.total },
722 { label: __("CPT"), value: cs.cpt },
723 {
724 label: __("Comments received"),
725 value: cs.commentsReceived
726 },
727 {
728 label: __("Comments left"),
729 value: cs.commentsLeft
730 }
731 ])
732 );
733 }
734 if (stats?.topTerms?.length) {
735 currentPage.appendChild(
736 renderTopTerms(__("Top topics"), stats.topTerms)
737 );
738 }
739 if (stats) {
740 currentPage.appendChild(
741 renderMilestones([
742 {
743 label: __("First published"),
744 iso: stats.milestones.firstPublished
745 },
746 {
747 label: __("Last published"),
748 iso: stats.milestones.lastPublished
749 }
750 ])
751 );
752 }
753 if (loading) {
754 currentPage.appendChild(renderLoadingRow());
755 }
756 currentPage.appendChild(
757 renderActionRow({
758 label: __("Open in WordPress"),
759 icon: "dashicons-admin-users",
760 href: user.edit_url,
761 title: user.name,
762 primary: true,
763 windowKey: `user-${user.id}`
764 })
765 );
766 };
767 const renderTermView = (view) => {
768 const { term, stats, loading } = view;
769 title.textContent = stats?.profile.name ?? term.name;
770 meta.textContent = `${stats?.profile.taxonomyLabel ?? term.tax_label} · ${stats?.profile.taxonomy ?? term.taxonomy}`;
771 if (stats?.profile.parentName) {
772 currentPage.appendChild(
773 renderInlineMeta([
774 {
775 label: __("Parent"),
776 value: stats.profile.parentName
777 }
778 ])
779 );
780 }
781 if (stats?.profile.description) {
782 currentPage.appendChild(
783 renderProse(__("Description"), stats.profile.description)
784 );
785 }
786 if (stats) {
787 currentPage.appendChild(
788 renderStatsGrid([
789 { label: __("Posts"), value: stats.counts.posts.total },
790 {
791 label: __("Comments"),
792 value: stats.counts.commentsReceived
793 },
794 {
795 label: __("Authors"),
796 value: stats.counts.distinctAuthors
797 }
798 ])
799 );
800 } else {
801 currentPage.appendChild(
802 renderStatsGrid([
803 { label: __("Posts"), value: term.count }
804 ])
805 );
806 }
807 if (stats?.topAuthors?.length) {
808 currentPage.appendChild(
809 renderTopAuthors(__("Top authors"), stats.topAuthors)
810 );
811 }
812 if (stats?.coTerms?.length) {
813 currentPage.appendChild(
814 renderTopTerms(
815 __("Co-occurring"),
816 stats.coTerms.map((c) => ({
817 ...c,
818 taxonomy: term.taxonomy
819 }))
820 )
821 );
822 }
823 if (stats) {
824 currentPage.appendChild(
825 renderMilestones([
826 {
827 label: __("First post"),
828 iso: stats.milestones.firstPosted
829 },
830 {
831 label: __("Latest post"),
832 iso: stats.milestones.lastPosted
833 }
834 ])
835 );
836 }
837 if (loading) {
838 currentPage.appendChild(renderLoadingRow());
839 }
840 currentPage.appendChild(
841 renderActionRow({
842 label: __("Open in WordPress"),
843 icon: "dashicons-tag",
844 href: term.edit_url,
845 title: term.name,
846 primary: true,
847 windowKey: `term-${term.taxonomy}-${term.id}`
848 })
849 );
850 };
851 const renderCommentView = (view) => {
852 const { comment, stats, loading } = view;
853 const authorName = stats?.author.name ?? comment.author;
854 title.textContent = authorName || `#${comment.id}`;
855 meta.textContent = formatDate$1(stats?.comment.date ?? comment.date);
856 const avatar = stats?.author.avatarUrl;
857 if (avatar) {
858 const head2 = document.createElement("div");
859 head2.className = "desktop-mode-content-graph__panel-detail-head";
860 const img = document.createElement("img");
861 img.className = "desktop-mode-content-graph__panel-detail-avatar desktop-mode-content-graph__panel-detail-avatar--lg";
862 img.src = avatar;
863 img.alt = "";
864 head2.appendChild(img);
865 const handleEl = document.createElement("div");
866 handleEl.className = "desktop-mode-content-graph__panel-detail-handle";
867 handleEl.innerHTML = `<strong>${escapeHtml$1(authorName)}</strong>` + (stats?.author.totalApprovedComments ? `<span>${formatNumber(
868 stats.author.totalApprovedComments
869 )} ${escapeHtml$1(__("comments"))}</span>` : "");
870 head2.appendChild(handleEl);
871 currentPage.appendChild(head2);
872 }
873 const status = stats?.comment.status ?? __("");
874 currentPage.appendChild(
875 renderBadges(__("Status"), [status], {
876 accent: status === "1" || status === "approved" ? "green" : "amber"
877 })
878 );
879 const content = stats?.comment.rendered;
880 if (content) {
881 const wrap = document.createElement("div");
882 wrap.className = "desktop-mode-content-graph__panel-detail-section";
883 const label = document.createElement("span");
884 label.className = "desktop-mode-content-graph__panel-section-label";
885 label.textContent = __("Comment");
886 wrap.appendChild(label);
887 const html = document.createElement("div");
888 html.className = "desktop-mode-content-graph__panel-detail-html";
889 html.innerHTML = content;
890 wrap.appendChild(html);
891 currentPage.appendChild(wrap);
892 } else if (comment.excerpt) {
893 currentPage.appendChild(
894 renderProse(__("Comment"), comment.excerpt)
895 );
896 }
897 if (stats?.parent) {
898 const wrap = document.createElement("blockquote");
899 wrap.className = "desktop-mode-content-graph__panel-detail-quote";
900 wrap.innerHTML = `<strong>${escapeHtml$1(stats.parent.authorName)}</strong><span>${escapeHtml$1(stats.parent.excerpt)}</span>`;
901 currentPage.appendChild(wrap);
902 }
903 if (stats?.replies?.length) {
904 currentPage.appendChild(renderReplies(stats.replies));
905 }
906 if (loading) {
907 currentPage.appendChild(renderLoadingRow());
908 }
909 currentPage.appendChild(
910 renderActionRow({
911 label: __("Open in WordPress"),
912 icon: "dashicons-admin-comments",
913 href: comment.edit_url,
914 title: authorName || __("Comment"),
915 primary: true,
916 windowKey: `comment-${comment.id}`
917 })
918 );
919 };
920 const renderMediaView = (media) => {
921 title.textContent = media.title || `#${media.id}`;
922 meta.textContent = media.mime || __("Media");
923 if (media.thumb) {
924 const wrap = document.createElement("div");
925 wrap.className = "desktop-mode-content-graph__panel-detail-thumb";
926 const img = document.createElement("img");
927 img.src = media.thumb;
928 img.alt = media.title || "";
929 wrap.appendChild(img);
930 currentPage.appendChild(wrap);
931 }
932 currentPage.appendChild(
933 renderInlineMeta([
934 { label: __("Type"), value: media.mime }
935 ])
936 );
937 currentPage.appendChild(
938 renderActionRow({
939 label: __("Open in WordPress"),
940 icon: "dashicons-admin-media",
941 href: media.edit_url,
942 title: media.title,
943 primary: true,
944 windowKey: `media-${media.id}`
945 })
946 );
947 };
948 const renderRevisionView = (revision) => {
949 title.textContent = revision.author?.name ?? __("Revision");
950 meta.textContent = formatDate$1(revision.date);
951 if (revision.author) {
952 const head2 = document.createElement("div");
953 head2.className = "desktop-mode-content-graph__panel-detail-head";
954 if (revision.author.avatar) {
955 const img = document.createElement("img");
956 img.className = "desktop-mode-content-graph__panel-detail-avatar desktop-mode-content-graph__panel-detail-avatar--lg";
957 img.src = revision.author.avatar;
958 img.alt = "";
959 head2.appendChild(img);
960 }
961 const handleEl = document.createElement("div");
962 handleEl.className = "desktop-mode-content-graph__panel-detail-handle";
963 handleEl.innerHTML = `<strong>${escapeHtml$1(revision.author.name)}</strong><span>@${escapeHtml$1(revision.author.slug)}</span>`;
964 head2.appendChild(handleEl);
965 currentPage.appendChild(head2);
966 }
967 currentPage.appendChild(
968 renderInlineMeta([
969 { label: __("Saved"), value: formatDate$1(revision.date) }
970 ])
971 );
972 currentPage.appendChild(
973 renderActionRow({
974 label: __("Open revision in WordPress"),
975 icon: "dashicons-backup",
976 href: revision.edit_url,
977 title: __("Revision"),
978 primary: true,
979 windowKey: `revision-${revision.id}`
980 })
981 );
982 };
983 const renderProse = (label, text) => {
984 const wrap = document.createElement("div");
985 wrap.className = "desktop-mode-content-graph__panel-detail-section";
986 const labelEl = document.createElement("span");
987 labelEl.className = "desktop-mode-content-graph__panel-section-label";
988 labelEl.textContent = label;
989 const p = document.createElement("p");
990 p.className = "desktop-mode-content-graph__panel-detail-prose";
991 p.textContent = text;
992 wrap.appendChild(labelEl);
993 wrap.appendChild(p);
994 return wrap;
995 };
996 const renderBadges = (label, items, opts = {}) => {
997 const wrap = document.createElement("div");
998 wrap.className = "desktop-mode-content-graph__panel-detail-section";
999 const labelEl = document.createElement("span");
1000 labelEl.className = "desktop-mode-content-graph__panel-section-label";
1001 labelEl.textContent = label;
1002 wrap.appendChild(labelEl);
1003 const list = document.createElement("div");
1004 list.className = "desktop-mode-content-graph__panel-badges";
1005 for (const it of items) {
1006 const badge = document.createElement("span");
1007 badge.className = "desktop-mode-content-graph__panel-badge" + (opts.accent ? ` desktop-mode-content-graph__panel-badge--${opts.accent}` : "");
1008 badge.textContent = it;
1009 list.appendChild(badge);
1010 }
1011 wrap.appendChild(list);
1012 return wrap;
1013 };
1014 const renderInlineMeta = (items) => {
1015 const wrap = document.createElement("div");
1016 wrap.className = "desktop-mode-content-graph__panel-inline-meta";
1017 for (const it of items) {
1018 if (!it.value) {
1019 continue;
1020 }
1021 const row = document.createElement("div");
1022 row.className = "desktop-mode-content-graph__panel-inline-meta-row";
1023 const labelEl = document.createElement("span");
1024 labelEl.className = "desktop-mode-content-graph__panel-section-label";
1025 labelEl.textContent = it.label;
1026 const valueEl = document.createElement("span");
1027 valueEl.className = "desktop-mode-content-graph__panel-date-value";
1028 valueEl.textContent = it.value;
1029 row.appendChild(labelEl);
1030 row.appendChild(valueEl);
1031 wrap.appendChild(row);
1032 }
1033 return wrap;
1034 };
1035 const renderLinkRow = (label, text, href) => {
1036 const wrap = document.createElement("div");
1037 wrap.className = "desktop-mode-content-graph__panel-detail-section";
1038 const labelEl = document.createElement("span");
1039 labelEl.className = "desktop-mode-content-graph__panel-section-label";
1040 labelEl.textContent = label;
1041 const link = document.createElement("a");
1042 link.className = "desktop-mode-content-graph__panel-detail-link";
1043 link.href = href;
1044 link.textContent = text;
1045 link.target = "_blank";
1046 link.rel = "noopener noreferrer";
1047 wrap.appendChild(labelEl);
1048 wrap.appendChild(link);
1049 return wrap;
1050 };
1051 const renderTopTerms = (label, terms) => {
1052 const wrap = document.createElement("div");
1053 wrap.className = "desktop-mode-content-graph__panel-detail-section";
1054 const labelEl = document.createElement("span");
1055 labelEl.className = "desktop-mode-content-graph__panel-section-label";
1056 labelEl.textContent = label;
1057 wrap.appendChild(labelEl);
1058 const list = document.createElement("ul");
1059 list.className = "desktop-mode-content-graph__panel-chips";
1060 for (const t of terms.slice(0, 8)) {
1061 const li = document.createElement("li");
1062 li.className = "desktop-mode-content-graph__panel-chip";
1063 li.innerHTML = `<span>${escapeHtml$1(t.name)}</span><span class="desktop-mode-content-graph__panel-chip-count">${formatNumber(
1064 t.count
1065 )}</span>`;
1066 list.appendChild(li);
1067 }
1068 wrap.appendChild(list);
1069 return wrap;
1070 };
1071 const renderTopAuthors = (label, authors) => {
1072 const wrap = document.createElement("div");
1073 wrap.className = "desktop-mode-content-graph__panel-detail-section";
1074 const labelEl = document.createElement("span");
1075 labelEl.className = "desktop-mode-content-graph__panel-section-label";
1076 labelEl.textContent = label;
1077 wrap.appendChild(labelEl);
1078 const list = document.createElement("ul");
1079 list.className = "desktop-mode-content-graph__panel-author-list";
1080 for (const a of authors.slice(0, 6)) {
1081 const li = document.createElement("li");
1082 li.className = "desktop-mode-content-graph__panel-author-list-row";
1083 li.innerHTML = (a.userAvatarUrl ? `<img class="desktop-mode-content-graph__panel-avatar" src="${escapeAttr(
1084 a.userAvatarUrl
1085 )}" alt="" />` : "") + `<span class="desktop-mode-content-graph__panel-author-name">${escapeHtml$1(
1086 a.userName
1087 )}</span><span class="desktop-mode-content-graph__panel-chip-count">${formatNumber(
1088 a.count
1089 )}</span>`;
1090 list.appendChild(li);
1091 }
1092 wrap.appendChild(list);
1093 return wrap;
1094 };
1095 const renderMilestones = (entries) => {
1096 const filtered = entries.filter((e) => e.iso);
1097 const wrap = document.createElement("div");
1098 wrap.className = "desktop-mode-content-graph__panel-detail-section";
1099 if (filtered.length === 0) {
1100 wrap.hidden = true;
1101 return wrap;
1102 }
1103 const labelEl = document.createElement("span");
1104 labelEl.className = "desktop-mode-content-graph__panel-section-label";
1105 labelEl.textContent = __("Milestones");
1106 wrap.appendChild(labelEl);
1107 const list = document.createElement("div");
1108 list.className = "desktop-mode-content-graph__panel-milestones";
1109 for (const e of filtered) {
1110 const row = document.createElement("div");
1111 row.className = "desktop-mode-content-graph__panel-milestone-row";
1112 const k = document.createElement("span");
1113 k.className = "desktop-mode-content-graph__panel-milestone-key";
1114 k.textContent = e.label;
1115 const v = document.createElement("span");
1116 v.className = "desktop-mode-content-graph__panel-date-value";
1117 v.textContent = formatDate$1(e.iso);
1118 row.appendChild(k);
1119 row.appendChild(v);
1120 list.appendChild(row);
1121 }
1122 wrap.appendChild(list);
1123 return wrap;
1124 };
1125 const renderReplies = (replies) => {
1126 const wrap = document.createElement("div");
1127 wrap.className = "desktop-mode-content-graph__panel-detail-section";
1128 const labelEl = document.createElement("span");
1129 labelEl.className = "desktop-mode-content-graph__panel-section-label";
1130 labelEl.textContent = sprintf(
1131 /* translators: %d: number of comment replies. */
1132 __("Replies (%d)"),
1133 replies.length
1134 );
1135 wrap.appendChild(labelEl);
1136 const list = document.createElement("ul");
1137 list.className = "desktop-mode-content-graph__panel-replies";
1138 for (const r of replies.slice(0, 5)) {
1139 const li = document.createElement("li");
1140 li.className = "desktop-mode-content-graph__panel-reply";
1141 li.innerHTML = `<header><strong>${escapeHtml$1(r.authorName)}</strong><span>${formatDate$1(r.date)}</span></header><p>${escapeHtml$1(r.excerpt)}</p>`;
1142 list.appendChild(li);
1143 }
1144 wrap.appendChild(list);
1145 return wrap;
1146 };
1147 const renderLoadingRow = () => {
1148 const div = document.createElement("div");
1149 div.className = "desktop-mode-content-graph__panel-detail-loading";
1150 div.innerHTML = `<wpd-spinner></wpd-spinner><span>${escapeHtml$1(
1151 __("Loading details…")
1152 )}</span>`;
1153 return div;
1154 };
1155 const renderActionRow = (opts) => {
1156 const wrap = document.createElement("div");
1157 wrap.className = "desktop-mode-content-graph__panel-actions";
1158 if (!opts.href) {
1159 wrap.hidden = true;
1160 return wrap;
1161 }
1162 const btn = button({
1163 label: opts.label,
1164 icon: opts.icon,
1165 primary: opts.primary
1166 });
1167 btn.addEventListener(
1168 "click",
1169 () => openAdminUrl(opts.href, opts.title, opts.icon, opts.windowKey)
1170 );
1171 wrap.appendChild(btn);
1172 return wrap;
1173 };
1174 const button = (opts) => {
1175 const btn = document.createElement("button");
1176 btn.type = "button";
1177 btn.className = "desktop-mode-content-graph__btn" + (opts.primary ? " desktop-mode-content-graph__btn--primary" : "");
1178 btn.innerHTML = `<span class="dashicons ${escapeAttr(opts.icon)}" aria-hidden="true"></span><span>${escapeHtml$1(opts.label)}</span>`;
1179 return btn;
1180 };
1181 return {
1182 setLoading: (id, fallbackTitle) => {
1183 setPanelOpen(true);
1184 breadcrumbHost.hidden = true;
1185 currentView = { kind: "post" };
1186 prevViewKind = null;
1187 title.textContent = fallbackTitle ?? `#${id}`;
1188 meta.textContent = __("Loading…");
1189 body.replaceChildren();
1190 currentPage = createPage();
1191 body.append(currentPage);
1192 const loading = document.createElement("div");
1193 loading.className = "desktop-mode-content-graph__panel-loading";
1194 loading.innerHTML = "<wpd-spinner></wpd-spinner>";
1195 currentPage.appendChild(loading);
1196 callbacks.onViewChange?.(null);
1197 },
1198 setError: (message) => {
1199 setPanelOpen(true);
1200 breadcrumbHost.hidden = true;
1201 currentView = { kind: "post" };
1202 prevViewKind = null;
1203 body.replaceChildren();
1204 currentPage = createPage();
1205 body.append(currentPage);
1206 const empty = document.createElement("p");
1207 empty.className = "desktop-mode-content-graph__panel-empty";
1208 empty.textContent = message;
1209 currentPage.appendChild(empty);
1210 callbacks.onViewChange?.(null);
1211 },
1212 setDetail: (detail) => {
1213 currentPost = detail;
1214 currentView = { kind: "post" };
1215 renderCurrent();
1216 },
1217 showUser: (userId) => {
1218 if (!currentPost) {
1219 return;
1220 }
1221 const isAuthor = currentPost.author?.id === userId;
1222 const user = isAuthor ? currentPost.author : currentPost.contributors.find((c) => c.id === userId);
1223 if (!user) {
1224 return;
1225 }
1226 currentView = {
1227 kind: "user",
1228 user,
1229 role: isAuthor ? "author" : "contributor",
1230 stats: null,
1231 loading: true
1232 };
1233 renderCurrent();
1234 const seq = ++fetchSeq;
1235 void fetchUserStats(cfg, userId).then((stats) => {
1236 if (seq !== fetchSeq || currentView.kind !== "user" || currentView.user.id !== userId) {
1237 return;
1238 }
1239 currentView = { ...currentView, stats, loading: false };
1240 renderCurrent();
1241 }).catch((err) => {
1242 if (seq !== fetchSeq || currentView.kind !== "user" || currentView.user.id !== userId) {
1243 return;
1244 }
1245 currentView = { ...currentView, loading: false };
1246 renderCurrent();
1247 console.warn("[content-graph] user-stats failed", err);
1248 });
1249 },
1250 showTerm: (termId, taxonomy) => {
1251 if (!currentPost) {
1252 return;
1253 }
1254 const term = currentPost.categories.find(
1255 (t) => t.id === termId && t.taxonomy === taxonomy
1256 );
1257 if (!term) {
1258 return;
1259 }
1260 currentView = {
1261 kind: "term",
1262 term,
1263 stats: null,
1264 loading: true
1265 };
1266 renderCurrent();
1267 const seq = ++fetchSeq;
1268 void fetchTermStats(cfg, taxonomy, termId).then((stats) => {
1269 if (seq !== fetchSeq || currentView.kind !== "term" || currentView.term.id !== termId) {
1270 return;
1271 }
1272 currentView = { ...currentView, stats, loading: false };
1273 renderCurrent();
1274 }).catch((err) => {
1275 if (seq !== fetchSeq || currentView.kind !== "term" || currentView.term.id !== termId) {
1276 return;
1277 }
1278 currentView = { ...currentView, loading: false };
1279 renderCurrent();
1280 console.warn("[content-graph] term-stats failed", err);
1281 });
1282 },
1283 showComment: (commentId) => {
1284 if (!currentPost) {
1285 return;
1286 }
1287 const comment = currentPost.comments.find(
1288 (c) => c.id === commentId
1289 );
1290 if (!comment) {
1291 return;
1292 }
1293 currentView = {
1294 kind: "comment",
1295 comment,
1296 stats: null,
1297 loading: true
1298 };
1299 renderCurrent();
1300 const seq = ++fetchSeq;
1301 void fetchCommentStats(cfg, commentId).then((stats) => {
1302 if (seq !== fetchSeq || currentView.kind !== "comment" || currentView.comment.id !== commentId) {
1303 return;
1304 }
1305 currentView = { ...currentView, stats, loading: false };
1306 renderCurrent();
1307 }).catch((err) => {
1308 if (seq !== fetchSeq || currentView.kind !== "comment" || currentView.comment.id !== commentId) {
1309 return;
1310 }
1311 currentView = { ...currentView, loading: false };
1312 renderCurrent();
1313 console.warn(
1314 "[content-graph] comment-stats failed",
1315 err
1316 );
1317 });
1318 },
1319 showMedia: (mediaId) => {
1320 if (!currentPost) {
1321 return;
1322 }
1323 const media = currentPost.attached_media.find(
1324 (m) => m.id === mediaId
1325 );
1326 if (!media) {
1327 return;
1328 }
1329 currentView = { kind: "media", media };
1330 renderCurrent();
1331 },
1332 showRevision: (revisionId) => {
1333 if (!currentPost) {
1334 return;
1335 }
1336 const revision = currentPost.revisions.find(
1337 (r) => r.id === revisionId
1338 );
1339 if (!revision) {
1340 return;
1341 }
1342 currentView = { kind: "revision", revision };
1343 renderCurrent();
1344 },
1345 hide: () => {
1346 setPanelOpen(false);
1347 },
1348 destroy: () => {
1349 host.replaceChildren();
1350 }
1351 };
1352 }
1353 function formatDate$1(iso) {
1354 if (!iso) {
1355 return "";
1356 }
1357 try {
1358 return new Date(iso).toLocaleString();
1359 } catch {
1360 return iso;
1361 }
1362 }
1363 function formatNumber(n) {
1364 try {
1365 return new Intl.NumberFormat().format(n);
1366 } catch {
1367 return String(n);
1368 }
1369 }
1370 function escapeHtml$1(s) {
1371 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1372 }
1373 function escapeAttr(s) {
1374 return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1375 }
1376 let _cache = null;
1377 function parseCssContentToChar(raw) {
1378 let value = raw.trim();
1379 if (value === "") {
1380 return null;
1381 }
1382 if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
1383 value = value.slice(1, -1);
1384 }
1385 const escaped = value.match(/^\\([0-9a-f]{1,6})\s?$/i);
1386 if (escaped) {
1387 return String.fromCodePoint(parseInt(escaped[1], 16));
1388 }
1389 return value || null;
1390 }
1391 function buildMap() {
1392 const map = /* @__PURE__ */ new Map();
1393 if (typeof document === "undefined") {
1394 return map;
1395 }
1396 const sheets = Array.from(document.styleSheets ?? []);
1397 for (const sheet of sheets) {
1398 let rules = null;
1399 try {
1400 rules = sheet.cssRules;
1401 } catch {
1402 continue;
1403 }
1404 if (!rules) {
1405 continue;
1406 }
1407 for (const rule of Array.from(rules)) {
1408 const styleRule = rule;
1409 if (!styleRule || !styleRule.selectorText) {
1410 continue;
1411 }
1412 const match = styleRule.selectorText.match(
1413 /\.dashicons-([a-z0-9-]+)::?before/i
1414 );
1415 if (!match) {
1416 continue;
1417 }
1418 const content = styleRule.style?.content;
1419 if (!content) {
1420 continue;
1421 }
1422 const char = parseCssContentToChar(content);
1423 if (char) {
1424 map.set(match[1], char);
1425 }
1426 }
1427 }
1428 return map;
1429 }
1430 function resolveDashicon(name) {
1431 if (!_cache) {
1432 _cache = buildMap();
1433 }
1434 const slug = name.startsWith("dashicons-") ? name.slice("dashicons-".length) : name;
1435 return _cache.get(slug) ?? null;
1436 }
1437 function getPixi() {
1438 const pixi = window.PIXI;
1439 return pixi ?? null;
1440 }
1441 const DEFAULT_SIM_OPTIONS = {
1442 // Tuned to mirror Obsidian's airy graph: longer springs, weaker
1443 // gravity, stronger repulsion, so disconnected components drift
1444 // to the periphery instead of pancaking onto the connected core.
1445 // Repulsion is intentionally generous, on a real-world dataset
1446 // like Obsidian's vault you'd see most nodes isolated from the
1447 // connected core, and we want them spread out enough that the
1448 // initial fit doesn't crush every label on top of every other.
1449 repulsion: 26e3,
1450 springK: 0.04,
1451 springLen: 200,
1452 gravity: 35e-4,
1453 damping: 0.86
1454 };
1455 const ALPHA_DECAY = 0.992;
1456 const ALPHA_MIN = 0.01;
1457 const ALPHA_REHEAT = 1;
1458 const MAX_VELOCITY = 12;
1459 class ForceSim {
1460 constructor(nodes, edges, opts = DEFAULT_SIM_OPTIONS) {
1461 this.dragOrigin = null;
1462 this.dragInfluenceRadius = 500;
1463 this.groupAssignment = null;
1464 this.groupOrder = null;
1465 this.groupAttractorStrength = 0.12;
1466 this.groupOrderSpacing = 320;
1467 this.groupOrderStaggerY = 0;
1468 this.alpha = ALPHA_REHEAT;
1469 this.nodes = nodes;
1470 this.edges = edges;
1471 this.opts = opts;
1472 }
1473 reheat(value = ALPHA_REHEAT, kick = true) {
1474 this.alpha = Math.max(this.alpha, value);
1475 if (!kick) {
1476 return;
1477 }
1478 const kickStrength = value * 3;
1479 for (const n of this.nodes) {
1480 if (n.pinned) {
1481 continue;
1482 }
1483 n.vx += (Math.random() - 0.5) * kickStrength;
1484 n.vy += (Math.random() - 0.5) * kickStrength;
1485 }
1486 }
1487 get isSettled() {
1488 return this.alpha < ALPHA_MIN;
1489 }
1490 /**
1491 * One simulation step. `dt` defaults to 1 (one frame at the
1492 * `Pixi.Ticker`'s native pace); pass a tick's `deltaTime` to
1493 * compensate for frame skips.
1494 */
1495 step(dt = 1) {
1496 if (this.isSettled) {
1497 return;
1498 }
1499 const { repulsion, springK, springLen, gravity, damping } = this.opts;
1500 const nodes = this.nodes;
1501 const len = nodes.length;
1502 for (let i = 0; i < len; i++) {
1503 const a2 = nodes[i];
1504 for (let j = i + 1; j < len; j++) {
1505 const b = nodes[j];
1506 let dx = a2.x - b.x;
1507 let dy = a2.y - b.y;
1508 let d2 = dx * dx + dy * dy;
1509 if (d2 < 0.01) {
1510 dx = (i - j) * 0.5;
1511 dy = (i + j) * 0.5;
1512 d2 = dx * dx + dy * dy;
1513 }
1514 const f = repulsion / d2;
1515 const d = Math.sqrt(d2);
1516 const fx = dx / d * f;
1517 const fy = dy / d * f;
1518 const wa = a2.pinned ? 0 : 1;
1519 const wb = b.pinned ? 0 : 1;
1520 a2.vx += fx * wa;
1521 a2.vy += fy * wa;
1522 b.vx -= fx * wb;
1523 b.vy -= fy * wb;
1524 }
1525 }
1526 for (const e of this.edges) {
1527 const dx = e.to.x - e.from.x;
1528 const dy = e.to.y - e.from.y;
1529 const d = Math.sqrt(dx * dx + dy * dy) || 1e-4;
1530 const f = (d - springLen) * springK;
1531 const fx = dx / d * f;
1532 const fy = dy / d * f;
1533 if (!e.from.pinned) {
1534 e.from.vx += fx;
1535 e.from.vy += fy;
1536 }
1537 if (!e.to.pinned) {
1538 e.to.vx -= fx;
1539 e.to.vy -= fy;
1540 }
1541 }
1542 this.applyClusterAttractor();
1543 const a = this.alpha;
1544 const drag = this.dragOrigin;
1545 const dragR = this.dragInfluenceRadius;
1546 const dragR2 = dragR * dragR;
1547 for (const n of nodes) {
1548 if (!n.pinned) {
1549 n.vx -= n.x * gravity;
1550 n.vy -= n.y * gravity;
1551 n.vx *= damping;
1552 n.vy *= damping;
1553 if (n.vx > MAX_VELOCITY) {
1554 n.vx = MAX_VELOCITY;
1555 } else if (n.vx < -MAX_VELOCITY) {
1556 n.vx = -MAX_VELOCITY;
1557 }
1558 if (n.vy > MAX_VELOCITY) {
1559 n.vy = MAX_VELOCITY;
1560 } else if (n.vy < -MAX_VELOCITY) {
1561 n.vy = -MAX_VELOCITY;
1562 }
1563 let nodeAlpha = a;
1564 if (drag) {
1565 const ddx = n.x - drag.x;
1566 const ddy = n.y - drag.y;
1567 const dd2 = ddx * ddx + ddy * ddy;
1568 if (dd2 >= dragR2) {
1569 nodeAlpha = 0;
1570 } else {
1571 const t = 1 - Math.sqrt(dd2) / dragR;
1572 nodeAlpha *= t * t * (3 - 2 * t);
1573 }
1574 }
1575 n.x += n.vx * nodeAlpha * dt;
1576 n.y += n.vy * nodeAlpha * dt;
1577 }
1578 }
1579 this.alpha *= ALPHA_DECAY;
1580 }
1581 /**
1582 * Hand each non-pinned node a pull toward its group centroid(s).
1583 * Centroids are emergent — recomputed from current member positions
1584 * each tick — so they drift with their cluster, rather than being
1585 * pinned to a fixed lattice. Multi-membership posts (a post in two
1586 * categories) receive `attractorStrength / membershipCount` of force
1587 * per group, summed across their groups, so the post settles at
1588 * the balance between centroids.
1589 *
1590 * No-op when `groupAssignment` is null.
1591 */
1592 applyClusterAttractor() {
1593 const assignment = this.groupAssignment;
1594 if (!assignment) {
1595 return;
1596 }
1597 const k = this.groupAttractorStrength;
1598 if (k <= 0) {
1599 return;
1600 }
1601 const orderedX = /* @__PURE__ */ new Map();
1602 const orderedY = /* @__PURE__ */ new Map();
1603 if (this.groupOrder && this.groupOrder.length > 0) {
1604 const n = this.groupOrder.length;
1605 const spacing = this.groupOrderSpacing;
1606 const stagger = this.groupOrderStaggerY;
1607 for (let i = 0; i < n; i++) {
1608 const key = this.groupOrder[i];
1609 orderedX.set(key, (i - (n - 1) / 2) * spacing);
1610 if (stagger > 0) {
1611 orderedY.set(key, i % 2 === 0 ? -stagger : stagger);
1612 }
1613 }
1614 }
1615 const centroids = /* @__PURE__ */ new Map();
1616 for (const n of this.nodes) {
1617 const keys = assignment.get(n.id);
1618 if (!keys || keys.length === 0) {
1619 continue;
1620 }
1621 for (const key of keys) {
1622 const c = centroids.get(key);
1623 if (c) {
1624 c.sx += n.x;
1625 c.sy += n.y;
1626 c.count++;
1627 } else {
1628 centroids.set(key, { sx: n.x, sy: n.y, count: 1 });
1629 }
1630 }
1631 }
1632 for (const n of this.nodes) {
1633 if (n.pinned) {
1634 continue;
1635 }
1636 const keys = assignment.get(n.id);
1637 if (!keys || keys.length === 0) {
1638 continue;
1639 }
1640 const perKey = k / keys.length;
1641 for (const key of keys) {
1642 const c = centroids.get(key);
1643 if (!c || c.count === 0) {
1644 continue;
1645 }
1646 const cx = orderedX.has(key) ? orderedX.get(key) : c.sx / c.count;
1647 const cy = orderedY.has(key) ? orderedY.get(key) : c.sy / c.count;
1648 n.vx += (cx - n.x) * perKey;
1649 n.vy += (cy - n.y) * perKey;
1650 }
1651 }
1652 }
1653 /**
1654 * Public hand-off used by the scene when the toolbar's group-by
1655 * selector changes. Pass `null` for the map to disable clustering
1656 * and fall back to the existing gravity-toward-origin layout.
1657 * The optional `order` argument pins listed group keys to a
1658 * left-to-right horizontal lattice (chronological for date
1659 * facets); keys not in `order` keep emergent centroids.
1660 *
1661 * The sim is reheated so the new force visibly settles instead
1662 * of waiting for the next external nudge.
1663 */
1664 setGroupAssignment(map, order = null) {
1665 this.groupAssignment = map;
1666 this.groupOrder = map ? order : null;
1667 this.reheat(0.3, false);
1668 }
1669 }
1670 const KIND_COLOR = {
1671 user: 3829232,
1672 term: 2926970,
1673 comment: 15239482,
1674 media: 10510036,
1675 revision: 7042949
1676 };
1677 const KIND_DASHICON = {
1678 user: "admin-users",
1679 // Fallback for term kinds we don't have a specific icon for.
1680 // Per-taxonomy lookup (see `iconForTermRef`) overrides this for
1681 // the WordPress built-ins so categories don't share the tag's
1682 // visual identity.
1683 term: "tag",
1684 comment: "admin-comments",
1685 media: "admin-media",
1686 revision: "backup"
1687 };
1688 function iconForTermRef(ref) {
1689 switch (ref.taxonomy) {
1690 case "category":
1691 return "category";
1692 case "post_tag":
1693 return "tag";
1694 default:
1695 return KIND_DASHICON.term;
1696 }
1697 }
1698 const KIND_ICON_NUDGE = {
1699 user: { x: 0, y: 3 },
1700 term: { x: 0, y: 3 },
1701 // The speech-bubble dashicon is intrinsically off-balance — even
1702 // after the bbox is centred, the visible bubble drifts toward the
1703 // upper-right because the tail-less side carries more glyph mass.
1704 // Tuned by eye against the rendered output: pull a bit left, push
1705 // a bit down. Don't pile on more correction without re-checking;
1706 // what looks centred at one zoom can over-shoot at another.
1707 comment: { x: 1, y: 4 },
1708 media: { x: -1, y: 1 },
1709 revision: { x: 0, y: 3 }
1710 };
1711 const DISC_RADIUS = 14;
1712 class SatelliteLayer {
1713 constructor(pixi, satelliteParent, spokeParent, onClick, hostEl, claimPointer) {
1714 this.pixi = pixi;
1715 this.satelliteParent = satelliteParent;
1716 this.spokeParent = spokeParent;
1717 this.onClick = onClick;
1718 this.hostEl = hostEl;
1719 this.claimPointer = claimPointer;
1720 this.views = [];
1721 this.focused = null;
1722 this.rafId = null;
1723 this.selectedKey = null;
1724 this.linkGfx = new pixi.Graphics();
1725 this.spokeParent.addChild(this.linkGfx);
1726 this.layer = new pixi.Container();
1727 this.satelliteParent.addChild(this.layer);
1728 this.hoverEl = document.createElement("div");
1729 this.hoverEl.className = "desktop-mode-content-graph__tooltip";
1730 this.hoverEl.hidden = true;
1731 this.hostEl.appendChild(this.hoverEl);
1732 }
1733 clear() {
1734 this.linkGfx.clear();
1735 this.layer.removeChildren();
1736 this.views = [];
1737 this.focused = null;
1738 this.selectedKey = null;
1739 this.hideTooltip();
1740 }
1741 drawLinks() {
1742 this.linkGfx.clear();
1743 if (!this.focused || this.views.length === 0) {
1744 return;
1745 }
1746 const halo = this.focused.radius + 8;
1747 const fx = this.focused.x;
1748 const fy = this.focused.y;
1749 for (const v of this.views) {
1750 const dx = v.container.x - fx;
1751 const dy = v.container.y - fy;
1752 const d = Math.sqrt(dx * dx + dy * dy);
1753 if (d <= halo) {
1754 continue;
1755 }
1756 const t = halo / d;
1757 const sx = fx + dx * t;
1758 const sy = fy + dy * t;
1759 const color = KIND_COLOR[v.ref.kind];
1760 this.linkGfx.moveTo(sx, sy).lineTo(v.container.x, v.container.y).stroke({
1761 color,
1762 width: v.selected ? 1.8 : 1.4,
1763 alpha: v.selected ? 0.85 : 0.5
1764 });
1765 }
1766 }
1767 setFocused(focused, detail) {
1768 this.clear();
1769 this.focused = focused;
1770 const refs = this.flattenDetail(detail);
1771 if (refs.length === 0) {
1772 return;
1773 }
1774 const baseR = focused.radius;
1775 const minSpacing = 36;
1776 const ringR = Math.max(
1777 baseR + 86,
1778 baseR + 70 + refs.length * minSpacing / (2 * Math.PI)
1779 );
1780 const startAngle = -Math.PI / 2;
1781 const slice = 2 * Math.PI / refs.length;
1782 refs.forEach((ref, i) => {
1783 const angle = startAngle + i * slice;
1784 const tx = focused.x + Math.cos(angle) * ringR;
1785 const ty = focused.y + Math.sin(angle) * ringR;
1786 const view = this.buildSatellite(ref, focused.x, focused.y);
1787 view.targetX = tx;
1788 view.targetY = ty;
1789 this.views.push(view);
1790 });
1791 this.animateIn();
1792 }
1793 /**
1794 * Mark a satellite by its synthetic key (e.g. `user:123`,
1795 * `term:category:42`). Pass `null` to clear. The selected satellite
1796 * gets a thicker stroke + soft halo so the user can see which one
1797 * matches the panel content. Auto-cleared by `clear()` and on a
1798 * fresh `setFocused()`.
1799 */
1800 setSelectedKey(key) {
1801 if (this.selectedKey === key) {
1802 return;
1803 }
1804 this.selectedKey = key;
1805 for (const v of this.views) {
1806 const next = v.key === key;
1807 if (next === v.selected) {
1808 continue;
1809 }
1810 v.selected = next;
1811 this.repaintDisc(v);
1812 }
1813 this.drawLinks();
1814 }
1815 destroy() {
1816 if (this.rafId !== null) {
1817 cancelAnimationFrame(this.rafId);
1818 this.rafId = null;
1819 }
1820 this.clear();
1821 this.layer.destroy({ children: true });
1822 this.linkGfx.destroy();
1823 this.hoverEl.remove();
1824 }
1825 flattenDetail(detail) {
1826 const out = [];
1827 if (detail.author) {
1828 out.push({
1829 kind: "user",
1830 userId: detail.author.id,
1831 label: detail.author.name,
1832 meta: __("Author"),
1833 avatar: detail.author.avatar
1834 });
1835 }
1836 for (const u of detail.contributors.slice(0, 8)) {
1837 out.push({
1838 kind: "user",
1839 userId: u.id,
1840 label: u.name,
1841 meta: __("Contributor"),
1842 avatar: u.avatar
1843 });
1844 }
1845 for (const t of detail.categories.slice(0, 12)) {
1846 out.push({
1847 kind: "term",
1848 termId: t.id,
1849 taxonomy: t.taxonomy,
1850 label: t.name,
1851 meta: sprintf(
1852 /* translators: 1: taxonomy label (e.g. Category, Tag). 2: post count for the term. */
1853 __("%1$s · %2$d posts"),
1854 t.tax_label,
1855 t.count
1856 )
1857 });
1858 }
1859 for (const c of detail.comments.slice(0, 8)) {
1860 out.push({
1861 kind: "comment",
1862 commentId: c.id,
1863 label: c.author,
1864 meta: c.excerpt || formatDate(c.date)
1865 });
1866 }
1867 for (const m of detail.attached_media.slice(0, 12)) {
1868 out.push({
1869 kind: "media",
1870 mediaId: m.id,
1871 label: m.title,
1872 meta: m.mime,
1873 thumb: m.thumb
1874 });
1875 }
1876 for (const r of detail.revisions.slice(0, 8)) {
1877 out.push({
1878 kind: "revision",
1879 revisionId: r.id,
1880 parentId: detail.post.id,
1881 label: r.author?.name ?? __("Revision"),
1882 meta: formatDate(r.date)
1883 });
1884 }
1885 return out;
1886 }
1887 buildSatellite(ref, startX, startY) {
1888 const container = new this.pixi.Container();
1889 container.x = startX;
1890 container.y = startY;
1891 container.alpha = 0;
1892 container.eventMode = "static";
1893 container.cursor = "pointer";
1894 const hitR = DISC_RADIUS + 4;
1895 container.hitArea = {
1896 contains: (x, y) => {
1897 return x >= -hitR && x <= hitR && y >= -hitR && y <= hitR + 18;
1898 }
1899 };
1900 const disc = new this.pixi.Graphics();
1901 container.addChild(disc);
1902 const dashName = ref.kind === "term" ? iconForTermRef(ref) : KIND_DASHICON[ref.kind];
1903 const iconChar = resolveDashicon(dashName);
1904 const icon = new this.pixi.Text({
1905 text: iconChar ?? "?",
1906 style: {
1907 fontFamily: iconChar ? "dashicons" : "sans-serif",
1908 fontSize: iconChar ? 20 : 13,
1909 fill: 16777215
1910 },
1911 resolution: 2,
1912 anchor: { x: 0.5, y: 0.5 }
1913 });
1914 const nudge = KIND_ICON_NUDGE[ref.kind];
1915 icon.x = nudge?.x ?? 0;
1916 icon.y = nudge?.y ?? 0;
1917 container.addChild(icon);
1918 const labelText = truncate(ref.label || "", 28);
1919 const labelBg = new this.pixi.Graphics();
1920 container.addChild(labelBg);
1921 const label = new this.pixi.Text({
1922 text: labelText,
1923 style: {
1924 fill: 1711915,
1925 fontSize: 11,
1926 fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
1927 fontWeight: "500"
1928 },
1929 resolution: 2,
1930 anchor: { x: 0.5, y: 0 }
1931 });
1932 label.x = 0;
1933 label.y = DISC_RADIUS + 2;
1934 container.addChild(label);
1935 const padX = 6;
1936 const padY = 1;
1937 const lw = label.width + padX * 2;
1938 const lh = label.height + padY * 2;
1939 labelBg.roundRect(-lw / 2, label.y - padY, lw, lh, 4).fill({ color: 16777215, alpha: 0.92 }).stroke({ color: 0, alpha: 0.08, width: 1 });
1940 container.on("pointerdown", (evt) => {
1941 const e = evt;
1942 e.stopPropagation?.();
1943 this.claimPointer();
1944 });
1945 container.on("pointerover", (evt) => {
1946 disc.alpha = 1;
1947 const e = evt;
1948 this.showTooltip(ref, e.global);
1949 });
1950 container.on("pointermove", (evt) => {
1951 const e = evt;
1952 this.showTooltip(ref, e.global);
1953 });
1954 container.on("pointerout", () => {
1955 disc.alpha = 0.95;
1956 this.hideTooltip();
1957 });
1958 container.on("pointertap", (evt) => {
1959 const e = evt;
1960 e.stopPropagation?.();
1961 this.hideTooltip();
1962 this.setSelectedKey(keyForRef(ref));
1963 this.onClick(ref);
1964 });
1965 this.layer.addChild(container);
1966 const view = {
1967 ref,
1968 key: keyForRef(ref),
1969 container,
1970 disc,
1971 icon,
1972 label,
1973 targetX: startX,
1974 targetY: startY,
1975 selected: false
1976 };
1977 this.repaintDisc(view);
1978 return view;
1979 }
1980 repaintDisc(v) {
1981 const fill = KIND_COLOR[v.ref.kind];
1982 v.disc.clear();
1983 if (v.selected) {
1984 v.disc.circle(0, 0, DISC_RADIUS + 6).fill({ color: fill, alpha: 0.18 });
1985 }
1986 v.disc.circle(0, 0, DISC_RADIUS).fill({ color: fill, alpha: 0.95 }).stroke({
1987 color: 16777215,
1988 width: v.selected ? 2.5 : 1.5,
1989 alpha: 1
1990 });
1991 }
1992 animateIn() {
1993 const t0 = performance.now();
1994 const duration = 240;
1995 const starts = this.views.map((v) => ({
1996 x: v.container.x,
1997 y: v.container.y
1998 }));
1999 const frame = (now) => {
2000 const t = Math.min(1, (now - t0) / duration);
2001 const k = 1 - Math.pow(1 - t, 3);
2002 for (let i = 0; i < this.views.length; i++) {
2003 const v = this.views[i];
2004 const s = starts[i];
2005 v.container.x = s.x + (v.targetX - s.x) * k;
2006 v.container.y = s.y + (v.targetY - s.y) * k;
2007 v.container.alpha = k;
2008 }
2009 this.drawLinks();
2010 if (t < 1) {
2011 this.rafId = requestAnimationFrame(frame);
2012 } else {
2013 this.rafId = null;
2014 }
2015 };
2016 this.rafId = requestAnimationFrame(frame);
2017 }
2018 showTooltip(ref, global) {
2019 this.hoverEl.hidden = false;
2020 this.hoverEl.innerHTML = `<strong>${escapeHtml(ref.label || "")}</strong>` + (ref.meta ? `<span>${escapeHtml(ref.meta)}</span>` : "");
2021 if (global) {
2022 this.hoverEl.style.left = `${global.x + 14}px`;
2023 this.hoverEl.style.top = `${global.y + 14}px`;
2024 }
2025 }
2026 hideTooltip() {
2027 this.hoverEl.hidden = true;
2028 }
2029 }
2030 function keyForRef(ref) {
2031 switch (ref.kind) {
2032 case "user":
2033 return `user:${ref.userId}`;
2034 case "term":
2035 return `term:${ref.taxonomy}:${ref.termId}`;
2036 case "comment":
2037 return `comment:${ref.commentId}`;
2038 case "media":
2039 return `media:${ref.mediaId}`;
2040 case "revision":
2041 return `revision:${ref.revisionId}`;
2042 }
2043 }
2044 function truncate(text, max) {
2045 if (text.length <= max) {
2046 return text;
2047 }
2048 return text.slice(0, max - 1).trimEnd() + "";
2049 }
2050 function formatDate(iso) {
2051 if (!iso) {
2052 return "";
2053 }
2054 try {
2055 return new Date(iso).toLocaleString();
2056 } catch {
2057 return iso;
2058 }
2059 }
2060 function escapeHtml(s) {
2061 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2062 }
2063 const NODE_FILL = 4937059;
2064 const NODE_FILL_FOCUS = 2911205;
2065 const NODE_FILL_NEIGHBOUR = 5213171;
2066 const EDGE_BASE = 10135222;
2067 const EDGE_HOT = 2911205;
2068 const ICON_NUDGE_Y_ASCENT = 0;
2069 const ICON_NUDGE = {
2070 "admin-post": { x: 0.06, y: 0.06 }
2071 };
2072 const GROUP_LABEL_COLOR = {
2073 category: "#2c6be5",
2074 tag: "#2ca97a",
2075 author: "#7c3aed",
2076 year: "#ea580c",
2077 year_month: "#ea580c"
2078 };
2079 const ZOOM_MIN = 0.15;
2080 const ZOOM_MAX = 4;
2081 const ZOOM_SENSITIVITY = 8e-4;
2082 const CAMERA_EASE = 0.18;
2083 const CAMERA_EPSILON = 1e-3;
2084 const RESIZE_RECENTER_THRESHOLD = 24;
2085 class GraphScene {
2086 constructor(host, callbacks, onSatelliteClick, postTypes) {
2087 this.groupLabelOverlay = null;
2088 this.satellites = null;
2089 this.nodeViews = /* @__PURE__ */ new Map();
2090 this.edgeViews = [];
2091 this.groupViews = /* @__PURE__ */ new Map();
2092 this.currentGrouping = null;
2093 this.groupCatalogs = {
2094 authors: {},
2095 categories: {},
2096 tags: {}
2097 };
2098 this.groupingTween = null;
2099 this.fitFollowActive = false;
2100 this.fitFollowStartedAt = 0;
2101 this.fitFollowSawMotion = false;
2102 this.fitFollowMaxDurationMs = 3e3;
2103 this.fitFollowVelocityThreshold = 1;
2104 this.nodes = [];
2105 this.edges = [];
2106 this.sim = null;
2107 this.focusedId = null;
2108 this.hoveredId = null;
2109 this.pressedNode = null;
2110 this.dragOffset = { x: 0, y: 0 };
2111 this.isPanning = false;
2112 this.panStart = { x: 0, y: 0, wx: 0, wy: 0 };
2113 this.nodeClickActive = false;
2114 this.destroyed = false;
2115 this.tickerCb = null;
2116 this.resizeObserver = null;
2117 this.lastResizeWidth = 0;
2118 this.lastResizeHeight = 0;
2119 this.targetScale = 1;
2120 this.targetX = 0;
2121 this.targetY = 0;
2122 this.host = host;
2123 this.callbacks = callbacks;
2124 this.onSatelliteClick = onSatelliteClick;
2125 const map = /* @__PURE__ */ new Map();
2126 for (const t of postTypes) {
2127 map.set(t.slug, normalizeDashiconName(t.icon));
2128 }
2129 this.postTypeIcon = (slug) => map.get(slug) ?? defaultIconForPostType(slug);
2130 }
2131 async mount(api) {
2132 if (typeof api.loadModules === "function") {
2133 await api.loadModules(["pixijs"]);
2134 }
2135 const pixi = getPixi();
2136 if (!pixi) {
2137 throw new Error("PIXI namespace missing after loadModules.");
2138 }
2139 this.pixi = pixi;
2140 if (typeof document !== "undefined" && document.fonts) {
2141 try {
2142 await document.fonts.load("16px dashicons");
2143 } catch {
2144 }
2145 }
2146 const app = new pixi.Application();
2147 await app.init({
2148 resizeTo: this.host,
2149 backgroundAlpha: 0,
2150 antialias: true,
2151 autoDensity: true,
2152 resolution: Math.min(window.devicePixelRatio || 1, 2),
2153 // Dedicated ticker, NOT the shared one. Other desktop-mode
2154 // bundles (posts-window, recycle-bin, …) also load Pixi via
2155 // `loadModules('pixijs')` — sharing `Ticker.shared` across
2156 // independent Application instances has bitten us: a render
2157 // triggered by another bundle's app would also drive our
2158 // renderer, sometimes while the browser had perturbed our
2159 // canvas (iframe mount, layout shift) and our pipes weren't
2160 // ready, producing the "Cannot read properties of null
2161 // (reading 'clear')" crash inside `Batcher.break()`.
2162 sharedTicker: false
2163 });
2164 this.app = app;
2165 this.host.appendChild(app.canvas);
2166 app.canvas.classList.add("desktop-mode-content-graph__canvas");
2167 this.world = new pixi.Container();
2168 this.world.x = this.host.clientWidth / 2;
2169 this.world.y = this.host.clientHeight / 2;
2170 this.world.scale.set(1);
2171 this.targetX = this.world.x;
2172 this.targetY = this.world.y;
2173 this.targetScale = 1;
2174 app.stage.addChild(this.world);
2175 this.edgeLayer = new pixi.Container();
2176 this.spokeLayer = new pixi.Container();
2177 this.nodeLayer = new pixi.Container();
2178 this.labelLayer = new pixi.Container();
2179 this.world.addChild(
2180 this.edgeLayer,
2181 this.spokeLayer,
2182 this.nodeLayer,
2183 this.labelLayer
2184 );
2185 this.groupLabelOverlay = document.createElement("div");
2186 this.groupLabelOverlay.className = "desktop-mode-content-graph__group-labels";
2187 this.host.appendChild(this.groupLabelOverlay);
2188 app.canvas.addEventListener(
2189 "webglcontextlost",
2190 (ev) => {
2191 ev.preventDefault();
2192 try {
2193 this.app?.ticker?.stop();
2194 } catch {
2195 }
2196 },
2197 false
2198 );
2199 const renderer = app.renderer;
2200 const origRender = renderer.render.bind(renderer);
2201 renderer.render = (...a) => {
2202 try {
2203 return origRender(...a);
2204 } catch (err) {
2205 try {
2206 this.app?.ticker?.stop();
2207 } catch {
2208 }
2209 console.warn(
2210 "[content-graph] Pixi render threw, stopping ticker:",
2211 err
2212 );
2213 return void 0;
2214 }
2215 };
2216 this.satellites = new SatelliteLayer(
2217 pixi,
2218 this.world,
2219 this.spokeLayer,
2220 this.onSatelliteClick,
2221 this.host,
2222 () => {
2223 this.nodeClickActive = true;
2224 }
2225 );
2226 this.bindStageInput(app.canvas);
2227 this.bindResize();
2228 this.tickerCb = (ticker) => this.tick(ticker.deltaTime);
2229 app.ticker.add(this.tickerCb);
2230 }
2231 setData(payload) {
2232 const prev = /* @__PURE__ */ new Map();
2233 for (const n of this.nodes) {
2234 prev.set(n.id, n);
2235 }
2236 this.groupCatalogs = payload.groups ?? {
2237 authors: {},
2238 categories: {},
2239 tags: {}
2240 };
2241 const nodes = payload.nodes.map((p) => {
2242 const old = prev.get(p.id);
2243 const angle = Math.random() * Math.PI * 2;
2244 const r = 150 + Math.random() * 250;
2245 return {
2246 ...p,
2247 x: old?.x ?? Math.cos(angle) * r,
2248 y: old?.y ?? Math.sin(angle) * r,
2249 vx: 0,
2250 vy: 0,
2251 pinned: false,
2252 radius: 4,
2253 color: NODE_FILL,
2254 degree: 0
2255 };
2256 });
2257 const byId = /* @__PURE__ */ new Map();
2258 for (const n of nodes) {
2259 byId.set(n.id, n);
2260 }
2261 const edges = [];
2262 for (const e of payload.edges) {
2263 const f = byId.get(e.from);
2264 const t = byId.get(e.to);
2265 if (!f || !t) {
2266 continue;
2267 }
2268 f.degree++;
2269 t.degree++;
2270 edges.push({ from: f, to: t });
2271 }
2272 for (const n of nodes) {
2273 n.radius = 8 + Math.min(8, Math.sqrt(n.degree) * 2.4);
2274 }
2275 this.nodes = nodes;
2276 this.edges = edges;
2277 this.rebuildSprites();
2278 this.sim = new ForceSim(nodes, edges);
2279 this.sim.reheat(0.12, false);
2280 const warmupSteps = Math.min(90, 30 + nodes.length);
2281 for (let i = 0; i < warmupSteps; i++) {
2282 this.sim.step(1);
2283 }
2284 if (this.currentGrouping) {
2285 this.setGrouping(this.currentGrouping);
2286 }
2287 }
2288 rebuildSprites() {
2289 this.edgeLayer.removeChildren();
2290 this.nodeLayer.removeChildren();
2291 this.labelLayer.removeChildren();
2292 this.nodeViews.clear();
2293 this.edgeViews = [];
2294 for (const e of this.edges) {
2295 const gfx = new this.pixi.Graphics();
2296 this.edgeLayer.addChild(gfx);
2297 this.edgeViews.push({ edge: e, gfx });
2298 }
2299 for (const n of this.nodes) {
2300 const container = new this.pixi.Container();
2301 container.eventMode = "static";
2302 container.cursor = "pointer";
2303 container.hitArea = new this.pixi.Circle(
2304 0,
2305 0,
2306 Math.max(18, n.radius + 8)
2307 );
2308 this.bindNodeInput(container, n);
2309 this.nodeLayer.addChild(container);
2310 const halo = new this.pixi.Graphics();
2311 container.addChild(halo);
2312 const iconName = this.postTypeIcon(n.type);
2313 const iconChar = resolveDashicon(iconName);
2314 const icon = new this.pixi.Text({
2315 text: iconChar ?? "",
2316 // black circle fallback
2317 style: {
2318 fontFamily: iconChar ? "dashicons" : "sans-serif",
2319 fontSize: 2 * n.radius,
2320 fill: NODE_FILL
2321 },
2322 resolution: 2,
2323 anchor: { x: 0.5, y: 0.5 }
2324 });
2325 container.addChild(icon);
2326 const labelBox = new this.pixi.Container();
2327 this.labelLayer.addChild(labelBox);
2328 const labelBg = new this.pixi.Graphics();
2329 labelBox.addChild(labelBg);
2330 const label = new this.pixi.Text({
2331 text: this.truncate(n.title || `#${n.id}`, 32),
2332 style: {
2333 fill: 2042167,
2334 fontSize: 11,
2335 fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
2336 fontWeight: "500"
2337 },
2338 resolution: 2,
2339 anchor: { x: 0.5, y: 0 }
2340 });
2341 labelBox.addChild(label);
2342 const padX = 5;
2343 const padY = 1;
2344 const lw = label.width + padX * 2;
2345 const lh = label.height + padY * 2;
2346 labelBg.roundRect(-lw / 2, -padY, lw, lh, 4).fill({ color: 16777215, alpha: 0.78 }).stroke({
2347 color: 0,
2348 alpha: 0.06,
2349 width: 1
2350 });
2351 this.nodeViews.set(n.id, {
2352 node: n,
2353 container,
2354 halo,
2355 icon,
2356 labelBox,
2357 labelBg,
2358 label,
2359 iconCharCode: iconChar,
2360 iconName
2361 });
2362 }
2363 }
2364 truncate(text, max) {
2365 if (text.length <= max) {
2366 return text;
2367 }
2368 return text.slice(0, max - 1).trimEnd() + "";
2369 }
2370 bindNodeInput(gfx, node) {
2371 let downAt = { x: 0, y: 0 };
2372 let isDragging = false;
2373 const DRAG_THRESHOLD_SQ = 36;
2374 gfx.on("pointerdown", (evt) => {
2375 const e = evt;
2376 e.stopPropagation?.();
2377 downAt = { x: e.global.x, y: e.global.y };
2378 this.nodeClickActive = true;
2379 this.pressedNode = node;
2380 isDragging = false;
2381 node.pinned = true;
2382 node.vx = 0;
2383 node.vy = 0;
2384 });
2385 gfx.on("pointerover", () => {
2386 this.hoveredId = node.id;
2387 this.draw();
2388 });
2389 gfx.on("pointerout", () => {
2390 if (this.hoveredId === node.id) {
2391 this.hoveredId = null;
2392 this.draw();
2393 }
2394 });
2395 gfx.on("pointerup", (evt) => {
2396 const e = evt;
2397 const dx = e.global.x - downAt.x;
2398 const dy = e.global.y - downAt.y;
2399 if (!isDragging && dx * dx + dy * dy <= 256) {
2400 this.callbacks.onNodeClick?.(node);
2401 }
2402 node.pinned = this.focusedId === node.id;
2403 this.pressedNode = null;
2404 if (this.sim) {
2405 this.sim.dragOrigin = null;
2406 if (isDragging) {
2407 this.sim.reheat(0.35, false);
2408 }
2409 }
2410 isDragging = false;
2411 });
2412 gfx.on("pointerupoutside", () => {
2413 node.pinned = this.focusedId === node.id;
2414 this.pressedNode = null;
2415 if (this.sim) {
2416 this.sim.dragOrigin = null;
2417 }
2418 isDragging = false;
2419 });
2420 gfx.on("globalpointermove", (evt) => {
2421 if (this.pressedNode !== node) {
2422 return;
2423 }
2424 const e = evt;
2425 const dx = e.global.x - downAt.x;
2426 const dy = e.global.y - downAt.y;
2427 const d2 = dx * dx + dy * dy;
2428 if (!isDragging) {
2429 if (d2 < DRAG_THRESHOLD_SQ) {
2430 return;
2431 }
2432 isDragging = true;
2433 const w2 = this.toWorld(e.global.x, e.global.y);
2434 this.dragOffset = { x: node.x - w2.x, y: node.y - w2.y };
2435 if (this.sim) {
2436 this.sim.dragOrigin = { x: node.x, y: node.y };
2437 this.sim.reheat(0.3, false);
2438 }
2439 }
2440 const w = this.toWorld(e.global.x, e.global.y);
2441 node.x = w.x + this.dragOffset.x;
2442 node.y = w.y + this.dragOffset.y;
2443 node.vx = 0;
2444 node.vy = 0;
2445 if (this.sim) {
2446 this.sim.dragOrigin = { x: node.x, y: node.y };
2447 }
2448 });
2449 }
2450 bindStageInput(canvas) {
2451 canvas.addEventListener(
2452 "wheel",
2453 (ev) => {
2454 ev.preventDefault();
2455 const factor = Math.exp(-ev.deltaY * ZOOM_SENSITIVITY);
2456 const nextScale = Math.max(
2457 ZOOM_MIN,
2458 Math.min(ZOOM_MAX, this.targetScale * factor)
2459 );
2460 const rect = canvas.getBoundingClientRect();
2461 const lx = ev.clientX - rect.left;
2462 const ly = ev.clientY - rect.top;
2463 const beforeWorldX = (lx - this.targetX) / this.targetScale;
2464 const beforeWorldY = (ly - this.targetY) / this.targetScale;
2465 this.targetScale = nextScale;
2466 this.targetX = lx - beforeWorldX * nextScale;
2467 this.targetY = ly - beforeWorldY * nextScale;
2468 },
2469 { passive: false }
2470 );
2471 canvas.addEventListener("pointerdown", (ev) => {
2472 if (ev.target !== canvas) {
2473 return;
2474 }
2475 if (this.nodeClickActive) {
2476 return;
2477 }
2478 this.isPanning = true;
2479 this.panStart = {
2480 x: ev.clientX,
2481 y: ev.clientY,
2482 wx: this.world.x,
2483 wy: this.world.y
2484 };
2485 });
2486 window.addEventListener("pointermove", (ev) => {
2487 if (!this.isPanning || this.nodeClickActive) {
2488 return;
2489 }
2490 const newX = this.panStart.wx + (ev.clientX - this.panStart.x);
2491 const newY = this.panStart.wy + (ev.clientY - this.panStart.y);
2492 this.world.x = newX;
2493 this.world.y = newY;
2494 this.targetX = newX;
2495 this.targetY = newY;
2496 });
2497 window.addEventListener("pointerup", (ev) => {
2498 const nodeWasTarget = this.nodeClickActive;
2499 this.nodeClickActive = false;
2500 if (!this.isPanning) {
2501 return;
2502 }
2503 const dx = ev.clientX - this.panStart.x;
2504 const dy = ev.clientY - this.panStart.y;
2505 this.isPanning = false;
2506 if (!nodeWasTarget && dx * dx + dy * dy < 9) {
2507 this.callbacks.onBackgroundClick?.();
2508 }
2509 });
2510 }
2511 bindResize() {
2512 this.lastResizeWidth = this.host.clientWidth;
2513 this.lastResizeHeight = this.host.clientHeight;
2514 this.resizeObserver = new ResizeObserver(() => {
2515 if (this.destroyed) {
2516 return;
2517 }
2518 const w = this.host.clientWidth;
2519 const h = this.host.clientHeight;
2520 if (w <= 0 || h <= 0) {
2521 return;
2522 }
2523 try {
2524 this.app.renderer.resize(w, h);
2525 } catch {
2526 return;
2527 }
2528 try {
2529 this.app.render();
2530 } catch {
2531 }
2532 const dw = Math.abs(w - this.lastResizeWidth);
2533 const dh = Math.abs(h - this.lastResizeHeight);
2534 if (dw >= RESIZE_RECENTER_THRESHOLD || dh >= RESIZE_RECENTER_THRESHOLD) {
2535 this.lastResizeWidth = w;
2536 this.lastResizeHeight = h;
2537 }
2538 });
2539 this.resizeObserver.observe(this.host);
2540 }
2541 toWorld(clientX, clientY) {
2542 const rect = this.app.canvas.getBoundingClientRect();
2543 const lx = clientX - rect.left;
2544 const ly = clientY - rect.top;
2545 return {
2546 x: (lx - this.world.x) / this.world.scale.x,
2547 y: (ly - this.world.y) / this.world.scale.y
2548 };
2549 }
2550 tick(delta) {
2551 if (this.destroyed) {
2552 return;
2553 }
2554 if (!this.app || !this.world) {
2555 return;
2556 }
2557 if (this.groupingTween) {
2558 this.advanceGroupingTween();
2559 } else {
2560 this.sim?.step(delta);
2561 }
2562 const k = 1 - Math.pow(1 - CAMERA_EASE, delta);
2563 const ds = this.targetScale - this.world.scale.x;
2564 if (Math.abs(ds) < CAMERA_EPSILON) {
2565 this.world.scale.set(this.targetScale);
2566 } else {
2567 this.world.scale.set(this.world.scale.x + ds * k);
2568 }
2569 const dxc = this.targetX - this.world.x;
2570 const dyc = this.targetY - this.world.y;
2571 if (Math.abs(dxc) < CAMERA_EPSILON) {
2572 this.world.x = this.targetX;
2573 } else {
2574 this.world.x += dxc * k;
2575 }
2576 if (Math.abs(dyc) < CAMERA_EPSILON) {
2577 this.world.y = this.targetY;
2578 } else {
2579 this.world.y += dyc * k;
2580 }
2581 this.draw();
2582 this.drawGroupLabels();
2583 this.satellites?.drawLinks();
2584 this.advanceFitFollow();
2585 }
2586 /**
2587 * Per-tick auto-fit while the layout is still moving after a
2588 * grouping change. Re-frames the camera at the current node
2589 * bounds whenever peak velocity is above the threshold; once
2590 * the layout has settled (or the hard cap has elapsed) the
2591 * loop disarms and the camera stays put. Skips while the
2592 * grouping tween is mid-lerp — velocities are zeroed there by
2593 * design, and the initial `fitToViewOfTargets` already framed
2594 * the target bounds.
2595 */
2596 advanceFitFollow() {
2597 if (!this.fitFollowActive) {
2598 return;
2599 }
2600 if (performance.now() - this.fitFollowStartedAt > this.fitFollowMaxDurationMs) {
2601 this.fitFollowActive = false;
2602 return;
2603 }
2604 if (this.groupingTween) {
2605 return;
2606 }
2607 let peakVelocity = 0;
2608 for (const n of this.nodes) {
2609 if (n.pinned) {
2610 continue;
2611 }
2612 const v = Math.hypot(n.vx, n.vy);
2613 if (v > peakVelocity) {
2614 peakVelocity = v;
2615 }
2616 }
2617 if (peakVelocity >= this.fitFollowVelocityThreshold) {
2618 this.fitFollowSawMotion = true;
2619 this.fitToView();
2620 } else if (this.fitFollowSawMotion) {
2621 this.fitFollowActive = false;
2622 }
2623 }
2624 deriveGroupKeys(n, facet) {
2625 switch (facet) {
2626 case "category":
2627 if (n.category_ids.length === 0) {
2628 return ["cat:uncat"];
2629 }
2630 return n.category_ids.map((id) => `cat:${id}`);
2631 case "tag":
2632 if (n.tag_ids.length === 0) {
2633 return ["tag:untagged"];
2634 }
2635 return n.tag_ids.map((id) => `tag:${id}`);
2636 case "author": {
2637 const primaryId = n.author_id || 0;
2638 const primaryKey = `author:${primaryId}`;
2639 const keys = [primaryKey, primaryKey];
2640 const contribs = Array.isArray(n.contributor_ids) ? n.contributor_ids : [];
2641 for (const cid of contribs) {
2642 if (cid > 0 && cid !== primaryId) {
2643 keys.push(`author:${cid}`);
2644 }
2645 }
2646 return keys;
2647 }
2648 case "year":
2649 return [`year:${n.year || 0}`];
2650 case "year_month":
2651 return [`ym:${n.year_month || "unknown"}`];
2652 }
2653 }
2654 labelForGroupKey(key) {
2655 const idx = key.indexOf(":");
2656 const facet = key.slice(0, idx);
2657 const rest = key.slice(idx + 1);
2658 switch (facet) {
2659 case "cat": {
2660 if (rest === "uncat") {
2661 return __("Uncategorized");
2662 }
2663 const id = Number(rest);
2664 return this.groupCatalogs.categories[id]?.name ?? `#${id}`;
2665 }
2666 case "tag": {
2667 if (rest === "untagged") {
2668 return __("Untagged");
2669 }
2670 const id = Number(rest);
2671 return this.groupCatalogs.tags[id]?.name ?? `#${id}`;
2672 }
2673 case "author": {
2674 const id = Number(rest);
2675 if (id <= 0) {
2676 return __("Unknown author");
2677 }
2678 return this.groupCatalogs.authors[id]?.name ?? `#${id}`;
2679 }
2680 case "year": {
2681 const y = Number(rest);
2682 if (y <= 0) {
2683 return __("Undated");
2684 }
2685 return String(y);
2686 }
2687 case "ym": {
2688 if (rest === "unknown" || rest === "") {
2689 return __("Undated");
2690 }
2691 return formatYearMonth(rest);
2692 }
2693 }
2694 return key;
2695 }
2696 buildGroupViews(members, facet) {
2697 if (!this.groupLabelOverlay) {
2698 return;
2699 }
2700 const tint = GROUP_LABEL_COLOR[facet];
2701 for (const [key, ids] of members) {
2702 if (ids.length === 0) {
2703 continue;
2704 }
2705 const label = `${this.labelForGroupKey(key)} (${ids.length})`;
2706 const el = document.createElement("div");
2707 el.className = "desktop-mode-content-graph__group-label";
2708 el.textContent = label;
2709 el.style.setProperty("--wpd-cg-cluster-color", tint);
2710 this.groupLabelOverlay.appendChild(el);
2711 this.groupViews.set(key, { key, label, el, members: ids });
2712 }
2713 }
2714 clearGroupViews() {
2715 for (const v of this.groupViews.values()) {
2716 v.el.remove();
2717 }
2718 this.groupViews.clear();
2719 }
2720 /**
2721 * Per-frame paint of the cluster label markers. Centroid is the
2722 * running average of member positions, scale is inverse of world
2723 * scale (so labels stay legible across zoom), alpha fades the
2724 * labels OUT as you zoom past the focused-node range so they
2725 * don't clutter the close-up view.
2726 */
2727 drawGroupLabels() {
2728 if (this.destroyed || this.groupViews.size === 0) {
2729 return;
2730 }
2731 const fade = 1 - smoothstep(1.2, 2.4, this.world.scale.x);
2732 const scale = this.world.scale.x;
2733 const ox = this.world.x;
2734 const oy = this.world.y;
2735 for (const v of this.groupViews.values()) {
2736 let sumX = 0;
2737 let sumY = 0;
2738 let count = 0;
2739 for (const id of v.members) {
2740 const node = this.nodeViews.get(id)?.node;
2741 if (!node) {
2742 continue;
2743 }
2744 sumX += node.x;
2745 sumY += node.y;
2746 count++;
2747 }
2748 if (count === 0 || fade <= 0.02) {
2749 v.el.style.display = "none";
2750 continue;
2751 }
2752 const screenX = ox + sumX / count * scale;
2753 const screenY = oy + sumY / count * scale;
2754 v.el.style.display = "";
2755 v.el.style.transform = `translate(${screenX}px, ${screenY}px) translate(-50%, -50%)`;
2756 v.el.style.opacity = String(fade);
2757 }
2758 }
2759 draw() {
2760 if (this.destroyed) {
2761 return;
2762 }
2763 const focusId = this.focusedId;
2764 const hoverId = this.hoveredId;
2765 const focusNeighbours = /* @__PURE__ */ new Set();
2766 if (focusId !== null) {
2767 for (const e of this.edges) {
2768 if (e.from.id === focusId) {
2769 focusNeighbours.add(e.to.id);
2770 }
2771 if (e.to.id === focusId) {
2772 focusNeighbours.add(e.from.id);
2773 }
2774 }
2775 focusNeighbours.add(focusId);
2776 }
2777 const dimmed = focusId !== null;
2778 const edgeZoomFade = smoothstep(0.45, 1.1, this.world.scale.x);
2779 const edgeBaseAlpha = 0.2 + edgeZoomFade * 0.35;
2780 for (const v of this.edgeViews) {
2781 const { edge, gfx } = v;
2782 gfx.clear();
2783 const isFocusEdge = focusId !== null && (edge.from.id === focusId || edge.to.id === focusId);
2784 const isHoverEdge = hoverId !== null && (edge.from.id === hoverId || edge.to.id === hoverId);
2785 let alpha;
2786 if (dimmed) {
2787 alpha = isFocusEdge ? 0.85 : 0;
2788 } else if (isHoverEdge) {
2789 alpha = 0.7;
2790 } else {
2791 alpha = edgeBaseAlpha;
2792 }
2793 const color = isFocusEdge || isHoverEdge ? EDGE_HOT : EDGE_BASE;
2794 const width = isFocusEdge || isHoverEdge ? 1.2 : 0.7;
2795 let sx = edge.from.x;
2796 let sy = edge.from.y;
2797 let ex = edge.to.x;
2798 let ey = edge.to.y;
2799 if (focusId !== null) {
2800 if (edge.from.id === focusId) {
2801 const p = pointOnSegment(
2802 edge.from.x,
2803 edge.from.y,
2804 edge.to.x,
2805 edge.to.y,
2806 edge.from.radius + 8
2807 );
2808 sx = p.x;
2809 sy = p.y;
2810 }
2811 if (edge.to.id === focusId) {
2812 const p = pointOnSegment(
2813 edge.to.x,
2814 edge.to.y,
2815 edge.from.x,
2816 edge.from.y,
2817 edge.to.radius + 8
2818 );
2819 ex = p.x;
2820 ey = p.y;
2821 }
2822 }
2823 gfx.moveTo(sx, sy).lineTo(ex, ey).stroke({ color, width, alpha });
2824 }
2825 const inverseScale = 1 / this.world.scale.x;
2826 const zoomFade = smoothstep(0.55, 0.95, this.world.scale.x);
2827 for (const v of this.nodeViews.values()) {
2828 const { node, container, halo, icon, labelBox } = v;
2829 const isFocus = node.id === focusId;
2830 const isHover = node.id === hoverId;
2831 const isNeighbour = focusId !== null && focusNeighbours.has(node.id);
2832 const inFocus = focusId === null || isNeighbour;
2833 const baseAlpha = inFocus ? 1 : 0.25;
2834 container.x = node.x;
2835 container.y = node.y;
2836 container.alpha = baseAlpha;
2837 let fill = NODE_FILL;
2838 if (isFocus) {
2839 fill = NODE_FILL_FOCUS;
2840 } else if (isNeighbour) {
2841 fill = NODE_FILL_NEIGHBOUR;
2842 }
2843 halo.clear();
2844 if (isFocus || isHover) {
2845 halo.circle(0, 0, node.radius + 8).fill({
2846 color: fill,
2847 alpha: 0.18
2848 });
2849 }
2850 icon.style.fill = fill;
2851 const fontSize = 2 * node.radius;
2852 icon.style.fontSize = fontSize;
2853 const nudge = ICON_NUDGE[v.iconName];
2854 icon.x = (nudge?.x ?? 0) * fontSize;
2855 icon.y = (nudge?.y ?? ICON_NUDGE_Y_ASCENT) * fontSize;
2856 labelBox.x = node.x;
2857 labelBox.y = node.y + node.radius + 4;
2858 labelBox.scale.set(inverseScale);
2859 let baseLabelAlpha;
2860 if (isFocus) {
2861 baseLabelAlpha = 1;
2862 } else if (inFocus) {
2863 baseLabelAlpha = 0.92;
2864 } else {
2865 baseLabelAlpha = 0.32;
2866 }
2867 labelBox.alpha = baseLabelAlpha * zoomFade;
2868 labelBox.visible = labelBox.alpha > 0.01;
2869 }
2870 }
2871 focusNode(id) {
2872 if (this.focusedId !== null) {
2873 const prev = this.nodeViews.get(this.focusedId);
2874 if (prev) {
2875 prev.node.pinned = false;
2876 }
2877 }
2878 this.focusedId = id;
2879 const view = this.nodeViews.get(id);
2880 if (view) {
2881 view.node.pinned = true;
2882 view.node.vx = 0;
2883 view.node.vy = 0;
2884 const target = view.node;
2885 const newScale = Math.max(this.targetScale, 1.6);
2886 this.targetScale = newScale;
2887 this.targetX = this.host.clientWidth / 2 - target.x * newScale;
2888 this.targetY = this.host.clientHeight / 2 - target.y * newScale;
2889 }
2890 this.draw();
2891 }
2892 setFocusedDetail(detail) {
2893 if (!this.satellites) {
2894 return;
2895 }
2896 if (!detail || this.focusedId === null) {
2897 this.satellites.clear();
2898 return;
2899 }
2900 const node = this.nodeViews.get(this.focusedId)?.node;
2901 if (!node) {
2902 this.satellites.clear();
2903 return;
2904 }
2905 this.satellites.setFocused(node, detail);
2906 }
2907 /**
2908 * Swap the active clustering facet. Pass `null` to disable
2909 * clustering entirely. Computes the per-node group assignment from
2910 * the current node set, hands it to the sim (which reheats), and
2911 * rebuilds the per-cluster label markers in `groupLabelLayer`.
2912 *
2913 * Cheap to call repeatedly — there's no Pixi teardown beyond
2914 * destroying / recreating the small `GroupView` containers.
2915 */
2916 setGrouping(facet) {
2917 this.currentGrouping = facet;
2918 this.clearGroupViews();
2919 if (!facet || !this.sim) {
2920 this.sim?.setGroupAssignment(null);
2921 return;
2922 }
2923 const assignment = /* @__PURE__ */ new Map();
2924 const members = /* @__PURE__ */ new Map();
2925 for (const n of this.nodes) {
2926 const keys = this.deriveGroupKeys(n, facet);
2927 assignment.set(n.id, keys);
2928 const seen = /* @__PURE__ */ new Set();
2929 for (const key of keys) {
2930 if (seen.has(key)) {
2931 continue;
2932 }
2933 seen.add(key);
2934 const list = members.get(key);
2935 if (list) {
2936 list.push(n.id);
2937 } else {
2938 members.set(key, [n.id]);
2939 }
2940 }
2941 }
2942 const order = this.chronologicalOrder(facet, members);
2943 this.sim.groupOrderStaggerY = facet === "year_month" ? 160 : 0;
2944 this.sim.setGroupAssignment(assignment, order);
2945 const targets = this.buildGroupSeedTargets(assignment, members, order);
2946 this.startGroupingTween(targets);
2947 this.fitToViewOfTargets(targets);
2948 this.buildGroupViews(members, facet);
2949 this.fitFollowActive = true;
2950 this.fitFollowStartedAt = performance.now();
2951 this.fitFollowSawMotion = false;
2952 }
2953 /**
2954 * Drive one frame of the active grouping tween. Lerps each
2955 * non-pinned node from its captured start position to its target
2956 * with an ease-out cubic. Cleans up + resumes the sim when done.
2957 */
2958 advanceGroupingTween() {
2959 if (this.destroyed) {
2960 return;
2961 }
2962 const tween = this.groupingTween;
2963 if (!tween) {
2964 return;
2965 }
2966 const t = Math.min(1, (performance.now() - tween.startTime) / tween.duration);
2967 const k = 1 - Math.pow(1 - t, 3);
2968 for (const [nodeId, start] of tween.starts) {
2969 const target = tween.targets.get(nodeId);
2970 if (!target) {
2971 continue;
2972 }
2973 const node = this.nodeViews.get(nodeId)?.node;
2974 if (!node || node.pinned) {
2975 continue;
2976 }
2977 node.x = start.x + (target.x - start.x) * k;
2978 node.y = start.y + (target.y - start.y) * k;
2979 node.vx = 0;
2980 node.vy = 0;
2981 }
2982 if (t >= 1) {
2983 this.groupingTween = null;
2984 this.sim?.reheat(0.18, false);
2985 }
2986 }
2987 /**
2988 * Compute a per-cluster seed position + per-node target on that
2989 * seed. The tween animates each node from its current position
2990 * to its target so the user sees a smooth flow into clusters
2991 * instead of an instant snap.
2992 *
2993 * Seeds:
2994 * - **Ordered facets** (year, year-month): a horizontal lattice
2995 * matching the order array, so chronological clusters land
2996 * in the same left-to-right slots the cluster force pins
2997 * them to. Unordered keys (e.g. `'ym:unknown'`) sit to the
2998 * right of the chronological range.
2999 * - **Unordered facets** (category, tag, author): polar
3000 * distribution around the origin, radius scaling with the
3001 * number of groups. Floor keeps small group counts (2–3)
3002 * visually distinct.
3003 *
3004 * Multi-membership posts (a post in two categories) target the
3005 * average of their group seeds so they start at the force-balance
3006 * midpoint instead of being arbitrarily assigned to one cluster.
3007 */
3008 buildGroupSeedTargets(assignment, members, order) {
3009 const targets = /* @__PURE__ */ new Map();
3010 if (!this.sim) {
3011 return targets;
3012 }
3013 const groupKeys = Array.from(members.keys());
3014 const seeds = /* @__PURE__ */ new Map();
3015 const spacing = this.sim.groupOrderSpacing;
3016 if (order && order.length > 0) {
3017 const n = order.length;
3018 const stagger = this.sim.groupOrderStaggerY;
3019 const staggerY = (idx) => {
3020 if (stagger <= 0) {
3021 return 0;
3022 }
3023 return idx % 2 === 0 ? -stagger : stagger;
3024 };
3025 for (let i = 0; i < n; i++) {
3026 seeds.set(order[i], {
3027 x: (i - (n - 1) / 2) * spacing,
3028 y: staggerY(i)
3029 });
3030 }
3031 let extra = n;
3032 for (const k of groupKeys) {
3033 if (seeds.has(k)) {
3034 continue;
3035 }
3036 seeds.set(k, {
3037 x: (extra - (n - 1) / 2) * spacing,
3038 y: staggerY(extra)
3039 });
3040 extra++;
3041 }
3042 } else {
3043 const n = groupKeys.length;
3044 const radius = Math.max(220, 120 + n * 40);
3045 for (let i = 0; i < n; i++) {
3046 const angle = i / Math.max(1, n) * Math.PI * 2 - Math.PI / 2;
3047 seeds.set(groupKeys[i], {
3048 x: Math.cos(angle) * radius,
3049 y: Math.sin(angle) * radius
3050 });
3051 }
3052 }
3053 const jitter = 40;
3054 for (const node of this.nodes) {
3055 if (node.pinned) {
3056 continue;
3057 }
3058 const keys = assignment.get(node.id);
3059 if (!keys || keys.length === 0) {
3060 continue;
3061 }
3062 let sx = 0;
3063 let sy = 0;
3064 let count = 0;
3065 for (const k of keys) {
3066 const s = seeds.get(k);
3067 if (!s) {
3068 continue;
3069 }
3070 sx += s.x;
3071 sy += s.y;
3072 count++;
3073 }
3074 if (count === 0) {
3075 continue;
3076 }
3077 targets.set(node.id, {
3078 x: sx / count + (Math.random() - 0.5) * jitter,
3079 y: sy / count + (Math.random() - 0.5) * jitter
3080 });
3081 }
3082 return targets;
3083 }
3084 /**
3085 * Capture the current positions as the tween starts, set the
3086 * tween clock, and let `tick()` drive each frame from there.
3087 * Replaces any in-flight tween — picking a new facet mid-tween
3088 * just retargets from wherever the nodes currently sit.
3089 */
3090 startGroupingTween(targets) {
3091 if (targets.size === 0) {
3092 this.groupingTween = null;
3093 return;
3094 }
3095 const starts = /* @__PURE__ */ new Map();
3096 for (const nodeId of targets.keys()) {
3097 const node = this.nodeViews.get(nodeId)?.node;
3098 if (!node) {
3099 continue;
3100 }
3101 starts.set(nodeId, { x: node.x, y: node.y });
3102 }
3103 this.groupingTween = {
3104 startTime: performance.now(),
3105 // Fast enough to feel responsive, long enough to read as
3106 // a real transition (not a snap). Tuned by feel; if it
3107 // looks sluggish on slow machines, drop to 350.
3108 duration: 450,
3109 starts,
3110 targets
3111 };
3112 }
3113 /**
3114 * Frame the camera against the target bounds (not the current
3115 * node positions) so the zoom-out animates IN PARALLEL with the
3116 * layout tween instead of waiting for it to settle.
3117 */
3118 fitToViewOfTargets(targets) {
3119 if (targets.size === 0) {
3120 return;
3121 }
3122 let minX = Infinity;
3123 let minY = Infinity;
3124 let maxX = -Infinity;
3125 let maxY = -Infinity;
3126 for (const t of targets.values()) {
3127 if (t.x < minX) {
3128 minX = t.x;
3129 }
3130 if (t.y < minY) {
3131 minY = t.y;
3132 }
3133 if (t.x > maxX) {
3134 maxX = t.x;
3135 }
3136 if (t.y > maxY) {
3137 maxY = t.y;
3138 }
3139 }
3140 const padding = 100;
3141 const w = maxX - minX + padding * 2;
3142 const h = maxY - minY + padding * 2;
3143 const sx = this.host.clientWidth / w;
3144 const sy = this.host.clientHeight / h;
3145 const s = Math.max(ZOOM_MIN, Math.min(1.5, Math.min(sx, sy)));
3146 const cx = (minX + maxX) / 2;
3147 const cy = (minY + maxY) / 2;
3148 this.targetScale = s;
3149 this.targetX = this.host.clientWidth / 2 - cx * s;
3150 this.targetY = this.host.clientHeight / 2 - cy * s;
3151 }
3152 /**
3153 * For date facets, sort the keys oldest-to-newest so the cluster
3154 * attractor can lay them out left-to-right. For other facets,
3155 * returns `null` — those clusters stay fully emergent.
3156 *
3157 * `'year:<unknown>'` and `'ym:unknown'` are skipped from the
3158 * order: an undated post shouldn't bias one end of the timeline.
3159 */
3160 chronologicalOrder(facet, members) {
3161 if (facet !== "year" && facet !== "year_month") {
3162 return null;
3163 }
3164 const ordered = [];
3165 for (const key of members.keys()) {
3166 const idx = key.indexOf(":");
3167 const rest = key.slice(idx + 1);
3168 if (facet === "year") {
3169 const y = Number(rest);
3170 if (!Number.isFinite(y) || y <= 0) {
3171 continue;
3172 }
3173 ordered.push({ key, sort: String(y).padStart(6, "0") });
3174 } else {
3175 if (rest === "unknown" || rest === "") {
3176 continue;
3177 }
3178 ordered.push({ key, sort: rest });
3179 }
3180 }
3181 ordered.sort((a, b) => {
3182 if (a.sort < b.sort) {
3183 return -1;
3184 }
3185 if (a.sort > b.sort) {
3186 return 1;
3187 }
3188 return 0;
3189 });
3190 return ordered.map((e) => e.key);
3191 }
3192 clearFocus() {
3193 if (this.focusedId !== null) {
3194 const view = this.nodeViews.get(this.focusedId);
3195 if (view) {
3196 view.node.pinned = false;
3197 }
3198 }
3199 this.focusedId = null;
3200 this.satellites?.clear();
3201 this.sim?.reheat(0.25, false);
3202 this.draw();
3203 }
3204 /**
3205 * Mark a satellite by its synthetic key as selected (e.g. when
3206 * the side panel switches to that satellite's dossier). Pass
3207 * `null` to clear the selection — done when the panel navigates
3208 * back to the post view or closes entirely.
3209 */
3210 setSatelliteSelectedKey(key) {
3211 this.satellites?.setSelectedKey(key);
3212 }
3213 getNode(id) {
3214 return this.nodes.find((n) => n.id === id);
3215 }
3216 getNodes() {
3217 return this.nodes;
3218 }
3219 /**
3220 * Currently focused node id, or `null` when nothing is focused.
3221 * Used by the host orchestrator to implement click-to-deselect:
3222 * if the user clicks the already-focused node, the host calls
3223 * `clearFocus()` instead of re-focusing.
3224 */
3225 getFocusedId() {
3226 return this.focusedId;
3227 }
3228 fitToView() {
3229 if (this.nodes.length === 0) {
3230 return;
3231 }
3232 let minX = Infinity;
3233 let minY = Infinity;
3234 let maxX = -Infinity;
3235 let maxY = -Infinity;
3236 for (const n of this.nodes) {
3237 if (n.x < minX) {
3238 minX = n.x;
3239 }
3240 if (n.y < minY) {
3241 minY = n.y;
3242 }
3243 if (n.x > maxX) {
3244 maxX = n.x;
3245 }
3246 if (n.y > maxY) {
3247 maxY = n.y;
3248 }
3249 }
3250 const padding = 100;
3251 const w = maxX - minX + padding * 2;
3252 const h = maxY - minY + padding * 2;
3253 const sx = this.host.clientWidth / w;
3254 const sy = this.host.clientHeight / h;
3255 const s = Math.max(ZOOM_MIN, Math.min(1.5, Math.min(sx, sy)));
3256 const cx = (minX + maxX) / 2;
3257 const cy = (minY + maxY) / 2;
3258 this.targetScale = s;
3259 this.targetX = this.host.clientWidth / 2 - cx * s;
3260 this.targetY = this.host.clientHeight / 2 - cy * s;
3261 }
3262 destroy() {
3263 this.destroyed = true;
3264 try {
3265 this.app?.ticker?.stop();
3266 } catch {
3267 }
3268 if (this.tickerCb) {
3269 try {
3270 this.app?.ticker?.remove(this.tickerCb);
3271 } catch {
3272 }
3273 this.tickerCb = null;
3274 }
3275 this.resizeObserver?.disconnect();
3276 this.resizeObserver = null;
3277 this.satellites?.destroy();
3278 this.satellites = null;
3279 this.clearGroupViews();
3280 this.groupLabelOverlay?.remove();
3281 this.groupLabelOverlay = null;
3282 try {
3283 this.app.destroy(true, { children: true });
3284 } catch {
3285 }
3286 }
3287 }
3288 function normalizeDashiconName(raw) {
3289 if (typeof raw !== "string" || raw === "") {
3290 return "admin-generic";
3291 }
3292 if (raw.startsWith("http://") || raw.startsWith("https://")) {
3293 return "admin-generic";
3294 }
3295 return raw.replace(/^dashicons-/, "");
3296 }
3297 function pointOnSegment(fromX, fromY, toX, toY, distance) {
3298 const dx = toX - fromX;
3299 const dy = toY - fromY;
3300 const d = Math.sqrt(dx * dx + dy * dy);
3301 if (d === 0) {
3302 return { x: fromX, y: fromY };
3303 }
3304 const t = Math.min(distance / d, 1);
3305 return { x: fromX + dx * t, y: fromY + dy * t };
3306 }
3307 function smoothstep(a, b, x) {
3308 if (x <= a) {
3309 return 0;
3310 }
3311 if (x >= b) {
3312 return 1;
3313 }
3314 const t = (x - a) / (b - a);
3315 return t * t * (3 - 2 * t);
3316 }
3317 function formatYearMonth(token) {
3318 const m = /^(\d{4})-(\d{2})$/.exec(token);
3319 if (!m) {
3320 return token;
3321 }
3322 const monthIdx = Number(m[2]) - 1;
3323 if (monthIdx < 0 || monthIdx > 11) {
3324 return token;
3325 }
3326 const year = Number(m[1]);
3327 try {
3328 const d = new Date(Date.UTC(year, monthIdx, 1));
3329 return new Intl.DateTimeFormat(void 0, {
3330 month: "short",
3331 year: "numeric",
3332 timeZone: "UTC"
3333 }).format(d);
3334 } catch {
3335 return token;
3336 }
3337 }
3338 function defaultIconForPostType(slug) {
3339 switch (slug) {
3340 case "post":
3341 return "admin-post";
3342 case "page":
3343 return "admin-page";
3344 case "attachment":
3345 return "admin-media";
3346 default:
3347 return "admin-generic";
3348 }
3349 }
3350 const WINDOW_ID = "desktop-mode-content-graph";
3351 async function renderContentGraph(body) {
3352 const root = body.querySelector(
3353 "[data-desktop-mode-content-graph-root]"
3354 );
3355 if (!root) {
3356 body.textContent = __("Content Graph container missing.");
3357 return { abort: () => {
3358 } };
3359 }
3360 const cfg = getConfig();
3361 const toolbarHost = root.querySelector(
3362 "[data-desktop-mode-content-graph-toolbar]"
3363 );
3364 const stageHost = root.querySelector(
3365 "[data-desktop-mode-content-graph-stage]"
3366 );
3367 const panelHost = root.querySelector(
3368 "[data-desktop-mode-content-graph-panel]"
3369 );
3370 const loading = root.querySelector(
3371 "[data-desktop-mode-content-graph-loading]"
3372 );
3373 const desktopApi = window.wp?.desktop ?? {};
3374 let activeTypes = cfg.postTypes.map((t) => t.slug);
3375 let scene = null;
3376 let detailRequestId = 0;
3377 let aborted = false;
3378 const showLoading = (show) => {
3379 if (!loading) {
3380 return;
3381 }
3382 loading.hidden = !show;
3383 };
3384 const panel = renderPanel(panelHost, cfg, {
3385 onClose: () => {
3386 panel.hide();
3387 scene?.clearFocus();
3388 },
3389 // Mirror the panel's visible view onto the satellite layer so
3390 // the bubble matching the dossier picks up its selected state
3391 // (and clears when the user navigates back to the post view).
3392 onViewChange: (key) => {
3393 scene?.setSatelliteSelectedKey(key);
3394 }
3395 });
3396 const handleSatelliteClick = (ref) => {
3397 switch (ref.kind) {
3398 case "user":
3399 panel.showUser(ref.userId);
3400 break;
3401 case "term":
3402 panel.showTerm(ref.termId, ref.taxonomy);
3403 break;
3404 case "comment":
3405 panel.showComment(ref.commentId);
3406 break;
3407 case "media":
3408 panel.showMedia(ref.mediaId);
3409 break;
3410 case "revision":
3411 panel.showRevision(ref.revisionId);
3412 break;
3413 }
3414 };
3415 const focusNode = (node) => {
3416 scene?.focusNode(node.id);
3417 panel.setLoading(node.id, node.title);
3418 const myId = ++detailRequestId;
3419 void (async () => {
3420 try {
3421 const detail = await fetchPostDetail(cfg, node.id);
3422 if (aborted || myId !== detailRequestId) {
3423 return;
3424 }
3425 panel.setDetail(detail);
3426 scene?.setFocusedDetail(detail);
3427 } catch (err) {
3428 if (aborted || myId !== detailRequestId) {
3429 return;
3430 }
3431 panel.setError(
3432 sprintf(
3433 /* translators: %d: numeric post id that failed to load. */
3434 __("Could not load post #%d."),
3435 node.id
3436 )
3437 );
3438 console.warn("[content-graph] detail fetch failed", err);
3439 }
3440 })();
3441 };
3442 const buildToolbarCallbacks = () => ({
3443 onTypesChange: (types) => {
3444 activeTypes = types;
3445 void loadGraph();
3446 },
3447 onFitToView: () => scene?.fitToView(),
3448 onSearchSelect: (node) => focusNode(node),
3449 onGroupChange: (facet) => {
3450 scene?.setGrouping(facet);
3451 },
3452 getNodes: () => scene?.getNodes() ?? []
3453 });
3454 let toolbar = renderToolbar(
3455 toolbarHost,
3456 cfg.postTypes,
3457 buildToolbarCallbacks()
3458 );
3459 const loadGraph = async () => {
3460 if (aborted) {
3461 return;
3462 }
3463 showLoading(true);
3464 toolbar.setStatus(__("Loading graph…"));
3465 try {
3466 const payload = await fetchGraph(cfg, activeTypes);
3467 if (aborted) {
3468 return;
3469 }
3470 scene?.setData(payload);
3471 toolbar.setStatus(
3472 sprintf(
3473 /* translators: 1: number of nodes (posts/pages) in the graph. 2: number of links between them. */
3474 __("%1$d nodes · %2$d links"),
3475 payload.stats.nodes,
3476 payload.stats.edges
3477 )
3478 );
3479 scene?.fitToView();
3480 scene?.clearFocus();
3481 panel.hide();
3482 } catch (err) {
3483 if (aborted) {
3484 return;
3485 }
3486 toolbar.setStatus(__("Failed to load graph."));
3487 console.warn("[content-graph] graph fetch failed", err);
3488 } finally {
3489 showLoading(false);
3490 }
3491 };
3492 const closeFocus = () => {
3493 detailRequestId++;
3494 panel.hide();
3495 scene?.clearFocus();
3496 };
3497 scene = new GraphScene(
3498 stageHost,
3499 {
3500 onNodeClick: (node) => {
3501 if (scene?.getFocusedId() === node.id) {
3502 closeFocus();
3503 return;
3504 }
3505 focusNode(node);
3506 },
3507 onBackgroundClick: closeFocus
3508 },
3509 handleSatelliteClick,
3510 cfg.postTypes
3511 );
3512 try {
3513 await scene.mount(desktopApi);
3514 } catch (err) {
3515 stageHost.textContent = __("Could not initialise the graph renderer.");
3516 console.warn("[content-graph] scene mount failed", err);
3517 return { abort: () => {
3518 } };
3519 }
3520 try {
3521 const refreshed = await fetchPostTypes(cfg);
3522 toolbar.destroy();
3523 toolbar = renderToolbar(toolbarHost, refreshed, buildToolbarCallbacks());
3524 } catch {
3525 }
3526 await loadGraph();
3527 return {
3528 abort: () => {
3529 aborted = true;
3530 toolbar.destroy();
3531 panel.destroy();
3532 scene?.destroy();
3533 scene = null;
3534 }
3535 };
3536 }
3537 const registry = window.desktopModeNativeWindows ?? (window.desktopModeNativeWindows = {});
3538 registry[WINDOW_ID] = async (body) => {
3539 const state = await renderContentGraph(body);
3540 return state.abort;
3541 };
3542 })();
3543