feat(B05/B06): 입력 흐름·알약 번호·초기화 모달·전후 분할·표시 반폭 정비

- 위치 마지막 칸 Tab → 상세 옵션 첫 칸(잠금 해제보다 탭 계산이 빨라 메모로
  건너뛰던 문제). 화면 순서(위→아래) 그대로 이동.
- [초기선 복원] → "저장선 복원"으로 문구 교정 — 복원 목표는 마지막 저장선이다
  (임시저장 시 편집이 정본에 반영). 최초 자동 계산 복귀는 [초기화] 안내.
- 종단 알약에 좌측 목록과 같은 중복 번호("옹벽 1/2") — 라벨·툴팁·벌룬 동일 표기.
- [초기화] 무동작 수정: 원인은 playwright 연결 브라우저의 네이티브 confirm 자동
  취소. 공용 showConfirmDialog 모달(ui_template_elements) 신설로 교체.
- C군 옵션: 전길이 → "기준측점 전" + "기준측점 후" 신설(기본 10/5/5). 나중에
  입력한 쪽이 살아남고 반대쪽 = 길이 − 그 값. 저장은 before_m 기준(후는 파생).
- B06 횡단 반폭 표시 기본 20 → 12 (계산·보유 샘플 폭은 백엔드 20m 유지 — 표시
  범위만 좁힘, 저장·세션값 있으면 그 값 우선).
- 측점 범위 표기 폰트 = 기준 측점 입력 칸과 동일(13.33px), 볼드 유지.
- 검증: pytest 112 통과, tsc, 헤드 브라우저 — Tab 포커스 '높이', 전7→후3·후2→전8
  연동, 범위 2+12.0~3+2.0, 알약 '옹벽 1/2', 초기화 모달 표시, 저장 왕복·원복.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 18:56:06 +09:00
co-authored by Claude Fable 5
parent 074bf9c0c0
commit 0b1a66b8d1
8 changed files with 239 additions and 29 deletions
+50 -5
View File
@@ -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,
+10 -3
View File
@@ -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<void> {
}
}
/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. */
/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입.
* 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다
* (2026-08-19 진단) — 화면 안 모달로 확인받는다. */
async function resetDesign(): Promise<void> {
if (!window.confirm(L("B05_Route_Reset_Confirm"))) return;
if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
showLoadingOverlay();
try {
await resetRouteDesign(activeProjectId);
@@ -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();
});
+26 -5
View File
@@ -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);
+71 -8
View File
@@ -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<HTMLElement>(
"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);
@@ -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;
}
+12 -5
View File
@@ -89,6 +89,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
});
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<void> {
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<void> {
// 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시).
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);
+59
View File
@@ -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<boolean> {
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 {