/* ============================================================================= * 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 { 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 = 0.51; /** 종단 우클릭의 옛 자유 텍스트 구조물 → 레지스트리 타입(종단 보기와 같은 매핑). */ 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; } /** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */ function pipeStations( marks: ReadonlyArray, types: ReadonlyArray, intervalM: number, ): IrregularStation[] { return marks .filter((mark) => isPipeMark(mark) && mark.type_id !== "ford_pavement") .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 = []; let types: ReadonlyArray = []; let stations: IrregularStation[] = []; let intervalM = 20; /** 가까운 카드 — 관 측점 · 구조물 자리에서 카드를 고를 때. */ 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; }; 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: { onCrossDesignsUpdated: (updated) => 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), // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. onStructureSelect: (structureId) => { const hit = marks.find((entry) => entry.structure_id === structureId); if (hit) host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); }, 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(); }, 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); }, }; }