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 }); })();