@@ -2,34 +2,30 @@
|
||||
* <swim-lanes> 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.
|
||||
* Bars grow from the BOTTOM of the timeline upward to the dot position
|
||||
* (avatar center) of the relevant release card.
|
||||
*
|
||||
* Usage:
|
||||
* <swim-lanes>
|
||||
* <div data-lane="staging"></div>
|
||||
* <div data-lane="prod"></div>
|
||||
* <div data-swimlane-timeline>
|
||||
* <div data-release data-envs="staging,prod">...</div>
|
||||
* <div data-release data-envs="staging">...</div>
|
||||
* </div>
|
||||
* </swim-lanes>
|
||||
* 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: ["#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"],
|
||||
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 = ["#d1d5db", "#9ca3af"];
|
||||
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();
|
||||
@@ -40,17 +36,80 @@ function envColors(name) {
|
||||
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8">
|
||||
<rect width="8" height="8" fill="${bgColor}"/>
|
||||
<path d="M-2,2 l4,-4 M0,8 l8,-8 M6,10 l4,-4" stroke="${color}" stroke-width="1.5" opacity="0.6"/>
|
||||
</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() {
|
||||
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);
|
||||
}
|
||||
// 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() {
|
||||
@@ -65,37 +124,70 @@ class SwimLanes extends HTMLElement {
|
||||
if (cards.length === 0) return;
|
||||
|
||||
const timelineRect = timeline.getBoundingClientRect();
|
||||
const lanes = Array.from(this.querySelectorAll("[data-lane]"));
|
||||
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, labelColor] = envColors(env);
|
||||
const [barColor, lightColor] = 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
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 the lane container
|
||||
lane.style.width = "14px";
|
||||
lane.style.marginRight = "4px";
|
||||
// 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";
|
||||
lane.style.minHeight = timelineRect.height + "px";
|
||||
lane.style.flexShrink = "0";
|
||||
|
||||
// Create or update bar (anchored to bottom)
|
||||
const hasHatch = !!flightCard;
|
||||
const hasSolid = solidBarFromBottom > 0;
|
||||
const R = "9999px";
|
||||
|
||||
// ── Solid bar ──
|
||||
let bar = lane.querySelector(".lane-bar");
|
||||
if (!bar) {
|
||||
bar = document.createElement("div");
|
||||
@@ -104,26 +196,85 @@ class SwimLanes extends HTMLElement {
|
||||
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.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;
|
||||
|
||||
// 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 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 avatar = card.querySelector("[data-avatar]");
|
||||
const anchor = avatar || card;
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const centerY = anchorRect.top + anchorRect.height / 2 - timelineRect.top;
|
||||
const cy = dotY(card, timelineRect.top);
|
||||
|
||||
let dot = existingDots[dotIndex];
|
||||
if (!dot) {
|
||||
@@ -132,41 +283,23 @@ class SwimLanes extends HTMLElement {
|
||||
dot.style.position = "absolute";
|
||||
dot.style.left = "50%";
|
||||
dot.style.transform = "translateX(-50%)";
|
||||
dot.style.width = "8px";
|
||||
dot.style.height = "8px";
|
||||
dot.style.width = DOT_SIZE + "px";
|
||||
dot.style.height = DOT_SIZE + "px";
|
||||
dot.style.borderRadius = "50%";
|
||||
dot.style.backgroundColor = "#fff";
|
||||
dot.style.border = "2px solid " + barColor;
|
||||
dot.style.zIndex = "1";
|
||||
dot.style.zIndex = "2";
|
||||
lane.appendChild(dot);
|
||||
}
|
||||
dot.style.top = centerY - 4 + "px";
|
||||
dot.style.borderColor = barColor;
|
||||
dot.style.top = cy - DOT_SIZE / 2 + "px";
|
||||
dot.style.backgroundColor = "#fff";
|
||||
dot.style.border = "2px solid " + barColor;
|
||||
dot.classList.remove("lane-pulse");
|
||||
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;
|
||||
// Labels are rendered server-side above the gutter (no JS needed).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user