This commit is contained in:
2026-07-24 14:07:50 +09:00
parent 2bce04227f
commit 1154e169ca
5 changed files with 129 additions and 106 deletions
-4
View File
@@ -168,11 +168,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined,
longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined,
terrainType: options.terrain_type as RoutePanelValues["terrainType"] | undefined,
mainDirection: options.main_direction as RoutePanelValues["mainDirection"] | undefined,
maxGradePct: next.route_params?.max_grade_pct ?? undefined,
minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined,
minTangentLength: next.route_params?.min_tangent_length_m ?? undefined,
balanceSegmentLength: next.route_params?.balance_segment_length_m ?? undefined,
startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined,
endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined,
});
@@ -290,11 +288,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
cross_sample_interval_m: values.crossSampleInterval,
long_sample_interval_m: values.longSampleInterval,
terrain_type: values.terrainType,
main_direction: values.mainDirection,
max_grade_pct: values.maxGradePct,
min_vertical_radius_m: values.minVerticalRadius,
min_tangent_length_m: values.minTangentLength,
balance_segment_length_m: values.balanceSegmentLength,
start_elevation_offset_m: values.startElevationOffset,
end_elevation_offset_m: values.endElevationOffset,
});
+22 -29
View File
@@ -17,11 +17,9 @@ export interface RoutePanelValues {
crossSampleInterval: number | null;
longSampleInterval: number | null;
terrainType: "normal" | "special";
mainDirection: "auto" | "ascending" | "descending" | "none";
maxGradePct: number | null;
minVerticalRadius: number | null;
minTangentLength: number | null;
balanceSegmentLength: number | null;
startElevationOffset: number | null;
endElevationOffset: number | null;
}
@@ -263,21 +261,11 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
const terrainLabel = document.createElement("label");
terrainLabel.className = "b05-route__field";
terrainLabel.append(document.createTextNode("지형 구분"), terrainType);
// 역기울기(5%) 상한을 어느 방향에 적용할지 결정한다. 계곡 횡단·능선 통과처럼
// 주 진행방향이 없는 노선에 역기울기를 걸면 지형을 따라갈 수 없어진다.
const mainDirection = document.createElement("select");
mainDirection.innerHTML =
'<option value="auto">자동 판정</option>' +
'<option value="ascending">상행 (오르막 노선)</option>' +
'<option value="descending">하행 (내리막 노선)</option>' +
'<option value="none">역기울기 미적용</option>';
const directionLabel = document.createElement("label");
directionLabel.className = "b05-route__field";
directionLabel.append(document.createTextNode("주 진행방향"), mainDirection);
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
const maxGradePct = numberField("최대 종단기울기 (%)");
const minVerticalRadius = numberField("종단곡선 최소 반경 (m)");
const minTangentLength = numberField("최소 직선 길이 (m)");
const balanceSegmentLength = numberField("균형 구역 길이 (m, 비우면 전체)");
const startElevationOffset = numberField("시점 계획고 조정 (m)");
const endElevationOffset = numberField("종점 계획고 조정 (m)");
const criteriaNote = document.createElement("p");
@@ -297,14 +285,23 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
gradeHelp.innerHTML =
"<summary>계획선이란?</summary><p>공사 후 노면이 될 높이입니다. 직선과 종단곡선만으로 구성되며, " +
"절토량과 성토량이 균형을 이루도록(적분값 0) 자동 산출됩니다.</p>";
gradeLine.body.append(
terrainLabel,
directionLabel,
balanceSegmentLength.wrapper,
criteriaNote,
gradeAdvanced,
gradeHelp,
);
gradeLine.body.append(terrainLabel, criteriaNote, gradeAdvanced, gradeHelp);
// 비정규 측점(구조물 설치용) — 사용자가 판단해 X+XX 위치에 구조물 정보를 적어 두는 컨테이너.
// 지금은 자유 텍스트 초안 입력만 받는다(구조물 형식·옵션 미확정). 추후 선택+값 입력으로 대체 예정.
// 값을 재탐색 트리거(inputElements)에 넣지 않아 텍스트를 고쳐도 "재탐색 필요"가 뜨지 않는다.
const irregular = section("비정규 측점 (구조물)");
const irregularStations = document.createElement("textarea");
irregularStations.className = "b05-route__textarea";
irregularStations.rows = 4;
irregularStations.placeholder =
"예)\n0+15 배수구조물\n2+18 옹벽\n(측점 위치 + 구조물, 한 줄에 하나)";
const irregularHelp = document.createElement("p");
irregularHelp.className = "b05-route__note";
irregularHelp.textContent =
"구조물 설치가 필요한 지점을 측점(X+XX) 위치로 적어 두는 초안 입력입니다. " +
"지금은 자유 텍스트만 받고, 이후 구조물 선택·값 입력으로 발전시킵니다.";
irregular.body.append(irregularStations, irregularHelp);
/** 등급·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */
function syncCriteria(): void {
@@ -352,11 +349,9 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
crossSampleInterval,
longSampleInterval,
terrainType,
mainDirection,
maxGradePct,
minVerticalRadius,
minTangentLength,
balanceSegmentLength,
startElevationOffset,
endElevationOffset,
];
@@ -368,6 +363,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
conditions.root,
sectionOptions.root,
gradeLine.root,
irregular.root,
result.root,
actionRow,
);
@@ -375,6 +371,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
return {
root,
viewControls,
/** 비정규 측점(구조물) 초안 텍스트. 아직 백엔드로 보내지 않는다(형식 확정 전 임시 보관용). */
irregularStationsText: () => irregularStations.value,
values(): RoutePanelValues {
return {
contourInterval: Number(contourInterval.value) || 1,
@@ -391,11 +389,9 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
crossSampleInterval: parseOptional(crossSampleInterval),
longSampleInterval: parseOptional(longSampleInterval),
terrainType: terrainType.value as RoutePanelValues["terrainType"],
mainDirection: mainDirection.value as RoutePanelValues["mainDirection"],
maxGradePct: parseOptional(maxGradePct),
minVerticalRadius: parseOptional(minVerticalRadius),
minTangentLength: parseOptional(minTangentLength),
balanceSegmentLength: parseOptional(balanceSegmentLength),
startElevationOffset: parseOptional(startElevationOffset),
endElevationOffset: parseOptional(endElevationOffset),
};
@@ -417,13 +413,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
if (values.longSampleInterval != null)
longSampleInterval.value = String(values.longSampleInterval);
if (values.terrainType) terrainType.value = values.terrainType;
if (values.mainDirection) mainDirection.value = values.mainDirection;
if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct);
if (values.minVerticalRadius != null)
minVerticalRadius.value = String(values.minVerticalRadius);
if (values.minTangentLength != null) minTangentLength.value = String(values.minTangentLength);
if (values.balanceSegmentLength != null)
balanceSegmentLength.value = String(values.balanceSegmentLength);
if (values.startElevationOffset != null)
startElevationOffset.value = String(values.startElevationOffset);
if (values.endElevationOffset != null)
+52 -33
View File
@@ -128,26 +128,19 @@ function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection
const CELL_GAP_PX = 2;
/**
* **측점 간격 기본값(px)** — 측점 한 칸의 고정 폭.
*
* 목표 글자 크기(12px)로 7자리 값(`3000.00`)이 잘리지 않는 크기다. 브라우저 폭에 맞춰
* 늘였다 줄였다 하지 않고 이 값을 항상 유지하며, 남는 가로는 스크롤로 훑는다. 규칙 측점은
* 정확히 이 간격으로 찍히고, 셀 폭·글자 크기도 이 값에서 나온다.
*
* 다음 세션의 **비정규 측점**(`+18`, `+15` 등)은 이 간격을 한 칸으로 보고 그 안에서
* chainage 비례로 자리 잡는다 — 값 자체는 chainage×(간격/측점간격)로 매핑되므로 규칙 칸을
* 넓히면 비정규 측점이 겹칠 여유도 함께 늘어난다. 그래서 기본값을 넉넉히 잡아 둔다.
* 측점 한 칸의 **기준 폭(px)** — 목표 글자 크기(12px)로 7자리 값(`3000.00`)이 잘리지 않는 크기.
* 실제 기본 간격은 여기에 배수(`PROFILE_SPACING_MULTIPLIER`)를 곱한 값을 쓴다.
*/
const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX;
/**
* 0측점·종점을 좌우 축 프레임 안쪽으로 반 칸 밀어넣는 여백(px).
* **측점 간격 기본 배수**. 기준 폭의 1.5배를 한 측점 칸의 기본 간격으로 삼는다.
*
* 0측점을 이름표 열(labelWidth = LONG_PAD.left) 바로 위에 두면 셀의 왼쪽 절반이 이름표에
* 가려진다. 반 칸을 밀어 0측점 셀 전체가 이름표 오른쪽으로 나오게 하고, 종점도 대칭으로
* 오른쪽 끝에서 반 칸 띄운다. 그래프도 같은 오프셋(`originOffsetPx`)을 받아 X축을 맞춘다.
* 이 배수로 펼친 폭이 **최소 폭**이다 — 브라우저가 이보다 넓으면 폭맞춤으로 늘리고, 좁으면
* 이 간격을 유지한 채 스크롤로 훑는다. 넉넉한 기본값은 다음 세션의 **비정규 측점**(`+18` 등)이
* 규칙 칸 안에서 chainage 비례로 자리 잡을 여유도 함께 확보한다.
*/
const PROFILE_ORIGIN_OFFSET_PX = STATION_SPACING_PX / 2;
const PROFILE_SPACING_MULTIPLIER = 1.5;
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
function maxChainageOf(data: LongitudinalSection): number {
@@ -168,17 +161,38 @@ function chainageMapper(
return (chainage: number) => LONG_PAD.left + originOffset + (chainage / maxChainage) * plotWidth;
}
interface ProfileLayout {
/** 캔버스 폭(px). 화면이 넓으면 폭맞춤으로, 좁으면 최소 폭으로. */
width: number;
/** 0측점·종점을 축 프레임 안으로 반 칸씩 들여쓰는 여백(px) — 그래프·테이블 공통. */
originOffset: number;
/** 이웃 측점과 겹치지 않는 테이블 셀 폭(px). 실제 측점 간격에 맞춰 함께 늘어난다. */
cellWidth: number;
}
/**
* 측점 간격을 `STATION_SPACING_PX`로 고정했을 때의 캔버스 폭.
* 측점 간격 기본값(기준 폭 × 1.5)으로 노선을 펼치되, 화면이 더 넓으면 폭맞춤으로 늘린다.
*
* 브라우저 폭에 맞춰 늘리지 않는다 — 노선 전체를 `STATION_SPACING_PX / 측점간격(m)`의
* 고정 배율(px/m)로 펼치고, 화면보다 길면 스크롤로 훑는다. 이렇게 해야 측점 간격·셀 폭·
* 글자 크기가 브라우저 크기와 무관하게 일정하게 유지된다.
* 매핑은 `x(c) = LONG_PAD.left + halfCell + c·pxPerMeter`이고, 좌우로 반 칸(halfCell)씩 띄워
* 0측점 셀이 이름표 열 밖으로, 종점 셀이 오른쪽 끝 밖으로 나오게 한다. 좌우 여백을 합치면
* 한 칸(측점간격)이므로 `width = pads + (maxChainage + interval)·pxPerMeter`가 되고, 이를 뒤집어
* pxPerMeter를 구하면 halfCell·셀 폭이 실제 간격과 항상 맞물린다.
*/
function fixedProfileWidth(data: LongitudinalSection, stationIntervalM: number): number {
const pxPerMeter = STATION_SPACING_PX / Math.max(stationIntervalM, 1e-6);
// 좌우 반 칸씩(= STATION_SPACING_PX) 여백을 더해 0측점·종점 셀이 프레임에 붙지 않게 한다.
return LONG_PAD.left + LONG_PAD.right + STATION_SPACING_PX + maxChainageOf(data) * pxPerMeter;
function computeProfileLayout(
data: LongitudinalSection,
stationIntervalM: number,
availableWidth: number,
): ProfileLayout {
const maxChainage = maxChainageOf(data);
const interval = Math.max(stationIntervalM, 1e-6);
const framePad = LONG_PAD.left + LONG_PAD.right;
const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER;
// 기본 배수로 펼친 최소 폭 (좌우 반 칸 = 한 칸 여백 포함).
const minWidth = framePad + ((maxChainage + interval) / interval) * minSpacing;
const width = Math.max(minWidth, availableWidth);
const pxPerMeter = (width - framePad) / (maxChainage + interval);
const spacing = interval * pxPerMeter;
return { width, originOffset: spacing / 2, cellWidth: Math.max(1, spacing - CELL_GAP_PX) };
}
export function createRouteProfilePanel(
@@ -311,17 +325,22 @@ export function createRouteProfilePanel(
renderBalance();
const longitudinal = detail.longitudinal;
// 계획선(편집 가능) 상태에서는 측점 간격을 고정한다. 그 외(구버전·플레인 뷰)는
// 예전처럼 화면 폭에 맞춰 펼친다.
const availableWidth = Math.max(1, body.clientWidth - 15);
// 계획선(편집 가능) 상태에서는 측점 간격 기본값(기준×1.5)으로 펼치되 화면이 넓으면
// 폭맞춤으로 늘린다. 그 외(구버전·플레인 뷰)는 예전처럼 화면 폭에 맞춰 펼친다.
const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m;
const originOffset = alignment ? PROFILE_ORIGIN_OFFSET_PX : 0;
const width =
const layout =
alignment && stationIntervalM
? fixedProfileWidth(longitudinal, stationIntervalM)
: Math.max(
Math.max(1, body.clientWidth - 15),
longitudinalMinimumWidth(longitudinal, stationInterval),
);
? computeProfileLayout(longitudinal, stationIntervalM, availableWidth)
: {
width: Math.max(
availableWidth,
longitudinalMinimumWidth(longitudinal, stationInterval),
),
originOffset: 0,
cellWidth: STATION_SPACING_PX - CELL_GAP_PX,
};
const { width, originOffset } = layout;
const canvas = document.createElement("div");
canvas.className = "b05-profile__canvas";
canvas.style.width = `${width}px`;
@@ -341,8 +360,8 @@ export function createRouteProfilePanel(
stationInterval: stationInterval ?? alignment.policy.station_interval_m,
width,
height: tableHeight,
// 측점 간격을 고정했으므로 셀 폭도 고정값으로 둔다(브라우저 폭과 무관하게 유지).
cellWidth: STATION_SPACING_PX - CELL_GAP_PX,
// 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다).
cellWidth: layout.cellWidth,
// 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다.
labelWidth: LONG_PAD.left,
rowCount: TABLE_ROW_COUNT,
@@ -95,6 +95,29 @@ function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void {
row.append(node);
}
/**
* 각 측점 셀의 중심 x. 규칙 측점은 측점 수직선 위(`x(chainage)`)에 그대로 두되,
* **종점(마지막 측점)**이 앞 측점과 겹치면 오른쪽으로 민다. 종점이라 위치가 수직선에서
* 살짝 벗어나도 사용자가 종점 값임을 인지하는 데 무리가 없다. 캔버스 오른쪽 끝을 넘지 않게
* 가용 여백 안에서만 민다. (곡선/구배 행이 아니라 측점 값 셀·곡선 셀에만 적용한다.)
*/
function stationCellCenters(
stations: ReadonlyArray<{ chainage_m: number }>,
x: (chainageM: number) => number,
cellWidth: number,
width: number,
): number[] {
const centers = stations.map((station) => x(station.chainage_m));
const last = centers.length - 1;
if (last >= 1) {
const cleared = centers[last - 1] + cellWidth;
if (centers[last] < cleared) {
centers[last] = Math.min(cleared, width - cellWidth / 2 - 2);
}
}
return centers;
}
/**
* 행 하나. 이름표는 sticky로 좌측에 고정되며 **그래프의 좌측 여백과 같은 폭**을 쓴다.
* 단위는 이름표를 좁게 유지하려고 툴팁으로 뺐다(넓히면 그래프 시작점을 가린다).
@@ -206,8 +229,8 @@ function curveTitle(curve: AlignmentCurve): string {
* 곡선이 없는 측점에도 **빈 칸을 만든다**. 변화점에만 셀을 두면 세로 구분선이 띄엄띄엄
* 끊겨 위쪽 측점값 행들과 격자가 맞지 않는다.
*/
function buildCurveRows(options: ProfileTableOptions): HTMLElement[] {
const { alignment, x, onCurveRadiusChange } = options;
function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLElement[] {
const { alignment, onCurveRadiusChange } = options;
const lengthRow = createRow(
"b05-profile-table__row--curve is-group-start",
"곡선 L",
@@ -218,7 +241,7 @@ function buildCurveRows(options: ProfileTableOptions): HTMLElement[] {
alignment.curves.map((curve) => [curve.chainage_m.toFixed(3), curve]),
);
alignment.stations.forEach((station) => {
alignment.stations.forEach((station, index) => {
const lengthCell = element("span", "b05-profile-table__curve");
const radiusCell = element("span", "b05-profile-table__curve");
const curve = curveByChainage.get(station.chainage_m.toFixed(3));
@@ -244,8 +267,8 @@ function buildCurveRows(options: ProfileTableOptions): HTMLElement[] {
if (curve.omitted) cell.classList.add("is-omitted");
});
}
placeCell(lengthRow, x(station.chainage_m), lengthCell);
placeCell(radiusRow, x(station.chainage_m), radiusCell);
placeCell(lengthRow, centers[index], lengthCell);
placeCell(radiusRow, centers[index], radiusCell);
});
return [lengthRow, radiusRow];
}
@@ -254,6 +277,9 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options;
const table = element("div", "b05-profile-table");
const fontSize = fitFontSize(height / Math.max(rowCount, 1), cellWidth);
// 종점이 앞 측점과 겹치면 오른쪽으로 민 셀 중심(측점 값·곡선 셀 공용). 구배 블록은 구간을
// 덮는 넓은 범위라 겹치지 않으므로 x를 그대로 쓴다.
const centers = stationCellCenters(alignment.stations, x, cellWidth, width);
// 이름표는 "누가거리"(한글 4자)가 들어가야 하므로 값 글자보다 크지 않게 제한한다.
const labelFontSize = Math.min(fontSize, Math.floor((labelWidth - 10) / 4));
table.style.width = `${width}px`;
@@ -271,12 +297,12 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
spec.unit,
);
// 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다.
alignment.stations.forEach((station, stationIndex) => {
alignment.stations.forEach((_station, stationIndex) => {
const cell = element("span", "b05-profile-table__cell", spec.cell(stationIndex));
placeCell(row, x(station.chainage_m), cell);
placeCell(row, centers[stationIndex], cell);
});
table.append(row);
});
table.append(...buildCurveRows(options));
table.append(...buildCurveRows(options, centers));
return table;
}
+21 -32
View File
@@ -66,33 +66,8 @@
padding-right: 15px;
}
/* 종단면도는 노선 전체 길이만큼 가로로 길어 스크롤바가 유일한 이동 수단이다.
전역 스크롤바(8px, thumb 불투명도 12%)는 가로로 쓰기엔 너무 옅어 잡히지 않으므로
이 패널에서만 더 두껍고 대비가 분명한 막대로 덮어쓴다. */
.b05-route-profile__body {
scrollbar-color: var(--color-text-muted, var(--color-plum-velvet)) var(--color-surface);
scrollbar-width: auto;
}
.b05-route-profile__body::-webkit-scrollbar {
height: 12px;
}
.b05-route-profile__body::-webkit-scrollbar-track {
border-top: 1px solid var(--color-border);
background-color: var(--color-surface);
}
.b05-route-profile__body::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: var(--radius-pills);
background-color: color-mix(in srgb, var(--color-text) 45%, transparent);
background-clip: padding-box;
}
.b05-route-profile__body::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--color-text) 65%, transparent);
}
/* 가로 스크롤바는 전역 pill 스크롤바(테마 `*::-webkit-scrollbar`, 8px)를 그대로 쓴다 —
좌측 사이드바와 동일한 형태로 통일. (예전엔 이 패널만 12px 두꺼운 막대로 덮어썼다.) */
/* 그래프와 테이블을 같은 폭으로 쌓아 X축이 저절로 맞물리게 한다. */
.b05-profile__canvas {
@@ -122,13 +97,18 @@
height: 100%;
}
/* 축 제목과 측점 라벨은 아래 도면 테이블(측점 행)이 그대로 담고 있어 중복이다.
지우면 그래프 상·하단이 비어 편집 버튼이 가려지지 않고, 좌측 여백도 이름표 열로 쓸 수 있다. */
.b05-route-profile .b06-chart__axis-label,
.b05-route-profile .b06-chart__station-label {
/* X·Y축 제목은 아래 도면 테이블이 그대로 담고 있어 중복이라 숨긴다. */
.b05-route-profile .b06-chart__axis-label {
display: none;
}
/* 측점 이름(세로선 항목)은 그래프에서도 보여야 어느 측점의 선인지 바로 읽힌다.
다만 하단 편집 버튼(is-down: bottom 2px, 높이 15px)과 겹치지 않도록 라벨을 살짝
위로 올려 하단 여백 위쪽에 앉힌다(하단 버튼 밴드와 분리). */
.b05-route-profile .b06-chart__station-label {
transform: translateY(-9px);
}
.b05-route__viewport canvas {
display: block;
width: 100%;
@@ -233,7 +213,8 @@
.b05-route__field input,
.b05-route__field select,
.b05-route__panel-section > select {
.b05-route__panel-section > select,
.b05-route__textarea {
width: 100%;
min-width: 0;
padding: var(--spacing-8);
@@ -243,6 +224,14 @@
color: var(--color-text-body);
}
/* 비정규 측점 초안 입력 — 세로 리사이즈만 허용(가로는 컨테이너 폭 고정). */
.b05-route__textarea {
box-sizing: border-box;
resize: vertical;
font: inherit;
line-height: 1.4;
}
.b05-route__check {
display: flex;
align-items: center;