diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 920f92c0e..61525c4bf 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -259,7 +259,9 @@ export async function mountProfileView( panel: () => panel, profilePanel: () => profilePanel, bridge: () => bridge, + visible: () => host.visible(), // 숨은 동안 갈아끼운 상세(346-2)는 다시 보일 때 그림 }); + host.onDispose(sections.stopSwap); const { restorePanel, renderLatest, restoreSections } = sections; const view3d = createProfile3d({ projectId: activeProjectId, @@ -390,6 +392,7 @@ export async function mountProfileView( ready: () => !state.restoring, // 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다. reload: async () => { + sections.flushSwap(); // 숨은 동안 측점 목록이 바뀌었으면 종단도 · 3D 측점선 다시(346-2) await bridge.load(); if (lastPipes) applyPipes(lastPipes); }, diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts index dc9702ea6..47787fe6a 100644 --- a/A00_Common/design/design_view_profile_sections.ts +++ b/A00_Common/design/design_view_profile_sections.ts @@ -13,7 +13,12 @@ import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_P import type { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures"; import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; -import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; +import { + loadSectionDetail, + subscribeSectionDetail, + swapSectionStations, +} from "../../B06_Section/B06_Section_Section_Store"; +import { useDesignStore } from "./design_store"; import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures"; import { loadCorridorIfFresh } from "../../B05_Profile/B05_Profile_UI_Corridor"; import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations"; @@ -42,6 +47,8 @@ export interface ProfileSectionsDeps { panel: () => RoutePanel; profilePanel: () => ReturnType; bridge: () => ReturnType; + /** 종단 보기가 보이나 — 숨은 동안 갈아끼운 상세는 다시 보일 때 그림(346-2 · 없으면 늘 보임). */ + visible?: () => boolean; } /** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ @@ -114,6 +121,7 @@ export function createProfileSections(deps: ProfileSectionsDeps) { return; } profilePanel.setLoading(null); + followSwap(routeId); try { renderSections(detail, routeId); // 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만 @@ -154,6 +162,38 @@ export function createProfileSections(deps: ProfileSectionsDeps) { } } + /* 측점 수가 바뀜(346-2 · 묶음 측점 → 횡단 쪽이 상세를 갈아끼움) — 종단도 · 표 · 3D 측점선을 새 목록으로 + * 다시(보일 때만 · 숨었으면 다시 보일 때 `flushSwap`). 노선마다 한 번 듣고, 노선이 바뀌면 옛 것을 끊음. */ + let swap: { routeId: number; stop: () => void } | null = null; + let swapPending = false; + function followSwap(routeId: number): void { + if (swap?.routeId === routeId) return; + swap?.stop(); + const stopListen = subscribeSectionDetail(projectId, routeId, (next) => { + state.detail = next; + swapPending = !(deps.visible?.() ?? true); + if (!swapPending) renderSections(next, routeId); + }); + // 묶음 측점(가게)이 바뀌면 갈아끼움 — 횡단 보기를 안 열었어도(그쪽도 부르면 둘째는 「같은 목록」) + const stopStore = useDesignStore(projectId, null).subscribe("stations", (stations) => { + if (stations?.length) void swapSectionStations(projectId, routeId, stations); + }); + swap = { + routeId, + stop: () => { + stopListen(); + stopStore(); + }, + }; + } + + /** 숨은 동안 받은 새 상세를 그림 — 종단 보기가 다시 보일 때. */ + function flushSwap(): void { + if (!swapPending || !state.detail || !swap) return; + swapPending = false; + renderSections(state.detail, swap.routeId); + } + /** 「3D 늦음」 — [3D 업데이트] 표시(setCorridorPending 그대로)와 상태 한 칸을 같이 맞춘다. */ function setCorridorPending(pending: boolean): void { state.corridorPending = pending; @@ -184,5 +224,7 @@ export function createProfileSections(deps: ProfileSectionsDeps) { restoreSections, renderLatest, setCorridorPending, + flushSwap, + stopSwap: () => swap?.stop(), }; } diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 43e57226f..9df278c0e 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -38,6 +38,9 @@ import { useStationKeyList, } from "./B06_Section_Station_Keys"; import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap"; +import { emitSwap, type DetailListener } from "./B06_Section_Swap_Bus"; + +export { onAnySwap } from "./B06_Section_Swap_Bus"; const cache = new Map(); const pending = new Map>(); @@ -111,8 +114,6 @@ export async function loadSectionDetail( return request; } -/** 새 상세 · 이어 받은 측점의 누가거리 옮김(옛 0.01 m 글 → 새 글 · 조작값 맵을 옮길 때). */ -type DetailListener = (detail: SectionDetailResponse, moves: ReadonlyMap) => void; const listeners = new Map>(); /** 상세 객체가 갈아끼워짐(측점 수가 바뀜 · 346-2 4파) — 쥔 옛 객체를 버리고 새것으로 다시 그릴 것. */ @@ -154,6 +155,7 @@ export async function swapSectionStations( await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4) withStructureAreas(swap.detail); if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음 + emitSwap(swap.detail, swap.moves); // 세션 값 옮김 · 다시 그림(어느 보기든) listeners.get(key)?.forEach((fn) => fn(swap.detail, swap.moves)); return { detail: swap.detail, moves: swap.moves }; } diff --git a/B06_Section/B06_Section_Swap_Bus.ts b/B06_Section/B06_Section_Swap_Bus.ts new file mode 100644 index 000000000..277ea26e8 --- /dev/null +++ b/B06_Section/B06_Section_Swap_Bus.ts @@ -0,0 +1,27 @@ +/* ============================================================================= + * B06_Section_Swap_Bus.ts + * 상세 갈아끼움 알림판(PLAN 346-2) — 측점 수가 바뀌어 `Section_Store` 가 상세 객체를 갈아끼우면 여기로 알림. + * 가져오는 것 없음 — 세션 조작값 맵(`_UI_Page_Link_Session`) · 암 경계 저장소(`_UI_Page_Persist`)가 모듈을 읽을 때 + * 붙어도 `Section_Store` 와 돌고 도는 가져오기가 안 생김. + * ========================================================================== */ + +import type { SectionDetailResponse } from "./B06_Section_Api_Types"; + +/** 새 상세 · 이어 받은 측점의 누가거리 옮김(옛 0.01 m 글 → 새 글 · 조작값 맵을 옮길 때). */ +export type DetailListener = ( + detail: SectionDetailResponse, + moves: ReadonlyMap, +) => void; + +const anyListeners = new Set(); + +/** 어느 노선이든 상세가 갈아끼워지면 부름(조정창 값 · 암 경계 옮김 · 다시 그림) — 돌려준 함수로 끊음. */ +export function onAnySwap(fn: DetailListener): () => void { + anyListeners.add(fn); + return () => anyListeners.delete(fn); +} + +/** 갈아끼운 쪽(`Section_Store`)이 부름. */ +export function emitSwap(detail: SectionDetailResponse, moves: ReadonlyMap): void { + anyListeners.forEach((fn) => fn(detail, moves)); +} diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index 3544a19d6..18fe380ad 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -27,6 +27,7 @@ import { import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc"; import { renameChainageKeys } from "./logic/B06_Section_Station_Swap"; +import { onAnySwap } from "./B06_Section_Swap_Bus"; /** 세션에 담기는 측점별 조작값 한 겹 맵. */ export interface SessionMap { @@ -65,6 +66,9 @@ export function moveSessionMaps(moves: ReadonlyMap): void { } } +// 상세 갈아끼움(어느 보기가 했든)마다 한 번 — 조정창 값을 옮긴 측점 자리로 +onAnySwap((_detail, moves) => moveSessionMaps(moves)); + export function createSessionMap( sessionKey: () => string | null, accept: (value: unknown) => V | undefined, diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 3580cdb6f..398fe1520 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -15,6 +15,7 @@ import { type SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { invalidateSectionDetail } from "./B06_Section_Section_Store"; +import { onAnySwap } from "./B06_Section_Swap_Bus"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { @@ -87,6 +88,9 @@ export function reloadKeyedStores(): void { keyedStores.forEach((store) => store.load()); } +// 상세 갈아끼움(어느 보기가 했든)마다 한 번 — 암 경계 · 암 경사를 새 목록 누가거리로 다시 읽음 +onAnySwap(() => reloadKeyedStores()); + /** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */ function keyedScope(storageKey: string): { projectId: string; routeId: number } | null { const [projectId, route] = storageKey.split(":").slice(-2); diff --git a/B06_Section/B06_Section_UI_Page_Station_Swap.ts b/B06_Section/B06_Section_UI_Page_Station_Swap.ts index a53ff4132..6a2c0db26 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Swap.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Swap.ts @@ -1,14 +1,13 @@ /* ============================================================================= * B06_Section_UI_Page_Station_Swap.ts * 횡단 보기가 계산 묶음 측점을 따라감(PLAN 346-2 4파) — 가게 `stations` 가 측점 목록을 바꾸면(늘고 줄면) - * 상세 객체를 갈아끼우고(`swapSectionStations`) 조정창 값 · 암 경계 · 암 경사를 새 자리로 옮긴 뒤 보일 때 카드 · - * 종단도를 다시(카드는 보이는 것 먼저 · 다시 셈은 그 길이 맡음 · 안 보이면 다시 보일 때 그림). + * 상세 객체를 갈아끼우고(`swapSectionStations` · 조정창 값 · 암 경계 · 암 경사 옮김은 `onAnySwap` 이 맡음) 보일 때 + * 카드 · 종단도를 다시(카드는 보이는 것 먼저 · 다시 셈은 그 길이 맡음 · 안 보이면 다시 보일 때 그림). + * 종단 보기도 같은 가게를 듣고 같은 함수를 부름 — 먼저 부른 쪽이 갈아끼우고 다음 쪽은 「같은 목록」. * ========================================================================== */ import { useDesignStore } from "../A00_Common/design/design_store"; -import { swapSectionStations } from "./B06_Section_Section_Store"; -import { moveSessionMaps } from "./B06_Section_UI_Page_Link_Session"; -import { reloadKeyedStores } from "./B06_Section_UI_Page_Persist"; +import { onAnySwap, swapSectionStations } from "./B06_Section_Section_Store"; /** 묶음 측점을 따라감 — 돌려준 함수로 끊음. `redraw` = 캐시에서 상세를 다시 읽어 카드 · 그림 다시. */ export function followSwaps( @@ -18,14 +17,14 @@ export function followSwaps( redraw: () => Promise, ): () => void { if (!projectId) return () => undefined; - return useDesignStore(projectId, null).subscribe("stations", (stations) => { + // 누가 갈아끼웠든(종단 보기가 먼저 불렀어도) 보이면 다시 그림 — 한 페이지 한 노선 + const stopRedraw = onAnySwap(() => void (host.visible() && redraw())); + const stopStore = useDesignStore(projectId, null).subscribe("stations", (stations) => { const route = routeId(); - if (route === null || !stations?.length) return; - void swapSectionStations(projectId, route, stations).then((swap) => { - if (!swap) return; // 같은 목록 — 제자리 갱신 길이 맡음 - moveSessionMaps(swap.moves); // 조정창 값(반폭 · 기슭막이 · 유입 · 집수정 · 추가 벽) - reloadKeyedStores(); // 암 경계 · 암 경사 = 측점 열쇠 세션 → 새 목록 누가거리로 - if (host.visible()) void redraw(); - }); + if (route !== null && stations?.length) void swapSectionStations(projectId, route, stations); }); + return () => { + stopRedraw(); + stopStore(); + }; } diff --git a/resources/tester/helper_323_9_save_redraw.cjs b/resources/tester/helper_323_9_save_redraw.cjs index 858520282..5408af51a 100644 --- a/resources/tester/helper_323_9_save_redraw.cjs +++ b/resources/tester/helper_323_9_save_redraw.cjs @@ -68,6 +68,10 @@ function loadTs(file) { return box.exports; } +// 측점 열쇠 한 벌(344-3 · 346-2)은 진짜로 — 빈 대역은 열쇠 목록을 못 냄 +STUBS["@util/common_util_station_key"] = loadTs( + path.join(ROOT, "common_util", "common_util_station_key.ts"), +); const { saveDesign, savingDesign } = loadTs( path.join(ROOT, "A00_Common", "design", "design_save.ts"), ); diff --git a/resources/tester/helper_342_1_entry.ts b/resources/tester/helper_342_1_entry.ts index c9ec78270..c1a660fff 100644 --- a/resources/tester/helper_342_1_entry.ts +++ b/resources/tester/helper_342_1_entry.ts @@ -26,3 +26,4 @@ export { createCrossPreview } from "../../B05_Profile/B05_Profile_UI_Profile_Pre export { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; export { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync"; export { writeState } from "../../A00_Common/b_page_state"; +export { listedBase } from "../../A00_Common/design/design_profile_base"; diff --git a/resources/tester/helper_342_1_station_line.mjs b/resources/tester/helper_342_1_station_line.mjs index e336a91a2..953785df9 100644 --- a/resources/tester/helper_342_1_station_line.mjs +++ b/resources/tester/helper_342_1_station_line.mjs @@ -114,29 +114,8 @@ const irregularStations = [ { id: "pipe-1", station: 4, remainder: 5.05, chainage_m: PIPE, structure: "파형강관 D800" }, ]; -/* 화면 선 = design_profile.ts 의 진짜 listed() */ -const dpSrc = readFileSync(join(ROOT, "A00_Common", "design", "design_profile.ts"), "utf8"); -const listedSrc = dpSrc.match(/\r?\n function listed\(\)[\s\S]*?\r?\n \}\r?\n/)[0]; -const listedOf = new Function( - "storedBase", - "detail", - "irregularStations", - "oneStationList", - "irregularGraphStations", - "maxChainageOf", - "followMovedNodes", - `${transpile(listedSrc)}\nreturn listed();`, -); -const screenBase = (detail) => - listedOf( - m.toAlignmentBase(stored), - detail, - irregularStations, - m.oneStationList, - m.irregularGraphStations, - m.maxChainageOf, - m.followMovedNodes, - ); +/* 화면 선 = 종단도가 쓰는 진짜 `listedBase`(design_profile_base · 옛 design_profile 안 listed()) */ +const screenBase = (detail) => m.listedBase(m.toAlignmentBase(stored), detail, irregularStations); const staleOf = (detail, line) => m.staleKey({ longitudinal: { design_profiles: [m.toDesignProfile(line, undefined)] }, diff --git a/resources/tester/helper_346_2_station_swap.mjs b/resources/tester/helper_346_2_station_swap.mjs index 9458927f0..08fc7ef39 100644 --- a/resources/tester/helper_346_2_station_swap.mjs +++ b/resources/tester/helper_346_2_station_swap.mjs @@ -190,6 +190,60 @@ const NEW = (dy) => [ stopListen(); } +/* 2-2. 종단 보기(B05) · 3D 측점선 — 횡단 보기 없이도 가게 측점으로 갈아끼우고 보일 때만 다시 그림 */ +{ + const R2 = 11; + m.writeState("section-detail", oldDetail(), P, R2); + const renders = []; + const lines = []; + let shown = true; + const sections = m.createProfileSections({ + projectId: P, + state: { + confirmedSurface: null, + latest: { route_points: [] }, + roadWidths: { g: 5 }, + detail: null, + corridorVisible: false, + corridorPending: false, + routeReady: true, + restoring: false, + }, + viewer: { renderStationLines: (list) => lines.push(list.length), markers: {} }, + panel: () => ({ + values: () => ({ gradeClass: "g", stationInterval: 20, enforcePipeClearance: false }), + gradeLimitPct: () => 12, + stationDisplayOffset: () => ({ station: 0, cumulative: 0 }), + setCorridorPending: () => undefined, + }), + profilePanel: () => ({ + render: (detail) => renders.push(detail.cross_sections.length), + setGradeLimit: () => undefined, + setEnforceMinCover: () => undefined, + setStationDisplay: () => undefined, + setIrregularStations: () => undefined, + alignmentSamples: () => null, + setLoading: () => undefined, + clear: () => undefined, + }), + bridge: () => ({ irregularStations: () => [] }), + visible: () => shown, + }); + await sections.restoreSections(R2); + const shop = m.useDesignStore(P, null); + shop.set("stations", NEW(0)); // 보임 — 바로 다시 + await new Promise((resolve) => setTimeout(resolve, 30)); + const afterShown = [...renders]; + shown = false; + shop.set("stations", NEW(0).slice(0, 6)); // 숨음 — 안 그림 + await new Promise((resolve) => setTimeout(resolve, 30)); + const whileHidden = [...renders]; + shown = true; + sections.flushSwap(); // 다시 보임 + out.profile = { afterShown, whileHidden, afterShow: [...renders], lines: lines.length }; + sections.stopSwap(); +} + /* 3. 보이는 카드 먼저 — 바로 그릴 수 */ out.eager = [ m.eagerCardCount({ clientHeight: 600, scrollTop: 0 }, 3, 250, 12), diff --git a/resources/tester/helper_346_2_swap_entry.ts b/resources/tester/helper_346_2_swap_entry.ts index 29be9449b..56d0b2bbe 100644 --- a/resources/tester/helper_346_2_swap_entry.ts +++ b/resources/tester/helper_346_2_swap_entry.ts @@ -23,3 +23,4 @@ export { } from "../../B06_Section/B06_Section_UI_Page_Link_Session"; export { useDesignStore } from "../../A00_Common/design/design_store"; export { readState, writeState } from "../../A00_Common/b_page_state"; +export { createProfileSections } from "../../A00_Common/design/design_view_profile_sections"; diff --git a/resources/tester/test_346_2_station_swap.py b/resources/tester/test_346_2_station_swap.py index df0af3dbc..d03def1d7 100644 --- a/resources/tester/test_346_2_station_swap.py +++ b/resources/tester/test_346_2_station_swap.py @@ -7,6 +7,7 @@ - 가게 `stations` → 캐시 상세 갈아끼움 · 듣는 쪽에 새 상세 + 누가거리 옮김 · 카드 선택은 측점 열쇠로 새 자리(관 45.054 → 47 m) · 노선 모양이 바뀌면 넘김 규칙 (단면 유형 버림) · 사라진 측점 고침 버림 · 조정창 값도 옮긴 자리로. + - 종단 보기(B05) · 3D 측점선 — 횡단 보기를 안 열어도 가게 측점으로 갈아끼움 · 보일 때만 다시 그림. - 보이는 카드 먼저 — 보이는 줄 + 한 줄만 바로. - `formatStation` ↔ 파이썬 `format_station` 같은 글. @@ -116,6 +117,14 @@ def test_가게_측점으로_상세_갈아끼움_열쇠로_이어_받기(out): assert store["again"] is True # 같은 목록 = 안 갈아끼움 +def test_종단_보기_3D_측점선도_새_목록_보일_때만(out): + profile = out["profile"] + assert profile["afterShown"] == [7, 8] # 처음 7 → 가게 측점으로 갈아끼움 8(횡단 보기 없이) + assert profile["whileHidden"] == [7, 8] # 숨은 동안은 안 그림 + assert profile["afterShow"] == [7, 8, 6] # 다시 보일 때 새 목록(6) + assert profile["lines"] == 3 # 3D 측점선도 같은 차례로 다시 + + def test_보이는_카드_먼저(out): assert out["eager"] == [12, 27, 4] # 3열 · 600 px · (250+12) px 줄 = 3 줄 + 1 assert out["rename"] == {"47.00": 1, "b47.00": 2, "47.00:outlet": 3, "60.00": 4}