@typecad/typecad-gitdiff
Version:
See differences between two KiCAD PCB files
1,567 lines (1,352 loc) • 57.9 kB
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PCB Layer Diff Viewer</title>
<link rel="icon" type="image/x-icon" href="https://typecad.net/favicon.ico" />
<link
href="https://cdn.jsdelivr.net/npm/basecoat-css@0.2.8/dist/basecoat.cdn.min.css"
rel="stylesheet"
/>
<script
src="https://cdn.jsdelivr.net/npm/basecoat-css@0.2.8/dist/js/all.min.js"
defer
></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 0;
background-color: var(--background);
color: var(--foreground);
}
/* Make all links underlined with higher specificity */
a {
text-decoration: underline !important;
}
/* Ensure links in titles and headers are also underlined */
.title a,
h1 a,
h2 a,
h3 a,
h4 a,
h5 a,
h6 a {
text-decoration: underline !important;
}
/* Specific styling for the title link to ensure it's visible and underlined */
.title a {
text-decoration: underline !important;
-webkit-text-fill-color: var(--primary) !important;
color: var(--primary) !important;
background: none !important;
-webkit-background-clip: unset !important;
background-clip: unset !important;
}
.container {
margin: 0;
padding: 1rem;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
text-align: center;
margin-bottom: 1rem;
flex-shrink: 0;
}
.title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
background: linear-gradient(
135deg,
var(--primary),
var(--primary-600, var(--primary))
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: var(--muted-foreground);
font-size: 1.125rem;
}
.main-card {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
gap: 0;
min-height: 0;
border-radius: 0;
}
.layer-controls {
display: flex;
align-items: center;
gap: 2rem;
margin: 1rem 1rem 0 1rem;
padding-bottom: 1rem;
flex-shrink: 0;
}
.layer-badges-container {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.layer-badge {
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.layer-badge:hover {
transform: scale(1.05);
}
.layer-badge.inactive {
opacity: 0.6;
}
/* Ensure outline badges remain visible */
.layer-badge.badge--outline {
opacity: 0.8 !important;
border: 1px solid var(--border);
background-color: transparent;
color: var(--foreground);
}
.layer-badge.badge--outline.inactive {
opacity: 0.4 !important;
}
.view-mode-controls {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 200px;
}
.view-mode-controls label {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
}
.view-mode-controls select {
flex: 1;
padding: 0.5rem;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--background);
color: var(--foreground);
}
.layer-opacity {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 200px;
}
.layer-opacity label {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
}
.layer-opacity input[type="range"] {
flex: 1;
height: 0.5rem;
background: var(--secondary);
border-radius: var(--radius);
outline: none;
-webkit-appearance: none;
appearance: none;
}
.layer-opacity input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 1rem;
height: 1rem;
background: var(--primary);
border-radius: 50%;
cursor: pointer;
}
.layer-opacity input[type="range"]::-moz-range-thumb {
width: 1rem;
height: 1rem;
background: var(--primary);
border-radius: 50%;
cursor: pointer;
border: none;
}
#opacityValue {
font-size: 0.875rem;
font-weight: 500;
color: var(--muted-foreground);
min-width: 3rem;
}
.image-viewer {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.image-stack {
position: relative;
border-radius: 0;
overflow: hidden;
background-color: var(--muted);
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
.image-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: opacity 0.3s ease;
mix-blend-mode: normal;
/* Default opacity will be set by JavaScript */
}
.image-layer img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
cursor: grab;
transition: transform 0.1s ease-out;
}
.image-layer img:active {
cursor: grabbing;
}
.image-layer.zoomed img {
cursor: grab;
}
.image-layer.zoomed img:active {
cursor: grabbing;
}
/* Zoom controls */
.zoom-controls {
position: absolute;
top: 10px;
right: 10px;
display: flex;
gap: 5px;
z-index: 20;
background: rgba(0, 0, 0, 0.8);
border-radius: var(--radius);
padding: 5px;
}
.zoom-btn {
width: 32px;
height: 32px;
border: none;
background: rgba(255, 255, 255, 0.2);
color: white;
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: bold;
transition: background 0.2s ease;
}
.zoom-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.zoom-btn:active {
background: rgba(255, 255, 255, 0.4);
}
.zoom-info {
position: absolute;
bottom: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px 10px;
border-radius: var(--radius);
font-size: 12px;
z-index: 20;
pointer-events: none;
}
/* Side by side view */
.image-stack.side-by-side {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
padding: 0;
}
.image-stack.side-by-side .image-layer {
position: relative;
border-radius: 0;
overflow: hidden;
background: var(--background);
border: 1px solid var(--border);
}
.image-stack.side-by-side .image-caption {
display: none; /* Hide captions in side-by-side view */
}
/* Overlay view */
.image-stack.overlay .image-layer:not(.original-layer) {
mix-blend-mode: difference;
}
/* Onion skin view */
.image-stack.onion-skin .modified-layer {
opacity: 0.5;
}
/* Swipe view */
.image-stack.swipe {
position: relative;
overflow: hidden;
}
.image-stack.swipe .swipe-container {
position: relative;
width: 100%;
height: 100%;
}
.image-stack.swipe .swipe-divider {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--primary);
cursor: ew-resize;
z-index: 10;
left: 50%;
transform: translateX(-50%);
}
.image-stack.swipe .swipe-divider::before {
content: "⟷";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 30px;
height: 30px;
background: var(--primary);
border-radius: 50%;
border: 2px solid var(--background);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-foreground);
font-size: 16px;
font-weight: bold;
line-height: 1;
}
.image-stack.swipe .original-layer {
clip-path: inset(0 50% 0 0);
}
.image-stack.swipe .modified-layer {
clip-path: inset(0 0 0 50%);
}
.image-stack.swipe .original-layer::before {
content: "ORIGINAL";
position: absolute;
top: 10px;
left: 10px;
background: rgba(239, 68, 68, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
z-index: 5;
}
.image-stack.swipe .modified-layer::before {
content: "MODIFIED";
position: absolute;
top: 10px;
right: 10px;
background: rgba(34, 197, 94, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
z-index: 5;
}
/* Adjust zoom controls position in swipe mode to avoid overlap with labels */
.image-stack.swipe .zoom-controls {
top: 50px;
}
/* Layer-specific blend modes for better visualization */
.image-layer[data-layer*="Cu"] {
mix-blend-mode: normal;
}
.image-layer[data-layer*="Mask"] {
mix-blend-mode: normal;
}
.image-layer[data-layer*="Paste"] {
mix-blend-mode: normal;
}
/* Diff-only view mode - make all layers partially transparent */
.image-stack.diff-only .image-layer {
opacity: 0.6;
}
/* Ensure diff layers are more visible in diff-only mode */
.image-stack.diff-only .diff-layer {
opacity: 0.8;
}
/* Make images within layers also transparent for better layering effect */
.image-stack.diff-only .image-layer img {
opacity: 0.8;
}
.image-stack.diff-only .diff-layer img {
opacity: 1;
}
.empty-viewer {
text-align: center;
padding: 3rem;
color: var(--muted-foreground);
}
.empty-viewer-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-viewer h3 {
margin-bottom: 0.5rem;
color: var(--foreground);
}
.card-footer {
margin-top: 0;
padding-top: 1rem;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.diff-legend {
display: flex;
justify-content: center;
gap: 2rem;
align-items: center;
margin-bottom: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.legend-swatch {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.legend-removed {
background-color: #f97316; /* Orange for removed features */
}
.legend-added {
background-color: #22c55e; /* Green for added features */
}
.legend-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
}
.side-by-side-labels {
display: flex;
justify-content: space-between;
gap: 2rem;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.label-item {
flex: 1;
text-align: center;
}
.label-text {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
padding: 0.5rem;
background: var(--secondary);
border-radius: var(--radius);
display: inline-block;
}
.theme-switcher {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 50;
}
@media (max-width: 768px) {
.container {
padding: 0.5rem;
}
.title {
font-size: 2rem;
}
.header {
margin-bottom: 0.5rem;
}
.main-card {
padding: 0;
}
.layer-controls {
flex-direction: column;
align-items: stretch;
gap: 1rem;
margin-bottom: 0;
padding-bottom: 0.5rem;
}
.image-stack.side-by-side {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.zoom-controls {
top: 5px;
right: 5px;
padding: 3px;
}
.zoom-btn {
width: 28px;
height: 28px;
font-size: 16px;
}
.zoom-info {
bottom: 5px;
right: 5px;
padding: 3px 6px;
font-size: 11px;
}
}
</style>
</head>
<body>
<!-- Theme Switcher -->
<div class="theme-switcher">
<button
type="button"
id="themeToggle"
aria-label="Toggle theme"
onclick="toggleTheme()"
class="btn-icon-outline size-8"
>
<span id="sunIcon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
</span>
<span id="moonIcon" style="display: none">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
</span>
</button>
</div>
<div class="container">
<div class="header">
<h1 class="title"><a href="https://typecad.net">typeCAD</a> git-diff</h1>
</div>
<div class="main-card card">
<div class="layer-controls">
<div class="layer-badges-container" id="layerBadgesContainer">
<!-- Layer badges will be populated dynamically -->
</div>
<div class="layer-opacity" id="globalOpacityControls" style="display: flex;">
<label for="globalOpacitySlider">Layer Transparency</label>
<input
type="range"
id="globalOpacitySlider"
min="20"
max="100"
value="70"
oninput="updateGlobalOpacity(this.value)"
/>
<span id="globalOpacityValue">70%</span>
</div>
<div class="layer-opacity" id="opacityControls" style="display: none">
<label for="opacitySlider">Overlay Opacity</label>
<input
type="range"
id="opacitySlider"
min="0"
max="100"
value="50"
oninput="updateOpacity(this.value)"
/>
<span id="opacityValue">50%</span>
</div>
<div class="view-mode-controls">
<label for="viewModeSelect">View Mode</label>
<select
id="viewModeSelect"
class="button button--outline"
onchange="changeViewMode(this.value)"
>
<option value="diff-only">Diff Only</option>
<option value="side-by-side">Side by Side</option>
<option value="overlay">Overlay</option>
<option value="onion-skin">Onion Skin</option>
<option value="swipe">Swipe Compare</option>
</select>
</div>
</div>
<div class="image-viewer">
<div class="image-stack" id="imageStack">
<!-- Image layers will be populated dynamically -->
<!-- Zoom Controls -->
<div class="zoom-controls" id="zoomControls" style="display: none;">
<button class="zoom-btn" id="zoomInBtn" title="Zoom In">+</button>
<button class="zoom-btn" id="zoomOutBtn" title="Zoom Out">−</button>
<button class="zoom-btn" id="zoomResetBtn" title="Reset Zoom">⌂</button>
</div>
<!-- Zoom Info -->
<div class="zoom-info" id="zoomInfo" style="display: none;">
Zoom: <span id="zoomLevel">100%</span>
</div>
<div class="empty-viewer" id="emptyViewer">
<div class="empty-viewer-icon">🔧</div>
<h3>No PCB layers selected</h3>
<p>
Choose PCB layers from the dropdown above to view differences
</p>
</div>
<div
class="empty-viewer"
id="noDifferencesViewer"
style="display: none"
>
<div class="empty-viewer-icon">✅</div>
<h3>No differences found</h3>
<p>All PCB layers are identical between the compared files</p>
</div>
</div>
</div>
<div class="card-footer" id="cardFooter" style="display: none">
<div class="diff-legend">
<div class="legend-item">
<div class="legend-swatch legend-removed"></div>
<span class="legend-label">Removed Feature</span>
</div>
<div class="legend-item">
<div class="legend-swatch legend-added"></div>
<span class="legend-label">Added Feature</span>
</div>
</div>
<div class="side-by-side-labels" id="sideBySideLabels" style="display: none !important;">
<div class="label-item">
<span class="label-text">Original</span>
</div>
<div class="label-item">
<span class="label-text">Differences</span>
</div>
<div class="label-item">
<span class="label-text">Modified</span>
</div>
</div>
</div>
</div>
</div>
<script>
// Layer data structure - this will be populated with actual diff data
let layerDiffData = {}; // Stores image sources for each layer
let availableLayers = []; // Array of layers that have differences
// Theme toggle functionality
function toggleTheme() {
const html = document.documentElement;
const isDark = html.classList.contains("dark");
if (isDark) {
html.classList.remove("dark");
html.classList.add("light");
updateThemeIcon("light");
localStorage.setItem("theme", "light");
} else {
html.classList.remove("light");
html.classList.add("dark");
updateThemeIcon("dark");
localStorage.setItem("theme", "dark");
}
}
function updateThemeIcon(theme) {
const sunIcon = document.getElementById("sunIcon");
const moonIcon = document.getElementById("moonIcon");
if (theme === "dark") {
sunIcon.style.display = "block";
moonIcon.style.display = "none";
} else {
sunIcon.style.display = "none";
moonIcon.style.display = "block";
}
}
// Initialize theme on page load
function initializeTheme() {
const savedTheme = localStorage.getItem("theme") || "light";
const html = document.documentElement;
// Remove any existing theme classes
html.classList.remove("light", "dark");
// Add the saved theme class
html.classList.add(savedTheme);
updateThemeIcon(savedTheme);
}
// Determine layer type based on layer name
function getLayerType(layerName) {
if (layerName.includes(".Cu")) return "COPPER";
if (layerName.includes(".Paste")) return "PASTE";
if (layerName.includes(".Mask")) return "MASK";
if (layerName.includes(".Silkscreen")) return "SILK";
if (layerName.includes("Edge.Cuts")) return "EDGE";
if (layerName.includes("Render")) return "RENDER";
if (layerName.includes(".Fab")) return "FAB";
if (layerName.includes(".Courtyard")) return "COURTYARD";
if (layerName.includes(".Adhesive")) return "ADHESIVE";
if (layerName.includes("User.")) return "USER";
if (layerName.includes("Margin")) return "MARGIN";
return "OTHER";
}
// Load layer data from external source (this would be populated by the diff generator)
function loadLayerData() {
// This function will be called to load actual diff data
// For now, we'll simulate with the existing static data
const allPossibleLayers = [
{ name: "F.Cu", hasDiff: true },
{ name: "B.Cu", hasDiff: false },
{ name: "F.Paste", hasDiff: true },
{ name: "B.Paste", hasDiff: false },
{ name: "F.Mask", hasDiff: true },
{ name: "B.Mask", hasDiff: false },
{ name: "F.Silkscreen", hasDiff: false },
{ name: "B.Silkscreen", hasDiff: false },
{ name: "Edge.Cuts", hasDiff: true },
];
// Filter to only layers with differences and exclude render layers
availableLayers = allPossibleLayers
.filter((layer) => layer.hasDiff && !layer.name.includes("Render"))
.map((layer) => ({
name: layer.name,
type: getLayerType(layer.name),
}));
// Store layer data for image sources
availableLayers.forEach((layer) => {
// Create proper SVG content with layer name - make them visually different
const originalSvg = `<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#1f2937"/>
<rect x="100" y="100" width="200" height="100" fill="#ef4444" stroke="#ffffff" stroke-width="2"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="24" fill="#ffffff" text-anchor="middle" dy=".3em">${layer.name} ORIGINAL</text>
</svg>`;
const diffSvg = `<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#000000"/>
<rect x="100" y="100" width="200" height="100" fill="#ef4444"/>
<rect x="350" y="150" width="150" height="80" fill="#22c55e"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="24" fill="#ffffff" text-anchor="middle" dy=".3em">${layer.name} DIFFERENCES</text>
</svg>`;
const modifiedSvg = `<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#1f2937"/>
<rect x="100" y="100" width="200" height="100" fill="#ef4444" stroke="#ffffff" stroke-width="2"/>
<rect x="350" y="150" width="150" height="80" fill="#22c55e" stroke="#ffffff" stroke-width="2"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="24" fill="#ffffff" text-anchor="middle" dy=".3em">${layer.name} MODIFIED</text>
</svg>`;
layerDiffData[layer.name] = {
originalImage: `data:image/svg+xml;base64,${btoa(originalSvg)}`,
diffImage: `data:image/svg+xml;base64,${btoa(diffSvg)}`,
modifiedImage: `data:image/svg+xml;base64,${btoa(modifiedSvg)}`,
};
});
}
// Populate layer badges
function populateLayerBadges() {
const layerBadgesContainer = document.getElementById("layerBadgesContainer");
layerBadgesContainer.innerHTML = "";
// Add layer badges for layers with differences only
availableLayers.forEach((layer) => {
const badgeClass = getBadgeClass(layer.type);
const badge = document.createElement("span");
badge.className = `badge ${badgeClass} layer-badge main-control-badge`;
badge.setAttribute("data-layer", layer.name);
badge.textContent = layer.name;
badge.title = `Click to toggle ${layer.name} layer`;
badge.addEventListener("click", () => {
toggleLayer(layer.name);
});
layerBadgesContainer.appendChild(badge);
});
}
// Get appropriate badge class based on layer type
function getBadgeClass(layerType) {
switch (layerType) {
case "COPPER":
return "badge--destructive";
case "PASTE":
return "badge--success";
case "MASK":
return "badge--warning";
case "SILK":
return "badge--secondary";
case "EDGE":
return "badge--outline";
default:
return "badge";
}
}
// Create image layers dynamically
function createImageLayers() {
const imageStack = document.getElementById("imageStack");
const emptyViewer = imageStack.querySelector(".empty-viewer");
// Clear existing layers but keep empty viewer
const existingLayers = imageStack.querySelectorAll(".image-layer");
existingLayers.forEach((layer) => layer.remove());
// Create layers for each available layer
availableLayers.forEach((layer) => {
// Original layer
const originalDiv = document.createElement("div");
originalDiv.className = "image-layer original-layer";
originalDiv.setAttribute("data-layer", layer.name);
originalDiv.setAttribute("data-type", "original");
originalDiv.style.display = "none";
const originalImg = document.createElement("img");
originalImg.src = layerDiffData[layer.name].originalImage;
originalImg.alt = `${layer.name} Original`;
const originalCaption = document.createElement("div");
originalCaption.className = "image-caption";
originalCaption.textContent = "Original";
originalDiv.appendChild(originalImg);
originalDiv.appendChild(originalCaption);
// Diff layer
const diffDiv = document.createElement("div");
diffDiv.className = "image-layer diff-layer";
diffDiv.setAttribute("data-layer", layer.name);
diffDiv.setAttribute("data-type", "diff");
diffDiv.style.display = "none";
const diffImg = document.createElement("img");
diffImg.src = layerDiffData[layer.name].diffImage;
diffImg.alt = `${layer.name} Diff`;
const diffCaption = document.createElement("div");
diffCaption.className = "image-caption";
diffCaption.textContent = "Differences";
diffDiv.appendChild(diffImg);
diffDiv.appendChild(diffCaption);
// Modified layer
const modifiedDiv = document.createElement("div");
modifiedDiv.className = "image-layer modified-layer";
modifiedDiv.setAttribute("data-layer", layer.name);
modifiedDiv.setAttribute("data-type", "modified");
modifiedDiv.style.display = "none";
const modifiedImg = document.createElement("img");
modifiedImg.src = layerDiffData[layer.name].modifiedImage;
modifiedImg.alt = `${layer.name} Modified`;
const modifiedCaption = document.createElement("div");
modifiedCaption.className = "image-caption";
modifiedCaption.textContent = "Modified";
modifiedDiv.appendChild(modifiedImg);
modifiedDiv.appendChild(modifiedCaption);
imageStack.insertBefore(originalDiv, emptyViewer);
imageStack.insertBefore(diffDiv, emptyViewer);
imageStack.insertBefore(modifiedDiv, emptyViewer);
});
}
// Initialize on page load
document.addEventListener("DOMContentLoaded", function () {
initializeTheme();
loadLayerData();
populateLayerBadges();
createImageLayers();
updateSelectedCount();
updateEmptyState();
updateNoDifferencesState();
// Initialize global opacity control
updateGlobalOpacity(70); // Set initial value to 70%
// Show footer legend for diff-only mode on initial load
const cardFooter = document.getElementById("cardFooter");
const sideBySideLabels = document.getElementById("sideBySideLabels");
cardFooter.style.display = "block";
sideBySideLabels.style.setProperty("display", "none", "important");
// Select all layers by default after a short delay to ensure everything is ready
setTimeout(() => {
selectAllLayers();
updateBadgeStates();
initializeZoomPan();
}, 100);
});
// Layer management
let activeLayers = new Set();
let currentOpacity = 50;
let currentViewMode = "diff-only";
// Removed toggleCombobox function - no longer needed with badges
function toggleLayer(layerName) {
// Only toggle layers that actually exist in availableLayers
const layerExists = availableLayers.some(
(layer) => layer.name === layerName
);
if (!layerExists) {
console.warn(`Layer ${layerName} not found in available layers`);
return;
}
if (activeLayers.has(layerName)) {
activeLayers.delete(layerName);
} else {
activeLayers.add(layerName);
}
updateLayerVisibility();
updateSelectedCount();
updateEmptyState();
updateBadgeStates();
// Debug: Check if badge still exists after update
const badge = document.querySelector(`.main-control-badge[data-layer="${layerName}"]`);
}
function updateSelectedCount() {
// This function is no longer needed since we're using badges
// But keeping it for potential future use
const count = activeLayers.size;
}
function changeViewMode(mode) {
currentViewMode = mode;
const imageStack = document.getElementById("imageStack");
const opacityControls = document.getElementById("opacityControls");
const cardFooter = document.getElementById("cardFooter");
// Remove all view mode classes
imageStack.classList.remove(
"side-by-side",
"overlay",
"onion-skin",
"swipe",
"diff-only"
);
// Show/hide opacity controls based on mode
if (mode === "overlay" || mode === "onion-skin") {
opacityControls.style.display = "flex";
} else {
opacityControls.style.display = "none";
}
// Show/hide global opacity controls based on mode
const globalOpacityControls = document.getElementById("globalOpacityControls");
if (mode === "side-by-side" || mode === "overlay" || mode === "onion-skin") {
globalOpacityControls.style.display = "none";
} else {
globalOpacityControls.style.display = "flex";
}
// Show/hide footer legend based on mode
const sideBySideLabels = document.getElementById("sideBySideLabels");
if (mode === "diff-only") {
cardFooter.style.display = "block";
sideBySideLabels.style.display = "none";
sideBySideLabels.style.setProperty("display", "none", "important");
} else if (mode === "side-by-side") {
cardFooter.style.display = "block";
sideBySideLabels.style.setProperty("display", "flex", "important");
} else {
cardFooter.style.display = "none";
sideBySideLabels.style.setProperty("display", "none", "important");
}
// Apply new view mode
if (mode === "diff-only") {
imageStack.classList.add("diff-only");
} else {
imageStack.classList.add(mode);
}
// Reset clip-path styles when switching away from swipe mode
if (mode !== "swipe") {
resetClipPaths();
}
// Update layer visibility based on mode
updateLayerVisibility();
// Initialize swipe functionality if needed
if (mode === "swipe") {
initializeSwipeView();
}
// Reset zoom/pan when changing view modes to avoid conflicts
if (typeof initializeZoomPan !== 'undefined' && zoomPanInitialized) {
zoomLevel = 1;
translateX = 0;
translateY = 0;
const zoomLevelSpan = document.getElementById('zoomLevel');
if (zoomLevelSpan) {
zoomLevelSpan.textContent = '100%';
}
// Apply reset transform to all image layers
const allLayers = imageStack.querySelectorAll('.image-layer img');
allLayers.forEach(img => {
img.style.transform = 'scale(1) translate(0px, 0px)';
img.style.transformOrigin = 'center center';
});
}
}
function updateLayerVisibility() {
// Clean up activeLayers to only include layers that actually exist
const validLayerNames = new Set(availableLayers.map((l) => l.name));
const invalidLayers = Array.from(activeLayers).filter(
(name) => !validLayerNames.has(name)
);
if (invalidLayers.length > 0) {
console.warn(
"Removing invalid layers from activeLayers:",
invalidLayers
);
invalidLayers.forEach((name) => activeLayers.delete(name));
}
// First, hide ALL layers
availableLayers.forEach((layer) => {
const originalLayer = document.querySelector(
`.image-layer[data-layer="${layer.name}"][data-type="original"]`
);
const diffLayer = document.querySelector(
`.image-layer[data-layer="${layer.name}"][data-type="diff"]`
);
const modifiedLayer = document.querySelector(
`.image-layer[data-layer="${layer.name}"][data-type="modified"]`
);
[originalLayer, diffLayer, modifiedLayer].forEach((layerElement) => {
if (layerElement) layerElement.style.display = "none";
});
});
// Then show only the active layers
activeLayers.forEach((layerName) => {
const originalLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="original"]`
);
const diffLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="diff"]`
);
const modifiedLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="modified"]`
);
// Show layers based on view mode
switch (currentViewMode) {
case "diff-only":
// Show all three layers with transparency in diff-only mode
[originalLayer, diffLayer, modifiedLayer].forEach((layer) => {
if (layer) {
layer.style.display = "block";
// Remove inline opacity to let CSS handle it for diff-only mode
layer.style.removeProperty("opacity");
}
});
break;
case "side-by-side":
[originalLayer, diffLayer, modifiedLayer].forEach((layer) => {
if (layer) {
layer.style.display = "block";
// Side-by-side view should always be fully opaque since layers don't overlap
layer.style.opacity = 1;
}
});
break;
case "overlay":
if (originalLayer && modifiedLayer) {
originalLayer.style.display = "block";
originalLayer.style.opacity = 1 - (currentOpacity / 100);
modifiedLayer.style.display = "block";
modifiedLayer.style.opacity = currentOpacity / 100;
}
break;
case "onion-skin":
if (originalLayer && modifiedLayer) {
originalLayer.style.display = "block";
// Remove inline opacity to use CSS default transparency
originalLayer.style.removeProperty("opacity");
modifiedLayer.style.display = "block";
modifiedLayer.style.opacity = currentOpacity / 100;
}
break;
case "swipe":
if (originalLayer && modifiedLayer) {
originalLayer.style.display = "block";
// Remove inline opacity to use CSS default transparency
originalLayer.style.removeProperty("opacity");
modifiedLayer.style.display = "block";
// Remove inline opacity to use CSS default transparency
modifiedLayer.style.removeProperty("opacity");
// Debug: Log the image sources to verify they're different
const originalImg = originalLayer.querySelector("img");
const modifiedImg = modifiedLayer.querySelector("img");
}
break;
}
});
// Apply global opacity after all layers are updated
const globalOpacitySlider = document.getElementById("globalOpacitySlider");
if (globalOpacitySlider && currentViewMode !== "overlay" && currentViewMode !== "onion-skin" && currentViewMode !== "side-by-side") {
updateGlobalOpacity(globalOpacitySlider.value);
}
}
function updateOpacity(value) {
currentOpacity = value;
document.getElementById("opacityValue").textContent = `${value}%`;
// Apply opacity based on current view mode
if (currentViewMode === "overlay") {
activeLayers.forEach((layerName) => {
const originalLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="original"]`
);
const modifiedLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="modified"]`
);
if (originalLayer) {
originalLayer.style.opacity = 1 - (value / 100);
}
if (modifiedLayer) {
modifiedLayer.style.opacity = value / 100;
}
});
} else if (currentViewMode === "onion-skin") {
activeLayers.forEach((layerName) => {
const modifiedLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="modified"]`
);
if (modifiedLayer) {
modifiedLayer.style.opacity = value / 100;
}
});
} else {
// Clear inline opacity styles for other view modes
clearAllInlineOpacityStyles();
}
}
function updateGlobalOpacity(value) {
// Convert percentage to decimal (e.g., 70% -> 0.7)
const opacityDecimal = value / 100;
// Update the display value
document.getElementById("globalOpacityValue").textContent = `${value}%`;
// Apply global opacity to all visible layers, but respect view mode specific opacity
activeLayers.forEach((layerName) => {
const originalLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="original"]`
);
const diffLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="diff"]`
);
const modifiedLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="modified"]`
);
// Apply global opacity only if not in overlay, onion-skin, or side-by-side modes
if (currentViewMode !== "overlay" && currentViewMode !== "onion-skin" && currentViewMode !== "side-by-side") {
[originalLayer, diffLayer, modifiedLayer].forEach((layer) => {
if (layer && layer.style.display !== "none") {
// For diff-only mode, apply different opacity to diff layer
if (currentViewMode === "diff-only" && layer.classList.contains("diff-layer")) {
layer.style.opacity = Math.min(opacityDecimal * 1.2, 1); // Slightly more opaque for diff layer
} else {
layer.style.opacity = opacityDecimal;
}
}
});
}
});
}
function clearAllInlineOpacityStyles() {
// Clear all inline opacity styles from all image layers
const allImageLayers = document.querySelectorAll(".image-layer");
allImageLayers.forEach((layer) => {
layer.style.removeProperty("opacity");
});
}
function initializeSwipeView() {
const imageStack = document.getElementById("imageStack");
// Remove existing swipe container if any
const existingContainer = imageStack.querySelector(".swipe-container");
if (existingContainer) {
existingContainer.remove();
}
// Create swipe container
const swipeContainer = document.createElement("div");
swipeContainer.className = "swipe-container";
// Create divider
const divider = document.createElement("div");
divider.className = "swipe-divider";
swipeContainer.appendChild(divider);
imageStack.appendChild(swipeContainer);
// Add drag functionality
let isDragging = false;
divider.addEventListener("mousedown", (e) => {
isDragging = true;
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const rect = imageStack.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = (x / rect.width) * 100;
const clampedPercentage = Math.max(0, Math.min(100, percentage));
divider.style.left = `${clampedPercentage}%`;
// Update clip paths
activeLayers.forEach((layerName) => {
const originalLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="original"]`
);
const modifiedLayer = document.querySelector(
`.image-layer[data-layer="${layerName}"][data-type="modified"]`
);
if (originalLayer) {
originalLayer.style.clipPath = `inset(0 ${
100 - clampedPercentage
}% 0 0)`;
}
if (modifiedLayer) {
modifiedLayer.style.clipPath = `inset(0 0 0 ${clampedPercentage}%)`;
}
});
});
document.addEventListener("mouseup", () => {
isDragging = false;
});
}
function resetClipPaths() {
// Reset clip-path styles for all image layers
const allImageLayers = document.querySelectorAll(".image-layer");
allImageLayers.forEach((layer) => {
layer.style.clipPath = "";
});
}
function updateEmptyState() {
const emptyViewer = document.getElementById("emptyViewer");
const noDifferencesViewer = document.getElementById(
"noDifferencesViewer"
);
if (availableLayers.length === 0) {
emptyViewer.style.display = "none";
noDifferencesViewer.style.display = "block";
} else if (activeLayers.size === 0) {
emptyViewer.style.display = "block";
noDifferencesViewer.style.display = "none";
} else {
emptyViewer.style.display = "none";
noDifferencesViewer.style.display = "none";
}
}
function updateNoDifferencesState() {
// This function is no longer needed since we're using badges
// But keeping it for potential future use
}
function updateBadgeStates() {
// Update the visual state of layer badges in the main controls area only
availableLayers.forEach((layer) => {
const badge = document.querySelector(`.main-control-badge[data-layer="${layer.name}"]`);
if (badge) {
if (activeLayers.has(layer.name)) {
// Layer is active - show filled badge
badge.classList.remove("badge--outline", "inactive");
// Ensure the original color class is present
const baseBadgeClass = getBadgeClass(layer.type);
badge.classList.add(baseBadgeClass);
} else {
// Layer is inactive - show outline badge
badge.classList.add("badge--outline", "inactive");
// Remove color classes to avoid conflicts with outline
badge.classList.remove("badge--destructive", "badge--success", "badge--warning", "badge--secondary");
}
}
});
}
function selectAllLayers() {
availableLayers.forEach((layer) => {
if (!activeLayers.has(layer.name)) {
activeLayers.add(layer.name);
}
});
updateLayerVisibility();
updateSelectedCount();
updateEmptyState();
updateBadgeStates();
}
function clearAllLayers() {
activeLayers.clear();
updateLayerVisibility();
updateSelectedCount();
updateEmptyState();
updateBadgeStates();
}
// Remove old combobox event listeners - no longer needed
// Function to load actual layer data from diff generator (to be called externally)
// This should be called by the diff generator with LayerInfo[] data
// Example usage: setLayerData([{name: "F.Cu", originalImage: "...", diffImage: "...", modifiedImage: "..."}])
function setLayerData(layerData) {
// layerData should be an array of LayerInfo objects with diffImage !== null
// Exclude render layers as they are special case layers
availableLayers = layerData
.filter(
(layer) =>
layer.diffImage !== null && !layer.name.includes("Render")
)
.map((layer) => ({
name: layer.name,
type: getLayerType(layer.name),
}));
// Clear activeLayers to prevent stale layer names
activeLayers.clear();
layerDiffData = {};
layerData.forEach((layer) => {
if (layer.diffImage !== null && !layer.name.includes("Render")) {
layerDiffData[layer.name] = {
originalImage: layer.originalImage,
diffImage: layer.diffImage,
modifiedImage: layer.modifiedImage,
};
}
});
populateLayerBadges();
createImageLayers();
updateSelectedCount();
updateEmptyState();
updateNoDifferencesState();
// Select all layers by default after a short delay to ensure everything is ready
setTimeout(() => {
selectAllLayers();
updateBadgeStates();
initializeZoomPan();
}, 100);
}
// Zoom and Pan functionality
let zoomLevel = 1;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let dragStartTranslateX = 0;
let dragStartTranslateY = 0;
let translateX = 0;
let translateY = 0;
let zoomPanInitialized = false;
function initializeZoomPan() {
if (zoomPanInitialized) return;
zoomPanInitialized = true;
const imageStack = document.getElementById('imageStack');
const zoomControls = document.getElementById('zoomControls');
const zoomInfo = document.getElementById('zoomInfo');
const zoomInBtn = document.getElementById('zoomInBtn');
const zoomOutBtn = document.getElementById('zoomOutBtn');
const zoomResetBtn = document.getElementById('zoomResetBtn');
const zoomLevelSpan = document.getElementById('zoomLevel');
// Show zoom controls and info
function showZoomControls() {
if (activeLayers.size > 0) {
zoomControls.style.display = 'flex';
zoomInfo.style.display = 'block';
} else {
zoomControls.style.display = 'none';
zoomInfo.style.display = 'none';
}
}
// Update zoom level display
function updateZoomLevel() {
zoomLevelSpan.textContent = Math.round(zoomLevel * 100) + '%';