- 카드 하단 ◀/▶/↺(개별 표시 반폭) 삭제. 조작구는 우측 상단 줌 버튼 하나로. - 줌 버튼 역할 확장(원배율에서만 반폭 조작): − 배율>1이면 축소, 원배율이면 표시 반폭 +1m + 배율>1이면 확대, 원배율이면 표시 반폭 −1m(하한 2m) ⤢ 배율 원복 + 절·성토 교차점이 다 보이는 폭으로 맞춤 - 교차점 맞춤(_UI_Cross_Fit): 보유 샘플(계산 반폭 20m) 안에서 교차점을 찾고, 샘플 끝까지 안 만나면 끝단 사면·지반 기울기 차로 만나는 자리를 추세 외삽해 올림 +1m을 요청 반폭으로 쓴다(상한 60m). - 요청 반폭 > 보유 샘플이면 그 폭으로 재생성(regenerate) 후 해당 측점만 세션 반폭으로 넓게 표시. 계산 기본 20m·캐시는 그대로, 확정 시 기존 경로대로 display_half_width_m에 영구 저장. - 재생성 로직을 _UI_Page_Common.createSampleWidener로 분리(700줄 제한), [전체 측점 반영]도 같은 경로 사용. 연타 중복 요청 차단. 자체검증: node tmp/tests/test_toe_extrapolate.mjs (외삽 좌/우·발산 지형·+1m 올림), tsc --noEmit 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
192 lines
7.5 KiB
TypeScript
192 lines
7.5 KiB
TypeScript
/* =============================================================================
|
||
* B06_Section_UI_Cross_View_Zoom.ts
|
||
* 횡단 카드 그래프의 **확대·축소·팬** 조작구. 카드 렌더러(`_UI_Cross_View.ts`)에서
|
||
* 700줄 제한으로 분리했다. 동작 규칙(휠은 줌이 아님)은 아래 주석 그대로다.
|
||
* ========================================================================== */
|
||
|
||
import { CROSS_PAD, L } from "./B06_Section_UI_Section_Common";
|
||
|
||
/**
|
||
* 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다.
|
||
*
|
||
* **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에
|
||
* 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭.
|
||
*/
|
||
export interface ZoomPanHandle {
|
||
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
|
||
zoom: (factor: number) => void;
|
||
reset: () => void;
|
||
/** 현재 배율 — 원배율(1)일 때는 버튼이 배율 대신 **표시 반폭**을 조절한다. */
|
||
scale: () => number;
|
||
}
|
||
|
||
/**
|
||
* 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단
|
||
* 줌 버튼에 합쳤다 — 원배율에서 −는 폭 넓히기, +는 폭 좁히기, ⤢는 교차점 맞춤이다.
|
||
*/
|
||
export interface CrossWidthActions {
|
||
/** 표시 반폭을 deltaM(m)만큼 조절한다. 계산 반폭을 넘으면 페이지가 재생성까지 처리. */
|
||
step: (deltaM: number) => void;
|
||
/** 절·성토 교차점이 모두 보이는 폭으로 맞춘다. */
|
||
fit: () => void;
|
||
}
|
||
|
||
/** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
|
||
export interface ZoomPanState {
|
||
scale: number;
|
||
tx: number;
|
||
ty: number;
|
||
}
|
||
|
||
export function attachZoomPan(
|
||
svg: SVGSVGElement,
|
||
plotLayer: SVGGElement,
|
||
widthPx: number,
|
||
heightPx: number,
|
||
/** 이전 카드의 줌·팬 상태 — 조정창 조작으로 카드가 다시 그려져도 배율 유지. */
|
||
initial?: ZoomPanState,
|
||
/** 상태가 바뀔 때마다 부른다 — 카드 밖(뷰)이 담아 뒀다가 다음 렌더에 되돌린다. */
|
||
onChange?: (state: ZoomPanState) => void,
|
||
): ZoomPanHandle {
|
||
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
|
||
const plot = {
|
||
x: CROSS_PAD.left,
|
||
y: CROSS_PAD.top,
|
||
width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1),
|
||
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1),
|
||
};
|
||
const maxScale = 8;
|
||
let scale = initial?.scale ?? 1;
|
||
let tx = initial?.tx ?? 0;
|
||
let ty = initial?.ty ?? 0;
|
||
const applyTransform = (): void => {
|
||
plotLayer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||
onChange?.({ scale, tx, ty });
|
||
};
|
||
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
|
||
const clampPan = (): void => {
|
||
tx = Math.min(Math.max(tx, (plot.x + plot.width) * (1 - scale)), plot.x * (1 - scale));
|
||
ty = Math.min(Math.max(ty, (plot.y + plot.height) * (1 - scale)), plot.y * (1 - scale));
|
||
};
|
||
|
||
// 보이는 **플롯 영역의 중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다.
|
||
const zoom = (factor: number): void => {
|
||
const next = Math.min(maxScale, Math.max(1, scale * factor));
|
||
const cx = plot.x + plot.width / 2;
|
||
const cy = plot.y + plot.height / 2;
|
||
tx = cx - ((cx - tx) / scale) * next;
|
||
ty = cy - ((cy - ty) / scale) * next;
|
||
scale = next;
|
||
clampPan();
|
||
applyTransform();
|
||
};
|
||
|
||
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();
|
||
});
|
||
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 };
|
||
}
|
||
|
||
/**
|
||
* 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다.
|
||
*
|
||
* `width`를 넘기면 **원배율에서만** 버튼이 표시 반폭을 조절한다(2026-08-23 사용자 지시):
|
||
* − : 배율>1이면 축소, 원배율이면 반폭 +1m(계산 반폭 20m를 넘으면 백엔드 재계산)
|
||
* + : 배율>1이면 확대, 원배율이면 반폭 −1m
|
||
* ⤢ : 배율 원복 + 절·성토 교차점이 다 보이는 폭으로 맞춤
|
||
*/
|
||
export function buildZoomControls(handle: ZoomPanHandle, width?: CrossWidthActions): HTMLElement {
|
||
const bar = document.createElement("div");
|
||
bar.className = "b06-cross-card__zoom";
|
||
const add = (label: string, title: string, action: () => void): void => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "b06-cross-card__zoom-btn";
|
||
button.textContent = label;
|
||
button.title = title;
|
||
button.addEventListener("click", (event) => {
|
||
// 카드 선택으로 번지면 그래프가 다시 그려져 방금 맞춘 배율이 날아간다.
|
||
event.stopPropagation();
|
||
action();
|
||
});
|
||
bar.append(button);
|
||
};
|
||
add("+", L("B06_Profile_View_ZoomIn"), () => {
|
||
if (!width || handle.scale() > 1 + 1e-6) handle.zoom(1 / 0.85);
|
||
else width.step(-1);
|
||
});
|
||
add("−", L("B06_Profile_View_ZoomOut"), () => {
|
||
if (!width || handle.scale() > 1 + 1e-6) handle.zoom(0.85);
|
||
else width.step(1);
|
||
});
|
||
add("⤢", L("B06_Profile_View_ZoomFit"), () => {
|
||
handle.reset();
|
||
width?.fit();
|
||
});
|
||
return bar;
|
||
}
|