From 1ca99ed3ae5fea6fe4a1c0b251f377f2fa80cb4a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 12:25:12 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(M01):=20=EB=A1=9C=EC=A7=81=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=EB=91=90=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EA=B3=B5=EC=9A=A9=20=EC=A0=91=EA=B8=B0=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=E2=80=94=20=EC=A0=9C=EB=AA=A9=20=ED=96=89?= =?UTF-8?q?=20=EB=88=84=EB=A6=84=20=C2=B7=20=EC=BA=90=EB=9F=BF=20=C2=B7=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A7=8C=20=EC=97=B4=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 흐름도 · 원문 = ui-collapsible 카드(M02 면적산출 오른쪽 패널 꼴) · attachCollapsible 토글 위에 하나만 열림 규칙 - 따로 만든 원문 토글 단추 · 얇은 띠 제거 · 흐름도 패널 자체 접기 단추는 M01 에서 숨김 - 둘 다 접힘 = 제목 행 둘(칸 140px) · 취향 기억(m01-logic-panel) 그대로 - 44-4 화면 시험 카드 꼴로 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../M01_MasterData_UI_LogicLab_Side.css | 24 +++--- .../M01_MasterData_UI_LogicLab_Side.ts | 77 ++++++++++--------- .../tester/ui_checks/44-4_로직상세_패널.js | 27 ++++--- 3 files changed, 74 insertions(+), 54 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css index 711326450..853a55b29 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css @@ -1,22 +1,28 @@ -/* 로직 상세 오른쪽 두 패널 — 흐름도 · 원문 · 접힌 패널은 얇은 띠 */ +/* 로직 상세 오른쪽 두 패널 — 흐름도 · 원문 · 공용 접기 카드(접힌 카드 = 제목 행) */ .m01lab-side { - display: flex; - align-items: stretch; min-width: 0; } +.m01lab-side__card, .m01lab-side__slot { - flex: 0 0 40px; min-width: 0; } -.m01lab-side__slot.is-open { - flex: 1 1 auto; +/* 카드 제목 행이 접기 단추 — 흐름도 패널 자체 접기 단추 · 테두리는 뺌 */ +.m01lab-side .m02-dfp__toggle { + display: none; } -/* 접힌 흐름도 자리 — 띠만(기다림 글 · 변환 알림은 숨김) */ -.m01lab-side__slot:not(.is-open) > :not(.m01lab-flow__panel, .m01lab-src) { - display: none; +.m01lab-side .m02-dfp { + border-left: 0; + background: none; +} + +.m01lab-src { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; } .m01lab-src__body { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts index 13f8da260..3077d01bb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts @@ -1,12 +1,14 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Side.ts * 로직 상세 오른쪽 두 패널 — 「흐름도」(다이어그램 · 상세 내용 = M02_DF_Panel) · 「원문」(품셈 본문 절) - * 둘 중 하나만 열림(하나 열면 다른 하나 접힘) · 둘 다 접기 됨 · 접힌 패널 = 얇은 띠(머리 단추 그대로) + * 공용 접기 카드(`ui-collapsible` · 제목 행 누름 · 캐럿) 두 장 — attachCollapsible 토글 위에 하나만 열림 규칙 + * 둘 다 접기 됨 · 접힌 카드 = 제목 행만 * 열린 패널은 계정 취향(`m01-logic-panel`)으로 기억 · 원문은 출처 글로 서버가 찾음(`GET /m01/logic/source`) * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el } from "@ui/ui_template_elements"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { el } from "@ui/ui_template_elements"; import { API_BASE_URL } from "@config/config_frontend"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow"; @@ -14,9 +16,8 @@ import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css"; import "./M01_MasterData_UI_LogicLab_Side.css"; const TEXT = { - Toggle: ["원문", "Source"], - Open: ["원문 펴기", "Open the source panel"], - Close: ["원문 접기", "Collapse the source panel"], + Flow: ["흐름도", "Flow"], + Source: ["원문", "Source"], Loading: ["원문 찾는 중", "Looking up the source"], None: ["원문 연결 없음", "No linked source"], From: ["출처", "Source"], @@ -27,8 +28,8 @@ const TEXT = { const ts = (key: keyof typeof TEXT, v = ""): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); -/** 접힌 띠 너비 — M02_DF_Panel 의 STRIP_WIDTH 와 같음(패널 모듈은 늦게 받으므로 따로 둠) */ -const STRIP = 40; +/** 둘 다 접힘 — 제목 행 둘이 들어가는 칸 너비 */ +const FOLDED = 140; export type PanelPick = "flow" | "source" | "none"; @@ -86,21 +87,28 @@ export function buildLogicSide(split: SplitHandle): LogicSide { const flowSlot = el("div", { className: "m01lab-side__slot" }); const body = el("div", { className: "m01lab-src__body" }); const title = el("strong", { className: "m02-dfp__title" }); - const toggle = createButton({ - label: ts("Toggle"), - size: "sm", - variant: "ghost", - onClick: () => pick(now === "source" ? "none" : "source"), - }); - toggle.classList.add("m02-dfp__toggle"); - const head = el("header", { className: "m02-dfp__head", children: [toggle, title] }); - const open = el("div", { className: "m02-dfp__open", children: [head, body] }); - const strip = el("div", { className: "m02-dfp__strip" }); - const source = el("aside", { className: "m02-dfp m01lab-src", children: [open, strip] }); - const sourceSlot = el("div", { className: "m01lab-side__slot", children: [source] }); - split.right.replaceChildren( - el("div", { className: "m01lab-side", children: [flowSlot, sourceSlot] }), + const card = (label: string, inner: HTMLElement): HTMLElement => + el("section", { + className: "m01lab-side__card ui-collapsible ui-sidebar-section ui-sidebar-card", + children: [el("h3", { className: "ui-collapsible__title", text: label }), inner], + }); + const flowCard = card(ts("Flow"), flowSlot); + const sourceCard = card( + ts("Source"), + el("div", { className: "m01lab-src", children: [title, body] }), ); + const side = el("div", { + className: "m01lab-side ui-sidebar-panel", + children: [flowCard, sourceCard], + }); + attachCollapsible(side); + // 공용 토글 뒤 — 연 카드만 남기고 다른 카드는 접음(접었으면 둘 다 닫힘) + side.addEventListener("click", (ev) => { + const at = (ev.target as HTMLElement).closest(".ui-collapsible__title")?.parentElement; + if (at !== flowCard && at !== sourceCard) return; + pick(at.classList.contains("is-collapsed") ? "none" : at === flowCard ? "flow" : "source"); + }); + split.right.replaceChildren(side); let now = readPick(); let flowPx: number | null = null; @@ -108,14 +116,22 @@ export function buildLogicSide(split: SplitHandle): LogicSide { let want = ""; let shown: string | null = null; - /** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 띠 · 둘 다 접힘 = 띠 둘 */ + /** 카드 안팎 여백(좌우 안쪽 + 테두리) — 흐름도 패널 너비에 더함 */ + const pad = (): number => { + const s = getComputedStyle(flowCard); + return ["paddingLeft", "paddingRight", "borderLeftWidth", "borderRightWidth"].reduce( + (n, k) => n + (parseFloat(s[k as "paddingLeft"]) || 0), + 0, + ); + }; + /** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 카드 여백 · 둘 다 접힘 = 제목 행 */ const width = (): void => split.setRight( now === "source" || (now === "flow" && flowPx === null) ? null : now === "flow" - ? (flowPx ?? 0) + STRIP - : STRIP * 2, + ? (flowPx ?? 0) + pad() + : FOLDED, ); const load = (): void => { @@ -149,21 +165,12 @@ export function buildLogicSide(split: SplitHandle): LogicSide { }); }; - const fold = (on: boolean): void => { - open.hidden = on; - strip.hidden = !on; - (on ? strip : head).prepend(toggle); - toggle.setAttribute("aria-expanded", String(!on)); - toggle.title = ts(on ? "Open" : "Close"); - }; - const pick = (next: PanelPick): void => { if (next !== now) writeStateRaw("m01-logic-panel", next); now = next; flowFold?.(now !== "flow"); // 칸을 줄이기 전에 — 흐름도 패널이 접기 전 너비를 재어 둠 - flowSlot.classList.toggle("is-open", now === "flow"); - sourceSlot.classList.toggle("is-open", now === "source"); - fold(now !== "source"); + flowCard.classList.toggle("is-collapsed", now !== "flow"); + sourceCard.classList.toggle("is-collapsed", now !== "source"); width(); load(); }; diff --git a/resources/tester/ui_checks/44-4_로직상세_패널.js b/resources/tester/ui_checks/44-4_로직상세_패널.js index 682c0204e..df8d948c0 100644 --- a/resources/tester/ui_checks/44-4_로직상세_패널.js +++ b/resources/tester/ui_checks/44-4_로직상세_패널.js @@ -4,10 +4,11 @@ * 로직 GC000277(건설품셈 토목 1-1-2 유도선 설치 및 해체)을 엶 · 고치지 않음 · 끝에 패널 취향을 처음 값으로 되돌림. * 기대 수치: * order = ["기본정보","시험 계산","단가산출 상세","텍스트 수식"](흐름 로직이면 셋째 · 넷째는 흐름 보기 안) - * flow = {flow:true, source:false, pref:"flow"} · 오른쪽 칸 너비 > 300 - * source = {flow:false, source:true, pref:"source"} · srcHead 에 「1-1-2 유도선」 · srcTable 에 「특별인부」 - * none = {flow:false, source:false, pref:"none"} · 오른쪽 칸 너비 = 80(띠 둘) - * back = {flow:true, source:false, pref:"flow"}(흐름도 띠 단추로 다시 폄) + * 두 패널 = 공용 접기 카드(ui-collapsible · 제목 행 누름) · 흐름도 패널 자체 접기 단추는 안 보임(dfpToggle 0) + * flow = {flow:true, source:false, pref:"flow", caret:["▾","▸"]} · 오른쪽 칸 너비 > 300 + * source = {flow:false, source:true, pref:"source", caret:["▸","▾"]} · srcHead 에 「1-1-2 유도선」 · srcTable 에 「특별인부」 + * none = {flow:false, source:false, pref:"none", caret:["▸","▸"]} · 오른쪽 칸 너비 = 140(제목 행 둘) + * back = {flow:true, source:false, pref:"flow"}(흐름도 제목 행으로 다시 폄) * noLink = 「원문 연결 없음」 + 「출처 · …」(서버가 빈 목록을 줄 때 · fetch 를 잠시 바꿔 봄) * restored = 처음 취향 값 */ @@ -43,17 +44,23 @@ out.order = $$(".m01lab-flow__left .m01lab__box > .m01-logic__section-head h3").map((h) => h.innerText.trim(), ); - const slots = () => $$(".m01lab-side__slot"); + const cards = () => $$(".m01lab-side__card"); + const heads = () => cards().map((c) => $(".ui-collapsible__title", c)); + const shown = (n) => !!n && n.getBoundingClientRect().height > 0; const state = () => ({ flow: - slots()[0].classList.contains("is-open") && - !$(".m02-dfp", slots()[0]).classList.contains("is-folded"), - source: slots()[1].classList.contains("is-open") && !$(".m02-dfp__open", slots()[1]).hidden, + !cards()[0].classList.contains("is-collapsed") && + shown($(".m02-dfp__open", cards()[0])) && + !$(".m02-dfp", cards()[0]).classList.contains("is-folded"), + source: !cards()[1].classList.contains("is-collapsed") && shown($(".m01lab-src", cards()[1])), pref: localStorage.getItem(PREF), + caret: heads().map((h) => getComputedStyle(h, "::after").content.replace(/"/g, "")), width: Math.round($(".m01lab-flow__right").getBoundingClientRect().width), }); - const flowToggle = () => $(".m02-dfp__toggle", slots()[0]); - const srcToggle = () => $(".m02-dfp__toggle", slots()[1]); + const flowToggle = () => heads()[0]; + const srcToggle = () => heads()[1]; + out.titles = heads().map((h) => h.innerText.trim()); + out.dfpToggle = $$(".m01lab-side .m02-dfp__toggle").filter(shown).length; // 처음 = 흐름도 열림으로 맞춤 if (!state().flow) { if (state().source) srcToggle().click(); From 9b4b56edc64907584ddbd7b0067c7bd9c298b6d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 12:26:29 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=ED=9A=A1=EB=8B=A8=20=ED=8C=90?= =?UTF-8?q?=20=ED=81=AC=EA=B8=B0=20=EB=B0=94=EB=80=9C=20=EB=95=8C=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=A7=80=EC=96=B4=20=EB=B0=B0=EC=9C=A8=20?= =?UTF-8?q?1=20=C2=B7=20=EA=B8=80=EC=9E=90=2011px=20=EB=B3=B5=EA=B7=80=20(?= =?UTF-8?q?44-2=20=EB=8D=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판이 접힌 · 좁은 때 지은 viewBox 가 펼쳐질 때 통째로 늘어나 글자가 커 보이던 길 없앰 - ResizeObserver 로 판 크기가 바뀌면 실제 크기로 다시 지음 · 확대 · 이동 상태 유지 - 최소치(240×150)는 판이 0 일 때만 · 범례 · 좌우 표고 글 11px - 44-2 시나리오 — 접힘 · 좁음 · 넓음 · 줄임 네 갈래 배율 · 글자 높이 잼 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 61 ++++--- .../B05_Profile_UI_Style_RouteEdit.css | 2 +- resources/tester/ui_checks/44-2_횡단_글자.js | 151 ++++++++++++++---- 3 files changed, 158 insertions(+), 56 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index def15d290..1c6a3648c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -16,7 +16,7 @@ * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * ========================================================================== */ -import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan"; +import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; import { buildCrossSvg, @@ -97,10 +97,47 @@ function createPane(title: string, empty: string): CrossPane { const station = root.querySelector(".b05-routeedit__cross-station")!; const foot = root.querySelector(".b05-routeedit__cross-foot")!; const box = root.querySelector(".b05-routeedit__cross-box")!; + /** 그려 둔 횡단 · 그때 판 크기 · 확대·이동 — 판 크기가 바뀌면 이것으로 다시 짓는다. */ + let drawn: CrossPreviewResponse | null = null; + let size = { width: 0, height: 0 }; + let zoom: ZoomPanState | undefined; + function draw(preview: CrossPreviewResponse): void { + // 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고 + // 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고, + // 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다. + const rect = box.getBoundingClientRect(); + size = { width: Math.round(rect.width), height: Math.round(rect.height) }; + const plot = buildCrossSvg(preview, size.width || 240, size.height || 150); + box.replaceChildren(plot.svg); + observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다. + // 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③). + // 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다). + attachSvgZoomPan({ + svg: plot.svg, + layer: plot.layer, + widthPx: plot.width, + heightPx: plot.height, + pad: CROSS_PLOT_PAD, + content: plot.content, + wheelZoom: true, + initial: zoom, + onChange: (state) => (zoom = state), + }); + } + const observer = new ResizeObserver(() => { + if (!box.isConnected) return observer.disconnect(); // 편집 창이 닫혔다. + const rect = box.getBoundingClientRect(); + const width = Math.round(rect.width); + const height = Math.round(rect.height); + if (!drawn || width === 0 || (width === size.width && height === size.height)) return; + draw(drawn); + }); return { root, show(preview, intervalM) { box.replaceChildren(); + drawn = preview; + zoom = undefined; if (!preview) { station.textContent = ""; foot.textContent = empty; @@ -109,30 +146,12 @@ function createPane(title: string, empty: string): CrossPane { // 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물 // 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다. station.textContent = formatStation(preview.chainage_m, intervalM); - // 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다. - // 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다. - const rect = box.getBoundingClientRect(); - const plot = buildCrossSvg( - preview, - Math.max(Math.round(rect.width), 240), - Math.max(Math.round(rect.height), 150), - ); - box.append(plot.svg); - // 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③). - // 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다). - attachSvgZoomPan({ - svg: plot.svg, - layer: plot.layer, - widthPx: plot.width, - heightPx: plot.height, - pad: CROSS_PLOT_PAD, - content: plot.content, - wheelZoom: true, - }); + draw(preview); foot.textContent = summarizeCross(preview); }, wait(text) { box.replaceChildren(); + drawn = null; foot.textContent = text; }, }; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index a7ebab158..506fa6eb1 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -235,7 +235,7 @@ .b05-routeedit__cross-key { fill: var(--color-text-secondary, #94a3b8); - font-size: 5.5px; /* 44-2 — 지금(11px)의 절반 */ + font-size: 11px; } .b05-routeedit__cross-key.is-design { diff --git a/resources/tester/ui_checks/44-2_횡단_글자.js b/resources/tester/ui_checks/44-2_횡단_글자.js index d5d0f35fc..0e69f47d7 100644 --- a/resources/tester/ui_checks/44-2_횡단_글자.js +++ b/resources/tester/ui_checks/44-2_횡단_글자.js @@ -1,11 +1,16 @@ -/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기 +/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기 · 판 크기 바뀜에도 배율 1 * 시작 화면: B05 종단설계(#/b05-profile) · 환경 검증 프로젝트(OWNERS.md) · 로그인됨 · 계획노선 편집 닫힘 + * · 창 너비 1500px 이상(그 아래면 오른쪽 칸이 접힘) * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) - * 하는 일: [계획노선 편집] 열기 → 첫 측점 라벨 누름(앱 모듈의 라벨 상자 자리) → 횡단 판 글자 잼 → [취소]로 닫음 - * 기대 수치: - * - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 5.5px(옛 11px 의 절반) + * 하는 일: [계획노선 편집] 열기 → 오른쪽 칸 크기를 세 갈래로 둔 채 측점 라벨 누름 → 칸을 원래대로 → 잼 + * folded = 칸 접힘(display none) 상태로 셈 → 펼침 · narrow = 260×300 으로 셈 → 원래 + * wide = 원래 크기로 셈 · shrink = wide 뒤 260×300 으로 줄임 → [취소]로 닫음 + * · 탭이 화면에 안 보여 프레임이 없으면(frames = false) ResizeObserver 대역(100ms 재기)을 깔고 돎 + * 기대 수치(갈래마다): + * - sx · sy = 화면 크기 ÷ viewBox = 1(±0.02 · 글자가 늘어나 보이지 않음) + * - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 11px · screenPx(11 × sy) 10~12 * - station.fontSize = title.fontSize(측점 표기 = 제목 「횡단」 크기) - * - 바꾸는 값 없음(취소로 닫음 · 노선 안 고침) + * - 바꾸는 값 없음(칸 크기 임시 style 지움 · 취소로 닫음 · 노선 안 고침) */ (async () => { const w = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -21,13 +26,50 @@ [...document.querySelectorAll("button")].find( (b) => b.offsetParent && b.textContent.trim() === text, ); + // 화면에 안 보이는 ORCA 탭은 프레임을 안 그려 ResizeObserver 가 안 옴 — 그때만 100ms 마다 + // 크기를 재어 같은 방식(처음 한 번 · 바뀔 때마다)으로 부르는 대역을 편집 창 열기 전에 깖 + const OrigRO = window.ResizeObserver; + const frames = await Promise.race([ + new Promise((r) => requestAnimationFrame(() => r(true))), + w(500).then(() => false), + ]); + if (!frames) + window.ResizeObserver = class { + constructor(cb) { + this.cb = cb; + this.t = new Map(); + this.iv = setInterval(() => { + for (const [el, last] of this.t) { + const r = el.getBoundingClientRect(); + const k = `${r.width}x${r.height}`; + if (k === last) continue; + this.t.set(el, k); + this.cb([{ target: el }], this); + } + }, 100); + } + observe(el) { + if (!this.t.has(el)) this.t.set(el, null); + } + unobserve(el) { + this.t.delete(el); + } + disconnect() { + this.t.clear(); + clearInterval(this.iv); + } + }; btn("계획노선 편집")?.click(); const canvas = await until(() => [...document.querySelectorAll("canvas")].find( (c) => c.offsetParent && c.closest("[class*='routeedit']"), ), ); + window.ResizeObserver = OrigRO; // 편집 창(횡단 판)은 다 지어짐 if (!canvas) return JSON.stringify({ ok: false, why: "편집 캔버스 없음" }); + const side = document.querySelector(".b05-routeedit__side"); + if (!side || !side.offsetParent) + return JSON.stringify({ ok: false, why: "오른쪽 칸 접힘", innerWidth }); // 앱이 실제로 부른 그 모듈 주소(@fs/<폴더>/…) — 같은 주소라야 라벨 상자 WeakMap 을 같이 씀 const url = performance .getEntriesByType("resource") @@ -39,34 +81,75 @@ return b.length ? b : null; }); if (!boxes) return JSON.stringify({ ok: false, why: "측점 라벨 상자 없음" }); - const rect = canvas.getBoundingClientRect(); - const box = boxes[Math.floor(boxes.length / 2)]; - const at = { clientX: rect.left + box.x, clientY: rect.top + box.y, button: 0, bubbles: true }; - canvas.dispatchEvent(new PointerEvent("pointerdown", at)); - canvas.dispatchEvent(new PointerEvent("pointerup", at)); - const pane = await until(() => - [...document.querySelectorAll(".b05-routeedit__cross")].find((p) => - p.querySelector(".b05-routeedit__cross-key"), - ), - ); - if (!pane) return JSON.stringify({ ok: false, why: "횡단 판 안 뜸", chainage: box.chainage }); - const fs = (el) => el && getComputedStyle(el).fontSize; - const keys = [...pane.querySelectorAll(".b05-routeedit__cross-key")].map((t) => ({ - text: t.textContent.slice(0, 20), - fontSize: fs(t), - h: +t.getBoundingClientRect().height.toFixed(1), - })); - const title = pane.querySelector(".b05-routeedit__cross-title"); - const station = pane.querySelector(".b05-routeedit__cross-station"); - const out = { - keys, - title: { text: title.textContent, fontSize: fs(title) }, - station: { text: station.textContent, fontSize: fs(station) }, + const pane = side.querySelector(".b05-routeedit__cross"); + const draw = async () => { + const old = pane.querySelector("svg"); + const rect = canvas.getBoundingClientRect(); + const box = boxes[Math.floor(boxes.length / 2)]; + const at = { clientX: rect.left + box.x, clientY: rect.top + box.y, button: 0, bubbles: true }; + canvas.dispatchEvent(new PointerEvent("pointerdown", at)); + canvas.dispatchEvent(new PointerEvent("pointerup", at)); + return until(() => { + const svg = pane.querySelector("svg"); + return svg && svg !== old && svg.querySelector(".b05-routeedit__cross-key"); + }); }; - out.ok = - keys.length === 3 && - keys.every((k) => k.fontSize === "5.5px") && - out.title.fontSize === out.station.fontSize; - btn("취소")?.click(); - return JSON.stringify(out); + const fs = (el) => el && getComputedStyle(el).fontSize; + const measure = (tag) => { + const svg = pane.querySelector("svg"); + const vb = svg.viewBox.baseVal; + const r = svg.getBoundingClientRect(); + const sx = +(r.width / vb.width).toFixed(3); + const sy = +(r.height / vb.height).toFixed(3); + const keys = [...pane.querySelectorAll(".b05-routeedit__cross-key")].map((t) => ({ + text: t.textContent.slice(0, 12), + fontSize: fs(t), + screenPx: +(parseFloat(fs(t)) * sy).toFixed(1), + h: +t.getBoundingClientRect().height.toFixed(1), + })); + const title = pane.querySelector(".b05-routeedit__cross-title"); + const station = pane.querySelector(".b05-routeedit__cross-station"); + const res = { + tag, + viewBox: [vb.width, vb.height], + box: [Math.round(r.width), Math.round(r.height)], + sx, + sy, + keys, + title: fs(title), + station: fs(station), + }; + res.ok = + Math.abs(sx - 1) <= 0.02 && + Math.abs(sy - 1) <= 0.02 && + keys.length === 3 && + keys.every((k) => k.fontSize === "11px" && k.screenPx >= 10 && k.screenPx <= 12) && + res.title === res.station; + return res; + }; + const narrow = () => Object.assign(side.style, { width: "260px", height: "300px" }); + const reset = () => Object.assign(side.style, { display: "", width: "", height: "" }); + const out = []; + try { + side.style.display = "none"; + if (!(await draw())) return JSON.stringify({ ok: false, why: "접힘 · 횡단 안 뜸" }); + reset(); + await w(600); + out.push(measure("folded")); + narrow(); + if (!(await draw())) return JSON.stringify({ ok: false, why: "좁음 · 횡단 안 뜸" }); + reset(); + await w(600); + out.push(measure("narrow")); + if (!(await draw())) return JSON.stringify({ ok: false, why: "넓음 · 횡단 안 뜸" }); + await w(300); + out.push(measure("wide")); + narrow(); + await w(600); + out.push(measure("shrink")); + } finally { + reset(); + btn("취소")?.click(); + } + return JSON.stringify({ ok: out.every((o) => o.ok), innerWidth, frames, out }); })(); From b94259f3b5fd599306a0b3f18298babc387a1bf7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 12:26:41 +0900 Subject: [PATCH 3/4] auto: 2026-10-05 12:26 (ESD_LAPTOP) --- 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 eec77747b..305ec2bf6 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -29,3 +29,9 @@ - CAD 프로그램 : 한글입력 안됨. 계획서를 작성하고 코드작업을 진행해줘. 난 이제 자리를 비워야하니까 참고해. + +## 추가작업 (10-5 · 원문 그대로) + +1. M02 페이지의 전체영역에서 알약 템플릿으로 프론트엔드에 사용중인데 버튼들은 기본 버튼 템플릿을 사용하게 변경해서 알약 템플릿은 알약에만 사용하는게 맞아. +2. M02 페이지의 좌측패널 내부 별도의 서브 컨테이너로 구조물도면, 일위대가, 면적산출 구성되어 있는데 이건 필요없어 보임 접근은 구조물 조합을 선택하면 바로 3개의 항목 연결이 보이니 불필요해보여. +3. 구조물 조합 상세 페이지의 구조물 분류 테이블에 일위대가 도면 면적산출 행에 키값들을 노출하고 있는데 이건 필요없어보여. From 966669bb3489915de04b416110f71062e8fcb5b2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 12:28:04 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(M02):=2045-3=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EB=B6=84=EB=A5=98=20=ED=91=9C?= =?UTF-8?q?=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=ED=82=A4=20=EC=A4=84=20=EC=88=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표 부품 combo 일 때 마스터 번호 줄 안 그림(보기만 · 장부 · 저장 문서 그대로) - 번호 글 폭 맞춤(fitMeta) · 전용 CSS 없앰 · 방향키는 머리 줄만 - 시험 test_m02_combo_table 지금 코드에 맞춤 · 시나리오 ui_checks/45-3_분류표_키줄.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- resources/tester/test_m02_combo_table.py | 13 ++-- .../tester/ui_checks/45-3_분류표_키줄.js | 72 +++++++++++++++++++ ui_template/sheet/ui_template_sheet.css | 4 -- ui_template/sheet/ui_template_sheet.ts | 3 +- ui_template/sheet/ui_template_sheet_render.ts | 18 +---- 5 files changed, 86 insertions(+), 24 deletions(-) create mode 100644 resources/tester/ui_checks/45-3_분류표_키줄.js diff --git a/resources/tester/test_m02_combo_table.py b/resources/tester/test_m02_combo_table.py index 11619815b..f6d74b82b 100644 --- a/resources/tester/test_m02_combo_table.py +++ b/resources/tester/test_m02_combo_table.py @@ -64,17 +64,22 @@ def test_표_부품_combo_선택(): assert "combo?: { onHeadEdit?: (colId: string, level: number, name: string) => void };" in sheet assert '...(ro || opts.combo ? [] : [el("p", { className: "ui-sheet__hint"' in sheet assert ( - "filter((k) => !opts.combo || k !== KEY_UNIT)" in sheet - ) # 방향키가 숨은 단위 줄을 안 밟음 + "const keys = opts.combo ? [KEY_UNIT] : navRows(state.doc, state.mode);" in sheet + ) # 방향키가 숨은 단위 · 번호 줄을 안 밟음 assert 'head?.classList.add("is-head-picked")' in sheet assert "if (th && opts.combo?.onHeadEdit) editComboHead(th);" in sheet assert "if (opts.combo && sel.r === KEY_UNIT)" in sheet # F2 · Enter = 머리 이름 고침 render = _text(SHEET / "ui_template_sheet_render.ts") assert "children: state.combo ? rows : [...rows, unit]" in render # 단위 줄 없음 - assert "if (state.combo) fitMeta(state, cols, mins);" in render + # 45-3 일위대가 · 도면 · 면적산출 키 줄 안 보임(보기만) + assert ( + '...(mode === "master" && p === 0 && !state.combo ? MASTER_ROWS.map(masterRow) : [])' + in render + ) + assert "fitMeta" not in render assert "열너비: {}" in render # 저장 폭 안 씀 — 자동 css = _text(SHEET / "ui_template_sheet.css") - assert ".ui-sheet--combo .ui-sheet__meta td" in css and "th.is-head-picked" in css + assert ".ui-sheet--combo .ui-sheet__meta td" not in css and "th.is-head-picked" in css def test_머리_고침은_쓸_수_있는_층만_나무로(): diff --git a/resources/tester/ui_checks/45-3_분류표_키줄.js b/resources/tester/ui_checks/45-3_분류표_키줄.js new file mode 100644 index 000000000..a62c5f083 --- /dev/null +++ b/resources/tester/ui_checks/45-3_분류표_키줄.js @@ -0,0 +1,72 @@ +/* 45-3 — 구조물 조합 상세 분류 표: 일위대가 · 도면 · 면적산출 키 줄 안 보임 · [저장] 뒤 장부 번호 그대로 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글 · 20초 안) + * 쓰는 것: 표 [저장] 한 번(고침 없음) — 끝나면 resources/master_template 사본으로 되씀 + * 기대 수치: + * - metaRows = 0 · refRowNames = 0(줄 머리 「일위대가 · 도면 · 면적산출」) · refCells = 0(data-r s:price|drawing|area) + * - headPick = true(머리 칸 누르면 열 고름 · 카드 셋) · keyMove = true(방향키로 옆 열) + * - book.same = true(저장 앞뒤 장부 줄의 키 · 열 · 도번 · 면적번 같음) · book.laborAfter = rows(일위대가번 모두 있음) + * (git 장부가 옛 꼴(재료번)이면 저장 때 서버 맞춤이 일위대가번으로 바꿈 — 보기와 무관) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await w(150); + } + return null; + }; + const vis = (b) => b && !b.hidden && b.offsetParent !== null; + const sheet = await until(() => document.querySelector(".ui-sheet--combo")); + if (!sheet) return JSON.stringify({ error: "구조물 조합 표 없음" }); + const out = {}; + const gutters = () => + [...sheet.querySelectorAll(".ui-sheet__gutter")].map((g) => g.textContent.trim()); + out.metaRows = sheet.querySelectorAll(".ui-sheet__meta").length; + out.refRowNames = gutters().filter((t) => ["일위대가", "도면", "면적산출"].includes(t)).length; + out.refCells = sheet.querySelectorAll( + '[data-r="s:price"],[data-r="s:drawing"],[data-r="s:area"]', + ).length; + out.gutters = gutters(); + + // 머리 칸 누르면 열 고름(카드) · 방향키 = 옆 열 + const heads = [...sheet.querySelectorAll("thead th[data-col]")].filter(vis); + const deepest = Math.max(...heads.map((h) => Number(h.dataset.level))); + const leaves = heads.filter((h) => Number(h.dataset.level) === deepest); + leaves[0].click(); + await w(400); + out.headPick = !!sheet.querySelector("th.is-head-picked"); + const first = sheet.querySelector("th.is-head-picked")?.dataset.col; + sheet.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + await w(300); + const second = sheet.querySelector("th.is-head-picked")?.dataset.col; + out.keyMove = !!first && !!second && first !== second; + + // [저장] 앞뒤 장부(구조물집계표조합) 번호 + const book = () => + fetch("/api/m02/layers/system/structure-combos", { credentials: "include" }) + .then((r) => r.json()) + .then((b) => + (b.줄 ?? []).map((r) => ({ + id: JSON.stringify([r.키, r.열, r.도번, r.면적번]), + su: r.일위대가번, + })), + ); + const before = await book(); + const save = [...document.querySelectorAll("button")].find( + (b) => vis(b) && b.innerText.trim() === "저장", + ); + save.click(); + await w(2500); + const after = await book(); + out.book = { + rows: before.length, + same: JSON.stringify(before.map((r) => r.id)) === JSON.stringify(after.map((r) => r.id)), + laborAfter: after.filter((r) => r.su).length, + sample: after[0], + }; + out.metaAfterSave = document.querySelectorAll(".ui-sheet--combo .ui-sheet__meta").length; + return JSON.stringify(out); +})(); diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index aa97203d9..6f2beb712 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -141,10 +141,6 @@ outline-offset: -2px; } -.ui-sheet--combo .ui-sheet__meta td { - white-space: nowrap; -} - .ui-sheet__table tr.is-fixed td { border-bottom: 2px solid var(--color-border); } diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index b8f38ebe6..418dcb355 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -222,7 +222,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio opts.onSelect?.(selectionOf(state.doc, r, c)); }; const move = (dr: number, dc: number): void => { - const keys = navRows(state.doc, state.mode).filter((k) => !opts.combo || k !== KEY_UNIT); + // 구조물 조합 = 머리만(단위 · 번호 줄 없음) — 방향키는 열만 옮김(머리 = 단위 줄 자리) + const keys = opts.combo ? [KEY_UNIT] : navRows(state.doc, state.mode); const cols = state.doc.열; if (!state.sel) { if (keys.length && cols.length) select(keys[0], cols[0].id, true); diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index e9992bbf7..8ef33269c 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -47,7 +47,7 @@ export interface RenderState { pick?: boolean; /** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */ headLocked?: boolean; - /** 구조물 조합 표(`combo`) — 단위 줄 없음 · 열 폭 = 머리 · 번호 글이 한 줄에 들게(저장 폭 안 씀) · + /** 구조물 조합 표(`combo`) — 단위 줄 · 일위대가 · 도면 · 면적산출 줄 없음 · 열 폭 = 머리(저장 폭 안 씀) · * `headEditable` = 머리 이름 칸을 고칠 수 있음(나무 이름 바꾸기로). */ combo?: boolean; headEditable?: boolean; @@ -68,18 +68,6 @@ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map) mins.get(col.id) ?? 0, ); -/** 구조물 조합 — 일위대가 · 도면 · 면적산출 줄 글(키 · 번호)이 줄바꿈 없이 들게 최소 폭을 넓힘 - * (`.ui-sheet__meta td` 글꼴과 같게 잼). */ -function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map): void { - const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; - const font = `400 ${rem * 0.76}px ${getComputedStyle(document.body).fontFamily}`; - for (const col of cols) { - const texts = REF_ROWS.map((key) => refText(state, key, col.id)); - const need = Math.ceil(Math.max(...texts.map((t) => textWidth(t, font)))) + 14; - mins.set(col.id, Math.max(mins.get(col.id) ?? 0, need)); - } -} - /** 머리 칸 글꼴 — 폭 재기용(`.ui-sheet__table thead` 와 같게). */ function headFont(): string { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; @@ -113,7 +101,6 @@ export function renderSheet(state: RenderState): HTMLElement { const names = columnNames(cols); const font = headFont(); const mins = headMinWidths(cols, depth, font); - if (state.combo) fitMeta(state, cols, mins); const widthDoc = state.combo ? { ...doc, 보기: { ...doc.보기, 열너비: {} } } : doc; // 줄 머리 칸 폭 — 층 이름 · 단위 · 일위대가 · 도면 · 면적산출 · 합계 이름이 안 잘리게 const GUTTER = Math.min( @@ -275,7 +262,8 @@ ${formulaLabel(formula, names)}` for (const [p, page] of plan.entries()) { const body = el("tbody", { children: [ - ...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []), + // 구조물 조합 = 일위대가 · 도면 · 면적산출 키 줄 안 보임(보기만 · 번호는 장부 · 카드에) + ...(mode === "master" && p === 0 && !state.combo ? MASTER_ROWS.map(masterRow) : []), ...page.rows.map((row, i) => dataRow(row, page.first + i)), ...(page.totals ? totalRows() : []), ],