- 흐름 강도 색띠 범례를 지도 우측에 세로로 세운다(B04 2D 지도·B05 배수유역도 공용). 색은 화면과 같은 색띠를, 눈금은 같은 로그 정규화를 되돌려 적는다. 제목 "흐름강도". - 배관 누가거리 라벨에 흰 테두리를 깔았다. --map-* 토큰은 다크 테마에서 바뀌지 않아 색만으로는 위성사진·다크 배경에서 묻힌다. - 배수유역 영역 · 종단 그래프 세로선 · 좌측 구조물 폼 · 3D 마커의 선택이 서로를 갱신한다. B05_wf2_Route_UI_Selection.ts로 경로를 한곳에 모으고 isSyncing 가드로 재진입을 막았다. 유역 강조는 origin === "pipe" 항목에만 붙는다. 700줄 규칙 유지를 위한 분리 - B04_wf1_Surface_UI_MapOverlays.ts 신설(유역 채움·번호 배지·분수령·상류 세류망) - reconcilePipes / fetchDrainageLayers / fitViewToRoute → _Drainage_Parts.ts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
680 lines
30 KiB
TypeScript
680 lines
30 KiB
TypeScript
/* =============================================================================
|
|
* B05_wf2_Route_UI_Profile_Panel.ts
|
|
* 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집.
|
|
*
|
|
* 화면 높이의 60%를 쓰며, 그래프와 12행 도면 테이블이 **하나의 가로 스크롤러** 안에
|
|
* 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요).
|
|
* 본문 세로는 그래프 40% : 테이블 60%로 나눈다.
|
|
*
|
|
* 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에
|
|
* `saveProfileAlignment()`로 편집 델타만 보낸다.
|
|
* ========================================================================== */
|
|
|
|
import type {
|
|
LongitudinalSection,
|
|
SectionDetailResponse,
|
|
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
|
|
import {
|
|
createLongitudinalProfile,
|
|
longitudinalMinimumWidth,
|
|
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal";
|
|
import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
|
|
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
|
import { createPanelResizer } from "@ui/ui_template_resizer";
|
|
import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel";
|
|
import { mountStructureMenu } from "./B05_wf2_Route_UI_Profile_Structures";
|
|
import {
|
|
hasLegacyAlignment,
|
|
normalizedLongitudinal,
|
|
readAlignment,
|
|
toDesignProfile,
|
|
} from "./B05_wf2_Route_UI_Profile_Data";
|
|
import { createProgressCircle } from "@ui/ui_template_progress";
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch";
|
|
import type {
|
|
AlignmentBase,
|
|
AlignmentEdits,
|
|
ProfileAlignment,
|
|
} from "./B05_wf2_Route_UI_Profile_Alignment";
|
|
import {
|
|
adjustStation,
|
|
buildAlignment,
|
|
chainageKey,
|
|
emptyEdits,
|
|
setCurveRadius,
|
|
shiftSegment,
|
|
toAlignmentBase,
|
|
} from "./B05_wf2_Route_UI_Profile_Alignment";
|
|
import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit";
|
|
import {
|
|
createProfileTable,
|
|
tableCellWidthFor,
|
|
TABLE_TARGET_FONT_PX,
|
|
} from "./B05_wf2_Route_UI_Profile_Table";
|
|
import {
|
|
irregularLabel,
|
|
irregularStationId,
|
|
type IrregularStation,
|
|
} from "./B05_wf2_Route_UI_IrregularStations";
|
|
import type { SectionStation } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
|
|
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
|
|
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
|
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
|
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css";
|
|
|
|
const COLLAPSED_KEY = "b05-route-profile-collapsed";
|
|
/** 드래그로 조절한 하단 패널 높이(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
|
|
const HEIGHT_KEY = "b05-route-profile-height";
|
|
/** 처음 잡힌 도면 테이블 높이(px). 패널 높이와 같은 수명(세션)으로 함께 남긴다 —
|
|
* 페이지를 다시 들어와도 테이블 높이가 달라지지 않아야 한다. */
|
|
const TABLE_HEIGHT_KEY = "b05-route-profile-table-height";
|
|
/** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */
|
|
const CHART_HEIGHT_RATIO = 0.4;
|
|
const MIN_CHART_HEIGHT = 100;
|
|
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
|
|
const MIN_PANEL_HEIGHT = 180;
|
|
/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
|
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
|
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
|
|
const TABLE_ROW_COUNT = 12;
|
|
|
|
/**
|
|
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
|
|
*
|
|
* 곡선 기준이 길이(L)에서 반경(R)으로 바뀌기 전에 만들어진 데이터는 `policy`에
|
|
* `default_curve_radius_m`가 없어 그대로 쓰면 계산 도중 터진다. 그런 데이터는
|
|
* 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다.
|
|
*/
|
|
/** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */
|
|
const CELL_GAP_PX = 2;
|
|
|
|
/**
|
|
* 측점 한 칸의 **기준 폭(px)** — 목표 글자 크기(12px)로 7자리 값(`3000.00`)이 잘리지 않는 크기.
|
|
* 실제 기본 간격은 여기에 배수(`PROFILE_SPACING_MULTIPLIER`)를 곱한 값을 쓴다.
|
|
*/
|
|
const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX;
|
|
|
|
/**
|
|
* **측점 간격 기본 배수**. 기준 폭의 1.5배를 한 측점 칸의 기본 간격으로 삼는다.
|
|
*
|
|
* 이 배수로 펼친 폭이 **최소 폭**이다 — 브라우저가 이보다 넓으면 폭맞춤으로 늘리고, 좁으면
|
|
* 이 간격을 유지한 채 스크롤로 훑는다. 넉넉한 기본값은 다음 세션의 **비정규 측점**(`+18` 등)이
|
|
* 규칙 칸 안에서 chainage 비례로 자리 잡을 여유도 함께 확보한다.
|
|
*/
|
|
const PROFILE_SPACING_MULTIPLIER = 1.5;
|
|
|
|
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
|
|
function maxChainageOf(data: LongitudinalSection): number {
|
|
const samples = normalizedLongitudinal(data).samples.filter(
|
|
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
|
|
);
|
|
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
|
}
|
|
|
|
/**
|
|
* 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만
|
|
* 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외.
|
|
*/
|
|
function irregularGraphStations(list: IrregularStation[], maxChainage: number): SectionStation[] {
|
|
return list
|
|
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
|
|
.map((entry) => ({
|
|
station_id: irregularStationId(entry.id),
|
|
chainage_m: entry.chainage_m,
|
|
label: irregularLabel(entry),
|
|
kind: "irregular" as const,
|
|
center_z: null,
|
|
azimuth_deg: null,
|
|
center_x: 0,
|
|
center_y: 0,
|
|
frame: { left_xy: [0, 0] as [number, number] },
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 가로 스크롤에도 좌측에 고정되는 Y축(표고 눈금) 오버레이.
|
|
*
|
|
* SVG와 **같은 눈금**(렌더러가 콜백으로 넘김)을 쓰고, 불투명 배경으로 스크롤되는 그래프가
|
|
* 새어 보이지 않게 가린다. 레이아웃에 영향을 주지 않도록 0크기 sticky 앵커 위에 축을 절대배치한다.
|
|
*/
|
|
function buildStickyYAxis(
|
|
axis: { padLeft: number; ticks: Array<{ y: number; label: string }> },
|
|
chartHeight: number,
|
|
): HTMLElement {
|
|
const anchor = document.createElement("div");
|
|
anchor.className = "b05-profile__yaxis";
|
|
const inner = document.createElement("div");
|
|
inner.className = "b05-profile__yaxis-inner";
|
|
inner.style.width = `${axis.padLeft}px`;
|
|
inner.style.height = `${chartHeight}px`;
|
|
axis.ticks.forEach(({ y, label }) => {
|
|
const tick = document.createElement("span");
|
|
tick.className = "b05-profile__yaxis-tick";
|
|
tick.style.top = `${y}px`;
|
|
tick.textContent = label;
|
|
inner.append(tick);
|
|
});
|
|
anchor.append(inner);
|
|
return anchor;
|
|
}
|
|
|
|
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
|
|
function chainageMapper(
|
|
data: LongitudinalSection,
|
|
width: number,
|
|
originOffset: number,
|
|
): (chainage: number) => number {
|
|
const maxChainage = maxChainageOf(data);
|
|
const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset;
|
|
return (chainage: number) => LONG_PAD.left + originOffset + (chainage / maxChainage) * plotWidth;
|
|
}
|
|
|
|
/** `chainageMapper`의 역변환 — 구조물 라인을 끌 때 화면 x를 누가거리로 되돌린다. */
|
|
function chainageInverter(
|
|
data: LongitudinalSection,
|
|
width: number,
|
|
originOffset: number,
|
|
): (px: number) => number {
|
|
const maxChainage = maxChainageOf(data);
|
|
const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset;
|
|
return (px: number) =>
|
|
plotWidth > 0 ? ((px - LONG_PAD.left - originOffset) / plotWidth) * maxChainage : 0;
|
|
}
|
|
|
|
interface ProfileLayout {
|
|
/** 캔버스 폭(px). 화면이 넓으면 폭맞춤으로, 좁으면 최소 폭으로. */
|
|
width: number;
|
|
/** 0측점·종점을 축 프레임 안으로 반 칸씩 들여쓰는 여백(px) — 그래프·테이블 공통. */
|
|
originOffset: number;
|
|
/** 이웃 측점과 겹치지 않는 테이블 셀 폭(px). 실제 측점 간격에 맞춰 함께 늘어난다. */
|
|
cellWidth: number;
|
|
}
|
|
|
|
/**
|
|
* 측점 간격 기본값(기준 폭 × 1.5)으로 노선을 펼치되, 화면이 더 넓으면 폭맞춤으로 늘린다.
|
|
*
|
|
* 매핑은 `x(c) = LONG_PAD.left + halfCell + c·pxPerMeter`이고, 좌우로 반 칸(halfCell)씩 띄워
|
|
* 0측점 셀이 이름표 열 밖으로, 종점 셀이 오른쪽 끝 밖으로 나오게 한다. 좌우 여백을 합치면
|
|
* 한 칸(측점간격)이므로 `width = pads + (maxChainage + interval)·pxPerMeter`가 되고, 이를 뒤집어
|
|
* pxPerMeter를 구하면 halfCell·셀 폭이 실제 간격과 항상 맞물린다.
|
|
*/
|
|
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) };
|
|
}
|
|
|
|
/** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */
|
|
export interface RouteProfilePanelCallbacks {
|
|
/** 관 매설 목록이 바뀜 — 구조물 목록의 "배관" 항목을 이 누가거리로 맞춘다. */
|
|
onPipesChanged?: (chainages: number[]) => void;
|
|
/** 테이블에서 구조물 라인을 끌어 옮김. */
|
|
onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void;
|
|
/** 테이블 우클릭으로 구조물(배관 포함)을 지움. */
|
|
onStructureRemove?: (station: IrregularStation) => void;
|
|
/** 테이블 빈 자리 우클릭으로 배관을 넣음. */
|
|
onPipeAdd?: (chainageM: number) => void;
|
|
/** 구조물 라인을 눌러 고름. */
|
|
onIrregularSelect?: (station: IrregularStation) => void;
|
|
/** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */
|
|
onBasinSelected?: (chainageM: number | null) => void;
|
|
}
|
|
|
|
export function createRouteProfilePanel(
|
|
projectId: string,
|
|
onSelectStation: (stationId: string) => void,
|
|
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 Page가 넘긴다). */
|
|
onResetAll?: () => void,
|
|
callbacks?: RouteProfilePanelCallbacks,
|
|
) {
|
|
const root = document.createElement("section");
|
|
root.className = "b05-route-profile";
|
|
const panelHandle = createWorkflowPanelHandle("bottom");
|
|
const balanceBar = document.createElement("div");
|
|
balanceBar.className = "b05-route-profile__balance";
|
|
const body = document.createElement("div");
|
|
body.className = "b05-route-profile__body";
|
|
const empty = document.createElement("p");
|
|
empty.className = "b05-route-profile__empty";
|
|
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
|
|
body.append(empty);
|
|
// 종단면 본문 + 우측 배수유역 패널을 나란히 놓는 2단 구성.
|
|
// 배수유역 패널이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
|
|
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
|
|
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
|
|
const progress = createProgressCircle({ overlay: true });
|
|
progress.root.hidden = true;
|
|
const bodyWrap = document.createElement("div");
|
|
bodyWrap.className = "b05-route-profile__body-wrap";
|
|
bodyWrap.append(body, progress.root);
|
|
const content = document.createElement("div");
|
|
content.className = "b05-route-profile__content";
|
|
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
|
|
const drainagePanel = createDrainagePanel({
|
|
onPipesChanged: (chainages) => callbacks?.onPipesChanged?.(chainages),
|
|
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
|
|
});
|
|
content.append(bodyWrap, drainagePanel.root);
|
|
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
|
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
|
|
const heightResizer = createPanelResizer({
|
|
axis: "vertical",
|
|
target: root,
|
|
cssVar: "--b05-profile-height",
|
|
direction: -1,
|
|
min: MIN_PANEL_HEIGHT,
|
|
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
|
storageKey: HEIGHT_KEY,
|
|
});
|
|
root.append(panelHandle.root, heightResizer.root, balanceBar, content);
|
|
drainagePanel.load(projectId);
|
|
|
|
let detail: SectionDetailResponse | null = null;
|
|
let selectedStationId: string | null = null;
|
|
let stationInterval: number | undefined;
|
|
let routeId: number | null = null;
|
|
let irregularStations: IrregularStation[] = [];
|
|
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
|
|
let stationDisplay = { station: 0, cumulative: 0 };
|
|
let base: AlignmentBase | null = null;
|
|
let alignment: ProfileAlignment | null = null;
|
|
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
|
let resizeTimer = 0;
|
|
let redrawPending = false;
|
|
let lastWidth = 0;
|
|
let lastHeight = 0;
|
|
/** 처음 그릴 때 잡힌 도면 테이블 높이(px). 패널을 끌어도 이 값을 지킨다. */
|
|
let fixedTableHeight = Number(sessionStorage.getItem(TABLE_HEIGHT_KEY)) || 0;
|
|
|
|
function renderBalance(): void {
|
|
balanceBar.replaceChildren();
|
|
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"],
|
|
["성토", `${balance.fill_area_m2.toFixed(1)} m²`, "fill"],
|
|
[
|
|
"불균형",
|
|
`${balance.imbalance_percent.toFixed(1)} % / 허용 ${balance.tolerance_percent.toFixed(0)} %`,
|
|
balance.within_tolerance ? undefined : "over",
|
|
],
|
|
["변화점", `${alignment.pvi.length} 개`],
|
|
["종단곡선", `${alignment.curves.filter((curve) => !curve.omitted).length} 개`],
|
|
["기본 R", `${policy.default_curve_radius_m.toFixed(1)} m`],
|
|
];
|
|
const editedCount = Object.keys(alignment.edits.station_offsets).length;
|
|
if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]);
|
|
entries.forEach(([label, value, tone]) => {
|
|
const item = document.createElement("span");
|
|
item.className = `b05-route-profile__balance-item${tone ? ` is-${tone}` : ""}`;
|
|
const caption = document.createElement("em");
|
|
caption.textContent = label;
|
|
item.append(caption, document.createTextNode(value));
|
|
balanceBar.append(item);
|
|
});
|
|
if (violations.length) {
|
|
const warning = document.createElement("span");
|
|
warning.className = "b05-route-profile__balance-warning";
|
|
warning.textContent = `⚠ 종단기울기 초과 ${violations.length}개 구간`;
|
|
warning.title = violations
|
|
.map((item) => `구간 ${item.segment_index + 1}: ${item.value.toFixed(2)}% > ${item.limit}%`)
|
|
.join("\n");
|
|
balanceBar.append(warning);
|
|
}
|
|
if (store.edited() || irregularStations.length) {
|
|
const reset = document.createElement("button");
|
|
reset.type = "button";
|
|
reset.className = "b05-route-profile__balance-reset";
|
|
reset.textContent = "초기선 복원";
|
|
reset.title = "모든 편집과 추가한 비정규 측점을 지우고 자동 산출된 계획선으로 되돌립니다.";
|
|
reset.addEventListener("click", () => {
|
|
store.resetAll();
|
|
onResetAll?.();
|
|
});
|
|
balanceBar.append(reset);
|
|
}
|
|
if (store.dirty()) {
|
|
const badge = document.createElement("span");
|
|
badge.className = "b05-route-profile__balance-item is-unsaved";
|
|
badge.textContent = "미저장 (확정 시 반영)";
|
|
balanceBar.append(badge);
|
|
}
|
|
}
|
|
|
|
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
|
|
function applyEdits(next: AlignmentEdits): void {
|
|
if (!base || !alignment) return;
|
|
const candidate = buildAlignment(base, next);
|
|
if (
|
|
base.policy.grade_violation_policy === "block" &&
|
|
candidate.violations.length > alignment.violations.length
|
|
) {
|
|
showToast(
|
|
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
store.replace(next);
|
|
}
|
|
|
|
/**
|
|
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
|
* 실제 렌더링하도록 모은다.
|
|
*/
|
|
function rebuild(): void {
|
|
if (base) alignment = buildAlignment(base, store.edits());
|
|
if (redrawPending) return;
|
|
redrawPending = true;
|
|
requestAnimationFrame(() => {
|
|
redrawPending = false;
|
|
draw();
|
|
});
|
|
}
|
|
|
|
function draw(): void {
|
|
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
|
|
lastWidth = body.clientWidth;
|
|
lastHeight = body.clientHeight;
|
|
// 편집할 때마다 본문을 갈아끼우므로 보고 있던 가로 위치를 잃지 않게 되돌린다.
|
|
const scrollLeft = body.scrollLeft;
|
|
renderBalance();
|
|
|
|
const longitudinal = detail.longitudinal;
|
|
const availableWidth = Math.max(1, body.clientWidth - 15);
|
|
// 계획선(편집 가능) 상태에서는 측점 간격 기본값(기준×1.5)으로 펼치되 화면이 넓으면
|
|
// 폭맞춤으로 늘린다. 그 외(구버전·플레인 뷰)는 예전처럼 화면 폭에 맞춰 펼친다.
|
|
const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m;
|
|
const layout =
|
|
alignment && stationIntervalM
|
|
? 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`;
|
|
|
|
const x = chainageMapper(longitudinal, width, originOffset);
|
|
// 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
|
|
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
|
|
const available = Math.max(120, body.clientHeight);
|
|
// 첫 화면에서만 4:6으로 나누고, 그때 잡힌 테이블 높이를 이후로도 그대로 쓴다.
|
|
// 패널을 끌어 늘리거나 줄이면 그 차이는 전부 그래프가 흡수한다(사용자 지시).
|
|
if (alignment && fixedTableHeight <= 0) {
|
|
fixedTableHeight = Math.round(available * (1 - CHART_HEIGHT_RATIO));
|
|
sessionStorage.setItem(TABLE_HEIGHT_KEY, String(fixedTableHeight));
|
|
}
|
|
const chartHeight = alignment
|
|
? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight)
|
|
: available;
|
|
const tableHeight = available - chartHeight;
|
|
|
|
const table = alignment
|
|
? createProfileTable({
|
|
alignment,
|
|
stationInterval: stationInterval ?? alignment.policy.station_interval_m,
|
|
width,
|
|
height: tableHeight,
|
|
// 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다).
|
|
cellWidth: layout.cellWidth,
|
|
// 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다.
|
|
labelWidth: LONG_PAD.left,
|
|
rowCount: TABLE_ROW_COUNT,
|
|
x,
|
|
// 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다.
|
|
irregularStations: irregularStations.filter(
|
|
(entry) =>
|
|
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
|
|
),
|
|
selectedStationId,
|
|
stationDisplay,
|
|
onCurveRadiusChange: (curve, radius) =>
|
|
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
|
|
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
|
|
onAdjustStation: (chainage, delta) =>
|
|
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
|
|
})
|
|
: null;
|
|
|
|
const chartWrap = document.createElement("div");
|
|
chartWrap.className = "b05-profile__chart";
|
|
chartWrap.style.height = `${chartHeight}px`;
|
|
const designProfiles = alignment
|
|
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
|
|
: (longitudinal.design_profiles ?? []);
|
|
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
|
const graphData = normalizedLongitudinal(longitudinal);
|
|
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
|
|
const graphLongitudinal = injected.length
|
|
? {
|
|
...graphData,
|
|
stations: [...graphData.stations, ...injected].sort(
|
|
(a, b) => a.chainage_m - b.chainage_m,
|
|
),
|
|
}
|
|
: graphData;
|
|
let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
|
|
chartWrap.append(
|
|
createLongitudinalProfile(
|
|
graphLongitudinal,
|
|
selectedStationId,
|
|
1,
|
|
undefined,
|
|
onSelectStation,
|
|
stationInterval,
|
|
width,
|
|
chartHeight,
|
|
width,
|
|
designProfiles,
|
|
originOffset,
|
|
(axis) => {
|
|
yAxis = axis;
|
|
},
|
|
stationDisplay.station,
|
|
// 구조물 측점선을 끌어 옮긴다 — 배관이면 관 지점 정본을 거쳐 세부유역까지 다시 나뉜다.
|
|
(stationId, toChainage) => {
|
|
const target = irregularStations.find(
|
|
(entry) => irregularStationId(entry.id) === stationId,
|
|
);
|
|
if (target) callbacks?.onStructureMove?.(target.chainage_m, toChainage, target);
|
|
},
|
|
),
|
|
);
|
|
// 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다.
|
|
mountStructureMenu(chartWrap, {
|
|
stations: irregularStations,
|
|
x,
|
|
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
|
|
maxChainageM: maxChainageOf(longitudinal),
|
|
onRemove: (station) => callbacks?.onStructureRemove?.(station),
|
|
onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage),
|
|
});
|
|
// 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단).
|
|
if (yAxis) chartWrap.append(buildStickyYAxis(yAxis, chartHeight));
|
|
if (alignment) {
|
|
chartWrap.append(
|
|
createEditOverlay({
|
|
alignment,
|
|
width,
|
|
x,
|
|
step: alignment.policy.edit_step_m,
|
|
// 비정규 측점도 규칙 측점처럼 ▲/▼ 버튼으로 계획고 조정(임의 chainage 변화점 승격).
|
|
irregularStations: irregularStations.filter(
|
|
(entry) =>
|
|
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
|
|
),
|
|
onStation: (chainage, delta) =>
|
|
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
|
|
onSegment: (segment, delta) =>
|
|
base && applyEdits(shiftSegment(base, store.edits(), segment, delta)),
|
|
onResetStation: (chainage) => store.resetStation(chainage),
|
|
// 구간 원복 = 양 끝 측점 오프셋 삭제(측점 원복 연산 ×2).
|
|
onResetSegment: (segment) => {
|
|
store.resetStation(segment.from_m);
|
|
store.resetStation(segment.to_m);
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
canvas.style.height = `${available}px`;
|
|
canvas.append(chartWrap);
|
|
if (table) canvas.append(table);
|
|
body.replaceChildren(canvas);
|
|
body.scrollLeft = scrollLeft;
|
|
}
|
|
|
|
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
|
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
|
body.addEventListener(
|
|
"wheel",
|
|
(event) => {
|
|
if (event.shiftKey || event.deltaY === 0) return;
|
|
const delta = event.deltaY;
|
|
const limit = body.scrollWidth - body.clientWidth;
|
|
if (limit <= 0) return;
|
|
if ((delta < 0 && body.scrollLeft <= 0) || (delta > 0 && body.scrollLeft >= limit)) return;
|
|
body.scrollLeft += delta;
|
|
event.preventDefault();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
if (
|
|
body.clientWidth <= 0 ||
|
|
body.clientHeight <= 0 ||
|
|
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
|
|
)
|
|
return;
|
|
window.clearTimeout(resizeTimer);
|
|
resizeTimer = window.setTimeout(draw, 150);
|
|
});
|
|
resizeObserver.observe(body);
|
|
|
|
function setCollapsed(collapsed: boolean): void {
|
|
root.classList.toggle("is-collapsed", collapsed);
|
|
panelHandle.setOpen(!collapsed);
|
|
sessionStorage.setItem(COLLAPSED_KEY, String(collapsed));
|
|
if (!collapsed) requestAnimationFrame(draw);
|
|
}
|
|
|
|
panelHandle.root.addEventListener("click", () =>
|
|
setCollapsed(!root.classList.contains("is-collapsed")),
|
|
);
|
|
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true");
|
|
|
|
return {
|
|
root,
|
|
render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) {
|
|
detail = nextDetail;
|
|
stationInterval = nextStationInterval;
|
|
const stored = readAlignment(nextDetail.longitudinal);
|
|
// 재탐색으로 경로(routeId)가 바뀔 때, 사용자가 조작한 편집이 있으면 **새 경로에 이월**한다.
|
|
// 편집은 chainage 키라 새 base에 그대로 재적용된다(범위 밖·미매칭 변화점은 best-effort로 드롭).
|
|
const routeChanged = nextRouteId !== routeId;
|
|
const carried = routeChanged && store.edited() ? store.edits() : null;
|
|
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
|
|
if (routeChanged || !store.dirty()) {
|
|
routeId = nextRouteId ?? routeId;
|
|
store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild());
|
|
}
|
|
base = stored ? toAlignmentBase(stored) : null;
|
|
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
|
if (carried) store.replace(carried);
|
|
alignment = base ? buildAlignment(base, store.edits()) : null;
|
|
draw();
|
|
requestAnimationFrame(draw);
|
|
},
|
|
setSelectedStation(stationId: string | null) {
|
|
selectedStationId = stationId;
|
|
draw();
|
|
},
|
|
/** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */
|
|
setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) {
|
|
drainagePanel.setRoute(points);
|
|
},
|
|
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
|
setIrregularStations(stations: IrregularStation[]) {
|
|
irregularStations = stations;
|
|
draw();
|
|
},
|
|
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
|
setStationDisplay(next: { station: number; cumulative: number }) {
|
|
stationDisplay = next;
|
|
draw();
|
|
},
|
|
/**
|
|
* 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다.
|
|
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
|
|
*/
|
|
resetStationEdit(chainageM: number) {
|
|
const key = chainageKey(chainageM);
|
|
const edits = store.edits();
|
|
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
|
|
store.resetStation(chainageM);
|
|
},
|
|
isDirty: () => store.dirty(),
|
|
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
|
async save(): Promise<void> {
|
|
if (!routeId || !store.dirty()) return;
|
|
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
|
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
|
if (next?.base_pvi?.length) {
|
|
base = toAlignmentBase(next);
|
|
alignment = next;
|
|
if (detail) detail.longitudinal.profile_alignment = next;
|
|
}
|
|
store.markSaved();
|
|
draw();
|
|
},
|
|
clear() {
|
|
detail = null;
|
|
base = null;
|
|
alignment = null;
|
|
selectedStationId = null;
|
|
balanceBar.replaceChildren();
|
|
body.replaceChildren(empty);
|
|
},
|
|
/** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */
|
|
drainage: drainagePanel,
|
|
/** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */
|
|
setLoading(label: string | null) {
|
|
progress.root.hidden = label === null;
|
|
if (label !== null) progress.set(null, label);
|
|
},
|
|
dispose() {
|
|
window.clearTimeout(resizeTimer);
|
|
resizeObserver.disconnect();
|
|
heightResizer.dispose();
|
|
},
|
|
};
|
|
}
|