refactor(B05): 700줄 초과 잔여 2파일 분리 — Page·Profile_Panel
앞 커밋에서 남긴 두 파일을 마저 갈랐다. 상태를 모듈로 옮기면 나머지 참조가 전부
바뀌므로 상태는 제자리에 두고 **접근자만 넘기는** 방식으로 동작·공개 인터페이스를
보존했다.
- Profile_Panel 1152 → 649
- _Profile_Layout: X축 배치(측점 칸 폭·캔버스 폭·chainage↔x 매핑). 순수 함수
- _Profile_Heights: 그래프·유토곡선·테이블 높이 배분. 드래그 플래그·상세 유무는
본체가 계속 들고 접근자로 읽는다(저장 높이 기준 판정 규칙 그대로)
- _Profile_Render: 본문 재구성(캔버스·그래프·구조물 레인·테이블·유토곡선).
그리기 시작 시 상태를 스냅숏하되 이벤트 핸들러 안에서만 현재값을 다시 읽는다
- _Profile_Balance: 상단 균형 표시줄(절·성토·불균형·위반 경고·초기선 복원)
- Page 1031 → 685
- _Page_Helpers: 설계폭 조회·모델 경계 변환·마커 복원·비정규 측점 보간 +
시설 표시 이름
- _Page_Structures: 구조물 정본과 관 지점 정본을 사이드 목록·그래프·3D에 맞추는
다리. 두 정본을 섞는 지점이라 여기만 상태(비정규 측점 목록·판번호·저장 큐·
타입 사전)를 팩토리 안으로 옮겼고, 본체는 bridge.irregularStations()로 읽는다
B05_Profile 전 파일이 700줄 이하가 됐다(최대 695).
검증: npm run typecheck 무오류, npm run build 성공(374 modules),
pytest tmp/tests 107 passed·7 skipped, prettier 정합.
프론트 테스트 러너가 없어 실제 화면 동작 확인은 남는다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,15 +31,9 @@ import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { createSelectionSync } from "./B05_Profile_UI_Selection";
|
||||
import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
|
||||
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
|
||||
import {
|
||||
irregularStationId,
|
||||
isPipeStation,
|
||||
structureLabel,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { pickPipeDiameter, PIPE_DEFAULT_TYPE } from "@config/config_frontend";
|
||||
import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
fetchSectionContext,
|
||||
type SectionDetailResponse,
|
||||
@@ -48,14 +42,7 @@ import {
|
||||
invalidateSectionDetail,
|
||||
loadSectionDetail,
|
||||
} from "../B06_Section/B06_Section_Section_Store";
|
||||
import {
|
||||
fetchStructures,
|
||||
fetchStructureTypes,
|
||||
migrateLegacyStations,
|
||||
saveStructures,
|
||||
StructureConflictError,
|
||||
type StructureInstance,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import { migrateLegacyStations } from "./B05_Profile_Api_Structures";
|
||||
import "./B05_Profile_UI_Style.css";
|
||||
import "./B05_Profile_UI_Style_Structures.css";
|
||||
import {
|
||||
@@ -68,8 +55,6 @@ import {
|
||||
toBounds,
|
||||
} from "./B05_Profile_UI_Page_Helpers";
|
||||
|
||||
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
@@ -98,17 +83,14 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
syncIrregularSelection(stationId);
|
||||
},
|
||||
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
|
||||
() => clearProjectedStations(),
|
||||
() => bridge.clearProjectedStations(),
|
||||
{
|
||||
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다.
|
||||
// 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다.
|
||||
onPipesChanged: (pipes) => {
|
||||
// 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅
|
||||
// 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다.
|
||||
pipeStations = pipesToStations(pipes);
|
||||
syncCrossDrainStations();
|
||||
pipeMarks = pipesToMarks(pipes);
|
||||
syncGraphStructures();
|
||||
bridge.setPipes(pipes);
|
||||
panel.structures.setPipeFacilities(
|
||||
pipes.map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
@@ -140,7 +122,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
|
||||
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
|
||||
onStructureSelect: (structureId) => {
|
||||
const chainage = pipeMarkChainage(structureId);
|
||||
const chainage = bridge.pipeMarkChainage(structureId);
|
||||
if (chainage !== null) {
|
||||
panel.structures.selectPipeByChainage(chainage);
|
||||
selectStationOfPipe(chainage);
|
||||
@@ -149,7 +131,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
panel.structures.selectById(structureId);
|
||||
},
|
||||
onStructureMarkMove: (structureId, toChainage) => {
|
||||
const chainage = pipeMarkChainage(structureId);
|
||||
const chainage = bridge.pipeMarkChainage(structureId);
|
||||
if (chainage !== null) {
|
||||
profilePanel.drainage.movePipe(chainage, toChainage);
|
||||
return;
|
||||
@@ -171,7 +153,6 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
let currentSectionDetail: SectionDetailResponse | null = null;
|
||||
let routeReady = false;
|
||||
let restoring = true;
|
||||
let irregularStations: IrregularStation[] = [];
|
||||
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
|
||||
let selectionSyncing = false;
|
||||
|
||||
@@ -195,7 +176,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
|
||||
stations: () => irregularStations,
|
||||
stations: () => bridge.irregularStations(),
|
||||
isSyncing: () => selectionSyncing,
|
||||
setSyncing: (value) => {
|
||||
selectionSyncing = value;
|
||||
@@ -259,7 +240,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
|
||||
onInputChange: markStale,
|
||||
onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset),
|
||||
onStructuresChange: (next) => applyStructures(next),
|
||||
onStructuresChange: (next) => bridge.applyStructures(next),
|
||||
// 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합).
|
||||
onPipeFacilityAdd: (chainage, attributes) =>
|
||||
profilePanel.drainage.addPipe(chainage, attributes),
|
||||
@@ -270,9 +251,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
if (selectionSyncing) return;
|
||||
selectionSyncing = true;
|
||||
try {
|
||||
const station = irregularStations.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainage) < 0.05,
|
||||
);
|
||||
const station = bridge
|
||||
.irregularStations()
|
||||
.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05);
|
||||
const id = station ? irregularStationId(station.id) : null;
|
||||
viewer.markers.selectStation(id);
|
||||
profilePanel.setSelectedStation(id);
|
||||
@@ -318,7 +299,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
const prefix = irregularStationId("");
|
||||
if (stationId.startsWith(prefix)) {
|
||||
const id = stationId.slice(prefix.length);
|
||||
chainage = irregularStations.find((entry) => entry.id === id)?.chainage_m;
|
||||
chainage = bridge.irregularStations().find((entry) => entry.id === id)?.chainage_m;
|
||||
}
|
||||
}
|
||||
if (chainage === undefined) return;
|
||||
@@ -361,7 +342,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular");
|
||||
const injected = interpolateIrregularStations(
|
||||
regular,
|
||||
irregularStations,
|
||||
bridge.irregularStations(),
|
||||
detail.longitudinal.length_m,
|
||||
);
|
||||
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
|
||||
@@ -373,231 +354,22 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2);
|
||||
}
|
||||
|
||||
/** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지). */
|
||||
const FACILITY_NAMES: Record<PipeFacility, string> = {
|
||||
pipe: "배수관",
|
||||
box_culvert: "BOX암거",
|
||||
ford_pavement: "물넘이포장",
|
||||
ford_bridge: "세월교",
|
||||
};
|
||||
|
||||
/** 관 매설 목록을 그래프·3D용 측점 목록으로 실체화한다(구 비정규 측점 투영의 후신).
|
||||
* 배관은 유효직경으로 관경을 자동 지정하고, 다른 시설은 종류 이름을 라벨로 쓴다. */
|
||||
function pipesToStations(
|
||||
pipes: Array<{
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
facility: PipeFacility;
|
||||
}>,
|
||||
): IrregularStation[] {
|
||||
const interval = panel.values().stationInterval || 20;
|
||||
return pipes.map((pipe) => {
|
||||
const station = Math.floor(pipe.chainage_m / interval);
|
||||
const remainder = pipe.chainage_m - station * interval;
|
||||
const label =
|
||||
pipe.facility === "pipe"
|
||||
? structureLabel({
|
||||
structureType: "배관",
|
||||
pipeType: PIPE_DEFAULT_TYPE,
|
||||
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
|
||||
})
|
||||
: FACILITY_NAMES[pipe.facility];
|
||||
return {
|
||||
id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
||||
station,
|
||||
remainder,
|
||||
chainage_m: pipe.chainage_m,
|
||||
structure: label,
|
||||
structureType: "배관",
|
||||
origin: "pipe",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
|
||||
function clearProjectedStations(): void {
|
||||
pipeStations = [];
|
||||
irregularStations = [];
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
profilePanel.setIrregularStations([]);
|
||||
}
|
||||
|
||||
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
|
||||
function applyIrregularStations(stations: IrregularStation[]): void {
|
||||
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다.
|
||||
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
|
||||
irregularStations
|
||||
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3)))
|
||||
.forEach((station) => profilePanel.resetStationEdit(station.chainage_m));
|
||||
irregularStations = stations;
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
profilePanel.setIrregularStations(stations);
|
||||
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
|
||||
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
|
||||
profilePanel.drainage.setPipeChainages(
|
||||
stations.filter(isPipeStation).map((entry) => entry.chainage_m),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 구조물 정본(structures.json) ────────────────────────────────────
|
||||
* 사이드 목록이 바뀌면 곧바로 서버 정본에 저장한다 — 화면에만 남겨 두면 새로고침에
|
||||
* 사라지고, 다른 창과도 어긋난다. 판번호가 밀리면(다른 창이 먼저 저장) 최신본을
|
||||
* 받아 화면을 맞추고 사용자에게 알린다. */
|
||||
let structureRevision = 0;
|
||||
let structureSaving: Promise<void> = Promise.resolve();
|
||||
|
||||
/* ── 횡단배수(A군) 측점 세로선 ──────────────────────────────────────────
|
||||
* 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅
|
||||
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
|
||||
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
|
||||
let pipeStations: IrregularStation[] = [];
|
||||
let structureTypeMap = new Map<string, { group: string; name: string }>();
|
||||
|
||||
/** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
||||
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
|
||||
const interval = panel.values().stationInterval || 20;
|
||||
return structures
|
||||
.filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A")
|
||||
.map((structure) => {
|
||||
const chainage = structure.chainage_m ?? structure.start_m ?? 0;
|
||||
const station = Math.floor(chainage / interval);
|
||||
return {
|
||||
id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`,
|
||||
station,
|
||||
remainder: chainage - station * interval,
|
||||
chainage_m: chainage,
|
||||
structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */
|
||||
function syncCrossDrainStations(): void {
|
||||
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]);
|
||||
}
|
||||
|
||||
/** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */
|
||||
function pipeMarkChainage(structureId: string | null): number | null {
|
||||
if (!structureId?.startsWith("pipe-")) return null;
|
||||
const value = Number(structureId.slice("pipe-".length));
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다.
|
||||
* 자동 배수관도 세로 점선이 아니라 같은 알약으로 나온다(2026-08-17 사용자 지시 1). */
|
||||
let ownStructures: StructureInstance[] = [];
|
||||
let pipeMarks: StructureInstance[] = [];
|
||||
|
||||
function syncGraphStructures(): void {
|
||||
profilePanel.setStructures([...ownStructures, ...pipeMarks]);
|
||||
}
|
||||
|
||||
/** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */
|
||||
function pipesToMarks(
|
||||
pipes: Array<{
|
||||
chainage_m: number;
|
||||
facility: PipeFacility;
|
||||
options?: Record<string, string | number>;
|
||||
}>,
|
||||
): StructureInstance[] {
|
||||
return pipes.map((pipe) => ({
|
||||
structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
||||
type_id: pipe.facility,
|
||||
placement: "point",
|
||||
chainage_m: pipe.chainage_m,
|
||||
start_m: null,
|
||||
end_m: null,
|
||||
side: "cross",
|
||||
offset_m: 0,
|
||||
options: pipe.options ?? {},
|
||||
memo: "",
|
||||
placement_source: "automatic",
|
||||
status: "draft",
|
||||
revision: 0,
|
||||
geometry: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function applyStructures(next: StructureInstance[]): void {
|
||||
ownStructures = next;
|
||||
syncGraphStructures();
|
||||
syncCrossDrainStations();
|
||||
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
|
||||
structureSaving = structureSaving.then(() => persistStructures(next));
|
||||
}
|
||||
|
||||
/** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */
|
||||
async function refreshStructuresFromServer(): Promise<boolean> {
|
||||
const stored = await fetchStructures(activeProjectId).catch(() => null);
|
||||
if (!stored) return false;
|
||||
structureRevision = stored.revision;
|
||||
panel.structures.setStructures(stored.structures);
|
||||
ownStructures = stored.structures;
|
||||
syncGraphStructures();
|
||||
syncCrossDrainStations();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function persistStructures(next: StructureInstance[]): Promise<void> {
|
||||
if (restoring) return;
|
||||
try {
|
||||
const saved = await saveStructures(activeProjectId, structureRevision, next);
|
||||
structureRevision = saved.revision;
|
||||
// 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다.
|
||||
await refreshStructuresFromServer();
|
||||
// 구조물이 바뀌면 B06 이후를 다시 돌려야 한다. 그 표시를 서버가 못 남겼다면
|
||||
// 화면상 "완료"인 뒤 단계가 옛 구조물로 만든 결과라는 뜻이라 사용자가 알아야 한다.
|
||||
if (saved.needs_downstream_invalidation && !saved.invalidated_downstream) {
|
||||
showToast(
|
||||
"구조물은 저장되었지만 이후 단계(횡단·수량) 재작업 표시에 실패했습니다. " +
|
||||
"B06을 다시 실행해 주세요.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof StructureConflictError) {
|
||||
await refreshStructuresFromServer();
|
||||
showToast("다른 창에서 구조물이 먼저 저장되어 최신 내용으로 되돌렸습니다.", "error");
|
||||
return;
|
||||
}
|
||||
// 저장이 거절되면 화면에만 남은 항목은 식별자가 없어 고치지도 지우지도 못한다.
|
||||
// 서버 정본으로 되돌려 화면과 정본을 다시 일치시킨다(2026-08-16 크로스체크 지적 1).
|
||||
await refreshStructuresFromServer();
|
||||
showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */
|
||||
async function loadStructures(): Promise<void> {
|
||||
try {
|
||||
const [types, stored] = await Promise.all([
|
||||
fetchStructureTypes(),
|
||||
fetchStructures(activeProjectId),
|
||||
]);
|
||||
panel.structures.setTypes(types);
|
||||
profilePanel.setStructureTypes(types);
|
||||
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
||||
structureTypeMap = new Map(
|
||||
types.map((type) => [type.type_id, { group: type.group, name: type.name }]),
|
||||
);
|
||||
structureRevision = stored.revision;
|
||||
panel.structures.setStructures(stored.structures);
|
||||
ownStructures = stored.structures;
|
||||
syncGraphStructures();
|
||||
syncCrossDrainStations();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
/** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */
|
||||
const bridge = createStructuresBridge({
|
||||
projectId: activeProjectId,
|
||||
panel: () => panel,
|
||||
profilePanel: () => profilePanel,
|
||||
renderStationLines: () => {
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
},
|
||||
isRestoring: () => restoring,
|
||||
});
|
||||
|
||||
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
|
||||
currentSectionDetail = detail;
|
||||
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
|
||||
profilePanel.setStationDisplay(panel.stationDisplayOffset());
|
||||
profilePanel.setIrregularStations(irregularStations);
|
||||
profilePanel.setIrregularStations(bridge.irregularStations());
|
||||
renderStationLines(detail);
|
||||
}
|
||||
|
||||
@@ -632,7 +404,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
.then((result) => {
|
||||
if (result.migrated > 0) {
|
||||
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
|
||||
return refreshStructuresFromServer().then(() => undefined);
|
||||
return bridge.refreshFromServer().then(() => undefined);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
@@ -768,7 +540,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
method: latest?.surface_params.method,
|
||||
smooth: latest?.surface_params.smooth,
|
||||
surface_model_id: confirmedSurface?.id,
|
||||
irregular_stations: irregularStations.map((station) => ({
|
||||
irregular_stations: bridge.irregularStations().map((station) => ({
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.structure,
|
||||
})),
|
||||
@@ -900,7 +672,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
}
|
||||
// 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후).
|
||||
await loadStructures();
|
||||
await bridge.load();
|
||||
advanceLoading("");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
RoutePointKind,
|
||||
} from "./B05_Profile_UI_Markers";
|
||||
import type { CirclePoint, RouteLatestResponse, RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
irregularLabel,
|
||||
@@ -142,3 +143,11 @@ export function interpolateIrregularStations(
|
||||
structure: entry.structure,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지 따로 붙인다). */
|
||||
export const FACILITY_NAMES: Record<PipeFacility, string> = {
|
||||
pipe: "배수관",
|
||||
box_culvert: "BOX암거",
|
||||
ford_pavement: "물넘이포장",
|
||||
ford_bridge: "세월교",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Page_Structures.ts
|
||||
* 구조물·계곡 통과 시설을 화면 세 곳(사이드 목록·종단 그래프·3D)에 맞추는 다리.
|
||||
*
|
||||
* 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다. 두 정본을 섞어
|
||||
* 쓰는 자리라 상태를 여기로 옮겨 왔다 — 구조물 정본(structures.json)의 목록·판번호와,
|
||||
* 관 지점 정본(pipe_points.json)에서 투영한 측점·알약이 그것이다. 본체는 이 객체의
|
||||
* 메서드만 부르고 목록은 `irregularStations()`로 읽는다.
|
||||
*
|
||||
* 저장 규칙은 종전 그대로다 — 사이드 목록이 바뀌면 곧바로 서버 정본에 저장하고,
|
||||
* 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { PIPE_DEFAULT_TYPE, pickPipeDiameter } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
fetchStructures,
|
||||
fetchStructureTypes,
|
||||
saveStructures,
|
||||
StructureConflictError,
|
||||
type StructureInstance,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import {
|
||||
isPipeStation,
|
||||
structureLabel,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
||||
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
||||
|
||||
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
||||
export interface PipeProjection {
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
facility: PipeFacility;
|
||||
options?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface StructuresBridgeDeps {
|
||||
projectId: string;
|
||||
/** 사이드 패널 — 생성 순서상 나중에 만들어지므로 게터로 받는다. */
|
||||
panel: () => ReturnType<typeof createRoutePanel>;
|
||||
profilePanel: () => ReturnType<typeof createRouteProfilePanel>;
|
||||
/** 3D 측점 세로선 다시 그리기 — 상세가 아직 없으면 본체가 알아서 건너뛴다. */
|
||||
renderStationLines: () => void;
|
||||
/** 복원 중에는 서버로 저장하지 않는다(되살린 값을 그대로 되쓰지 않기 위함). */
|
||||
isRestoring: () => boolean;
|
||||
}
|
||||
|
||||
export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
/** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */
|
||||
let irregularStations: IrregularStation[] = [];
|
||||
|
||||
function pipesToStations(
|
||||
pipes: Array<{
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
facility: PipeFacility;
|
||||
}>,
|
||||
): IrregularStation[] {
|
||||
const interval = deps.panel().values().stationInterval || 20;
|
||||
return pipes.map((pipe) => {
|
||||
const station = Math.floor(pipe.chainage_m / interval);
|
||||
const remainder = pipe.chainage_m - station * interval;
|
||||
const label =
|
||||
pipe.facility === "pipe"
|
||||
? structureLabel({
|
||||
structureType: "배관",
|
||||
pipeType: PIPE_DEFAULT_TYPE,
|
||||
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
|
||||
})
|
||||
: FACILITY_NAMES[pipe.facility];
|
||||
return {
|
||||
id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
||||
station,
|
||||
remainder,
|
||||
chainage_m: pipe.chainage_m,
|
||||
structure: label,
|
||||
structureType: "배관",
|
||||
origin: "pipe",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
|
||||
function clearProjectedStations(): void {
|
||||
pipeStations = [];
|
||||
irregularStations = [];
|
||||
deps.renderStationLines();
|
||||
deps.profilePanel().setIrregularStations([]);
|
||||
}
|
||||
|
||||
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
|
||||
function applyIrregularStations(stations: IrregularStation[]): void {
|
||||
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다.
|
||||
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
|
||||
irregularStations
|
||||
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3)))
|
||||
.forEach((station) => deps.profilePanel().resetStationEdit(station.chainage_m));
|
||||
irregularStations = stations;
|
||||
deps.renderStationLines();
|
||||
deps.profilePanel().setIrregularStations(stations);
|
||||
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
|
||||
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
|
||||
deps
|
||||
.profilePanel()
|
||||
.drainage.setPipeChainages(stations.filter(isPipeStation).map((entry) => entry.chainage_m));
|
||||
}
|
||||
|
||||
/* ── 구조물 정본(structures.json) ────────────────────────────────────
|
||||
* 사이드 목록이 바뀌면 곧바로 서버 정본에 저장한다 — 화면에만 남겨 두면 새로고침에
|
||||
* 사라지고, 다른 창과도 어긋난다. 판번호가 밀리면(다른 창이 먼저 저장) 최신본을
|
||||
* 받아 화면을 맞추고 사용자에게 알린다. */
|
||||
let structureRevision = 0;
|
||||
let structureSaving: Promise<void> = Promise.resolve();
|
||||
|
||||
/* ── 횡단배수(A군) 측점 세로선 ──────────────────────────────────────────
|
||||
* 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅
|
||||
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
|
||||
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
|
||||
let pipeStations: IrregularStation[] = [];
|
||||
let structureTypeMap = new Map<string, { group: string; name: string }>();
|
||||
|
||||
/** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
||||
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
|
||||
const interval = deps.panel().values().stationInterval || 20;
|
||||
return structures
|
||||
.filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A")
|
||||
.map((structure) => {
|
||||
const chainage = structure.chainage_m ?? structure.start_m ?? 0;
|
||||
const station = Math.floor(chainage / interval);
|
||||
return {
|
||||
id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`,
|
||||
station,
|
||||
remainder: chainage - station * interval,
|
||||
chainage_m: chainage,
|
||||
structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */
|
||||
function syncCrossDrainStations(): void {
|
||||
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]);
|
||||
}
|
||||
|
||||
/** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */
|
||||
function pipeMarkChainage(structureId: string | null): number | null {
|
||||
if (!structureId?.startsWith("pipe-")) return null;
|
||||
const value = Number(structureId.slice("pipe-".length));
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다.
|
||||
* 자동 배수관도 세로 점선이 아니라 같은 알약으로 나온다(2026-08-17 사용자 지시 1). */
|
||||
let ownStructures: StructureInstance[] = [];
|
||||
let pipeMarks: StructureInstance[] = [];
|
||||
|
||||
function syncGraphStructures(): void {
|
||||
deps.profilePanel().setStructures([...ownStructures, ...pipeMarks]);
|
||||
}
|
||||
|
||||
/** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */
|
||||
function pipesToMarks(
|
||||
pipes: Array<{
|
||||
chainage_m: number;
|
||||
facility: PipeFacility;
|
||||
options?: Record<string, string | number>;
|
||||
}>,
|
||||
): StructureInstance[] {
|
||||
return pipes.map((pipe) => ({
|
||||
structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
||||
type_id: pipe.facility,
|
||||
placement: "point",
|
||||
chainage_m: pipe.chainage_m,
|
||||
start_m: null,
|
||||
end_m: null,
|
||||
side: "cross",
|
||||
offset_m: 0,
|
||||
options: pipe.options ?? {},
|
||||
memo: "",
|
||||
placement_source: "automatic",
|
||||
status: "draft",
|
||||
revision: 0,
|
||||
geometry: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function applyStructures(next: StructureInstance[]): void {
|
||||
ownStructures = next;
|
||||
syncGraphStructures();
|
||||
syncCrossDrainStations();
|
||||
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
|
||||
structureSaving = structureSaving.then(() => persistStructures(next));
|
||||
}
|
||||
|
||||
/** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */
|
||||
async function refreshStructuresFromServer(): Promise<boolean> {
|
||||
const stored = await fetchStructures(deps.projectId).catch(() => null);
|
||||
if (!stored) return false;
|
||||
structureRevision = stored.revision;
|
||||
deps.panel().structures.setStructures(stored.structures);
|
||||
ownStructures = stored.structures;
|
||||
syncGraphStructures();
|
||||
syncCrossDrainStations();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function persistStructures(next: StructureInstance[]): Promise<void> {
|
||||
if (deps.isRestoring()) return;
|
||||
try {
|
||||
const saved = await saveStructures(deps.projectId, structureRevision, next);
|
||||
structureRevision = saved.revision;
|
||||
// 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다.
|
||||
await refreshStructuresFromServer();
|
||||
// 구조물이 바뀌면 B06 이후를 다시 돌려야 한다. 그 표시를 서버가 못 남겼다면
|
||||
// 화면상 "완료"인 뒤 단계가 옛 구조물로 만든 결과라는 뜻이라 사용자가 알아야 한다.
|
||||
if (saved.needs_downstream_invalidation && !saved.invalidated_downstream) {
|
||||
showToast(
|
||||
"구조물은 저장되었지만 이후 단계(횡단·수량) 재작업 표시에 실패했습니다. " +
|
||||
"B06을 다시 실행해 주세요.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof StructureConflictError) {
|
||||
await refreshStructuresFromServer();
|
||||
showToast("다른 창에서 구조물이 먼저 저장되어 최신 내용으로 되돌렸습니다.", "error");
|
||||
return;
|
||||
}
|
||||
// 저장이 거절되면 화면에만 남은 항목은 식별자가 없어 고치지도 지우지도 못한다.
|
||||
// 서버 정본으로 되돌려 화면과 정본을 다시 일치시킨다(2026-08-16 크로스체크 지적 1).
|
||||
await refreshStructuresFromServer();
|
||||
showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */
|
||||
async function loadStructures(): Promise<void> {
|
||||
try {
|
||||
const [types, stored] = await Promise.all([
|
||||
fetchStructureTypes(),
|
||||
fetchStructures(deps.projectId),
|
||||
]);
|
||||
deps.panel().structures.setTypes(types);
|
||||
deps.profilePanel().setStructureTypes(types);
|
||||
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
||||
structureTypeMap = new Map(
|
||||
types.map((type) => [type.type_id, { group: type.group, name: type.name }]),
|
||||
);
|
||||
structureRevision = stored.revision;
|
||||
deps.panel().structures.setStructures(stored.structures);
|
||||
ownStructures = stored.structures;
|
||||
syncGraphStructures();
|
||||
syncCrossDrainStations();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** 그래프·3D가 쓰는 현재 비정규 측점 목록. */
|
||||
irregularStations: () => irregularStations,
|
||||
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
|
||||
setPipes(pipes: PipeProjection[]): void {
|
||||
pipeStations = pipesToStations(pipes);
|
||||
syncCrossDrainStations();
|
||||
pipeMarks = pipesToMarks(pipes);
|
||||
syncGraphStructures();
|
||||
},
|
||||
/** [초기선 복원] — 그래프의 배관 투영만 지운다(관 정본은 배수유역이 맡는다). */
|
||||
clearProjectedStations,
|
||||
/** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */
|
||||
pipeMarkChainage,
|
||||
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
|
||||
applyStructures,
|
||||
/** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */
|
||||
refreshFromServer: refreshStructuresFromServer,
|
||||
/** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */
|
||||
load: loadStructures,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Balance.ts
|
||||
* 종단 패널 상단 균형 표시줄 — 절·성토 면적, 불균형률, 변화점·종단곡선 개수,
|
||||
* 종단기울기 위반 경고, [초기선 복원] 버튼, 미저장 배지.
|
||||
*
|
||||
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
|
||||
* 표시줄은 상태를 갖지 않는다 — 그릴 때마다 현재 선형·편집 상태를 인자로 받는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
export interface BalanceBarParams {
|
||||
/** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */
|
||||
balanceBar: HTMLElement;
|
||||
/** 현재 계획선(없으면 안내만 띄운다). */
|
||||
alignment: ProfileAlignment | null;
|
||||
/** 계획선이 없고 저장분이 구버전 형식일 때 재계산을 안내한다. */
|
||||
legacyAlignment: boolean;
|
||||
/** 편집이 있었는지(초기선 복원 버튼 노출 조건). */
|
||||
edited: boolean;
|
||||
/** 비정규 측점이 있는지(초기선 복원 버튼 노출 조건). */
|
||||
hasIrregularStations: boolean;
|
||||
/** 저장되지 않은 편집이 있는지. */
|
||||
dirty: boolean;
|
||||
/** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */
|
||||
onResetAll: () => void;
|
||||
}
|
||||
|
||||
export function renderBalanceBar(params: BalanceBarParams): void {
|
||||
params.balanceBar.replaceChildren();
|
||||
if (!params.alignment) {
|
||||
if (params.legacyAlignment) {
|
||||
const note = document.createElement("span");
|
||||
note.className = "b05-route-profile__balance-warning";
|
||||
note.textContent =
|
||||
"⚠ 계획선 데이터가 구버전 형식입니다 — [최적 경로 계산]을 다시 실행하세요.";
|
||||
params.balanceBar.append(note);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { alignment } = params;
|
||||
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} 개`],
|
||||
// 기준은 길이 L이다. 옛 저장분(L 없음)만 그 시절 기준인 R을 그대로 밝혀 적는다.
|
||||
Number.isFinite(policy.default_curve_length_m)
|
||||
? ["기본 곡선길이 L", `${(policy.default_curve_length_m as number).toFixed(1)} m`]
|
||||
: ["기본 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));
|
||||
params.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");
|
||||
params.balanceBar.append(warning);
|
||||
}
|
||||
if (params.edited || params.hasIrregularStations) {
|
||||
const reset = document.createElement("button");
|
||||
reset.type = "button";
|
||||
reset.className = "b05-route-profile__balance-reset";
|
||||
reset.textContent = "초기선 복원";
|
||||
reset.title = "모든 편집과 추가한 비정규 측점을 지우고 자동 산출된 계획선으로 되돌립니다.";
|
||||
reset.addEventListener("click", () => {
|
||||
params.onResetAll();
|
||||
});
|
||||
params.balanceBar.append(reset);
|
||||
}
|
||||
if (params.dirty) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "b05-route-profile__balance-item is-unsaved";
|
||||
badge.textContent = "미저장 (확정 시 반영)";
|
||||
params.balanceBar.append(badge);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Heights.ts
|
||||
* 종단 패널의 높이 배분 — 그래프·유토곡선·테이블이 한 패널 높이를 나눠 갖는 규칙.
|
||||
*
|
||||
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
|
||||
* 판정 기준은 **저장 높이**(리사이저 변수)이며 화면 높이가 아니다 — 화면 높이로 재면
|
||||
* 줄임과 풀림이 호출마다 번갈아 도는 진동이 생긴다(2026-08-06 분석).
|
||||
* 드래그 플래그·상세 데이터 유무는 본체가 계속 들고 있고 여기서는 접근자로만 읽는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
|
||||
|
||||
/** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */
|
||||
const MIN_CHART_HEIGHT = 100;
|
||||
/** 접힌 유토곡선 손잡이가 차지하는 바닥 여백(px). */
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
/** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
|
||||
export interface HeightCascadeDeps {
|
||||
/** 패널 루트 — 자리가 모자라면 이 높이를 키운다. */
|
||||
root: HTMLElement;
|
||||
/** 본문(그래프+서브패널이 들어가는 스크롤 영역). */
|
||||
body: HTMLElement;
|
||||
massHaul: {
|
||||
overlay: HTMLElement;
|
||||
desiredHeight: () => number;
|
||||
syncHandle: () => void;
|
||||
};
|
||||
tableOverlay: {
|
||||
overlay: HTMLElement;
|
||||
isOpen: () => boolean;
|
||||
desiredHeight: () => number;
|
||||
setBottomOffset: (value: number) => void;
|
||||
};
|
||||
isMainDragging: () => boolean;
|
||||
isSubDragging: () => boolean;
|
||||
/** 메인 드래그 시작 시점의 3영역 비율(없으면 null). */
|
||||
mainDragRef: () => { chart: number; mass: number; table: number } | null;
|
||||
/** 손 뗀 직후 첫 재구성까지는 자동 확대를 막는다. */
|
||||
isMainDragCooldown: () => boolean;
|
||||
/** 상세 데이터가 아직 없으면 경량 동기화를 건너뛴다. */
|
||||
hasDetail: () => boolean;
|
||||
}
|
||||
|
||||
export interface HeightCascade {
|
||||
/** 높이를 배분하고 그래프에 남는 높이를 돌려준다. */
|
||||
apply: (allowGrow: boolean) => { chartHeight: number };
|
||||
/** 경량 동기화를 다음 프레임에 1회 예약한다. */
|
||||
scheduleLightSync: () => void;
|
||||
}
|
||||
|
||||
export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
let lightSyncPending = false;
|
||||
|
||||
function apply(allowGrow: boolean): { chartHeight: number } {
|
||||
// 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다
|
||||
// (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소).
|
||||
const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX);
|
||||
const massOpen = !deps.massHaul.overlay.hidden;
|
||||
const tableOpen = deps.tableOverlay.isOpen();
|
||||
// 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면
|
||||
// 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이
|
||||
// 호출마다 번갈아 도는 진동이 되고, 풀리는 호출의 deficit 처리가 메인 패널을
|
||||
// 멋대로 키운다(2026-08-06 분석: 튐·자동 확대의 핵심 원인). 저장 높이 기준이면
|
||||
// 몇 번을 호출해도 같은 답이 나온다(멱등).
|
||||
const massDesired = massOpen ? deps.massHaul.desiredHeight() : 0;
|
||||
const tableDesired = tableOpen ? deps.tableOverlay.desiredHeight() : 0;
|
||||
let massHeight = massDesired;
|
||||
let tableOverlayHeight = tableDesired;
|
||||
const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
// ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·유토곡선·테이블을 같이 늘리고
|
||||
// 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다.
|
||||
// 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다.
|
||||
const dragRef = deps.mainDragRef();
|
||||
if (deps.isMainDragging() && dragRef) {
|
||||
const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
const items = [
|
||||
{ key: "chart", ref: Math.max(1, dragRef.chart), min: MIN_CHART_HEIGHT },
|
||||
...(massOpen
|
||||
? [{ key: "mass", ref: Math.max(1, dragRef.mass), min: MASSHAUL_MIN_HEIGHT }]
|
||||
: []),
|
||||
...(tableOpen
|
||||
? [{ key: "table", ref: Math.max(1, dragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }]
|
||||
: []),
|
||||
];
|
||||
// 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다.
|
||||
const out: Record<string, number> = {};
|
||||
let pool = content;
|
||||
let active = items;
|
||||
while (active.length) {
|
||||
const refSum = active.reduce((sum, item) => sum + item.ref, 0);
|
||||
const scale = pool / refSum;
|
||||
const pinned = active.filter((item) => item.ref * scale < item.min);
|
||||
if (!pinned.length) {
|
||||
active.forEach((item) => (out[item.key] = Math.round(item.ref * scale)));
|
||||
break;
|
||||
}
|
||||
pinned.forEach((item) => {
|
||||
out[item.key] = item.min;
|
||||
pool -= item.min;
|
||||
});
|
||||
active = active.filter((item) => !pinned.includes(item));
|
||||
}
|
||||
// 서브패널 상한(개별 리사이저 max와 같은 비율)은 안전상 유지 — 넘치면 종단이 흡수.
|
||||
massHeight = massOpen ? Math.min(out.mass ?? 0, Math.round(available * 0.8)) : 0;
|
||||
tableOverlayHeight = tableOpen ? Math.min(out.table ?? 0, Math.round(available * 0.75)) : 0;
|
||||
if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
// 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다.
|
||||
deps.massHaul.syncHandle();
|
||||
deps.tableOverlay.setBottomOffset(massHeight);
|
||||
return {
|
||||
chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight),
|
||||
};
|
||||
}
|
||||
if (deps.isSubDragging()) {
|
||||
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를
|
||||
// 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로
|
||||
// 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다.
|
||||
deps.massHaul.overlay.style.height = "";
|
||||
deps.tableOverlay.overlay.style.height = "";
|
||||
} else if (massDesired + tableDesired > budget) {
|
||||
// ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한.
|
||||
// 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다.
|
||||
const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0;
|
||||
const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
const shrinkable = massDesired - massMin + (tableDesired - tableMin);
|
||||
const over = massDesired + tableDesired - budget;
|
||||
const ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1;
|
||||
massHeight = Math.round(massDesired - (massDesired - massMin) * ratio);
|
||||
tableOverlayHeight = Math.round(tableDesired - (tableDesired - tableMin) * ratio);
|
||||
// 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아
|
||||
// 패널을 다시 키우면 원래 높이로 돌아온다.
|
||||
if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
} else {
|
||||
// 공간 충분 — 임시 축소 해제(저장 높이로 복귀).
|
||||
deps.massHaul.overlay.style.height = "";
|
||||
deps.tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
// 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는
|
||||
// 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition).
|
||||
deps.tableOverlay.setBottomOffset(massHeight);
|
||||
// ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우)
|
||||
// 메인 패널을 키운다. 단 **메인 패널을 끄는 중과 손 뗀 직후 첫 재구성까지**는 절대
|
||||
// 안 된다 — 포인터가 정한 높이를 되돌려 서로 밀고 당기는 진동이 생긴다.
|
||||
const deficit =
|
||||
MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
|
||||
if (deficit > 1 && allowGrow && !deps.isMainDragging() && !deps.isMainDragCooldown()) {
|
||||
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
|
||||
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
|
||||
// 상한으로 clamp되며 패널이 뚝 떨어졌다(2026-08-06 분석).
|
||||
const growCap = Math.round(
|
||||
(deps.root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
||||
);
|
||||
const grown = Math.min(deps.root.offsetHeight + deficit, growCap);
|
||||
if (grown > deps.root.offsetHeight + 1) {
|
||||
deps.root.style.setProperty("--b05-profile-height", `${grown}px`);
|
||||
// ResizeObserver가 새 높이로 draw를 다시 부른다 — 이번 프레임은 그대로 마저 그린다.
|
||||
}
|
||||
}
|
||||
return {
|
||||
chartHeight: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 경량 동기화를 다음 프레임에 1회 예약 — 메인 리사이즈(ResizeObserver)와
|
||||
* 서브패널 드래그(onResize 알림)가 같은 스로틀을 공유한다. */
|
||||
function scheduleLightSync(): void {
|
||||
if (lightSyncPending) return;
|
||||
lightSyncPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
lightSyncPending = false;
|
||||
syncHeightsLight();
|
||||
});
|
||||
}
|
||||
|
||||
/** 리사이즈 중 프레임당 경량 동기화 — 차트·테이블 재구성 없이 높이만 맞춰 끊김을 없앤다.
|
||||
* SVG는 잠깐 세로 스케일되지만, 손을 떼면(디바운스) 전체 재그리기가 정확히 다시 그린다. */
|
||||
function syncHeightsLight(): void {
|
||||
if (!deps.hasDetail()) return;
|
||||
// 서브패널을 끄는 중엔 즉시 메인 패널을 밀어 올려야(grow) 드래그가 자연스럽다.
|
||||
const { chartHeight } = apply(deps.isSubDragging());
|
||||
const canvas = deps.body.firstElementChild as HTMLElement | null;
|
||||
if (!canvas || !canvas.classList.contains("b05-profile__canvas")) return;
|
||||
canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`;
|
||||
const chartWrap = canvas.querySelector<HTMLElement>(".b05-profile__chart");
|
||||
if (chartWrap) {
|
||||
chartWrap.style.height = `${chartHeight}px`;
|
||||
const svg = chartWrap.querySelector("svg");
|
||||
if (svg) svg.style.height = `${chartHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
return { apply, scheduleLightSync };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Layout.ts
|
||||
* 종단면도의 X축 배치 — 측점 칸 폭·캔버스 폭·chainage ↔ 화면 x 매핑.
|
||||
*
|
||||
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
|
||||
* 그래프·테이블·구조물 레인이 **같은 매핑**을 써야 X축이 맞물리므로 그 계산을
|
||||
* 한 곳에 모아 둔다. 전부 순수 함수라 패널 상태와 무관하다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { normalizedLongitudinal } from "./B05_Profile_UI_Profile_Data";
|
||||
import { TABLE_TARGET_FONT_PX, tableCellWidthFor } from "./B05_Profile_UI_Profile_Table";
|
||||
import type { LongitudinalSection, SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
irregularLabel,
|
||||
irregularStationId,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
|
||||
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
|
||||
export const TABLE_ROW_COUNT = 12;
|
||||
|
||||
/** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */
|
||||
export const CELL_GAP_PX = 2;
|
||||
|
||||
/**
|
||||
* 측점 한 칸의 **기준 폭(px)** — 목표 글자 크기(12px)로 7자리 값(`3000.00`)이 잘리지 않는 크기.
|
||||
* 실제 기본 간격은 여기에 배수(`PROFILE_SPACING_MULTIPLIER`)를 곱한 값을 쓴다.
|
||||
*/
|
||||
export const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX;
|
||||
|
||||
/**
|
||||
* **측점 간격 기본 배수**. 기준 폭의 1.5배를 한 측점 칸의 기본 간격으로 삼는다.
|
||||
*
|
||||
* 이 배수로 펼친 폭이 **최소 폭**이다 — 브라우저가 이보다 넓으면 폭맞춤으로 늘리고, 좁으면
|
||||
* 이 간격을 유지한 채 스크롤로 훑는다. 넉넉한 기본값은 다음 세션의 **비정규 측점**(`+18` 등)이
|
||||
* 규칙 칸 안에서 chainage 비례로 자리 잡을 여유도 함께 확보한다.
|
||||
*/
|
||||
export const PROFILE_SPACING_MULTIPLIER = 1.5;
|
||||
|
||||
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
|
||||
export 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가 따로 보간). 범위 밖은 제외.
|
||||
*/
|
||||
export 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] },
|
||||
}));
|
||||
}
|
||||
|
||||
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
|
||||
export 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를 누가거리로 되돌린다. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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·셀 폭이 실제 간격과 항상 맞물린다.
|
||||
*/
|
||||
export 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) };
|
||||
}
|
||||
@@ -10,15 +10,7 @@
|
||||
* `saveProfileAlignment()`로 편집 델타만 보낸다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
LongitudinalSection,
|
||||
SectionDetailResponse,
|
||||
} from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
} from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
|
||||
@@ -28,18 +20,11 @@ import {
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
|
||||
import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures";
|
||||
import {
|
||||
createProfileTableOverlay,
|
||||
TABLE_OVERLAY_MIN_HEIGHT,
|
||||
} from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
import {
|
||||
hasLegacyAlignment,
|
||||
normalizedLongitudinal,
|
||||
readAlignment,
|
||||
toDesignProfile,
|
||||
} from "./B05_Profile_UI_Profile_Data";
|
||||
import { hasLegacyAlignment, readAlignment } from "./B05_Profile_UI_Profile_Data";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { saveProfileAlignment } from "./B05_Profile_Api_Fetch";
|
||||
@@ -50,33 +35,22 @@ import type {
|
||||
ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
adjustStation,
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
emptyEdits,
|
||||
setCurveRadius,
|
||||
shiftSegment,
|
||||
toAlignmentBase,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { createEditOverlay, createProfileEditStore } from "./B05_Profile_UI_Profile_Edit";
|
||||
import { createProfileEditStore } from "./B05_Profile_UI_Profile_Edit";
|
||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import {
|
||||
buildStickyYAxis,
|
||||
createRouteMassHaulPanel,
|
||||
MASSHAUL_MIN_HEIGHT,
|
||||
type RouteMassHaulContext,
|
||||
} from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import {
|
||||
createProfileTable,
|
||||
tableCellWidthFor,
|
||||
TABLE_TARGET_FONT_PX,
|
||||
} from "./B05_Profile_UI_Profile_Table";
|
||||
import {
|
||||
irregularLabel,
|
||||
irregularStationId,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { renderBalanceBar } from "./B05_Profile_UI_Profile_Balance";
|
||||
import { createHeightCascade } from "./B05_Profile_UI_Profile_Heights";
|
||||
import { renderProfile } from "./B05_Profile_UI_Profile_Render";
|
||||
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import "../B06_Section/B06_Section_UI_Style.css";
|
||||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||||
@@ -97,8 +71,6 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
const MIN_PANEL_HEIGHT = 180;
|
||||
/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
|
||||
const TABLE_ROW_COUNT = 12;
|
||||
/** 계획고 편집 후 횡단 재계산을 서버에 묻기까지 기다리는 시간(ms).
|
||||
* ▲/▼ 길게 누르기(초당 10회)로 요청이 쏟아지지 않게 마지막 값만 보낸다. */
|
||||
const CROSS_PREVIEW_DEBOUNCE_MS = 250;
|
||||
@@ -110,108 +82,6 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 250;
|
||||
* `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] },
|
||||
}));
|
||||
}
|
||||
|
||||
/** 종단면도 렌더러와 **같은** 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 {
|
||||
@@ -406,7 +276,6 @@ export function createRouteProfilePanel(
|
||||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||||
let resizeTimer = 0;
|
||||
let redrawPending = false;
|
||||
let lightSyncPending = false;
|
||||
/** 횡단 설계 프리뷰 디바운스 타이머와 최신 요청 번호(늦게 온 응답 버리기용). */
|
||||
let crossPreviewTimer = 0;
|
||||
let crossPreviewSeq = 0;
|
||||
@@ -459,70 +328,18 @@ export function createRouteProfilePanel(
|
||||
}
|
||||
|
||||
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} 개`],
|
||||
// 기준은 길이 L이다. 옛 저장분(L 없음)만 그 시절 기준인 R을 그대로 밝혀 적는다.
|
||||
Number.isFinite(policy.default_curve_length_m)
|
||||
? ["기본 곡선길이 L", `${(policy.default_curve_length_m as number).toFixed(1)} m`]
|
||||
: ["기본 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", () => {
|
||||
renderBalanceBar({
|
||||
balanceBar,
|
||||
alignment,
|
||||
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
|
||||
edited: store.edited(),
|
||||
hasIrregularStations: irregularStations.length > 0,
|
||||
dirty: store.dirty(),
|
||||
onResetAll: () => {
|
||||
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이면 새 위반이 생기는 편집을 막는다. */
|
||||
@@ -603,384 +420,58 @@ export function createRouteProfilePanel(
|
||||
* draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
|
||||
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
|
||||
*/
|
||||
function applyHeightCascade(allowGrow: boolean): { chartHeight: number } {
|
||||
// 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다
|
||||
// (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소).
|
||||
const available = Math.max(120, body.clientHeight - STRUCTURE_LANE_HEIGHT_PX);
|
||||
const massOpen = !massHaul.overlay.hidden;
|
||||
const tableOpen = tableOverlay.isOpen();
|
||||
// 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면
|
||||
// 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이
|
||||
// 호출마다 번갈아 도는 진동이 되고, 풀리는 호출의 deficit 처리가 메인 패널을
|
||||
// 멋대로 키운다(2026-08-06 분석: 튐·자동 확대의 핵심 원인). 저장 높이 기준이면
|
||||
// 몇 번을 호출해도 같은 답이 나온다(멱등).
|
||||
const massDesired = massOpen ? massHaul.desiredHeight() : 0;
|
||||
const tableDesired = tableOpen ? tableOverlay.desiredHeight() : 0;
|
||||
let massHeight = massDesired;
|
||||
let tableOverlayHeight = tableDesired;
|
||||
const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
// ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(mainDragRef)대로 종단·유토곡선·테이블을 같이 늘리고
|
||||
// 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다.
|
||||
// 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다.
|
||||
if (mainPanelDragging && mainDragRef) {
|
||||
const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
const items = [
|
||||
{ key: "chart", ref: Math.max(1, mainDragRef.chart), min: MIN_CHART_HEIGHT },
|
||||
...(massOpen
|
||||
? [{ key: "mass", ref: Math.max(1, mainDragRef.mass), min: MASSHAUL_MIN_HEIGHT }]
|
||||
: []),
|
||||
...(tableOpen
|
||||
? [{ key: "table", ref: Math.max(1, mainDragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }]
|
||||
: []),
|
||||
];
|
||||
// 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다.
|
||||
const out: Record<string, number> = {};
|
||||
let pool = content;
|
||||
let active = items;
|
||||
while (active.length) {
|
||||
const refSum = active.reduce((sum, item) => sum + item.ref, 0);
|
||||
const scale = pool / refSum;
|
||||
const pinned = active.filter((item) => item.ref * scale < item.min);
|
||||
if (!pinned.length) {
|
||||
active.forEach((item) => (out[item.key] = Math.round(item.ref * scale)));
|
||||
break;
|
||||
}
|
||||
pinned.forEach((item) => {
|
||||
out[item.key] = item.min;
|
||||
pool -= item.min;
|
||||
});
|
||||
active = active.filter((item) => !pinned.includes(item));
|
||||
}
|
||||
// 서브패널 상한(개별 리사이저 max와 같은 비율)은 안전상 유지 — 넘치면 종단이 흡수.
|
||||
massHeight = massOpen ? Math.min(out.mass ?? 0, Math.round(available * 0.8)) : 0;
|
||||
tableOverlayHeight = tableOpen ? Math.min(out.table ?? 0, Math.round(available * 0.75)) : 0;
|
||||
if (massOpen) massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
// 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다.
|
||||
massHaul.syncHandle();
|
||||
tableOverlay.setBottomOffset(massHeight);
|
||||
return {
|
||||
chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight),
|
||||
};
|
||||
}
|
||||
if (subPanelDragging) {
|
||||
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를
|
||||
// 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로
|
||||
// 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다.
|
||||
massHaul.overlay.style.height = "";
|
||||
tableOverlay.overlay.style.height = "";
|
||||
} else if (massDesired + tableDesired > budget) {
|
||||
// ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한.
|
||||
// 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다.
|
||||
const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0;
|
||||
const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
const shrinkable = massDesired - massMin + (tableDesired - tableMin);
|
||||
const over = massDesired + tableDesired - budget;
|
||||
const ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1;
|
||||
massHeight = Math.round(massDesired - (massDesired - massMin) * ratio);
|
||||
tableOverlayHeight = Math.round(tableDesired - (tableDesired - tableMin) * ratio);
|
||||
// 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아
|
||||
// 패널을 다시 키우면 원래 높이로 돌아온다.
|
||||
if (massOpen) massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
} else {
|
||||
// 공간 충분 — 임시 축소 해제(저장 높이로 복귀).
|
||||
massHaul.overlay.style.height = "";
|
||||
tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
// 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는
|
||||
// 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition).
|
||||
tableOverlay.setBottomOffset(massHeight);
|
||||
// ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우)
|
||||
// 메인 패널을 키운다. 단 **메인 패널을 끄는 중과 손 뗀 직후 첫 재구성까지**는 절대
|
||||
// 안 된다 — 포인터가 정한 높이를 되돌려 서로 밀고 당기는 진동이 생긴다.
|
||||
const deficit =
|
||||
MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
|
||||
if (deficit > 1 && allowGrow && !mainPanelDragging && !mainDragCooldown) {
|
||||
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
|
||||
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
|
||||
// 상한으로 clamp되며 패널이 뚝 떨어졌다(2026-08-06 분석).
|
||||
const growCap = Math.round(
|
||||
(root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
||||
);
|
||||
const grown = Math.min(root.offsetHeight + deficit, growCap);
|
||||
if (grown > root.offsetHeight + 1) {
|
||||
root.style.setProperty("--b05-profile-height", `${grown}px`);
|
||||
// ResizeObserver가 새 높이로 draw를 다시 부른다 — 이번 프레임은 그대로 마저 그린다.
|
||||
}
|
||||
}
|
||||
return {
|
||||
chartHeight: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 경량 동기화를 다음 프레임에 1회 예약 — 메인 리사이즈(ResizeObserver)와
|
||||
* 서브패널 드래그(onResize 알림)가 같은 스로틀을 공유한다. */
|
||||
function scheduleLightSync(): void {
|
||||
if (lightSyncPending) return;
|
||||
lightSyncPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
lightSyncPending = false;
|
||||
syncHeightsLight();
|
||||
});
|
||||
}
|
||||
|
||||
/** 리사이즈 중 프레임당 경량 동기화 — 차트·테이블 재구성 없이 높이만 맞춰 끊김을 없앤다.
|
||||
* SVG는 잠깐 세로 스케일되지만, 손을 떼면(디바운스) 전체 재그리기가 정확히 다시 그린다. */
|
||||
function syncHeightsLight(): void {
|
||||
if (!detail) return;
|
||||
// 서브패널을 끄는 중엔 즉시 메인 패널을 밀어 올려야(grow) 드래그가 자연스럽다.
|
||||
const { chartHeight } = applyHeightCascade(subPanelDragging);
|
||||
const canvas = body.firstElementChild as HTMLElement | null;
|
||||
if (!canvas || !canvas.classList.contains("b05-profile__canvas")) return;
|
||||
canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`;
|
||||
const chartWrap = canvas.querySelector<HTMLElement>(".b05-profile__chart");
|
||||
if (chartWrap) {
|
||||
chartWrap.style.height = `${chartHeight}px`;
|
||||
const svg = chartWrap.querySelector("svg");
|
||||
if (svg) svg.style.height = `${chartHeight}px`;
|
||||
}
|
||||
}
|
||||
const heights = createHeightCascade({
|
||||
root,
|
||||
body,
|
||||
massHaul,
|
||||
tableOverlay,
|
||||
isMainDragging: () => mainPanelDragging,
|
||||
isSubDragging: () => subPanelDragging,
|
||||
mainDragRef: () => mainDragRef,
|
||||
isMainDragCooldown: () => mainDragCooldown,
|
||||
hasDetail: () => detail !== null,
|
||||
});
|
||||
const applyHeightCascade = (allowGrow: boolean): { chartHeight: number } =>
|
||||
heights.apply(allowGrow);
|
||||
const scheduleLightSync = (): void => heights.scheduleLightSync();
|
||||
|
||||
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 { chartHeight } = applyHeightCascade(true);
|
||||
// 전체 재구성이 한 번 돌면 배치가 확정된 것 — 다음 캐스케이드부터 grow를 다시 허용한다.
|
||||
mainDragCooldown = false;
|
||||
const tableHeight = tableOverlay.contentHeight();
|
||||
|
||||
const table =
|
||||
alignment && tableOverlay.isOpen() && tableHeight > 0
|
||||
? 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`;
|
||||
// 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시).
|
||||
// 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation).
|
||||
chartWrap.addEventListener("click", (event) => {
|
||||
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
|
||||
if (selectedStationId !== null) selectStation(null);
|
||||
});
|
||||
const designProfiles = alignment
|
||||
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
|
||||
: (longitudinal.design_profiles ?? []);
|
||||
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
||||
const graphData = normalizedLongitudinal(longitudinal);
|
||||
// 종단 정본(확정 시 병합분)에 들어 있는 비정규 측점은 걷어낸다 — 화면의 정본은 사이드바
|
||||
// 목록이고, 관을 옮기면 그 목록만 따라온다. 둘을 겹쳐 그리면 옮기기 전 자리의 세로선이
|
||||
// 그대로 남는다(2026-08-02 사용자 보고).
|
||||
const regular = graphData.stations.filter((station) => station.kind !== "irregular");
|
||||
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
|
||||
const graphLongitudinal = {
|
||||
...graphData,
|
||||
stations: [...regular, ...injected].sort((a, b) => a.chainage_m - b.chainage_m),
|
||||
};
|
||||
let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
|
||||
chartWrap.append(
|
||||
createLongitudinalProfile(
|
||||
graphLongitudinal,
|
||||
selectedStationId,
|
||||
1,
|
||||
undefined,
|
||||
selectStation,
|
||||
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);
|
||||
},
|
||||
// 계획고 편집 ▼ 버튼(바닥 2px + 높이 17px)과 측점 라벨이 겹치지 않게 X축·라벨을
|
||||
// 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다
|
||||
// (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지.
|
||||
15,
|
||||
),
|
||||
);
|
||||
// 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다.
|
||||
mountStructureMenu(chartWrap, {
|
||||
stations: irregularStations,
|
||||
x,
|
||||
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
onRemove: (station) => callbacks?.onStructureRemove?.(station),
|
||||
onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage),
|
||||
onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type),
|
||||
// 우클릭 한 번으로 끝나는 타입만 메뉴에 올린다 — 계곡 통과 시설(managed_by)은
|
||||
// 관 지점 정본 소관이라 위의 [배관 추가]가 따로 맡는다. 상세(detail) 필수는
|
||||
// B06/B07 몫이라 막지 않고, B05 단계(b05) 필수만 사이드 폼으로 보낸다
|
||||
// (2026-08-17 phase 분리 — B05는 유무·종류·위치 단계).
|
||||
structureTypes: structureTypes
|
||||
.filter(
|
||||
(type) =>
|
||||
!type.managed_by &&
|
||||
!type.options.some((option) => option.required && (option.phase ?? "b05") !== "detail"),
|
||||
)
|
||||
.map((type) => ({ type_id: type.type_id, group: type.group, name: type.name })),
|
||||
onAddStructureType: (chainage, typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId),
|
||||
});
|
||||
// 구조물 알약 레인 — 그래프 아래 별도 줄(2026-08-17 사용자 지시 1·3). 자동 배수관도
|
||||
// 세로 점선이 아니라 같은 알약으로 여기에 놓인다.
|
||||
const structureLane = buildStructureLane({
|
||||
structures,
|
||||
types: structureTypes,
|
||||
x,
|
||||
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
widthPx: width,
|
||||
// 그래프 sticky Y축과 같은 폭으로 레인 축을 이어 붙인다(2026-08-17 지시 2).
|
||||
axisWidthPx: LONG_PAD.left,
|
||||
// 벌룬·툴팁 위치 표기는 측점번호+잔여거리(2026-08-17 사용자 확정).
|
||||
stationIntervalM: stationInterval ?? 20,
|
||||
selectedId: selectedStructureId,
|
||||
onSelect: (structureId) => {
|
||||
selectedStructureId = structureId;
|
||||
// 같은 자리 측점 세로선도 함께 켜고 끈다 — 알약과 세로선은 한 구조물이다.
|
||||
selectedStationId = stationIdAtStructure(structureId);
|
||||
callbacks?.onStructureSelect?.(structureId);
|
||||
draw();
|
||||
renderProfile({
|
||||
body,
|
||||
callbacks,
|
||||
massHaul,
|
||||
tableOverlay,
|
||||
store,
|
||||
detail: () => detail,
|
||||
alignment: () => alignment,
|
||||
base: () => base,
|
||||
stationInterval: () => stationInterval,
|
||||
irregularStations: () => irregularStations,
|
||||
structures: () => structures,
|
||||
structureTypes: () => structureTypes,
|
||||
selectedStationId: () => selectedStationId,
|
||||
setSelectedStationId: (value) => {
|
||||
selectedStationId = value;
|
||||
},
|
||||
onMove: (structureId, toChainage) =>
|
||||
callbacks?.onStructureMarkMove?.(structureId, toChainage),
|
||||
selectedStructureId: () => selectedStructureId,
|
||||
setSelectedStructureId: (value) => {
|
||||
selectedStructureId = value;
|
||||
},
|
||||
stationDisplay: () => stationDisplay,
|
||||
setLastSize: (width, height) => {
|
||||
lastWidth = width;
|
||||
lastHeight = height;
|
||||
},
|
||||
renderBalance,
|
||||
applyHeightCascade,
|
||||
clearMainDragCooldown: () => {
|
||||
mainDragCooldown = false;
|
||||
},
|
||||
selectStation,
|
||||
applyEdits,
|
||||
stationIdAtStructure,
|
||||
redraw: draw,
|
||||
});
|
||||
// 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단).
|
||||
// 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트
|
||||
// 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙).
|
||||
if (yAxis) chartWrap.prepend(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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 캔버스 높이 = 종단도 영역만. 서브패널이 차지한 아래 공간을 덮지 않는다(밀어올리기).
|
||||
// 구조물 알약 레인이 그래프 밑에 한 줄 붙으므로 그만큼 캔버스가 커진다(2026-08-17 지시 6).
|
||||
canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`;
|
||||
canvas.append(chartWrap, structureLane);
|
||||
body.replaceChildren(canvas);
|
||||
body.scrollLeft = scrollLeft;
|
||||
// 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다.
|
||||
if (table) table.style.width = `${width}px`;
|
||||
tableOverlay.setTable(table);
|
||||
|
||||
// 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정).
|
||||
// X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다.
|
||||
if (alignment && designProfiles[0]) {
|
||||
massHaul.draw({
|
||||
stationSource: graphLongitudinal,
|
||||
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
|
||||
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
|
||||
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
|
||||
crossSections: detail.cross_sections,
|
||||
axis: {
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
||||
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
|
||||
padLeft: LONG_PAD.left + originOffset,
|
||||
padRight: LONG_PAD.right + originOffset,
|
||||
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게.
|
||||
axisX: LONG_PAD.left,
|
||||
// 범례·기준 버튼 오버레이(top 34px)가 곡선 위에 떠서 그만큼 상단 여유를 준다
|
||||
// (2026-08-05 사용자 보고: 버튼과 커브 겹침).
|
||||
padTop: 40,
|
||||
},
|
||||
stationInterval: stationIntervalM ?? 1,
|
||||
widthPx: width,
|
||||
selectedStationId,
|
||||
onSelectStation: selectStation,
|
||||
onClearSelection: () => {
|
||||
if (selectedStationId !== null) selectStation(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Render.ts
|
||||
* 종단 패널 본문 재구성 — 캔버스·그래프·구조물 레인·테이블·유토곡선을 한 번에 그린다.
|
||||
*
|
||||
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
|
||||
* 상태는 여전히 본체가 들고 있고 여기서는 컨텍스트로 받아 **그리기만** 한다 —
|
||||
* 그리는 도중 상태를 바꾸는 자리(선택 변경·드래그 쿨다운 해제)는 컨텍스트의 세터를
|
||||
* 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
} from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
adjustStation,
|
||||
setCurveRadius,
|
||||
shiftSegment,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
type ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit";
|
||||
import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { createProfileTable } from "./B05_Profile_UI_Profile_Table";
|
||||
import {
|
||||
CELL_GAP_PX,
|
||||
chainageInverter,
|
||||
chainageMapper,
|
||||
computeProfileLayout,
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
STATION_SPACING_PX,
|
||||
TABLE_ROW_COUNT,
|
||||
} from "./B05_Profile_UI_Profile_Layout";
|
||||
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures";
|
||||
import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
|
||||
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
|
||||
import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel";
|
||||
|
||||
/** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */
|
||||
export interface ProfileRenderContext {
|
||||
body: HTMLElement;
|
||||
callbacks: RouteProfilePanelCallbacks | undefined;
|
||||
massHaul: { draw: (params: RouteMassHaulDrawParams) => void };
|
||||
tableOverlay: {
|
||||
isOpen: () => boolean;
|
||||
contentHeight: () => number;
|
||||
setTable: (table: HTMLElement | null) => void;
|
||||
};
|
||||
store: {
|
||||
edits: () => AlignmentEdits;
|
||||
resetStation: (chainageM: number) => void;
|
||||
};
|
||||
detail: () => SectionDetailResponse | null;
|
||||
alignment: () => ProfileAlignment | null;
|
||||
base: () => AlignmentBase | null;
|
||||
stationInterval: () => number | undefined;
|
||||
irregularStations: () => IrregularStation[];
|
||||
structures: () => StructureInstance[];
|
||||
structureTypes: () => StructureType[];
|
||||
selectedStationId: () => string | null;
|
||||
setSelectedStationId: (value: string | null) => void;
|
||||
selectedStructureId: () => string | null;
|
||||
setSelectedStructureId: (value: string | null) => void;
|
||||
stationDisplay: () => { station: number; cumulative: number };
|
||||
/** 본문 크기 기록 — 다음 리사이즈에서 재구성이 필요한지 판단하는 값. */
|
||||
setLastSize: (width: number, height: number) => void;
|
||||
renderBalance: () => void;
|
||||
applyHeightCascade: (allowGrow: boolean) => { chartHeight: number };
|
||||
/** 전체 재구성이 한 번 돌면 배치가 확정된 것 — 자동 확대를 다시 허용한다. */
|
||||
clearMainDragCooldown: () => void;
|
||||
selectStation: (stationId: string | null) => void;
|
||||
applyEdits: (next: AlignmentEdits) => void;
|
||||
stationIdAtStructure: (structureId: string | null) => string | null;
|
||||
/** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */
|
||||
redraw: () => void;
|
||||
}
|
||||
|
||||
/** 본문을 통째로 다시 그린다. 상세가 없거나 본문이 아직 0크기면 아무것도 하지 않는다. */
|
||||
export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
const { body, callbacks, massHaul, tableOverlay, store } = ctx;
|
||||
const detail = ctx.detail();
|
||||
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
|
||||
const alignment = ctx.alignment();
|
||||
const base = ctx.base();
|
||||
const stationInterval = ctx.stationInterval();
|
||||
const irregularStations = ctx.irregularStations();
|
||||
const structures = ctx.structures();
|
||||
const structureTypes = ctx.structureTypes();
|
||||
const selectedStationId = ctx.selectedStationId();
|
||||
const selectedStructureId = ctx.selectedStructureId();
|
||||
const stationDisplay = ctx.stationDisplay();
|
||||
|
||||
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
|
||||
ctx.setLastSize(body.clientWidth, body.clientHeight);
|
||||
// 편집할 때마다 본문을 갈아끼우므로 보고 있던 가로 위치를 잃지 않게 되돌린다.
|
||||
const scrollLeft = body.scrollLeft;
|
||||
ctx.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 { chartHeight } = ctx.applyHeightCascade(true);
|
||||
// 전체 재구성이 한 번 돌면 배치가 확정된 것 — 다음 캐스케이드부터 grow를 다시 허용한다.
|
||||
ctx.clearMainDragCooldown();
|
||||
const tableHeight = tableOverlay.contentHeight();
|
||||
|
||||
const table =
|
||||
alignment && tableOverlay.isOpen() && tableHeight > 0
|
||||
? 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) =>
|
||||
ctx.applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
|
||||
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
|
||||
onAdjustStation: (chainage, delta) =>
|
||||
base && ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)),
|
||||
})
|
||||
: null;
|
||||
|
||||
const chartWrap = document.createElement("div");
|
||||
chartWrap.className = "b05-profile__chart";
|
||||
chartWrap.style.height = `${chartHeight}px`;
|
||||
// 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시).
|
||||
// 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation).
|
||||
chartWrap.addEventListener("click", (event) => {
|
||||
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
|
||||
if (ctx.selectedStationId() !== null) ctx.selectStation(null);
|
||||
});
|
||||
const designProfiles = alignment
|
||||
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
|
||||
: (longitudinal.design_profiles ?? []);
|
||||
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
||||
const graphData = normalizedLongitudinal(longitudinal);
|
||||
// 종단 정본(확정 시 병합분)에 들어 있는 비정규 측점은 걷어낸다 — 화면의 정본은 사이드바
|
||||
// 목록이고, 관을 옮기면 그 목록만 따라온다. 둘을 겹쳐 그리면 옮기기 전 자리의 세로선이
|
||||
// 그대로 남는다(2026-08-02 사용자 보고).
|
||||
const regular = graphData.stations.filter((station) => station.kind !== "irregular");
|
||||
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
|
||||
const graphLongitudinal = {
|
||||
...graphData,
|
||||
stations: [...regular, ...injected].sort((a, b) => a.chainage_m - b.chainage_m),
|
||||
};
|
||||
let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
|
||||
chartWrap.append(
|
||||
createLongitudinalProfile(
|
||||
graphLongitudinal,
|
||||
selectedStationId,
|
||||
1,
|
||||
undefined,
|
||||
ctx.selectStation,
|
||||
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);
|
||||
},
|
||||
// 계획고 편집 ▼ 버튼(바닥 2px + 높이 17px)과 측점 라벨이 겹치지 않게 X축·라벨을
|
||||
// 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다
|
||||
// (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지.
|
||||
15,
|
||||
),
|
||||
);
|
||||
// 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다.
|
||||
mountStructureMenu(chartWrap, {
|
||||
stations: irregularStations,
|
||||
x,
|
||||
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
onRemove: (station) => callbacks?.onStructureRemove?.(station),
|
||||
onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage),
|
||||
onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type),
|
||||
// 우클릭 한 번으로 끝나는 타입만 메뉴에 올린다 — 계곡 통과 시설(managed_by)은
|
||||
// 관 지점 정본 소관이라 위의 [배관 추가]가 따로 맡는다. 상세(detail) 필수는
|
||||
// B06/B07 몫이라 막지 않고, B05 단계(b05) 필수만 사이드 폼으로 보낸다
|
||||
// (2026-08-17 phase 분리 — B05는 유무·종류·위치 단계).
|
||||
structureTypes: structureTypes
|
||||
.filter(
|
||||
(type) =>
|
||||
!type.managed_by &&
|
||||
!type.options.some((option) => option.required && (option.phase ?? "b05") !== "detail"),
|
||||
)
|
||||
.map((type) => ({ type_id: type.type_id, group: type.group, name: type.name })),
|
||||
onAddStructureType: (chainage, typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId),
|
||||
});
|
||||
// 구조물 알약 레인 — 그래프 아래 별도 줄(2026-08-17 사용자 지시 1·3). 자동 배수관도
|
||||
// 세로 점선이 아니라 같은 알약으로 여기에 놓인다.
|
||||
const structureLane = buildStructureLane({
|
||||
structures,
|
||||
types: structureTypes,
|
||||
x,
|
||||
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
widthPx: width,
|
||||
// 그래프 sticky Y축과 같은 폭으로 레인 축을 이어 붙인다(2026-08-17 지시 2).
|
||||
axisWidthPx: LONG_PAD.left,
|
||||
// 벌룬·툴팁 위치 표기는 측점번호+잔여거리(2026-08-17 사용자 확정).
|
||||
stationIntervalM: stationInterval ?? 20,
|
||||
selectedId: selectedStructureId,
|
||||
onSelect: (structureId) => {
|
||||
ctx.setSelectedStructureId(structureId);
|
||||
// 같은 자리 측점 세로선도 함께 켜고 끈다 — 알약과 세로선은 한 구조물이다.
|
||||
ctx.setSelectedStationId(ctx.stationIdAtStructure(structureId));
|
||||
callbacks?.onStructureSelect?.(structureId);
|
||||
ctx.redraw();
|
||||
},
|
||||
onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage),
|
||||
});
|
||||
// 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단).
|
||||
// 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트
|
||||
// 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙).
|
||||
if (yAxis) chartWrap.prepend(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 && ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)),
|
||||
onSegment: (segment, delta) =>
|
||||
base && ctx.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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 캔버스 높이 = 종단도 영역만. 서브패널이 차지한 아래 공간을 덮지 않는다(밀어올리기).
|
||||
// 구조물 알약 레인이 그래프 밑에 한 줄 붙으므로 그만큼 캔버스가 커진다(2026-08-17 지시 6).
|
||||
canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`;
|
||||
canvas.append(chartWrap, structureLane);
|
||||
body.replaceChildren(canvas);
|
||||
body.scrollLeft = scrollLeft;
|
||||
// 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다.
|
||||
if (table) table.style.width = `${width}px`;
|
||||
tableOverlay.setTable(table);
|
||||
|
||||
// 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정).
|
||||
// X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다.
|
||||
if (alignment && designProfiles[0]) {
|
||||
massHaul.draw({
|
||||
stationSource: graphLongitudinal,
|
||||
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
|
||||
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
|
||||
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
|
||||
crossSections: detail.cross_sections,
|
||||
axis: {
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
||||
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
|
||||
padLeft: LONG_PAD.left + originOffset,
|
||||
padRight: LONG_PAD.right + originOffset,
|
||||
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게.
|
||||
axisX: LONG_PAD.left,
|
||||
// 범례·기준 버튼 오버레이(top 34px)가 곡선 위에 떠서 그만큼 상단 여유를 준다
|
||||
// (2026-08-05 사용자 보고: 버튼과 커브 겹침).
|
||||
padTop: 40,
|
||||
},
|
||||
stationInterval: stationIntervalM ?? 1,
|
||||
widthPx: width,
|
||||
selectedStationId,
|
||||
onSelectStation: ctx.selectStation,
|
||||
onClearSelection: () => {
|
||||
if (ctx.selectedStationId() !== null) ctx.selectStation(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,11 @@
|
||||
* 항목을 누르면 넘겨받은 콜백으로 폼 로드를 되돌려 준다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { structureAnchorM, type StructureInstance, type StructureType } from "./B05_Profile_Api_Structures";
|
||||
import {
|
||||
structureAnchorM,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel";
|
||||
import { formatStation } from "./B05_Profile_Util_Station";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user