From abe956affc8f047a8bb75510ceb57f540a661904 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 20:37:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(b05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EC=86=90=EC=A7=88=20=EC=97=AC?= =?UTF-8?q?=EC=84=AF=20=E2=80=94=20=EA=B5=90=EA=B0=81=EC=A0=90=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=C2=B7=ED=9A=A1=EB=8B=A8=20=EB=B2=A1=ED=84=B0=ED=99=94?= =?UTF-8?q?=C2=B7=EB=9D=BC=EB=B2=A8=20=EB=A7=A8=20=EC=9C=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 교각점 고정 단추: 켜면 그 꺾임점이 노드·손잡이 어느 쪽으로도 안 끌림(`apexLock`) - 계획 횡단선을 절·성토 교차점에서 끊음 — 그 밖은 원지반이라 지반선과 겹쳐 그려지던 것. 교차점은 B06 이 쓰는 `toeOffsets` 를 열어 함께 씀 - 횡단 판을 캔버스에서 SVG 로 바꾸고 휠 줌·가운데 끌기 팬·더블클릭 원복을 붙임 - 줌·팬 본문을 `A00_Common/b_svg_zoom_pan.ts` 공용 조각으로 빼고 B06 은 감싸개만 남김 - 「이전 횡단」 판은 계획 평면선이 바뀔 때만 채움(같은 측점 두 번 누르기로는 안 채움) - 「거리 재기」 단추의 눌린 꼴 추가 · 등고선 높이 라벨을 맨 위로 올리고 더 촘촘히 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd --- A00_Common/b_svg_zoom_pan.ts | 219 ++++++++++++++++++ B05_Profile/B05_Profile_UI_RouteEdit.ts | 43 +++- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 47 +++- .../B05_Profile_UI_RouteEdit_Cross_Draw.ts | 218 ++++++++++++----- .../B05_Profile_UI_RouteEdit_CurveBar.ts | 11 + B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts | 8 +- .../B05_Profile_UI_RouteEdit_History.ts | 4 + B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 14 ++ .../B05_Profile_UI_RouteEdit_Render.ts | 49 ++-- .../B05_Profile_UI_Style_RouteEdit.css | 55 ++++- B06_Section/B06_Section_UI_Cross_Fit.ts | 32 +++ B06_Section/B06_Section_UI_Cross_View_Zoom.ts | 185 +++------------ 12 files changed, 629 insertions(+), 256 deletions(-) create mode 100644 A00_Common/b_svg_zoom_pan.ts diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts new file mode 100644 index 00000000..d7d3ff6c --- /dev/null +++ b/A00_Common/b_svg_zoom_pan.ts @@ -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 }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 87521bd7..ada357e2 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -74,6 +74,10 @@ const SEGMENT_HIT_PX = 12; const CONTOUR_HIT_PX = 6; /** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */ const STATION_HIT_PX = 11; +/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 + * (2026-09-12 사용자 지시 ⑦ 「좀더 촘촘히」). 편집 창은 노선 둘레 300m 띠만 보이므로 + * 같은 예산으로도 화면에 드는 줄이 적어 눈금이 필요 이상으로 성겼다. */ +const CONTOUR_LEVEL_BUDGET = 1200; type Vertex = [number, number]; @@ -118,6 +122,9 @@ export async function openRouteEditModal( * 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */ let curveLock: CurveLock[] = []; let curveArc: Array = []; + /** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①) — 켜진 자리는 노드도 손잡이도 + * 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. */ + let apexLock: boolean[] = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ @@ -258,7 +265,7 @@ export async function openRouteEditModal( /** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */ 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 { if (closed) return; @@ -291,7 +298,10 @@ export async function openRouteEditModal( const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => handleAtScreen( // 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 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, px, py, @@ -354,6 +364,7 @@ export async function openRouteEditModal( curveOn, curveRadius, curveLock, + apexLock, curveCount: curveInfo.length, violationCount: nodeInfo.filter((node) => node.violations.length).length, minRadiusM, @@ -372,6 +383,7 @@ export async function openRouteEditModal( curveRadius, curveLock, curveArc, + apexLock, limitRadiusM, limitArcM, }), @@ -398,7 +410,7 @@ export async function openRouteEditModal( /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ function snapshotNow(): RouteEditSnapshot { - return { planned, curveOn, curveRadius, curveLock, curveArc, picked }; + return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked }; } const historyControls = bindHistoryControls({ @@ -410,6 +422,7 @@ export async function openRouteEditModal( curveRadius = snapshot.curveRadius; curveLock = snapshot.curveLock; curveArc = snapshot.curveArc; + apexLock = snapshot.apexLock; picked = snapshot.picked; applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. }, @@ -436,11 +449,13 @@ export async function openRouteEditModal( // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. - dragNode = nodeAt(px, py); - dragHandle = dragNode >= 0 ? null : handleAt(px, py); + const hitNode = nodeAt(px, py); + // 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①). + dragNode = hitNode >= 0 && apexLock[hitNode] !== true ? hitNode : -1; + dragHandle = hitNode >= 0 ? null : handleAt(px, py); dragMoved = false; - if (dragNode >= 0) { - picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. + if (hitNode >= 0) { + picked = hitNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔). syncCurveBar(); draw(); @@ -538,7 +553,16 @@ export async function openRouteEditModal( draw(); 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 => { @@ -570,6 +594,7 @@ export async function openRouteEditModal( curveRadius.splice(segment + 1, 0, null); curveLock.splice(segment + 1, 0, null); curveArc.splice(segment + 1, 0, null); + apexLock.splice(segment + 1, 0, false); picked = segment + 1; applyEdit("새 노드를 넣었습니다(직선 추가)."); }); @@ -588,6 +613,7 @@ export async function openRouteEditModal( curveRadius.splice(index, 1); curveLock.splice(index, 1); curveArc.splice(index, 1); + apexLock.splice(index, 1); picked = -1; applyEdit("노드를 지웠습니다(직선 삭제)."); }); @@ -638,6 +664,7 @@ export async function openRouteEditModal( curveRadius = flat.curveRadius; curveLock = flat.curveLock; curveArc = flat.curveArc; + apexLock = flat.apexLock; picked = -1; // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. history = createRouteEditHistory(snapshotNow()); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index f990ada5..4117ec05 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -16,8 +16,13 @@ * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * ========================================================================== */ +import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan"; 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"; export interface CrossPreviewParams { @@ -33,7 +38,7 @@ export interface CrossPreviewParams { } export interface CrossPreviewWindow { - /** 그 측점의 횡단을 위 판에 낸다. 같은 측점을 다시 누르면 보던 것을 아래로 내린다. */ + /** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */ open: (chainageM: number) => Promise; /** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */ refresh: () => Promise; @@ -55,16 +60,15 @@ function createPane(title: string, empty: string): CrossPane { ${title} - +
${empty}
`; const station = root.querySelector(".b05-routeedit__cross-station")!; const foot = root.querySelector(".b05-routeedit__cross-foot")!; - const canvas = root.querySelector(".b05-routeedit__cross-canvas")!; - const context = canvas.getContext("2d")!; + const box = root.querySelector(".b05-routeedit__cross-box")!; return { root, show(preview, intervalM) { - context.clearRect(0, 0, canvas.width, canvas.height); + box.replaceChildren(); if (!preview) { station.textContent = ""; foot.textContent = empty; @@ -73,11 +77,30 @@ function createPane(title: string, empty: string): CrossPane { // 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물 // 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다. 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); }, wait(text) { - context.clearRect(0, 0, canvas.width, canvas.height); + box.replaceChildren(); foot.textContent = text; }, }; @@ -117,11 +140,11 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind return { async open(chainageM) { - // 다른 측점을 고른 것이라면 전후 비교가 아니다 — 아래 판을 비운다. - const sameStation = watching !== null && Math.abs(watching - chainageM) < 1e-6; - if (!sameStation) previous.show(null, params.request().station_interval_m); + // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 + // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. + previous.show(null, params.request().station_interval_m); watching = chainageM; - await load(chainageM, sameStation); + await load(chainageM, false); }, async refresh() { if (watching === null) return; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts index 825e2aa1..b99f5d44 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts @@ -1,17 +1,39 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Cross_Draw.ts - * 횡단 한 장을 캔버스에 그린다 — **원지반선·기본 계획 횡단선**과 아래 한 줄 요약. + * 횡단 한 장을 **SVG 선**으로 짓는다 — 원지반선·기본 계획 횡단선과 아래 한 줄 요약. * * `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정). * 값은 서버가 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 { 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"; -/** 그림 가장자리 여백(px). */ -const PAD = 24; +/** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */ +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 { @@ -19,10 +41,7 @@ export function summarizeCross(preview: CrossPreviewResponse): string { if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다."; // 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를 // 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다. - const lengths = fillSlopeLengths({ - samples: preview.samples, - design, - } as unknown as CrossSection); + const lengths = fillSlopeLengths(asSection(preview)); const sides = (["left", "right"] as const) .filter((side) => lengths[side] !== null) .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)}㎡`; } +/** 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 규약과 같은 방향). * * **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는 * 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다). */ -export function drawCross( - context: CanvasRenderingContext2D, - canvas: HTMLCanvasElement, +export function buildCrossSvg( 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 .filter((sample) => sample.valid && sample.elevation_m !== null) - .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]); - const design = (preview.design?.design_line ?? []).map( - (point) => [point.offset_m, point.elevation_m] as [number, number], + .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point); + const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null }; + // 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다. + 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]; - context.clearRect(0, 0, canvas.width, canvas.height); - if (all.length < 2) return; + if (all.length < 2) { + svg.append(layer); + return { svg, layer, width, height, content: { x: 0, y: 0, width, height } }; + } const offsets = all.map((point) => point[0]); const heights = all.map((point) => point[1]); @@ -64,57 +137,80 @@ export function drawCross( const maxZ = Math.max(...heights); const spanX = maxOffset - minOffset || 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 centerZ = (minZ + maxZ) / 2; - const toScreen = (point: [number, number]): [number, number] => [ - canvas.width / 2 + (centerOffset - point[0]) * scale, - canvas.height / 2 + (centerZ - point[1]) * scale, + const toScreen = (point: Point): Point => [ + width / 2 + (centerOffset - point[0]) * 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; - context.beginPath(); - points.forEach((point, index) => { - const [x, y] = toScreen(point); - if (index === 0) context.moveTo(x, y); - else context.lineTo(x, y); - }); - context.strokeStyle = color; - context.lineWidth = width; - context.stroke(); + layer.append( + svgElement("polyline", { + class: className, + points: points.map((point) => toScreen(point).join(",")).join(" "), + }), + ); }; // 중심선 — 어디가 노선 가운데인지 먼저 보이게. const [centerX] = toScreen([0, centerZ]); - context.save(); - context.setLineDash([4, 4]); - context.strokeStyle = "rgba(148,163,184,0.7)"; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(centerX, PAD / 2); - context.lineTo(centerX, canvas.height - PAD / 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, + layer.append( + svgElement("line", { + class: "b05-routeedit__cross-axis", + x1: centerX, + y1: CROSS_PLOT_PAD.top / 2, + x2: centerX, + y2: height - CROSS_PLOT_PAD.bottom / 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), + }, + }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index 5e4ec28c..49170ef7 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -29,6 +29,8 @@ export interface CurveBarState { curveRadius: Array; curveLock: CurveLock[]; curveArc: Array; + /** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①). */ + apexLock: boolean[]; /** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ limitRadiusM: 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) => { const { picked, curveOn } = params.state(); if (picked < 0) return; @@ -111,6 +120,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar { curveOn, curveRadius, curveLock, + apexLock, limitRadiusM, limitArcM, } = params.state(); @@ -146,6 +156,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar { radiusShown: shown, arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, lock: curveLock[picked] ?? null, + apexLocked: apexLock[picked] === true, innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, limitRadiusM, limitArcM, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts index 1ff96453..af68eb9e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts @@ -114,6 +114,8 @@ export interface CurveSummaryInput { curveOn: boolean[]; curveRadius: Array; curveLock: CurveLock[]; + /** 교각점을 못 박은 자리 — 「고정 N곳」에 함께 센다(2026-09-12 사용자 지시 ①). */ + apexLock: boolean[]; /** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */ curveCount: number; /** 법정 기준을 못 맞춘 자리 수. */ @@ -129,7 +131,8 @@ export function curveSummary(input: CurveSummaryInput): string { (on, index) => !on && index > 0 && index < input.nodeCount - 1, ).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 = [ off ? `곡선 지움 ${off}곳` : "", forced ? `R 지정 ${forced}곳` : "", @@ -157,6 +160,7 @@ export interface FlattenedPlan { curveRadius: Array; curveLock: CurveLock[]; curveArc: Array; + apexLock: boolean[]; } interface ServerNode { @@ -198,6 +202,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F curveRadius: [], curveLock: [], curveArc: [], + apexLock: [], }; nodes.forEach((node, index) => { 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.curveLock.push(null); out.curveArc.push(null); + out.apexLock.push(false); if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat }); }); return out; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index 9db4946c..329eb678 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -21,6 +21,8 @@ export interface RouteEditSnapshot { curveLock: Array<"radius" | "arc" | null>; /** 길이를 붙들었을 때의 그 길이(m). */ curveArc: Array; + /** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */ + apexLock: boolean[]; picked: number; } @@ -50,6 +52,7 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { curveRadius: [...snapshot.curveRadius], curveLock: [...snapshot.curveLock], curveArc: [...snapshot.curveArc], + apexLock: [...snapshot.apexLock], 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.curveLock[index] !== b.curveLock[index]) return false; if (a.curveArc[index] !== b.curveArc[index]) return false; + if (a.apexLock[index] !== b.apexLock[index]) return false; } return true; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index 0f0703b1..1736f758 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -70,6 +70,8 @@ export interface CurveLabelState { /** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ arcLengthShown: number | null; lock: CurveLock; + /** 교각점(이 꺾임점) 자리를 못 박았나 — 켜면 끌어서 못 옮긴다(2026-09-12 사용자 지시 ①). */ + apexLocked: boolean; innerAngleDeg: number | null; /** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ limitRadiusM?: number; @@ -84,6 +86,8 @@ export interface CurveLabelHandlers { onCurveOn: (on: boolean) => void; /** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */ onLock: (lock: CurveLock) => void; + /** 교각점 자리를 못 박거나 푼다. */ + onApexLock: (locked: boolean) => void; } export interface CurveLabel { @@ -131,6 +135,10 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { +
교각점 + +
`; document.body.append(root); @@ -141,6 +149,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { const arc = root.querySelector(".b05-routeedit__curve-arc")!; const lockRadius = root.querySelector('[data-act="lock-radius"]')!; const lockArc = root.querySelector('[data-act="lock-arc"]')!; + const lockApex = root.querySelector('[data-act="lock-apex"]')!; const info = root.querySelector(".b05-routeedit__curve-info")!; // 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. @@ -150,6 +159,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { let curveOn = true; let lock: CurveLock = null; + let apexLocked = false; let seat = -1; /** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */ let manual: [number, number] | null = null; @@ -179,6 +189,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { .addEventListener("click", () => handlers.onClose()); lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius")); 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; @@ -260,6 +272,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { } curveOn = state.curveOn; lock = state.lock; + apexLocked = state.apexLocked; limit = state.bounds; limitRadius = state.limitRadiusM ?? 0; limitArc = state.limitArcM ?? 0; @@ -275,6 +288,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { lockArc.disabled = !state.curveOn; lockRadius.classList.toggle("is-on", lock === "radius"); lockArc.classList.toggle("is-on", lock === "arc"); + lockApex.classList.toggle("is-on", apexLocked); radius.value = state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10); arc.value = diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 9bfa6af0..023cd01c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -160,20 +160,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed"; context.lineWidth = 2.6; 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(); @@ -254,9 +242,44 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou drawStationMarks(context, scene, line); drawMeasureMarks(context, scene, line); + // 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저 + // 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다. + drawContourLabels(context, scene, band, style); 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 ⑤). */ function drawMeasureMarks( context: CanvasRenderingContext2D, diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index a02701a2..b620c00a 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -190,15 +190,58 @@ font-size: var(--text-caption); } -.b05-routeedit__cross-canvas { +/* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③). + 휠 = 확대·축소, 가운데 버튼 끌기 = 이동, 더블클릭 = 원복(공용 조각이 맡음). */ +.b05-routeedit__cross-box { flex: 1 1 auto; - min-height: 0; + min-height: 150px; + overflow: hidden; width: 100%; border: 1px solid var(--color-border); border-radius: var(--radius-8, 6px); 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 { flex: none; color: var(--color-text-secondary); @@ -381,3 +424,11 @@ color: var(--color-text-secondary); 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; +} diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index b5d09f6c..c05d8f59 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -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 사용자 지시). * diff --git a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts index 9ccb9de7..928d2071 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts @@ -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(); +/** 카드 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 }; } /**