diff --git a/B06_Section/B06_Section_UI_Section_Columns.ts b/B06_Section/B06_Section_UI_Section_Columns.ts new file mode 100644 index 000000000..36653df8f --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_Columns.ts @@ -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) }; +} diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index f745e7b63..79f63d0c1 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -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 사용자 지시). diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index b857bea3e..1b5a20121 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -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): 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)); },