Files
Aislo/B05_Profile/B05_Profile_UI_Structures_List.ts
T
eomsangdonandClaude Opus 5.5 08e493f132 feat(B05·B06·B07): 소수 측점 횡단 — 관 · 구조물 자리마다 따로 · 이름 소수 2자리 · 화면 표기만 정수
PLAN 20장 소수 측점 횡단(sub6). 사용자 확정: 관 · 구조물이 놓인 소수 측점(예 119.8)에 그 자리 횡단을
따로 둠 · 백엔드 · 파일 · 저장값은 소수 그대로 · 화면 표기만 「측점 + 거리」의 거리를 정수로(5+20).
- 이름 규칙 한 벌(B05_Profile_Engine_Sections): cross_key · cross_filename · parse_cross_key ·
  CROSS_KEY_PATTERN — 정수 m 는 옛 이름 그대로(cross_00120m) · 소수 측점은 cross_00119.80m.
  find_cross_file = 읽기용(새 이름이 없으면 옛 정수 m 이름) · prune_stale_cross_files 도 이것으로
  가려 옛 프로젝트의 정수 이름 파일을 지우지 않음
- generate_sections: 정수 m 격자 붙임을 풂 — 같은 이름이 되는 0.005m 안 겹침만 하나로
- B06: 찾기 · 패치 입력(Router_Design 둘) · 샘플 넓힘(SampleExtend)이 find_cross_file ·
  DB 설계 찾기 · 병합(get_cross_section_design · merge_…_by_round)은 ROUND(chainage_m)=정수 →
  0.005m 안 매칭
- B07: 도면 id · 장 id(cross_{key}m · cross_s{key}m) 정규식 · 측점 키(cross_station — 정수는 int
  그대로라 옛 id · 수량표 키 안 바뀜) · 측점은 파일 속 값으로(옛 정수 이름 파일도) · 횡단 파일 찾기
- 화면 표기 한 벌 common_util/common_util_station.ts(stationLabel · stationPartsLabel · 순수 함수) —
  common_util_svg 가 다시 내보내고 B05 formatStation 을 대신함(B05 목록 · 표시 · 폼 범위 · 계획노선
  편집 · 비정규 측점 라벨 · B06 카드 · 종단 · 유토) · 입력 칸 · 저장값은 소수 그대로
- B05_Profile_UI_Structures_Panel 700줄 → 690(순수 도우미 셋을 Panel_Types 로)
- M02_Table_Fill 관연장 찾기는 이미 측점 값(정확 먼저 · 0.5m 안)이라 그대로
시험: test_b05_station_roundtrip(표기 5+20 · 옛 정수 이름 찾기 · 안 지움 · 119.8 과 120 따로) ·
test_b05_extra_stations_supplier(새 규칙) · test_b06_patch_station(119.8 한 건) ·
test_b05_b06_save_roundtrip(보낸 관 측점마다 소수 이름 횡단 파일 있음) 등 고친 대상 108 통과 ·
test_b05_station_merge 통과 · 타입검사 통과.
ORCA(IP 5181 · 사본 프로젝트 sub6_검증 — 끝나고 DB 행 · 폴더 지움 · 원본 안 씀): 노출형 횡단수로를
5+19.8 에 놓고 저장 → structures.json 119.8 · 종단 측점 119.8(irregular) + 120 · 파일
cross_00119.80m.json 과 cross_00120m.json · B05 목록 「5+20」 · B06 카드 「5+20 · 119.8m」 열림.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-29 11:54:51 +09:00

