/** * web component * * Renders colored vertical bars alongside a release timeline. * Each bar grows from the BOTTOM of the timeline upward to the top edge * of the last release card deployed to that environment. * Labels are rendered at the bottom of each bar, rotated vertically. * * Usage: * *
*
*
*
...
*
...
*
*
*/ const ENV_COLORS = { prod: ["#f472b6", "#ec4899"], production: ["#f472b6", "#ec4899"], preprod: ["#fdba74", "#f97316"], "pre-prod": ["#fdba74", "#f97316"], staging: ["#fbbf24", "#ca8a04"], stage: ["#fbbf24", "#ca8a04"], dev: ["#a78bfa", "#7c3aed"], development: ["#a78bfa", "#7c3aed"], test: ["#67e8f9", "#0891b2"], }; const DEFAULT_COLORS = ["#d1d5db", "#9ca3af"]; function envColors(name) { const lower = name.toLowerCase(); if (ENV_COLORS[lower]) return ENV_COLORS[lower]; for (const [key, colors] of Object.entries(ENV_COLORS)) { if (lower.includes(key)) return colors; } return DEFAULT_COLORS; } class SwimLanes extends HTMLElement { connectedCallback() { this.style.display = "flex"; this._render(); this._ro = new ResizeObserver(() => this._render()); const timeline = this.querySelector("[data-swimlane-timeline]"); if (timeline) { this._ro.observe(timeline); // Re-render when details elements are toggled (show/hide commits) timeline.addEventListener("toggle", () => this._render(), true); } } disconnectedCallback() { if (this._ro) this._ro.disconnect(); } _render() { const timeline = this.querySelector("[data-swimlane-timeline]"); if (!timeline) return; const cards = Array.from(timeline.querySelectorAll("[data-release]")); if (cards.length === 0) return; const timelineRect = timeline.getBoundingClientRect(); const lanes = Array.from(this.querySelectorAll("[data-lane]")); for (const lane of lanes) { const env = lane.dataset.lane; const [barColor, labelColor] = envColors(env); // Find the LAST (bottommost) card deployed to this env let lastCard = null; for (const card of cards) { const envs = (card.dataset.envs || "") .split(",") .map((s) => s.trim()) .filter(Boolean); if (envs.includes(env)) lastCard = card; } // Bar height: from bottom of timeline up to top of the last deployed card let barHeight = 0; if (lastCard) { const cardRect = lastCard.getBoundingClientRect(); barHeight = timelineRect.bottom - cardRect.top; } // Style the lane container lane.style.width = "14px"; lane.style.marginRight = "4px"; lane.style.position = "relative"; lane.style.minHeight = timelineRect.height + "px"; lane.style.flexShrink = "0"; // Create or update bar (anchored to bottom) let bar = lane.querySelector(".lane-bar"); if (!bar) { bar = document.createElement("div"); bar.className = "lane-bar"; bar.style.position = "absolute"; bar.style.bottom = "0"; bar.style.left = "0"; bar.style.width = "100%"; bar.style.borderRadius = "9999px"; lane.appendChild(bar); } bar.style.height = barHeight + "px"; bar.style.backgroundColor = barColor; // Place dots on the lane for each card deployed to this env const existingDots = lane.querySelectorAll(".lane-dot"); let dotIndex = 0; for (const card of cards) { const envs = (card.dataset.envs || "") .split(",") .map((s) => s.trim()) .filter(Boolean); if (!envs.includes(env)) continue; const avatar = card.querySelector("[data-avatar]"); const anchor = avatar || card; const anchorRect = anchor.getBoundingClientRect(); const centerY = anchorRect.top + anchorRect.height / 2 - timelineRect.top; let dot = existingDots[dotIndex]; if (!dot) { dot = document.createElement("div"); dot.className = "lane-dot"; dot.style.position = "absolute"; dot.style.left = "50%"; dot.style.transform = "translateX(-50%)"; dot.style.width = "8px"; dot.style.height = "8px"; dot.style.borderRadius = "50%"; dot.style.backgroundColor = "#fff"; dot.style.border = "2px solid " + barColor; dot.style.zIndex = "1"; lane.appendChild(dot); } dot.style.top = centerY - 4 + "px"; dot.style.borderColor = barColor; dotIndex++; } // Remove extra dots from previous renders for (let i = dotIndex; i < existingDots.length; i++) { existingDots[i].remove(); } // Create or update label (at the very bottom, below bars) let label = lane.querySelector(".lane-label"); if (!label) { label = document.createElement("span"); label.className = "lane-label"; label.style.position = "absolute"; label.style.bottom = "-4px"; label.style.left = "50%"; label.style.writingMode = "vertical-lr"; label.style.transform = "translateX(-50%) translateY(100%) rotate(180deg)"; label.style.fontSize = "10px"; label.style.fontWeight = "500"; label.style.whiteSpace = "nowrap"; label.style.paddingTop = "6px"; lane.appendChild(label); } label.textContent = env; label.style.color = labelColor; } } } customElements.define("swim-lanes", SwimLanes);