175 lines
5.6 KiB
JavaScript
175 lines
5.6 KiB
JavaScript
/**
|
|
* <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.
|
|
*
|
|
* 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>
|
|
*/
|
|
|
|
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);
|