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
This commit is contained in:
@@ -58,6 +58,38 @@ export function toeFitHalfWidth(section: CrossSection): number | null {
|
||||
return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M);
|
||||
}
|
||||
|
||||
/**
|
||||
* 절·성토 사면이 원지반과 **실제로 만나는** 좌·우 offset(m). 그 측에서 못 만나면 null.
|
||||
*
|
||||
* `toeFitHalfWidth` 와 같은 교차점을 쓰되 **좌우를 따로** 낸다(2026-09-12 사용자 지시 ②) —
|
||||
* 반폭 하나로 자르면 한쪽만 성토인 측점에서 반대쪽 원지반이 필요보다 넓게 남는다.
|
||||
* **외삽으로 짚은 자리는 안 쓴다**(null) — 지반을 모르는 자리라 거기까지 그으면 없는 지반을
|
||||
* 그리는 셈이 된다.
|
||||
*/
|
||||
export function toeOffsets(section: CrossSection): { left: number | null; right: number | null } {
|
||||
const out: { left: number | null; right: number | null } = { left: null, right: null };
|
||||
const design = section.design;
|
||||
if (!design) return out;
|
||||
const groundAt = groundInterpolator(section.samples);
|
||||
const designAt = designInterpolator(design.design_line);
|
||||
const edges = design.road_edges;
|
||||
if (!groundAt || !designAt || !edges) return out;
|
||||
const lineOffsets = design.design_line.map((point) => point.offset_m);
|
||||
if (lineOffsets.length < 2) return out;
|
||||
const { protectMax, protectMin } = protectedSpan(design, edges);
|
||||
for (const side of ["left", "right"] as const) {
|
||||
const outward = side === "left" ? 1 : -1;
|
||||
const start = side === "left" ? protectMax : protectMin;
|
||||
const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets);
|
||||
const meet = meetOffset(designAt, groundAt, start, limit, outward);
|
||||
// 설계선 끝 밖으로 나간 값은 외삽이다 — 만난 것으로 치지 않는다.
|
||||
if (Math.abs(meet) > Math.abs(limit) + 1e-9) continue;
|
||||
if (Math.abs(designAt(meet) - groundAt(meet)) > MEET_TOLERANCE_M) continue;
|
||||
out[side] = meet;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
|
||||
*
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_View_Zoom.ts
|
||||
* 횡단 카드 그래프의 **확대·축소·팬** 조작구. 카드 렌더러(`_UI_Cross_View.ts`)에서
|
||||
* 700줄 제한으로 분리했다. 동작 규칙(휠은 줌이 아님)은 아래 주석 그대로다.
|
||||
* 횡단 카드 그래프의 **확대·축소·팬** 조작구.
|
||||
*
|
||||
* ⚠ 줌·팬 **본문은 공용 조각**(`A00_Common/b_svg_zoom_pan.ts`)으로 옮겼다
|
||||
* (2026-09-12 사용자 지시 「공용화해줘」) — 계획노선 편집 횡단이 같은 코드를 쓴다.
|
||||
* 여기에는 **B06 것**만 남는다: 카드 여백(`CROSS_PAD`)을 씌운 감싸개, 측점별 배율 기억,
|
||||
* 우측 상단 버튼 묶음. 동작 규칙(휠은 줌이 아님)은 그대로다 — 카드가 수십 장 깔리는
|
||||
* 화면에서 휠을 줌에 묶으면 목록을 훑을 수가 없다(2026-08-02 사용자 확정).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
attachSvgZoomPan,
|
||||
type ContentBounds,
|
||||
type ZoomPanHandle,
|
||||
type ZoomPanState,
|
||||
} from "../A00_Common/b_svg_zoom_pan";
|
||||
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;
|
||||
}
|
||||
export type { ContentBounds, ZoomPanHandle, ZoomPanState };
|
||||
|
||||
/**
|
||||
* 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단
|
||||
@@ -34,28 +33,10 @@ export interface CrossWidthActions {
|
||||
fit: () => void;
|
||||
}
|
||||
|
||||
/** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(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;
|
||||
}
|
||||
|
||||
/** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */
|
||||
export const cardZoomStates = new Map<string, ZoomPanState>();
|
||||
|
||||
/** 카드 SVG 에 줌·팬을 붙인다 — 여백만 씌워 공용 조각에 넘긴다. 부르는 꼴은 그대로다. */
|
||||
export function attachZoomPan(
|
||||
svg: SVGSVGElement,
|
||||
plotLayer: SVGGElement,
|
||||
@@ -68,132 +49,18 @@ export function attachZoomPan(
|
||||
/** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||
content?: ContentBounds,
|
||||
): 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으로 묶인다.
|
||||
// **확대 상태**에서는 한계가 그려진 도형 전체(표시 반폭 밖 캐시 보유분 포함)까지 늘어나,
|
||||
// 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(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 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단).
|
||||
// 캐시가 표시 폭과 같으면 범위가 플롯과 같아져 종전처럼 이동량 0으로 묶인다.
|
||||
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 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();
|
||||
return attachSvgZoomPan({
|
||||
svg,
|
||||
layer: plotLayer,
|
||||
widthPx,
|
||||
heightPx,
|
||||
pad: CROSS_PAD,
|
||||
initial,
|
||||
onChange,
|
||||
content,
|
||||
// 휠은 카드 목록을 훑는 몫이다 — 줌에 안 묶는다.
|
||||
wheelZoom: false,
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user