diff --git a/B05_Profile/B05_Profile_UI_Profile_Table.ts b/B05_Profile/B05_Profile_UI_Profile_Table.ts index b48c66550..3dd442f14 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Table.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Table.ts @@ -19,6 +19,7 @@ import type { ProfileAlignment, } from "./logic/B05_Profile_UI_Profile_Alignment"; import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common"; +import { crowdedThickness } from "../B06_Section/logic/B06_Section_Label_Tiers"; import { irregularStationId, isPipeStation, @@ -74,7 +75,7 @@ const CHAR_WIDTH_EM = 0.6; const CELL_PADDING_PX = 2; /** 가로 여유가 있을 때 목표로 삼는 글자 크기. 캔버스 최소 폭 산정의 기준이 된다. */ export const TABLE_TARGET_FONT_PX = 12; -/** 곡선 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭(px). */ +/** 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭 위 끝(px · 이웃 사이 틈이 더 좁으면 그 틈). */ const CURVE_ROTATED_WIDTH_PX = 16; /** * 같은 측점으로 볼 누가거리 차이(m). @@ -339,17 +340,17 @@ function buildCurveRows( : curveByChainage.get(key); // 직선화된 자리는 반경이 없다 — 빈 칸으로 둔다. const curve = found && !isStraightCurve(found) ? found : undefined; - return { center: centers[index], curve, rotated: false }; + return { center: centers[index], curve }; }); - const withCurve = curveEntries.filter((entry) => entry.curve); - for (let pair = 1; pair < withCurve.length; pair += 1) { - if (withCurve[pair].center - withCurve[pair - 1].center < options.cellWidth) { - withCurve[pair - 1].rotated = true; - withCurve[pair].rotated = true; - } - } + // 세운 칸 두께 = 이웃 사이 틈에 맞춤(407-5 — 1.6 m 이웃도 겹치지 않게 · 측점 값 행과 같은 규칙) + const thickness = crowdedThickness( + curveEntries.map((entry) => (entry.curve ? entry.center : null)), + options.cellWidth, + CURVE_ROTATED_WIDTH_PX, + ); - curveEntries.forEach(({ center, curve, rotated }) => { + curveEntries.forEach(({ center, curve }, entryIndex) => { + const rotated = thickness[entryIndex]; const lengthCell = element("span", "b05-profile-table__curve"); const radiusCell = element("span", "b05-profile-table__curve"); if (curve) { @@ -370,16 +371,16 @@ function buildCurveRows( ); radiusCell.append(radiusInput); lengthCell.append(lengthInput); - if (rotated) { + if (rotated !== null) { [lengthCell, radiusCell].forEach((cell) => { cell.classList.add("is-rotated"); - cell.style.width = `${CURVE_ROTATED_WIDTH_PX}px`; + cell.style.width = `${rotated}px`; }); [lengthInput, radiusInput].forEach((input) => { const byLength = input.value.length ? (rowHeightPx - 6) / (input.value.length * CHAR_WIDTH_EM) : fontPx; - input.style.fontSize = `${Math.max(7, Math.min(fontPx, CURVE_ROTATED_WIDTH_PX - 4, byLength))}px`; + input.style.fontSize = `${Math.max(6, Math.min(fontPx, rotated - 2, byLength))}px`; }); } @@ -585,21 +586,51 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { table.append( ...buildSegmentRows(alignment, x, fontSize, rowHeight, structureChainages, selectedChainageM), ); - buildStationRows(alignment, stationInterval, display).forEach((spec, index) => { + // 이웃 칸과 글이 겹치는 값 칸(1.6 m 이웃 등)만 그 자리에서 세움 — 판정 = 세 행 가운데 가장 긴 글 폭 · + // 두께 = 이웃 사이 틈(407-5 · 곡선 행과 같은 함수) + const stationSpecs = buildStationRows(alignment, stationInterval, display); + const isStructural = alignment.stations.map((station) => + isNearChainage(station.chainage_m, structureChainageList), + ); + const longest = Math.max( + 0, + ...stationSpecs.flatMap((spec) => + alignment.stations.map((_, stationIndex) => + isStructural[stationIndex] ? 0 : spec.cell(stationIndex).length, + ), + ), + ); + const stationThickness = crowdedThickness( + alignment.stations.map((_, stationIndex) => + isStructural[stationIndex] ? null : centers[stationIndex], + ), + longest * fontSize * CHAR_WIDTH_EM + CELL_PADDING_PX * 2, + CURVE_ROTATED_WIDTH_PX, + ); + stationSpecs.forEach((spec, index) => { const row = createRow( `${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`, spec.label, spec.unit, ); // 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다. - alignment.stations.forEach((station, stationIndex) => { + alignment.stations.forEach((_, stationIndex) => { // 구조물 측점 열은 기본값을 비운다 — 격자(빈 셀)만 남기고 값은 선택 오버레이가 맡는다. - const structural = isNearChainage(station.chainage_m, structureChainageList); + const structural = isStructural[stationIndex]; const cell = element( "span", `b05-profile-table__cell${structural ? " is-structure" : ""}`, structural ? "" : spec.cell(stationIndex), ); + const rotated = stationThickness[stationIndex]; + if (rotated !== null) { + const text = cell.textContent ?? ""; + const byLength = text.length ? (rowHeight - 2) / (text.length * CHAR_WIDTH_EM) : fontSize; + cell.classList.add("is-rotated"); + cell.style.width = `${rotated}px`; + cell.style.fontSize = `${Math.max(6, Math.min(fontSize, rotated - 2, byLength))}px`; + cell.title = text; + } placeCell(row, centers[stationIndex], cell); }); table.append(row); diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 3f1dedf96..71a35fa80 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -84,6 +84,12 @@ /* 이웃 곡선 셀과 겹치는 좁은 자리(배관 측점 등) — 셀을 얇게 줄이고 입력을 세로쓰기로 세운다. 자리는 측점 수직선 그대로다(옆으로 비키면 다른 열 값으로 오해, 2026-08-04). */ +/* 측점 값 칸도 같은 규칙(407-5) — 이웃과 겹치면 칸을 얇게 · 글을 세움 · 지우지 않음. */ +.b05-profile-table__cell.is-rotated { + writing-mode: vertical-rl; + white-space: nowrap; +} + .b05-profile-table__curve.is-rotated input { writing-mode: vertical-rl; width: 100%; diff --git a/B06_Section/logic/B06_Section_Label_Tiers.ts b/B06_Section/logic/B06_Section_Label_Tiers.ts index 1d7596dfc..ee3cba1e0 100644 --- a/B06_Section/logic/B06_Section_Label_Tiers.ts +++ b/B06_Section/logic/B06_Section_Label_Tiers.ts @@ -2,6 +2,7 @@ * B06_Section/logic/B06_Section_Label_Tiers.ts * 종단 측점 · 구조물 이름표 비켜 그리기(PLAN 407-4) — 가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단으로 * 올림 · 이름은 지우지 않음. 그리는 곳 = `B06_Section_UI_Longitudinal`(B05 종단도 같은 함수). + * B05 종단 아래 표 겹친 칸 세우기(407-5 · `crowdedThickness` · `B05_Profile_UI_Profile_Table`). * 시험 `resources/tester/test_407_4_label_tiers.py`. 화면 · 세션 없음(순수). * ========================================================================== */ @@ -29,3 +30,26 @@ export function labelTiers( } return tiers; } + +/** 겹친 칸 세우기(407-5 · B05 종단 아래 표) — 같은 폭(`width`) 칸이 이웃과 가로로 겹치면 세운 칸 두께 + * (px · 가까운 이웃까지 거리 − 1 · `minPx` ~ `maxPx`) · 안 겹치면 null(그대로) · x 가 null = 빈 칸(판정에서 뺌). */ +export function crowdedThickness( + xs: ReadonlyArray, + width: number, + maxPx: number, + minPx = 6, +): Array { + const order = xs + .map((x, index) => ({ x, index })) + .filter((item): item is { x: number; index: number } => item.x !== null) + .sort((a, b) => a.x - b.x); + const out: Array = xs.map(() => null); + order.forEach(({ x, index }, k) => { + const nearest = Math.min( + k > 0 ? x - order[k - 1].x : Infinity, + k < order.length - 1 ? order[k + 1].x - x : Infinity, + ); + if (nearest < width) out[index] = Math.max(minPx, Math.min(maxPx, Math.floor(nearest - 1))); + }); + return out; +} diff --git a/resources/tester/test_407_4_label_tiers.py b/resources/tester/test_407_4_label_tiers.py index 9725a290c..3aac3a881 100644 --- a/resources/tester/test_407_4_label_tiers.py +++ b/resources/tester/test_407_4_label_tiers.py @@ -1,4 +1,5 @@ -"""PLAN 407-4 — 종단 측점 · 구조물 이름표 비켜 그리기(`logic/B06_Section_Label_Tiers.ts`). +"""PLAN 407-4 — 종단 측점 · 구조물 이름표 비켜 그리기(`logic/B06_Section_Label_Tiers.ts`) · +407-5 — B05 종단 아래 표 겹친 칸 세우기(`crowdedThickness` · 두께 = 이웃 사이 틈 − 1). 가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단 · 안 겹치면 제자리(0) · 이름은 지우지 않음 · 단 수 위 끝(`MAX_LABEL_TIER`) 넘으면 마지막 단 · 글 폭 짐작(한글 = 글자 크기). @@ -30,6 +31,13 @@ process.stdout.write(JSON.stringify({ max: m.MAX_LABEL_TIER, tiers: cases.map((items) => m.labelTiers(items)), widths: ["19+18 (MC)", "20+0", "배수관"].map((t) => m.labelWidthPx(t, 10)), + thick: [ + m.crowdedThickness([0, 50, 100], 40, 16), + m.crowdedThickness([100, 108, 200], 40, 16), + m.crowdedThickness([100, 130, 200], 40, 16), + m.crowdedThickness([100, 104, null, 106], 40, 16), + m.crowdedThickness([100, null, 103], 40, 16), + ], })); """ @@ -77,3 +85,12 @@ def test_글_폭(r) -> None: mc, plain, korean = r["widths"] assert mc == pytest.approx(60.0) and plain == pytest.approx(24.0) assert korean == pytest.approx(30.0) + + +def test_표_칸_세우기(r) -> None: + no, near, wide, empty, floor = r["thick"] + assert no == [None, None, None] # 안 겹침 = 그대로 + assert near == [7, 7, None] # 1.6 m 이웃(8 px) = 둘 다 세움 · 두께 7 + assert wide == [16, 16, None] # 틈 넓으면 위 끝 16 + assert empty == [6, 6, None, 6] # 빈 칸(null) 건너 이웃 · 아래 끝 6 + assert floor == [6, None, 6]