diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index b9656a297..764d17b64 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -201,6 +201,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { const title = containerTitle(c, names, groupNames[c.link]); const box = group(c.required ? title : `${title} ${index + 1}`); box.root.dataset.specContainer = c.id; + box.root.dataset.specIndex = String(index); // 칸을 고친 뒤 다시 그려도 몇째 상자인지(54-30) // 보이는 값 = 고른 값 + 그림이 쓰는 값(54-13) · 위 칸이 다른 갈래를 보이면 아래 칸은 빈 채로 const view: Instance = { ...values }; const tags = new Map(); @@ -309,6 +310,8 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { root.replaceChildren(...boxes); // 개수가 밖에서 바뀌었으면 값 변경으로 알림(불러오기 때는 안 함) if (!quiet) fitted.forEach((c) => hooks.onChange?.(c, c.items[0])); + // 상자를 새로 세움 — 고른 상자 강조를 다시 얹을 곳(B06 · 54-30 · 시험 흉내 DOM 은 없음) + root.dispatchEvent?.(new Event("specpaint", { bubbles: true })); } return { diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index 37061d3e9..1915eda99 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -152,15 +152,8 @@ export const PIPE_SPEC: StructureSpec = { required: false, prefix: "soil_add", link: "gsoiladd", - items: [ - { - id: "structure", - label: "구조", - kind: "select", - choices: [{ value: SOIL, label: "흙막이" }], - }, - ...guardItems(), - ], + // 사면안정 흙막이(표 09 · 54-26)와 같은 칸 — 형태 6 · 길이 · 높이 · 전 · 후 · 구조 고르기 없음(54-30) + items: guardItems(), }, { id: "chute_add", diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index ddbd00eb4..a7caceac2 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -122,3 +122,40 @@ export function guardHeightHint(diameterMm: string): string { const d = Number(diameterMm || "1000") / 1000; return `비우면 관경 기준 ${pipeWallMinHeightM(d)}`; } + +/** 관 보호공 [연동] · [경사](54-30) — null = 그 쪽을 쓸 수 없음(벽이 서는 옆 측점 없음 · 구간 없음). */ +export interface PipeRevetChoice { + linked: boolean | null; + grade: boolean | null; +} +/** 읽고 쓰는 곳 — 횡단 보기의 연동 제어(`revetLink` · 옛 저장 자리)가 걸어 둠. */ +interface RevetChoiceBridge { + read: (owner: CrossSection, sections: readonly CrossSection[]) => PipeRevetChoice; + write: ( + owner: CrossSection, + sections: readonly CrossSection[], + kind: "link" | "grade", + on: boolean, + ) => void; +} +let revetChoice: RevetChoiceBridge | null = null; +export function setPipeRevetChoiceBridge(bridge: RevetChoiceBridge): void { + revetChoice = bridge; +} + +/** 그 관의 [연동] · [경사] 지금 값 — 횡단 자료가 없으면 null(모름). */ +export function pipeRevetChoice(chainageM: number | null): PipeRevetChoice | null { + const found = ownerSection(chainageM); + return found && revetChoice ? revetChoice.read(found.owner, found.sections) : null; +} + +/** 좌측 [연동] · [경사] → 횡단 연동 제어(종단 보기는 횡단 보기를 숨긴 채 세워 씀 · 54-27 과 같은 길). */ +export async function setPipeRevetChoice( + chainageM: number | null, + kind: "link" | "grade", + on: boolean, +): Promise { + if (!revetChoice) await crossOpener?.(); + const found = ownerSection(chainageM); + if (found) revetChoice?.write(found.owner, found.sections, kind, on); +} diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index b260c25b4..8a0fda6d9 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -78,8 +78,10 @@ import { setPipeExtraCountSink, setPipeExtraCountSource, setPipeInletSource, + setPipeRevetChoiceBridge, setPipeSectionsSource, } from "./design_structure_pipe_calc"; +import { pipeRevetChoiceBridge } from "../../B06_Section/B06_Section_UI_Page_Link_Session"; import { bindDesignView, bindPipeOptions, @@ -313,6 +315,8 @@ export async function mountCrossView( }); // 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선 setPipeInletSource((section) => stationControls.inletStructure.valueFor(section)); + // 좌측 관 보호공 [연동] · [경사] = 옛 연동 제어 그대로(54-30) + setPipeRevetChoiceBridge(pipeRevetChoiceBridge(stationControls.revetLink)); const sectionView = createSectionView( (chainageM, change) => { @@ -372,6 +376,7 @@ export async function mountCrossView( partKey: key, from: "b06", }), + (chainageM) => sectionView.refreshCard(chainageM), ); // 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자). // 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다. diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 45a65767a..601174bac 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -47,6 +47,7 @@ import { WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; +import { revetChoiceRow } from "./B05_Profile_UI_Drainage_Revet_Choice"; import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form"; import { branchOf } from "../A00_Common/design/design_structure_spec"; import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; @@ -321,8 +322,22 @@ export function createFacilityOptionsForm( // 지형이 못 세운 단은 횡단 기하가 자름 — 끝난 뒤 세운 만큼으로 다시 그림 void setPipeExtraCount(chainage, count).then(() => pipeForm.recount()); }); + const inletChoice = revetChoiceRow( + () => chainage, + () => pipeForm.refresh(), + ); + const outletChoice = revetChoiceRow( + () => chainage, + () => pipeForm.refresh(), + ); const pipeForm = createSpecForm(specLinks, { - fields: (c, values) => (c.id === "outlet" ? [paintTiers(values)] : []), + // 관 보호공 [연동] · [경사](54-30) — 유입 · 유출 상자 각각 한 줄(같은 값) · 집수정이면 숨김 + fields: (c, values) => + c.id === "outlet" + ? [paintTiers(values), outletChoice.paint(false)] + : c.id === "inlet" + ? [inletChoice.paint(values.structure === "집수정")] + : [], shown: (c, item, values, index) => { if (c.id === "inlet" && item.id === "structure" && (!values.structure || inletIsAuto())) { const drawn = drawnNow(); @@ -331,8 +346,6 @@ export function createFacilityOptionsForm( : { value: "", tag: "자동(지형 규칙)" }; } if (values[item.id]) return null; - // 흙막이 상자 구조 = 표 01 흙막이 하나(54-27) - if (c.id === "soil_add" && item.id === "structure") return { value: "기슭막이", tag: "기본" }; if (item.id !== "form") return byDefault(branchOf(item, values)?.key); const basin = c.id === "inlet" && values.structure === "집수정"; const drawn = drawnNow(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts b/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts new file mode 100644 index 000000000..58effae3d --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts @@ -0,0 +1,55 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Revet_Choice.ts + * 관 보호공 상자 안 [연동] · [경사] 한 줄(54-30) — 반 폭 단추 둘 · 쓸 수 있는 쪽만 누를 수 있음. + * 값 = 횡단 연동 제어 옛 자리(`revet_link_detached` · `revet_follow_grade` · `design_structure_pipe_calc`) · + * 고르면 횡단 그림이 바로 따라감. 경사를 끄면(수평) 연동은 풀 수 없음(옛 규칙 그대로). + * ========================================================================== */ + +import { toggleButton } from "./B05_Profile_UI_Panel_Fields"; +import { + pipeRevetChoice, + setPipeRevetChoice, +} from "../A00_Common/design/design_structure_pipe_calc"; + +const LINK_TITLE = "옆 측점 흙막이와 횡단 자리를 함께 씀 — 끄면 측점마다 따로 잡음"; +const LINK_LOCKED = "경사를 끈(수평) 동안은 연동을 풀 수 없음"; +const GRADE_TITLE = "종단경사 반영 — 끄면 연장 구간 내내 같은 높이(수평)"; + +/** 한 줄 — `paint` 가 지금 값 · 쓸 수 있는지를 다시 칠하고 줄을 돌려줌(폼 다시 그림마다). */ +export function revetChoiceRow( + chainageOf: () => number | null, + /** 쓴 뒤 — 폼을 다시 그려 유입 · 유출 두 줄을 함께 칠함. */ + written: () => void, +): { + paint: (hidden: boolean) => HTMLElement; +} { + const row = document.createElement("div"); // label 이 아님 — 이름표를 눌러 첫 단추가 눌리지 않게 + row.className = "b05-route__field"; + const caption = document.createElement("span"); + caption.textContent = "옵션"; + const pair = document.createElement("div"); + pair.className = "b05-structure__half-pair"; + const write = (kind: "link" | "grade", on: boolean): void => + void setPipeRevetChoice(chainageOf(), kind, on).then(written); + const link = toggleButton("연동", true, (on) => write("link", on)); + const grade = toggleButton("경사", true, (on) => write("grade", on)); + grade.title = GRADE_TITLE; + pair.append(link, grade); + row.append(caption, pair); + + const set = (button: HTMLButtonElement, value: boolean | null, locked: boolean): void => { + button.disabled = value === null || locked; + button.classList.toggle("is-active", value === true); + button.setAttribute("aria-pressed", String(value === true)); + }; + function paint(hidden: boolean): HTMLElement { + const now = pipeRevetChoice(chainageOf()); + const locked = now?.grade === false; + set(link, now?.linked ?? null, locked); + link.title = locked ? LINK_LOCKED : LINK_TITLE; + set(grade, now?.grade ?? null, false); + row.hidden = hidden; + return row; + } + return { paint }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 78c34e1c3..c51e9da27 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -547,3 +547,10 @@ .b05-structure__spec-add { align-self: flex-start; } + +/* 반 폭 단추 둘 한 줄 — 관 보호공 [연동] · [경사](54-30). */ +.b05-structure__half-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; +} diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 91c2b61df..9c1ef254b 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -148,7 +148,7 @@ function moveRows(panelRoot: HTMLElement, selector: string, target: HTMLElement) * 조정창 사본의 단 수·옵션 행을 고른 벽 쪽(유입/유출) 그룹으로 옮긴다. 행 DOM을 * 그대로 옮기므로 배선·값 동기는 손대지 않는다. 자리가 없으면 사본에 그대로 둔다. */ -function relocateSideRows(panelRoot: HTMLElement): void { +function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { clearSideSlots(); const side = panelRoot.dataset.side; if (!sideSlots || (side !== "inlet" && side !== "outlet" && side !== "extra")) return; @@ -173,7 +173,7 @@ function relocateSideRows(panelRoot: HTMLElement): void { moveRows(panelRoot, PIPE_OUTLET_ROWS, ownerSlot); setGroupLabel(soil, ""); soil.classList.add("is-active"); - soil.scrollIntoView({ block: "nearest" }); + if (scroll) soil.scrollIntoView({ block: "nearest" }); return; } const owner = ownerSlot.closest("fieldset"); @@ -197,7 +197,7 @@ function relocateSideRows(panelRoot: HTMLElement): void { const pipe = target === sideSlots.inlet || target === sideSlots.outlet; setGroupLabel(group, pipe ? "" : (panelRoot.dataset.panelTitle ?? "")); // 고른 칸이 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2 · 유입 · 유출도 53-1). - group.scrollIntoView({ block: "nearest" }); + if (scroll) group.scrollIntoView({ block: "nearest" }); } function ensureDock(): void { @@ -248,9 +248,9 @@ export function adoptAdjustPanel(panelRoot: HTMLElement): void { * 칸(배관 유입구·유출구 / 독립 좌·우)이 아직 이전 시설 것이다(2026-08-30 사용자: * 다른 기슭막이를 고른 뒤 배관 유출을 고르면 강조가 없다). */ -export function refreshAdjustSlots(): void { +export function refreshAdjustSlots(scroll = true): void { const panel = slot?.firstElementChild as HTMLElement | null; - if (panel) relocateSideRows(panel); + if (panel) relocateSideRows(panel, scroll); } /** 이 사본이 슬롯에 서 있으면 내린다 — 다른 사본이 이미 대신 서 있으면 손대지 않는다. */ diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index a014cc93d..c12715271 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -9,6 +9,15 @@ * 검증 → 담기 / 쓰기 → 통째 직렬화)이라 제어기마다 짝을 두던 것을 하나로 모았다. * ========================================================================== */ +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import { + culvertOwnerFor, + culvertReach, + culvertWallsStandAt, + type RevetLinkControl, +} from "./B06_Section_UI_Cross_Culvert_Wire"; +import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc"; + /** 세션에 담기는 측점별 조작값 한 겹 맵. */ export interface SessionMap { values: Map; @@ -101,3 +110,31 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag }, }; } + +/** + * 좌측 관 보호공 [연동] · [경사](54-30) — 옛 연동 제어 그대로 읽고 씀. 연동 = 벽이 서는 옆 측점이 + * 있을 때만 · 켬 = 그 측점이 모두 연동 · 끄면 모두 따로. 경사 = 보호공 구간이 있을 때만. + * ponytail: 연동을 풀 때 카드 단추가 하던 높이 굳히기는 안 함 — 필요하면 카드 높이 셈을 여기로. + */ +export function pipeRevetChoiceBridge(revetLink: RevetLinkControl) { + const linkCards = (owner: CrossSection, sections: readonly CrossSection[]) => + sections.filter((s) => culvertOwnerFor(s, sections) === owner && culvertWallsStandAt(owner, s)); + return { + read: (owner: CrossSection, sections: readonly CrossSection[]): PipeRevetChoice => { + const cards = linkCards(owner, sections); + return { + linked: cards.length ? cards.every((s) => revetLink.linkedFor(s)) : null, + grade: culvertReach(owner) ? revetLink.followGradeFor(owner) : null, + }; + }, + write: ( + owner: CrossSection, + sections: readonly CrossSection[], + kind: "link" | "grade", + on: boolean, + ): void => { + if (kind === "grade") revetLink.setFollowGrade(owner, on); + else linkCards(owner, sections).forEach((s) => revetLink.setLinked(s.chainage_m, on)); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index db4d5a2c8..dfc668894 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -55,6 +55,7 @@ import { } from "./B06_Section_UI_Adjust_Dock"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; +import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec"; const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; @@ -317,6 +318,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc resetAdjustDock(); section.root.querySelector(".b05-route__panel-body")?.append(adjustDockRoot()); // 단 수·옵션 행은 유입구·유출구 그룹으로 옮겨 붙는다(2026-08-29 지시 4). + // 상자를 새로 세우면(단 수 · 유입 형식 · 벽 조작 · 저장 · 넘김) 고른 상자 강조를 다시 얹음(54-30) + section.facility.root.addEventListener("specpaint", () => refreshAdjustSlots(false)); setAdjustSideSlots({ inlet: section.facility.inletSlot, outlet: section.facility.outletSlot, @@ -496,8 +499,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc facility: { setInletStructure: (value) => section.facility.setInletStructure(value), onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler), - // 다시 그린 상자에 강조를 다시 얹음(흙막이 상자 · 54-29) - recount: () => (section.facility.recount(), refreshAdjustSlots()), + recount: () => section.facility.recount(), }, overrideOptions: (chainageM, patch) => { const hit = pipeFacilities.find( @@ -567,6 +569,8 @@ export function wireStructureSelection( /** 고른 부재를 세션에 남긴다(2026-09-04). 해제(null)는 부르지 않는다 — 조정창 닫기가 * 카드(측점) 선택까지 지우면 안 된다. 측점 해제는 카드 선택 리스너가 따로 적는다. */ remember?: (chainageM: number, key: string) => void, + /** 폼에서 고른 벽을 카드 조정창에도 세움(54-30 · 목록 넘김 `applyStructurePick` 과 같은 차례). */ + refreshCard?: (chainageM: number) => void, ): { syncInletStructure: () => void } { const ownerChainageOf = (chainageM: number): number => { const sectionAt = detail()?.cross_sections.find( @@ -588,9 +592,7 @@ export function wireStructureSelection( const syncInletStructure = (): void => { const chainageM = panel.currentChainage(); const target = chainageM === null ? undefined : sectionAt(chainageM); - if (!target) return; - panel.facility.setInletStructure(stationControls.inletStructure.valueFor(target)); - refreshAdjustSlots(); // 폼을 다시 그림 — 고른 상자 강조를 다시 얹음(54-29) + if (target) panel.facility.setInletStructure(stationControls.inletStructure.valueFor(target)); }; const origRevet = stationControls.revetOffset.select; @@ -611,6 +613,26 @@ export function wireStructureSelection( refreshAdjustSlots(); }; + // 관 상자 칸을 고치거나 고르면 그 부분을 고름 — 횡단에서 누른 것과 같은 길(54-30) + panel.root.addEventListener("change", (event) => { + // 단 수 칸은 고른 단을 지킴(단 수 바꿈 뒤 강조 유지) + if ((event.target as HTMLElement).closest('[data-spec-item="outlet.tiers"]')) return; + const box = (event.target as HTMLElement).closest("fieldset[data-spec-container]"); + const chainageM = panel.currentChainage(); + const target = chainageM === null ? undefined : sectionAt(chainageM); + if (!box || !target?.culvert) return; + const id = box.dataset.specContainer; + const key: RevetKey | null = + id === "inlet" || id === "outlet" + ? id + : id === "soil_add" + ? `extra${Number(box.dataset.specIndex)}` + : null; + if (!key || stationControls.revetOffset.highlightFor(target) === key) return; + stationControls.revetOffset.select(target.chainage_m, key); + refreshCard?.(target.chainage_m); + }); + const origFord = stationControls.ford.select; stationControls.ford.select = (chainageM, role) => { const nested = wallSelecting; diff --git a/docs/raw/조사/2026-10-06_54-33_횡단카드_종단창.md b/docs/raw/조사/2026-10-06_54-33_횡단카드_종단창.md new file mode 100644 index 000000000..c89449c09 --- /dev/null +++ b/docs/raw/조사/2026-10-06_54-33_횡단카드_종단창.md @@ -0,0 +1,72 @@ +54-33 끝 조사 · ① 갈래 3(추천 B: 열 수 자동 + 카드 최소·최대 폭) · ② 됨(같은 창 코드로 화면 조각만 팝업에 옮기는 길 · 키보드·메뉴 연결 손봐야 함) + +# ① 횡단 카드 크기 + +## 지금 크기가 정해지는 곳 +- 열 수 — `B06_Section_UI_Section_View.ts:319-326` : 열 수 = 칸 폭 ÷ (최소 폭 560 + 간격 16) 내림. 카드 폭 = 칸 폭을 열 수로 나눈 값. 최대 폭 없음 (`_Section_Common.ts:39-40` 상수 560·16). +- 높이 — `_Cross_View_Metrics.ts:57-64` : 1m 당 픽셀 = (카드 폭 − 좌우 여백 78) ÷ 가로 범위(m). 높이 = 고도 범위 × 1m 당 픽셀 + 위아래 여백 36 + 면적 표 머리방 58. 바닥 250 (`_Section_Common.ts:37`). +- 줄 높이 — `_Section_View.ts:344-349` : 같은 줄 카드 중 가장 큰 높이로 통일 (낮은 카드는 위아래 빈 자리). +- 그림 — `_Cross_View.ts:310-314` : SVG 가로세로 = 카드 px 그대로, viewBox 도 같은 값. 즉 CSS 확대·축소 없음 · 가로세로 같은 1m 당 픽셀 · 글자 크기 고정. +- 카드 틀 — `_Style_Cross.css:42-63`(머리·꼬리 줄) · `:119-123`(바둑판 · 열 수는 JS 가 직접 덮어씀). + +## 원인 +- 카드 높이 = 폭에 비례 (그림 모양 고정이므로). 칸 폭 1500 이면 2열 · 카드 약 740 폭 · 그림 높이 약 480 + 머리·꼬리 줄 → 한 줄이 칸 높이를 넘음. +- 폭을 줄이면 그림 전체가 같은 비율로 작아짐 = 축척(가로세로 비) 유지. 높이만 자르면 위아래 고도가 잘리거나 빈 자리가 늚. +- 줄여도 안 줄어드는 고정분 : 여백 36 · 면적 표 머리방 58 · 카드 머리·꼬리 · 바닥 250 → 아주 좁은 카드에서 높이가 폭을 따라 안 줄어듦. + +## 갈래 +- A. 최소 폭만 낮추기 (560 → 약 400) + - 고칠 곳 : 상수 한 줄. + - 장 : 가장 작음 · 축척 그대로. + - 단 : 1.5줄 보장 없음 (칸 높이를 안 봄) · 칸 폭마다 우연히 맞음. + - 크기 : 1줄. +- B. 열 수 자동 (추천) + - 고칠 곳 : `_Section_View.ts:319-326` 열 수 결정부 + 칸 높이도 보기(ResizeObserver 가 지금 폭만 봄 `:402-412`). + - 방식 : 열 수 2·3·4… 를 차례로 시험 → 첫 줄 높이 + 간격 + 둘째 줄 절반이 칸 높이 안에 드는 가장 적은 열 수 채택. 카드 폭이 최소 폭(예 360~400) 아래로 내려가면 거기서 멈춤. 최대 폭(예 800~900)을 넘는 1열은 열을 늘리거나 가운데 정렬로 폭 상한. + - 장 : 칸 높이가 달라져도(하단 패널 끌기·창 크기) 늘 1.5줄 근처 · 축척 유지(그림이 통째로 작아질 뿐 · 잘림 없음) · 계산이 기존 `crossPlotBase` 재사용. + - 단 : 하단 패널을 끌 때 열 수가 바뀌면 카드 전체 다시 그림 (열 수가 바뀔 때만 다시 그리게 막아야 함 · 줌·팬 상태는 측점별 캐시라 유지) · 좁아진 카드는 글자 대비 그림이 작아 읽기 힘든 한계(바닥 폭이 정함) · 바닥 250 · 머리방 58 같은 고정분이 1.5줄에 못 미치게 할 수 있음 → 이 값도 열 수 계산에 넣어야 함. + - 크기 : 약 40~60줄 + 시험 (`_Section_View.ts` 700줄 한계 확인 필요 · 열 수 계산을 별 파일로). +- C. 높이 상한 + 그림 맞춤 (CSS 만) + - 방식 : 카드 높이를 상한으로 막고 SVG 를 `preserveAspectRatio` 로 맞춤. + - 장 : 고치는 곳 적음. + - 단 : 그림이 줄어 양옆이 비고(폭 낭비) 카드 안 모든 클릭 좌표·끌기(`data-px-per-m`·팬)·글자 크기가 화면 배율과 어긋나 축척 신뢰 훼손 → 비추천. + +## 한계·확인 필요 +- 1.5줄이 실제로 되는 폭은 칸 높이와 단면 모양(고도 범위 · 가로 범위 · 세로 과장 배율)에 따라 다름 — 실측값 아님 · 식으로만 추정 : 칸 높이 500, 가로 40m, 고도 범위 23m 이면 카드 폭 약 300 까지 내려가야 1.5줄 → 바닥 폭 360~400 이면 칸 높이가 작은 화면에서는 1.5줄에 못 미칠 수 있음. 바닥 폭은 사용자 협의 대상(임의 확정 안 함). +- 줄 높이가 줄 최댓값이라 줄마다 열 수 시험 값이 다름 → 시험은 첫 줄과 둘째 줄을 실제 데이터로 계산. + +# ② 종단 하단 패널을 별도 브라우저 창으로 + +## 지금 하단 패널이 기대는 것 +- 한 벌만 존재 — `A00_Common/design/design_page.ts:116-118,125-138` (`profileSlot` · `shared.profile`) · 종단 보기·횡단 보기가 같은 인스턴스를 씀. 만드는 곳 `design_profile.ts:128-239`. +- 자료 — 같은 창 메모리(모듈 변수) + `sessionStorage`(초안·결과 · `b_page_state.ts:46-199,249-250`) + 설계 가게(`design_store.ts` : 구독 방식이 같은 창 안에서만 동작). 화면 취향(패널 높이·접힘)은 `localStorage` (+ 계정 서버 저장) → 창끼리 이미 공유. +- 페이지 쪽 연결 — 콜백 약 12 종 (`B05_Profile_UI_Profile_Panel_Types.ts:17-57` : 관 변경 · 구조물 이동·추가·삭제 · 측점 선택 · 횡단 재계산 알림 등) + `onSelectStation` · `onResetAll` + 횡단 카드 갱신 `refreshCards`. +- 창 전역에 거는 리스너 — `window` 포인터 놓기·취소·blur·keydown·keyup (`_Profile_Drag.ts:82-83,201-202` · `_Profile_Edit.ts:60-62` · `_Profile_Panel_Tools.ts:268-269,313-317` · `_Profile_Wheel`) · 우클릭 메뉴가 `document` 에 리스너 (`ui_template_context_menu.ts:57,67`) · 화면 크기 `window.innerHeight` (`_Profile_Heights.ts:26` · `_MassHaul.ts:231` · `_TableOverlay.ts:84`) · 토스트·모달은 `document.body` (`ui_template_elements.ts`, `ui_template_modal.ts`). +- 위치 — 종단 보기에서는 3D 위에 뜨는 판(`B05_Profile_UI_Style.css:42-54` absolute) · 횡단 보기는 카드 아래 흐름(`design_page.css:38-41`). + +## 갈래 +- A. 같은 창 코드로 화면 조각만 팝업에 옮기기 (추천) + - 방식 : [분리] 단추 → `window.open`(같은 출처 · 사용자 클릭 안에서) → 팝업 문서에 스타일 복사 → `profileSlot` 안 종단도 DOM 을 팝업 본문으로 옮김. 코드는 메인 창에서 계속 실행. + - 자료 공유 : 필요 없음 — 같은 메모리 · 같은 `sessionStorage` · 같은 가게. 편집 → 횡단 카드·3D 갱신도 지금 경로 그대로. + - 손볼 곳 : ① 창 전역 리스너를 요소의 소속 창(`ownerDocument.defaultView`)에 걸도록 약 10곳 ② 우클릭 메뉴 `document` 리스너와 화면 밖 보정 `window.innerWidth/Height` ③ 팝업 닫힘(`pagehide`) 때 DOM 을 원래 자리로 되돌림 · 메인 창 새로고침·이동 때 팝업 닫기 ④ 메인 쪽 빈 자리 처리(종단 보기 = 3D 가 전면 · 횡단 보기 = 카드 칸이 아래까지 늘어남) ⑤ 스타일 복사 (`link`·`style` 통째 복제 · 개발 서버 핫리로드 스타일 포함) ⑥ 토스트·모달은 메인 창에 뜸 (팝업 쪽에서 보이려면 별도 처리). + - 크기 : 신규 약 200~300줄 + 기존 약 8 파일 소폭 수정. 위험 중간 — 키보드(▲▼ 방향키)·끌기·우클릭 전부 다시 시험해야 함. +- B. 팝업이 자체 앱을 띄우고 BroadcastChannel 로 동기화 + - 방식 : 별도 라우트로 종단도만 띄우고 메인과 메시지 주고받기. + - 막히는 것 : `sessionStorage` 는 탭마다 따로(팝업 열릴 때 복사본만 받고 이후 갈라짐 · `storage` 이벤트도 세션 저장소는 창 사이 안 옴) → 초안 5종(계획고·관·구조물·선택·횡단 상세) 양방향 동기화 직접 구현 · 콜백 12종을 메시지로 바꿔야 함 · 자료 정본이 둘로 갈려 `CLAUDE.md` 5장(캐시 한 곳)과 충돌 · 횡단 상세 응답이 커서 메시지 비용 큼. + - 크기 : 큼 (수백 줄 + 충돌 처리). 비추천. +- C. 읽기 전용 거울 (팝업은 종단도 그림 + 선택 표시만, 편집은 메인) + - 장 : 단순(BroadcastChannel 로 선택 측점·종단 자료 복사). + - 단 : 편집 도구(▲▼·직선화·유토곡선 표)를 쓰려는 목적이면 의미 없음. + +## 한계·확인 필요 +- ORCA 내장 브라우저에서 `window.open` 팝업이 뜨는지 확인 안 함 — 되는지 먼저 한 번 시험 필요(안 되면 공용 브라우저에서만 검증). +- 팝업 차단 : 반드시 단추 클릭 안에서 열어야 함. +- 메인 창 닫기·이동 시 팝업 정리, 팝업 위치·크기 기억(취향 저장소 `pref` 에 한 칸)은 부가 작업. +- 두 창을 동시에 켜는 쓰기 충돌은 A 에서는 없음 (한 인스턴스). + +# 요약 다섯 줄 +1. ① 카드 높이는 폭에 비례(그림이 CSS 확대 없이 px 그대로) → 폭을 줄이면 축척 유지한 채 높이가 줄어듦. +2. 추천 B : 열 수를 칸 높이에 맞춰 자동 선택(1.5줄) + 카드 최소·최대 폭 상한 · 약 50줄 · 최소 폭 값은 협의 필요. +3. 한계 : 여백 36·머리방 58·바닥 250 은 안 줄어서 칸이 낮은 화면에서는 1.5줄에 못 미칠 수 있음. +4. ② 종단 패널은 지금 한 벌 + 같은 창 메모리·세션 저장소에 기댐 → 팝업에 별도 앱을 띄우면 동기화가 크게 어려움. +5. 추천 A : 코드는 메인에 두고 화면 조각만 팝업으로 옮김(자료 공유 불필요) · 창 전역 리스너 약 10곳과 메뉴 손봐야 함 · ORCA 에서 팝업 되는지 먼저 확인. diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index f62eaddb2..8a304220d 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3331,8 +3331,6 @@ "outlet.before": "기준측점 전 (m)", "outlet.after": "기준측점 후 (m)", "soil_add": "흙막이 추가", - "soil_add.structure": "구조", - "soil_add.structure=기슭막이": "흙막이", "soil_add.form": "형태", "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", diff --git a/resources/tester/helper_54_27_soil_tier_field.cjs b/resources/tester/helper_54_27_soil_tier_field.cjs index d32dc24b0..2f76d7a9b 100644 --- a/resources/tester/helper_54_27_soil_tier_field.cjs +++ b/resources/tester/helper_54_27_soil_tier_field.cjs @@ -127,6 +127,10 @@ const PIPE_CALC = { const STUBS = { "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, "../A00_Common/design/design_structure_pipe_calc": PIPE_CALC, + // 관 보호공 [연동] · [경사] 줄(54-30) — 여기서는 자리만 + "./B05_Profile_UI_Drainage_Revet_Choice": { + revetChoiceRow: () => ({ paint: () => new FakeNode("div") }), + }, "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => { const root = new FakeNode("div"); @@ -174,7 +178,10 @@ const tierInput = () => tierRow()?.querySelector("input"); const snap = () => ({ value: tierInput()?.value ?? null, boxes: boxesOf("soil_add").length, - structures: boxesOf("soil_add").map((b) => b.querySelector("select")?.children[0]?.textContent), + // 상자 첫 칸 이름 — 구조 고르기 없이 형태부터(54-30 · 사면안정 흙막이와 같은 칸) + structures: boxesOf("soil_add").map( + (b) => b.querySelectorAll("label")[0]?.children[0]?.textContent, + ), }); async function main() { diff --git a/resources/tester/test_52_structure_spec_pipe.py b/resources/tester/test_52_structure_spec_pipe.py index 229b448b2..88277e3d1 100644 --- a/resources/tester/test_52_structure_spec_pipe.py +++ b/resources/tester/test_52_structure_spec_pipe.py @@ -66,9 +66,10 @@ def test_옛_키로_읽고_같은_키로_씀_옛_칸은_안_씀(out): def test_선택_컨테이너는_인스턴스마다_차례대로_다시_매김(out): - assert [i["structure"] for i in out["state"]["soil_add"]] == ["기슭막이", ""] + # 흙막이 상자 = 구조 고르기 없음(54-30) — 옛 구조 키만 든 상자도 자리는 지킴 + assert [i["form"] for i in out["state"]["soil_add"]] == ["바자", ""] assert out["back"]["soil_add_1_form"] == "바자" - assert "soil_add_2_form" not in out["back"] + assert out["back"]["soil_add_2_form"] == "" # 빈 상자 자리 표식 = 첫 칸(형태) 빈 글 assert out["back"]["chute_add_1_structure"] == "도수로" diff --git a/resources/tester/test_54_14_balloon_lines.py b/resources/tester/test_54_14_balloon_lines.py index 6374f7903..aa9b92539 100644 --- a/resources/tester/test_54_14_balloon_lines.py +++ b/resources/tester/test_54_14_balloon_lines.py @@ -40,7 +40,7 @@ def test_배관_컨테이너마다_첫_값_한_줄(out): "관: 관경 1200", "관 보호공(유입): 구조 집수정", "관 보호공(유출): 구조 흙막이", - "흙막이 1: 구조 흙막이", + "흙막이 1: 형태 돌망태", # 구조 칸 없음(54-30) "도수로/돌붙임 1: 구조 도수로", ] @@ -56,7 +56,7 @@ def test_빈_값은_등록부_기본값에_기본_표시(out): def test_흙막이_상자_수는_밖에서_정한_단_수(out): - assert out["pipeCount"][-2:] == ["흙막이 1: 구조 흙막이", "흙막이 2: 구조 —"] + assert out["pipeCount"][-2:] == ["흙막이 1: 형태 —", "흙막이 2: 형태 —"] assert len(out["pipeCount"]) == 2 + 3 + 2 diff --git a/resources/tester/test_54_27_soil_tier_field.py b/resources/tester/test_54_27_soil_tier_field.py index 6ee475570..4c55e96a6 100644 --- a/resources/tester/test_54_27_soil_tier_field.py +++ b/resources/tester/test_54_27_soil_tier_field.py @@ -52,8 +52,9 @@ def test_횡단에서_바꾸면_좌측_칸이_따라감(out) -> None: assert (out["fromCross"]["value"], out["fromCross"]["boxes"]) == ("2", 2) -def test_늘어난_흙막이_상자_구조는_흙막이_기본(out) -> None: - assert out["set2"]["structures"] == ["흙막이 (기본)", "흙막이 (기본)"] +def test_늘어난_흙막이_상자는_구조_칸_없이_형태부터(out) -> None: + """54-30 — 사면안정 흙막이와 같은 칸(형태 · 길이 · 높이 · 전 · 후) · 구조 고르기 없음.""" + assert out["set2"]["structures"] == ["형태", "형태"] def test_흙막이_아닌_구조면_칸을_가림(out) -> None: diff --git a/resources/tester/test_54_29_pick_same_panel.py b/resources/tester/test_54_29_pick_same_panel.py index 5d6cd6073..2849d2862 100644 --- a/resources/tester/test_54_29_pick_same_panel.py +++ b/resources/tester/test_54_29_pick_same_panel.py @@ -40,7 +40,31 @@ def test_배관_유입_단_수_줄은_선_단이_있을_때만() -> None: def test_폼을_다시_그리면_강조_다시() -> None: + """54-30 — 상자를 새로 세울 때마다(단 수 · 유입 형식 · 벽 조작 · 저장 · 넘김) 한 곳에서 강조를 다시 얹음.""" + form = _src("A00_Common/design/design_structure_form.ts") + assert 'root.dispatchEvent?.(new Event("specpaint", { bubbles: true }));' in form page = _src("B06_Section/B06_Section_UI_Page_Structures_Panel.ts") - assert "recount: () => (section.facility.recount(), refreshAdjustSlots())" in page - sync = page[page.index("const syncInletStructure") :] - assert sync.index("refreshAdjustSlots()") < sync.index("};") + assert 'addEventListener("specpaint", () => refreshAdjustSlots(false))' in page + + +def test_상자_칸을_고치면_그_부분을_고름() -> None: + """54-30 — 칸 고침 = 횡단에서 그 벽을 누른 것과 같은 길 · 단 수 칸은 고른 단을 지킴.""" + page = _src("B06_Section/B06_Section_UI_Page_Structures_Panel.ts") + assert '[data-spec-item="outlet.tiers"]' in page + assert "`extra${Number(box.dataset.specIndex)}`" in page + assert "refreshCard?.(target.chainage_m);" in page + + +def test_흙막이_상자는_사면안정_흙막이와_같은_칸() -> None: + pipe = _src("A00_Common/design/design_structure_pipe.ts") + soil = pipe[pipe.index('id: "soil_add"') :] + assert soil[: soil.index("},")].count("items: guardItems(),") == 1 + + +def test_관_보호공_연동_경사_단추() -> None: + row = _src("B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts") + assert 'toggleButton("연동"' in row and 'toggleButton("경사"' in row + assert "b05-structure__half-pair" in row + session = _src("B06_Section/B06_Section_UI_Page_Link_Session.ts") + assert "revetLink.setFollowGrade(owner, on)" in session + assert "revetLink.setLinked(s.chainage_m, on)" in session