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:
2026-09-12 20:37:13 +09:00
co-authored by Claude Opus 5
parent ae5dab661e
commit abe956affc
12 changed files with 629 additions and 256 deletions
+219
View File
@@ -0,0 +1,219 @@
/* =============================================================================
* 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 };
}
+35 -8
View File
@@ -74,6 +74,10 @@ const SEGMENT_HIT_PX = 12;
const CONTOUR_HIT_PX = 6; const CONTOUR_HIT_PX = 6;
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */ /** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
const STATION_HIT_PX = 11; const STATION_HIT_PX = 11;
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
* (2026-09-12 사용자 지시 ⑦ 「좀더 촘촘히」). 편집 창은 노선 둘레 300m 띠만 보이므로
* 같은 예산으로도 화면에 드는 줄이 적어 눈금이 필요 이상으로 성겼다. */
const CONTOUR_LEVEL_BUDGET = 1200;
type Vertex = [number, number]; type Vertex = [number, number];
@@ -118,6 +122,9 @@ export async function openRouteEditModal(
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */ * 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
let curveLock: CurveLock[] = []; let curveLock: CurveLock[] = [];
let curveArc: Array<number | null> = []; let curveArc: Array<number | null> = [];
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①) — 켜진 자리는 노드도 손잡이도
* 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. */
let apexLock: boolean[] = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1; let picked = -1;
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
@@ -258,7 +265,7 @@ export async function openRouteEditModal(
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */ /** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
const contourStepM = (): number => const contourStepM = (): number =>
contours ? pickLevelStep(contours.layer, view, contours.intervalM) : 0; contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
function draw(): void { function draw(): void {
if (closed) return; if (closed) return;
@@ -291,7 +298,10 @@ export async function openRouteEditModal(
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
handleAtScreen( handleAtScreen(
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5). // 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5).
curveInfo.filter((entry) => (curveLock[entry.node_first] ?? null) === null), curveInfo.filter(
(entry) =>
(curveLock[entry.node_first] ?? null) === null && apexLock[entry.node_first] !== true,
),
toScreen, toScreen,
px, px,
py, py,
@@ -354,6 +364,7 @@ export async function openRouteEditModal(
curveOn, curveOn,
curveRadius, curveRadius,
curveLock, curveLock,
apexLock,
curveCount: curveInfo.length, curveCount: curveInfo.length,
violationCount: nodeInfo.filter((node) => node.violations.length).length, violationCount: nodeInfo.filter((node) => node.violations.length).length,
minRadiusM, minRadiusM,
@@ -372,6 +383,7 @@ export async function openRouteEditModal(
curveRadius, curveRadius,
curveLock, curveLock,
curveArc, curveArc,
apexLock,
limitRadiusM, limitRadiusM,
limitArcM, limitArcM,
}), }),
@@ -398,7 +410,7 @@ export async function openRouteEditModal(
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
function snapshotNow(): RouteEditSnapshot { function snapshotNow(): RouteEditSnapshot {
return { planned, curveOn, curveRadius, curveLock, curveArc, picked }; return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
} }
const historyControls = bindHistoryControls({ const historyControls = bindHistoryControls({
@@ -410,6 +422,7 @@ export async function openRouteEditModal(
curveRadius = snapshot.curveRadius; curveRadius = snapshot.curveRadius;
curveLock = snapshot.curveLock; curveLock = snapshot.curveLock;
curveArc = snapshot.curveArc; curveArc = snapshot.curveArc;
apexLock = snapshot.apexLock;
picked = snapshot.picked; picked = snapshot.picked;
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
}, },
@@ -436,11 +449,13 @@ export async function openRouteEditModal(
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
dragNode = nodeAt(px, py); const hitNode = nodeAt(px, py);
dragHandle = dragNode >= 0 ? null : handleAt(px, py); // 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
dragNode = hitNode >= 0 && apexLock[hitNode] !== true ? hitNode : -1;
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
dragMoved = false; dragMoved = false;
if (dragNode >= 0) { if (hitNode >= 0) {
picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. picked = hitNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔). measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
syncCurveBar(); syncCurveBar();
draw(); draw();
@@ -538,7 +553,16 @@ export async function openRouteEditModal(
draw(); draw();
return; return;
} }
canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default"; const overNode = nodeAt(px, py);
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
canvas.style.cursor =
overNode >= 0
? apexLock[overNode]
? "not-allowed"
: "grab"
: handleAt(px, py)
? "grab"
: "default";
}); });
const endDrag = (event: PointerEvent): void => { const endDrag = (event: PointerEvent): void => {
@@ -570,6 +594,7 @@ export async function openRouteEditModal(
curveRadius.splice(segment + 1, 0, null); curveRadius.splice(segment + 1, 0, null);
curveLock.splice(segment + 1, 0, null); curveLock.splice(segment + 1, 0, null);
curveArc.splice(segment + 1, 0, null); curveArc.splice(segment + 1, 0, null);
apexLock.splice(segment + 1, 0, false);
picked = segment + 1; picked = segment + 1;
applyEdit("새 노드를 넣었습니다(직선 추가)."); applyEdit("새 노드를 넣었습니다(직선 추가).");
}); });
@@ -588,6 +613,7 @@ export async function openRouteEditModal(
curveRadius.splice(index, 1); curveRadius.splice(index, 1);
curveLock.splice(index, 1); curveLock.splice(index, 1);
curveArc.splice(index, 1); curveArc.splice(index, 1);
apexLock.splice(index, 1);
picked = -1; picked = -1;
applyEdit("노드를 지웠습니다(직선 삭제)."); applyEdit("노드를 지웠습니다(직선 삭제).");
}); });
@@ -638,6 +664,7 @@ export async function openRouteEditModal(
curveRadius = flat.curveRadius; curveRadius = flat.curveRadius;
curveLock = flat.curveLock; curveLock = flat.curveLock;
curveArc = flat.curveArc; curveArc = flat.curveArc;
apexLock = flat.apexLock;
picked = -1; picked = -1;
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
history = createRouteEditHistory(snapshotNow()); history = createRouteEditHistory(snapshotNow());
+35 -12
View File
@@ -16,8 +16,13 @@
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
* ========================================================================== */ * ========================================================================== */
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import { drawCross, summarizeCross } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; import {
buildCrossSvg,
CROSS_PLOT_PAD,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import { formatStation } from "./B05_Profile_Util_Station"; import { formatStation } from "./B05_Profile_Util_Station";
export interface CrossPreviewParams { export interface CrossPreviewParams {
@@ -33,7 +38,7 @@ export interface CrossPreviewParams {
} }
export interface CrossPreviewWindow { export interface CrossPreviewWindow {
/** 그 측점의 횡단을 위 판에 낸다. 같은 측점을 다시 누르면 보던 것을 아래로 내린다. */ /** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
open: (chainageM: number) => Promise<void>; open: (chainageM: number) => Promise<void>;
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */ /** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
refresh: () => Promise<void>; refresh: () => Promise<void>;
@@ -55,16 +60,15 @@ function createPane(title: string, empty: string): CrossPane {
<strong class="b05-routeedit__cross-title">${title}</strong> <strong class="b05-routeedit__cross-title">${title}</strong>
<span class="b05-routeedit__cross-station"></span> <span class="b05-routeedit__cross-station"></span>
</div> </div>
<canvas class="b05-routeedit__cross-canvas" width="420" height="240"></canvas> <div class="b05-routeedit__cross-box"></div>
<div class="b05-routeedit__cross-foot">${empty}</div>`; <div class="b05-routeedit__cross-foot">${empty}</div>`;
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!; const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!; const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!; const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
const context = canvas.getContext("2d")!;
return { return {
root, root,
show(preview, intervalM) { show(preview, intervalM) {
context.clearRect(0, 0, canvas.width, canvas.height); box.replaceChildren();
if (!preview) { if (!preview) {
station.textContent = ""; station.textContent = "";
foot.textContent = empty; foot.textContent = empty;
@@ -73,11 +77,30 @@ function createPane(title: string, empty: string): CrossPane {
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물 // 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다. // 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
station.textContent = formatStation(preview.chainage_m, intervalM); station.textContent = formatStation(preview.chainage_m, intervalM);
drawCross(context, canvas, preview); // 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
const rect = box.getBoundingClientRect();
const plot = buildCrossSvg(
preview,
Math.max(Math.round(rect.width), 240),
Math.max(Math.round(rect.height), 150),
);
box.append(plot.svg);
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
});
foot.textContent = summarizeCross(preview); foot.textContent = summarizeCross(preview);
}, },
wait(text) { wait(text) {
context.clearRect(0, 0, canvas.width, canvas.height); box.replaceChildren();
foot.textContent = text; foot.textContent = text;
}, },
}; };
@@ -117,11 +140,11 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
return { return {
async open(chainageM) { async open(chainageM) {
// 다른 측점을 고른 것이라면 전후 비교가 아니다 — 아래 판을 비운다. // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
const sameStation = watching !== null && Math.abs(watching - chainageM) < 1e-6; // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
if (!sameStation) previous.show(null, params.request().station_interval_m); previous.show(null, params.request().station_interval_m);
watching = chainageM; watching = chainageM;
await load(chainageM, sameStation); await load(chainageM, false);
}, },
async refresh() { async refresh() {
if (watching === null) return; if (watching === null) return;
@@ -1,17 +1,39 @@
/* ============================================================================= /* =============================================================================
* B05_Profile_UI_RouteEdit_Cross_Draw.ts * B05_Profile_UI_RouteEdit_Cross_Draw.ts
* 횡단 한 장을 캔버스에 그린다 — **원지반선·기본 계획 횡단선**과 아래 한 줄 요약. * 횡단 한 장을 **SVG 선**으로 짓는다 — 원지반선·기본 계획 횡단선과 아래 한 줄 요약.
* *
* `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정). * `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정).
* 값은 서버가 B05·B06 정본으로 낸 것을 그대로 그린다 — 여기서 기하를 만들지 않는다. * 값은 서버가 B05·B06 정본으로 낸 것을 그대로 그린다 — 여기서 기하를 만들지 않는다.
*
* **왜 캔버스가 아니라 SVG 인가**(2026-09-12 사용자 지시 ③) — 캔버스는 한 번 찍으면 그림이라
* 확대하면 뭉개진다. SVG 는 선이 그대로 남아 휠로 키워도 또렷하다. 확대·이동은 **공용
* 조각**(`A00_Common/b_svg_zoom_pan.ts`)이 맡는다 — B06 횡단 카드와 한 코드다.
*
* **계획 횡단선은 절·성토 교차점까지만 그린다**(2026-09-12 사용자 지시 ②). 서버가 주는
* `design_line` 은 계산 반폭 끝까지 이어지는데 **사면 끝 바깥은 손대지 않는 원지반**이라,
* 그대로 그으면 지반선과 겹쳐 같은 지형이 두 번 그려진다. B06 횡단 카드는 2026-08-20 에
* 같은 지적을 받아 이미 이렇게 끊고 있다(`appendCrossDesignOverlay`) — 교차점 찾기도
* 그쪽이 쓰는 `toeOffsets` 를 그대로 부른다.
* ========================================================================== */ * ========================================================================== */
import { svgElement, svgText } from "@util/common_util_svg";
import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan";
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch"; import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit"; import { fillSlopeLengths, toeOffsets } from "./../B06_Section/B06_Section_UI_Cross_Fit";
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
/** 그림 가장자리 여백(px). */ /** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */
const PAD = 24; export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 };
/** 지은 한 판 — 확대·이동은 `layer` 에만 걸린다(범례·안내 글자는 제자리). */
export interface CrossPlot {
svg: SVGSVGElement;
layer: SVGGElement;
width: number;
height: number;
/** 실제로 선이 놓인 범위 — 확대했을 때 팬이 갈 수 있는 데까지. */
content: ContentBounds;
}
/** 성토사면 길이·절성토 면적 한 줄. */ /** 성토사면 길이·절성토 면적 한 줄. */
export function summarizeCross(preview: CrossPreviewResponse): string { export function summarizeCross(preview: CrossPreviewResponse): string {
@@ -19,10 +41,7 @@ export function summarizeCross(preview: CrossPreviewResponse): string {
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다."; if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를 // 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다. // 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
const lengths = fillSlopeLengths({ const lengths = fillSlopeLengths(asSection(preview));
samples: preview.samples,
design,
} as unknown as CrossSection);
const sides = (["left", "right"] as const) const sides = (["left", "right"] as const)
.filter((side) => lengths[side] !== null) .filter((side) => lengths[side] !== null)
.map((side) => { .map((side) => {
@@ -34,27 +53,81 @@ export function summarizeCross(preview: CrossPreviewResponse): string {
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}`; return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}`;
} }
/** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */
function asSection(preview: CrossPreviewResponse): CrossSection {
return { samples: preview.samples, design: preview.design } as unknown as CrossSection;
}
type Point = [number, number];
/** /**
* 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+offset)가 화면 왼쪽이다 * 선을 **절·성토 교차점 사이만** 남긴다(2026-09-12 사용자 지시 ②).
*
* 교차점이 꼭짓점 사이에 떨어지면 그 자리에 점을 하나 만들어 **딱 거기서 끊는다** — 가까운
* 꼭짓점에서 끊으면 사면 끝이 지반에 닿기 전에 멈춘 것처럼 보인다.
* 그 측에서 교차점을 못 찾으면(사면이 지반을 못 만난 측점) 그쪽은 안 자른다.
*/
function clipToToes(line: Point[], low: number, high: number): Point[] {
if (line.length < 2) return line;
const inside = (point: Point): boolean => point[0] >= low - 1e-9 && point[0] <= high + 1e-9;
const cutAt = (from: Point, to: Point, edge: number): Point => {
const span = to[0] - from[0];
const ratio = Math.abs(span) <= 1e-9 ? 0 : (edge - from[0]) / span;
return [edge, from[1] + (to[1] - from[1]) * ratio];
};
const out: Point[] = [];
line.forEach((point, index) => {
if (inside(point)) out.push(point);
const next = line[index + 1];
if (!next) return;
// 경계를 넘어가는 구간은 경계 자리에 점을 하나 세운다. 가까운 경계부터 넣어야 순서가 산다.
[low, high]
.filter((edge) => (point[0] - edge) * (next[0] - edge) < 0)
.sort((a, b) => Math.abs(a - point[0]) - Math.abs(b - point[0]))
.forEach((edge) => out.push(cutAt(point, next, edge)));
});
return out;
}
/**
* 원지반선과 기본 계획 횡단선을 한 판에 짓는다. 좌(+offset)가 화면 왼쪽이다
* (`generate_sections` cad_exchange 규약과 같은 방향). * (`generate_sections` cad_exchange 규약과 같은 방향).
* *
* **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는 * **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
* 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다). * 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
*/ */
export function drawCross( export function buildCrossSvg(
context: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
preview: CrossPreviewResponse, preview: CrossPreviewResponse,
): void { width: number,
height: number,
): CrossPlot {
const svg = svgElement("svg", {
viewBox: `0 0 ${width} ${height}`,
width: "100%",
height: "100%",
preserveAspectRatio: "none",
class: "b05-routeedit__cross-svg",
});
const layer = svgElement("g", { class: "b05-routeedit__cross-plot" });
const ground = preview.samples const ground = preview.samples
.filter((sample) => sample.valid && sample.elevation_m !== null) .filter((sample) => sample.valid && sample.elevation_m !== null)
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]); .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point);
const design = (preview.design?.design_line ?? []).map( const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
(point) => [point.offset_m, point.elevation_m] as [number, number], // 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다.
const design = clipToToes(
(preview.design?.design_line ?? []).map(
(point) => [point.offset_m, point.elevation_m] as Point,
),
toes.right ?? -Infinity, // offset = 우
toes.left ?? Infinity, // +offset = 좌
); );
const all = [...ground, ...design]; const all = [...ground, ...design];
context.clearRect(0, 0, canvas.width, canvas.height); if (all.length < 2) {
if (all.length < 2) return; svg.append(layer);
return { svg, layer, width, height, content: { x: 0, y: 0, width, height } };
}
const offsets = all.map((point) => point[0]); const offsets = all.map((point) => point[0]);
const heights = all.map((point) => point[1]); const heights = all.map((point) => point[1]);
@@ -64,57 +137,80 @@ export function drawCross(
const maxZ = Math.max(...heights); const maxZ = Math.max(...heights);
const spanX = maxOffset - minOffset || 1; const spanX = maxOffset - minOffset || 1;
const spanZ = maxZ - minZ || 1; const spanZ = maxZ - minZ || 1;
const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ); const plotWidth = Math.max(width - CROSS_PLOT_PAD.left - CROSS_PLOT_PAD.right, 1);
const plotHeight = Math.max(height - CROSS_PLOT_PAD.top - CROSS_PLOT_PAD.bottom, 1);
const scale = Math.min(plotWidth / spanX, plotHeight / spanZ);
const centerOffset = (minOffset + maxOffset) / 2; const centerOffset = (minOffset + maxOffset) / 2;
const centerZ = (minZ + maxZ) / 2; const centerZ = (minZ + maxZ) / 2;
const toScreen = (point: [number, number]): [number, number] => [ const toScreen = (point: Point): Point => [
canvas.width / 2 + (centerOffset - point[0]) * scale, width / 2 + (centerOffset - point[0]) * scale,
canvas.height / 2 + (centerZ - point[1]) * scale, height / 2 + (centerZ - point[1]) * scale,
]; ];
const stroke = (points: Array<[number, number]>, color: string, width: number): void => { const path = (points: Point[], className: string): void => {
if (points.length < 2) return; if (points.length < 2) return;
context.beginPath(); layer.append(
points.forEach((point, index) => { svgElement("polyline", {
const [x, y] = toScreen(point); class: className,
if (index === 0) context.moveTo(x, y); points: points.map((point) => toScreen(point).join(",")).join(" "),
else context.lineTo(x, y); }),
}); );
context.strokeStyle = color;
context.lineWidth = width;
context.stroke();
}; };
// 중심선 — 어디가 노선 가운데인지 먼저 보이게. // 중심선 — 어디가 노선 가운데인지 먼저 보이게.
const [centerX] = toScreen([0, centerZ]); const [centerX] = toScreen([0, centerZ]);
context.save(); layer.append(
context.setLineDash([4, 4]); svgElement("line", {
context.strokeStyle = "rgba(148,163,184,0.7)"; class: "b05-routeedit__cross-axis",
context.lineWidth = 1; x1: centerX,
context.beginPath(); y1: CROSS_PLOT_PAD.top / 2,
context.moveTo(centerX, PAD / 2); x2: centerX,
context.lineTo(centerX, canvas.height - PAD / 2); y2: height - CROSS_PLOT_PAD.bottom / 2,
context.stroke(); }),
context.restore();
stroke(ground, "#94a3b8", 1.6); // 원지반
stroke(design, "#f97316", 2.2); // 기본 계획 횡단
context.font = "11px system-ui, sans-serif";
context.textBaseline = "top";
context.fillStyle = "#94a3b8";
context.textAlign = "left";
context.fillText("원지반", PAD, 4);
context.fillStyle = "#f97316";
context.textAlign = "right";
context.fillText("기본 계획 횡단", canvas.width - PAD, 4);
context.fillStyle = "#94a3b8";
context.textAlign = "center";
context.textBaseline = "bottom";
context.fillText(
`${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
canvas.width / 2,
canvas.height - 2,
); );
path(ground, "b05-routeedit__cross-ground");
path(design, "b05-routeedit__cross-design");
// 범례·눈금 안내는 **확대해도 제자리**다 — 확대 묶음 밖에 둔다.
svg.append(layer);
svg.append(
svgText("원지반", {
class: "b05-routeedit__cross-key is-ground",
x: CROSS_PLOT_PAD.left,
y: 13,
"text-anchor": "start",
}),
svgText("기본 계획 횡단", {
class: "b05-routeedit__cross-key is-design",
x: width - CROSS_PLOT_PAD.right,
y: 13,
"text-anchor": "end",
}),
svgText(
`${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
{
class: "b05-routeedit__cross-key",
x: width / 2,
y: height - 6,
"text-anchor": "middle",
},
),
);
const screens = all.map(toScreen);
const xs = screens.map((point) => point[0]);
const ys = screens.map((point) => point[1]);
return {
svg,
layer,
width,
height,
content: {
x: Math.min(...xs),
y: Math.min(...ys),
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys),
},
};
} }
@@ -29,6 +29,8 @@ export interface CurveBarState {
curveRadius: Array<number | null>; curveRadius: Array<number | null>;
curveLock: CurveLock[]; curveLock: CurveLock[];
curveArc: Array<number | null>; curveArc: Array<number | null>;
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①). */
apexLock: boolean[];
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ /** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM: number; limitRadiusM: number;
limitArcM: number; limitArcM: number;
@@ -93,6 +95,13 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
: "고정을 풀었습니다.", : "고정을 풀었습니다.",
); );
}, },
onApexLock: (locked) => {
const { picked, apexLock } = params.state();
if (picked < 0) return;
apexLock[picked] = locked;
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
params.applyEdit(locked ? "교각점을 고정했습니다." : "교각점 고정을 풀었습니다.");
},
onCurveOn: (on) => { onCurveOn: (on) => {
const { picked, curveOn } = params.state(); const { picked, curveOn } = params.state();
if (picked < 0) return; if (picked < 0) return;
@@ -111,6 +120,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
curveOn, curveOn,
curveRadius, curveRadius,
curveLock, curveLock,
apexLock,
limitRadiusM, limitRadiusM,
limitArcM, limitArcM,
} = params.state(); } = params.state();
@@ -146,6 +156,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
radiusShown: shown, radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null, lock: curveLock[picked] ?? null,
apexLocked: apexLock[picked] === true,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
limitRadiusM, limitRadiusM,
limitArcM, limitArcM,
@@ -114,6 +114,8 @@ export interface CurveSummaryInput {
curveOn: boolean[]; curveOn: boolean[];
curveRadius: Array<number | null>; curveRadius: Array<number | null>;
curveLock: CurveLock[]; curveLock: CurveLock[];
/** 교각점을 못 박은 자리 — 「고정 N곳」에 함께 센다(2026-09-12 사용자 지시 ①). */
apexLock: boolean[];
/** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */ /** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */
curveCount: number; curveCount: number;
/** 법정 기준을 못 맞춘 자리 수. */ /** 법정 기준을 못 맞춘 자리 수. */
@@ -129,7 +131,8 @@ export function curveSummary(input: CurveSummaryInput): string {
(on, index) => !on && index > 0 && index < input.nodeCount - 1, (on, index) => !on && index > 0 && index < input.nodeCount - 1,
).length; ).length;
const forced = input.curveRadius.filter((value) => value !== null).length; const forced = input.curveRadius.filter((value) => value !== null).length;
const locked = input.curveLock.filter((lock) => lock !== null).length; const locked =
input.curveLock.filter((lock) => lock !== null).length + input.apexLock.filter(Boolean).length;
const edits = [ const edits = [
off ? `곡선 지움 ${off}` : "", off ? `곡선 지움 ${off}` : "",
forced ? `R 지정 ${forced}` : "", forced ? `R 지정 ${forced}` : "",
@@ -157,6 +160,7 @@ export interface FlattenedPlan {
curveRadius: Array<number | null>; curveRadius: Array<number | null>;
curveLock: CurveLock[]; curveLock: CurveLock[];
curveArc: Array<number | null>; curveArc: Array<number | null>;
apexLock: boolean[];
} }
interface ServerNode { interface ServerNode {
@@ -198,6 +202,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
curveRadius: [], curveRadius: [],
curveLock: [], curveLock: [],
curveArc: [], curveArc: [],
apexLock: [],
}; };
nodes.forEach((node, index) => { nodes.forEach((node, index) => {
if (dropped.has(index)) return; if (dropped.has(index)) return;
@@ -215,6 +220,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
out.curveLock.push(null); out.curveLock.push(null);
out.curveArc.push(null); out.curveArc.push(null);
out.apexLock.push(false);
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat }); if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
}); });
return out; return out;
@@ -21,6 +21,8 @@ export interface RouteEditSnapshot {
curveLock: Array<"radius" | "arc" | null>; curveLock: Array<"radius" | "arc" | null>;
/** 길이를 붙들었을 때의 그 길이(m). */ /** 길이를 붙들었을 때의 그 길이(m). */
curveArc: Array<number | null>; curveArc: Array<number | null>;
/** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */
apexLock: boolean[];
picked: number; picked: number;
} }
@@ -50,6 +52,7 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
curveRadius: [...snapshot.curveRadius], curveRadius: [...snapshot.curveRadius],
curveLock: [...snapshot.curveLock], curveLock: [...snapshot.curveLock],
curveArc: [...snapshot.curveArc], curveArc: [...snapshot.curveArc],
apexLock: [...snapshot.apexLock],
picked: snapshot.picked, picked: snapshot.picked,
}; };
} }
@@ -64,6 +67,7 @@ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
if (a.curveRadius[index] !== b.curveRadius[index]) return false; if (a.curveRadius[index] !== b.curveRadius[index]) return false;
if (a.curveLock[index] !== b.curveLock[index]) return false; if (a.curveLock[index] !== b.curveLock[index]) return false;
if (a.curveArc[index] !== b.curveArc[index]) return false; if (a.curveArc[index] !== b.curveArc[index]) return false;
if (a.apexLock[index] !== b.apexLock[index]) return false;
} }
return true; return true;
} }
@@ -70,6 +70,8 @@ export interface CurveLabelState {
/** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ /** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
arcLengthShown: number | null; arcLengthShown: number | null;
lock: CurveLock; lock: CurveLock;
/** 교각점(이 꺾임점) 자리를 못 박았나 — 켜면 끌어서 못 옮긴다(2026-09-12 사용자 지시 ①). */
apexLocked: boolean;
innerAngleDeg: number | null; innerAngleDeg: number | null;
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ /** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM?: number; limitRadiusM?: number;
@@ -84,6 +86,8 @@ export interface CurveLabelHandlers {
onCurveOn: (on: boolean) => void; onCurveOn: (on: boolean) => void;
/** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */ /** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */
onLock: (lock: CurveLock) => void; onLock: (lock: CurveLock) => void;
/** 교각점 자리를 못 박거나 푼다. */
onApexLock: (locked: boolean) => void;
} }
export interface CurveLabel { export interface CurveLabel {
@@ -131,6 +135,10 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<button type="button" class="b05-routeedit__lock" data-act="lock-arc" <button type="button" class="b05-routeedit__lock" data-act="lock-arc"
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button> title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
</label> </label>
<div class="b05-routeedit__curve-field">교각점
<button type="button" class="b05-routeedit__lock" data-act="lock-apex"
title="교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다">고정</button>
</div>
<span class="b05-routeedit__curve-info"></span>`; <span class="b05-routeedit__curve-info"></span>`;
document.body.append(root); document.body.append(root);
@@ -141,6 +149,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
const arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-arc")!; const arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-arc")!;
const lockRadius = root.querySelector<HTMLButtonElement>('[data-act="lock-radius"]')!; const lockRadius = root.querySelector<HTMLButtonElement>('[data-act="lock-radius"]')!;
const lockArc = root.querySelector<HTMLButtonElement>('[data-act="lock-arc"]')!; const lockArc = root.querySelector<HTMLButtonElement>('[data-act="lock-arc"]')!;
const lockApex = root.querySelector<HTMLButtonElement>('[data-act="lock-apex"]')!;
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!; const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. // 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
@@ -150,6 +159,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
let curveOn = true; let curveOn = true;
let lock: CurveLock = null; let lock: CurveLock = null;
let apexLocked = false;
let seat = -1; let seat = -1;
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */ /** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
let manual: [number, number] | null = null; let manual: [number, number] | null = null;
@@ -179,6 +189,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
.addEventListener("click", () => handlers.onClose()); .addEventListener("click", () => handlers.onClose());
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius")); lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc")); lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
// 교각점 고정은 **곡선 유무와 무관**하다 — 곡선을 지운 자리도 꺾임점 자리는 못 박을 수 있다.
lockApex.addEventListener("click", () => handlers.onApexLock(!apexLocked));
// ── 머리를 잡아 옮기기 — 곡선을 가리면 손으로 치울 수 있어야 한다 ── // ── 머리를 잡아 옮기기 — 곡선을 가리면 손으로 치울 수 있어야 한다 ──
let dragFrom: { x: number; y: number; left: number; top: number } | null = null; let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
@@ -260,6 +272,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
} }
curveOn = state.curveOn; curveOn = state.curveOn;
lock = state.lock; lock = state.lock;
apexLocked = state.apexLocked;
limit = state.bounds; limit = state.bounds;
limitRadius = state.limitRadiusM ?? 0; limitRadius = state.limitRadiusM ?? 0;
limitArc = state.limitArcM ?? 0; limitArc = state.limitArcM ?? 0;
@@ -275,6 +288,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
lockArc.disabled = !state.curveOn; lockArc.disabled = !state.curveOn;
lockRadius.classList.toggle("is-on", lock === "radius"); lockRadius.classList.toggle("is-on", lock === "radius");
lockArc.classList.toggle("is-on", lock === "arc"); lockArc.classList.toggle("is-on", lock === "arc");
lockApex.classList.toggle("is-on", apexLocked);
radius.value = radius.value =
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10); state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
arc.value = arc.value =
+36 -13
View File
@@ -160,20 +160,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed"; context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
context.lineWidth = 2.6; context.lineWidth = 2.6;
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view); drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
drawPickedContourLabel(context, scene, view);
} }
// 등고 높이값 — 확대가 클수록 촘촘히 낸다(계획서 0-9 ③). // 높이값 라벨은 여기서 안 낸다 — 노선·눈금 **뒤에** 그려야 안 묻힌다(맨 아래 참고).
context.font = "10px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
drawPreparedLabels(
context,
scene.contours.layer,
view,
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
everyM,
scene.uprightRad,
);
} }
context.restore(); context.restore();
@@ -254,9 +242,44 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
drawStationMarks(context, scene, line); drawStationMarks(context, scene, line);
drawMeasureMarks(context, scene, line); drawMeasureMarks(context, scene, line);
// 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
// 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
drawContourLabels(context, scene, band, style);
context.restore(); // 회전 끝 context.restore(); // 회전 끝
} }
/** 등고 높이값 라벨 — **그림 맨 위**에 얹는다(2026-09-12 사용자 지시 ⑦).
*
* 고른 가닥의 큰 이름표도 여기서 낸다. 줄과 라벨은 **같은 눈금**(`contourStepM`)으로 솎아
* 그린 줄에만 숫자가 붙는다 — 촘촘함은 부르는 쪽이 그 눈금으로 정한다. */
function drawContourLabels(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
band: { x: number; y: number; width: number; height: number } | null,
style: CSSStyleDeclaration,
): void {
if (!scene.contours) return;
context.save();
if (band) {
context.beginPath();
context.rect(band.x, band.y, band.width, band.height);
context.clip();
}
context.font = "10px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
drawPreparedLabels(
context,
scene.contours.layer,
scene.view,
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
scene.contourStepM,
scene.uprightRad,
);
if (scene.pickedContour >= 0) drawPickedContourLabel(context, scene, scene.view);
context.restore();
}
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */ /** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
function drawMeasureMarks( function drawMeasureMarks(
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
+53 -2
View File
@@ -190,15 +190,58 @@
font-size: var(--text-caption); font-size: var(--text-caption);
} }
.b05-routeedit__cross-canvas { /* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③).
휠 = 확대·축소, 가운데 버튼 끌기 = 이동, 더블클릭 = 원복(공용 조각이 맡음). */
.b05-routeedit__cross-box {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 150px;
overflow: hidden;
width: 100%; width: 100%;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px); border-radius: var(--radius-8, 6px);
background: var(--color-surface); background: var(--color-surface);
} }
.b05-routeedit__cross-svg {
display: block;
width: 100%;
height: 100%;
cursor: default;
}
.b05-routeedit__cross-svg.is-panning {
cursor: grabbing;
}
.b05-routeedit__cross-axis {
stroke: rgb(148 163 184 / 70%);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.b05-routeedit__cross-ground {
fill: none;
stroke: var(--color-text-secondary, #94a3b8);
stroke-width: 1.6;
vector-effect: non-scaling-stroke;
}
.b05-routeedit__cross-design {
fill: none;
stroke: var(--map-route, #f97316);
stroke-width: 2.2;
vector-effect: non-scaling-stroke;
}
.b05-routeedit__cross-key {
fill: var(--color-text-secondary, #94a3b8);
font-size: 11px;
}
.b05-routeedit__cross-key.is-design {
fill: var(--map-route, #f97316);
}
.b05-routeedit__cross-foot { .b05-routeedit__cross-foot {
flex: none; flex: none;
color: var(--color-text-secondary); color: var(--color-text-secondary);
@@ -381,3 +424,11 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
line-height: 1.35; line-height: 1.35;
} }
/* 「거리 재기」가 켜진 동안 — 단추가 눌린 꼴로 남는다(2026-09-12 사용자 지시 ⑥).
`is-active` 는 예전에도 붙고 있었으나 꼴이 없어 겉모습이 그대로였다. */
.b05-routeedit__actions .ui-btn.is-active {
border-color: transparent;
background: var(--color-primary, #7c3aed);
color: #fff;
}
+32
View File
@@ -58,6 +58,38 @@ export function toeFitHalfWidth(section: CrossSection): number | null {
return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M); 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 사용자 지시). * 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
* *
+26 -159
View File
@@ -1,24 +1,23 @@
/* ============================================================================= /* =============================================================================
* B06_Section_UI_Cross_View_Zoom.ts * 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"; import { CROSS_PAD, L } from "./B06_Section_UI_Section_Common";
/** export type { ContentBounds, ZoomPanHandle, ZoomPanState };
* 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다.
*
* **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에
* 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭.
*/
export interface ZoomPanHandle {
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
zoom: (factor: number) => void;
reset: () => void;
/** 현재 배율 — 원배율(1)일 때는 버튼이 배율 대신 **표시 반폭**을 조절한다. */
scale: () => number;
}
/** /**
* 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단 * 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단
@@ -34,28 +33,10 @@ export interface CrossWidthActions {
fit: () => void; 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 사용자 ③). */ /** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */
export const cardZoomStates = new Map<string, ZoomPanState>(); export const cardZoomStates = new Map<string, ZoomPanState>();
/** 카드 SVG 에 줌·팬을 붙인다 — 여백만 씌워 공용 조각에 넘긴다. 부르는 꼴은 그대로다. */
export function attachZoomPan( export function attachZoomPan(
svg: SVGSVGElement, svg: SVGSVGElement,
plotLayer: SVGGElement, plotLayer: SVGGElement,
@@ -68,132 +49,18 @@ export function attachZoomPan(
/** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */ /** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */
content?: ContentBounds, content?: ContentBounds,
): ZoomPanHandle { ): ZoomPanHandle {
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다. return attachSvgZoomPan({
const plot = { svg,
x: CROSS_PAD.left, layer: plotLayer,
y: CROSS_PAD.top, widthPx,
width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1), heightPx,
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1), pad: CROSS_PAD,
}; initial,
const maxScale = 8; onChange,
let scale = initial?.scale ?? 1; content,
let tx = initial?.tx ?? 0; // 휠은 카드 목록을 훑는 몫이다 — 줌에 안 묶는다.
let ty = initial?.ty ?? 0; wheelZoom: false,
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();
}); });
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 };
} }
/** /**