diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 397d66421..0b5fedd0b 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -532,7 +532,16 @@ }, { "key": "before_m", - "label": "전길이", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", "input": "number", "unit": "m", "default": 5, @@ -581,7 +590,16 @@ }, { "key": "before_m", - "label": "전길이", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", "input": "number", "unit": "m", "default": 5, @@ -630,7 +648,16 @@ }, { "key": "before_m", - "label": "전길이", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", "input": "number", "unit": "m", "default": 5, @@ -679,7 +706,16 @@ }, { "key": "before_m", - "label": "전길이", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", "input": "number", "unit": "m", "default": 5, @@ -728,7 +764,16 @@ }, { "key": "before_m", - "label": "전길이", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", "input": "number", "unit": "m", "default": 5, diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 0ad1ca1f9..361582f14 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -1,6 +1,11 @@ import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; @@ -572,9 +577,11 @@ export async function renderB05Route(root: HTMLElement): Promise { } } - /** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. */ + /** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. + * 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다 + * (2026-08-19 진단) — 화면 안 모달로 확인받는다. */ async function resetDesign(): Promise { - if (!window.confirm(L("B05_Route_Reset_Confirm"))) return; + if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return; showLoadingOverlay(); try { await resetRouteDesign(activeProjectId); diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 01f26ed06..4c97f9e95 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -78,8 +78,13 @@ export function renderBalanceBar(params: BalanceBarParams): void { const reset = document.createElement("button"); reset.type = "button"; reset.className = "b05-route-profile__balance-reset"; - reset.textContent = "초기선 복원"; - reset.title = "모든 편집과 추가한 비정규 측점을 지우고 자동 산출된 계획선으로 되돌립니다."; + // 복원 목표는 **마지막 저장된 계획선**이다 — 임시저장을 거치면 편집이 정본에 + // 반영되므로 최초 자동 계산선과 다를 수 있다(2026-08-19 사용자 확인). 최초 + // 파이프라인 결과로의 완전 복귀는 좌측 하단 [초기화](B05·B06 재계산)가 맡는다. + reset.textContent = "저장선 복원"; + reset.title = + "저장 후의 편집과 추가한 비정규 측점을 지우고 마지막 저장된 계획선으로 되돌립니다.\n" + + "최초 자동 계산 상태로 되돌리려면 좌측 하단 [초기화]를 쓰세요."; reset.addEventListener("click", () => { params.onResetAll(); }); diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 40cd72966..9f4c4bb74 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -19,6 +19,7 @@ import { type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; +import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; import { formatStation } from "./B05_Profile_Util_Station"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 @@ -125,10 +126,26 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement const typeMap = new Map(options.types.map((type) => [type.type_id, type])); const rows = rowAssignments(options.structures, options.x); + // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 시점에 가까운 순 + // "이름 1/2…". 알약 라벨(약호)·툴팁·벌룬 제목이 모두 이 번호를 따른다. + const anchorSorted = [...options.structures].sort( + (left, right) => structureAnchorM(left) - structureAnchorM(right), + ); + const numberedNames = numberDuplicateNames( + anchorSorted.map((entry) => typeMap.get(entry.type_id)?.name ?? entry.type_id), + ); + const displayNames = new Map( + anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]), + ); + options.structures.forEach((structure) => { const id = structure.structure_id; if (!id) return; const type = typeMap.get(structure.type_id); + const rawName = type?.name ?? structure.type_id; + const displayName = displayNames.get(id) ?? rawName; + // "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지). + const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : ""; const anchorPx = options.x(structureAnchorM(structure)); const selected = options.selectedId === id; // 이웃과 겹치는 알약만 위·아래 두 줄로 가르고, 혼자면 레인 세로 가운데에 크게 둔다. @@ -161,9 +178,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement mark.style.top = `${top}px`; mark.style.borderColor = type?.style?.color ?? "#888"; mark.style.color = type?.style?.color ?? "#888"; - // 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4). - mark.textContent = type?.style?.abbr ?? "?"; - mark.title = `${type?.name ?? structure.type_id} ${positionText(structure, options.stationIntervalM)}`; + // 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호. + mark.textContent = `${type?.style?.abbr ?? "?"}${numberSuffix}`; + mark.title = `${displayName} ${positionText(structure, options.stationIntervalM)}`; mark.dataset.structureId = id; mark.addEventListener("click", (event) => { @@ -174,7 +191,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement lane.append(mark); if (selected) - lane.append(buildBalloon(structure, type, anchorPx, row, options.stationIntervalM)); + lane.append( + buildBalloon(structure, type, anchorPx, row, options.stationIntervalM, displayName), + ); }); // Y축 띠 — 그래프 축을 레인까지 이어 붙인다. sticky라 가로 스크롤에도 왼쪽에 @@ -203,6 +222,8 @@ function buildBalloon( anchorPx: number, row: number | null, stationIntervalM: number, + /** 중복 번호가 붙은 표시 이름("옹벽 1") — 좌측 목록과 같은 표기(2026-08-19). */ + displayName?: string, ): HTMLElement { const balloon = document.createElement("div"); balloon.className = "b05-structure__balloon"; @@ -212,7 +233,7 @@ function buildBalloon( balloon.style.borderColor = type?.style?.color ?? "#888"; const title = document.createElement("strong"); - title.textContent = type?.name ?? structure.type_id; + title.textContent = displayName ?? type?.name ?? structure.type_id; const position = document.createElement("span"); position.textContent = positionText(structure, stationIntervalM); balloon.append(title, position); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index fb00e1c9e..eaac6eb6c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -186,8 +186,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu return Number.isFinite(length) && length > 0 ? length : null; } - /** 전길이 옵션(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0 - * (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후길이 0). */ + /** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0 + * (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */ function readBeforeM(): number { const entry = optionInputs.find((input) => input.key === "before_m"); if (!entry || entry.isEmpty()) return 0; @@ -197,6 +197,31 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu return length !== null && before > length ? length : before; } + /** 기준측점 전·후 중 나중에 고친 쪽 — 길이가 바뀔 때 이쪽 값을 지키며 재배분한다. */ + let lastSplitEdit: "before_m" | "after_m" = "before_m"; + + /** 길이·전·후 연동(2026-08-19 사용자 지시): 전 + 후 = 길이. 나중에 입력된 쪽이 + * 살아남고 반대쪽이 길이 − 그 값으로 맞춰진다. `normalizeSource`는 입력 확정 + * (change) 때만 true — 타이핑(input) 중에 그 칸을 다시 쓰면 입력이 끊긴다. */ + function syncSplitInputs( + source: "length" | "before_m" | "after_m", + normalizeSource: boolean, + ): void { + const before = optionInputs.find((entry) => entry.key === "before_m"); + const after = optionInputs.find((entry) => entry.key === "after_m"); + if (before && after) { + if (source !== "length") lastSplitEdit = source; + const length = readLengthM() ?? 0; + const keepEntry = source === "after_m" ? after : source === "before_m" ? before : null; + const keep = keepEntry ?? (lastSplitEdit === "after_m" ? after : before); + const raw = keep.isEmpty() ? 0 : Number(keep.read()); + const kept = Math.min(Math.max(Number.isFinite(raw) ? raw : 0, 0), length); + if (normalizeSource) keep.input.value = kept.toFixed(1); + (keep === before ? after : before).input.value = Math.max(length - kept, 0).toFixed(1); + } + syncRangeDisplay(); + } + /** 측점 범위 표시 갱신 — 시작 = 기준 − 전길이, 종료 = 시작 + 길이(2026-08-19 * 사용자 지시: 전/후 분할, 후길이 = 길이 − 전길이 자동). 계산 불가면 "—". */ function syncRangeDisplay(): void { @@ -411,14 +436,18 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), isEmpty: () => input.value.trim() === "", }); - // 길이·전길이가 바뀌면 측점 범위 표시가 즉시 따라온다(범위 계산 타입, 2026-08-19). - if (option.key === "length_m" || option.key === "before_m") { - const refresh = (): void => { + // 길이·기준측점 전/후가 바뀌면 서로 연동하고 측점 범위 표시가 즉시 따라온다 + // (범위 계산 타입, 2026-08-19). 전·후는 **나중에 고친 쪽**이 살아남고 반대쪽이 + // 길이 − 그 값으로 맞춰진다. 길이를 고치면 나중에 고쳤던 쪽을 지키며 재배분. + if (option.key === "length_m" || option.key === "before_m" || option.key === "after_m") { + const source = (option.key === "length_m" ? "length" : option.key) as + "length" | "before_m" | "after_m"; + const refresh = (normalize: boolean): void => { input.classList.remove("is-invalid"); - syncRangeDisplay(); + syncSplitInputs(source, normalize); }; - input.addEventListener("input", refresh); - input.addEventListener("change", refresh); + input.addEventListener("input", () => refresh(false)); + input.addEventListener("change", () => refresh(true)); } }); syncOptionLock(); @@ -536,6 +565,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const before = Math.max(structureAnchorM(target) - target.start_m, 0); beforeEntry.input.value = before.toFixed(1); } + const afterEntry = optionInputs.find((entry) => entry.key === "after_m"); + if (afterEntry && target.options.after_m === undefined) { + const after = Math.max(target.end_m - structureAnchorM(target), 0); + afterEntry.input.value = after.toFixed(1); + } } syncFacilityForm(target.options); } else { @@ -796,12 +830,41 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu commitPosition(); }); }); + /** 지금 보이는 위치 입력 칸들(화면 순서). Tab 흐름 판정에 쓴다. */ + function visiblePositionInputs(): HTMLInputElement[] { + return [startFields, anchorFields, endFields] + .filter((fields) => !fields.wrap.hidden) + .flatMap((fields) => [fields.station, fields.remainder]); + } + + /** 상세 옵션의 첫 입력 칸으로 포커스 — 없으면 메모로. */ + function focusFirstDetailInput(): void { + const target = + optionInputs.find((entry) => !entry.input.disabled && !optionRow.hidden)?.input ?? + facilityOptions.root.querySelector( + "input:not(:disabled), select:not(:disabled)", + ) ?? + memoField; + target.focus(); + } + positionRow.addEventListener("keydown", (event) => { // Enter = 입력 확정 — 포커스를 빼서 focusout 경로 하나로 처리한다(측점번호+ // 잔여거리 합산값이 그대로 임시 배치 좌표가 된다). if (event.key === "Enter" && document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } + // Tab = 화면 순서(위→아래)대로 다음 입력인 상세 옵션 첫 칸으로. 위치 확정으로 + // 옵션 잠금이 풀리는 시점이 브라우저 기본 탭 계산보다 늦어, disabled 상태를 본 + // 탭이 메모까지 건너뛰던 문제(2026-08-19 사용자 보고). + if (event.key === "Tab" && !event.shiftKey) { + const inputs = visiblePositionInputs(); + if (document.activeElement === inputs[inputs.length - 1]) { + event.preventDefault(); + (document.activeElement as HTMLElement).blur(); // change → 위치 확정 → 잠금 해제 + window.requestAnimationFrame(focusFirstDetailInput); + } + } }); body.insertBefore(field("메모", memoField), actions); diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 9dc7e7fcc..a1c1c7ba8 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -223,10 +223,13 @@ } /* 측점 범위(계산 표시) — C군 등 길이 옵션 기반 구간 타입. 입력 칸이 아니라 기준 - * 측점 + 길이로 계산된 "시작 ~ 종료"를 보여만 준다(2026-08-19 사용자 지시). */ + * 측점 + 길이로 계산된 "시작 ~ 종료"를 보여만 준다(2026-08-19 사용자 지시). + * 글자 크기는 기준 측점 입력 칸과 동일(크롬 input 기본 13.333px — 패널 입력에 + * 별도 font-size가 없어 이 값으로 그려진다), 굵기만 유지(2026-08-19 지시 7). */ .b05-structure__position-row > .b05-route__field > .b05-structure__range-value { flex: 1; min-width: 0; + font-size: 13.3333px; font-weight: 600; } diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 5348838c3..12d96b385 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -89,6 +89,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }); crossHalfWidthField.input.min = "0.1"; crossHalfWidthField.input.step = "0.1"; + /** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유 + * 샘플 폭은 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시 + * 범위만 좁힌다(12 ≤ 20이므로 재계산 없음). 계산 범위와 혼동 금지. */ + const DISPLAY_HALF_WIDTH_DEFAULT_M = 12; // 하단 액션 [종단 이동][임시저장][확정] — 종단 이동은 저장 없이 B05로 페이지 이동만, // 확정은 임시저장과 같은 내용 저장 후 stage 2·3을 닫고 B07 수량으로 넘어간다 @@ -664,7 +668,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return; } - crossHalfWidthField.input.value = String(context.defaults.cross_half_width_m); + // 표시 기본 12m — 계산 기본(context.defaults.cross_half_width_m = 20m)과 다르다 + // (2026-08-19 사용자 지시). 사용자 확정 이력·세션값이 있으면 아래에서 덮는다. + crossHalfWidthField.input.value = String(DISPLAY_HALF_WIDTH_DEFAULT_M); stationInterval = context.defaults.station_interval_m; rockBoundaryDefault = context.rock_boundary_default_offset_m; rockBoundaryStep = context.rock_boundary_step_m; @@ -708,10 +714,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시). if (storedOptions?.standard_cross_section) standardPanel?.applyStored(storedOptions.standard_cross_section); - // 입력칸의 기준값은 **백엔드 기본 반폭**(config SECTION_CROSS_HALF_WIDTH_M = 20m, 위에서 - // 이미 넣었다)이고, 사용자가 값을 지정해 확정한 이력(data.options)이 있을 때만 그 값으로 - // 바꾼다(2026-08-08 사용자 지시). 예전에는 options가 없으면 보유 샘플의 최대 offset을 계산해 - // 덮었는데, 옛 기본값(15m)으로 만들어진 노선이 15.0으로 보여 기준값 구실을 못 했다. + // 입력칸의 기준값은 **표시 기본 12m**(2026-08-19 사용자 지시, 위에서 이미 넣었다 — + // 계산·보유 샘플 폭은 백엔드 기본 20m 유지)이고, 사용자가 값을 지정해 확정한 이력 + // (data.options)이 있을 때만 그 값으로 바꾼다(2026-08-08 사용자 지시). 예전에는 + // options가 없으면 보유 샘플의 최대 offset을 계산해 덮었는데, 옛 기본값(15m)으로 + // 만들어진 노선이 15.0으로 보여 기준값 구실을 못 했다. // 표시값이 보유 샘플 폭보다 크면 [전체 측점 반영]이 알아서 재생성한다(applyPanelToAll). const storedHalfWidth = storedOptions?.cross_half_width_m ?? 0; if (storedHalfWidth > 0) crossHalfWidthField.input.value = storedHalfWidth.toFixed(1); diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 50a6c4e90..7a68ec0d4 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -290,6 +290,35 @@ export function showToast(message: string, kind: ToastKind = "info", durationMs }, durationMs); } +/* ----------------------------------------------------------------------------- + * 6-1. 확인 대화상자 (Confirm) — window.confirm 대체 + * + * 공용 헤드 브라우저(playwright 연결)는 네이티브 confirm/alert를 자동 취소해 + * 버려 [초기화] 같은 버튼이 죽은 것처럼 보였다(2026-08-19 진단). 화면 안 모달로 + * 확인을 받아 어느 실행 환경에서도 같은 동작을 보장한다. + * -------------------------------------------------------------------------- */ + +/** 확인/취소 모달 — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */ +export function showConfirmDialog(message: string, confirmLabel = "확인"): Promise { + return new Promise((resolve) => { + const overlay = el("div", { className: "ui-confirm" }); + const panel = el("div", { className: "ui-confirm__panel" }); + const text = el("p", { className: "ui-confirm__message", text: message }); + const actions = el("div", { className: "ui-confirm__actions" }); + const done = (result: boolean): void => { + overlay.remove(); + resolve(result); + }; + actions.append( + createButton({ label: "취소", variant: "ghost", onClick: () => done(false) }), + createButton({ label: confirmLabel, variant: "danger", onClick: () => done(true) }), + ); + panel.append(text, actions); + overlay.append(panel); + document.body.append(overlay); + }); +} + /* ----------------------------------------------------------------------------- * 7. 워크플로우 3단 레이아웃 셸 (frontend.md §2) * 상단 헤더 + 좌측 고정 패널 + 우측 뷰어 영역. @@ -706,6 +735,36 @@ const BASE_CSS = ` .ui-toast--warning { background-color: var(--color-warning); } .ui-toast--info { background-color: var(--color-royal-amethyst); } +/* --- Confirm 모달 (window.confirm 대체 — 공용 브라우저 호환) --- */ +.ui-confirm { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(38, 17, 74, 0.35); + z-index: var(--z-overlay); +} +.ui-confirm__panel { + min-width: 300px; + max-width: 420px; + padding: var(--spacing-24); + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} +.ui-confirm__message { + margin: 0 0 var(--spacing-16); + color: var(--color-text-body); + font-size: var(--text-body-sm); + white-space: pre-line; +} +.ui-confirm__actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} + /* --- Workflow Shell (3단 레이아웃) --- */ .ui-wf { display: flex; flex-direction: column; height: 100%; } .ui-wf__header {