124 lines
5.7 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Structures_List.ts
* 「구조물 배치」 폼 아래 목록 — 구조물 정본(structures)과 계곡 통과 시설 정본
* (pipe_points)을 기준점 오름차순으로 하나의 목록에 병합해 그린다.
*
* 패널 본체(B05_Profile_UI_Structures_Panel)가 700줄 한계에 닿아 분리했다.
* 목록은 상태를 갖지 않는다 — 그릴 때마다 현재 목록·선택 상태를 인자로 받고,
* 항목을 누르면 넘겨받은 콜백으로 폼 로드를 되돌려 준다.
* ========================================================================== */
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel";
import { stationLabel as formatStation } from "../common_util/common_util_station";
export interface StructureListParams {
/** 목록을 담을 <ul>. 그릴 때마다 통째로 갈아 끼운다. */
list: HTMLElement;
structures: ReadonlyArray<StructureInstance>;
pipeFacilities: ReadonlyArray<PipeFacilityItem>;
/** 타입 이름을 찾을 레지스트리 사전. */
typeMap: Map<string, StructureType>;
/** 측점 표기용 측점 간격(m). */
intervalM: number;
/** 지금 폼에 올라와 있는 구조물(없으면 null). */
editingId: string | null;
/** 지금 폼에 올라와 있는 계곡 통과 시설의 누가거리(없으면 null). */
selectedPipeChainage: number | null;
onSelectStructure: (structure: StructureInstance) => void;
onSelectPipe: (pipe: PipeFacilityItem) => void;
}
/** 구조물 한 건의 측점 표기 — 점형은 기준 측점, 구간형은 시작~종료 측점. */
export function stationOfStructure(structure: StructureInstance, intervalM: number): string {
return structure.placement === "interval"
? `${formatStation(structure.start_m ?? 0, intervalM)}~${formatStation(structure.end_m ?? 0, intervalM)}`
: formatStation(structure.chainage_m ?? 0, intervalM);
}
/** 중복 이름 번호 규칙 — 같은 이름이 여럿이면 노선 시점에 가까운 순서로 "이름 1",
* "이름 2"…를 붙인다(2026-08-19 사용자 지시, B05·B06 공통 규칙). 입력은 시점
* 오름차순으로 정렬된 이름 목록이어야 한다. 하나뿐인 이름은 그대로 둔다. */
export function numberDuplicateNames(names: ReadonlyArray<string>): string[] {
const totals = new Map<string, number>();
names.forEach((name) => totals.set(name, (totals.get(name) ?? 0) + 1));
const seen = new Map<string, number>();
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();
// 두 정본을 하나의 목록으로 — 기준점 오름차순 병합 (2026-08-17 통합 표시).
const rows: Array<
| { kind: "structure"; anchor: number; structure: StructureInstance }
| { kind: "pipe"; anchor: number; pipe: PipeFacilityItem }
> = [
...params.structures.map((structure) => ({
kind: "structure" as const,
anchor: structureAnchorM(structure),
structure,
})),
...params.pipeFacilities.map((pipe) => ({
kind: "pipe" as const,
anchor: pipe.chainage_m,
pipe,
})),
].sort((left, right) => left.anchor - right.anchor);
if (!rows.length) {
const empty = document.createElement("li");
empty.className = "b05-route__irregular-empty";
empty.textContent = "배치된 구조물이 없습니다.";
list.append(empty);
return;
}
// 한 항목 = [측점(좌측 맞춤)][구조물 이름(우측 맞춤)] — 메모·연동 표기 등 그 외
// 정보는 적지 않는다(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);
item.addEventListener("click", () => params.onSelectStructure(structure));
if (structure.structure_id === editingId) {
requestAnimationFrame(() => item.scrollIntoView({ block: "nearest" }));
}
} else {
const { pipe } = row;
const picked =
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05;
item.classList.toggle("is-selected", picked);
station.textContent = formatStation(pipe.chainage_m, intervalM);
item.addEventListener("click", () => params.onSelectPipe(pipe));
// 하이라이트만으로는 목록 밖에 있으면 안 보인다 — 그 자리로 끌어온다(2026-09-04
// 사용자). 패널이 막 펼쳐진 참이라 자리가 잡힌 다음 프레임에 민다.
if (picked) requestAnimationFrame(() => item.scrollIntoView({ block: "nearest" }));
}
item.append(station, name);
list.append(item);
});
}