- 흙막이 양식 한 벌(design_structure_guard) — 배관 · 박스 · 세월교가 같은 칸(형태 · 길이 · 높이 · 측점 전후) · 추가 흙막이 · 도수로/돌붙임 상자
- 세월교 · 박스 유입 · 유출: 흙막이 고르면 흙막이 칸(키 {side}_revet_* · 기본 찰 10 · 1.5 · 5 · 5) · [연동][경사] 옵션 줄
- B06 측벽 높이 · 좌우 · 상하 · 바닥판 읽기 줄을 세월교 · 박스 유입 · 유출 칸에 안 옮김(강조만)
- 유입 · 유출 「없음」 = 목록 램프 · 구조물 조합 잇기 줄 없음(명세 none)
- 세월교: 수량(련) 관경 바로 뒤 · 련 기본 2 · 새 시설 빈 월류 높이 = 유량 오면 필요 수심 · 종류 바꿀 때 유량 넘김
- 물넘이 포장: 경사각 기본 3° · 월류 폭 · 높이 칸(세월교와 같은 키 ford_width_m · ford_height_m · 같은 계산)
- 구조물정의 판 18(판_17 이력) · 이름 표 · 집계표 선택 컨테이너 짝 · 저장 병합 키
- 시험: 세월교 · 박스 · 물넘이 명세 기대값 · 흙막이 짝 자리 옮김
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
244 lines
11 KiB
TypeScript
244 lines
11 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";
|
|
import {
|
|
readGroupItems,
|
|
readSummaryItem,
|
|
summaryLabel,
|
|
summaryTooltip,
|
|
type SummaryItem,
|
|
} from "../common_util/common_util_summary_item";
|
|
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
|
import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc";
|
|
import {
|
|
containerTitle,
|
|
isNone,
|
|
linkOf,
|
|
readState,
|
|
} from "../A00_Common/design/design_structure_spec";
|
|
import { withLegacyGuards } from "../A00_Common/design/design_structure_ford";
|
|
import { asRoad } from "./B05_Profile_UI_Structures_Define";
|
|
|
|
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}`;
|
|
});
|
|
}
|
|
|
|
/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름) · 알약도 씀(53-2). */
|
|
export function etcName(structure: StructureInstance): string | null {
|
|
const name = structure.type_id === "etc" ? String(structure.options?.name ?? "").trim() : "";
|
|
return name || null;
|
|
}
|
|
|
|
/** 잇기 자리 하나 — 상자 이름 · 이은 항목(없으면 null) · 필수 여부. */
|
|
interface LinkLamp {
|
|
title: string;
|
|
item: SummaryItem | null;
|
|
required: boolean;
|
|
}
|
|
|
|
/** 줄의 잇기 자리(54-15) — 폼이 잇기 줄을 세우는 규칙 그대로: 명세 컨테이너마다(선택은 인스턴스마다 ·
|
|
* 배관 흙막이는 지형이 세운 단까지) · 정의 그룹마다(옛 종류 단위 값 = 첫 그룹 · 54-6) · 그 밖은
|
|
* 종류 단위 하나 · 종단배수(B군)는 없음. */
|
|
export function linkLamps(
|
|
type: StructureType | undefined,
|
|
source: {
|
|
type_id: string;
|
|
options?: Record<string, string | number>;
|
|
summary_item?: unknown;
|
|
group_items?: unknown;
|
|
},
|
|
chainageM: number | null,
|
|
): LinkLamp[] {
|
|
const links = readGroupItems(source.group_items);
|
|
const summary = readSummaryItem(source.summary_item);
|
|
const spec = STRUCTURE_SPECS[source.type_id];
|
|
if (spec?.containers.length) {
|
|
const groupNames = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name]));
|
|
// 배관 흙막이 = 명세 이름 「흙막이」가 정의 옛 이름 「흙막이 추가」보다 앞섬(54-1 · 시설 폼과 같음)
|
|
const { soil_add: _old, ...pipeNames } = type?.spec_names ?? {};
|
|
const names = source.type_id === "pipe" ? pipeNames : (type?.spec_names ?? {});
|
|
// 세월교 옛 날개벽 설치 칸 → 구조(시설 폼과 같은 길 · 「없음」이면 램프 없음 · 65-3)
|
|
const options = source.options ?? {};
|
|
const state = readState(
|
|
spec,
|
|
source.type_id === "ford_bridge" ? withLegacyGuards(options) : options,
|
|
);
|
|
return spec.containers.flatMap((c): LinkLamp[] => {
|
|
const title = containerTitle(c, names, groupNames[c.link]);
|
|
if (c.required)
|
|
return isNone(c, state[c.id][0])
|
|
? []
|
|
: [{ title, item: links[c.link] ?? null, required: true }];
|
|
const pipeSoil = source.type_id === "pipe" && c.id === "soil_add";
|
|
const n = (pipeSoil ? pipeExtraCount(chainageM) : null) ?? state[c.id].length;
|
|
return Array.from({ length: n }, (_, i) => ({
|
|
title: `${title} ${i + 1}`,
|
|
item: links[linkOf(c, i + 1)] ?? null,
|
|
required: false,
|
|
}));
|
|
});
|
|
}
|
|
if (type?.groups?.length) {
|
|
const first = type.groups[0].id;
|
|
return type.groups.map((g) => ({
|
|
title: g.name,
|
|
item: links[g.id] ?? (g.id === first ? summary : null),
|
|
required: true,
|
|
}));
|
|
}
|
|
if (!type || type.group === "B") return [];
|
|
return [{ title: "구조물 조합", item: summary, required: true }];
|
|
}
|
|
|
|
/** 램프 칸 — 3행 × 열(위 → 아래 → 다음 열) · 색 = 이음 · 필수 미지정 · 선택 미지정 · 툴팁 = 상자 · 항목. */
|
|
function lampCell(lamps: LinkLamp[]): HTMLElement {
|
|
const cell = document.createElement("div");
|
|
cell.className = "b05-route__lamps";
|
|
for (const lamp of lamps) {
|
|
const dot = document.createElement("i");
|
|
dot.className = lamp.item ? "is-linked" : lamp.required ? "is-required" : "is-optional";
|
|
cell.append(dot);
|
|
}
|
|
if (lamps.length)
|
|
cell.title = lamps.map((lamp) => `${lamp.title} · ${summaryLabel(lamp.item)}`).join("\n");
|
|
return cell;
|
|
}
|
|
|
|
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"
|
|
? (etcName(row.structure) ??
|
|
typeMap.get(row.structure.type_id)?.name ??
|
|
row.structure.type_id)
|
|
: (typeMap.get(row.pipe.facility)?.name ?? row.pipe.facility),
|
|
),
|
|
);
|
|
const lamps = rows.map((row) =>
|
|
row.kind === "structure"
|
|
? linkLamps(typeMap.get(asRoad(row.structure).type_id), asRoad(row.structure), null)
|
|
: linkLamps(
|
|
typeMap.get(row.pipe.facility),
|
|
{ ...row.pipe, type_id: row.pipe.facility },
|
|
row.pipe.chainage_m,
|
|
),
|
|
);
|
|
// 램프 칸 폭 = 목록에서 가장 많은 열 — 줄마다 글 시작 자리가 같게(54-15)
|
|
list.style.setProperty(
|
|
"--lamp-cols",
|
|
String(Math.max(0, ...lamps.map((l) => Math.ceil(l.length / 3)))),
|
|
);
|
|
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];
|
|
// 집계 항목이 지정된 줄은 색으로(`.is-assigned`) · 툴팁에 항목 이름(PLAN 21-4) — B05 · B06 공통.
|
|
const assigned = readSummaryItem(
|
|
row.kind === "structure" ? row.structure.summary_item : row.pipe.summary_item,
|
|
);
|
|
item.classList.toggle("is-assigned", assigned !== null);
|
|
if (assigned) item.title = summaryTooltip(assigned); // 미지정 줄은 지금 그대로
|
|
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(lampCell(lamps[index]), station, name);
|
|
list.append(item);
|
|
});
|
|
}
|