diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 75ec3d42f..d315b7d3e 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -142,6 +142,8 @@ export const STATE_REGISTRY = { }, /** 관 세션 id 장부(323-5 · `design_pipe_ids`) — `{next, ids: [{id, at}]}` · 저장 안 함. */ "pipe-ids": { bucket: "draft", scope: "project" }, + /** 횡단 보기가 서기 전 종단 보기 폼이 고친 관 옵션(세월교 · BOX) `[[누가거리, 옵션]]` — 횡단 보기가 서면 캐시에 얹고 비움(462). */ + "pipe-option-queue": { bucket: "draft", scope: "project" }, /** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */ selection: { bucket: "draft", scope: "project" }, /** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */ diff --git a/A00_Common/design/design_facility_lane.ts b/A00_Common/design/design_facility_lane.ts new file mode 100644 index 000000000..3cc92d480 --- /dev/null +++ b/A00_Common/design/design_facility_lane.ts @@ -0,0 +1,219 @@ +/* ============================================================================= + * A00_Common/design/design_facility_lane.ts + * B05 · B06 한 페이지 **시설 줄 한 벌**(요구 271-5 · 462) — 구조물 + 관 → 알약 · 비정규 측점 · 흙덮기 관 · + * 관 id 장부 맞춤 · 평면 마커 원천. + * + * 페이지가 세움 — 보기가 숨어도 · 아직 안 섰어도 늘 셈(순수 계산 · 서버 GET 없음). 종단도에 알약 · 측점 · + * 흙덮기 관을 쓰는 자리는 여기 하나 · 보기는 읽기만(`marks` · `stations` · `pipes`) · 바뀜은 `onChange`. + * 입력 = 관 알림(배수유역도 · B06 관 목록) · 구조물(보기 고침 · 저장분) · 종류 표 · 측점 간격 · 추가 측점(가게) + * 관 id 장부 = 관 고정 id 가 쥔 세션 id 를 그 자리로(411-B) · 헛 id 정리(411-E) — 어느 보기에서 고쳐도 + * 계획고 편집 옮김(사라진 측점 → 새 측점 · 213-2) = 사용자 고침만 · 노선 따라감(`follow`)은 안 옮김(계산 묶음 몫 · 349-0d) + * ========================================================================== */ + +import { useDesignStore } from "./design_store"; +import { pipeIdAt, placePipeIds, prunePipeIds, withPipeIds } from "./design_pipe_ids"; +import { plannedIrregular } from "./design_structure_status"; +import { planStations } from "../../common_util/common_util_structure_station"; +import { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow"; +import { followedStation } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; +import { followSnapped } from "../../B05_Profile/B05_Profile_UI_Structures_Snap"; +import { + pipesToStructureMarks, + type StructureInstance, + type StructureType, +} from "../../B05_Profile/B05_Profile_Api_Structures"; +import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; +import type { MinCoverPipe } from "../../B05_Profile/B05_Profile_UI_Profile_MinCover"; + +/** 관 한 건 — 배수유역도 알림 · B06 관 목록 어느 쪽이든(`id` = 관 고정 id · 새 관은 없음). */ +export interface LanePipe { + chainage_m: number; + id?: string | null; + facility: string; + options?: Record | null; + start_m?: number | null; + end_m?: number | null; +} + +/** 시설 줄이 쓰는 종단도 몫. */ +export interface LaneProfile { + setStructureTypes: (types: StructureType[]) => void; + setStructures: (next: StructureInstance[]) => void; + setIrregularStations: (stations: IrregularStation[]) => void; + setMinCoverPipes: (pipes: ReadonlyArray) => void; + resetStationEdit: (chainageM: number, moveTo?: number) => void; +} + +export interface FacilityLane { + /** 종류 표 · 저장분 구조물(보기가 받은 것 — 같은 페이지 자료 줄). */ + setTypes: (types: StructureType[]) => void; + setStored: (structures: StructureInstance[]) => void; + /** 구조물 · 관 목록이 바뀜 — `carryEdits` = 사용자 고침(계획고 편집 옮김) · 불러옴은 false. */ + set: ( + next: { structures?: StructureInstance[]; pipes?: ReadonlyArray }, + carryEdits?: boolean, + ) => void; + /** 노선을 놓아 계산 묶음이 가게 관 · 구조물을 다시 매김(되돌림 포함) — 편집 안 옮김. */ + follow: () => void; + setIntervalM: (intervalM: number) => void; + /** [초기선 복원] — 관 투영 · 측점만 비움(관 정본은 배수유역 몫). */ + clearProjected: () => void; + /** 다시 셈(편집 안 옮김) · 종단도에 다시 씀 — 종단도가 막 섰을 때 · 다시 그렸을 때(추가 측점이 늦게 옴). */ + paint: () => void; + pipes: () => LanePipe[]; + structures: () => StructureInstance[]; + types: () => StructureType[]; + /** 알약 = 구조물 + 관(가상 구조물 · 관 세션 id) — 평면 마커도 이것. */ + marks: () => StructureInstance[]; + stations: () => IrregularStation[]; + intervalM: () => number; + onChange: (fn: () => void) => () => void; + dispose: () => void; +} + +const run = (fn: () => void): void => { + try { + fn(); + } catch (error) { + console.error("[facility_lane]", error); + } +}; + +export function createFacilityLane( + projectId: string, + profile: () => LaneProfile | null, +): FacilityLane { + const store = useDesignStore(projectId, null); + let types: StructureType[] = []; + let stored: StructureInstance[] = []; + let structures: StructureInstance[] = []; + /** 마지막 관 알림 — 노선 따라감 · 관 초안이 바뀌면 이것을 초안 측점으로 다시 맞춤. */ + let reported: LanePipe[] | null = null; + let pipes: LanePipe[] = []; + let sessionIds = new Map(); // 관 고정 id → 세션 id(알약 id) + let stations: IrregularStation[] = []; + let intervalM = 20; + let painted: { to: LaneProfile; stations: string } | null = null; + const listeners = new Set<() => void>(); + + /** 화면 관 목록 = 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 세션 id 도 그 자리로 — + * 짝 = 관 고정 id(411-B · 앞 관을 지나 옮겨도 · 되돌려도 그 관) · id 없는 관(막 놓은 관)은 자리로. */ + const shownPipes = (list: ReadonlyArray): LanePipe[] => { + const shown = followPipeReports( + list.map((pipe) => ({ ...pipe, id: pipe.id ?? undefined })), + store.get("pipes"), + ).sort((x, y) => x.chainage_m - y.chainage_m); + placePipeIds( + shown.flatMap((pipe) => { + const sid = pipe.id && sessionIds.get(pipe.id); + return sid ? [[sid, pipe.chainage_m] as const] : []; + }), + projectId, + ); + sessionIds = new Map( + shown.flatMap((pipe) => + pipe.id ? [[pipe.id, pipeIdAt(pipe.chainage_m, projectId)] as const] : [], + ), + ); + prunePipeIds( + shown.map((pipe) => pipe.chainage_m), + projectId, + ); // 헛 id 정리(411-E) + return shown; + }; + + /** 옛 측점에 남은 계획고 편집(유령 변화점)을 옮기거나 지움 — 옮김 = 0.006m 안 새 측점 · 같은 id · + * 사라진 것 하나와 새것 하나(옮긴 관 · 213-2) · 없으면 맞춘 자리의 새 측점(234-1 · 272) · 나머지는 지움. */ + const carry = (next: IrregularStation[]): void => { + const to = profile(); + if (!to) return; + const nextKeys = new Set(next.map((station) => station.chainage_m.toFixed(3))); + const oldKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3))); + const gone = stations.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3))); + const born = next.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3))); + gone.forEach((station) => { + const same = followedStation(station, gone, born) ?? followSnapped(station, born); + to.resetStationEdit(station.chainage_m, same?.chainage_m); + }); + }; + + const marks = (): StructureInstance[] => [...structures, ...pipesToStructureMarks(pipes)]; + + function paint(force = false): void { + const to = profile(); + if (!to) return; + to.setStructureTypes([...types]); + to.setStructures(marks()); + const signature = JSON.stringify(stations); + // 같은 측점이면 안 씀 — 측점을 쓰면 종단도가 계산 묶음을 다시 돌림 + if (force || painted?.to !== to || painted.stations !== signature) + to.setIrregularStations(stations); + painted = { to, stations: signature }; + to.setMinCoverPipes(pipes as MinCoverPipe[]); + } + + /** 다시 셈 — 종류 표를 받기 전이면 측점은 미룸(받으면 다시 맞춤). */ + function refresh(carryEdits: boolean, force = false): void { + pipes = reported ? shownPipes(reported) : []; + if (types.length) { + const next = plannedIrregular( + planStations(withPipeIds(pipes, projectId), structures, types), + intervalM, + ); + if (carryEdits) carry(next); + stations = next; + } + paint(force); + listeners.forEach(run); + } + + // 추가 측점(368-2) — 초안 · 저장분이 바뀌면 측점 계획을 다시 맞춤 + const stopPlus = store.subscribe("plusStations", () => refresh(true)); + + return { + setTypes(next) { + types = next; + refresh(false); + }, + setStored(next) { + stored = next; + }, + set(next, carryEdits = true) { + if (next.structures) structures = next.structures; + if (next.pipes) reported = [...next.pipes]; + refresh(carryEdits); + }, + follow() { + structures = store.get("structures") ?? stored; + refresh(false); + }, + setIntervalM(next) { + if (next > 0 && next !== intervalM) { + intervalM = next; + refresh(false); + } + }, + clearProjected() { + reported = []; + pipes = []; + stations = []; + paint(true); + listeners.forEach(run); + }, + paint: () => refresh(false, true), + pipes: () => pipes, + structures: () => structures, + types: () => types, + marks, + stations: () => stations, + intervalM: () => intervalM, + onChange(fn) { + listeners.add(fn); + return () => void listeners.delete(fn); + }, + dispose() { + stopPlus(); + listeners.clear(); + }, + }; +} diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 667ae0db0..09230f52e 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -31,6 +31,7 @@ import type { DesignView } from "./design_dock"; import { saveSharedDesign, type DesignPageShared } from "./design_shared"; import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; +import { createFacilityLane } from "./design_facility_lane"; import { pinDraftProject } from "./design_store"; import { pipelineDetail, reconcilePagePipeline } from "./design_pipeline_page"; import { forgetPageData, onPageBusy } from "./design_page_data"; @@ -149,7 +150,8 @@ export function renderDesignPage(root: HTMLElement): MountedPage { panel: null, profile: null, structures: null, - pipeOptionQueue: [], + // 시설 줄 한 벌(462) — 보기가 숨어도 · 아직 안 섰어도 듣고 셈 · 종단도는 서면 받음 + lane: createFacilityLane(projectId, () => shared.profile), revealLeft: () => layout?.setOptionsOpen(true), ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready, }; @@ -281,6 +283,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { slots.forEach((slot) => slot.dispose()); slots.clear(); shared.profile?.dispose(); + shared.lane.dispose(); stopBusy(); forgetPageData(projectId, true); // 다시 열면 저장분 · 노선 id 새로(386-2) delete (window as unknown as { __designPage?: unknown }).__designPage; diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 13552aaac..269a789fb 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -6,6 +6,7 @@ * 한 벌이 받은 사용자 조작(종단도 측점 고름 · 구조물 폼 · 우클릭)은 **지금 보이는 보기**가 * `bindDesignView` 로 건 동작으로 간다. 값이 바뀐 알림(관 목록 · 횡단 재계산)은 숨은 보기도 * 받는다 — 다시 보일 때 같은 값을 들고 있게. + * 시설 줄(알약 · 비정규 측점 · 관 id 장부)은 페이지 한 벌(`design_facility_lane`) — 관 알림 · 노선 따라감을 여기서 넘김. * [저장] · [확정]은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 보낸다. 종단 보기의 * [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`). * ========================================================================== */ @@ -15,6 +16,7 @@ import { showToast } from "@ui/ui_template_elements"; import { splitSidebarActions } from "@ui/ui_template_overlay"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav"; +import { readState, writeState } from "../b_page_state"; import { currentRoute, navigateTo, remountRoute } from "../router"; import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch"; @@ -29,6 +31,7 @@ import { saveDesign, type DesignSaveStep } from "./design_save"; import { markDesignClean } from "./design_leave"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; +import type { FacilityLane } from "./design_facility_lane"; import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page"; import { plantedBase } from "./design_pipeline_base"; import { isUserPick } from "./design_user_pick"; @@ -107,8 +110,8 @@ export interface DesignPageShared { structures: StructuresSection | null; /** 배수유역도가 마지막으로 알린 관 목록 — 뒤늦게 서는 보기가 처음부터 같은 관 목록을 쥔다(24-7a). */ lastPipes?: Parameters>[0]; - /** 횡단 보기가 서기 전에 종단 보기가 고친 관 옵션 — 횡단 보기가 서면 캐시에 얹는다. */ - pipeOptionQueue: Array<[number, Record]>; + /** 시설 줄 한 벌(462) — 알약 · 비정규 측점 · 흙덮기 관 · 관 id 장부 · 평면 마커 원천. 보기는 읽기만. */ + lane: FacilityLane; /** 접힌 좌측 패널을 편다(바깥에서 고른 것이 폼에 실릴 때). */ revealLeft: () => void; /** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */ @@ -129,7 +132,7 @@ export function bindDesignView( /** * 종단 보기 폼에서 고친 관 옵션을 횡단 캐시에도 얹는다 — 세월교 · BOX 폼 값은 횡단 스펙(캐시)이 * 정본이라, 안 얹으면 한 벌 폼이 횡단 보기로 가는 순간 옛 값으로 돌아간다. 횡단 보기가 아직 - * 안 섰으면 담아 두었다가 설 때 얹는다. + * 안 섰으면 세션 칸에 담아 두었다가 설 때 얹는다(새로고침해도 남음 · 462). */ export function forwardPipeOptions( shared: DesignPageShared, @@ -138,17 +141,23 @@ export function forwardPipeOptions( ): void { if (!patch || !Object.keys(patch).length) return; const apply = shared.bindings.cross?.onPipeOptions; - if (apply) apply(chainageM, patch); - else shared.pipeOptionQueue.push([chainageM, patch]); + if (apply) return apply(chainageM, patch); + const queue = readState("pipe-option-queue", shared.projectId) ?? []; + writeState("pipe-option-queue", [...queue, [chainageM, patch]], shared.projectId); } +type PipeOptionQueue = Array<[number, Record]>; + /** 횡단 보기가 캐시 창구를 건다 — 담아 둔 옵션을 먼저 얹는다. */ export function bindPipeOptions( shared: DesignPageShared, apply: (chainageM: number, patch: Record) => void, ): void { bindDesignView(shared, "cross", { onPipeOptions: apply }); - shared.pipeOptionQueue.splice(0).forEach(([chainageM, patch]) => apply(chainageM, patch)); + const queue = readState("pipe-option-queue", shared.projectId); + if (!queue?.length) return; + writeState("pipe-option-queue", null, shared.projectId); + queue.forEach(([chainageM, patch]) => apply(chainageM, patch)); } /** 지금 보기의 종단도 알림으로 보낸다. */ @@ -194,6 +203,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { callbacks: { onPipesChanged: (pipes) => { shared.lastPipes = pipes; + shared.lane.set({ pipes }); // 시설 줄 먼저 — 보기 알림은 맞춘 값을 읽음 toAll(shared, "onPipesChanged")(pipes); }, onCrossDesignsUpdated: (updated) => crossDesignsUpdated(shared, updated), @@ -214,6 +224,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { attachProfilePopout(profile, shared.profileSlot, popoutButton); attachSubPanelPopouts(profile); shared.profile = profile; + shared.lane.paint(); // 종단도가 서기 전 시설 줄이 먼저 셌으면 그 값 if (shared.projectId) startPagePipeline(shared, profile); return profile; } @@ -227,7 +238,10 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo routeDraftBridge(projectId, profile, { stationIntervalM: SECTION_STATION_INTERVAL_M, onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()), - onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)), + onFacilitiesFollowed: (far) => { + shared.lane.follow(); // 가게 관 · 구조물 = 따라간 자리(편집은 묶음이 옮김) + each((hooks) => hooks.onFacilitiesFollowed?.(far)); + }, onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"), // 초안 소단 = 지금 구조물(따라간 자리)의 소단 구간(365-5 · 저장 노선은 다시 셈 창구가 세션 사본으로) bermAt: (structures) => { diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index db9d53e9c..68def71e5 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -66,14 +66,13 @@ import "../../B05_Profile/B05_Profile_UI_Style.css"; import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; -import { pipeIdAt, placePipeIds, prunePipeIds } from "./design_pipe_ids"; +import { pipeIdAt } from "./design_pipe_ids"; import { useDesignStore } from "./design_store"; import { setPlusStations } from "../../common_util/common_util_structure_station"; import { loadSavedPlusStations, plusStationsOf, } from "../../B05_Profile/B05_Profile_Api_PlusStations"; -import { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow"; import { STRUCTURE_ROUTE_DRIFT_MAX_M } from "../../common_util/common_util_route_follow"; import { DEFAULT_ROAD_WIDTHS, @@ -84,7 +83,10 @@ import { } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections"; import { createProfile3d } from "./design_view_profile_3d"; -import { setPlanMarkSource } from "../../B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks"; +import { + planMarksChanged, + setPlanMarkSource, +} from "../../B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks"; type PipeReport = Parameters>[0]; @@ -113,38 +115,10 @@ export async function mountProfileView( /** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */ let lastPipes: PipeReport | null = shared.lastPipes ?? null; const designStore = useDesignStore(activeProjectId, null); - /** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 세션 id 도 - * 그 자리로 — 짝 = 관 고정 id(411-B · 앞 관을 지나 옮겨도 · 되돌려도 그 관) · id 없는 관(막 놓은 관)은 자리로. */ - let sessionIds = new Map(); // 관 고정 id → 세션 id(알약 id) - const shownPipes = (pipes: PipeReport): PipeReport => { - const shown = followPipeReports(pipes, designStore.get("pipes")).sort( - (x, y) => x.chainage_m - y.chainage_m, - ); - placePipeIds( - shown.flatMap((pipe) => { - const sid = pipe.id && sessionIds.get(pipe.id); - return sid ? [[sid, pipe.chainage_m] as const] : []; - }), - activeProjectId, - ); - sessionIds = new Map( - shown.flatMap((pipe) => - pipe.id ? [[pipe.id, pipeIdAt(pipe.chainage_m, activeProjectId)] as const] : [], - ), - ); - prunePipeIds( - shown.map((pipe) => pipe.chainage_m), - activeProjectId, - ); // 헛 id 정리(411-E) - return shown; - }; - const applyPipes = (reported: PipeReport, carryEdits = true): void => { - const pipes = shownPipes(reported); - // 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅 - // 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다. - bridge.setPipes(pipes, carryEdits); - panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem)); - profilePanel.setMinCoverPipes(pipes); + /** 화면 관 목록 = 시설 줄 한 벌(462 · 가게 관 초안 측점 · 관 세션 id 장부 · 알약 · 측점선 · 흙덮기는 그쪽이 + * 숨어도 셈) — 이 보기는 보일 때 좌측 목록만 맞춤. */ + const applyPipes = (): void => { + panel.structures.setPipeFacilities((shared.lane.pipes() as PipeReport).map(pipeToFacilityItem)); snapOld(); restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. }; @@ -159,9 +133,9 @@ export async function mountProfileView( const followed = designStore.get("pipes"); if (followed) profilePanel.drainage.followPipes(followed); else if (profilePanel.drainage.pipeChainages().length) profilePanel.drainage.reload(); - // 숨어도 관 세션 id 장부는 따라감 — 평면 마커 누름이 초안 자리를 고름(440-3) · 그리기는 보일 때 - if (!host.visible()) return void (lastPipes && shownPipes(lastPipes)); - if (lastPipes) applyPipes(lastPipes, false); + // 관 세션 id 장부 · 측점선 · 알약은 시설 줄이 따라감(숨어도 · 462) — 이 보기는 보일 때 목록만 + if (!host.visible()) return; + if (lastPipes) applyPipes(); bridge.takeStructures(designStore.get("structures")); }; /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) · @@ -214,8 +188,7 @@ export async function mountProfileView( // 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫). onPipesChanged: (pipes) => { lastPipes = pipes; - if (host.visible()) applyPipes(pipes); - else shownPipes(pipes); // 숨어도 관 id 장부만(440-3) + if (host.visible()) applyPipes(); // 관 id 장부 · 측점은 시설 줄이 먼저 맞춤(462) }, onStructureMove: (from, to, station) => { if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to); @@ -389,15 +362,21 @@ export async function mountProfileView( const bridge = createStructuresBridge({ projectId: activeProjectId, panel: () => panel, - profilePanel: () => profilePanel, - renderStationLines: () => sections.redrawStationLines(), + lane: shared.lane, isRestoring: () => state.restoring, }); - // 평면 구조물 측점 마커(399) — 알약과 같은 목록 · 고름 · 옮김 + // 시설 줄이 바뀜(어느 보기 고침 · 노선 따라감 · 숨은 동안도) — 평면 마커 · 보일 때 3D 측점선 + host.onDispose( + shared.lane.onChange(() => { + planMarksChanged(activeProjectId); + if (host.visible()) sections.redrawStationLines(); + }), + ); + // 평면 구조물 측점 마커(399) — 알약과 같은 목록(시설 줄) · 고름 · 옮김 host.onDispose( setPlanMarkSource(activeProjectId, { - marks: bridge.marks, - types: bridge.types, + marks: shared.lane.marks, + types: shared.lane.types, intervalM: () => panel.values().stationInterval || 20, select: onStructureSelect, move: onStructureMarkMove, @@ -447,10 +426,7 @@ export async function mountProfileView( // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) const reloadIfShown = (): void => void (host.visible() && !state.restoring && bridge.load()); host.onDispose(onStaleStructureTypes(reloadIfShown)); - // 추가 측점(368-2) — 초안 · 저장분이 바뀌면 측점 계획을 다시 맞춤(종단 측점선 · 3D) - host.onDispose( - useDesignStore(activeProjectId, null).subscribe("plusStations", () => bridge.resyncStations()), - ); + // 추가 측점(368-2) — 초안 · 저장분이 바뀌면 시설 줄이 측점 계획을 다시 맞춤(종단 측점선 · 3D) setPlusStations(() => plusStationsOf(activeProjectId)); // 저장분이 늦게 와도 한 번 다시 맞춤 void loadSavedPlusStations(activeProjectId).then(() => bridge.resyncStations()); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). @@ -468,7 +444,7 @@ export async function mountProfileView( reload: async () => { sections.flushSwap(); // 숨은 동안 측점 목록이 바뀌었으면 종단도 · 3D 측점선 다시(346-2) await bridge.load(); - if (lastPipes) applyPipes(lastPipes); + if (lastPipes) applyPipes(); }, syncPick: () => { if (readStructurePick(activeProjectId)) { @@ -571,7 +547,7 @@ export async function mountProfileView( if (latestResponse.route && routeId !== null) await restoreSections(routeId); // 횡단 보기가 먼저 서서 배수유역도가 관 목록을 이미 알렸다면 그것을 먼저 싣는다 — 빈 목록으로 // 시작하면 아래 구조물 로딩이 관 대조로 관을 지우고 초안 옵션이 자동 배치로 되돌아간다(24-7a). - if (lastPipes) applyPipes(lastPipes); + if (lastPipes) applyPipes(); // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). await bridge.load(); advanceLoading(""); diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 8cf42e300..6d43421d6 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -2,10 +2,9 @@ * B05_Profile_UI_Page_Structures.ts * 구조물·계곡 통과 시설을 화면 세 곳(사이드 목록·종단 그래프·3D)에 맞추는 다리. * - * 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다. 두 정본을 섞어 - * 쓰는 자리라 상태를 여기로 옮겨 왔다 — 구조물 정본(structures.json)의 목록·판번호와, - * 관 지점 정본(pipe_points.json)에서 투영한 측점·알약이 그것이다. 본체는 이 객체의 - * 메서드만 부르고 목록은 `irregularStations()`로 읽는다. + * 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다. 여기 몫 = 구조물 정본(structures.json)의 + * 목록 · 종류 · 판번호 · 저장. 알약 · 비정규 측점 · 관 투영은 페이지 시설 줄 한 벌(`design_facility_lane` · 462)이 + * 셈 — 이 다리는 목록을 넘기고 `irregularStations()` · `marks()` 는 시설 줄을 읽음. * * 저장 규칙은 종전 그대로다 — 사이드 목록이 바뀌면 곧바로 서버 정본에 저장하고, * 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다. @@ -13,7 +12,6 @@ import { showToast } from "@ui/ui_template_elements"; import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu"; -import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { fetchStructures, fetchStructureTypes, @@ -26,77 +24,24 @@ import { type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; -import 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 { followedStation } from "./logic/B05_Profile_UI_Profile_Alignment"; -import { followSnapped } from "./B05_Profile_UI_Structures_Snap"; import { plantsStation } from "./B05_Profile_UI_Profile_Structures"; import { listPick } from "../common_util/common_util_structure_catalog"; -import { planStations } from "../common_util/common_util_structure_station"; -import { plannedIrregular } from "../A00_Common/design/design_structure_status"; -import { pipeChainageOf, pipeIdAt, withPipeIds } from "../A00_Common/design/design_pipe_ids"; +import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids"; import { pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data"; -import { planMarksChanged } from "./B05_Profile_UI_RouteEdit_StructureMarks"; - -/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ -export interface PipeProjection { - chainage_m: number; - effective_diameter_mm: number | null; - facility: PipeFacility; - options?: Record; - /** 독립 기슭막이 구간 — 시작 · 끝도 측점을 심는다(272 · 서버와 같은 규칙). */ - start_m?: number | null; - end_m?: number | null; -} +import type { FacilityLane } from "../A00_Common/design/design_facility_lane"; export interface StructuresBridgeDeps { projectId: string; /** 사이드 패널 — 생성 순서상 나중에 만들어지므로 게터로 받는다. */ panel: () => ReturnType; - profilePanel: () => ReturnType; - /** 3D 측점 세로선 다시 그리기 — 상세가 아직 없으면 본체가 알아서 건너뛴다. */ - renderStationLines: () => void; + /** 페이지 시설 줄 한 벌(462) — 알약 · 비정규 측점은 여기서 셈. */ + lane: FacilityLane; /** 복원 중에는 서버로 저장하지 않는다(되살린 값을 그대로 되쓰지 않기 위함). */ isRestoring: () => boolean; } export function createStructuresBridge(deps: StructuresBridgeDeps) { - /** 비정규 측점(그래프 세로선·3D 라벨) — 측점 계획 한 벌(관 · 측점 심는 구조물 · 322-3). */ - let irregularStations: IrregularStation[] = []; - - /** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */ - function clearProjectedStations(): void { - planPipes = []; - irregularStations = []; - deps.renderStationLines(); - deps.profilePanel().setIrregularStations([]); - } - - /** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). - * `carryEdits` false = 노선이 움직여 따라간 측점(계획고 편집은 계산 묶음 follow 가 옮김 · 349-0d). */ - function applyIrregularStations(stations: IrregularStation[], carryEdits = true): void { - // 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다. - // 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2) - // — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움. - const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3))); - const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3))); - const gone = irregularStations.filter( - (station) => !nextKeys.has(station.chainage_m.toFixed(3)), - ); - const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3))); - if (carryEdits) - gone.forEach((station) => { - // 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272) - const same = followedStation(station, gone, born) ?? followSnapped(station, born); - deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); - }); - irregularStations = stations; - deps.renderStationLines(); - deps.profilePanel().setIrregularStations(stations); - // 배수유역도로 되먹이지 않음(322-3) — 관 고침 · 지움은 폼 콜백이 배수 패널로 바로 감(관 정본이 원천) - } - /* ── 구조물 정본(structures.json) ──────────────────────────────────── * 목록이 바뀌면 **세션 초안**에 담고(`writePending`), 정본에는 [저장]·[확정]에서만 * 쓴다(`saveStructuresIfDirty`). 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 @@ -106,29 +51,11 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { let structureRevision = 0; let structureSaving: Promise = Promise.resolve(); - /* ── 횡단배수(A군) 측점 세로선 ────────────────────────────────────────── - * 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅 - * 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거· - * 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */ - /** 측점 계획 입력 — 관(배수 패널이 알려 줌) · 종류 표(정의 기본값 · 카드 이름). */ - let planPipes: PipeProjection[] = []; - let planTypes: StructureType[] = []; let structureTypeMap = new Map< string, { type_id: string; group: string; name: string; placement: string; managed_by: string | null } >(); - /** 그래프 · 3D 측점 = 측점 계획 한 벌(322-3 · 카드 · B06 종단 · 서버와 같은 목록 · 같은 m 하나 · 관이 먼저). - * 종류 표를 받기 전이면 미룸(받으면 다시 맞춤). */ - function syncCrossDrainStations(carryEdits = true): void { - if (!planTypes.length) return; - const interval = deps.panel().values().stationInterval || 20; - applyIrregularStations( - plannedIrregular(planStations(withPipeIds(planPipes), ownStructures, planTypes), interval), - carryEdits, - ); - } - /** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null). * 3D·사이드 목록과 마찬가지로 **종단 알약 선택도 B06 으로 이어져야** 해서 둔다 * (2026-09-06: 알약만 `structure-pick` 을 안 써 B06 이 못 잡았음). */ @@ -147,41 +74,10 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { return structureId ? pipeChainageOf(structureId) : null; // 관 세션 id 장부(323-5) } - /** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다. - * 자동 배수관도 세로 점선이 아니라 같은 알약으로 나온다(2026-08-17 사용자 지시 1). */ + /** 구조물 정본 목록(미저장분 우선) — 알약 레인 · 측점 계획은 시설 줄이 이것 + 관으로 셈. */ let ownStructures: StructureInstance[] = []; /** 서버 정본 목록 — 가게 초안이 비면(노선 초안을 버림) 이것으로. */ let storedStructures: StructureInstance[] = []; - let pipeMarks: StructureInstance[] = []; - - function syncGraphStructures(): void { - deps.profilePanel().setStructures([...ownStructures, ...pipeMarks]); - planMarksChanged(deps.projectId); // 평면 구조물 마커 = 알약과 같은 목록(399) - } - - /** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */ - function pipesToMarks( - pipes: Array<{ - chainage_m: number; - facility: PipeFacility; - options?: Record; - }>, - ): StructureInstance[] { - return pipes.map((pipe) => ({ - structure_id: pipeIdAt(pipe.chainage_m), // 관 세션 id(323-5) - type_id: pipe.facility, - placement: "point", - chainage_m: pipe.chainage_m, - start_m: null, - end_m: null, - options: pipe.options ?? {}, - memo: "", - placement_source: "automatic", - status: "draft", - revision: 0, - geometry: null, - })); - } /* ── 미저장 조작분 세션 보관(2026-08-29 — CLAUDE.md 5장) ────────────── * 조작은 화면과 세션에만 남기고 영구저장은 [저장]·[확정]에서만 한다. 세션에 두는 @@ -201,8 +97,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { function applyStructures(next: StructureInstance[]): void { ownStructures = withLocalIds(next); - syncGraphStructures(); - syncCrossDrainStations(); + deps.lane.set({ structures: ownStructures }); // 복원 중 호출은 사용자 조작이 아니다 — 미저장 표시를 남기지 않는다. if (!deps.isRestoring()) { deps.panel().structures.setStructures(ownStructures); @@ -230,8 +125,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { structureRevision = stored.revision; deps.panel().structures.setStructures(stored.structures); ownStructures = storedStructures = stored.structures; - syncGraphStructures(); - syncCrossDrainStations(); + deps.lane.setStored(storedStructures); + deps.lane.set({ structures: ownStructures }, false); return true; } @@ -284,8 +179,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { pageStructures(deps.projectId), ]); deps.panel().structures.setTypes(allTypes, listPick("b05")); // 표 목록 both(322-1) - deps.profilePanel().setStructureTypes(allTypes); - planTypes = allTypes; + deps.lane.setTypes(allTypes); // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. structureTypeMap = new Map( allTypes.map((type) => [ @@ -301,14 +195,14 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ); structureRevision = stored.revision; storedStructures = stored.structures; + deps.lane.setStored(storedStructures); const note = staleDefinitionNotice(deps.projectId, stored.definition_version); // 322-2 if (note) showToast(note); // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29). const pending = readPending(); ownStructures = pending ?? stored.structures; deps.panel().structures.setStructures(ownStructures); - syncGraphStructures(); - syncCrossDrainStations(); + deps.lane.set({ structures: ownStructures }, false); } catch (error) { showToast( error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.", @@ -335,43 +229,35 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { onPick: (chainage, typeId) => deps.panel().structures.addAt(chainage, typeId), }); }, - /** 측점 계획을 다시 맞춤 — 추가 측점이 바뀜(368-2). */ - resyncStations: () => syncCrossDrainStations(), - /** 알약 레인 목록 · 종류 — 평면 구조물 마커가 같은 것을 씀(399). */ - marks: (): StructureInstance[] => [...ownStructures, ...pipeMarks], - types: (): StructureType[] => planTypes, - /** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */ - irregularStations: () => irregularStations, + /** 측점 계획을 다시 맞춤 — 추가 측점 저장분이 늦게 옴(368-2). */ + resyncStations: () => deps.lane.set({}), + /** 알약 레인 목록 · 종류(시설 줄) — 평면 구조물 마커가 같은 것을 씀(399). */ + marks: (): StructureInstance[] => deps.lane.marks(), + types: (): StructureType[] => deps.lane.types(), + /** 그래프·3D가 쓰는 현재 비정규 측점 목록(시설 줄 · 물넘이포장 제외 — 알약 레인 몫). */ + irregularStations: () => deps.lane.stations(), /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group, - /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다(`carryEdits` = 계획고 편집 옮김). */ - setPipes(pipes: PipeProjection[], carryEdits = true): void { - planPipes = [...pipes]; - syncCrossDrainStations(carryEdits); - pipeMarks = pipesToMarks(pipes); - syncGraphStructures(); - }, /** [초기선 복원] — 그래프의 배관 투영만 지운다(관 정본은 배수유역이 맡는다). */ - clearProjectedStations, + clearProjectedStations: () => deps.lane.clearProjected(), /** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */ pipeMarkChainage, /** 측점 없는 관 알약(물넘이포장)인가 — 고름 값에 알약 id 를 남길지 가름(요구 178). */ stationlessPipeMark: (structureId: string | null): boolean => { - const mark = pipeMarks.find((entry) => entry.structure_id === structureId); + if (pipeMarkChainage(structureId) === null) return false; // 관 알약만 + const mark = deps.lane.marks().find((entry) => entry.structure_id === structureId); return !!mark && !plantsStation(mark, []); }, /** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */ markChainage, /** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */ applyStructures, - /** 가게 구조물 초안을 계산 묶음이 다시 씀(노선 따라 측점 다시 매김 · 되돌림 · 349-0d) — 화면만 맞춤. */ + /** 가게 구조물 초안을 계산 묶음이 다시 씀(노선 따라 측점 다시 매김 · 되돌림 · 349-0d) — 목록만 맞춤(측점은 시설 줄). */ takeStructures(next: StructureInstance[] | null): void { const list = next ?? storedStructures; if (JSON.stringify(list) === JSON.stringify(ownStructures)) return; ownStructures = list; deps.panel().structures.setStructures(ownStructures); - syncGraphStructures(); - syncCrossDrainStations(false); }, saveStructuresIfDirty, /** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */ diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index eef1873f8..1260b2b47 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -205,13 +205,16 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ? inside.reduce((best, at) => (Math.abs(at - anchor) < Math.abs(best - anchor) ? at : best)) : anchor; }; - /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ - const pushMarks = (): void => + /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). 알약 · 측점선 · 관 id 장부 = 페이지 + * 시설 줄 한 벌(462 · 숨은 B05 평면 마커도 따라감) · `carryEdits` = 사용자 고침(불러옴은 false). */ + const pushMarks = (carryEdits = true): void => { + deps.shared.lane.set({ structures, pipes: pipeFacilities }, carryEdits); deps.onMarks?.( [...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes, pipeFacilities, ); + }; // 폼 한 벌(계획서 24-3b) — 횡단 보기가 보일 때 폼 알림을 받는 동작만 건다. 상세 옵션 칸은 // 한 벌이 보기에 따라 그린다(횡단 보기에서만). const section = sharedStructures(deps.shared); @@ -396,8 +399,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ]); section.setTypes(types, listPick("b06")); // 표 목록 both + b06(322-1) markTypes = types; + deps.shared.lane.setTypes(types); // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). storedStructures = stored.structures; + deps.shared.lane.setStored(storedStructures); storedPipes = pipeResponse.pipe_points; structures = readPendingStructures(projectId) ?? stored.structures; const note = staleDefinitionNotice(projectId, stored.definition_version); // 322-2 @@ -438,7 +443,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (!pendingPipes) snapOldPipes(projectId); section.setPipeFacilities(pipeFacilities); // 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로. - pushMarks(); + pushMarks(false); // 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다 // (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도 // 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다. diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index dccd69edf..a31326e83 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -670,11 +670,10 @@ export function createSectionView( setStructureEdit(edit) { structureEdit = edit; }, - setStructureMarks(structures, types, pipes) { + setStructureMarks(structures, types) { spanMarks = structures; spanTypes = types; - syncSpanHighlights(); - profile.setMarks(structures, types, pipes); + syncSpanHighlights(); // 종단 알약 · 측점선은 시설 줄 한 벌(462) // 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다 // (`__corridorBuild` 등과 같은 용도). (window as unknown as { __b06Marks?: unknown }).__b06Marks = { diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index b102316d9..52d3ea729 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -5,7 +5,8 @@ * * 옛 위쪽 붙박이 종단(자체 그래프 · 유토곡선 없음 · 흙덮기 막기 없음)을 갈음한다. * 도구(▲▼ · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · 뱃지)와 막기 규칙 - * (최소 흙덮기 · 기울기 상한)은 종단도 한 벌이 맡고, 여기서는 이음만 한다. + * (최소 흙덮기 · 기울기 상한)은 종단도 한 벌이 맡고, 여기서는 이음만 한다. 알약 · 측점선 · 흙덮기 관은 + * 페이지 시설 줄 한 벌(`shared.lane` · 462)이 종단도에 씀 — 여기는 읽기만. * ========================================================================== */ import type { DesignProfile } from "../A00_Common/design/design_profile"; @@ -32,24 +33,13 @@ import type { 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 { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures"; +import { irregularStationId, isPipeStation } from "../B05_Profile/B05_Profile_UI_IrregularStations"; import { isPipeMark, moveMarkById, type SectionStructureEdit, } from "./B06_Section_UI_Section_View_Menu"; -import { planStations, type PlanPipe } from "../common_util/common_util_structure_station"; -import { plannedIrregular } from "../A00_Common/design/design_structure_status"; -import { withPipeIds } from "../A00_Common/design/design_pipe_ids"; /** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */ const MATCH_M = PIPE_MATCH_M; @@ -79,11 +69,6 @@ export interface SectionProfile { haulLimits?: HaulEquipmentLimit[]; naturalSpoilMinSlope?: number; }) => void; - setMarks: ( - structures: ReadonlyArray, - types: ReadonlyArray, - pipes?: ReadonlyArray, - ) => void; /** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */ redraw: () => void; /** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */ @@ -92,26 +77,6 @@ export interface SectionProfile { selectMark: (structureId: string | null) => void; } -/** 종단 측점선 = 측점 계획 한 벌(322-3 · 관 · 측점 심는 구조물 모두 · 종단 보기 · 카드와 같은 목록). - * 관 구간(독립 기슭막이 시작 · 끝)은 받은 관 목록에서 · 없으면 알약에서 기준만. */ -function planMarkStations( - marks: ReadonlyArray, - pipes: ReadonlyArray | null, - types: ReadonlyArray, - intervalM: number, -): IrregularStation[] { - const pipeList = - pipes ?? - marks.filter(isPipeMark).map((mark) => ({ - chainage_m: structureAnchorM(mark), - facility: mark.type_id, - options: mark.options, - })); - const structures = marks.filter((mark) => !isPipeMark(mark)); - // 덮는 구조물 id = 관 세션 id(323-5 · 알약 id 와 같음) - return plannedIrregular(planStations(withPipeIds(pipeList), structures, types), intervalM); -} - /** * 배수유역도에 노선 평면선을 싣는다 — 종단 보기가 두고 간 최신 노선 캐시를 먼저 쓰고, * 없으면 한 번 읽는다(읽기만 · 계산 없음). @@ -143,13 +108,10 @@ export function createSectionProfile( ): SectionProfile { let profile: DesignProfile | null = null; let detail: SectionDetailResponse | null = null; - let marks: ReadonlyArray = []; - let markPipes: ReadonlyArray | null = null; + /** 알약 · 비정규 측점 = 시설 줄 한 벌(462 · 읽기만). */ + const lane = shared.lane; /** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */ let markedId: string | null = null; - let types: ReadonlyArray = []; - let stations: IrregularStation[] = []; - let intervalM = 20; /** 마지막으로 받은 관 목록 지문(자리 · 종류 · 관경) — 바뀔 때만 카드를 다시 얹음. */ let pipeSignature: string | null = null; @@ -166,7 +128,7 @@ export function createSectionProfile( /** 종단도 측점 id → 카드 id. 관 측점(`irregular:`)은 누가거리로 카드를 찾는다. */ const cardOf = (stationId: string | null): string | null => { if (stationId === null) return null; - const pipe = stations.find((entry) => irregularStationId(entry.id) === stationId); + const pipe = lane.stations().find((entry) => irregularStationId(entry.id) === stationId); if (pipe) return nearestCard(pipe.chainage_m)?.station_id ?? null; return stationId; }; @@ -180,22 +142,6 @@ export function createSectionProfile( { from: "map" }, ); - const syncMarks = (): void => { - if (!profile) return; - profile.setStructureTypes([...types]); - profile.setStructures([...marks]); - stations = planMarkStations(marks, markPipes, 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", { @@ -245,7 +191,7 @@ export function createSectionProfile( selectDesign(shared.projectId, NO_SELECTION, { from: ESCAPE_FROM }); return; } - const hit = marks.find((entry) => entry.structure_id === structureId); + const hit = lane.marks().find((entry) => entry.structure_id === structureId); if (!hit) return; const edit = host.edit(); if (!isPipeMark(hit) && hit.structure_id && edit) { @@ -253,7 +199,7 @@ export function createSectionProfile( return; } // 측점 없는 관 = 카드 안 고름 · 스크롤 없음 · 그 관 폼 · 알약만(요구 178 · 307) - if (edit?.selectPipe && !plantsStation(hit, types)) { + if (edit?.selectPipe && !plantsStation(hit, lane.types())) { edit.selectPipe(structureAnchorM(hit)); return; } @@ -266,7 +212,7 @@ export function createSectionProfile( const edit = host.edit(); if (!edit) return; moveMarkById(structureId, toChainageM, { - structures: marks, + structures: lane.marks(), stations: detail?.longitudinal.stations ?? [], edit, }); @@ -287,10 +233,9 @@ export function createSectionProfile( const fresh = !shared.profile; profile = build(); if (fresh) loadRoute(id, profile); - syncMarks(); } detail = input.detail; - intervalM = input.stationInterval; + lane.setIntervalM(input.stationInterval); const routeId = Number(route); profile.render( input.detail, @@ -304,13 +249,7 @@ export function createSectionProfile( naturalSpoilMinSlope: input.naturalSpoilMinSlope, }); } - syncMarks(); - }, - setMarks(nextMarks, nextTypes, nextPipes) { - marks = nextMarks; - types = nextTypes; - markPipes = nextPipes ?? null; - syncMarks(); + lane.paint(); // 다시 그린 종단도에 시설 줄(추가 측점이 늦게 왔으면 다시 셈) }, redraw() { profile?.redraw(); @@ -325,12 +264,14 @@ export function createSectionProfile( profile.setSelectedStation(null); return; } - const pipe = stations.find( - (entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M, - ); + const pipe = lane + .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; + const marked = markedId + ? lane.marks().find((entry) => entry.structure_id === markedId) + : undefined; if (!pipe && marked && coversChainage(marked, section.chainage_m)) profile.setSelectedStructure(markedId, true); }, diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 2eb8288bd..e577756c2 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -312,7 +312,7 @@ async function build(list) { profile.line = null; panel = panelModule.createB06StructuresPanel({ projectId: PID, - shared: { view: () => "cross" }, + shared: { view: () => "cross", lane: { set() {}, setTypes() {}, setStored() {} } }, // 시설 줄(462) detail: () => ({ cross_sections: CARDS }), stationInterval: () => 20, focusChainage: (at, revealOnly) => { diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 7a1c21f56..7a96b037f 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -309,7 +309,7 @@ async function build(list, keep = false) { profile.pill = null; panel = panelModule.createB06StructuresPanel({ projectId: PID, - shared: { view: () => "cross" }, + shared: { view: () => "cross", lane: { set() {}, setTypes() {}, setStored() {} } }, // 시설 줄(462) detail: () => ({ cross_sections: CARDS }), stationInterval: () => 20, focusChainage: (at, revealOnly) => focusStationAt(at, revealOnly), diff --git a/resources/tester/helper_271_facility_lane.cjs b/resources/tester/helper_271_facility_lane.cjs new file mode 100644 index 000000000..007204966 --- /dev/null +++ b/resources/tester/helper_271_facility_lane.cjs @@ -0,0 +1,224 @@ +/* 462 B05 · B06 한 페이지 시설 줄 한 벌(요구 271-5) — 진짜 `design_facility_lane` · 관 id 장부 · 측점 계획 · + * 관 따라감 · 가게를 Node 로 돌려 JSON 한 줄(`test_271_facility_lane.py` 가 판정). 종단도는 가짜(받은 것 기록). + * node helper_271_facility_lane.cjs + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const CATALOG = require(path.join(__dirname, "helper_322_catalog_module.cjs")); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + key: (i) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() }; +globalThis.sessionStorage = globalThis.window.sessionStorage; +globalThis.localStorage = globalThis.window.localStorage; + +const anyModule = (extra = {}) => + new Proxy(extra, { get: (t, key) => (key in t ? t[key] : () => undefined) }); +const STUBS = { + "config_frontend.ts": anyModule({ + CURRENT_PROJECT_ID_KEY: "frd_current_project_id", + API_BASE_URL: "", + API_TIMEOUT_MS: 1000, + PIPE_TYPES: [], + }), + "ui_template_elements.ts": anyModule(), + "B04_PreProcess_Api_Fetch.ts": anyModule(), +}; + +const cache = new Map(); +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; +function loadTs(full) { + const stub = STUBS[path.basename(full)]; + if (stub) return stub; + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const near = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (request.endsWith(".json")) + return JSON.parse(fs.readFileSync(path.join(path.dirname(full), request), "utf8")); + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +const design = path.join(ROOT, "A00_Common", "design"); +const { createFacilityLane } = loadTs(path.join(design, "design_facility_lane.ts")); +const ids = loadTs(path.join(design, "design_pipe_ids.ts")); +const { useDesignStore } = loadTs(path.join(design, "design_store.ts")); +const { readState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); +const TYPES = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +).types; + +/** 가짜 종단도 — 받은 것 · 편집 옮김 기록. */ +function fakeProfile() { + const got = { + structures: null, + stations: null, + minCover: null, + types: 0, + resets: [], + stationWrites: 0, + }; + return { + got, + setStructureTypes: (types) => (got.types = types.length), + setStructures: (next) => (got.structures = next), + setIrregularStations: (stations) => ((got.stations = stations), (got.stationWrites += 1)), + setMinCoverPipes: (pipes) => (got.minCover = pipes.map((p) => p.chainage_m)), + resetStationEdit: (from, to) => got.resets.push([from, to ?? null]), + }; +} +const book = (projectId) => readState("pipe-ids", projectId) ?? { next: 1, ids: [] }; +const wall = (id, at) => ({ + structure_id: id, + type_id: "retaining_wall", + placement: "interval", + chainage_m: at, + start_m: at - 5, + end_m: at + 5, + options: {}, +}); +const markView = (lane) => lane.marks().map((m) => [m.structure_id, m.type_id, m.chainage_m]); +const stationView = (lane) => lane.stations().map((s) => [s.chainage_m, s.id]); + +const out = {}; + +// ① R3 뒤집기 — B06 만 연 채 351 관 → 360 옮김 → 버림 → 카드가 351 관 id 를 물음 → 다시 실음 +{ + const P = "P271-r3"; + ids.usePipeIds(P); + const profile = fakeProfile(); + const lane = createFacilityLane(P, () => profile); + lane.setTypes(TYPES); + lane.set({ structures: [], pipes: [{ id: "X", chainage_m: 351, facility: "pipe" }] }, false); + const first = ids.pipeIdAt(351, P); + ids.followMovedPipe(P, 351, 360); // B06 movePipeTo + lane.set({ pipes: [{ id: "X", chainage_m: 360, facility: "pipe" }] }); + const moved = ids.pipeIdAt(360, P); + const ghost = ids.pipeIdAt(351, P); // 버린 뒤 다시 받기 전 카드가 물음(헛 id) + lane.set({ pipes: [{ id: "X", chainage_m: 351, facility: "pipe" }] }, false); // 목록 다시 실음 + out.r3 = { + first, + moved, + ghostBeforeReload: ghost !== first, + after: book(P).ids, + afterId: ids.pipeIdAt(351, P), + marks: markView(lane), + }; +} + +// ② 같은 조작 — B05 먼저 듣는 중(listener 처음부터) 대 B06 먼저(끝에 B05 가 읽음) +function scenario(P, listenFromStart) { + ids.usePipeIds(P); + const profile = fakeProfile(); + const lane = createFacilityLane(P, () => profile); + const heard = []; + const listen = () => lane.onChange(() => heard.push(stationView(lane).length)); + if (listenFromStart) listen(); + lane.setTypes(TYPES); + lane.setStored([wall("w1", 100)]); + lane.set( + { + structures: [wall("w1", 100)], + pipes: [ + { id: "A", chainage_m: 60, facility: "pipe" }, + { id: "B", chainage_m: 200, facility: "pipe" }, + ], + }, + false, + ); + // B06 폼 — 구조물 옮김 · 관 하나 넣음(새 관 id 없음) · 관 옮김 + lane.set({ structures: [wall("w1", 140)] }); + lane.set({ + pipes: [ + { id: "A", chainage_m: 60, facility: "pipe" }, + { chainage_m: 120, facility: "pipe" }, + { id: "B", chainage_m: 200, facility: "pipe" }, + ], + }); + ids.followMovedPipe(P, 200, 230); + lane.set({ + pipes: [ + { id: "A", chainage_m: 60, facility: "pipe" }, + { chainage_m: 120, facility: "pipe" }, + { id: "B", chainage_m: 230, facility: "pipe" }, + ], + }); + // 배수유역도가 뒤늦게 같은 목록을 알림(B05 판) + lane.set({ + pipes: [ + { id: "A", chainage_m: 60, facility: "pipe" }, + { chainage_m: 120, facility: "pipe" }, + { id: "B", chainage_m: 230, facility: "pipe" }, + ], + }); + if (!listenFromStart) listen(); + const ledger = book(P).ids; + const markIds = lane.marks().map((m) => m.structure_id); + return { + marks: markView(lane).map(([id, type, at]) => [id.startsWith("pipe-") ? "pipe" : id, type, at]), + stations: stationView(lane).map(([at]) => at), + minCover: profile.got.minCover, + profileMarks: profile.got.structures.length === lane.marks().length, + profileStations: JSON.stringify(profile.got.stations) === JSON.stringify(lane.stations()), + ghostIds: ledger.filter((entry) => !markIds.includes(entry.id)).length, + resets: profile.got.resets, + heardB05: listenFromStart ? heard.length : null, + }; +} +out.b05First = scenario("P271-b05", true); +out.b06First = scenario("P271-b06", false); + +// ③ 노선 따라감(가게 씀 + follow) = 편집 안 옮김 · 다시 실음(같은 목록) = 안 옮김 · 측점 다시 안 씀 +{ + const P = "P271-follow"; + ids.usePipeIds(P); + const profile = fakeProfile(); + const lane = createFacilityLane(P, () => profile); + const store = useDesignStore(P, null); + lane.setTypes(TYPES); + lane.set({ structures: [], pipes: [{ id: "A", chainage_m: 80, facility: "pipe" }] }, false); + store.set("pipes", [{ id: "A", chainage_m: 95, source: "user", facility: "pipe" }]); + store.set("structures", [wall("w9", 150)]); + lane.follow(); + const followed = { + stations: stationView(lane).map(([at]) => at), + resets: [...profile.got.resets], + }; + const writes = profile.got.stationWrites; + lane.set({ structures: [wall("w9", 150)] }, false); // 숨었던 B05 가 다시 보이며 같은 목록을 실음 + const reshown = { resets: profile.got.resets.length, writes: profile.got.stationWrites - writes }; + lane.set({ structures: [wall("w9", 170)] }); // 사용자 옮김 = 옮김 + out.follow = { followed, reshown, userMove: profile.got.resets }; +} + +console.log(JSON.stringify(out)); diff --git a/resources/tester/helper_443_save_stops.cjs b/resources/tester/helper_443_save_stops.cjs index 9031f6c45..17ae3b82d 100644 --- a/resources/tester/helper_443_save_stops.cjs +++ b/resources/tester/helper_443_save_stops.cjs @@ -57,8 +57,7 @@ async function run() { const bridge = bridgeMod.createStructuresBridge({ projectId: "p1", panel: () => ({ structures: quiet, values: () => ({}) }), - profilePanel: () => quiet, - renderStationLines: () => undefined, + lane: quiet, // 시설 줄 한 벌(462) isRestoring: () => false, }); out.conflict = await settle(bridge.saveStructuresIfDirty()); diff --git a/resources/tester/test_213_2_ghost_edit_drop.py b/resources/tester/test_213_2_ghost_edit_drop.py index 203185130..10441557c 100644 --- a/resources/tester/test_213_2_ghost_edit_drop.py +++ b/resources/tester/test_213_2_ghost_edit_drop.py @@ -180,8 +180,7 @@ def test_move_sites_follow_edits(): b06 = (PROJECT_ROOT / "B06_Section/B06_Section_UI_Page_Structures_Panel.ts").read_text( encoding="utf-8" ) - b05 = (PROJECT_ROOT / "B05_Profile/B05_Profile_UI_Page_Structures.ts").read_text( - encoding="utf-8" - ) + # B05 측점 갱신 = 페이지 시설 줄 한 벌(462 · 어느 보기 고침이든) + lane = (PROJECT_ROOT / "A00_Common/design/design_facility_lane.ts").read_text(encoding="utf-8") assert "deps.shared.profile?.resetStationEdit(hit.chainage_m, to)" in b06 - assert "followedStation(station, gone, born)" in b05 + assert "followedStation(station, gone, born)" in lane diff --git a/resources/tester/test_271_facility_lane.py b/resources/tester/test_271_facility_lane.py new file mode 100644 index 000000000..0a34e3f68 --- /dev/null +++ b/resources/tester/test_271_facility_lane.py @@ -0,0 +1,111 @@ +"""462 B05 · B06 한 페이지 — 시설 줄 페이지 한 벌(요구 271-5 · 분석 4장 (A) · (E)). + +① R3 뒤집기 — B06 만 연 채 351 관 → 360 → 버림 → 카드가 351 관 id 를 물어 헛 id → 목록 다시 실음 = + 장부 한 줄(처음 id · 351) · 옛 코드 = {s1@360 헛 · s2@351}(장부 맞춤이 B05 보기에만) +② 같은 조작 — B05 먼저 듣는 중 = B06 먼저(끝에 B05 가 읽음) · 알약 · 측점 · 흙덮기 관 · 종단도 받은 것 같음 · 헛 id 0 +③ 노선 따라감 = 편집 안 옮김 · 숨었던 보기가 같은 목록을 다시 실음 = 안 옮김 · 측점 다시 안 씀 · 사용자 옮김 = 옮김 +④ 이음 — 관 알림 · 노선 따라감 · B06 목록이 페이지 한 벌로 · 보기는 읽기만 · B05 → B06 관 옵션 = 세션 칸 +TS 셈은 `helper_271_facility_lane.cjs`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_271_facility_lane.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_271_facility_lane.cjs") + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def src(*parts: str) -> str: + return ROOT.joinpath(*parts).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_B06_만_열어도_장부_헛_id_없음(got) -> None: + r3 = got["r3"] + assert r3["first"] == r3["moved"] == "pipe-s1" # 옮겨도 그 id + assert r3["ghostBeforeReload"] is True # 버린 사이 카드가 지은 헛 id(재현) + assert r3["after"] == [{"id": "pipe-s1", "at": 351}] # 다시 실으면 처음 id 한 줄 + assert r3["afterId"] == "pipe-s1" + assert r3["marks"] == [["pipe-s1", "pipe", 351]] + + +def test_B06_먼저_B05_먼저_같음(got) -> None: + first, late = got["b05First"], got["b06First"] + for key in ("marks", "stations", "minCover", "resets"): + assert first[key] == late[key], key + assert first["stations"] == [60, 120, 135, 140, 145, 230] + assert first["minCover"] == [60, 120, 230] + for one in (first, late): + assert one["profileMarks"] is True and one["profileStations"] is True + assert one["ghostIds"] == 0 + assert first["heardB05"] == 6 # 숨은 보기도 바뀔 때마다 들음 + + +def test_노선_따라감은_편집_안_옮김(got) -> None: + follow = got["follow"] + assert follow["followed"] == {"stations": [95, 145, 150, 155], "resets": []} + assert follow["reshown"] == {"resets": 0, "writes": 0} + assert follow["userMove"] == [[145, 165], [150, 170], [155, 175]] + + +def test_이음_페이지_한_벌() -> None: + shared = src("A00_Common", "design", "design_shared.ts") + changed = shared[shared.index("onPipesChanged: (pipes) => {") :] + changed = changed[: changed.index("},")] + assert changed.index("shared.lane.set({ pipes });") < changed.index("toAll(") + assert "shared.lane.follow();" in shared[shared.index("onFacilitiesFollowed: (far)") :][:200] + assert "shared.lane.paint();" in shared # 종단도가 막 섰을 때 + page = src("A00_Common", "design", "design_page.ts") + assert "lane: createFacilityLane(projectId, () => shared.profile)," in page + assert "shared.lane.dispose();" in page + # 종단도에 알약 · 측점 · 흙덮기 관을 쓰는 자리 = 시설 줄 하나 + for parts in ( + ("A00_Common", "design", "design_view_profile.ts"), + ("B05_Profile", "B05_Profile_UI_Page_Structures.ts"), + ("B06_Section", "B06_Section_UI_Section_View_Profile.ts"), + ): + text = src(*parts) + for call in ( + "setIrregularStations(", + "setMinCoverPipes(", + "placePipeIds(", + "prunePipeIds(", + ): + assert call not in text, (parts[-1], call) + assert "planStations(" not in text, parts[-1] + panel = src("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + push = panel[panel.index("const pushMarks") :] + assert "deps.shared.lane.set({ structures, pipes: pipeFacilities }, carryEdits);" in push[:600] + view = src("A00_Common", "design", "design_view_profile.ts") + assert "marks: shared.lane.marks," in view and "types: shared.lane.types," in view + + +def test_관_옵션_넘김은_세션_칸() -> None: + shared = src("A00_Common", "design", "design_shared.ts") + assert "pipeOptionQueue" not in shared + assert 'writeState("pipe-option-queue"' in shared + assert '"pipe-option-queue": { bucket: "draft", scope: "project" },' in src( + "A00_Common", "b_page_state.ts" + ) diff --git a/resources/tester/test_284_1_pill_pick.py b/resources/tester/test_284_1_pill_pick.py index 60eef37e8..c3bd0d0d4 100644 --- a/resources/tester/test_284_1_pill_pick.py +++ b/resources/tester/test_284_1_pill_pick.py @@ -80,7 +80,7 @@ def test_B06_알약_풂과_측점_없는_관(): view = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") assert "if (structureId === null) {" in view assert "selectDesign(shared.projectId, NO_SELECTION, { from: ESCAPE_FROM });" in view - assert "if (edit?.selectPipe && !plantsStation(hit, types)) {" in view + assert "if (edit?.selectPipe && !plantsStation(hit, lane.types())) {" in view cross = _read("A00_Common", "design", "design_view_cross.ts") assert "selectPipe: (chainageM) => structuresPanel.selectPipeAt(chainageM)," in cross panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") diff --git a/resources/tester/test_399_plan_structure_marks.py b/resources/tester/test_399_plan_structure_marks.py index dea27acd8..e9b4b3a13 100644 --- a/resources/tester/test_399_plan_structure_marks.py +++ b/resources/tester/test_399_plan_structure_marks.py @@ -44,10 +44,12 @@ def test_마커_목록은_알약_목록(got) -> None: assert "const structures = laneStructures(options.structures);" in lane marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") assert "laneStructures(source.marks())" in marks - bridge = _read("B05_Profile", "B05_Profile_UI_Page_Structures.ts") - assert "setStructures([...ownStructures, ...pipeMarks]);" in bridge - assert "marks: (): StructureInstance[] => [...ownStructures, ...pipeMarks]," in bridge - assert "planMarksChanged(deps.projectId);" in bridge + # 알약 · 평면 마커 = 시설 줄 한 벌(462) 같은 목록 · 바뀌면 평면 다시 그림 + lane = _read("A00_Common", "design", "design_facility_lane.ts") + assert "to.setStructures(marks());" in lane + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "marks: shared.lane.marks," in view + assert "planMarksChanged(activeProjectId);" in view def test_고름_정보_창_법선(got) -> None: diff --git a/resources/tester/test_451_session_canon.py b/resources/tester/test_451_session_canon.py index 0848624e7..7c8cdfd88 100644 --- a/resources/tester/test_451_session_canon.py +++ b/resources/tester/test_451_session_canon.py @@ -4,7 +4,7 @@ 이 탭에서 고친 사용자 칸만 · [저장] patch 바탕도 서버 값 · 세션 = 서버 정본 그대로 ② 같은 판 · 서버 실패 = 세션 그대로(갈아끼움 · 알림 없음) ③ 노선 초안이 옛 상세를 쥔 채 서버 판이 바뀜 — 초안을 버리면 새 판 -④ 숨은 종단 보기도 관 세션 id 장부를 돌림(평면 마커 누름 = 초안 자리) · 그리기는 보일 때 +④ 숨은 종단 보기도 관 세션 id 장부를 돌림(평면 마커 누름 = 초안 자리 · 장부 = 시설 줄 한 벌 462) · 그리기는 보일 때 TS 셈은 `helper_451_session_canon.cjs`. ./venv/Scripts/python.exe -m pytest resources/tester/test_451_session_canon.py -q @@ -63,9 +63,10 @@ def test_초안_hold_는_버리면_새_판(got) -> None: def test_숨은_종단_보기도_관_id_장부() -> None: - view = (ROOT / "A00_Common/design/design_view_profile.ts").read_text(encoding="utf-8") - follow = view[view.index("const followFacilities") : view.index("const selectGraphAt")] - assert "if (!host.visible()) return void (lastPipes && shownPipes(lastPipes));" in follow - changed = view[view.index("onPipesChanged: (pipes) => {") :] - changed = changed[: changed.index("},")] - assert "else shownPipes(pipes);" in changed + # 장부 맞춤 = 페이지 시설 줄 한 벌(462) — 관 알림 · 노선 따라감 모두 보기 숨김과 무관 + shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8") + changed = shared[shared.index("onPipesChanged: (pipes) => {") :] + assert "shared.lane.set({ pipes });" in changed[: changed.index("},")] + assert "shared.lane.follow();" in shared + lane = (ROOT / "A00_Common/design/design_facility_lane.ts").read_text(encoding="utf-8") + assert "placePipeIds(" in lane and "prunePipeIds(" in lane diff --git a/resources/tester/test_b06_draft_pipe_replay.py b/resources/tester/test_b06_draft_pipe_replay.py index 4b034f681..6dd6c3dd3 100644 --- a/resources/tester/test_b06_draft_pipe_replay.py +++ b/resources/tester/test_b06_draft_pipe_replay.py @@ -46,6 +46,6 @@ def test_draft_pipe_options_are_replayed_onto_the_cache(): shared = _read("A00_Common", "design", "design_shared.ts") assert "shared.lastPipes = pipes" in shared profile = _read("A00_Common", "design", "design_view_profile.ts") - assert profile.index("if (lastPipes) applyPipes(lastPipes)") < profile.index( + assert profile.index("if (lastPipes) applyPipes();") < profile.index( "await bridge.load();\n advanceLoading" ) diff --git a/resources/tester/test_design_profile_single.py b/resources/tester/test_design_profile_single.py index 73d87d6b0..a4b0cae55 100644 --- a/resources/tester/test_design_profile_single.py +++ b/resources/tester/test_design_profile_single.py @@ -62,7 +62,8 @@ def test_막기_규칙은_종단도_한_곳(): assert profile.count("blocksMinCover(") == 1 # 판이 없는 보기도 관 목록을 넘겨 최소 흙덮기를 판정한다. assert "setMinCoverPipes(" in profile - assert "setMinCoverPipes(" in _read("B06_Section/B06_Section_UI_Section_View_Profile.ts") + # 두 보기 = 시설 줄 한 벌이 넘김(462) + assert "setMinCoverPipes(" in _read("A00_Common/design/design_facility_lane.ts") def test_크기_접기_키는_하나():