This commit is contained in:
2026-07-23 19:44:19 +09:00
parent f633dc0a49
commit 05b24aa847
4 changed files with 111 additions and 17 deletions
+17 -1
View File
@@ -193,12 +193,28 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
}
async function restoreSections(routeId: number): Promise<void> {
let detail: SectionDetailResponse;
try {
renderSections(await fetchSectionDetail(activeProjectId, routeId), routeId);
detail = await fetchSectionDetail(activeProjectId, routeId);
} catch {
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
currentSectionDetail = null;
profilePanel.clear();
viewer.renderStationLines([], 0);
return;
}
try {
renderSections(detail, routeId);
} catch (error) {
// 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다. 조회는 성공했으므로
// 빈 안내로 되돌리지 않고 실패 사실을 그대로 드러낸다.
console.error("B05 종단면도 렌더 실패", error);
showToast(
error instanceof Error
? `종단면도 표시 실패: ${error.message}`
: "종단면도 표시에 실패했습니다.",
"error",
);
}
}
@@ -287,7 +287,16 @@ function trapezoidWeights(chainage: number[]): number[] {
return weights;
}
export function buildAlignment(base: AlignmentBase, edits: AlignmentEdits): ProfileAlignment {
/** 저장분·초안이 부분적으로 비어 있어도 계산이 터지지 않도록 편집 맵을 채운다. */
function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits {
return {
station_offsets: edits?.station_offsets ?? {},
curve_radii: edits?.curve_radii ?? {},
};
}
export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): ProfileAlignment {
const edits = normalizeEdits(input);
const warnings: string[] = [];
const nodes = resolvePvi(base, edits.station_offsets);
const pviS = nodes.map((node) => node.chainage_m);
@@ -492,5 +501,6 @@ export function setCurveRadius(
}
export function hasEdits(edits: AlignmentEdits): boolean {
return Object.keys(edits.station_offsets).length > 0 || Object.keys(edits.curve_radii).length > 0;
const safe = normalizeEdits(edits);
return Object.keys(safe.station_offsets).length > 0 || Object.keys(safe.curve_radii).length > 0;
}
@@ -37,7 +37,11 @@ import {
toAlignmentBase,
} from "./B05_wf2_Route_UI_Profile_Alignment";
import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit";
import { createProfileTable } from "./B05_wf2_Route_UI_Profile_Table";
import {
createProfileTable,
tableCellWidthFor,
TABLE_TARGET_FONT_PX,
} from "./B05_wf2_Route_UI_Profile_Table";
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
const COLLAPSED_KEY = "b05-route-profile-collapsed";
@@ -47,12 +51,30 @@ const MIN_CHART_HEIGHT = 100;
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
const TABLE_ROW_COUNT = 12;
/**
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
*
* 곡선 기준이 길이(L)에서 반경(R)으로 바뀌기 전에 만들어진 데이터는 `policy`에
* `default_curve_radius_m`가 없어 그대로 쓰면 계산 도중 터진다. 그런 데이터는
* 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다.
*/
function readAlignment(data: LongitudinalSection): ProfileAlignment | null {
const candidate = data.profile_alignment as ProfileAlignment | undefined;
if (!candidate?.base_pvi?.length || !candidate.samples?.length) return null;
if (!Number.isFinite(candidate.policy?.default_curve_radius_m)) return null;
if (!candidate.stations || !candidate.segments || !candidate.curves) return null;
candidate.edits = {
station_offsets: candidate.edits?.station_offsets ?? {},
curve_radii: candidate.edits?.curve_radii ?? {},
};
return candidate;
}
/** 저장분이 구버전이라 편집을 붙일 수 없는 상태인가 (재계산 안내용). */
function hasLegacyAlignment(data: LongitudinalSection): boolean {
return Boolean(data.profile_alignment) && readAlignment(data) === null;
}
/** 편집 결과를 종단면도 렌더러가 받는 계획선 형태로 감싼다. */
function toDesignProfile(
alignment: ProfileAlignment,
@@ -112,12 +134,27 @@ function chainageMapper(data: LongitudinalSection, width: number): (chainage: nu
return (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
}
/** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */
const CELL_GAP_PX = 2;
/** 이웃 측점과 겹치지 않는 테이블 셀 폭 (글자 크기를 정하는 기준이기도 하다). */
function stationCellWidth(data: LongitudinalSection, x: (chainage: number) => number): number {
const stations = data.stations;
if (stations.length < 2) return 72;
const span = x(stations[stations.length - 1].chainage_m) - x(stations[0].chainage_m);
return Math.max(30, Math.min(span / (stations.length - 1) - 2, 96));
return Math.max(30, Math.min(span / (stations.length - 1) - CELL_GAP_PX, 96));
}
/**
* 테이블 값이 목표 글자 크기로 잘리지 않으려면 캔버스가 최소 이만큼 넓어야 한다.
*
* 종단면도 렌더러의 최소 폭(`longitudinalMinimumWidth`)은 그래프 아래 **측점 라벨**만
* 안 겹치면 되는 기준이라 열이 48px밖에 안 돼, 7자리 값(`3000.00`)을 담기엔 좁다.
* 가로 스크롤로 훑는 화면이므로 폭을 늘려 글자 크기를 확보하는 편이 낫다.
*/
function tableMinimumWidth(stationCount: number): number {
const column = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX;
return LONG_PAD.left + LONG_PAD.right + Math.max(1, stationCount) * column;
}
export function createRouteProfilePanel(
@@ -151,7 +188,16 @@ export function createRouteProfilePanel(
function renderBalance(): void {
balanceBar.replaceChildren();
if (!alignment) return;
if (!alignment) {
if (detail && hasLegacyAlignment(detail.longitudinal)) {
const note = document.createElement("span");
note.className = "b05-route-profile__balance-warning";
note.textContent =
"⚠ 계획선 데이터가 구버전 형식입니다 — [최적 경로 계산]을 다시 실행하세요.";
balanceBar.append(note);
}
return;
}
const { balance, policy, violations } = alignment;
const entries: Array<[string, string, string?]> = [
["절토", `${balance.cut_area_m2.toFixed(1)}`, "cut"],
@@ -241,7 +287,10 @@ export function createRouteProfilePanel(
renderBalance();
const longitudinal = detail.longitudinal;
const minimumWidth = longitudinalMinimumWidth(longitudinal, stationInterval);
const minimumWidth = Math.max(
longitudinalMinimumWidth(longitudinal, stationInterval),
alignment ? tableMinimumWidth(longitudinal.stations.length) : 0,
);
const width = Math.max(Math.max(1, body.clientWidth - 30), minimumWidth);
const canvas = document.createElement("div");
canvas.className = "b05-profile__canvas";
+29 -10
View File
@@ -45,25 +45,40 @@ interface SegmentRowSpec {
cell: (segment: AlignmentSegment) => string;
}
/** 구간 블록이 이 폭보다 좁으면 글자가 겹쳐 읽을 수 없으므로 생략한다. */
const MIN_SEGMENT_WIDTH = 32;
const FONT_MIN_PX = 9;
const FONT_MAX_PX = 16;
/** 행 높이 대비 글자 크기 비율 (위아래 여백 확보). */
const FONT_PER_ROW_HEIGHT = 0.52;
/** 셀 폭 대비 글자 크기 비율 — "409.96"(6자) + `-` 여유가 잘리지 않는 값. */
const FONT_PER_CELL_WIDTH = 0.145;
/** 한 셀에 들어가는 가장 긴 값의 글자 수 — 누가거리 `3000.00`, 측점 `150+0.0`. */
const MAX_VALUE_CHARS = 7;
/** 숫자 한 글자의 대략적인 폭 (em 단위). 대부분의 산세리프에서 0.55~0.6em이다. */
const CHAR_WIDTH_EM = 0.6;
const CELL_PADDING_PX = 4;
/** 가로 여유가 있을 때 목표로 삼는 글자 크기. 캔버스 최소 폭 산정의 기준이 된다. */
export const TABLE_TARGET_FONT_PX = 12;
/** 주어진 글자 크기로 가장 긴 값을 자르지 않고 담는 데 필요한 셀 폭. */
export function tableCellWidthFor(fontPx: number): number {
return Math.ceil(MAX_VALUE_CHARS * fontPx * CHAR_WIDTH_EM) + CELL_PADDING_PX;
}
/**
* 행 높이와 셀 폭 중 빡빡한 쪽에 글자 크기를 맞춘다.
* 패널을 키우면 행이 두꺼워지며 글자도 같이 커지고, 측점이 촘촘해 셀이 좁아지면
* 가로가 먼저 한계에 걸려 글자가 작아진다.
*
* 가로 한계는 "가장 긴 값이 잘리지 않는 크기"로 직접 환산한다. 예전처럼 셀 폭에
* 임의 비율을 곱하면 실제 필요량보다 훨씬 작게 잡혀 늘 하한(9px)에 눌러붙었다.
*/
function fitFontSize(rowHeight: number, cellWidth: number): number {
const fitted = Math.min(rowHeight * FONT_PER_ROW_HEIGHT, cellWidth * FONT_PER_CELL_WIDTH);
const byWidth = (cellWidth - CELL_PADDING_PX) / (MAX_VALUE_CHARS * CHAR_WIDTH_EM);
const fitted = Math.min(rowHeight * FONT_PER_ROW_HEIGHT, byWidth);
return Math.round(Math.max(FONT_MIN_PX, Math.min(FONT_MAX_PX, fitted)));
}
/** 구간 블록에 이 글자가 실제로 들어가는가 (테두리만 남는 빈 칸 방지). */
function segmentTextFits(text: string, widthPx: number, fontPx: number): boolean {
return widthPx >= text.length * fontPx * CHAR_WIDTH_EM + CELL_PADDING_PX * 2;
}
function element(tag: string, className: string, text?: string): HTMLElement {
const node = document.createElement(tag);
node.className = className;
@@ -123,6 +138,7 @@ function buildStationRows(alignment: ProfileAlignment, interval: number): Statio
function buildSegmentRows(
alignment: ProfileAlignment,
x: (chainage: number) => number,
fontPx: number,
): HTMLElement[] {
const curveByPvi = new Map(
alignment.curves.filter((curve) => !curve.omitted).map((curve) => [curve.pvi_index, curve]),
@@ -134,8 +150,11 @@ function buildSegmentRows(
const endCurve = curveByPvi.get(segment.index + 1);
const left = x(startCurve ? startCurve.evc_m : segment.from_m);
const span = x(endCurve ? endCurve.bvc_m : segment.to_m) - left;
if (span < MIN_SEGMENT_WIDTH) return;
const node = element("span", "b05-profile-table__segment", spec.cell(segment));
const text = spec.cell(segment);
// 측점을 편집해 구간이 잘게 쪼개지면 글자가 안 들어가 테두리만 남은 빈 칸이 된다.
// 그럴 땐 블록 자체를 만들지 않는다(값은 계획고·거리 행과 그래프에서 읽을 수 있다).
if (!segmentTextFits(text, span, fontPx)) return;
const node = element("span", "b05-profile-table__segment", text);
node.style.left = `${left}px`;
node.style.width = `${span}px`;
node.title =
@@ -208,7 +227,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
// 행 이름표는 가장 긴 "구배 L (m)"이 잘리지 않을 만큼만 차지한다.
table.style.setProperty("--b05-table-label-width", `${Math.round(fontSize * 5.2 + 14)}px`);
table.append(...buildSegmentRows(alignment, x));
table.append(...buildSegmentRows(alignment, x, fontSize));
buildStationRows(alignment, stationInterval).forEach((spec, index) => {
const row = createRow(
`${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`,