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
+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));
},