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