From 66717162c5e436fd5068e732aafd5f3a77a5cc8f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 20:42:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EB=8F=84=EA=B5=AC=EC=A4=84=20[?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94]=20=EC=B6=94=EA=B0=80=20+=20?= =?UTF-8?q?=EC=89=AC=ED=94=84=ED=8A=B8=20=EC=84=A0=ED=83=9D=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=20=EB=B9=A8=EA=B0=84=20=EA=B0=95=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. [초기화] — [↶] 왼쪽에 둠. 전체 초기화가 아니라 **마지막 [저장] 시점**이 기준점임 (사용자 지시). 편집 스토어가 저장 시점 편집분(`savedBaseline`)을 들고 있다가 그리로 되돌림 — 저장한 작업은 남고 그 뒤 편집만 버림. 이력에 기록해 [↶]로 되살릴 수 있음. 되돌릴 것이 없으면 버튼은 비활성. 자동 선형까지 지우는 `resetAll`(좌측 [초기화])과는 다른 조작임. 2. [쉬프트]로 고른 직선이 화면에 표시되지 않아 무엇이 잡혔는지 알 수 없었음. 고른 구간을 빨갛게 덧그림 — 범위는 **직선 + 양 끝 라운드(R)** 임. 쉬프트는 직선을 통째로 올리고 내리므로 양 끝 호의 모양까지 함께 바뀜. 구간 시작 라운드의 BVC부터 끝 라운드의 EVC 까지 계획선 샘플을 그대로 덧그려 곡률이 따라옴. 경계에는 점선 세로 표식. 공용 종단 렌더러(`B06_Section_UI_Longitudinal`)는 건드리지 않고 별도 오버레이로 얹음 (`B05_Profile_UI_Profile_RunHighlight.ts`). Y 매핑은 그 렌더러가 넘겨 주는 축 눈금에서 되짚어 같은 스케일을 씀. 편집 버튼층(z-index 4) 아래(3)라 버튼을 가리지 않음. `tsc --noEmit` 통과, 전체 테스트 183건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 21 +++- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 3 + .../B05_Profile_UI_Profile_Panel_Tools.ts | 10 ++ B05_Profile/B05_Profile_UI_Profile_Render.ts | 16 +++ .../B05_Profile_UI_Profile_RunHighlight.ts | 111 ++++++++++++++++++ B05_Profile/B05_Profile_UI_Profile_Tools.ts | 16 ++- B05_Profile/B05_Profile_UI_Style.css | 26 ++++ 7 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 2679735b..dd5325b8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -63,8 +63,13 @@ function createHoldRepeater(): { start: (action: () => void) => void; stop: () = export interface ProfileEditStore { edits(): AlignmentEdits; replace(next: AlignmentEdits): void; - /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. */ + /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. + * 이때의 값이 [초기화]의 기준점이 된다. */ markSaved(): void; + /** 마지막 [저장] 시점으로 되돌린다 — 그 뒤의 편집만 버린다(2026-09-03 사용자 지시). */ + restoreSaved(): void; + /** 마지막 저장 시점과 지금이 다른가([초기화] 활성 조건). */ + canRestoreSaved(): boolean; resetStation(chainageM: number): void; resetAll(): void; /** 아직 서버에 반영되지 않은 변경이 있는가. */ @@ -114,6 +119,12 @@ export function createProfileEditStore( const draft = readDraft(storageKey); let current = draft ?? saved; let unsaved = draft !== null; + /** + * [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시). + * 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동 + * 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다. + */ + let savedBaseline = saved; function dropDraft(): void { try { @@ -139,8 +150,16 @@ export function createProfileEditStore( replace: commit, markSaved() { unsaved = false; + savedBaseline = current; dropDraft(); }, + restoreSaved() { + current = savedBaseline; + unsaved = false; + dropDraft(); + onChange(); + }, + canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline), resetStation(chainageM) { const key = chainageKey(chainageM); const stationOffsets = { ...current.station_offsets }; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 5e25ee90..37e81e3d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -389,6 +389,8 @@ export function createRouteProfilePanel( store = createProfileEditStore(routeId, savedEdits, () => rebuild()); rebuild(); }, + restoreSaved: () => store.restoreSaved(), + canRestoreSaved: () => store.canRestoreSaved(), refresh: () => renderBalance(), }); @@ -505,6 +507,7 @@ export function createRouteProfilePanel( applyEdits, handleToolPick, toolActive: () => tools.mode() !== "none", + selectedRuns: () => tools.selectedRuns(), stationIdAtStructure, redraw: draw, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 06ce78de..f886aa09 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -54,6 +54,10 @@ export interface PanelToolsContext { }; /** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */ restore: () => void; + /** 마지막 [저장] 시점으로 편집을 되돌린다(스토어 소관). */ + restoreSaved: () => void; + /** 되돌릴 것이 남아 있는가 — [초기화] 버튼 활성 조건. */ + canRestoreSaved: () => boolean; /** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */ refresh: () => void; } @@ -115,6 +119,12 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (!base) return; ctx.applyEdits(tiltStraightRun(base, ctx.edits(), run, delta)); }, + onResetToSaved: () => { + // 이력에 남겨 [↶]로 되살릴 수 있게 한다 — 잘못 눌러도 잃는 것이 없다. + ctx.restoreSaved(); + history.record(); + }, + canResetToSaved: ctx.canRestoreSaved, onUndo: () => inner.undo(), onRedo: () => inner.redo(), canUndo: () => inner.canUndo(), diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 13a0992d..10e6018d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -23,6 +23,8 @@ import { type ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; +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 { createProfileTable } from "./B05_Profile_UI_Profile_Table"; @@ -83,6 +85,8 @@ export interface ProfileRenderContext { handleToolPick: (chainageM: number | null) => boolean; /** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */ toolActive: () => boolean; + /** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */ + selectedRuns: () => StraightRun[]; stationIdAtStructure: (structureId: string | null) => string | null; /** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */ redraw: () => void; @@ -286,6 +290,18 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트 // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight)); + // 쉬프트로 고른 구간 강조 — 편집 버튼층보다 아래에 깔아 버튼을 가리지 않는다. + if (alignment) { + const highlight = createRunHighlight({ + alignment, + runs: ctx.selectedRuns(), + x, + axis: yAxis, + widthPx: width, + heightPx: chartHeight, + }); + if (highlight) chartWrap.append(highlight); + } if (alignment) { chartWrap.append( createEditOverlay({ diff --git a/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts b/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts new file mode 100644 index 00000000..c8db8b66 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_RunHighlight.ts + * [쉬프트]로 고른 직선 구간 강조 오버레이 (2026-09-03 사용자 지시). + * + * 고른 직선을 눌러도 화면에 아무 표시가 없어 무엇이 잡혔는지 알 수 없었다. 강조 범위는 + * **직선 + 양 끝 라운드(R)** 다 — 쉬프트는 직선을 통째로 올리고 내리므로 양 끝 호의 + * 모양까지 함께 바뀌기 때문이다. 그래서 구간 시작 라운드의 BVC부터 끝 라운드의 EVC까지 + * 계획선 위를 그대로 덧그린다. + * + * 종단 그래프 SVG(`B06_Section_UI_Longitudinal`)는 B05·B06 공용이라 건드리지 않고, + * 같은 좌표계 위에 별도 오버레이를 얹는다. Y 매핑은 그 렌더러가 넘겨 준 축 눈금 + * (`onYAxis`)에서 되짚는다 — 두 눈금의 (표고, y) 두 쌍이면 1차식이 정해진다. + * ========================================================================== */ + +import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; +import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 두 chainage를 같은 변화점으로 볼 허용 오차(m). */ +const SAME_NODE_M = 1e-6; + +export interface RunHighlightOptions { + alignment: ProfileAlignment; + /** 강조할 구간들(쉬프트 선택분). 비어 있으면 오버레이를 만들지 않는다. */ + runs: StraightRun[]; + /** 누가거리 → 화면 x(px). 종단 그래프와 같은 매핑이어야 선이 겹친다. */ + x: (chainageM: number) => number; + /** 종단 렌더러가 넘겨 준 Y축 눈금 — 표고 → y(px) 를 되짚는 근거. */ + axis: { ticks: Array<{ y: number; label: string }> } | null; + widthPx: number; + heightPx: number; +} + +/** 눈금 라벨(`880m`)에서 표고를 읽는다. 숫자가 아니면 null. */ +function tickElevation(label: string): number | null { + const value = Number.parseFloat(label); + return Number.isFinite(value) ? value : null; +} + +/** + * 축 눈금 두 개로 표고 → y(px) 1차식을 만든다. 눈금이 모자라거나 겹치면 null. + * (렌더러와 같은 스케일을 쓰려는 것이므로 별도로 계산하지 않는다.) + */ +function elevationToY( + axis: { ticks: Array<{ y: number; label: string }> } | null, +): ((elevationM: number) => number) | null { + const points = (axis?.ticks ?? []) + .map((tick) => ({ y: tick.y, elevation: tickElevation(tick.label) })) + .filter((entry): entry is { y: number; elevation: number } => entry.elevation !== null); + if (points.length < 2) return null; + const first = points[0]; + const last = points[points.length - 1]; + const span = last.elevation - first.elevation; + if (Math.abs(span) < 1e-9) return null; + const scale = (last.y - first.y) / span; + return (elevationM: number): number => first.y + (elevationM - first.elevation) * scale; +} + +/** 구간 양 끝 라운드까지 넓힌 강조 범위 [시작, 끝] (누가거리 m). */ +function runSpanWithCurves(alignment: ProfileAlignment, run: StraightRun): [number, number] { + const startCurve = alignment.curves.find( + (curve) => Math.abs(curve.chainage_m - run.fromM) <= SAME_NODE_M && !curve.omitted, + ); + const endCurve = alignment.curves.find( + (curve) => Math.abs(curve.chainage_m - run.toM) <= SAME_NODE_M && !curve.omitted, + ); + return [startCurve ? startCurve.bvc_m : run.fromM, endCurve ? endCurve.evc_m : run.toM]; +} + +/** + * 고른 구간을 계획선 위에 빨갛게 덧그린 오버레이. 고른 것이 없으면 null. + * 반환한 요소는 종단 차트 래퍼(`b05-profile__chart`) 안에 그대로 붙이면 된다. + */ +export function createRunHighlight(options: RunHighlightOptions): SVGElement | null { + const { alignment, runs, x, widthPx, heightPx } = options; + if (!runs.length) return null; + const toY = elevationToY(options.axis); + if (!toY) return null; + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "b05-profile-runmark"); + svg.setAttribute("width", String(widthPx)); + svg.setAttribute("height", String(heightPx)); + svg.setAttribute("viewBox", `0 0 ${widthPx} ${heightPx}`); + + const samples = alignment.samples; + for (const run of runs) { + const [fromM, toM] = runSpanWithCurves(alignment, run); + // 계획선 샘플에는 변화점·BVC·EVC가 모두 들어 있어 라운드 곡률까지 그대로 따라온다. + const points = samples + .filter((sample) => sample.chainage_m >= fromM - SAME_NODE_M) + .filter((sample) => sample.chainage_m <= toM + SAME_NODE_M) + .map((sample) => `${x(sample.chainage_m).toFixed(2)},${toY(sample.elevation_m).toFixed(2)}`); + if (points.length < 2) continue; + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute("class", "b05-profile-runmark__line"); + line.setAttribute("points", points.join(" ")); + svg.append(line); + // 양 끝 표시 — 어디까지가 이 구간인지(라운드 포함) 한눈에 보이게 세로 표식을 둔다. + for (const edge of [fromM, toM]) { + const tick = document.createElementNS(SVG_NS, "line"); + tick.setAttribute("class", "b05-profile-runmark__edge"); + tick.setAttribute("x1", x(edge).toFixed(2)); + tick.setAttribute("x2", x(edge).toFixed(2)); + tick.setAttribute("y1", "0"); + tick.setAttribute("y2", String(heightPx)); + svg.append(tick); + } + } + return svg.childElementCount ? svg : null; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index 758e2e55..47e1e13f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B05_Profile_UI_Profile_Tools.ts - * 종단 계획선 도구줄 — [↶][↷] · [직선화] · [쉬프트] · [▲][▼] (2026-09-02 사용자 지시). + * 종단 계획선 도구줄 — [초기화] · [↶][↷] · [직선화] · [쉬프트] · [▲][▼]. + * ([초기화]는 2026-09-03 추가 — 마지막 [저장] 시점 기준이며 전체 초기화가 아니다.) * * 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 그래프 위 * 틸팅 버튼과 같다(`b05-profile-edit__btn` 계열 크기를 CSS에서 공유). @@ -24,6 +25,9 @@ export interface ProfileToolsCallbacks { onShift: (runs: StraightRun[], delta: number) => void; /** 고른 직선 구간을 꺾는다 — 가운데 라운드 + 양측 탄젠트. */ onTilt: (run: StraightRun, delta: number) => void; + /** 마지막 [저장] 시점으로 되돌린다 — 전체 초기화가 아니다. */ + onResetToSaved: () => void; + canResetToSaved: () => boolean; onUndo: () => void; onRedo: () => void; canUndo: () => boolean; @@ -140,6 +144,14 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo const wrap = document.createElement("span"); wrap.className = "b05-route-profile__tools"; + // [초기화] — 되돌리기 왼쪽. 기준은 **마지막 [저장] 시점**이고 저장한 작업은 남는다. + const reset = toolButton( + "초기화", + "마지막 [저장] 시점으로 되돌립니다 (저장한 작업은 그대로 남습니다)", + callbacks.onResetToSaved, + ); + reset.disabled = !callbacks.canResetToSaved(); + const undo = toolButton("↶", "되돌리기 (Ctrl+Z)", callbacks.onUndo); undo.disabled = !callbacks.canUndo(); const redo = toolButton("↷", "다시하기 (Ctrl+Shift+Z)", callbacks.onRedo); @@ -164,7 +176,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo up.disabled = idle || (mode === "straighten" && runs.length !== 1); down.disabled = up.disabled; - wrap.append(undo, redo, straighten, shift, up, down); + wrap.append(reset, undo, redo, straighten, shift, up, down); if (mode === "straighten" && pending !== null) { const hint = document.createElement("em"); hint.className = "b05-route-profile__tool-hint"; diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index 7233f768..af9ef682 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -935,6 +935,32 @@ } /* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */ +/* [쉬프트]로 고른 직선 구간 강조 — 직선과 **양 끝 라운드**를 함께 빨갛게 덧그린다 + (2026-09-03 사용자 지시). 편집 버튼층(z-index 4)보다 아래에 깔아 버튼을 가리지 않는다. */ +.b05-profile-runmark { + position: absolute; + z-index: 3; + inset: 0; + pointer-events: none; +} + +.b05-profile-runmark__line { + fill: none; + stroke: var(--color-danger, #d64545); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.85; +} + +/* 구간 경계(라운드 시·종점) 세로 표식 — 어디까지 함께 움직이는지 알린다. */ +.b05-profile-runmark__edge { + stroke: var(--color-danger, #d64545); + stroke-width: 1; + stroke-dasharray: 3 3; + opacity: 0.5; +} + .b05-profile-edit { position: absolute; z-index: 4;