document.addEventListener("DOMContentLoaded", () => {
const container = document.querySelector(".crate-hierarchy");
if (!container) return;
const svg = container.querySelector("svg");
if (!svg) return;
// Wrap SVG in a viewport container
const viewport = document.createElement("div");
viewport.className = "crate-hierarchy-viewport";
svg?.parentNode?.insertBefore(viewport, svg);
viewport.appendChild(svg);
// Remove any width/height attributes so CSS controls sizing
svg.removeAttribute("width");
svg.removeAttribute("height");
// Create zoom controls
const controls = document.createElement("div");
controls.className = "crate-hierarchy-controls";
controls.innerHTML = ``;
container.insertBefore(controls, viewport);
const zoomInBtn = controls.querySelector(".zoom-in");
const zoomOutBtn = controls.querySelector(".zoom-out");
if (!(zoomInBtn instanceof HTMLButtonElement) || !(zoomOutBtn instanceof HTMLButtonElement)) return;
// Lock the viewport height to the SVG's natural rendered height (ignoring any zoom transform)
const updateViewportHeight = () => {
const prevTransform = svg.style.transform;
svg.style.transform = "";
viewport.style.height = `${svg.getBoundingClientRect().height}px`;
svg.style.transform = prevTransform;
};
updateViewportHeight();
window.addEventListener("resize", () => {
updateViewportHeight();
applyTransform();
});
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const ZOOM_STEP = 0.15;
const BUTTON_ZOOM_STEP = 0.5;
const ANIMATION_DURATION = 200;
let scale = MIN_SCALE;
let panX = 0;
let panY = 0;
let animationFrameId = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let panStartX = 0;
let panStartY = 0;
function clampPan() {
const viewportRect = viewport.getBoundingClientRect();
const viewportW = viewportRect.width;
const viewportH = viewportRect.height;
// The SVG is scaled to fill the viewport width at scale=1
const scaledW = viewportW * scale;
const scaledH = svg?.getBoundingClientRect()?.height || 0;
// How much overflow exists on each axis
const overflowX = Math.max(0, scaledW - viewportW);
const overflowY = Math.max(0, scaledH - viewportH);
// Pan is constrained so scaled content edges don't pull away from viewport edges
panX = Math.min(0, Math.max(-overflowX, panX));
panY = Math.min(0, Math.max(-overflowY, panY));
}
function updateButtons() {
if (zoomInBtn instanceof HTMLButtonElement) zoomInBtn.disabled = scale >= MAX_SCALE;
if (zoomOutBtn instanceof HTMLButtonElement) zoomOutBtn.disabled = scale <= MIN_SCALE;
}
function applyTransform() {
clampPan();
if (svg) svg.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
updateButtons();
}
function zoomAt(/** @type {number} */ clientX, /** @type {number} */ clientY, /** @type {number} */ newScale) {
const viewportRect = viewport.getBoundingClientRect();
// Point in viewport-local coordinates
const pointX = clientX - viewportRect.left;
const pointY = clientY - viewportRect.top;
// Where this point maps in the pre-zoom content
const contentX = (pointX - panX) / scale;
const contentY = (pointY - panY) / scale;
scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale));
// Adjust pan so the same content point stays under the cursor
panX = pointX - contentX * scale;
panY = pointY - contentY * scale;
applyTransform();
}
function animateZoomAt(/** @type {number} */ clientX, /** @type {number} */ clientY, /** @type {number} */ newTargetScale) {
cancelAnimationFrame(animationFrameId);
const targetScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newTargetScale));
const startScale = scale;
const startPanX = panX;
const startPanY = panY;
const viewportRect = viewport.getBoundingClientRect();
const pointX = clientX - viewportRect.left;
const pointY = clientY - viewportRect.top;
const contentX = (pointX - panX) / scale;
const contentY = (pointY - panY) / scale;
const targetPanX = pointX - contentX * targetScale;
const targetPanY = pointY - contentY * targetScale;
const startTime = performance.now();
const step = (/** @type {number} */ now) => {
const t = Math.min(1, (now - startTime) / ANIMATION_DURATION);
const ease = t * (2 - t); // ease-out quadratic
scale = startScale + (targetScale - startScale) * ease;
panX = startPanX + (targetPanX - startPanX) * ease;
panY = startPanY + (targetPanY - startPanY) * ease;
applyTransform();
if (t < 1) animationFrameId = requestAnimationFrame(step);
};
animationFrameId = requestAnimationFrame(step);
}
// Scroll wheel zoom
viewport.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
zoomAt(e.clientX, e.clientY, scale + delta);
},
{ passive: false },
);
// Button zoom (animated, zoom toward center of viewport)
zoomInBtn?.addEventListener("click", () => {
const rect = viewport.getBoundingClientRect();
animateZoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, scale + BUTTON_ZOOM_STEP);
});
zoomOutBtn?.addEventListener("click", () => {
const rect = viewport.getBoundingClientRect();
animateZoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, scale - BUTTON_ZOOM_STEP);
});
// Click-drag to pan
viewport.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = panX;
panStartY = panY;
viewport.setPointerCapture(e.pointerId);
viewport.style.cursor = "grabbing";
});
window.addEventListener("pointermove", (e) => {
if (!isDragging) return;
panX = panStartX + (e.clientX - dragStartX);
panY = panStartY + (e.clientY - dragStartY);
applyTransform();
});
window.addEventListener("pointerup", () => {
if (!isDragging) return;
isDragging = false;
viewport.style.cursor = "";
});
applyTransform();
});