- 교각점 고정 단추: 켜면 그 꺾임점이 노드·손잡이 어느 쪽으로도 안 끌림(`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
220 lines
8.9 KiB
TypeScript
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 };
|
|
}
|