diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts
index 065766020..046fbeadf 100644
--- a/A00_Common/design/design_view_cross.ts
+++ b/A00_Common/design/design_view_cross.ts
@@ -576,6 +576,7 @@ export async function mountCrossView(
}
// 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다.
sectionDetail = await loadSectionDetail(projectId, context.route_id);
+ structuresPanel.facility.recount(); // 단 수는 횡단 자료로 셈 — 흙막이 상자 · 목록 램프 다시(54-15)
// 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면
// 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측).
if (sectionDetail) {
diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts
index ee699ec95..9f75cc147 100644
--- a/B05_Profile/B05_Profile_UI_Structures_List.ts
+++ b/B05_Profile/B05_Profile_UI_Structures_List.ts
@@ -15,7 +15,17 @@ import {
} 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 { readSummaryItem, summaryTooltip } from "../common_util/common_util_summary_item";
+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, linkOf, readState } from "../A00_Common/design/design_structure_spec";
+import { asRoad } from "./B05_Profile_UI_Structures_Define";
export interface StructureListParams {
/** 목록을 담을
. 그릴 때마다 통째로 갈아 끼운다. */
@@ -62,6 +72,72 @@ export function etcName(structure: StructureInstance): string | null {
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;
+ 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 ?? {});
+ const state = readState(spec, source.options ?? {});
+ return spec.containers.flatMap((c): LinkLamp[] => {
+ const title = containerTitle(c, names, groupNames[c.link]);
+ if (c.required) return [{ title, item: links[c.link] ?? null, required: true }];
+ const n = (c.id === "soil_add" ? 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();
@@ -101,6 +177,20 @@ export function renderStructureList(params: StructureListParams): void {
: (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";
@@ -132,7 +222,7 @@ export function renderStructureList(params: StructureListParams): void {
// 사용자). 패널이 막 펼쳐진 참이라 자리가 잡힌 다음 프레임에 민다.
if (picked) requestAnimationFrame(() => item.scrollIntoView({ block: "nearest" }));
}
- item.append(station, name);
+ item.append(lampCell(lamps[index]), station, name);
list.append(item);
});
}
diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts
index 4f3e2e137..aec944f83 100644
--- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts
+++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts
@@ -540,7 +540,7 @@ export function createStructuresSection(
return {
root,
- facility: facilityOptions,
+ facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15)
listRoot: list,
hasSelection: () => editingId !== null || selectedPipeChainage !== null,
setTypes(next, pick = () => true) {
diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css
index cde7f1c91..fe14215b1 100644
--- a/B05_Profile/B05_Profile_UI_Style_Structures.css
+++ b/B05_Profile/B05_Profile_UI_Style_Structures.css
@@ -479,7 +479,35 @@
/* 목록 줄 — 집계 항목이 지정된 구조물 · 관은 왼쪽 선 + 이름 색으로(밝음 · 어두움 같은 토큰). */
.b05-route__irregular-item.is-assigned {
- border-left: 3px solid var(--color-success);
+ /* 테두리 두께 대신 안쪽 그림자 — 글 시작 자리가 다른 줄과 같게(54-15) */
+ border-left-color: var(--color-success);
+ box-shadow: inset 2px 0 0 var(--color-success);
+}
+
+/* 목록 줄 잇기 램프(54-15) — 측점 왼쪽 고정 폭 칸 · 3행 × 열(위 → 아래 → 다음 열) ·
+ 폭 = 목록에서 가장 많은 열(`--lamp-cols`) · 줄 높이를 늘리지 않음 */
+.b05-route__lamps {
+ display: grid;
+ flex: none;
+ grid-auto-flow: column;
+ grid-template-rows: repeat(3, 5px);
+ grid-auto-columns: 5px;
+ gap: 2px;
+ align-self: center;
+ width: max(0px, calc(var(--lamp-cols, 0) * 7px - 2px));
+}
+
+.b05-route__lamps i {
+ border-radius: 50%;
+ background: var(--color-success);
+}
+
+.b05-route__lamps i.is-required {
+ background: var(--color-danger);
+}
+
+.b05-route__lamps i.is-optional {
+ background: var(--color-warning);
}
.b05-route__irregular-item.is-assigned span {
diff --git a/resources/tester/ui_checks/54-15_잇기_램프.js b/resources/tester/ui_checks/54-15_잇기_램프.js
new file mode 100644
index 000000000..44cb3e42e
--- /dev/null
+++ b/resources/tester/ui_checks/54-15_잇기_램프.js
@@ -0,0 +1,242 @@
+/* 54-15 — 구조물 목록 잇기 램프(측점 왼쪽 · 3행 × 열 · 초록 이음 · 빨강 필수 미지정 · 노랑 선택 미지정)
+ * 시작 화면: B06(#/b06-section) 또는 B05 · 검증 프로젝트(sub4_검증) · 로그인
+ * 부르는 법: orca eval --expression "window.__m5415={mode:'단계'}; $(cat 이 파일)"
+ * look → 줄마다 램프 = 폼 잇기 줄(수 · 색) 대조 · 줄 높이(램프 끈 때와 같음) · 글 시작 x · 칸 폭
+ * live → ARGS.row(기본 「배관 1」) 빨강 하나 잇기 → 초록 → 해제 → 빨강 · 「도수로/돌붙임」 추가 →
+ * 노랑 → 잇기 → 초록 → 해제 → 노랑 → 빼기 → 램프 수 되돌아옴(저장 안 함)
+ * sig → 줄마다 램프 색 글(B05 · B06 대조 · 저장 · 다시 열기 대조용)
+ * (결과는 window.__m5415_out 에도 — orca eval 응답이 끊기면 그것을 읽음)
+ * link / unlink → ARGS.row 첫 빨강 잇기 / 첫 초록 해제 → [저장] → sig
+ */
+(async () => {
+ const ARGS = window.__m5415 || { mode: "look" };
+ const ROW = ARGS.row || "배관 1";
+ const w = (ms) => new Promise((r) => setTimeout(r, ms));
+ const until = async (fn, ms = 20000) => {
+ for (let t = 0; t < ms; t += 200) {
+ if (fn()) return true;
+ await w(200);
+ }
+ return false;
+ };
+ const modUrl = (part) =>
+ performance
+ .getEntriesByType("resource")
+ .map((e) => e.name)
+ .filter((n) => n.includes(part))
+ .pop();
+ // 명세 모듈 주소 — 자원 목록에 없으면(먼저 불린 계산 모듈 주소에서) 이름만 바꿔 씀
+ const specUrl =
+ modUrl("/design/design_structure_pipe.ts") ??
+ modUrl("/design/design_structure_pipe_calc.ts").replace("_calc.ts", ".ts");
+ const { STRUCTURE_SPECS } = await import(specUrl);
+ const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
+ const nameOf = (li) => li.querySelector("span")?.textContent ?? "";
+ const rowOf = (name) => items().find((li) => nameOf(li) === name);
+ const color = (dot) =>
+ dot.classList.contains("is-linked") ? "G" : dot.classList.contains("is-required") ? "R" : "Y";
+ const lampsOf = (li) => [...li.querySelectorAll(".b05-route__lamps i")].map(color).join("");
+ const visible = (n) => n.offsetParent !== null;
+ /** 폼에 보이는 잇기 줄 — 색 글(같은 차례 · 위 → 아래). */
+ const formLinks = () =>
+ [...document.querySelectorAll(".b05-structure__summary")].filter(visible).map((row) => {
+ if (row.querySelector(".b05-structure__summary-name.is-assigned")) return "G";
+ const box = row.closest("[data-spec-container]");
+ if (!box) return "R"; // 종류 단위 · 정의 그룹 상자 = 필수
+ const c = Object.values(STRUCTURE_SPECS)
+ .flatMap((s) => s.containers)
+ .find(
+ (c) => c.id === box.dataset.specContainer && row.dataset.specLink?.startsWith(c.link),
+ );
+ return c && !c.required ? "Y" : "R";
+ });
+ const sorted = (s) => [...s].sort().join("");
+ const select = async (name) => {
+ await until(() => items().length > 0);
+ const li = rowOf(name);
+ if (!li) return false;
+ if (!li.classList.contains("is-selected")) {
+ li.click();
+ await until(() => rowOf(name)?.classList.contains("is-selected"), 5000);
+ }
+ await w(700);
+ return true;
+ };
+ const release = async () => {
+ const li = document.querySelector(".b05-route__irregular-list li.is-selected");
+ if (li) li.click();
+ await w(500);
+ };
+ const sig = () =>
+ items().map((li) => `${li.querySelector("strong")?.textContent}${nameOf(li)}:${lampsOf(li)}`);
+ const pickLink = async (row) => {
+ row.querySelector('[data-b05-summary="pick"]').click();
+ await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row"));
+ for (let k = 0; k < 8; k++) {
+ const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")];
+ const leaf = rows.find((r) => !r.classList.contains("is-branch"));
+ if (leaf) {
+ leaf.click();
+ break;
+ }
+ rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click();
+ await w(300);
+ }
+ await w(300);
+ [...document.querySelectorAll(".ui-item-picker__actions button")]
+ .find((b) => b.textContent.includes("적용"))
+ .click();
+ await w(1500);
+ };
+ const linkRows = () => [...document.querySelectorAll(".b05-structure__summary")].filter(visible);
+ const save = async () => {
+ document.querySelector('[data-dock-action="save"]').click();
+ await w(500);
+ const ok = await until(() => !document.querySelector(".ui-overlay.is-active"), 90000);
+ await w(1500);
+ return ok;
+ };
+ const out = { mode: ARGS.mode };
+
+ if (ARGS.mode === "look") {
+ await release();
+ const list = document.querySelector(".b05-route__irregular-list");
+ const heights = () =>
+ items().map((li) => Math.round(li.getBoundingClientRect().height * 10) / 10);
+ const withLamps = heights();
+ list.classList.add("m5415-off");
+ const style = document.createElement("style");
+ style.textContent = ".m5415-off .b05-route__lamps{display:none}";
+ document.head.append(style);
+ const without = heights();
+ style.remove();
+ list.classList.remove("m5415-off");
+ const xs = items().map(
+ (li) => Math.round(li.querySelector("strong").getBoundingClientRect().left * 10) / 10,
+ );
+ const widths = items().map(
+ (li) => li.querySelector(".b05-route__lamps").getBoundingClientRect().width,
+ );
+ const dot = items()
+ .map((li) => li.querySelector(".b05-route__lamps i"))
+ .find(Boolean)
+ ?.getBoundingClientRect();
+ // 줄마다 폼을 열어 잇기 줄과 대조
+ const rows = [];
+ for (const li of items()) {
+ const name = nameOf(li);
+ const lamps = lampsOf(li);
+ await select(name);
+ const form = formLinks().join("");
+ rows.push({ name, lamps, form, ok: sorted(lamps) === sorted(form) });
+ await release();
+ }
+ const cols = Math.max(...rows.map((r) => Math.ceil(r.lamps.length / 3)));
+ Object.assign(out, {
+ rows: rows.length,
+ mismatch: rows.filter((r) => !r.ok),
+ lampTotal: rows.reduce((n, r) => n + r.lamps.length, 0),
+ colors: Object.fromEntries(
+ ["G", "R", "Y"].map((c) => [
+ c,
+ rows
+ .map((r) => r.lamps)
+ .join("")
+ .split(c).length - 1,
+ ]),
+ ),
+ byKind: [...new Set(rows.map((r) => `${r.name.replace(/ \d+$/, "")}=${r.lamps.length}`))],
+ heightSame: JSON.stringify(withLamps) === JSON.stringify(without),
+ heights: [...new Set(withLamps)],
+ textX: [...new Set(xs)],
+ lampWidth: [...new Set(widths)],
+ cols,
+ dot: dot && [dot.width, dot.height],
+ dotColors: ["is-linked", "is-required", "is-optional"].map((c) => {
+ const d = document.querySelector(`.b05-route__lamps i.${c}`);
+ return d ? getComputedStyle(d).backgroundColor : null;
+ }),
+ });
+ }
+
+ if (ARGS.mode === "live") {
+ await select(ROW);
+ const lamps = () => lampsOf(rowOf(ROW));
+ const steps = [["start", lamps(), formLinks().join("")]];
+ const red = linkRows().find(
+ (r) =>
+ !r.querySelector(".is-assigned") &&
+ !r.closest("[data-spec-container]")?.querySelector("[data-spec-remove]"),
+ );
+ if (red) {
+ await pickLink(red);
+ steps.push(["link", lamps(), formLinks().join("")]);
+ red.querySelector('[data-b05-summary="clear"]').click();
+ await w(1500);
+ steps.push(["clear", lamps(), formLinks().join("")]);
+ }
+ const add = [...document.querySelectorAll("[data-spec-add]")].find(visible);
+ if (add) {
+ const id = add.dataset.specAdd;
+ add.click();
+ await w(1500);
+ steps.push([`add ${id}`, lamps(), formLinks().join("")]);
+ // 램프 4 개 → 2 열 — 칸 폭 · 글 시작 x · 줄 높이가 목록 전체에서 하나
+ const box2 = (sel) => [
+ ...new Set(
+ items().map(
+ (li) =>
+ Math.round(
+ li.querySelector(sel).getBoundingClientRect()[sel === "strong" ? "left" : "width"] *
+ 10,
+ ) / 10,
+ ),
+ ),
+ ];
+ out.twoCols = {
+ lampWidth: box2(".b05-route__lamps"),
+ textX: box2("strong"),
+ heights: [
+ ...new Set(items().map((li) => Math.round(li.getBoundingClientRect().height * 10) / 10)),
+ ],
+ };
+ const box = [...document.querySelectorAll(`[data-spec-container="${id}"]`)]
+ .filter(visible)
+ .at(-1);
+ await pickLink(box);
+ steps.push(["link", lamps(), formLinks().join("")]);
+ box.querySelector('[data-b05-summary="clear"]').click();
+ await w(1500);
+ steps.push(["clear", lamps(), formLinks().join("")]);
+ [...document.querySelectorAll(`[data-spec-container="${id}"]`)]
+ .filter(visible)
+ .at(-1)
+ .querySelector("[data-spec-remove]")
+ .click();
+ await w(1500);
+ steps.push(["remove", lamps(), formLinks().join("")]);
+ }
+ out.steps = steps.map(([s, l, f]) => `${s}: ${l} (폼 ${f})`);
+ out.back = steps[0][1] === steps.at(-1)[1];
+ await release();
+ }
+
+ if (ARGS.mode === "link" || ARGS.mode === "unlink") {
+ await select(ROW);
+ const target = linkRows().find((r) =>
+ ARGS.mode === "link" ? !r.querySelector(".is-assigned") : r.querySelector(".is-assigned"),
+ );
+ if (ARGS.mode === "link") await pickLink(target);
+ else {
+ target.querySelector('[data-b05-summary="clear"]').click();
+ await w(1500);
+ }
+ out.row = lampsOf(rowOf(ROW));
+ out.saved = await save();
+ await release();
+ }
+
+ out.sig = sig();
+ window.__m5415_out = JSON.stringify(out); // 응답이 끊겨도 뒤에 읽음
+ return window.__m5415_out;
+})();