Mermaid 다이어그램 확대 기능 추가하기
viewBox로 크기 복원과 flexbox 중앙 정렬
들어가며
이 블로그의 본문 폭은 46rem으로 고정돼 있다. gantt나 journey처럼 원래 폭이 넓은 Mermaid 다이어그램을 넣으면 그 폭에 눌려 텍스트가 다 작아진다. 확대 버튼 하나만 붙이면 끝날 줄 알았는데, 실제로는 스크롤이 절반만 되는 버그까지 잡고 나서야 끝났다.
확대가 필요해진 계기
MermaidBlock 컴포넌트는 mermaid.render()가 만든 SVG를 그대로 페이지에 꽂아 넣는 구조다. mermaid는 다이어그램을 그릴 때 SVG에 width="100%"와 style="max-width: 686px" 같은 인라인 스타일을 붙인다.1 컨테이너가 이 max-width보다 좁으면 그 비율만큼 전체가 축소된다.
이 gantt 차트의 실제 크기는 1280×244다. 46rem(약 736px) 본문 폭에 맞춰 절반 가까이 줄어든 채로 보이니 막대 안의 텍스트가 다 눌린다. 확대 버튼을 눌러야 원래 크기로 볼 수 있는 구조가 필요했다.
같은 카드 폭 안에서도 자연 크기가 폭보다 넓은 gantt만 눌리고, 좁은 pie는 그대로 보인다. 다이어그램마다 확대가 필요한지 여부가 자연 크기에 따라 갈리는 이유다.
세로로 긴 다이어그램의 스크롤 문제
확대 버튼을 처음 만들 때는 단순하게 접근해서, 확대 컨테이너의 SVG에 width: 100%; height: auto를 강제하면 컨테이너 폭에 맞춰 커질 거라고 봤다.
문제는 노드 16개가 위아래로 쭉 이어지는 세로로 긴 flowchart(194×1630, 가로세로 비율 1:8.4)였다. 이 규칙을 적용하면 폭을 넓히는 배율만큼 높이도 똑같이 늘어난다. 확대 모달의 세로 한도를 넘어서, 확대했는데 오히려 스크롤 없이는 전체를 볼 수 없었다.
이 상태에서는 마우스 휠로 스크롤도 안 됐다. 확대 패널의 wheel 이벤트 핸들러가 맨 위에서 무조건 event.preventDefault()를 호출하기 때문에,2 휠을 굴려도 페이지 스크롤 자체가 막힌다. 휠 대신 다른 방법으로 스크롤해야 했다.
mermaid가 SVG에 실제 픽셀 크기를 넣지 않는 게 원인이었다. width="100%"는 비율일 뿐이고, height 속성 자체가 없다. 브라우저가 이 SVG의 원래 크기를 모르니 width: auto로 덮어쓰면 크기 계산이 아예 0으로 무너진다.3 반응형 이미지에서 흔히 쓰는 max-width/max-height + auto 조합이 SVG에서는 먹히지 않았던 이유다.
해결책은 SVG 자신의 viewBox에서 실제 크기를 읽어 width/height 속성으로 되돌려주는 것이었다. viewBox="0 0 194.65 1630"이면 194와 1630을 그대로 속성값으로 써넣는다.
function normalizeMermaidSvg(svgMarkup: string) {
const doc = new DOMParser().parseFromString(svgMarkup, "image/svg+xml");
const svgEl = doc.documentElement;
const viewBox = svgEl.getAttribute("viewBox");
const dimensions = viewBox?.trim().split(/\s+/);
if (dimensions?.length === 4) {
svgEl.setAttribute("width", dimensions[2]);
svgEl.setAttribute("height", dimensions[3]);
}
svgEl.removeAttribute("style");
return new XMLSerializer().serializeToString(doc);
}이렇게 하면 <img>처럼 진짜 intrinsic size를 가진 SVG가 된다. CSS는 max-width: 100%; max-height: 70vh 두 개만 걸어주면 된다.
.mermaid-container.is-expanded svg {
max-height: 70vh;
}폭과 높이 중 더 빡빡한 쪽 기준으로 축소되는, 이미지의 object-fit: contain과 같은 동작이다. 세로로 긴 다이어그램은 높이 70vh에 맞춰 줄어들고, 가로로 넓은 다이어그램은 폭에 맞춰 줄어든다. 확대 버튼을 눌러도 항상 같은 크기(70vh)로 열리니 다이어그램마다 확대 UI 크기가 들쭉날쭉하던 문제도 같이 없어졌다.
스크롤이 절반만 닿던 문제
Ctrl(Cmd)+스크롤로 배율을 키우고 드래그로 옆을 보려는데, 어느 지점부터는 스크롤이 닿지 않는 영역이 생겼다.
gantt 차트를 4배로 확대한 상태에서 SVG의 실제 렌더링 폭은 1560px, 보이는 영역은 790px, 즉 양쪽으로 넘치는 양은 총 770px이어야 한다. 그런데 브라우저가 알려주는 scrollWidth는 1175px뿐이었다. 스크롤로 도달 가능한 범위(1175-790=385px)가 실제 넘친 양의 정확히 절반이었다.
원인은 컨테이너에 걸어둔 justify-content: center였다. 이 속성 때문에 스크롤을 전혀 안 한 scrollLeft: 0 상태에서도 콘텐츠는 이미 컨테이너 중앙에 그려진다. 컨테이너보다 커진 만큼은 이 중앙 정렬 때문에 좌우로 정확히 절반씩 나뉘어 넘친다.
스크롤은 지금 보이는 영역을 오른쪽으로 밀어서 오른쪽에 숨어 있던 콘텐츠를 드러내는 동작이다. scrollLeft를 늘리면 오른쪽으로 넘친 절반은 그렇게 볼 수 있다. 문제는 왼쪽으로 넘친 절반이다. 이걸 보려면 반대로 보이는 영역을 왼쪽으로 밀어야 하는데, 그건 scrollLeft를 0보다 작은 음수로 만들어야 가능한 동작이다. scrollLeft는 스펙상 0 밑으로 내려갈 수 없다.4 그래서 왼쪽으로 넘친 절반은 애초에 스크롤 가능 범위에 들어가지 않는다. 브라우저가 보고한 scrollWidth가 실제 넘친 양의 절반(1175px, 실제로는 1560px이어야 함)만 반영했던 것도 같은 이유였다.
CSS Box Alignment 스펙에는 정확히 이 상황을 위한 키워드가 있다. justify-content: safe center는 콘텐츠가 들어갈 때는 center처럼 동작하고, 넘칠 때만 start로 자동 전환해서 스크롤이 항상 전체 콘텐츠에 닿게 만든다.5 실제로 넣어봤지만 계산된 스타일에는 safe center가 그대로 찍혀 있는데도 scrollWidth는 여전히 절반만 보고했다. 브라우저가 값은 파싱하면서도 스크롤 가능 영역 계산에는 반영하지 않는 구현 상태였다.
결국 CSS 키워드에 기대는 대신, 확대된 축에서만 justify-start/items-start로 전환하고 transform-origin도 그 축의 시작점(left, top)으로 옮기는 방식을 택했다.
<div
style={{
transform: `scale(${zoom})`,
// 확대된 축만 시작점 기준으로 커지게 해서, 음수 스크롤이
// 필요한 영역 자체가 생기지 않게 한다.
transformOrigin: `${overflowsX ? "left" : "center"} ${overflowsY ? "top" : "center"}`,
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>transform-origin을 시작점으로 옮기면 확대는 항상 오른쪽/아래쪽으로만 자라난다. scrollLeft: 0이 콘텐츠의 진짜 왼쪽 끝과 일치하니, 음수 스크롤이 필요한 영역 자체가 사라진다.
넓은 화면에서 생긴 정렬 쏠림 문제
zoom > 1이면 무조건 시작점 정렬로 바꾸는 방식으로 처음 만들고 로컬에서 넓은 창으로 테스트해보니, 브라우저 창이 넓을 때(확대 패널 자체가 1800px 폭까지 넓어지므로) 배율을 아주 살짝만(1.1배) 올려도 다이어그램이 왼쪽 구석에 붙어버리고 오른쪽에는 빈 공간만 남았다. 스크롤은 문제없이 됐지만 정렬만 보면 왼쪽으로 쏠려 있었다.
zoom > 1이라는 조건이 확대 여부만 볼 뿐 실제로 넘치는지는 안 본다는 게 원인이었다. 자연 크기가 작은 다이어그램은 1.1배 정도로는 넓은 패널을 채우지 못하는데도, 조건이 참이 되는 순간 시작점 정렬로 넘어가버린 것이다.
zoom > 1 대신, 각 축의 scrollWidth가 실제로 clientWidth를 넘는지를 직접 재서 그 축이 진짜로 넘칠 때만 정렬을 바꾸도록 좁혔다.
setOverflowsX(node.scrollWidth > node.clientWidth + 1);
setOverflowsY(node.scrollHeight > node.clientHeight + 1);가로/세로를 따로 판단하기 때문에, 가로는 넘치지 않고 세로만 넘치는 다이어그램(정사각형에 가까운 형태를 넓은 패널에서 확대할 때 자주 생긴다)도 축마다 올바르게 처리된다.
마우스 휠에서 배율이 한 번에 튀던 문제
트랙패드로 Cmd+스크롤을 하면 배율이 부드럽게 늘어나는데, 마우스 휠로 똑같이 해보면 어느 지점에서는 배율이 한 번에 최소값(1배)으로 돌아가 버리거나 계단처럼 확 튀었다.
배율을 계산하는 이 식이 입력 장치를 구분하지 않는 게 원인이었다.
const next = prev * (1 - event.deltaY * ZOOM_SENSITIVITY);트랙패드는 스크롤 이벤트 하나당 deltaY가 한 자릿수 정도로 잘게 나뉘어 들어오는 반면, 마우스 휠은 한 번 딸깍할 때 100 안팎의 값이 통째로 들어온다. ZOOM_SENSITIVITY(0.01)는 트랙패드의 작은 deltaY를 기준으로 잡아둔 값이라, 마우스 휠에서 deltaY가 100만 들어와도 1 - 100*0.01 = 0이 되어 배율이 곧바로 0으로 계산되고, 그대로 최소 배율(1)로 클램프됐다.
해결은 event.deltaY를 배율 계산식에 넣기 전에 그 값 자체를 먼저 clamp하는 것이었다. 어떤 장치에서 오든 휠 이벤트 하나가 배율을 30% 넘게 바꾸지 못하게 상한을 뒀다.
const MAX_DELTA_PER_EVENT = 30;
const clampedDelta = Math.max(-MAX_DELTA_PER_EVENT, Math.min(MAX_DELTA_PER_EVENT, event.deltaY));
setZoom((prev) => {
const next = prev * (1 - clampedDelta * ZOOM_SENSITIVITY);
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next));
});트랙패드처럼 deltaY가 원래 작은 경우는 이 범위 안에 그대로 들어오니 값이 바뀌지 않는다. 마우스 휠처럼 deltaY가 큰 경우만 30으로 깎여서, 두 장치 모두 휠 한 번에 배율이 비슷한 폭으로만 움직이게 됐다.
정리
- mermaid가 SVG에 넣는
width="100%"는 비율일 뿐 실제 크기가 아니다.viewBox에서 읽은 값을width/height속성으로 되돌려줘야max-width/max-height + auto조합이 이미지처럼 동작한다 justify-content: center로 정렬된 콘텐츠가 넘치면,scrollLeft: 0은 콘텐츠의 진짜 왼쪽 끝이 아니라 중앙 정렬된 위치다. 넘친 절반은 스크롤로 닿지 않는다safe center는 정확히 이 문제를 위한 CSS 키워드지만, 스크롤 가능 영역 계산까지 보장하는지는 브라우저 구현에 따라 다르다. 확인 없이 스펙만 믿고 넘어가면 안 됐다- 확대했는지와 실제로 넘치는지는 다른 조건이다. 후자로 판단해야 넓은 화면에서 저배율일 때도 자연스럽게 중앙 정렬이 유지된다
- 배율 계산에 쓰는
deltaY는 입력 장치마다 크기 단위가 다르다. 한 값을 그대로 곱하면 장치별로 감도가 완전히 달라지니, 계산식에 넣기 전에 값 자체를 상한으로 눌러둬야 한다
Footnotes
-
mermaid의
configureSvgSize함수는useMaxWidth가 true일 때 SVG에width="100%"와style="max-width: {N}px"만 설정하고height속성은 설정하지 않는다 (mermaid패키지 소스,calculateSvgSizeAttrs함수). ↩ -
src/hooks/useDiagramZoomPan.ts의handleWheel함수 맨 위에서 조건 없이event.preventDefault()를 호출한다. ctrl/cmd 여부를 확인하기 전에 실행되기 때문에, 확대 패널 위에서는 일반 휠도 전부 막힌다. ↩ -
width/height 속성이 없고 CSS
width: auto만 있는 SVG는 명시적인 intrinsic size가 없어 0으로 계산될 수 있다. viewBox만으로는 대체되지 않는다. ↩ -
Element.scrollLeft - MDN — LTR 문서에서
scrollLeft에 0보다 작은 값을 설정하면 0으로 고정된다. (RTL 문서에서는 반대로 음수가 정상 범위에 포함된다.) ↩ -
justify-content - MDN —
safe키워드는 정렬 때문에 콘텐츠 일부가 스크롤로 도달 불가능해지는 상황에서start정렬로 자동 전환된다. ↩