feat(B06): 알림 줄 상단 고정 · 열 수 칸 · 카드 그래프 3열 비율 유지(138-3)

- 알림 줄 sticky · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸(1~6 · 기본 3 · pref cross-columns)
- 카드 폭 = (칸 폭 − 간격) ÷ 열 수 · 높이 = 폭 × 3열 비율 · pickCrossColumns 지움
- 카드 scroll-margin-top = 고정 줄 높이 · test_138_cross_columns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
2026-10-09 14:53:14 +09:00
co-authored by Claude Opus 5.5
parent 0c22b7d4a1
commit cb81e13b29
10 changed files with 229 additions and 87 deletions
+2
View File
@@ -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",
@@ -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);
@@ -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);
+32 -36
View File
@@ -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));
});
}
@@ -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 사용자 지시).
+66 -44
View File
@@ -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<CrossPlotBase | null | undefined>): 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<CrossPlotBase | null | undefined>,
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));
},
+37 -1
View File
@@ -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);
@@ -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));
@@ -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]
+1
View File
@@ -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)",