From e8698a7fe3f220507653f95cdb2a80211df41145 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:44:34 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore(docs):=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20=EB=AC=B8=EC=84=9C=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/사용자요구/2026-10-05_수정요구.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 740f9584f..241734bd4 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -160,3 +160,9 @@ (10-6 사용자 · 추가 요청) - 안전부대용지 그룹의 사토장의 기준측점 표현이 예전 코드입. 토취장과 동일하게 구성 + +(10-6 사용자 · 다음 수정) + - B05/B06의 종단도 내널에서 구조물 알약을 선택하면 정보 라벨이 나오는데 위치가 상하 제어 버튼에 가려져서 안보여 최상위로 올려주고 그럼 버튼이 가려지니까 Y축 방향으로 조금 쉬프트 해줘. 그리고 안에 내용은 중요한 정보만 보여주면 됨. 지금은 너무 많이 나와. 중요한 정보라면 아마 기준 측점 정보와 컨테이너가 있는 경우 제일 앞에 있는 옵션값만 있어도 될것같아. + +(10-6 사용자 · 신규 기능) +다음은 신규 기능이야. 구조물 조합을 지정해야하는 경우에가 있는데 이때 좌측 패널의 구조물 리스트의 항목들에 측점정보 좌측에 연결되었거나 연결되지 않은 정보를 알려주는 기능이 있었으면 함. 이때 구조물에 따라 선택이나 필수 정보들에 의해 램프와 램프의 색상으로 표현되게 하면 될것같아. 램프영역에 의해 글자들의 배열이 깨지는건 싫으니 별도의 영역을 먼저지정하고 램프는 3행 다열 구조로 구성해줘. 램프의 사이즈는 작아도 되니 현재의 리스트 높이를 깨지 않는 범위에서 진행해줘. From 14a9366b30f6fb0c81a4c0b35f11819bc74c19ff Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:15:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(structure):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=AA=A9=EB=A1=9D=20=EC=A4=84=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=9E=87=EA=B8=B0=20=EB=9E=A8=ED=94=84=20=E2=80=94=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=99=BC=EC=AA=BD=20=EA=B3=A0=EC=A0=95=20=ED=8F=AD?= =?UTF-8?q?=20=EC=B9=B8=20=C2=B7=203=ED=96=89=20=C3=97=20=EC=97=B4=20?= =?UTF-8?q?=C2=B7=20=EC=B4=88=EB=A1=9D=20=EC=9D=B4=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=B9=A8=EA=B0=95=20=ED=95=84=EC=88=98=20=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EB=85=B8=EB=9E=91=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=A0=95=20=C2=B7=20=ED=88=B4=ED=8C=81=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=C2=B7=20=ED=95=AD=EB=AA=A9(54-15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 램프 자리 = 폼 잇기 줄 규칙(명세 컨테이너 · 인스턴스 · 배관 흙막이 지형 단 수 · 정의 그룹 · 종류 단위 · B군 없음) - 단 수 바뀜 · 횡단 자료 도착 때 목록 다시 그림 - 지정 줄 왼쪽 선 = 안쪽 그림자(글 시작 자리 같게) - ui_checks/54-15_잇기_램프.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- A00_Common/design/design_view_cross.ts | 1 + B05_Profile/B05_Profile_UI_Structures_List.ts | 94 ++++++- .../B05_Profile_UI_Structures_Panel.ts | 2 +- .../B05_Profile_UI_Style_Structures.css | 30 ++- resources/tester/ui_checks/54-15_잇기_램프.js | 242 ++++++++++++++++++ 5 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 resources/tester/ui_checks/54-15_잇기_램프.js 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; +})();