/* ============================================================================= * B06_Section_UI_Section_View_Profile.ts * 횡단 보기의 종단도 — 종단 보기와 **같은 노드 한 벌**(`design_shared` 의 종단도)에 횡단 보기 * 알림을 걸어 횡단 카드와 선택 · 구조물 편집 · 재계산을 잇는다(계획서 24-2c · 24-3b). * * 옛 위쪽 붙박이 종단(자체 그래프 · 유토곡선 없음 · 흙덮기 막기 없음)을 갈음한다. * 도구(▲▼ · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · 뱃지)와 막기 규칙 * (최소 흙덮기 · 기울기 상한)은 종단도 한 벌이 맡고, 여기서는 이음만 한다. * ========================================================================== */ import type { DesignProfile } from "../A00_Common/design/design_profile"; import { bindDesignView, sharedProfile, type DesignPageShared, } from "../A00_Common/design/design_shared"; import { readState } from "../A00_Common/b_page_state"; import { coversChainage, NO_SELECTION, PIPE_MATCH_M, readSelection, selectDesign, } from "../A00_Common/design/design_selection"; import { paintMapPick } from "../A00_Common/design/design_pick_profile"; import { ESCAPE_FROM } from "../A00_Common/design/design_pick_escape"; import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; import type { CrossSection, EarthworkConversion, HaulEquipmentLimit, SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { structureAnchorM, type StructureInstance, type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; import { irregularStationId, isPipeStation, type IrregularStation, } from "../B05_Profile/B05_Profile_UI_IrregularStations"; import { isPipeMark, moveMarkById, type SectionStructureEdit, } from "./B06_Section_UI_Section_View_Menu"; /** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */ const MATCH_M = PIPE_MATCH_M; /** 종단 우클릭의 옛 자유 텍스트 구조물 → 레지스트리 타입(종단 보기와 같은 매핑). */ const LEGACY_TYPE_IDS: Record = { 기성막이: "revetment", 대피로: "refuge", 기타: "etc", }; export interface SectionProfileHost { /** 종단도에서 측점을 골랐다 — 그 카드를 고르고 보이게 한다(null = 해제). */ selectCard: (stationId: string | null) => void; /** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭 · 끌기가 아무것도 안 함). */ edit: () => SectionStructureEdit | null; /** 계획고 편집으로 다시 계산한 측점 카드만 갈아 끼운다. */ refreshCards: (chainages: ReadonlyArray) => void; } export interface SectionProfile { root: () => HTMLElement | null; render: (input: { detail: SectionDetailResponse; balloonScope: string; stationInterval: number; conversion?: EarthworkConversion; haulLimits?: HaulEquipmentLimit[]; naturalSpoilMinSlope?: number; }) => void; setMarks: ( structures: ReadonlyArray, types: ReadonlyArray, ) => void; /** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */ redraw: () => void; /** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */ showCard: (section: CrossSection | null) => void; /** 알약 강조를 그 구조물로(null = 풀기). */ selectMark: (structureId: string | null) => void; } /** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */ function pipeStations( marks: ReadonlyArray, types: ReadonlyArray, intervalM: number, ): IrregularStation[] { return marks.filter(isPipeMark).map((mark) => { const chainage = structureAnchorM(mark); const station = Math.floor(chainage / intervalM); return { id: String(mark.structure_id), station, remainder: chainage - station * intervalM, chainage_m: chainage, structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관", structureType: "배관", origin: "pipe", } as IrregularStation; }); } /** * 배수유역도에 노선 평면선을 싣는다 — 종단 보기가 두고 간 최신 노선 캐시를 먼저 쓰고, * 없으면 한 번 읽는다(읽기만 · 계산 없음). */ function loadRoute(projectId: string, profile: DesignProfile): void { // 최소 흙덮기 강제 여부 — 종단 보기를 거치지 않고 바로 들어왔으면 저장된 노선 설정값. const enforceKnown = readState("profile-enforce-min-cover", projectId) !== null; const apply = (latest: RouteLatestResponse): void => { profile.setRoutePolyline(latest.route_points ?? []); if (!enforceKnown) { profile.setEnforceMinCover(latest.route_params?.enforce_pipe_clearance === true); } }; const cached = readState("latest", projectId); if (cached?.route_points?.length) { apply(cached); return; } void fetchLatestRoute(projectId) .then(apply) .catch(() => { /* 노선을 못 읽으면 판이 「노선 필요」 안내를 그대로 보인다. */ }); } export function createSectionProfile( host: SectionProfileHost, shared: DesignPageShared, ): SectionProfile { let profile: DesignProfile | null = null; let detail: SectionDetailResponse | null = null; let marks: ReadonlyArray = []; /** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */ let markedId: string | null = null; let types: ReadonlyArray = []; let stations: IrregularStation[] = []; let intervalM = 20; /** 마지막으로 받은 관 목록 지문(자리 · 종류 · 관경) — 바뀔 때만 카드를 다시 얹음. */ let pipeSignature: string | null = null; /** 가까운 카드 — 관 측점 · 구조물 자리에서 카드를 고를 때. */ const nearestCard = (chainageM: number): CrossSection | null => { let best: CrossSection | null = null; for (const section of detail?.cross_sections ?? []) { if (!best || Math.abs(section.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM)) best = section; } return best; }; /** 종단도 측점 id → 카드 id. 관 측점(`irregular:`)은 누가거리로 카드를 찾는다. */ const cardOf = (stationId: string | null): string | null => { if (stationId === null) return null; const pipe = stations.find((entry) => irregularStationId(entry.id) === stationId); if (pipe) return nearestCard(pipe.chainage_m)?.station_id ?? null; return stationId; }; /** 배수유역도에서 고른 관 자리 → 고름 한 칸(null = 풂) — 횡단 보기 구독이 카드 · 폼 · 알약 · 표 열을 * 그 값으로 세움(종단 보기 `pickFromMap` 과 같은 몫 · 270). */ const pickFromMap = (chainageM: number | null): void => selectDesign( shared.projectId, chainageM === null ? NO_SELECTION : { chainageM, structureId: null }, { from: "map" }, ); const syncMarks = (): void => { if (!profile) return; profile.setStructureTypes([...types]); profile.setStructures([...marks]); stations = pipeStations(marks, types, intervalM); profile.setIrregularStations(stations); // 판(배수유역도)이 없는 보기 — 관 목록을 직접 넘겨 최소 흙덮기를 판정한다. profile.setMinCoverPipes( marks.filter(isPipeMark).map((mark) => ({ chainage_m: structureAnchorM(mark), facility: mark.type_id as never, options: mark.options, })), ); }; /** 한 벌 종단도에 횡단 보기 알림을 건다 — 횡단 보기가 보일 때만 이쪽으로 온다. */ const build = (): DesignProfile => { bindDesignView(shared, "cross", { profile: { onSelectStation: (stationId) => host.selectCard(cardOf(stationId)), callbacks: { // 숨은 횡단 보기는 건너뜀 — 다시 보일 때 정합이 카드를 맞춤(onShow) onCrossDesignsUpdated: (updated) => { if (shared.view() === "cross") host.refreshCards(updated); }, onStructureMove: (from, to, station) => { if (isPipeStation(station)) host.edit()?.movePipe(from, to); }, onStructureRemove: (station) => { if (isPipeStation(station)) host.edit()?.removePipe(station.chainage_m); }, onPipeAdd: (chainage) => host.edit()?.addPipe(chainage), onStructureAdd: (chainage, type) => host.edit()?.addStructureType(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), onStructureTypeAdd: (chainage, typeId) => host.edit()?.addStructureType(chainage, typeId), onIrregularSelect: (station) => host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null), onBasinSelected: (chainageM) => pickFromMap(chainageM), onPipeSelected: (chainageM) => pickFromMap(chainageM), // 배수 재계산이 지운 지도 강조를 값대로 다시 얹음(종단 보기 restorePick 과 같은 몫 · 270) onPipesChanged: (pipes) => { if (shared.view() === "cross" && shared.profile?.side) paintMapPick(shared.profile.side, readSelection(shared.projectId)); // 관 종류 · 관경이 바뀜(새 관 관경은 배수 재계산이 정함) — 카드 관 그림도 다시(273-2) const signature = JSON.stringify( pipes.map((p) => [p.chainage_m, p.facility, p.options?.pipe_diameter_mm ?? null]), ); const changed = pipeSignature !== null && signature !== pipeSignature; pipeSignature = signature; if (changed && shared.view() === "cross") host.edit()?.pipesChanged?.(); }, // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. // 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15). onStructureSelect: (structureId) => { // 알약 다시 누름 = 풀기 한 길(Esc 와 같은 값 — 카드 · 폼 · 알약 · 범위 테두리 모두 · 요구 178) if (structureId === null) { selectDesign(shared.projectId, NO_SELECTION, { from: ESCAPE_FROM }); return; } const hit = marks.find((entry) => entry.structure_id === structureId); if (!hit) return; const edit = host.edit(); if (!isPipeMark(hit) && hit.structure_id && edit) { edit.selectStructure(hit.structure_id); return; } // 측점 없는 관(물넘이포장) = 카드 안 고름 · 그 자리로 스크롤만 · 그 관 폼 · 알약만(요구 178) if (edit?.selectPipe && !plantsStation(hit, types)) { edit.selectPipe(structureAnchorM(hit)); return; } // 목록에서 풀어도 카드는 남음 — 같은 카드면 「다시 보기」만 돼 관이 안 열렸다(53-2) host.selectCard(null); host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); profile?.setSelectedStructure(structureId); // 측점선 없는 물넘이포장도 그 알약 }, onStructureMarkMove: (structureId, toChainageM) => { const edit = host.edit(); if (!edit) return; moveMarkById(structureId, toChainageM, { structures: marks, stations: detail?.longitudinal.stations ?? [], edit, }); }, }, }, }); return sharedProfile(shared); }; return { root: () => profile?.root ?? null, render(input) { const [id, route] = input.balloonScope.split(":"); if (!id || id === "-") return; if (!profile) { // 종단 보기가 먼저 세웠으면 노선 · 흙덮기 설정은 이미 실려 있다(판 확대를 흔들지 않음). const fresh = !shared.profile; profile = build(); if (fresh) loadRoute(id, profile); syncMarks(); } detail = input.detail; intervalM = input.stationInterval; const routeId = Number(route); profile.render( input.detail, input.stationInterval, Number.isFinite(routeId) ? routeId : undefined, ); if (input.conversion) { profile.setEarthworkContext({ conversion: input.conversion, haulLimits: input.haulLimits, naturalSpoilMinSlope: input.naturalSpoilMinSlope, }); } syncMarks(); }, setMarks(nextMarks, nextTypes) { marks = nextMarks; types = nextTypes; syncMarks(); }, redraw() { profile?.redraw(); }, selectMark(structureId) { markedId = structureId; profile?.setSelectedStructure(structureId, true); // 카드가 측점선 주인 — 선 없는 구조물 · 풂은 카드 선 지킴(177-3) }, showCard(section) { if (!profile) return; if (!section) { profile.setSelectedStation(null); return; } const pipe = stations.find( (entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M, ); profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id); // 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1) const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined; if (!pipe && marked && coversChainage(marked, section.chainage_m)) profile.setSelectedStructure(markedId, true); }, }; }