PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.5
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.5, at assets/js/content-graph.js

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