Files
Aislo/A00_Common/b_svg_zoom_pan.ts
T
eomsangdonandClaude Opus 5 abe956affc feat(b05): 계획노선 편집 손질 여섯 — 교각점 고정·횡단 벡터화·라벨 맨 위
- 교각점 고정 단추: 켜면 그 꺾임점이 노드·손잡이 어느 쪽으로도 안 끌림(`apexLock`)
- 계획 횡단선을 절·성토 교차점에서 끊음 — 그 밖은 원지반이라 지반선과 겹쳐 그려지던 것.
  교차점은 B06 이 쓰는 `toeOffsets` 를 열어 함께 씀
- 횡단 판을 캔버스에서 SVG 로 바꾸고 휠 줌·가운데 끌기 팬·더블클릭 원복을 붙임
- 줌·팬 본문을 `A00_Common/b_svg_zoom_pan.ts` 공용 조각으로 빼고 B06 은 감싸개만 남김
- 「이전 횡단」 판은 계획 평면선이 바뀔 때만 채움(같은 측점 두 번 누르기로는 안 채움)
- 「거리 재기」 단추의 눌린 꼴 추가 · 등고선 높이 라벨을 맨 위로 올리고 더 촘촘히

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
2026-09-12 20:37:13 +09:00

220 lines
8.9 KiB
TypeScript

