diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 79165830a..6a233b179 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -2,7 +2,8 @@ * B06_Section_UI_Cross_Detail.ts * 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4). * - * 위 = 카드 그림 복제(어두운 바탕 · CAD 꼴 · 남은 높이를 채움) · + * 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 · + * 칸 크기가 바뀌면 다시 그림) · * 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 · * B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) · * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름). @@ -35,6 +36,8 @@ export interface CrossDetailHost { projectId: string; routeId: () => number | null; detail: () => SectionDetailResponse | null; + /** 칸 크기(W × H px)로 새로 그린 카드 svg(173-4 · 뷰 `detailChart`). */ + chart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null; } let host: CrossDetailHost | null = null; @@ -127,9 +130,8 @@ export function attachDetailZoom( const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number); const layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); layer.append(...clone.childNodes); + // 칸 = viewBox(칸 크기로 그림 · 173-4) → 커서 고정 · 팬 속도 정확 clone.append(layer); - // 칸 비율 = viewBox 비율 → 커서 고정 · 팬 속도 정확(공용 조각이 칸 크기로 환산) - clone.style.setProperty("--ratio", String(width / height)); return attachSvgZoomPan({ svg: clone, layer, @@ -153,13 +155,33 @@ interface OpenDetail { paint: () => void; loadTable: () => void; close: () => void; + stop: () => void; } let current: OpenDetail | null = null; let tableTimer = 0; +/** 모달 그림을 그리는 동안 — 카드 그리기가 부르는 `refreshCrossDetail` 이 다시 그리기를 걸지 않게. */ +let drawing = false; -const cardOf = (section: CrossSection): HTMLElement | null => - document.getElementById(`cross-${section.station_id}`); +/** 칸 크기로 새로 그림 → 복제(듣개 없음) · clip id 를 카드와 가름(같은 id 면 카드 clip 을 씀). */ +function drawChart(section: CrossSection, widthPx: number, heightPx: number): SVGSVGElement | null { + drawing = true; + try { + const svg = host?.chart(section.station_id, widthPx, heightPx); + if (!svg) return null; + const clone = svg.cloneNode(true) as SVGSVGElement; + clone.querySelectorAll("clipPath[id]").forEach((clip) => { + const id = clip.id; + clip.id = `b06-detail-${id}`; + clone + .querySelectorAll(`[clip-path="url(#${id})"]`) + .forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`)); + }); + return clone; + } finally { + drawing = false; + } +} function titleOf(section: CrossSection): string { const stations = host?.detail()?.longitudinal.stations ?? []; @@ -209,18 +231,24 @@ export function openCrossDetail(section: CrossSection): void { showPicked(); }); - // 모달 배율 — 다시 떠도(재복제) 이어 감 + // 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4) let zoom: ZoomPanState | undefined; + let drawnSize = ""; + const resize = new ResizeObserver(() => state.paint()); + resize.observe(cad); const state: OpenDetail = { stationId: section.station_id, section, paint() { - const card = cardOf(state.section); - const svg = card?.querySelector("svg.b06-section__chart"); - if (!svg) cad.replaceChildren(document.createTextNode("그림 없음")); + const { clientWidth: width, clientHeight: height } = cad; + if (width < 1 || height < 1) return; + const size = `${width}x${height}`; + if (size !== drawnSize) zoom = undefined; + drawnSize = size; + const clone = drawChart(state.section, width, height); + if (!clone) cad.replaceChildren(document.createTextNode("그림 없음")); else { - const clone = svg.cloneNode(true) as SVGSVGElement; const handle = attachDetailZoom(clone, zoom, (next) => (zoom = next)); cad.replaceChildren(clone, buildZoomControls(handle)); } @@ -256,6 +284,7 @@ export function openCrossDetail(section: CrossSection): void { forget(); close(); }, + stop: () => resize.disconnect(), }; current = state; tableBox.textContent = "수량표 불러오는 중…"; @@ -273,6 +302,7 @@ export function openCrossDetail(section: CrossSection): void { /** 열린 모달 기억을 지움(닫힐 때). */ function forget(): void { window.clearTimeout(tableTimer); + current?.stop(); current = null; } @@ -282,7 +312,7 @@ function forget(): void { */ export function refreshCrossDetail(section: CrossSection): void { const open = current; - if (!open || open.stationId !== section.station_id) return; + if (drawing || !open || open.stationId !== section.station_id) return; open.section = section; requestAnimationFrame(() => current === open && open.paint()); window.clearTimeout(tableTimer); diff --git a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts index d965aace4..be9b7967a 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts @@ -77,6 +77,24 @@ export function naturalHeightAtWidth( return (base.naturalHeight - fixed) * (inner(toWidthPx) / inner(fromWidthPx)) + fixed; } +/** + * 칸(카드 폭 W 로 잰 기하 × 높이 H px) 가득 그리기(173-4 [상세]) — 자연 높이가 H 보다 크면 가로 범위를 좌우 같이 넓혀 + * m당 px 를 줄임(넓힌 자리 = 샘플 밖 빈 자리). 작으면 그대로(`crossPlotFromBase` 가 세로를 늘림). + */ +export function fitPlotBase(base: CrossPlotBase, heightPx: number): CrossPlotBase { + const fixed = CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX; + if (base.naturalHeight <= heightPx || heightPx <= fixed) return base; + const k = (base.naturalHeight - fixed) / (heightPx - fixed); + const grow = ((base.maxOffset - base.minOffset) * (k - 1)) / 2; + return { + ...base, + minOffset: base.minOffset - grow, + maxOffset: base.maxOffset + grow, + pixelsPerMeter: base.pixelsPerMeter / k, + naturalHeight: heightPx, + }; +} + /** 재 둔 기하에 행 높이만 얹는다 — 다시 계산하지 않는다. */ export function crossPlotFromBase(base: CrossPlotBase, forcedHeightPx?: number): CrossPlotMetrics { const heightPx = forcedHeightPx ?? Math.max(base.naturalHeight, CROSS_HEIGHT); diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 582edd515..12be73986 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -43,8 +43,14 @@ export interface DesignSyncController { export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { const { projectId } = ctx; - // 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세 - if (projectId) setCrossDetailHost({ projectId, routeId: ctx.routeId, detail: ctx.detail }); + // 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세 · 칸 크기 그림(173-4) + if (projectId) + setCrossDetailHost({ + projectId, + routeId: ctx.routeId, + detail: ctx.detail, + chart: (stationId, widthPx, heightPx) => ctx.view().detailChart(stationId, widthPx, heightPx), + }); /** * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 9434bb10d..b4f916667 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -38,8 +38,10 @@ import { type CrossPlotBase, crossPlotBase, effectiveCardHalfWidth, + fitPlotBase, naturalHeightAtWidth, } from "./B06_Section_UI_Cross_View_Metrics"; +import { cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; import { CROSS_GRID_GAP, CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; @@ -347,6 +349,7 @@ export function createSectionView( section: CrossSection, forcedHeightPx?: number, plotBase?: CrossPlotBase | null, + widthPx = cachedCardWidth, ): HTMLElement => { const card = createCrossSectionCard( section, @@ -355,7 +358,7 @@ export function createSectionView( (stationId) => selectStation(stationId, false), cachedStationInterval, currentCrossHalfWidth, - cachedCardWidth, + widthPx, forcedHeightPx, currentDetail ? cardDesignElevation(currentDetail, section) : undefined, onDesignChange, @@ -430,6 +433,7 @@ export function createSectionView( // CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제 // 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다. grid.style.gridTemplateColumns = `repeat(${columnCount}, ${cachedCardWidth}px)`; + grid.dataset.columns = String(columnCount); // 4열부터 면적표 「계」만(173-3 · CSS) cachedRowHeight.clear(); if (detail.cross_sections.length) { // 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로. @@ -543,6 +547,24 @@ export function createSectionView( }, refreshCard, refreshCards, + detailChart(stationId, widthPx, heightPx) { + const detail = currentDetail; + const section = detail?.cross_sections.find((entry) => entry.station_id === stationId); + if (!detail || !section) return null; + const base = crossPlotBase( + section, + currentExaggeration, + widthPx, + effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth), + designElevationAt(detail.longitudinal.design_profiles, section.chainage_m), + ); + // 카드 줌 · 팬은 카드 몫 — 모달 그림은 1배에서 시작(카드 상태는 되돌려 둠) + const cardZoom = cardZoomStates.get(stationId); + cardZoomStates.delete(stationId); + const card = buildCrossCard(section, heightPx, base && fitPlotBase(base, heightPx), widthPx); + if (cardZoom) cardZoomStates.set(stationId, cardZoom); + return card.querySelector("svg.b06-section__chart"); + }, focusStation(stationId) { if (stationId === null) clearSelection(); else if (selectedStationId !== stationId) selectStation(stationId, true); diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index fe0dc87f1..1db878a43 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -37,6 +37,8 @@ export interface SectionViewController { refreshCard: (chainageM: number) => void; /** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */ refreshCards: (chainages: ReadonlyArray) => void; + /** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null). */ + detailChart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null; /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 62088f15c..0b21a02b5 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -10,7 +10,14 @@ /* 절·성토 면적 오버레이(E-4) — 그래프 **좌측 상단**(축선 바로 안쪽), 배경색으로 그래프 선과 겹쳐도 가독. 절토/성토 × EA·RR·BR·계 표라서 칩을 늘어놓을 때보다 가로 폭을 덜 먹는다. 가운데(left:50%)에 두던 것을 좌측으로만 옮겼다 — 세로 자리는 그대로(2026-08-23 사용자 지시). - 58px = CROSS_PAD.left(Y축 눈금 띠 폭) — 표가 플롯 왼쪽 끝에 맞춰 선다. */ + 58px = CROSS_PAD.left(Y축 눈금 띠 폭) — 표가 플롯 왼쪽 끝에 맞춰 선다. + 글자 = 그림 칸(컨테이너) 폭 비례(173-3 · 3열 ≈ 0.72rem) · 표 높이 변수도 같은 식 — + 조정창 · 보라 치수가 표 아래에서 시작(`_Cross_Controls`). */ +.b06-cross-card__chart-wrap { + --b06-areas-font: clamp(0.6rem, 0.55rem + 0.7cqw, 0.9rem); + --b06-areas-height: calc(var(--b06-areas-font) * 5); +} + .b06-cross-card__areas { position: absolute; top: var(--spacing-8); @@ -19,7 +26,7 @@ padding: 2px 6px; border-radius: var(--radius-inputs); background: color-mix(in srgb, var(--color-surface) 88%, transparent); - font-size: 0.72rem; + font-size: var(--b06-areas-font); font-family: var(--font-mono); white-space: nowrap; border-collapse: collapse; @@ -28,11 +35,18 @@ .b06-cross-card__areas th, .b06-cross-card__areas td { - padding: 0 5px; + padding: 0 0.45em; text-align: center; font-weight: var(--font-weight-regular); } +/* 4열부터 「계」만(173-3) — EA · RR · BR 칸(머리 · 본문 2~4째)을 접음 · 열 수 = 그리드 data-columns */ +.b06-section__grid:is([data-columns="4"], [data-columns="5"], [data-columns="6"]) + .b06-cross-card__areas + :is(th, td):nth-child(n + 2):nth-child(-n + 4) { + display: none; +} + /* 열 제목은 가운데 맞춤 — 값은 우측 정렬이라 자릿수가 흔들려도 머리글은 열 중앙에 선다. */ .b06-cross-card__areas thead th { text-align: center; @@ -432,7 +446,7 @@ .b06-design__rockb-readout { padding: 0 var(--spacing-8); - font-size: 0.72rem; + font-size: var(--b06-areas-font); font-family: var(--font-mono); color: var(--color-warning); align-self: center; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index 4dd3b21d7..a50607922 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -30,41 +30,22 @@ flex: none; } -/* 가벼운 CAD 꼴 — 어두운 바탕 · 카드 그림 복제에 다크 색 토큰을 덮어씀 */ +/* 그림 칸 = 테마 색(173-4 · 카드와 같은 바탕) · 그림은 칸 크기로 새로 그려 가득 */ .b06-detail__cad { - --color-bg: #111111; - --color-surface: #111111; - --color-surface-raised: #1a1a1a; - --color-border: #3a3a3a; - --color-text: #f2f2f2; - --color-text-body: #e6e6e6; - --color-text-secondary: #b8b8b8; - --color-text-muted: #8b8b8b; - --color-primary: #c9a8ff; - --color-warning: #ffd34d; - --color-danger: #ff5f57; - --color-success: #4cd37b; border-radius: var(--radius-cards); - background: #111111; - color: #f2f2f2; + background: var(--color-surface-raised); + color: var(--color-text); overflow: hidden; flex: 1; min-height: 0; - /* [+ − ⤢] 우상단(164-1) · 그림은 가운데 */ + /* [+ − ⤢] 우상단(164-1) */ position: relative; - container-type: size; - display: flex; - align-items: center; - justify-content: center; } -/* 칸 비율 = viewBox 비율(--ratio · 164-1) — 칸 가득 · 여백 없이 커서 고정 줌이 정확 */ .b06-detail__cad > svg { display: block; - flex: none; - width: min(100cqw, 100cqh * var(--ratio, 2)); - height: auto; - aspect-ratio: var(--ratio, 2); + width: 100%; + height: 100%; } /* B07 횡단 수량 산출표 — 칸 배치 그대로 */ diff --git a/resources/tester/helper_173_detail_fit.cjs b/resources/tester/helper_173_detail_fit.cjs new file mode 100644 index 000000000..5d8c1710d --- /dev/null +++ b/resources/tester/helper_173_detail_fit.cjs @@ -0,0 +1,47 @@ +/* 173-4 [상세] 그림 칸 가득 — `B06_Section_UI_Cross_View_Metrics.ts` `fitPlotBase` 를 그 자리에서 트랜스파일해 Node 로 돌림. + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_173_areas_detail.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const B06 = path.join(__dirname, "..", "..", "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +// 카드 여백 · 바닥 높이는 원본에서 읽음(화면과 같은 값) +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +const CROSS_HEIGHT = Number(/export const CROSS_HEIGHT = (\d+)/.exec(common)[1]); +const stub = { + CROSS_PAD, + CROSS_HEIGHT, + validElevation: (s) => Number.isFinite(s.elevation_m), +}; +const js = ts.transpileModule( + fs.readFileSync(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts"), "utf8"), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }, +).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => stub); +const m = box.exports; + +// 반폭 10 m · 높이 차 8 m — 넓은 칸(1200 × 400)에선 자연 높이가 칸보다 큼 +const section = { + samples: [-10, -5, 0, 5, 10].map((offset_m, i) => ({ offset_m, elevation_m: 100 + i * 2 })), +}; +const base = m.crossPlotBase(section, 1, 1200, 10, 104); +const fit = m.fitPlotBase(base, 400); +const plot = m.crossPlotFromBase(fit, 400); +const tall = m.fitPlotBase(base, base.naturalHeight + 100); +const out = { + natural: base.naturalHeight, + fitHeight: fit.naturalHeight, + plotHeight: plot.heightPx, + // 도면 폭(px) 그대로 = m당 px × 가로 범위 + innerBefore: base.pixelsPerMeter * (base.maxOffset - base.minOffset), + innerAfter: fit.pixelsPerMeter * (fit.maxOffset - fit.minOffset), + // 좌우 같이 넓힘 — 가운데 그대로 + midBefore: (base.maxOffset + base.minOffset) / 2, + midAfter: (fit.maxOffset + fit.minOffset) / 2, + wider: fit.maxOffset - fit.minOffset > base.maxOffset - base.minOffset, + tallSame: tall === base, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_150_detail_pick.py b/resources/tester/test_150_detail_pick.py index 250ea5908..f9df29850 100644 --- a/resources/tester/test_150_detail_pick.py +++ b/resources/tester/test_150_detail_pick.py @@ -95,4 +95,4 @@ def test_상세_큰_창_공용_막_그림_글_표_순서(): assert "body.append(cad, note, tableBox)" in src assert "pointer-events: none" not in css and "b06-detail__backdrop" not in css assert ".ui-modal.b06-detail > .ui-modal__body {\n overflow: hidden;" in css - assert "flex: 1;\n min-height: 0;" in css and "aspect-ratio: var(--ratio, 2);" in css + assert "flex: 1;\n min-height: 0;" in css and "height: 100%;" in css # 173-4 칸 가득 diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index 668369ae7..04b48be77 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -42,7 +42,7 @@ def out() -> dict: def test_복제_자식을_g_하나로_감쌈_비율(out): assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임 - assert out["ratio"] == "2" # viewBox 600 × 300 + assert out.get("ratio") is None # 173-4 — 칸 = viewBox(칸 크기로 새로 그림) · 비율 변수 없음 assert out["before"] is None @@ -71,9 +71,10 @@ def test_모달_배선_단추_칸_비율(): assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src assert "cad.replaceChildren(clone, buildZoomControls(handle))" in src assert "attachDetailZoom(clone, zoom, (next) => (zoom = next))" in src - assert "position: relative;" in css and "container-type: size;" in css - assert "aspect-ratio: var(--ratio, 2);" in css - assert "width: min(100cqw, 100cqh * var(--ratio, 2));" in css + assert "position: relative;" in css + # 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득 + assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css + assert "--ratio" not in css and "--ratio" not in src def test_강조_붉은_한_벌_고름_hover_다름_빈칸(): diff --git a/resources/tester/test_173_areas_detail.py b/resources/tester/test_173_areas_detail.py new file mode 100644 index 000000000..215419903 --- /dev/null +++ b/resources/tester/test_173_areas_detail.py @@ -0,0 +1,122 @@ +"""PLAN 173-3 · 173-4 — B06 횡단 카드 면적표 열 연동 · [상세] 그림 테마 색 · 칸 가득. + +짝: `B06_Section/B06_Section_UI_Style_Cross_Areas.css`(글자 = 그림 칸 폭 · 4열부터 「계」만) · +`_UI_Section_View.ts`(그리드 data-columns · `detailChart`) · `_UI_Cross_Detail.ts`(칸 크기로 새로 그림) · +`_UI_Style_Cross_Detail.css`(테마 토큰) · `_UI_Cross_View_Metrics.ts` `fitPlotBase`(Node 헬퍼 +`helper_173_detail_fit.cjs` 가 실제 TS 를 돌림). +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +B06 = os.path.join(ROOT, "B06_Section") + + +def _read(name: str) -> str: + with open(os.path.join(B06, name), encoding="utf-8") as f: + return f.read().replace(chr(13), "") + + +@pytest.fixture(scope="module") +def fit() -> dict: + if not os.path.isdir(os.path.join(ROOT, "config", "node_modules")): + pytest.skip("프론트엔드 의존성 미설치") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_173_detail_fit.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 173-3 면적표 ───────────────────────────────────────────── + + +def test_면적표_글자_그림_칸_폭_비례_높이_변수_같은_식(): + css = _read("B06_Section_UI_Style_Cross_Areas.css") + wrap = css.split(".b06-cross-card__chart-wrap {", 1)[1].split("}", 1)[0] + assert "--b06-areas-font: clamp(0.6rem, 0.55rem + 0.7cqw, 0.9rem);" in wrap + assert "--b06-areas-height: calc(var(--b06-areas-font) * 5);" in wrap + table = css.split(".b06-cross-card__areas {", 1)[1].split("}", 1)[0] + assert "font-size: var(--b06-areas-font);" in table + assert "font-size: 0.72rem" not in css + + # 3열 그림 칸 ≈ 400 px → 지금 글자(0.72rem)와 거의 같음 · 1열 · 6열은 끝값 + def rem(width_px: float) -> float: # 1cqw = 칸 폭 1% · 1rem = 16 px + return min(max(0.6, 0.55 + 0.7 * width_px / 100 / 16), 0.9) + + assert rem(400) == pytest.approx(0.725) + assert rem(1232) == 0.9 and rem(192) == pytest.approx(0.634) + + +def test_면적표_4열부터_계만_접기(): + css = _read("B06_Section_UI_Style_Cross_Areas.css") + rule = re.search( + r'\.b06-section__grid:is\(\[data-columns="4"\], \[data-columns="5"\], \[data-columns="6"\]\)\s+' + r"\.b06-cross-card__areas\s+:is\(th, td\):nth-child\(n \+ 2\):nth-child\(-n \+ 4\) \{\n display: none;", + css, + ) + assert rule, "4~6열 EA · RR · BR 접기 규칙 없음" + src = _read("B06_Section_UI_Section_View.ts") + assert "grid.dataset.columns = String(columnCount);" in src + + +# ── 173-4 [상세] 그림 ──────────────────────────────────────── + + +def test_상세_테마_토큰_어두운_고정색_없음(): + css = _read("B06_Section_UI_Style_Cross_Detail.css") + cad = css.split(".b06-detail__cad {", 1)[1].split("}", 1)[0] + assert "background: var(--color-surface-raised);" in cad + assert "color: var(--color-text);" in cad + assert "--color-" not in cad.replace("var(--color-", "") # 토큰 덮어쓰기 없음 + assert "#111111" not in css and "#f2f2f2" not in css and "#1a1a1a" not in css + + +def test_상세_칸_크기로_새로_그림_배선(): + src = _read("B06_Section_UI_Cross_Detail.ts") + assert "const { clientWidth: width, clientHeight: height } = cad;" in src + assert "const clone = drawChart(state.section, width, height);" in src + assert "host?.chart(section.station_id, widthPx, heightPx)" in src + assert "new ResizeObserver(() => state.paint())" in src and "resize.disconnect()" in src + # 카드 clip id 와 가름 · 그리는 동안 refresh 되먹임 막음 + assert "clip.id = `b06-detail-${id}`;" in src + assert "if (drawing || !open || open.stationId !== section.station_id) return;" in src + assert "getElementById(`cross-" not in src # 카드 DOM 복제 지움 + view = _read("B06_Section_UI_Section_View.ts") + assert "detailChart(stationId, widthPx, heightPx) {" in view + assert "base && fitPlotBase(base, heightPx)" in view + assert "cardZoomStates.delete(stationId);" in view + types = _read("B06_Section_UI_Section_View_Types.ts") + assert "detailChart: (stationId: string, widthPx: number, heightPx: number) =>" in types + sync = _read("B06_Section_UI_Page_Design_Sync.ts") + assert "ctx.view().detailChart(stationId, widthPx, heightPx)" in sync + + +def test_칸_가득_넓은_칸은_가로_범위_넓힘(fit): + assert fit["natural"] > 400 + assert fit["fitHeight"] == pytest.approx(400) + assert fit["plotHeight"] == pytest.approx(400) + assert fit["innerAfter"] == pytest.approx(fit["innerBefore"]) # 도면 폭 그대로 + assert fit["wider"] is True + assert fit["midAfter"] == pytest.approx(fit["midBefore"]) # 좌우 같이 + + +def test_칸_가득_높은_칸은_그대로(fit): + assert fit["tallSame"] is True # crossPlotFromBase 가 세로 범위를 늘려 채움 + + +def test_뷰_파일_700줄_안(): + for name in ("B06_Section_UI_Section_View.ts", "B06_Section_UI_Cross_Detail.ts"): + assert len(_read(name).splitlines()) <= 700, name