/** * web component * * Renders colored vertical bars alongside a release timeline. * Bars grow from the BOTTOM of the timeline upward to the dot position * (avatar center) of the relevant release card. * * In-flight deployments (QUEUED/RUNNING/ASSIGNED) show a hatched segment * with direction arrows: ▲ for forward deploy, ▼ for rollback. * * data-envs format: "env:STATUS,env:STATUS" e.g. "staging:SUCCEEDED,prod:QUEUED" */ const ENV_COLORS = { prod: ["#ec4899", "#fce7f3"], production: ["#ec4899", "#fce7f3"], preprod: ["#f97316", "#ffedd5"], "pre-prod": ["#f97316", "#ffedd5"], staging: ["#eab308", "#fef9c3"], stage: ["#eab308", "#fef9c3"], dev: ["#8b5cf6", "#ede9fe"], development: ["#8b5cf6", "#ede9fe"], test: ["#06b6d4", "#cffafe"], }; const DEFAULT_COLORS = ["#6b7280", "#e5e7eb"]; const IN_FLIGHT = new Set(["QUEUED", "RUNNING", "ASSIGNED"]); const DEPLOYED = new Set(["SUCCEEDED"]); 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; } function parseEnvs(raw) { if (!raw) return []; return raw .split(",") .map((s) => s.trim()) .filter(Boolean) .map((entry) => { const colon = entry.indexOf(":"); if (colon === -1) return { env: entry, status: "SUCCEEDED" }; return { env: entry.slice(0, colon), status: entry.slice(colon + 1) }; }); } function dotY(card, timelineTop) { const avatar = card.querySelector("[data-avatar]"); const anchor = avatar || card; const r = anchor.getBoundingClientRect(); return r.top + r.height / 2 - timelineTop; } /** Create an inline SVG data URL for a diagonal hatch pattern */ function hatchPattern(color, bgColor) { const svg = ` `; return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; } // Inject CSS once if (!document.getElementById("swim-lane-styles")) { const style = document.createElement("style"); style.id = "swim-lane-styles"; style.textContent = ` @keyframes lane-pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } } .lane-pulse { animation: lane-pulse 2s ease-in-out infinite; } .lane-arrow { font-size: 9px; line-height: 1; font-weight: 700; text-align: center; width: 100%; position: absolute; left: 0; z-index: 3; pointer-events: none; } `; document.head.appendChild(style); } const BAR_WIDTH = 20; const BAR_GAP = 4; const DOT_SIZE = 12; class SwimLanes extends HTMLElement { connectedCallback() { // Lanes live in [data-swimlane-gutter], a CSS grid column to the // left of the timeline. The grid column width is pre-set in the // template (lane_count * 18 + 8 px) so there is no layout shift. requestAnimationFrame(() => { this._render(); this._ro = new ResizeObserver(() => this._render()); const timeline = this.querySelector("[data-swimlane-timeline]"); if (timeline) { this._ro.observe(timeline); 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(); if (timelineRect.height === 0) return; const gutter = this.querySelector("[data-swimlane-gutter]"); const lanes = gutter ? Array.from(gutter.querySelectorAll("[data-lane]")) : Array.from(this.querySelectorAll("[data-lane]")); for (const lane of lanes) { const env = lane.dataset.lane; const [barColor, lightColor] = envColors(env); let deployedCard = null; let deployedIdx = -1; let flightCard = null; let flightIdx = -1; for (let i = 0; i < cards.length; i++) { const entries = parseEnvs(cards[i].dataset.envs); for (const entry of entries) { if (entry.env !== env) continue; if (DEPLOYED.has(entry.status) && !deployedCard) { deployedCard = cards[i]; deployedIdx = i; } if (IN_FLIGHT.has(entry.status) && !flightCard) { flightCard = cards[i]; flightIdx = i; } } } const timelineH = timelineRect.height; // Card top edge (Y relative to timeline) — bars extend to the card top const deployedTop = deployedCard ? deployedCard.getBoundingClientRect().top - timelineRect.top : null; const flightTop = flightCard ? flightCard.getBoundingClientRect().top - timelineRect.top : null; // Dot center Y — used for arrow placement const flightDot = flightCard ? dotY(flightCard, timelineRect.top) : null; // Solid bar: from bottom up to the card top of the LOWER card. // If both exist, only go to whichever is lower (further down) to avoid overlap. let solidBarFromBottom = 0; if (deployedTop !== null && flightTop !== null) { const lowerTop = Math.max(deployedTop, flightTop); solidBarFromBottom = timelineH - lowerTop; } else if (deployedTop !== null) { solidBarFromBottom = timelineH - deployedTop; } // Style lane container — width/gap only; height comes from the grid row lane.style.width = BAR_WIDTH + "px"; lane.style.marginRight = BAR_GAP + "px"; lane.style.position = "relative"; const hasHatch = !!flightCard; const hasSolid = solidBarFromBottom > 0; const R = "9999px"; // ── Solid bar ── 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%"; lane.appendChild(bar); } bar.style.height = Math.max(solidBarFromBottom, 0) + "px"; bar.style.backgroundColor = barColor; // Round bottom always; round top only if no hatch connects above bar.style.borderRadius = hasHatch ? `0 0 ${R} ${R}` : R; // ── Hatched segment for in-flight ── let hatch = lane.querySelector(".lane-hatch"); let arrow = lane.querySelector(".lane-arrow"); if (flightCard) { const isForward = deployedIdx === -1 || flightIdx < deployedIdx; // Hatched segment spans between the two card tops (or bottom of timeline) const anchorY = deployedTop !== null ? deployedTop : timelineH; const topY = Math.min(anchorY, flightTop); const bottomY = Math.max(anchorY, flightTop); const segHeight = bottomY - topY; if (!hatch) { hatch = document.createElement("div"); hatch.className = "lane-hatch lane-pulse"; hatch.style.position = "absolute"; hatch.style.left = "0"; hatch.style.width = "100%"; hatch.style.backgroundSize = "8px 8px"; hatch.style.backgroundRepeat = "repeat"; lane.appendChild(hatch); } hatch.style.backgroundImage = isForward ? hatchPattern(barColor, lightColor) : hatchPattern("#f59e0b", "#fef3c7"); hatch.style.top = topY + "px"; hatch.style.height = Math.max(segHeight, 4) + "px"; hatch.style.display = ""; // Round top always; round bottom only if no solid bar connects below hatch.style.borderRadius = hasSolid ? `${R} ${R} 0 0` : R; // Direction arrow: // Forward (▲): shown at the in-flight card (destination) // Rollback (▼): shown at the deployed card (source we're rolling back from) const arrowDotY = isForward ? flightDot : dotY(deployedCard, timelineRect.top); if (!arrow) { arrow = document.createElement("div"); arrow.className = "lane-arrow"; lane.appendChild(arrow); } arrow.textContent = isForward ? "\u25B2" : "\u25BC"; arrow.style.color = isForward ? barColor : "#f59e0b"; arrow.style.top = arrowDotY - 5 + "px"; arrow.style.display = ""; } else { if (hatch) hatch.style.display = "none"; if (arrow) arrow.style.display = "none"; } // ── Dots ── // The arrow replaces the dot on one card: // Forward: arrow on in-flight card (destination) // Rollback: arrow on deployed card (source) const arrowCard = flightCard ? (deployedIdx === -1 || flightIdx < deployedIdx ? flightCard : deployedCard) : null; const existingDots = lane.querySelectorAll(".lane-dot"); let dotIndex = 0; for (const card of cards) { const entries = parseEnvs(card.dataset.envs); const match = entries.find((e) => e.env === env); if (!match) continue; if (card === arrowCard) continue; // arrow shown instead of dot const cy = dotY(card, 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 = DOT_SIZE + "px"; dot.style.height = DOT_SIZE + "px"; dot.style.borderRadius = "50%"; dot.style.zIndex = "2"; lane.appendChild(dot); } dot.style.top = cy - DOT_SIZE / 2 + "px"; dot.style.backgroundColor = "#fff"; dot.style.border = "2px solid " + barColor; dot.classList.remove("lane-pulse"); dotIndex++; } for (let i = dotIndex; i < existingDots.length; i++) { existingDots[i].remove(); } // Labels are rendered server-side above the gutter (no JS needed). } } } customElements.define("swim-lanes", SwimLanes);