From ecb3bcb9ce2e73a71d96a430423d1bc2c5b3125c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 07:11:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B05):=20=EC=A2=85=EB=8B=A8=EB=8F=84=20?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=ED=82=A4=20=EC=97=B0=EC=86=8D=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=C2=B7=EC=9D=B4=EB=A0=A5=20=ED=95=9C=20=EB=8D=A9?= =?UTF-8?q?=EC=96=B4=EB=A6=AC=C2=B7=EC=89=AC=ED=94=84=ED=8A=B8=20=EC=83=81?= =?UTF-8?q?=ED=95=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 방향키 청취를 패널 루트에서 window 로 옮김 — 그래프 클릭 뒤 포커스가 body 에 남아 방향키가 아무 반응이 없던 결함 수정 (패널이 화면에 없으면 무시) - 길게 누르면 편집 버튼과 같은 속도로 연속 이동 (0.5초 뒤 0.1초 간격, OS 키 반복 무시) - 이력에 pause()/resume() 추가 — 연속 이동 전체가 되돌리기 1회로 원위치. 구조물 이동은 서버 재계산이 뒤따르므로 키를 뗀 뒤 700ms 뒤 기록 - pause() 에서도 관 목록을 세션에 흘려 되돌리기 기준을 조작 전 값으로 고정 - 좌우 연속 이동 중에는 처음 잡은 구조물 측점·자체 누가거리 카운터 사용 — 관 측점 id 가 누가거리 기반이라 한 번 옮기면 선택이 풀려 한 칸만 움직이던 결함 수정 - ProfileTools.nudge(delta) 노출 — [쉬프트] 모드의 상·하 방향키가 도구 ▲▼ 와 같은 경로(onShift)로 구간 평행이동 Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 4 +- B05_Profile/B05_Profile_UI_Profile_History.ts | 16 ++- .../B05_Profile_UI_Profile_Panel_Tools.ts | 135 +++++++++++++++--- B05_Profile/B05_Profile_UI_Profile_Tools.ts | 15 +- 4 files changed, 144 insertions(+), 26 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index dd5325b8..0b6ce3e4 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -23,8 +23,8 @@ const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; const BUTTON_CLEARANCE_PX = 23; const BUTTON_HALF_PX = 10; /** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 10회(0.1m씩)로 이어진다. */ -const HOLD_DELAY_MS = 500; -const HOLD_INTERVAL_MS = 100; +export const HOLD_DELAY_MS = 500; +export const HOLD_INTERVAL_MS = 100; /** * 길게 누르는 동안 같은 동작을 반복한다. diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index 701ae660..b1b41cd0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -75,6 +75,10 @@ function restoreSnapshot(snapshot: HistorySnapshot): void { export interface ProfileHistory { /** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */ record(): void; + /** 연속 조작(길게 누르기) 동안 기록을 미룬다 — 20칸 이동이 되돌리기 20번이 되는 것을 막는다. */ + pause(): void; + /** 연속 조작이 끝났음을 알린다 — 그 동안의 변화를 **한 덩어리로** 한 번만 쌓는다. */ + resume(): void; undo(): boolean; redo(): boolean; canUndo(): boolean; @@ -92,6 +96,8 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile let cursor = 0; /** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */ let restoring = false; + /** 연속 조작 중에는 record()를 흘려보내고 resume()에서 한 번만 쌓는다. */ + let paused = false; function apply(index: number): boolean { if (index < 0 || index >= stack.length) return false; @@ -108,7 +114,7 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile return { record() { - if (restoring) return; + if (restoring || paused) return; const snapshot = takeSnapshot(); if (sameSnapshot(snapshot, stack[cursor])) return; stack.splice(cursor + 1); @@ -116,6 +122,14 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile if (stack.length > limit) stack.shift(); cursor = stack.length - 1; }, + pause() { + paused = true; + }, + resume() { + if (!paused) return; + paused = false; + this.record(); + }, undo: () => apply(cursor - 1), redo: () => apply(cursor + 1), canUndo: () => cursor > 0, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index f886aa09..8bdb8b2a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -15,6 +15,7 @@ import type { ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { adjustStation } from "./B05_Profile_UI_Profile_Alignment"; +import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Profile_History"; import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools"; import { @@ -99,8 +100,14 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { ctx.restore(); restorePipes(); }); - /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. */ - const history: ProfileHistory = { ...inner, record: () => (syncPipes(), inner.record()) }; + /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. + * 연속 조작을 멈출 때(pause)도 같이 흘린다 — 그래야 "처음 본 관 목록"이 조작 **전** + * 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측: 한 칸 덜 돌아왔음). */ + const history: ProfileHistory = { + ...inner, + record: () => (syncPipes(), inner.record()), + pause: () => (syncPipes(), inner.pause()), + }; const tools = createProfileTools({ onStraighten: (fromM, toM) => { @@ -173,14 +180,110 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { /* 방향키 — 상·하는 계획고(라운드 포함 기존 틸팅 경로), 좌·우는 누가거리. * 좌우는 구조물·비정규 측점만 움직인다. 20m 정규 측점은 격자라 옮기면 수량·도면 - * 측점번호가 어긋난다(2026-09-02 사용자 확정). */ - ctx.root.addEventListener("keydown", (event) => { + * 측점번호가 어긋난다(2026-09-02 사용자 확정). + * 누르고 있으면 편집 버튼과 **같은 속도**로 이어진다(0.5초 뒤 초당 10회) — OS 키 반복에 + * 맡기면 기기마다 속도가 갈린다. 그 동안의 변화는 이력 한 덩어리다(2026-09-04 사용자 지시). */ + type ArrowKey = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"; + + /** 방향키 한 번의 조작 — 반복 타이머가 같은 함수를 다시 부른다. 대상이 없으면 false. */ + function nudge(key: ArrowKey): boolean { + const base = ctx.base(); + if (!base) return false; + if (key === "ArrowUp" || key === "ArrowDown") { + const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M; + // [쉬프트]가 켜져 있으면 고른 직선 구간의 평행이동 — 도구 ▲▼와 같은 경로다. + if (tools.mode() === "shift") return tools.nudge(delta); + const chainage = selectedChainage(); + if (chainage === null) return false; + ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); + return true; + } + // 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져 + // (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측). + const station = holdStation ?? selectedIrregular(); + if (!station) return false; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다. + // 목록(정본)은 서버 재계산 뒤에야 새 누가거리를 들고 온다 — 연속 이동 중에는 그것을 + // 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해 + // 0.1m 만 움직이고 멈춤(2026-09-04 실측). + const fromM = holdChainageM ?? station.chainage_m; + const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3)); + if (next < 0) return false; + ctx.moveStation({ ...station, chainage_m: fromM }, next); + holdStation = station; + holdChainageM = next; + history.record(); + return true; + } + + let heldKey: ArrowKey | null = null; + /** 좌우 연속 이동 중의 현재 누가거리 — 정본 목록이 따라오기 전까지 여기서 센다. */ + let holdChainageM: number | null = null; + /** 옮기는 중인 구조물 측점 — 선택이 풀려도 키를 뗄 때까지 이 측점을 움직인다. */ + let holdStation: IrregularStation | null = null; + let holdDelayTimer = 0; + let holdRepeatTimer = 0; + + /** 구조물 이동은 서버 재계산을 거쳐 돌아오고 그 뒷정리(유령 변화점 삭제)도 이력을 + * 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */ + const HOLD_SETTLE_MS = 700; + let settleTimer = 0; + + /** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */ + function stopHold(): void { + window.clearTimeout(holdDelayTimer); + window.clearInterval(holdRepeatTimer); + holdDelayTimer = 0; + holdRepeatTimer = 0; + if (heldKey === null) return; + heldKey = null; + holdChainageM = null; + holdStation = null; + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", stopHold); + window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(() => { + settleTimer = 0; + history.resume(); + }, HOLD_SETTLE_MS); + } + + /** 기다리지 않고 지금 바로 한 덩어리를 닫는다(다음 조작·되돌리기 직전). */ + function flushHold(): void { + stopHold(); + if (!settleTimer) return; + window.clearTimeout(settleTimer); + settleTimer = 0; + history.resume(); + } + + function onKeyUp(event: KeyboardEvent): void { + if (event.key === heldKey) stopHold(); + } + + /** 첫 한 번은 부른 쪽에서 이미 움직였다 — 여기서는 이어지는 반복만 건다. */ + function startHold(key: ArrowKey): void { + heldKey = key; + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", stopHold); + holdDelayTimer = window.setTimeout(() => { + holdRepeatTimer = window.setInterval(() => { + if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다. + }, HOLD_INTERVAL_MS); + }, HOLD_DELAY_MS); + } + + /* 듣는 자리는 **창(window)** 이다 — 패널에 걸어 두면 그래프를 눌러도 포커스가 body에 + * 남아(측점을 고르면 그래프가 다시 그려져 포커스가 풀림) 방향키가 아무 반응이 없었다 + * (2026-09-04 실측). 패널이 화면에서 빠지면(다른 페이지) 조용히 무시한다. */ + window.addEventListener("keydown", (event) => { + if (!ctx.root.isConnected) return; const target = event.target as HTMLElement | null; // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. if (target && target.closest("input, select, textarea")) return; const key = event.key; if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) { event.preventDefault(); + flushHold(); if (event.shiftKey) history.redo(); else history.undo(); return; @@ -188,24 +291,20 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") { return; } - const chainage = selectedChainage(); - const base = ctx.base(); - if (chainage === null || !base) return; - if (key === "ArrowUp" || key === "ArrowDown") { + // OS 키 반복은 무시한다 — 속도는 위 타이머가 정한다. + if (event.repeat) { event.preventDefault(); - const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M; - ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); return; } - const station = selectedIrregular(); - if (!station) return; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다. + // 기록을 먼저 멈춘다 — 첫 한 번까지 같은 덩어리에 들어가야 되돌리기 1회로 원위치한다. + flushHold(); + history.pause(); + if (!nudge(key)) { + history.resume(); // 움직인 것이 없으므로 이력에 남지 않는다. + return; + } event.preventDefault(); - const next = Number( - (station.chainage_m + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3), - ); - if (next < 0) return; - ctx.moveStation(station, next); - history.record(); + startHold(key); }); return { tools, history, handleToolPick }; diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index 47e1e13f..0487a666 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -50,6 +50,8 @@ export interface ProfileTools { selectedRuns: () => StraightRun[]; /** 직선화 대기 중 첫 측점(강조 표시용). */ pendingStation: () => number | null; + /** 고른 직선을 ▲▼ 버튼과 같은 경로로 옮긴다(방향키가 같이 쓴다) — 대상이 없으면 false. */ + nudge: (delta: number) => boolean; /** 모드·선택을 모두 끈다. */ clear: () => void; } @@ -89,16 +91,19 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo callbacks.onChanged(); } - function step(delta: number): void { + function step(delta: number): boolean { if (mode === "shift" && runs.length) { callbacks.onShift(runs, delta); - return; + return true; } - if (runs.length === 1) callbacks.onTilt(runs[0], delta); + if (runs.length !== 1) return false; + callbacks.onTilt(runs[0], delta); + return true; } return { mode: () => mode, + nudge: step, selectedRuns: () => runs, pendingStation: () => pending, clear() { @@ -170,8 +175,8 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo ); shift.classList.toggle("is-active", mode === "shift"); - const up = toolButton("▲", "고른 직선을 0.1m 올림", () => step(0.1)); - const down = toolButton("▼", "고른 직선을 0.1m 내림", () => step(-0.1)); + const up = toolButton("▲", "고른 직선을 0.1m 올림", () => void step(0.1)); + const down = toolButton("▼", "고른 직선을 0.1m 내림", () => void step(-0.1)); const idle = mode === "none" || (!runs.length && mode === "shift"); up.disabled = idle || (mode === "straighten" && runs.length !== 1); down.disabled = up.disabled; From 2d49c593427a458decdc8ecd3d168fcc9bebb058 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 07:23:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B05):=20=EC=A2=85=EB=8B=A8=EB=8F=84=20?= =?UTF-8?q?=EC=A4=8C=C2=B7Y=20=ED=91=9C=EC=8B=9C=20=ED=91=9C=EA=B3=A0?= =?UTF-8?q?=EC=B0=BD=20=EC=A1=B0=EC=9E=91=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile_UI_Profile_Zoom.ts 신규 — 배율 상태(가로 폭 배수·세로 배율·창 중심)와 버튼 7개(+ − ⇕+ ⇕− ▲ ▼ ⤢). 자리는 요약줄 오른쪽 끝(그래프 우측 상단), 설명은 툴팁, 클릭은 stopPropagation 으로 측점 선택에 번지지 않게 함 - X 줌은 폭 배수 — computeProfileLayout 에 zoomX 를 넘겨 측점 간격·폭맞춤 폭에 함께 곱함. 그래프·테이블·편집 버튼층·구조물 레인이 같은 매핑을 쓰므로 넷의 x 정렬이 유지됨 - Y 는 표시 표고창(제안 A) — createLongitudinalProfile 에 elevationOffsetRatio 인자 추가, 창 높이 = 전범위 ÷ 배율, ▲▼ 가 창 높이의 10%씩 중심 이동. 세로 스크롤 없음 - 지반선·계획선·절성토 음영을 플롯 사각형 clipPath 안으로 옮김 — 배율을 올려도 선이 축·측점 라벨 위로 흘러나오지 않음 (B06 은 배율 1·오프셋 0 이라 표시 불변) - 요약줄 렌더러에 trailing 자리 추가, .b05-profile__zoom* CSS 추가 Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Balance.ts | 5 + B05_Profile/B05_Profile_UI_Profile_Layout.ts | 8 +- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 6 ++ B05_Profile/B05_Profile_UI_Profile_Render.ts | 17 +++- B05_Profile/B05_Profile_UI_Profile_Zoom.ts | 93 +++++++++++++++++++ B05_Profile/B05_Profile_UI_Style.css | 36 +++++++ B06_Section/B06_Section_UI_Longitudinal.ts | 47 ++++++++-- 7 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_Zoom.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index f6166abe..3d5732bf 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -34,6 +34,8 @@ export interface BalanceBarParams { tools?: HTMLElement; /** 횡단배수 최소 계획고 위반(2026-08-23) — 배수관·BOX암거 토피 미확보 경고. */ minCoverViolations?: MinCoverViolation[]; + /** 요약줄 **오른쪽 끝**에 붙이는 묶음(줌·Y레인지 조작구) — 2026-09-04. */ + trailing?: HTMLElement; /** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */ onResetAll: () => void; } @@ -50,6 +52,7 @@ export function renderBalanceBar(params: BalanceBarParams): void { "⚠ 계획선 데이터가 구버전 형식입니다 — [최적 경로 계산]을 다시 실행하세요."; params.balanceBar.append(note); } + if (params.trailing) params.balanceBar.append(params.trailing); return; } const { alignment } = params; @@ -106,4 +109,6 @@ export function renderBalanceBar(params: BalanceBarParams): void { badge.textContent = "미저장 (확정 시 반영)"; params.balanceBar.append(badge); } + // 줌 조작구는 늘 오른쪽 끝 — 그래프 우측 상단 자리다(2026-09-04 사용자 지시). + if (params.trailing) params.balanceBar.append(params.trailing); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Layout.ts b/B05_Profile/B05_Profile_UI_Profile_Layout.ts index 8a2a50b0..987aa863 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Layout.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Layout.ts @@ -113,14 +113,18 @@ export function computeProfileLayout( data: LongitudinalSection, stationIntervalM: number, availableWidth: number, + /** 가로 줌 배수(1 = 현행). 측점 간격 기본값과 폭맞춤 폭에 함께 곱해 넷(그래프·테이블· + * 편집 버튼층·구조물 레인)이 같은 비율로 늘어나게 한다(2026-09-04 사용자 지시). */ + zoomX = 1, ): ProfileLayout { const maxChainage = maxChainageOf(data); const interval = Math.max(stationIntervalM, 1e-6); const framePad = LONG_PAD.left + LONG_PAD.right; - const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER; + const zoom = Math.max(zoomX, 1e-6); + const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER * zoom; // 기본 배수로 펼친 최소 폭 (좌우 반 칸 = 한 칸 여백 포함). const minWidth = framePad + ((maxChainage + interval) / interval) * minSpacing; - const width = Math.max(minWidth, availableWidth); + const width = Math.max(minWidth, availableWidth * zoom); const pxPerMeter = (width - framePad) / (maxChainage + interval); const spacing = interval * pxPerMeter; return { width, originOffset: spacing / 2, cellWidth: Math.max(1, spacing - CELL_GAP_PX) }; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 37e81e3d..08880c4b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -56,6 +56,7 @@ import { renderProfile } from "./B05_Profile_UI_Profile_Render"; import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { createCrossPreview } from "./B05_Profile_UI_Profile_Preview"; import { createPanelTools } from "./B05_Profile_UI_Profile_Panel_Tools"; +import { createProfileZoom } from "./B05_Profile_UI_Profile_Zoom"; import { stationIdAtStructure as stationIdAtStructureOf, structureIdAtStation as structureIdAtStationOf, @@ -359,6 +360,7 @@ export function createRouteProfilePanel( minCoverViolations, balanceBar, tools: tools.render(), + trailing: profileZoom.bar, alignment, legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), edited: store.edited(), @@ -371,6 +373,9 @@ export function createRouteProfilePanel( }); } + /* 줌·Y레인지 조작구 — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다. */ + const profileZoom = createProfileZoom(() => draw()); + /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */ const { tools, history, handleToolPick } = createPanelTools({ root, @@ -506,6 +511,7 @@ export function createRouteProfilePanel( selectStation, applyEdits, handleToolPick, + zoom: profileZoom.state, toolActive: () => tools.mode() !== "none", selectedRuns: () => tools.selectedRuns(), stationIdAtStructure, diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 10e6018d..383a4cfe 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -27,6 +27,7 @@ import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight"; import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; +import type { ProfileZoomState } from "./B05_Profile_UI_Profile_Zoom"; import { createProfileTable } from "./B05_Profile_UI_Profile_Table"; import { CELL_GAP_PX, @@ -83,6 +84,8 @@ export interface ProfileRenderContext { applyEdits: (next: AlignmentEdits) => void; /** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */ handleToolPick: (chainageM: number | null) => boolean; + /** 가로 폭 배수·세로 표시 표고창(줌 조작구 상태) — 2026-09-04. */ + zoom: () => ProfileZoomState; /** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */ toolActive: () => boolean; /** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */ @@ -114,15 +117,20 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.renderBalance(); const longitudinal = detail.longitudinal; + const zoom = ctx.zoom(); + // 가로 줌은 **폭 배수**다 — 캔버스가 넓어지고 가로 스크롤로 훑는다. 그래프·테이블· + // 편집 버튼층·구조물 레인이 같은 매핑을 쓰므로 폭 하나만 키우면 넷이 함께 늘어난다. const availableWidth = Math.max(1, body.clientWidth - 15); // 계획선(편집 가능) 상태에서는 측점 간격 기본값(기준×1.5)으로 펼치되 화면이 넓으면 // 폭맞춤으로 늘린다. 그 외(구버전·플레인 뷰)는 예전처럼 화면 폭에 맞춰 펼친다. const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m; const layout = alignment && stationIntervalM - ? computeProfileLayout(longitudinal, stationIntervalM, availableWidth) + ? computeProfileLayout(longitudinal, stationIntervalM, availableWidth, zoom.x) : { - width: Math.max(availableWidth, longitudinalMinimumWidth(longitudinal, stationInterval)), + width: + Math.max(availableWidth, longitudinalMinimumWidth(longitudinal, stationInterval)) * + zoom.x, originOffset: 0, cellWidth: STATION_SPACING_PX - CELL_GAP_PX, }; @@ -219,7 +227,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { createLongitudinalProfile( graphLongitudinal, selectedStationId, - 1, + // 세로 배율 = 표시 표고창 높이의 역수(1 = 표고 전범위). + zoom.y, undefined, ctx.selectStation, stationInterval, @@ -243,6 +252,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다 // (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지. 15, + // 표시 표고창의 중심 이동(창 높이 대비 비율) — ▲▼ 버튼이 옮긴다. + zoom.offsetRatio, ), ); // 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Zoom.ts b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts new file mode 100644 index 00000000..b6d13d86 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Zoom.ts + * 종단면도 줌·Y레인지 조작구 (2026-09-04 사용자 지시). + * + * 공사 범위가 넓고 고저차가 크면 종단 그래프가 눌려 읽히지 않는다. 조작은 두 갈래다. + * + * X (가로) — **폭 배수**다. SVG transform 으로 늘리면 그래프만 커지고 측점 테이블· + * 계획고 편집 버튼층·구조물 알약 레인이 어긋난다(넷이 같은 `chainageMapper` + * 를 쓴다). 캔버스 폭 자체를 키우고 가로 스크롤로 훑는다. + * Y (세로) — **표시 표고창**이다(제안 A, 2026-09-04 사용자 확정). 창 높이 = 전범위 ÷ 배율, + * 창 중심은 창 높이 대비 비율로 위·아래로 옮긴다. 세로 스크롤이 생기지 않아 + * X축·측점 라벨·편집 버튼이 항상 바닥에 남는다. + * + * 배율은 페이지가 들고 있다 — 편집·재계산으로 다시 그려도 유지된다(B06 `cardZoomStates` 규칙). + * 버튼 양식은 횡단도 줌 버튼세트(`b06-cross-card__zoom-btn`)와 같고, 설명은 툴팁이다. + * ========================================================================== */ + +/** 한 번 누를 때 배율 배수. 횡단도 줌(1/0.85)보다 성글게 — 폭 배수라 한 칸이 크게 느껴진다. */ +const ZOOM_STEP = 1.25; +/** 가로 폭 배수 상한 — 이 이상은 캔버스가 수만 px이 되어 브라우저가 버겁다. */ +const MAX_X = 8; +/** 세로 배율 상한. 전범위의 1/20 까지 좁혀 본다. */ +const MAX_Y = 20; +/** 창 중심 이동 한 번의 몫 — 창 높이의 10%. */ +const OFFSET_STEP = 0.1; +/** 창 중심 이동 한계 — 전범위 밖으로 완전히 벗어나지 않게 창 높이의 ±2배까지. */ +const MAX_OFFSET = 2; + +export interface ProfileZoomState { + /** 가로 폭 배수(1 = 현행 폭맞춤). */ + x: number; + /** 세로 배율(1 = 표고 전범위). */ + y: number; + /** 창 중심 이동 — 창 높이 대비 비율(+ 가 위쪽). */ + offsetRatio: number; +} + +export interface ProfileZoom { + /** 절·성토 요약줄 오른쪽 끝에 붙는 버튼 묶음(한 번 만들어 계속 쓴다). */ + bar: HTMLElement; + state: () => ProfileZoomState; +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +export function createProfileZoom(onChange: () => void): ProfileZoom { + const state: ProfileZoomState = { x: 1, y: 1, offsetRatio: 0 }; + + const bar = document.createElement("div"); + bar.className = "b05-profile__zoom"; + + function add(label: string, title: string, action: () => void): void { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-profile__zoom-btn"; + button.textContent = label; + button.title = title; + button.addEventListener("click", (event) => { + // 카드·측점 선택으로 번지면 그래프를 다시 그리며 방금 맞춘 배율이 날아간다. + event.stopPropagation(); + action(); + onChange(); + }); + bar.append(button); + } + + add("+", "가로 확대 — 측점 간격을 넓혀 폅니다 (가로 스크롤로 훑음)", () => { + state.x = clamp(state.x * ZOOM_STEP, 1, MAX_X); + }); + add("−", "가로 축소", () => { + state.x = clamp(state.x / ZOOM_STEP, 1, MAX_X); + }); + add("⇕+", "세로 확대 — 표시 표고 폭을 좁혀 고저차를 크게 봅니다", () => { + state.y = clamp(state.y * ZOOM_STEP, 1, MAX_Y); + }); + add("⇕−", "세로 축소", () => { + state.y = clamp(state.y / ZOOM_STEP, 1, MAX_Y); + }); + add("▲", "표시 표고창을 위로 (창 높이의 10%)", () => { + state.offsetRatio = clamp(state.offsetRatio + OFFSET_STEP, -MAX_OFFSET, MAX_OFFSET); + }); + add("▼", "표시 표고창을 아래로 (창 높이의 10%)", () => { + state.offsetRatio = clamp(state.offsetRatio - OFFSET_STEP, -MAX_OFFSET, MAX_OFFSET); + }); + add("⤢", "가로·세로 배율과 표시 표고창을 처음 상태로", () => { + state.x = 1; + state.y = 1; + state.offsetRatio = 0; + }); + + return { bar, state: () => ({ ...state }) }; +} diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index af9ef682..dc67478c 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -1341,3 +1341,39 @@ background: color-mix(in srgb, var(--color-primary) 14%, transparent); outline: 1px solid var(--color-primary); } + +/* ── 종단도 줌·Y레인지 조작구 (2026-09-04) ───────────────────────────── + * 요약줄 오른쪽 끝(= 그래프 우측 상단)에 붙는다. 양식은 횡단도 줌 버튼세트 + * (.b06-cross-card__zoom-btn)와 같게 맞췄다 — 그쪽 CSS는 B06 페이지 전용이라 + * 여기서 같은 값으로 다시 적는다. */ +.b05-profile__zoom { + display: flex; + flex: 0 0 auto; + margin-left: auto; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); +} + +.b05-profile__zoom-btn { + width: 22px; + height: 22px; + padding: 0; + border: none; + border-left: 1px solid var(--color-border); + background: none; + color: var(--color-text-secondary); + font-size: 0.8rem; + line-height: 1; + cursor: pointer; +} + +.b05-profile__zoom-btn:first-child { + border-left: none; +} + +.b05-profile__zoom-btn:hover { + color: var(--color-text); + background: var(--color-surface); +} diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 0db4f492..6facaf19 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -50,7 +50,7 @@ export function longitudinalMinimumWidth( * 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다. */ function appendCutFillBands( - svg: SVGSVGElement, + svg: SVGElement, profile: DesignProfile, x: (chainage: number) => number, planY: (index: number) => number, @@ -168,6 +168,12 @@ export function createLongitudinalProfile( * LONG_PAD.bottom을 직접 키우면 B06 종단면도 여백까지 변하므로 호출부 옵션으로 뒀다. */ bottomInsetPx = 0, + /** + * 세로 표시창의 중심을 위·아래로 옮기는 몫 — **창 높이 대비 비율**(0 = 현행 가운데, + * +0.1 = 창 높이의 10%만큼 위쪽을 본다). B05 종단도의 Y 레인지 조작구가 쓴다. + * 표고(m)가 아니라 비율이라 호출부가 노선 표고 범위를 몰라도 된다(2026-09-04). + */ + elevationOffsetRatio = 0, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -207,11 +213,13 @@ export function createLongitudinalProfile( : // 자동 스케일(B05)은 최고·최저 표고가 위아래 축선에 딱 붙지 않게 10%씩 여유를 둔다 // (2026-08-23 사용자 지시). 공통 Y스케일(B06 yScaleOptions)은 그대로 둔다. Math.max(rawMax - rawMin, 1) * 1.2; + // 화면에 담기는 표고 폭 = 전범위 ÷ 배율. 창 중심은 그 폭의 비율만큼 위·아래로 옮긴다. + const viewCenter = elevationMid + elevationOffsetRatio * (elevationSpan / exaggeration); const x = (chainage: number) => LONG_PAD.left + originOffsetPx + (chainage / maxChainage) * plotWidth; const xInverse = inverseOf(x, maxChainage); const y = (elevation: number) => - LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; + LONG_PAD.top + ((viewCenter + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); // Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의 @@ -219,13 +227,13 @@ export function createLongitudinalProfile( // 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고). const yAxisTicks: Array<{ y: number; label: string }> = []; const rawSpan = elevationSpan / exaggeration; - const rawTop = elevationMid + rawSpan / 2; + const rawTop = viewCenter + rawSpan / 2; // 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다. // 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시). const tickStep = Math.max(niceTickStep(rawSpan, 10, Math.floor(plotHeight / 16)), 1); const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9)); for ( - let value = Math.ceil((elevationMid - rawSpan / 2) / tickStep) * tickStep; + let value = Math.ceil((viewCenter - rawSpan / 2) / tickStep) * tickStep; value <= rawTop + 1e-9; value += tickStep ) { @@ -251,12 +259,29 @@ export function createLongitudinalProfile( // sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음). onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks }); + // 표시 표고창(세로 배율·중심 이동) 밖으로 나간 지반선·계획선은 그리지 않는다 — + // 안 자르면 축 라벨·측점 라벨 띠 위로 선이 흘러나온다(2026-09-04). + const clipId = `b06-long-clip-${Math.random().toString(36).slice(2, 9)}`; + const clipPath = svgElement("clipPath", { id: clipId }); + clipPath.append( + svgElement("rect", { + x: LONG_PAD.left, + y: LONG_PAD.top, + width: Math.max(0, widthPx - LONG_PAD.left - LONG_PAD.right), + height: Math.max(0, plotHeight), + }), + ); + const defs = svgElement("defs", {}); + defs.append(clipPath); + const bandLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); + svg.append(defs, bandLayer); + // 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다. - const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration); + const toY = (elevation: number) => y(viewCenter + (elevation - viewCenter) * exaggeration); for (const profile of designProfiles) { if (profile.samples.length < 2) continue; appendCutFillBands( - svg, + bandLayer, profile, x, (index) => toY(profile.samples[index].elevation_m), @@ -264,7 +289,7 @@ export function createLongitudinalProfile( ); if (profile.balance_segments.length > 1) { for (const segment of profile.balance_segments.slice(1)) { - svg.append( + bandLayer.append( svgElement("line", { x1: x(segment.start_chainage_m), y1: LONG_PAD.top, @@ -342,13 +367,14 @@ export function createLongitudinalProfile( const points = samples .map((sample) => { - const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; + const elevated = viewCenter + (sample.elevation_m - viewCenter) * exaggeration; return `${x(sample.chainage_m ?? 0)},${y(elevated)}`; }) .join(" "); + const lineLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); for (const profile of designProfiles) { if (profile.samples.length < 2) continue; - svg.append( + lineLayer.append( svgElement("polyline", { points: profile.samples .map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`) @@ -357,8 +383,9 @@ export function createLongitudinalProfile( }), ); } + lineLayer.append(svgElement("polyline", { points, class: "b06-chart__profile" })); svg.append( - svgElement("polyline", { points, class: "b06-chart__profile" }), + lineLayer, svgElement("line", { x1: LONG_PAD.left, y1: heightPx - padBottom,