Files
Aislo/B06_Section/B06_Section_UI_Cross_View_Zoom.ts
T
eomsangdonandClaude Opus 5 f2ad5508f5 feat(B06): 횡단 줌 조작 일원화 + 교차점 맞춤 자동 확대
- 카드 하단 ◀/▶/↺(개별 표시 반폭) 삭제. 조작구는 우측 상단 줌 버튼 하나로.
- 줌 버튼 역할 확장(원배율에서만 반폭 조작):
  − 배율>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>
2026-08-23 18:16:31 +09:00

192 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
}