feat(종단보기): 346-2 덧 2 B05 종단 보기 · 3D 측점선도 갈아끼운 상세로 — 보일 때만 · 숨었으면 다시 보일 때 · 횡단 보기 없이도

- design_view_profile_sections: 노선마다 상세 갈아끼움을 듣고 종단도 · 표 · 3D 측점선 다시(보일 때만 · 숨으면 flushSwap 이 다시 보일 때) · 가게 stations 도 들어 횡단 보기를 안 열어도 갈아끼움(먼저 부른 쪽만 · 다음은 「같은 목록」)
- design_view_profile: 받는 줄만(보임 · 떠날 때 끊음 · 다시 보일 때 flushSwap)
- 갈아끼움 알림판 B06_Section_Swap_Bus(가져오기 없음 · 돌고 도는 가져오기 막음) — 조정창 값 · 암 경계 옮김을 어느 보기가 갈아끼웠든 한 번 · 횡단 보기는 알림으로 다시 그림
- 시험: 종단 보기 보임 7 → 8 · 숨음 = 안 그림 · 다시 보임 = 6 · 3D 측점선 같은 차례 · 옛 시험 둘(342-1 화면 선 = listedBase · 323-9 열쇠 모듈 진짜로) 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-16 12:28:11 +09:00
co-authored by Claude Opus 5.5
parent 69d84e5ca9
commit da3dfc1ca8
13 changed files with 168 additions and 39 deletions
+3
View File
@@ -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);
},
@@ -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<typeof createRouteProfilePanel>;
bridge: () => ReturnType<typeof createStructuresBridge>;
/** 종단 보기가 보이나 — 숨은 동안 갈아끼운 상세는 다시 보일 때 그림(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(),
};
}
+4 -2
View File
@@ -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<string, SectionDetailResponse>();
const pending = new Map<string, Promise<SectionDetailResponse>>();
@@ -111,8 +114,6 @@ export async function loadSectionDetail(
return request;
}
/** 새 상세 · 이어 받은 측점의 누가거리 옮김(옛 0.01 m 글 → 새 글 · 조작값 맵을 옮길 때). */
type DetailListener = (detail: SectionDetailResponse, moves: ReadonlyMap<string, string>) => void;
const listeners = new Map<string, Set<DetailListener>>();
/** 상세 객체가 갈아끼워짐(측점 수가 바뀜 · 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 };
}
+27
View File
@@ -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<string, string>,
) => void;
const anyListeners = new Set<DetailListener>();
/** 어느 노선이든 상세가 갈아끼워지면 부름(조정창 값 · 암 경계 옮김 · 다시 그림) — 돌려준 함수로 끊음. */
export function onAnySwap(fn: DetailListener): () => void {
anyListeners.add(fn);
return () => anyListeners.delete(fn);
}
/** 갈아끼운 쪽(`Section_Store`)이 부름. */
export function emitSwap(detail: SectionDetailResponse, moves: ReadonlyMap<string, string>): void {
anyListeners.forEach((fn) => fn(detail, moves));
}
@@ -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<V> {
@@ -65,6 +66,9 @@ export function moveSessionMaps(moves: ReadonlyMap<string, string>): void {
}
}
// 상세 갈아끼움(어느 보기가 했든)마다 한 번 — 조정창 값을 옮긴 측점 자리로
onAnySwap((_detail, moves) => moveSessionMaps(moves));
export function createSessionMap<V>(
sessionKey: () => string | null,
accept: (value: unknown) => V | undefined,
@@ -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);
+12 -13
View File
@@ -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>,
): () => 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();
};
}
@@ -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"),
);
+1
View File
@@ -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";
+2 -23
View File
@@ -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)] },
@@ -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),
@@ -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";
@@ -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}