feat(B06): 54-34 횡단 카드 한 줄 수 자동 · 카드 폭 400~800

보기 칸 높이에 첫 줄 + 둘째 줄 절반이 드는 가장 적은 카드 수를 고름 (카드 폭 400~800px 상수는 Section_Common 한 곳). 그림은 px 그대로. 창 크기 · 패널 높이 변화 때 다시 맞춤 (높이만 바뀌면 카드 수가 바뀔 때만 다시 그림).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-06 14:00:38 +09:00
co-authored by Claude Sonnet 5.5
parent 265ad47aee
commit 6912246fb0
3 changed files with 100 additions and 25 deletions
@@ -0,0 +1,47 @@
/* =============================================================================
* B06_Section_UI_Section_Columns.ts
* 횡단 카드 한 줄 카드 수 고르기 — 보기 칸 높이에 첫 줄 + 둘째 줄 절반이 들어가는
* 가장 적은 카드 수. 카드 폭은 최소~최대 폭 안(그림은 축척 그대로 · 폭만 바뀜).
* ========================================================================== */
import {
CROSS_CARD_CHROME,
CROSS_GRID_GAP,
CROSS_GRID_MAX_WIDTH,
CROSS_GRID_MIN_WIDTH,
} from "./B06_Section_UI_Section_Common";
export interface CrossColumns {
columns: number;
cardWidth: number;
}
const widthFor = (columns: number, viewWidth: number): number =>
Math.min(CROSS_GRID_MAX_WIDTH, (viewWidth - (columns - 1) * CROSS_GRID_GAP) / columns);
/**
* `heightsFor(cardWidth, count)` — 그 폭일 때 앞 `count` 장의 카드 자연 높이.
* `viewHeight` 가 0 이하(숨김 · 아직 안 잰 칸)면 가장 큰 카드 쪽을 고른다.
*/
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) };
}
+5 -2
View File
@@ -35,8 +35,11 @@ export const LONG_WIDTH = 1200;
export const LONG_HEIGHT = 220;
export const CROSS_WIDTH = 560;
export const CROSS_HEIGHT = 250;
// 한 행 맞춤 기준 최소 카드 폭 — 조금 더 넓은 화면 필요(480→560, 약 +17%).
export const CROSS_GRID_MIN_WIDTH = 560;
// 카드 폭 범위 — 한 줄 카드 수는 보기 칸 높이로 정한다(`_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 사용자 지시).
+48 -23
View File
@@ -42,9 +42,8 @@ import { 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_GRID_GAP,
CROSS_GRID_MIN_WIDTH,
CROSS_WIDTH,
type CrossDesignChange,
type DesignChangeHandler,
@@ -93,6 +92,8 @@ export function createSectionView(
let currentStationInterval: number | undefined;
let structureEdit: SectionStructureEdit | null = null;
let renderWidth = 0;
let renderHeight = 0;
let drawnColumns = 0;
let resizeTimer = 0;
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
let cachedStationInterval = 1;
@@ -136,6 +137,33 @@ 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` 장의 기하 — 한 줄 카드 수 고르기 · 그리기가 함께 쓴다. */
const plotBasesFor = (detail: SectionDetailResponse, cardWidth: number, count: number) =>
detail.cross_sections
.slice(0, count)
.map((section) =>
crossPlotBase(
section,
currentExaggeration,
cardWidth,
effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth),
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)),
);
/**
* 선택 카드를 위 칸 안에서 보이게 한다 — `start`로 카드 **상단**(측점 라벨)을 칸 위에
* 붙인다(2026-08-04 사용자 지적). 목록 맨 끝 카드는 스크롤이 바닥에 걸려 그 자리까지 못
@@ -316,14 +344,14 @@ export function createSectionView(
grid.addEventListener("click", (event) => {
if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection();
});
const columnCount = Math.max(
1,
Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)),
);
// 열 수 = 보기 칸 높이에 1.5줄이 드는 가장 적은 수 · 카드 폭 400~800(`_Section_Columns`).
const picked = pickColumns(detail);
const columnCount = picked.columns;
drawnColumns = columnCount;
// CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수를 JS가 명시해 일치시킨다.
grid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`;
cachedCardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount;
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다.
grid.style.gridTemplateColumns = `repeat(${columnCount}, ${picked.cardWidth}px)`;
cachedCardWidth = picked.cardWidth;
cachedRowHeight.clear();
if (detail.cross_sections.length) {
// 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로.
@@ -332,18 +360,8 @@ export function createSectionView(
// 그것이 진입에서 `draw` 자기 시간의 대부분이었다(2026-09-06 CPU 프로파일).
// 반폭도 카드가 실제로 쓰는 값(`effectiveCardHalfWidth`, 자동 줌아웃 포함)으로 맞춰
// 재 둔다 — 카드·행 높이가 어긋나지 않는다.
const plotBases = sections.map((section) =>
crossPlotBase(
section,
currentExaggeration,
cachedCardWidth,
effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth),
designElevationAt(detail.longitudinal.design_profiles, section.chainage_m),
),
);
const naturalHeights = plotBases.map((base) =>
Math.max(base?.naturalHeight ?? CROSS_HEIGHT, CROSS_HEIGHT),
);
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));
@@ -401,11 +419,17 @@ export function createSectionView(
const resizeObserver = new ResizeObserver(() => {
const nextWidth = contentWidth();
if (!Number.isFinite(nextWidth) || nextWidth <= 0 || Math.abs(nextWidth - renderWidth) < 1)
return;
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;
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
renderWidth = nextWidth;
renderHeight = nextHeight;
// 높이만 바뀌면(아래 패널 끌기 · 접기) 한 줄 카드 수가 바뀔 때만 다시 그린다.
if (!widthMoved && currentDetail && pickColumns(currentDetail).columns === drawnColumns)
return;
draw();
}, 150);
});
@@ -442,6 +466,7 @@ export function createSectionView(
// 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로
// 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다.
renderWidth = contentWidth();
renderHeight = contentHeight();
draw();
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(cards));
},