From 19c0b085abe02bb35044319eb0b544f0c17a30e1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 19 Aug 2026 18:17:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20C=EA=B5=B0=20=EC=A0=84=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=EB=B6=84=ED=95=A0=C2=B7=EC=98=B9=EB=B2=BD=20?= =?UTF-8?q?=EB=AA=85=EC=B9=AD=20=EB=8B=A8=EC=88=9C=ED=99=94=C2=B7=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A4=91=EB=B3=B5=20=EC=9D=B4=EB=A6=84=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C군 5종에 전길이(before_m, 기본 0) 옵션 신설 — 시작 = 기준 − 전길이, 후길이 = 길이 − 전길이 자동. 종단 마크 = 기준점, 띠 = 시작~종료(기존 렌더 유지). 전길이 > 기준(시작 음수)은 칸 붉힘으로 거절. 구 저장분은 폼 로드 시 역산 채움. - 옹벽(철근콘크리트) → "옹벽" (전역 표기는 레지스트리 name 단일 원천). - 좌측 구조물 목록: 같은 이름 여럿이면 노선 시점 가까운 순 "이름 1/2…" 번호 (numberDuplicateNames 공용 헬퍼 — B06 목록 신설 시 재사용, 2026-08-19 사용자 지시). - 검증: pytest 112 통과, tsc, 헤드 브라우저(CDP 캐시 비움 방식) — 옵션 순서 높이→길이→전길이, 전5 범위 2+15.0~3+5.0, 목록 "옹벽 1/2", 원복 확인. Co-Authored-By: Claude Fable 5 --- B05_Profile/B05_Profile_Structure_Types.json | 47 +++++++++++++++- B05_Profile/B05_Profile_UI_Structures_List.ts | 30 +++++++++-- .../B05_Profile_UI_Structures_Panel.ts | 54 ++++++++++++++----- 3 files changed, 114 insertions(+), 17 deletions(-) diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index c805c061..e46623ca 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -504,7 +504,7 @@ { "type_id": "retaining_wall", "group": "C", - "name": "옹벽(철근콘크리트)", + "name": "옹벽", "placement": "interval", "style": { "color": "#e07a2e", @@ -530,6 +530,15 @@ "required": true, "phase": "b05" }, + { + "key": "before_m", + "label": "전길이", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05" + }, { "key": "form", "label": "형식", @@ -570,6 +579,15 @@ "required": true, "phase": "b05" }, + { + "key": "before_m", + "label": "전길이", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05" + }, { "key": "back_len_cm", "label": "뒷길이", @@ -610,6 +628,15 @@ "required": true, "phase": "b05" }, + { + "key": "before_m", + "label": "전길이", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05" + }, { "key": "back_len_cm", "label": "뒷길이", @@ -650,6 +677,15 @@ "required": true, "phase": "b05" }, + { + "key": "before_m", + "label": "전길이", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05" + }, { "key": "form", "label": "형식", @@ -690,6 +726,15 @@ "required": true, "phase": "b05" }, + { + "key": "before_m", + "label": "전길이", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05" + }, { "key": "stone_cm", "label": "돌규격", diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index de239d17..9966affb 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -40,6 +40,21 @@ export function stationOfStructure(structure: StructureInstance, intervalM: numb : formatStation(structure.chainage_m ?? 0, intervalM); } +/** 중복 이름 번호 규칙 — 같은 이름이 여럿이면 노선 시점에 가까운 순서로 "이름 1", + * "이름 2"…를 붙인다(2026-08-19 사용자 지시, B05·B06 공통 규칙). 입력은 시점 + * 오름차순으로 정렬된 이름 목록이어야 한다. 하나뿐인 이름은 그대로 둔다. */ +export function numberDuplicateNames(names: ReadonlyArray): string[] { + const totals = new Map(); + names.forEach((name) => totals.set(name, (totals.get(name) ?? 0) + 1)); + const seen = new Map(); + return names.map((name) => { + if ((totals.get(name) ?? 0) < 2) return name; + const seq = (seen.get(name) ?? 0) + 1; + seen.set(name, seq); + return `${name} ${seq}`; + }); +} + export function renderStructureList(params: StructureListParams): void { const { list, typeMap, intervalM, editingId, selectedPipeChainage } = params; list.replaceChildren(); @@ -68,17 +83,25 @@ export function renderStructureList(params: StructureListParams): void { return; } // 한 항목 = [측점(좌측 맞춤)][구조물 이름(우측 맞춤)] — 메모·연동 표기 등 그 외 - // 정보는 적지 않는다(2026-08-18 사용자 지시). - rows.forEach((row) => { + // 정보는 적지 않는다(2026-08-18 사용자 지시). 같은 이름이 여럿이면 시점에 가까운 + // 순서로 번호를 붙인다(2026-08-19 사용자 지시). + const displayNames = numberDuplicateNames( + rows.map((row) => + row.kind === "structure" + ? (typeMap.get(row.structure.type_id)?.name ?? row.structure.type_id) + : (typeMap.get(row.pipe.facility)?.name ?? row.pipe.facility), + ), + ); + rows.forEach((row, index) => { const item = document.createElement("li"); item.className = "b05-route__irregular-item"; const station = document.createElement("strong"); const name = document.createElement("span"); + name.textContent = displayNames[index]; if (row.kind === "structure") { const { structure } = row; item.classList.toggle("is-selected", structure.structure_id === editingId); station.textContent = stationOfStructure(structure, intervalM); - name.textContent = typeMap.get(structure.type_id)?.name ?? structure.type_id; item.addEventListener("click", () => params.onSelectStructure(structure)); if (structure.structure_id === editingId) item.scrollIntoView({ block: "nearest" }); } else { @@ -88,7 +111,6 @@ export function renderStructureList(params: StructureListParams): void { selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05, ); station.textContent = formatStation(pipe.chainage_m, intervalM); - name.textContent = typeMap.get(pipe.facility)?.name ?? pipe.facility; item.addEventListener("click", () => params.onSelectPipe(pipe)); } item.append(station, name); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index dfa362d1..0d1e1115 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -186,7 +186,19 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu return Number.isFinite(length) && length > 0 ? length : null; } - /** 측점 범위 표시 갱신 — 기준 측점 + 길이가 다 있으면 "시작 ~ 종료", 아니면 "—". */ + /** 전길이 옵션(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0 + * (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후길이 0). */ + function readBeforeM(): number { + const entry = optionInputs.find((input) => input.key === "before_m"); + if (!entry || entry.isEmpty()) return 0; + const before = Number(entry.read()); + if (!Number.isFinite(before) || before < 0) return 0; + const length = readLengthM(); + return length !== null && before > length ? length : before; + } + + /** 측점 범위 표시 갱신 — 시작 = 기준 − 전길이, 종료 = 시작 + 길이(2026-08-19 + * 사용자 지시: 전/후 분할, 후길이 = 길이 − 전길이 자동). 계산 불가면 "—". */ function syncRangeDisplay(): void { const computed = hasComputedRange(currentType()); rangeWrap.hidden = !computed; @@ -194,9 +206,10 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const step = interval(); const anchor = anchorFields.read(step, false); const length = readLengthM(); + const start = anchor === null ? null : anchor - readBeforeM(); rangeValue.textContent = - anchor !== null && length !== null - ? `${formatStation(anchor, step)} ~ ${formatStation(anchor + length, step)}` + start !== null && length !== null && start > -0.005 + ? `${formatStation(Math.max(start, 0), step)} ~ ${formatStation(Math.max(start, 0) + length, step)}` : "—"; } @@ -398,10 +411,14 @@ 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") { - input.addEventListener("input", syncRangeDisplay); - input.addEventListener("change", syncRangeDisplay); + // 길이·전길이가 바뀌면 측점 범위 표시가 즉시 따라온다(범위 계산 타입, 2026-08-19). + if (option.key === "length_m" || option.key === "before_m") { + const refresh = (): void => { + input.classList.remove("is-invalid"); + syncRangeDisplay(); + }; + input.addEventListener("input", refresh); + input.addEventListener("change", refresh); } }); syncOptionLock(); @@ -505,13 +522,18 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu endFields.write(target.end_m ?? null, step); memoField.value = target.memo ?? ""; renderOptionFields(target.options); - // 구간 직접 입력 시절에 저장된 항목 호환 — 길이 옵션이 비어 있으면 저장된 - // 시작~종료 구간 길이로 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). + // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 + // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) { const lengthEntry = optionInputs.find((entry) => entry.key === "length_m"); if (lengthEntry && lengthEntry.isEmpty()) { lengthEntry.input.value = (target.end_m - target.start_m).toFixed(1); } + const beforeEntry = optionInputs.find((entry) => entry.key === "before_m"); + if (beforeEntry && target.options.before_m === undefined) { + const before = structureAnchorM(target) - target.start_m; + if (before > 0.005) beforeEntry.input.value = before.toFixed(1); + } } syncFacilityForm(target.options); } else { @@ -624,7 +646,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu let start: number | null = null; let end: number | null = null; if (placement === "interval" && hasComputedRange(type)) { - // 범위 계산 타입(C군 등) — 시작 = 기준 측점, 종료 = 기준 + 길이(2026-08-19). + // 범위 계산 타입(C군 등) — 시작 = 기준 − 전길이, 종료 = 시작 + 길이 + // (2026-08-19 사용자 지시: 전/후 분할). 전길이가 기준을 넘어 시작이 음수면 거절. anchor = anchorFields.read(step, true); if (anchor === null) { anchorFields.station.focus(); @@ -635,8 +658,15 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); return; } - start = anchor; - end = anchor + length; + const before = readBeforeM(); + if (anchor - before < -0.005) { + const beforeInput = optionInputs.find((entry) => entry.key === "before_m")?.input; + beforeInput?.classList.add("is-invalid"); + beforeInput?.focus(); + return; + } + start = Math.max(anchor - before, 0); + end = start + length; } else if (placement === "interval") { start = startFields.read(step, true); end = endFields.read(step, true);