Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-18 09:43:24 +09:00
4 changed files with 95 additions and 17 deletions
+47 -16
View File
@@ -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);
@@ -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%;
@@ -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<number | null>,
width: number,
maxPx: number,
minPx = 6,
): Array<number | null> {
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<number | null> = 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;
}
+18 -1
View File
@@ -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]