diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index d02afc3c..53c62e38 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -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 { 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 { // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 // (가상 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 { 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 { 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 { } 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 { 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 { 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 { 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 { 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 { viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2); } - /** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지). */ - const FACILITY_NAMES: Record = { - 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 = Promise.resolve(); - - /* ── 횡단배수(A군) 측점 세로선 ────────────────────────────────────────── - * 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅 - * 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거· - * 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */ - let pipeStations: IrregularStation[] = []; - let structureTypeMap = new Map(); - - /** 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; - }>, - ): 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 { - 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 { - 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 { - 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 { .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 { 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 { if (currentSectionDetail) renderStationLines(currentSectionDetail); } // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). - await loadStructures(); + await bridge.load(); advanceLoading(""); } catch (error) { showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index f2cd97cc..c1132925 100644 --- a/B05_Profile/B05_Profile_UI_Page_Helpers.ts +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -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 = { + pipe: "배수관", + box_culvert: "BOX암거", + ford_pavement: "물넘이포장", + ford_bridge: "세월교", +}; diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts new file mode 100644 index 00000000..3361e062 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -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; +} + +export interface StructuresBridgeDeps { + projectId: string; + /** 사이드 패널 — 생성 순서상 나중에 만들어지므로 게터로 받는다. */ + panel: () => ReturnType; + profilePanel: () => ReturnType; + /** 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 = Promise.resolve(); + + /* ── 횡단배수(A군) 측점 세로선 ────────────────────────────────────────── + * 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅 + * 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거· + * 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */ + let pipeStations: IrregularStation[] = []; + let structureTypeMap = new Map(); + + /** 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; + }>, + ): 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 { + 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 { + 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 { + 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, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts new file mode 100644 index 00000000..01f26ed0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -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); + } +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts new file mode 100644 index 00000000..170c37ed --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -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 = {}; + 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(".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 }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Layout.ts b/B05_Profile/B05_Profile_UI_Profile_Layout.ts new file mode 100644 index 00000000..8a2a50b0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Layout.ts @@ -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) }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index c0f01a90..16f0e6ae 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -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 = {}; - 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(".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); - }, - }); - } } // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts new file mode 100644 index 00000000..5fa6a103 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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); + }, + }); + } +} diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index 78d4585d..a8d897d5 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -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";