From cb81e13b29a48afa21ce13f97159501227d6c384 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:53:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=95=8C=EB=A6=BC=20=EC=A4=84=20?= =?UTF-8?q?=EC=83=81=EB=8B=A8=20=EA=B3=A0=EC=A0=95=20=C2=B7=20=EC=97=B4=20?= =?UTF-8?q?=EC=88=98=20=EC=B9=B8=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=203=EC=97=B4=20=EB=B9=84=EC=9C=A8=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80(138-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 줄 sticky · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸(1~6 · 기본 3 · pref cross-columns) - 카드 폭 = (칸 폭 − 간격) ÷ 열 수 · 높이 = 폭 × 3열 비율 · pickCrossColumns 지움 - 카드 scroll-margin-top = 고정 줄 높이 · test_138_cross_columns Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- A00_Common/b_page_state.ts | 2 + .../B06_Section_UI_Cross_FillSlope_Notice.ts | 7 +- .../B06_Section_UI_Cross_View_Metrics.ts | 14 +++ B06_Section/B06_Section_UI_Section_Columns.ts | 68 +++++------ B06_Section/B06_Section_UI_Section_Common.ts | 5 - B06_Section/B06_Section_UI_Section_View.ts | 110 +++++++++++------- B06_Section/B06_Section_UI_Style_Cross.css | 38 +++++- resources/tester/helper_138_cross_columns.cjs | 25 ++++ resources/tester/test_138_cross_columns.py | 46 ++++++++ ui_template/ui_template_locale_b2.ts | 1 + 10 files changed, 229 insertions(+), 87 deletions(-) create mode 100644 resources/tester/helper_138_cross_columns.cjs create mode 100644 resources/tester/test_138_cross_columns.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index f2aa42d78..07dd518dc 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -102,6 +102,8 @@ export const STATE_REGISTRY = { scope: "global", legacy: () => "b06:profile-panel-collapsed", }, + /** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */ + "cross-columns": { bucket: "pref", scope: "global" }, "section-panel-height": { bucket: "pref", scope: "global", diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index 82f665e0f..ce8a899b3 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -5,6 +5,7 @@ * 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다. * ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③). * ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리). + * ⭐ 138-3 — 줄 전체가 카드 칸 위에 고정(sticky) · 알림 없어도 줄 유지(오른쪽 끝 열 수 칸은 부르는 쪽이 붙임). * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; @@ -60,11 +61,15 @@ function noticeBlock(className: string): { export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { const root = document.createElement("div"); + root.className = "b06-section__notice-bar"; + const blocks = document.createElement("div"); + blocks.className = "b06-section__notice-blocks"; + root.append(blocks); const warn = noticeBlock("b06-section__notice"); const info = noticeBlock("b06-section__notice b06-section__notice--info"); const pave = noticeBlock("b06-section__notice"); pave.root.title = L("B06_Design_Paved_Suggested"); - root.append(warn.root, pave.root, info.root); + blocks.append(warn.root, pave.root, info.root); root.addEventListener("click", (event) => { const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; if (id) onPick(id); diff --git a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts index 43d315ecf..d965aace4 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts @@ -63,6 +63,20 @@ export function crossPlotBase( return { sourceSamples, minOffset, maxOffset, elevationMid, pixelsPerMeter, naturalHeight }; } +/** + * 같은 기하를 다른 카드 폭으로 그렸을 때 자연 높이(바닥 적용 전 · 다시 계산 안 함 · 138-3). + * 그림 몫은 폭(좌우 여백 뺀)에 비례 · 위아래 여백은 그대로. + */ +export function naturalHeightAtWidth( + base: CrossPlotBase, + fromWidthPx: number, + toWidthPx: number, +): number { + const fixed = CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX; + const inner = (width: number): number => Math.max(width - CROSS_PAD.left - CROSS_PAD.right, 1e-6); + return (base.naturalHeight - fixed) * (inner(toWidthPx) / inner(fromWidthPx)) + fixed; +} + /** 재 둔 기하에 행 높이만 얹는다 — 다시 계산하지 않는다. */ 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_Section_Columns.ts b/B06_Section/B06_Section_UI_Section_Columns.ts index 36653df8f..b1481fff0 100644 --- a/B06_Section/B06_Section_UI_Section_Columns.ts +++ b/B06_Section/B06_Section_UI_Section_Columns.ts @@ -1,47 +1,43 @@ /* ============================================================================= * B06_Section_UI_Section_Columns.ts - * 횡단 카드 한 줄 카드 수 고르기 — 보기 칸 높이에 첫 줄 + 둘째 줄 절반이 들어가는 - * 가장 적은 카드 수. 카드 폭은 최소~최대 폭 안(그림은 축척 그대로 · 폭만 바뀜). + * 횡단 카드 열 수 · 폭 · 높이(138-3) — 열 수 = 사용자 값(알림 줄 오른쪽 끝 · 1~6 · 기본 3). + * 카드 폭 = (칸 폭 − 간격) ÷ 열 수 · 그래프 높이 = 폭 × (3열 기준 높이 ÷ 3열 폭) — 열 수가 + * 바뀌어도 그림 비율 그대로(글자 크기는 그대로). + * import 없음 — 시험이 이 파일만 옮겨 돌림(`test_138_cross_columns.py`). * ========================================================================== */ -import { - CROSS_CARD_CHROME, - CROSS_GRID_GAP, - CROSS_GRID_MAX_WIDTH, - CROSS_GRID_MIN_WIDTH, -} from "./B06_Section_UI_Section_Common"; +export const CROSS_COLUMNS_MIN = 1; +export const CROSS_COLUMNS_MAX = 6; +export const CROSS_COLUMNS_DEFAULT = 3; +/** 높이 비율의 기준 열 수. */ +export const CROSS_COLUMNS_BASE = 3; -export interface CrossColumns { - columns: number; - cardWidth: number; +/** 저장값 → 열 수(정수 · 1~6 · 못 읽으면 기본 3). */ +export function clampCrossColumns(value: unknown): number { + const number = typeof value === "string" && value.trim() === "" ? NaN : Number(value); + if (!Number.isFinite(number)) return CROSS_COLUMNS_DEFAULT; + return Math.min(CROSS_COLUMNS_MAX, Math.max(CROSS_COLUMNS_MIN, Math.round(number))); } -const widthFor = (columns: number, viewWidth: number): number => - Math.min(CROSS_GRID_MAX_WIDTH, (viewWidth - (columns - 1) * CROSS_GRID_GAP) / columns); +/** 카드 폭(px) = (칸 폭 − 간격 × (열 수 − 1)) ÷ 열 수. */ +export function crossCardWidth(viewWidth: number, columns: number, gap: number): number { + return Math.max(0, (viewWidth - gap * (columns - 1)) / columns); +} /** - * `heightsFor(cardWidth, count)` — 그 폭일 때 앞 `count` 장의 카드 자연 높이. - * `viewHeight` 가 0 이하(숨김 · 아직 안 잰 칸)면 가장 큰 카드 쪽을 고른다. + * 카드마다 강제 높이(px) — `baseHeights[i]` = 그 카드를 3열 폭(`baseWidth`)으로 그렸을 때 높이. + * 지금 폭으로 비례해 늘리고 · 같은 줄(열 수 단위) 최댓값으로 맞춤. */ -export function pickCrossColumns( - viewWidth: number, - viewHeight: number, - heightsFor: (cardWidth: number, count: number) => number[], -): CrossColumns { - const unit = CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP; - const most = Math.max(1, Math.floor((viewWidth + CROSS_GRID_GAP) / unit)); - const fewest = Math.min( - most, - Math.max(1, Math.ceil((viewWidth + CROSS_GRID_GAP) / (CROSS_GRID_MAX_WIDTH + CROSS_GRID_GAP))), - ); - for (let columns = fewest; columns < most; columns += 1) { - const cardWidth = widthFor(columns, viewWidth); - if (viewHeight <= 0) return { columns, cardWidth }; - const heights = heightsFor(cardWidth, columns * 2); - const first = Math.max(0, ...heights.slice(0, columns)) + CROSS_CARD_CHROME; - const second = Math.max(0, ...heights.slice(columns)) + CROSS_CARD_CHROME; - const needed = heights.length > columns ? first + CROSS_GRID_GAP + second / 2 : first; - if (needed <= viewHeight) return { columns, cardWidth }; - } - return { columns: most, cardWidth: widthFor(most, viewWidth) }; +export function crossRowHeights( + baseHeights: readonly number[], + baseWidth: number, + cardWidth: number, + columns: number, +): number[] { + const scale = baseWidth > 0 ? cardWidth / baseWidth : 1; + const heights = baseHeights.map((height) => height * scale); + return heights.map((_, index) => { + const start = index - (index % columns); + return Math.max(...heights.slice(start, start + columns)); + }); } diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 79f63d0c1..bfda291de 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -35,11 +35,6 @@ export const LONG_WIDTH = 1200; export const LONG_HEIGHT = 220; export const CROSS_WIDTH = 560; export const CROSS_HEIGHT = 250; -// 카드 폭 범위 — 한 줄 카드 수는 보기 칸 높이로 정한다(`_Section_Columns`). -export const CROSS_GRID_MIN_WIDTH = 400; -export const CROSS_GRID_MAX_WIDTH = 800; -// 카드에서 그림 높이 밖 몫 — 머리 · 설계 줄 · 꼬리(실측 135). -export const CROSS_CARD_CHROME = 135; export const CROSS_GRID_GAP = 16; // bottom은 측점 라벨 한 줄 몫이다. X축 제목을 그래프 상단으로 올린 뒤 남던 여백을 걷어내, // 종단면도 아래 라벨이 그 밑 유토곡선과의 틈 **가운데**에 오도록 좁혔다(2026-08-02 사용자 지시). diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 301272a01..ded85862f 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -38,12 +38,22 @@ import { type CrossPlotBase, crossPlotBase, effectiveCardHalfWidth, + naturalHeightAtWidth, } from "./B06_Section_UI_Cross_View_Metrics"; -import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; +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"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; -import { pickCrossColumns } from "./B06_Section_UI_Section_Columns"; +import { + CROSS_COLUMNS_BASE, + CROSS_COLUMNS_MAX, + CROSS_COLUMNS_MIN, + clampCrossColumns, + crossCardWidth, + crossRowHeights, +} from "./B06_Section_UI_Section_Columns"; +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; +import { createInputField } from "@ui/ui_template_elements"; import { CROSS_WIDTH, type CrossDesignChange, @@ -93,9 +103,9 @@ export function createSectionView( let currentStationInterval: number | undefined; let structureEdit: SectionStructureEdit | null = null; let renderWidth = 0; - let renderHeight = 0; - let drawnColumns = 0; let resizeTimer = 0; + /** 한 줄 카드 수 — 사용자 값(보기 설정 pref · 138-3). */ + let columnCount = clampCrossColumns(readStateRaw("cross-columns")); // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) let cachedStationInterval = 1; let cachedCardWidth = CROSS_WIDTH; @@ -113,6 +123,29 @@ export function createSectionView( const fillSlopeNotice = createFillSlopeNotice((id) => selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true), ); + // 열 수 칸(138-3) — 고정 알림 줄 오른쪽 끝 · 1~6 · 바꾸면 카드 전부 다시 그림 + const columnsField = createInputField({ + type: "number", + compact: true, + min: CROSS_COLUMNS_MIN, + max: CROSS_COLUMNS_MAX, + value: String(columnCount), + }); + columnsField.root.classList.add("b06-section__columns"); + columnsField.input.title = L("B06_Cross_Columns"); + columnsField.input.addEventListener("change", () => { + const next = clampCrossColumns(columnsField.input.value); + columnsField.input.value = String(next); + if (next === columnCount) return; + columnCount = next; + writeStateRaw("cross-columns", String(next)); + draw(); + }); + fillSlopeNotice.root.append(columnsField.root); + // 카드 scroll-margin-top = 고정 줄 높이 — 고른 카드가 줄 밑에 숨지 않게 + new ResizeObserver(() => + cards.style.setProperty("--b06-notice-h", `${fillSlopeNotice.root.offsetHeight}px`), + ).observe(fillSlopeNotice.root); /** 종단도 한 벌 — 첫 그리기에서 횡단 보기 알림을 건다(`_Section_View_Profile`). */ const profile = createSectionProfile( @@ -138,14 +171,7 @@ export function createSectionView( return Number.isFinite(width) ? Math.max(0, width) : 0; }; - const contentHeight = (): number => { - const style = getComputedStyle(cards); - const pad = (value: string): number => Number.parseFloat(value) || 0; - const height = cards.clientHeight - pad(style.paddingTop) - pad(style.paddingBottom); - return Number.isFinite(height) ? Math.max(0, height) : 0; - }; - - /** 카드 폭에서 앞 `count` 장의 기하 — 한 줄 카드 수 고르기 · 그리기가 함께 쓴다. */ + /** 카드 폭에서 앞 `count` 장의 기하. */ const plotBasesFor = (detail: SectionDetailResponse, cardWidth: number, count: number) => detail.cross_sections .slice(0, count) @@ -158,11 +184,17 @@ export function createSectionView( designElevationAt(detail.longitudinal.design_profiles, section.chainage_m), ), ); - const naturalHeightsOf = (bases: ReadonlyArray): number[] => - bases.map((base) => Math.max(base?.naturalHeight ?? CROSS_HEIGHT, CROSS_HEIGHT)); - const pickColumns = (detail: SectionDetailResponse) => - pickCrossColumns(renderWidth, renderHeight, (cardWidth, count) => - naturalHeightsOf(plotBasesFor(detail, cardWidth, count)), + /** 3열 폭으로 그렸을 때 높이(바닥 250) — 다른 열 수도 이 비율을 지킴(138-3). */ + const baseHeightsOf = ( + bases: ReadonlyArray, + cardWidth: number, + baseWidth: number, + ): number[] => + bases.map((base) => + Math.max( + base ? naturalHeightAtWidth(base, cardWidth, baseWidth) : CROSS_HEIGHT, + CROSS_HEIGHT, + ), ); /** @@ -368,14 +400,11 @@ export function createSectionView( grid.addEventListener("click", (event) => { if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection(); }); - // 열 수 = 보기 칸 높이에 1.5줄이 드는 가장 적은 수 · 카드 폭 400~800(`_Section_Columns`). - const picked = pickColumns(detail); - const columnCount = picked.columns; - drawnColumns = columnCount; + // 열 수 = 사용자 값 · 폭 = (칸 폭 − 간격) ÷ 열 수(`_Section_Columns` · 138-3). + cachedCardWidth = crossCardWidth(renderWidth, columnCount, CROSS_GRID_GAP); // CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제 // 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다. - grid.style.gridTemplateColumns = `repeat(${columnCount}, ${picked.cardWidth}px)`; - cachedCardWidth = picked.cardWidth; + grid.style.gridTemplateColumns = `repeat(${columnCount}, ${cachedCardWidth}px)`; cachedRowHeight.clear(); if (detail.cross_sections.length) { // 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로. @@ -385,19 +414,18 @@ export function createSectionView( // 반폭도 카드가 실제로 쓰는 값(`effectiveCardHalfWidth`, 자동 줌아웃 포함)으로 맞춰 // 재 둔다 — 카드·행 높이가 어긋나지 않는다. const plotBases = plotBasesFor(detail, cachedCardWidth, sections.length); - const naturalHeights = naturalHeightsOf(plotBases); - // 2차: 행별 최댓값으로 강제 높이를 정해 같은 행 카드를 동일 높이로 렌더한다. - for (let start = 0; start < sections.length; start += columnCount) { - const rowHeight = Math.max(...naturalHeights.slice(start, start + columnCount)); - for ( - let index = start; - index < Math.min(start + columnCount, sections.length); - index += 1 - ) { - cachedRowHeight.set(sections[index].station_id, rowHeight); - grid.append(buildCrossCard(sections[index], rowHeight, plotBases[index])); - } - } + // 2차: 높이 = 폭 × (3열 높이 ÷ 3열 폭) · 같은 행 최댓값으로 맞춰 동일 높이로 렌더한다. + const baseWidth = crossCardWidth(renderWidth, CROSS_COLUMNS_BASE, CROSS_GRID_GAP); + const rowHeights = crossRowHeights( + baseHeightsOf(plotBases, cachedCardWidth, baseWidth), + baseWidth, + cachedCardWidth, + columnCount, + ); + sections.forEach((section, index) => { + cachedRowHeight.set(section.station_id, rowHeights[index]); + grid.append(buildCrossCard(section, rowHeights[index], plotBases[index])); + }); } else { grid.append(emptyView(L("B06_Profile_View_NoCross"))); } @@ -441,19 +469,14 @@ export function createSectionView( else fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval); }; + // 높이만 바뀌면(아래 패널 끌기 · 접기) 다시 그리지 않는다 — 열 수는 사용자 값(138-3). const resizeObserver = new ResizeObserver(() => { const nextWidth = contentWidth(); - const nextHeight = contentHeight(); if (!Number.isFinite(nextWidth) || nextWidth <= 0) return; - const widthMoved = Math.abs(nextWidth - renderWidth) >= 1; - if (!widthMoved && Math.abs(nextHeight - renderHeight) < 1) return; + if (Math.abs(nextWidth - renderWidth) < 1) return; window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(() => { renderWidth = nextWidth; - renderHeight = nextHeight; - // 높이만 바뀌면(아래 패널 끌기 · 접기) 한 줄 카드 수가 바뀔 때만 다시 그린다. - if (!widthMoved && currentDetail && pickColumns(currentDetail).columns === drawnColumns) - return; draw(); }, 150); }); @@ -490,7 +513,6 @@ export function createSectionView( // 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로 // 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다. renderWidth = contentWidth(); - renderHeight = contentHeight(); draw(); if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(cards)); }, diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 0c9b34125..dcace78e4 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -44,7 +44,8 @@ border: 1px solid var(--color-border); border-radius: var(--radius-cards); background: var(--color-surface-raised); - scroll-margin-top: var(--spacing-8); + /* 고정 알림 줄 높이만큼 띄움(138-3) — 고른 카드가 줄 밑에 숨지 않게 */ + scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8)); /* 카드를 **자체 쌓임 맥락**으로 만든다. 이게 없으면 카드 안 오버레이(줌 버튼·면적 표)의 z-index가 최상위 축에서 겨루게 되어 이웃 판 위로 삐져나온다(2026-08-02 사용자 지적). */ isolation: isolate; @@ -122,6 +123,41 @@ gap: var(--spacing-16); } +/* 알림 줄(138-3) — 카드 칸 맨 위 고정 · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸. + 위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게 */ +.b06-section__notice-bar { + position: sticky; + top: calc(-1 * var(--spacing-16)); + z-index: 3; + display: flex; + align-items: flex-start; + gap: var(--spacing-8); + margin-top: calc(-1 * var(--spacing-16)); + padding-block: var(--spacing-16) var(--spacing-4); + background: var(--color-bg); +} + +.b06-section__notice-blocks { + flex: 1 1 auto; + min-width: 0; +} + +.b06-section__notice-blocks > .b06-section__notice:last-of-type { + margin-bottom: 0; +} + +.b06-section__columns { + flex: none; + width: 4.5em; + margin-left: auto; +} + +/* 펼친 목록이 길면 줄 안에서 스크롤 — 고정 줄이 카드 칸을 다 덮지 않게 */ +.b06-section__notice-list { + max-height: 30vh; + overflow: auto; +} + /* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */ .b06-section__notice { margin-bottom: var(--spacing-8); diff --git a/resources/tester/helper_138_cross_columns.cjs b/resources/tester/helper_138_cross_columns.cjs new file mode 100644 index 000000000..7835ffd85 --- /dev/null +++ b/resources/tester/helper_138_cross_columns.cjs @@ -0,0 +1,25 @@ +/* 138-3 횡단 카드 열 수 · 폭 · 높이 — `B06_Section_UI_Section_Columns.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "B06_Section", "B06_Section_UI_Section_Columns.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const c = box.exports; + +// 칸 폭 1232 · 간격 16 → 3열 폭 400 +const baseWidth = c.crossCardWidth(1232, 3, 16); +const base = [250, 300, 280, 260, 400]; +const out = { + clamp: [undefined, "", "abc", 0, 1, 2.6, 6, 9, "4"].map(c.clampCrossColumns), + widths: [1, 2, 3, 6].map((n) => c.crossCardWidth(1232, n, 16)), + baseWidth, + rows3: c.crossRowHeights(base, baseWidth, baseWidth, 3), + rows2: c.crossRowHeights(base, baseWidth, c.crossCardWidth(1232, 2, 16), 2), + rows1: c.crossRowHeights(base, baseWidth, c.crossCardWidth(1232, 1, 16), 1), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_138_cross_columns.py b/resources/tester/test_138_cross_columns.py new file mode 100644 index 000000000..179f87371 --- /dev/null +++ b/resources/tester/test_138_cross_columns.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""B06 횡단 카드 열 수 · 폭 · 높이(PLAN 138-3) — 열 수 1~6 · 기본 3 · 폭 = (칸 − 간격) ÷ n · 높이 = 폭 × 3열 비율. + +규칙은 Node 헬퍼(`helper_138_cross_columns.cjs`)가 `B06_Section_UI_Section_Columns.ts` 를 그대로 돌림. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_138_cross_columns.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_열_수_1_6_기본_3(out): + assert out["clamp"] == [3, 3, 3, 1, 1, 3, 6, 6, 4] + + +def test_카드_폭은_칸_폭_나누기_열_수(out): + assert out["widths"] == [1232, 608, 400, (1232 - 80) / 6] + assert out["baseWidth"] == 400 + + +def test_3열은_자연_높이_줄_최댓값(out): + assert out["rows3"] == [300, 300, 300, 400, 400] + + +def test_다른_열_수는_3열_비율_그대로(out): + # 2열 폭 608 = 3열 400 × 1.52 → 높이도 1.52 배 · 줄(2장) 최댓값 + assert [round(h, 6) for h in out["rows2"]] == [456, 456, 425.6, 425.6, 608] + # 1열 폭 1232 = 3.08 배 + assert [round(h, 6) for h in out["rows1"]] == [770, 924, 862.4, 800.8, 1232] diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index b23da94cb..a96d3e121 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -473,6 +473,7 @@ export const ui_locales_b2 = { "Available only when a ditch is created.", ], B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"], + B06_Cross_Columns: ["한 줄 카드 수(1~6)", "Cards per row (1-6)"], B06_Design_Paved_Suggested: [ "종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)", "Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",