import { dcxAsset } from './assets.js'; (() => { "use strict"; const DESKTOP_QUERY = "(min-width: 921px)"; const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; const CATEGORY_TRANSITION_MS = 520; const BRANCH_TRANSITION_MS = 320; const BRANCH_RETRACT_MS = 140; const SVG_NS = "http://www.w3.org/2000/svg"; const bezierValue = (time, pointOne, pointTwo) => { const coefficientA = 1 - 3 * pointTwo + 3 * pointOne; const coefficientB = 3 * pointTwo - 6 * pointOne; const coefficientC = 3 * pointOne; return ((coefficientA * time + coefficientB) * time + coefficientC) * time; }; const bezierSlope = (time, pointOne, pointTwo) => { const coefficientA = 1 - 3 * pointTwo + 3 * pointOne; const coefficientB = 3 * pointTwo - 6 * pointOne; const coefficientC = 3 * pointOne; return 3 * coefficientA * time * time + 2 * coefficientB * time + coefficientC; }; const materialEase = (progress) => { const target = Math.max(0, Math.min(1, progress)); let time = target; for (let iteration = 0; iteration < 8; iteration += 1) { const slope = bezierSlope(time, 0.4, 0.2); if (Math.abs(slope) < 0.000001) break; time -= (bezierValue(time, 0.4, 0.2) - target) / slope; time = Math.max(0, Math.min(1, time)); } return bezierValue(time, 0, 1); }; let railInstance = 0; let controller = null; let syncFrame = 0; const makeSvgElement = (name, attributes = {}) => { const element = document.createElementNS(SVG_NS, name); Object.entries(attributes).forEach(([attribute, value]) => element.setAttribute(attribute, value)); return element; }; class DcxMaterialRail { constructor(expander) { this.expander = expander; this.nav = expander.querySelector(".dcx-nav"); this.main = expander.querySelector(".dcx-main"); this.desktop = window.matchMedia(DESKTOP_QUERY); this.reducedMotion = window.matchMedia(REDUCED_MOTION_QUERY); this.instanceId = ++railInstance; this.currentArticle = null; this.currentCategory = ""; this.currentSubnav = null; this.contentSignature = ""; this.entries = []; this.segments = new Map(); this.railLength = 0; this.railReady = false; this.pinnedNode = null; this.scrollFrame = 0; this.rebuildFrame = 0; this.rebuildTimers = []; this.categoryTransitionBeginFrame = 0; this.categoryTransitionFrame = 0; this.categoryTransitionHandoffFrame = 0; this.categoryTransitionCleanupTimer = 0; this.categoryTransitionGeneration = 0; this.categoryTransitionActive = false; this.categoryTransitionStart = 0; this.categoryTransitionTarget = null; this.categoryTransitionFinalTarget = null; this.categoryTransitionNode = null; this.categoryTransitionBranchPoints = null; this.categoryTransitionBranchProgress = 0; this.categoryTransitionRetractPoints = null; this.categoryTransitionRetractProgress = 0; this.categoryTransitionRetractStart = 0; this.categoryTransitionRenderedPoints = null; this.categoryTransitionRenderedProgress = 0; this.currentPaintNode = null; if (!this.nav || !this.main) return; this.handleScroll = this.handleScroll.bind(this); this.handleResize = this.handleResize.bind(this); this.handleMediaChange = this.handleMediaChange.bind(this); this.handleReducedMotionChange = this.handleReducedMotionChange.bind(this); this.handleRailClick = this.handleRailClick.bind(this); this.handleContinuousCategoryWillChange = this.handleContinuousCategoryWillChange.bind(this); this.handleContinuousCategoryChange = this.handleContinuousCategoryChange.bind(this); this.clearPinnedNode = this.clearPinnedNode.bind(this); this.handleKeydown = this.handleKeydown.bind(this); this.createRail(); this.nav.classList.add("dcx-material-rail-host"); this.remeasureSubnavs(); this.main.addEventListener("scroll", this.handleScroll, { passive: true }); this.main.addEventListener("wheel", this.clearPinnedNode, { passive: true }); this.main.addEventListener("touchstart", this.clearPinnedNode, { passive: true }); this.main.addEventListener("pointerdown", this.clearPinnedNode, { passive: true }); this.nav.addEventListener("click", this.handleRailClick); this.expander.addEventListener("dcx:continuouscategorywillchange", this.handleContinuousCategoryWillChange); this.expander.addEventListener("dcx:continuouscategorychange", this.handleContinuousCategoryChange); window.addEventListener("resize", this.handleResize, { passive: true }); window.addEventListener("keydown", this.handleKeydown); this.desktop.addEventListener("change", this.handleMediaChange); this.reducedMotion.addEventListener("change", this.handleReducedMotionChange); this.resizeObserver = new ResizeObserver(this.handleResize); this.resizeObserver.observe(this.nav); document.fonts?.ready.then(() => { if (controller !== this) return; this.remeasureSubnavs(); this.scheduleRebuild(true); }); } createRail() { const patinaId = `dcx-material-rail-patina-${this.instanceId}`; const goldId = `dcx-material-rail-gold-${this.instanceId}`; const svg = makeSvgElement("svg", { class: "dcx-material-rail", "aria-hidden": "true", focusable: "false", }); const defs = makeSvgElement("defs"); const patinaPattern = makeSvgElement("pattern", { id: patinaId, width: "72", height: "48", patternUnits: "userSpaceOnUse", }); const goldPattern = makeSvgElement("pattern", { id: goldId, width: "72", height: "48", patternUnits: "userSpaceOnUse", }); patinaPattern.append( makeSvgElement("rect", { class: "dcx-material-rail__patina-base", width: "72", height: "48", }), makeSvgElement("image", { class: "dcx-material-rail__patina-image", href: dcxAsset("/assets/audience/verdigris-patina.png"), width: "72", height: "48", preserveAspectRatio: "xMidYMid slice", }), ); goldPattern.append( makeSvgElement("rect", { class: "dcx-material-rail__gold-base", width: "72", height: "48", }), makeSvgElement("image", { class: "dcx-material-rail__gold-image", href: dcxAsset("/assets/audience/kinpaku-gold-leaf.png"), width: "72", height: "48", preserveAspectRatio: "xMidYMid slice", }), ); this.track = makeSvgElement("path", { class: "dcx-material-rail__track", "data-dcx-rail-track": "", }); this.active = makeSvgElement("path", { class: "dcx-material-rail__active", "data-dcx-rail-active": "", }); this.transitionTrack = makeSvgElement("path", { class: "dcx-material-rail__transition-track", }); this.transitionActive = makeSvgElement("line", { class: "dcx-material-rail__transition-active", }); this.track.style.stroke = `url("#${patinaId}")`; this.active.style.stroke = `url("#${goldId}")`; this.transitionTrack.style.stroke = `url("#${patinaId}")`; this.transitionActive.style.stroke = `url("#${goldId}")`; defs.append(patinaPattern, goldPattern); svg.append( defs, this.transitionTrack, this.track, this.active, this.transitionActive, ); this.svg = svg; this.nav.prepend(svg); } prepareAudienceSubnav(subnav, blocks) { const descriptors = [ { label: "Overview", targetLabels: [], overview: true }, { label: "People", targetLabels: ["Who they are", "Emotional journey", "Emotional state"] }, { label: "Decision factors", targetLabels: ["Needs", "Trust triggers"] }, { label: "Inclusion", targetLabels: ["Who must not be excluded"] }, ]; const available = new Map(blocks.map((block, index) => [block.dataset.label, { block, index }])); const resolved = descriptors.map((descriptor) => { if (descriptor.overview) return descriptor; const firstTarget = descriptor.targetLabels.find((label) => available.has(label)); return firstTarget ? { ...descriptor, firstTarget } : null; }).filter(Boolean); const expected = resolved.map((descriptor) => descriptor.label).join("|"); const existing = [...subnav.querySelectorAll(":scope > .dcx-sub-link[data-dcx-rail-summary]")] .map((button) => button.textContent.trim()) .join("|"); subnav.classList.add("dcx-subnav--summary"); subnav.dataset.dcxRailSummary = "true"; if (existing === expected && subnav.childElementCount === resolved.length) return; const buttons = resolved.map((descriptor) => { const button = document.createElement("button"); button.className = "dcx-sub-link"; button.type = "button"; button.textContent = descriptor.label; button.dataset.dcxRailSummary = ""; button.dataset.dcxRailTarget = descriptor.overview ? "__overview__" : descriptor.firstTarget; if (!descriptor.overview) { button.dataset.dcxSubsection = String(available.get(descriptor.firstTarget).index); } return button; }); subnav.replaceChildren(...buttons); } refresh() { const activeItem = this.nav.querySelector(".dcx-nav-list > li.is-active[data-category]"); const category = activeItem?.dataset.category || ""; const continuous = this.main.dataset.dcxContinuous === "true"; const article = continuous ? this.main.querySelector(`:scope > .dcx-article[data-dcx-category="${category}"]`) : this.main.querySelector(":scope > .dcx-article"); if (!activeItem || !article) { this.nav.removeAttribute("data-rail-ready"); return; } const subnav = activeItem.querySelector(":scope > .dcx-subnav"); const blocks = [...article.querySelectorAll(":scope > .dcx-block[data-label]")]; if (!subnav) return; if (!continuous) { this.nav.querySelectorAll(".dcx-subnav").forEach((candidate) => { if (candidate === subnav && category === "audience") return; candidate.classList.remove("dcx-subnav--summary"); delete candidate.dataset.dcxRailSummary; }); } if (category === "audience" && !continuous) this.prepareAudienceSubnav(subnav, blocks); const buttonLabels = [...subnav.querySelectorAll(":scope > .dcx-sub-link")] .map((button) => button.textContent.trim()) .join("|"); const blockLabels = blocks.map((block) => block.dataset.label).join("|"); const signature = `${category}::${buttonLabels}::${blockLabels}`; const liveRailNodes = [ ...this.nav.querySelectorAll(".dcx-nav-list > li > .dcx-nav-link"), ...subnav.querySelectorAll(":scope > .dcx-sub-link"), ]; const railMarkupReady = liveRailNodes.length > 0 && liveRailNodes.every((node) => node.hasAttribute("data-dcx-rail-node")); const sameContent = this.currentArticle === article && this.currentCategory === category && this.currentSubnav === subnav && this.contentSignature === signature && railMarkupReady; if (sameContent) return; this.currentArticle = article; this.currentCategory = category; this.currentSubnav = subnav; this.contentSignature = signature; this.pinnedNode = null; this.rebuildTimers.forEach((timer) => window.clearTimeout(timer)); this.rebuildTimers = []; this.nav.querySelectorAll("[data-dcx-rail-node]").forEach((node) => { node.removeAttribute("data-dcx-rail-node"); }); this.nav.querySelectorAll('.dcx-sub-link[aria-current="location"]').forEach((button) => { button.removeAttribute("aria-current"); }); const topLinks = [...this.nav.querySelectorAll(".dcx-nav-list > li > .dcx-nav-link")]; const subLinks = [...subnav.querySelectorAll(":scope > .dcx-sub-link")]; topLinks.forEach((link) => link.setAttribute("data-dcx-rail-node", "")); subLinks.forEach((link) => link.setAttribute("data-dcx-rail-node", "")); const activeTopLink = activeItem.querySelector(":scope > .dcx-nav-link"); const blockForIndex = (button) => blocks[Number(button.dataset.dcxSubsection)]; const entries = []; if ((continuous || category !== "audience") && activeTopLink) { entries.push({ node: activeTopLink, target: article.querySelector(":scope > header") || article }); } subLinks.forEach((button) => { const summaryTarget = button.dataset.dcxRailTarget; const documentTarget = button.dataset.dcxDocumentTarget; const target = documentTarget ? this.main.querySelector(`#${CSS.escape(documentTarget)}`) : summaryTarget === "__overview__" ? article.querySelector(":scope > header") || article : summaryTarget ? blocks.find((block) => block.dataset.label === summaryTarget) : blockForIndex(button); if (target) entries.push({ node: button, target }); }); this.entries = entries; this.railReady = false; this.rebuild(true); if (!this.categoryTransitionActive) { [0, 320, 650].forEach((delay) => { this.rebuildTimers.push(window.setTimeout(() => { if (controller === this && this.currentArticle === article) this.rebuild(true); }, delay)); }); } } measureRailPoints() { if (!this.desktop.matches || !this.nav.offsetHeight || !this.track || !this.active) return []; const navRect = this.nav.getBoundingClientRect(); const nodes = [...this.nav.querySelectorAll("[data-dcx-rail-node]")] .filter((node) => node.getClientRects().length > 0); if (!nodes.length) return []; return nodes.map((node) => { const rect = node.getBoundingClientRect(); return { node, x: rect.left - navRect.left + 1, yTop: rect.top - navRect.top, yBottom: rect.bottom - navRect.top, }; }); } pathDataForProgress(points, progress = 1) { if (!points.length) return ""; const spineX = points[0].x; const morphX = (value) => spineX + (value - spineX) * progress; let pathData = ""; let previous = null; points.forEach((point, index) => { const pointX = morphX(point.x); if (index === 0) { pathData = `M ${pointX} ${point.yTop} `; } else if (Math.abs(point.x - previous.x) < 0.5) { pathData += `L ${pointX} ${point.yTop} `; } else { const previousX = morphX(previous.x); const midpoint = (previous.yBottom + point.yTop) / 2; pathData += `C ${previousX} ${midpoint}, ${pointX} ${midpoint}, ${pointX} ${point.yTop} `; } pathData += `L ${pointX} ${point.yBottom} `; previous = point; }); return pathData; } buildPath() { const points = this.measureRailPoints(); if (!points.length) return false; let pathData = ""; let previous = null; const nextSegments = new Map(); points.forEach((point, index) => { if (index === 0) { pathData = `M ${point.x} ${point.yTop} `; } else if (Math.abs(point.x - previous.x) < 0.5) { pathData += `L ${point.x} ${point.yTop} `; } else { const midpoint = (previous.yBottom + point.yTop) / 2; pathData += `C ${previous.x} ${midpoint}, ${point.x} ${midpoint}, ${point.x} ${point.yTop} `; } this.active.setAttribute("d", pathData); const start = this.active.getTotalLength(); pathData += `L ${point.x} ${point.yBottom} `; this.active.setAttribute("d", pathData); const end = this.active.getTotalLength(); nextSegments.set(point.node, { start, length: Math.max(1, end - start) }); previous = point; }); this.track.setAttribute("d", pathData); this.active.setAttribute("d", pathData); this.segments = nextSegments; this.railLength = this.active.getTotalLength(); return Number.isFinite(this.railLength) && this.railLength > 0; } moveRail(node, instant = false) { const segment = this.segments.get(node); if (!segment || !this.railLength) return; this.currentPaintNode = node; if (instant || this.reducedMotion.matches) { this.active.setAttribute("data-initial", "true"); this.active.getBoundingClientRect(); } this.active.style.strokeDasharray = `${segment.length} ${this.railLength + 1}`; this.active.style.strokeDashoffset = `${-segment.start}`; requestAnimationFrame(() => { if (controller !== this) return; this.active.setAttribute("data-ready", "true"); this.nav.setAttribute("data-rail-ready", "true"); requestAnimationFrame(() => this.active.removeAttribute("data-initial")); }); this.railReady = true; } updateCurrent(instant = false) { if (!this.entries.length || !this.desktop.matches) return; const mainRect = this.main.getBoundingClientRect(); const marker = mainRect.top + this.main.clientHeight * 0.25; let current = this.entries.find((entry) => entry.node === this.pinnedNode) || this.entries.find((entry) => entry.node === this.categoryTransitionNode) || this.entries[0]; if (!this.pinnedNode && !this.categoryTransitionNode) { if (this.main.scrollTop > 4) { this.entries.forEach((entry) => { if (entry.target.getBoundingClientRect().top <= marker) current = entry; }); if (this.main.scrollTop + this.main.clientHeight >= this.main.scrollHeight - 4) { current = this.entries.at(-1); } } } this.nav.querySelectorAll('.dcx-sub-link[aria-current="location"]').forEach((button) => { if (button !== current.node) button.removeAttribute("aria-current"); }); if (current.node.matches(".dcx-sub-link")) { current.node.setAttribute("aria-current", "location"); } this.moveRail(current.node, instant || !this.railReady); } rebuild(instant = false) { if (!this.desktop.matches) { this.nav.removeAttribute("data-rail-ready"); this.active.removeAttribute("data-ready"); return; } if (!this.buildPath()) return; this.updateCurrent(instant); } scheduleRebuild(instant = false) { if (this.rebuildFrame) cancelAnimationFrame(this.rebuildFrame); this.rebuildFrame = requestAnimationFrame(() => { this.rebuildFrame = 0; this.rebuild(instant); }); } handleScroll() { if (this.scrollFrame || !this.desktop.matches) return; this.scrollFrame = requestAnimationFrame(() => { this.scrollFrame = 0; if (this.main.dataset.dcxContinuous === "true") this.refresh(); this.updateCurrent(); }); } nodeLine(node) { if (!node?.getClientRects().length) return null; const navRect = this.nav.getBoundingClientRect(); const rect = node.getBoundingClientRect(); return { x: rect.left - navRect.left + 1, yTop: rect.top - navRect.top, yBottom: rect.bottom - navRect.top, }; } scrollTargetNode(category, topLink) { const article = this.main.querySelector(`:scope > .dcx-article[data-dcx-category="${CSS.escape(category)}"]`); const subnav = this.nav.querySelector( `.dcx-nav-list > li[data-category="${CSS.escape(category)}"] > .dcx-subnav`, ); if (!article || !subnav) return topLink; const mainRect = this.main.getBoundingClientRect(); const marker = mainRect.top + this.main.clientHeight * 0.25; let visibleBlock = null; article.querySelectorAll(":scope > .dcx-block[data-label]").forEach((block) => { if (block.getBoundingClientRect().top <= marker) visibleBlock = block; }); if (!visibleBlock) return topLink; const exact = [...subnav.querySelectorAll(":scope > .dcx-sub-link[data-dcx-document-target]")] .find((link) => link.dataset.dcxDocumentTarget === visibleBlock.id); if (exact) return exact; if (category !== "audience") return topLink; const label = visibleBlock.dataset.label; const group = label === "Who they are" || label === "Emotional journey" || label === "Emotional state" ? "People" : label === "Needs" || label === "Trust triggers" ? "Decision factors" : label === "Who must not be excluded" ? "Inclusion" : null; if (!group) return topLink; return [...subnav.querySelectorAll(":scope > .dcx-sub-link")] .find((link) => link.textContent.trim() === group) || topLink; } remeasureSubnavs() { if (!this.desktop.matches) return; this.nav.querySelectorAll(".dcx-nav-list > li[data-category] > .dcx-subnav").forEach((subnav) => { const links = [...subnav.querySelectorAll(":scope > .dcx-sub-link")]; const heights = links.map((link) => link.getBoundingClientRect().height); if (!heights.some((height) => height > 0)) return; const summary = subnav.classList.contains("dcx-subnav--summary"); const gap = summary ? 0 : 2; const padding = summary ? 14 : 10; const height = heights.reduce((total, value) => total + value, 0) + Math.max(0, links.length - 1) * gap + padding; subnav.style.setProperty("--dcx-subnav-height", `${Math.ceil(height)}px`); }); } finalNodeLine(toCategory, target) { const links = [...this.nav.querySelectorAll(".dcx-nav-list > li[data-category] > .dcx-nav-link")]; const toIndex = links.findIndex((link) => link.dataset.dcxNav === toCategory); const targetTopLink = links[toIndex]; if (!target || !targetTopLink || toIndex < 0 || !links.length) return null; const navRect = this.nav.getBoundingClientRect(); const firstRect = links[0].getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); let yTop = firstRect.top - navRect.top; for (let index = 0; index < toIndex; index += 1) { yTop += links[index].getBoundingClientRect().height; } if (target === targetTopLink) { return { x: targetRect.left - navRect.left + 1, yTop, yBottom: yTop + targetRect.height, }; } const subnav = targetTopLink.closest("li[data-category]") ?.querySelector(":scope > .dcx-subnav"); const subLinks = [...subnav?.querySelectorAll(":scope > .dcx-sub-link") || []]; const targetIndex = subLinks.indexOf(target); if (!subnav || targetIndex < 0) return null; const summary = subnav.classList.contains("dcx-subnav--summary"); const gap = summary ? 0 : 2; const paddingTop = summary ? 4 : 2; yTop += targetTopLink.getBoundingClientRect().height + paddingTop; for (let index = 0; index < targetIndex; index += 1) { yTop += subLinks[index].getBoundingClientRect().height + gap; } return { x: targetRect.left - navRect.left + 1, yTop, yBottom: yTop + targetRect.height, }; } transitionLineGeometry() { if (!this.transitionActive.hasAttribute("data-visible")) return null; const navRect = this.nav.getBoundingClientRect(); const rect = this.transitionActive.getBoundingClientRect(); if (!rect.height) return null; return { x: rect.left - navRect.left + rect.width / 2, yTop: rect.top - navRect.top, yBottom: rect.bottom - navRect.top, }; } measureStraightTrack() { if (!this.desktop.matches) return false; const links = [...this.nav.querySelectorAll(".dcx-nav-list > li[data-category] > .dcx-nav-link")] .filter((link) => link.getClientRects().length > 0); if (!links.length) return false; const navRect = this.nav.getBoundingClientRect(); const firstRect = links[0].getBoundingClientRect(); const lastRect = links.at(-1).getBoundingClientRect(); const x = firstRect.left - navRect.left + 1; const yTop = firstRect.top - navRect.top; const yBottom = lastRect.bottom - navRect.top; return { x, yTop, yBottom }; } buildStraightTrack() { const spine = this.measureStraightTrack(); if (!spine) return false; this.transitionTrack.setAttribute( "d", `M ${spine.x} ${spine.yTop} L ${spine.x} ${spine.yBottom}`, ); return true; } fitPointsToStraightTrack(points, spine) { if (!points?.length || !spine) return []; const sourceTop = points[0].yTop; const sourceBottom = points.at(-1).yBottom; const sourceHeight = Math.max(1, sourceBottom - sourceTop); const heightScale = (spine.yBottom - spine.yTop) / sourceHeight; const sourceX = points[0].x; return points.map((point) => ({ ...point, x: spine.x + point.x - sourceX, yTop: spine.yTop + (point.yTop - sourceTop) * heightScale, yBottom: spine.yTop + (point.yBottom - sourceTop) * heightScale, })); } moveTransitionActive(target) { if (!target) return; const sourceHeight = Math.max(1, Number(this.transitionActive.getAttribute("y2")) - Number(this.transitionActive.getAttribute("y1"))); const sourceX = Number(this.transitionActive.getAttribute("x1")); const sourceTop = Number(this.transitionActive.getAttribute("y1")); const targetHeight = Math.max(1, target.yBottom - target.yTop); const deltaX = target.x - sourceX; const deltaY = target.yTop - sourceTop + (targetHeight - sourceHeight) / 2; const scaleY = targetHeight / sourceHeight; this.transitionActive.style.transform = `translate(${deltaX}px, ${deltaY}px) scaleY(${scaleY})`; } clearCategoryTransitionVisuals() { window.clearTimeout(this.categoryTransitionCleanupTimer); this.categoryTransitionCleanupTimer = 0; this.transitionTrack.removeAttribute("data-visible"); this.transitionTrack.removeAttribute("data-initial"); this.transitionTrack.removeAttribute("data-settling"); this.transitionActive.removeAttribute("data-visible"); this.transitionActive.removeAttribute("data-initial"); this.transitionActive.removeAttribute("data-settling"); this.transitionActive.removeAttribute("data-handoff"); this.track.removeAttribute("data-entering"); this.track.removeAttribute("data-settling"); this.track.removeAttribute("data-morphing"); this.active.removeAttribute("data-entering"); this.active.removeAttribute("data-handoff"); this.transitionActive.style.removeProperty("transform"); this.nav.removeAttribute("data-category-transition"); } cancelCategoryTransitionFrames() { if (this.categoryTransitionBeginFrame) cancelAnimationFrame(this.categoryTransitionBeginFrame); if (this.categoryTransitionFrame) cancelAnimationFrame(this.categoryTransitionFrame); if (this.categoryTransitionHandoffFrame) cancelAnimationFrame(this.categoryTransitionHandoffFrame); this.categoryTransitionBeginFrame = 0; this.categoryTransitionFrame = 0; this.categoryTransitionHandoffFrame = 0; } cancelCategoryTransition() { this.categoryTransitionGeneration += 1; this.cancelCategoryTransitionFrames(); this.categoryTransitionActive = false; this.categoryTransitionTarget = null; this.categoryTransitionFinalTarget = null; this.categoryTransitionNode = null; this.categoryTransitionBranchPoints = null; this.categoryTransitionBranchProgress = 0; this.categoryTransitionRetractPoints = null; this.categoryTransitionRetractProgress = 0; this.categoryTransitionRetractStart = 0; this.categoryTransitionRenderedPoints = null; this.categoryTransitionRenderedProgress = 0; this.clearCategoryTransitionVisuals(); this.transitionTrack.removeAttribute("d"); this.transitionActive.removeAttribute("x1"); this.transitionActive.removeAttribute("x2"); this.transitionActive.removeAttribute("y1"); this.transitionActive.removeAttribute("y2"); } snapshotCategoryTransition(event) { if (!this.desktop.matches || this.reducedMotion.matches || !this.railReady) return false; const sourceLine = this.transitionLineGeometry() || this.nodeLine(this.currentPaintNode); const topLink = this.nav.querySelector( `.dcx-nav-list > li[data-category="${CSS.escape(event.detail?.to || "")}"] > .dcx-nav-link`, ); const targetNode = event.detail?.source === "scroll" ? this.scrollTargetNode(event.detail?.to, topLink) : topLink; const spineTarget = this.finalNodeLine(event.detail?.to, topLink); const finalTarget = this.finalNodeLine(event.detail?.to, targetNode); if (!sourceLine || !spineTarget || !finalTarget) return false; const branchPoints = this.categoryTransitionBranchPoints?.length && this.categoryTransitionBranchProgress > 0 ? this.categoryTransitionBranchPoints : null; const renderedPoints = this.categoryTransitionRenderedPoints?.length && this.categoryTransitionRenderedProgress > 0 ? this.categoryTransitionRenderedPoints : null; const retractSource = branchPoints || renderedPoints; const retractPoints = retractSource?.map((point) => ({ ...point })) || null; const retractProgress = branchPoints ? this.categoryTransitionBranchProgress : renderedPoints ? this.categoryTransitionRenderedProgress : 0; this.categoryTransitionGeneration += 1; this.cancelCategoryTransitionFrames(); this.clearCategoryTransitionVisuals(); if (retractPoints) { this.transitionTrack.setAttribute("d", this.pathDataForProgress(retractPoints, retractProgress)); } else { this.buildStraightTrack(); } this.transitionActive.setAttribute("x1", sourceLine.x); this.transitionActive.setAttribute("x2", sourceLine.x); this.transitionActive.setAttribute("y1", sourceLine.yTop); this.transitionActive.setAttribute("y2", sourceLine.yBottom); this.transitionActive.style.transform = "translate(0px, 0px) scaleY(1)"; this.transitionTrack.setAttribute("data-visible", "true"); this.transitionActive.setAttribute("data-visible", "true"); this.transitionTrack.setAttribute("data-initial", "true"); this.transitionActive.setAttribute("data-initial", "true"); this.track.setAttribute("data-entering", "true"); this.active.setAttribute("data-entering", "true"); this.nav.setAttribute("data-category-transition", "layout"); this.svg.getBoundingClientRect(); this.categoryTransitionTarget = spineTarget; this.categoryTransitionFinalTarget = finalTarget; this.categoryTransitionNode = targetNode; this.categoryTransitionBranchPoints = null; this.categoryTransitionBranchProgress = 0; this.categoryTransitionRetractPoints = retractPoints; this.categoryTransitionRetractProgress = retractProgress; this.categoryTransitionRetractStart = 0; this.categoryTransitionRenderedPoints = retractPoints?.map((point) => ({ ...point })) || null; this.categoryTransitionRenderedProgress = retractProgress; this.categoryTransitionActive = true; return true; } beginCategoryTransition(generation) { if (generation !== this.categoryTransitionGeneration || !this.categoryTransitionActive || !this.categoryTransitionTarget || !this.categoryTransitionFinalTarget) return; this.transitionTrack.removeAttribute("data-initial"); this.transitionActive.removeAttribute("data-initial"); this.moveTransitionActive(this.categoryTransitionTarget); const startTime = performance.now(); this.categoryTransitionStart = startTime; if (this.categoryTransitionRetractPoints) this.categoryTransitionRetractStart = startTime; this.categoryTransitionFrame = requestAnimationFrame((time) => { this.animateCategoryTransition(time, generation); }); } animateCategoryTransition(time, generation) { this.categoryTransitionFrame = 0; if (generation !== this.categoryTransitionGeneration || !this.categoryTransitionActive || controller !== this) return; if (this.categoryTransitionRetractPoints) { const spine = this.measureStraightTrack(); const fittedPoints = this.fitPointsToStraightTrack(this.categoryTransitionRetractPoints, spine); const retractTime = Math.min( 1, (time - this.categoryTransitionRetractStart) / BRANCH_RETRACT_MS, ); const progress = this.categoryTransitionRetractProgress * (1 - materialEase(retractTime)); if (fittedPoints.length) { this.transitionTrack.setAttribute("d", this.pathDataForProgress(fittedPoints, progress)); this.categoryTransitionRenderedPoints = fittedPoints.map((point) => ({ ...point })); this.categoryTransitionRenderedProgress = progress; } if (retractTime >= 1 || !fittedPoints.length) { this.categoryTransitionRetractPoints = null; this.categoryTransitionRetractProgress = 0; this.categoryTransitionRetractStart = 0; this.categoryTransitionRenderedPoints = null; this.categoryTransitionRenderedProgress = 0; this.buildStraightTrack(); } } else { this.buildStraightTrack(); } if (time - this.categoryTransitionStart < CATEGORY_TRANSITION_MS) { this.categoryTransitionFrame = requestAnimationFrame((nextTime) => { this.animateCategoryTransition(nextTime, generation); }); return; } this.beginBranchTransition(generation); } beginBranchTransition(generation) { if (generation !== this.categoryTransitionGeneration || !this.categoryTransitionActive || !this.categoryTransitionFinalTarget) return; if (this.categoryTransitionFrame) cancelAnimationFrame(this.categoryTransitionFrame); this.categoryTransitionFrame = 0; this.buildStraightTrack(); const points = this.measureRailPoints(); if (!points.length || !this.buildPath()) { this.cancelCategoryTransition(); this.remeasureSubnavs(); this.scheduleRebuild(true); return; } this.updateCurrent(false); this.categoryTransitionBranchPoints = points; this.categoryTransitionBranchProgress = 0; this.categoryTransitionRetractPoints = null; this.categoryTransitionRetractProgress = 0; this.categoryTransitionRetractStart = 0; this.categoryTransitionRenderedPoints = points.map((point) => ({ ...point })); this.categoryTransitionRenderedProgress = 0; this.track.setAttribute("d", this.pathDataForProgress(points, 0)); this.track.setAttribute("data-morphing", "true"); this.transitionTrack.setAttribute("data-initial", "true"); this.transitionActive.setAttribute("data-settling", "true"); this.nav.setAttribute("data-category-transition", "branch"); this.track.removeAttribute("data-entering"); this.transitionTrack.removeAttribute("data-visible"); this.svg.getBoundingClientRect(); this.moveTransitionActive(this.categoryTransitionFinalTarget); this.categoryTransitionStart = performance.now(); this.categoryTransitionFrame = requestAnimationFrame((time) => { this.animateBranchTransition(time, generation); }); } animateBranchTransition(time, generation) { this.categoryTransitionFrame = 0; if (generation !== this.categoryTransitionGeneration || !this.categoryTransitionActive || !this.categoryTransitionBranchPoints || controller !== this) return; const progress = Math.min(1, (time - this.categoryTransitionStart) / BRANCH_TRANSITION_MS); const easedProgress = materialEase(progress); this.categoryTransitionBranchProgress = easedProgress; this.categoryTransitionRenderedPoints = this.categoryTransitionBranchPoints .map((point) => ({ ...point })); this.categoryTransitionRenderedProgress = easedProgress; this.track.setAttribute( "d", this.pathDataForProgress(this.categoryTransitionBranchPoints, easedProgress), ); if (progress < 1) { this.categoryTransitionFrame = requestAnimationFrame((nextTime) => { this.animateBranchTransition(nextTime, generation); }); return; } this.completeBranchTransition(generation); } completeBranchTransition(generation) { if (generation !== this.categoryTransitionGeneration || !this.categoryTransitionActive) return; this.categoryTransitionActive = false; this.categoryTransitionTarget = null; this.categoryTransitionFinalTarget = null; this.categoryTransitionBranchPoints = null; this.categoryTransitionBranchProgress = 0; this.categoryTransitionRetractPoints = null; this.categoryTransitionRetractProgress = 0; this.categoryTransitionRetractStart = 0; this.categoryTransitionRenderedPoints = null; this.categoryTransitionRenderedProgress = 0; this.buildPath(); this.track.removeAttribute("data-morphing"); this.updateCurrent(false); this.active.setAttribute("data-handoff", "true"); this.transitionActive.setAttribute("data-handoff", "true"); this.svg.getBoundingClientRect(); this.active.removeAttribute("data-entering"); this.transitionActive.removeAttribute("data-visible"); this.categoryTransitionHandoffFrame = requestAnimationFrame(() => { this.categoryTransitionHandoffFrame = 0; if (generation === this.categoryTransitionGeneration) { this.active.removeAttribute("data-handoff"); this.transitionActive.removeAttribute("data-handoff"); } }); this.nav.removeAttribute("data-category-transition"); this.categoryTransitionCleanupTimer = window.setTimeout(() => { if (generation !== this.categoryTransitionGeneration) return; this.categoryTransitionCleanupTimer = 0; this.transitionTrack.removeAttribute("d"); this.transitionTrack.removeAttribute("data-initial"); this.transitionTrack.removeAttribute("data-settling"); this.transitionActive.removeAttribute("data-settling"); this.track.removeAttribute("data-settling"); this.transitionActive.style.removeProperty("transform"); this.transitionActive.removeAttribute("x1"); this.transitionActive.removeAttribute("x2"); this.transitionActive.removeAttribute("y1"); this.transitionActive.removeAttribute("y2"); this.categoryTransitionNode = null; this.updateCurrent(false); }, 220); } handleContinuousCategoryWillChange(event) { this.snapshotCategoryTransition(event); } handleContinuousCategoryChange() { this.refresh(); if (this.categoryTransitionActive) { const generation = this.categoryTransitionGeneration; if (this.categoryTransitionBeginFrame) cancelAnimationFrame(this.categoryTransitionBeginFrame); this.categoryTransitionBeginFrame = requestAnimationFrame(() => { this.categoryTransitionBeginFrame = 0; this.beginCategoryTransition(generation); }); } else { this.scheduleRebuild(true); } } handleResize() { if (this.categoryTransitionActive) return; this.remeasureSubnavs(); this.scheduleRebuild(true); } handleMediaChange() { this.cancelCategoryTransition(); this.remeasureSubnavs(); this.scheduleRebuild(true); } handleReducedMotionChange() { this.cancelCategoryTransition(); this.remeasureSubnavs(); this.scheduleRebuild(true); } handleRailClick(event) { const railButton = event.target.closest(".dcx-sub-link[data-dcx-rail-node]"); if (railButton && this.nav.contains(railButton)) { this.pinnedNode = railButton; this.updateCurrent(); } const button = event.target.closest(".dcx-sub-link[data-dcx-rail-summary]"); if (!button || !this.nav.contains(button)) return; if (this.main.dataset.dcxContinuous === "true") return; const targetName = button.dataset.dcxRailTarget; const target = targetName === "__overview__" ? this.currentArticle?.querySelector(":scope > header") || this.currentArticle : [...this.main.querySelectorAll(".dcx-block[data-label]")] .find((block) => block.dataset.label === targetName); if (!target) return; event.preventDefault(); event.stopPropagation(); const mainRect = this.main.getBoundingClientRect(); const top = targetName === "__overview__" ? 0 : target.getBoundingClientRect().top - mainRect.top + this.main.scrollTop - 18; this.main.scrollTo({ top: Math.max(0, top), behavior: this.reducedMotion.matches ? "auto" : "smooth", }); } clearPinnedNode() { if (!this.pinnedNode) return; this.pinnedNode = null; } handleKeydown(event) { if (["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(event.key)) { this.clearPinnedNode(); } } destroy() { this.cancelCategoryTransition(); this.main?.removeEventListener("scroll", this.handleScroll); this.main?.removeEventListener("wheel", this.clearPinnedNode); this.main?.removeEventListener("touchstart", this.clearPinnedNode); this.main?.removeEventListener("pointerdown", this.clearPinnedNode); this.nav?.removeEventListener("click", this.handleRailClick); this.expander?.removeEventListener("dcx:continuouscategorywillchange", this.handleContinuousCategoryWillChange); this.expander?.removeEventListener("dcx:continuouscategorychange", this.handleContinuousCategoryChange); window.removeEventListener("resize", this.handleResize); window.removeEventListener("keydown", this.handleKeydown); this.desktop?.removeEventListener("change", this.handleMediaChange); this.reducedMotion?.removeEventListener("change", this.handleReducedMotionChange); this.resizeObserver?.disconnect(); this.rebuildTimers.forEach((timer) => window.clearTimeout(timer)); if (this.scrollFrame) cancelAnimationFrame(this.scrollFrame); if (this.rebuildFrame) cancelAnimationFrame(this.rebuildFrame); this.svg?.remove(); this.nav?.classList.remove("dcx-material-rail-host"); this.nav?.removeAttribute("data-rail-ready"); } } const syncRail = () => { syncFrame = 0; const expander = document.querySelector(".dcx-expander"); if (!expander) { controller?.destroy(); controller = null; return; } if (!controller || controller.expander !== expander) { controller?.destroy(); controller = new DcxMaterialRail(expander); } controller.refresh(); }; const scheduleSync = () => { if (syncFrame) return; syncFrame = requestAnimationFrame(syncRail); }; const observer = new MutationObserver(scheduleSync); observer.observe(document.body, { childList: true, subtree: true }); window.addEventListener("pageshow", scheduleSync); scheduleSync(); })();