260722_3
This commit is contained in:
@@ -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)} m²`, "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";
|
||||
|
||||
@@ -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" : ""}`,
|
||||
|
||||
Reference in New Issue
Block a user