/* =============================================================================
* b_svg_zoom_pan.ts
* SVG 그래프 한 장의 **확대·축소·팬** — 여러 화면이 한 코드를 쓴다.
*
* B06 횡단 카드(`B06_Section_UI_Cross_View_Zoom.ts`)에 있던 것을 옮겨 온 것이다
* (2026-09-12 사용자 지시 「공용화해줘」). 동작 규칙은 그때 정해진 것을 그대로 지킨다.
*
* · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼은 그 화면의 고르기·조작 몫이다.
* · **더블클릭이면 원배율**로 돌아간다.
* · **휠은 화면마다 다르다** — 카드가 수십 장 깔리는 B06 은 휠을 줌에 묶지 않는다
* (묶으면 목록을 훑을 수가 없다, 2026-08-02 사용자 확정). 판이 둘뿐인 B05 계획노선
* 횡단은 휠 = 줌이다(2026-09-12 사용자 지시 ③). `wheelZoom` 한 칸으로 가른다.
*
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
* ========================================================================== */
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
export interface ZoomPanState {
scale: number;
tx: number;
ty: number;
}
/**
* 그려 둔 도형이 실제로 차지하는 범위(원배율 SVG 좌표). 표시 범위 밖에 있어 잘려 있는
* **캐시 보유분**까지 포함한다 — 확대 상태의 팬은 이 범위까지 갈 수 있다
* (2026-08-23 사용자 지시).
*/
export interface ContentBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface ZoomPanHandle {
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
zoom: (factor: number) => void;
reset: () => void;
/** 현재 배율 — 원배율(1)일 때는 부르는 쪽 버튼이 배율 대신 표시 폭을 조절한다. */
scale: () => number;
}
/** 축 안쪽 여백(px) — 확대·축소의 중심이자 이동 한계의 기준이 된다. */
export interface ZoomPanPad {
left: number;
right: number;
top: number;
bottom: number;
}
export interface SvgZoomPanOptions {
svg: SVGSVGElement;
/** 밀고 키울 도형 묶음 — 여기에만 `transform` 이 적힌다. */
layer: SVGGElement;
/** `viewBox` 의 폭·높이(원배율 SVG 좌표). */
widthPx: number;
heightPx: number;
pad: ZoomPanPad;
/** 배율 상한. 안 주면 8. */
maxScale?: number;
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
initial?: ZoomPanState;
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
onChange?: (state: ZoomPanState) => void;
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
content?: ContentBounds;
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
wheelZoom?: boolean;
}
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
const WHEEL_IN = 1.15;
const WHEEL_OUT = 0.87;
export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
const { svg, layer, widthPx, heightPx, pad, initial, onChange, content } = options;
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
const plot = {
x: pad.left,
y: pad.top,
width: Math.max(widthPx - pad.left - pad.right, 1),
height: Math.max(heightPx - pad.top - pad.bottom, 1),
};
const maxScale = options.maxScale ?? 8;
let scale = initial?.scale ?? 1;
let tx = initial?.tx ?? 0;
let ty = initial?.ty ?? 0;
const applyTransform = (): void => {
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
onChange?.({ scale, tx, ty });
};
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
// **확대 상태**에서는 한계가 그려진 도형 전체(표시 범위 밖 캐시 보유분 포함)까지 늘어나,
// 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(2026-08-23 사용자 지시).
const clampAxis = (
value: number,
start: number,
size: number,
from: number,
to: number,
): number =>
Math.min(
Math.max(value, Math.min(start + size - scale * to, start - scale * from)),
Math.max(start + size - scale * to, start - scale * from),
);
const clampPan = (): void => {
// 도형 범위를 모르면 플롯 영역 자신이 한계다(기존 규칙). 알면 **원배율에서도** 그
// 범위까지 열어 둔다 — 캐시 보유분이 표시 폭보다 넓으면 1배에서도 끌어다 봐야 한다
// (2026-08-23 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단).
const bounds = content
? {
x: Math.min(content.x, plot.x),
y: Math.min(content.y, plot.y),
right: Math.max(content.x + content.width, plot.x + plot.width),
bottom: Math.max(content.y + content.height, plot.y + plot.height),
}
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
tx = clampAxis(tx, plot.x, plot.width, bounds.x, bounds.right);
ty = clampAxis(ty, plot.y, plot.height, bounds.y, bounds.bottom);
};
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
const zoomAt = (factor: number, cx: number, cy: number): void => {
const next = Math.min(maxScale, Math.max(1, scale * factor));
tx = cx - ((cx - tx) / scale) * next;
ty = cy - ((cy - ty) / scale) * next;
scale = next;
clampPan();
applyTransform();
};
// 버튼에는 마우스 자리가 없다 — 보이는 **플롯 영역의 중앙**을 붙잡는다.
const zoom = (factor: number): void =>
zoomAt(factor, plot.x + plot.width / 2, plot.y + plot.height / 2);
let panning = false;
let moved = false;
let lastX = 0;
let lastY = 0;
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
svg.addEventListener("mousedown", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("auxclick", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다.
if (event.button !== 1) return;
event.preventDefault();
panning = true;
moved = false;
lastX = event.clientX;
lastY = event.clientY;
svg.classList.add("is-panning");
svg.setPointerCapture(event.pointerId);
});
svg.addEventListener("pointermove", (event) => {
if (!panning) return;
if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true;
// 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다).
const rect = svg.getBoundingClientRect();
tx += ((event.clientX - lastX) / rect.width) * widthPx;
ty += ((event.clientY - lastY) / rect.height) * heightPx;
lastX = event.clientX;
lastY = event.clientY;
clampPan();
applyTransform();
});
const endPan = (event: PointerEvent): void => {
if (!panning) return;
panning = false;
svg.classList.remove("is-panning");
try {
svg.releasePointerCapture(event.pointerId);
} catch {
/* 이미 해제됨 */
}
};
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
clampPan();
applyTransform();
}
svg.addEventListener("pointerup", endPan);
svg.addEventListener("pointercancel", endPan);
// 드래그(팬)로 끝난 클릭은 바깥(카드 고르기 등)으로 전파하지 않는다.
svg.addEventListener("click", (event) => {
if (moved) event.stopPropagation();
});
if (options.wheelZoom) {
svg.addEventListener(
"wheel",
(event) => {
event.preventDefault();
// 커서 아래 지점이 제자리에 남게 **그 자리**를 붙잡는다.
const rect = svg.getBoundingClientRect();
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
// 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`).
zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy);
},
{ passive: false },
);
}
const currentScale = (): number => scale;
const reset = (): void => {
scale = 1;
tx = 0;
ty = 0;
applyTransform();
};
// 더블클릭 원복.
svg.addEventListener("dblclick", (event) => {
event.stopPropagation();
reset();
});
return { zoom, reset, scale: currentScale };
}