refactor(387-4): 횡단 다시 셈 창구 하나 · 면적 얹기 한 곳 — 화면은 묶음에 부탁

- 다시 셈(refreshCrossDesigns) 부르는 곳 = 묶음 cross 단계 하나(design_shared savedCross)
- 종단 미리 셈 · B06 설계 맞춤 · 3D 업데이트 = pipelineCross · reconcilePagePipeline(▲ 한 번 = cross 한 번)
- 입력 칸 밖 세션 고침(노견 · 소단 · 단면 유형 · 조정창)은 forceCross 로 같은 입력이어도 한 번 더
- 카드 그리기 · 횡단 보기 면적 쓰기 지움 · 다시 셈 창구가 면적을 늘 얹음(바뀐 측점만 알림)
- 저장 안 한 조정창 값을 상세 가게가 읽을 때 얹음(새 Section_Adjusts · B05 만 열어도 같은 면적)
- 새 test_384_4_cross_window · 옛 약속 시험 넷 새 약속으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-17 18:19:21 +09:00
co-authored by Claude Opus 5.5
parent 1fa5717fca
commit 6a3f9647f3
17 changed files with 342 additions and 259 deletions
+6 -1
View File
@@ -56,6 +56,11 @@ function digest(text: string): string {
return `${text.length}:${hash >>> 0}`; return `${text.length}:${hash >>> 0}`;
} }
/** 다시 셈 부탁 수(387-4) — 입력 칸에 없는 세션 고침(노견 · 소단 구간 · 조정창 4축 · 단면 유형)을 고친 화면이
* 올림 · cross 입력 지문에 들어가 같은 입력이어도 묶음 cross 가 한 번 더 돎(다시 셈 창구 한 곳). */
let forced = 0;
export const forceCross = (): void => void (forced += 1);
const finite = (value: unknown): number | null => const finite = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null; typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -180,7 +185,7 @@ export function pipelineCrossSource(
standard, standard,
byKey, byKey,
rockBoundaryDefaultM: readRockBoundaryDefault(projectId), rockBoundaryDefaultM: readRockBoundaryDefault(projectId),
specsKey: digest(JSON.stringify([...specs])), specsKey: `${digest(JSON.stringify([...specs]))}|${forced}`,
}, },
specs, specs,
}; };
+7 -22
View File
@@ -67,10 +67,6 @@ import "../../B06_Section/B06_Section_UI_Style_Cross.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import {
applyStructureAreaRows,
structureAreaRows,
} from "../../B06_Section/B06_Section_Structure_Layouts";
import { createSampleWidener, L } from "../../B06_Section/B06_Section_UI_Page_Common"; import { createSampleWidener, L } from "../../B06_Section/B06_Section_UI_Page_Common";
import { import {
createCrossViewPart, createCrossViewPart,
@@ -228,14 +224,7 @@ export async function mountCrossView(
await new Promise((next) => setTimeout(next, 0)); // 겹친 신호(갈아끼움 · 묶음 · 초안) = 한 번(365-4) await new Promise((next) => setTimeout(next, 0)); // 겹친 신호(갈아끼움 · 묶음 · 초안) = 한 번(365-4)
const fresh = await loadSectionDetail(projectId, currentRouteId); const fresh = await loadSectionDetail(projectId, currentRouteId);
if (seq !== structuresRefreshSeq) return; if (seq !== structuresRefreshSeq) return;
sectionDetail = fresh; sectionDetail = fresh; // 구조물 면적은 상세 가게가 읽을 때 얹음(387-4 · 화면은 안 얹음)
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 셈(카드만 고치면 곡선이 옛 면적 · 2026-09-06 실측).
if (sectionDetail) {
applyStructureAreaRows(
sectionDetail.cross_sections,
structureAreaRows(sectionDetail.cross_sections),
);
}
renderSectionDetail(); renderSectionDetail();
void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314) void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314)
} catch (error) { } catch (error) {
@@ -292,7 +281,9 @@ export async function mountCrossView(
const stationControls = createStationControls({ const stationControls = createStationControls({
// 키는 등록표(`b_page_state`)가 만든다 — 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다. // 키는 등록표(`b_page_state`)가 만든다 — 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다.
sessionKey: (kind) => stateKey(kind, projectId, currentRouteId), sessionKey: (kind) => stateKey(kind, projectId, currentRouteId),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM), // 조정창 값 = 입력 칸 밖 세션 고침 → 묶음 cross 한 번 더 · 면적은 거기서(387-4 · 카드 그리기는 안 고침)
refreshCard: (chainageM) =>
void (sectionView.refreshCard(chainageM), reconcileStaleDesigns({ force: true })),
detail: () => sectionDetail, detail: () => sectionDetail,
crossHalfWidth, crossHalfWidth,
sampledHalfWidth, sampledHalfWidth,
@@ -366,7 +357,8 @@ export async function mountCrossView(
structureSpan: stationControls.structureSpan, structureSpan: stationControls.structureSpan,
soilTiers: pipeExtraCount, soilTiers: pipeExtraCount,
overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values), overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM), refreshCard: (chainageM) =>
void (sectionView.refreshCard(chainageM), reconcileStaleDesigns({ force: true })),
}; };
// 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화). // 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화).
const structureSelection = wireStructureSelection( const structureSelection = wireStructureSelection(
@@ -631,14 +623,7 @@ export async function mountCrossView(
// 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다. // 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다.
sectionDetail = await loadSectionDetail(projectId, context.route_id); sectionDetail = await loadSectionDetail(projectId, context.route_id);
structuresPanel.facility.recount(); // 단 수는 횡단 자료로 셈 — 흙막이 상자 · 목록 램프 다시(54-15) structuresPanel.facility.recount(); // 단 수는 횡단 자료로 셈 — 흙막이 상자 · 목록 램프 다시(54-15)
// 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면 // 구조물 초안(저장 안 한 벽) · 그 면적은 상세 가게가 얹어 줌(387-4 · 화면은 안 얹음).
// 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측).
if (sectionDetail) {
applyStructureAreaRows(
sectionDetail.cross_sections,
structureAreaRows(sectionDetail.cross_sections),
);
}
// 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정 // 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정
const summaryData = existing.longitudinal.data as { const summaryData = existing.longitudinal.data as {
options?: { options?: {
+3 -3
View File
@@ -20,7 +20,7 @@ import {
writeCachedBytes, writeCachedBytes,
} from "../A00_Common/b_asset_cache"; } from "../A00_Common/b_asset_cache";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { refreshStaleDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; import { reconcilePagePipeline } from "../A00_Common/design/design_pipeline_page";
import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { RoutePoint } from "./B05_Profile_Api_Fetch";
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client"; import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client";
@@ -207,8 +207,8 @@ export async function refreshCorridor(
rebuild = true, rebuild = true,
): Promise<void> { ): Promise<void> {
if (!routeId) return; if (!routeId) return;
// [3D 업데이트] — 낡은 횡단 설계(계획고 · 구조물 자리 값)를 먼저 다시 셈한 뒤 그 설계로 지음(317-1) // [3D 업데이트] — 계산 묶음 cross 가 다 돈 뒤(낡은 횡단 설계 다시 셈 · 387-4) 그 설계로 지음(317-1)
if (rebuild) await refreshStaleDesigns({ projectId, routeId, detail }).catch(() => []); if (rebuild) await reconcilePagePipeline(projectId).catch(() => 0);
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild)
.then((build) => viewer.setCorridor(withSections(build, detail))) .then((build) => viewer.setCorridor(withSections(build, detail)))
.catch(() => viewer.setCorridor(null)); .catch(() => viewer.setCorridor(null));
+17 -69
View File
@@ -2,20 +2,17 @@
* B05_Profile_UI_Profile_Preview.ts * B05_Profile_UI_Profile_Preview.ts
* 계획선 편집 → 횡단 설계 프리뷰 반영 (패널 본체에서 분리, 2026-09-02 · 700줄 한계). * 계획선 편집 → 횡단 설계 프리뷰 반영 (패널 본체에서 분리, 2026-09-02 · 700줄 한계).
* *
* 계획고가 바뀌면 측점별 횡단 단면적도 바뀐다 — 서버에 한 번 물어 전 측점을 다시 계산해 * 계획고가 바뀌면 측점별 횡단 단면적도 바뀐다 — 다시 셈은 계산 묶음 cross 단계 **한 곳**(`design_shared`
* **공유 캐시가 들고 있는 같은 객체**를 제자리 갱신한다. 그래야 횡단 기준 유토곡선과 * savedCross · 387-4)이 하고 여기서는 그 차례만 기다림(▲ 한 번 = cross 한 번). 예전에는 그리기가 낡음을 보면
* 3D 예상형상이 같은 값으로 따라온다(2026-08-03·08-23 사용자 보고). * 여기서도 따로 다시 셈 → 묶음과 두 번 셈.
* * 기다리는 동안 「다시 계산 중」 · 묶음 차례가 cross 를 지났는데 남은 낡음은 곡선을 그리고 그 측점만 표시(342-1 · 349-0f).
* 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다.
* 노선 초안 동안은 서버에 안 물음 — 계산 묶음 cross 가 그 차례를 끝낼 때까지만 「다시 계산 중」 · 그 뒤 남은
* 낡음은 곡선을 그리고 그 측점만 표시(342-1 · 349-0f).
* ========================================================================== */ * ========================================================================== */
import type { AlignmentEdits, ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment"; import type { AlignmentEdits, ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment";
import { holdScreenAlignment, refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; import { holdScreenAlignment } from "../B06_Section/B06_Section_Cross_Refresh";
import { staleChainages } from "../B06_Section/B06_Section_UI_Section_Common"; import { staleChainages } from "../B06_Section/B06_Section_UI_Section_Common";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; import { pagePipeline, pipelineCross } from "../A00_Common/design/design_pipeline_page";
import { useDesignStore } from "../A00_Common/design/design_store"; import { useDesignStore } from "../A00_Common/design/design_store";
import type { DesignPipeline, PipelineEvent } from "../A00_Common/design/logic/design_pipeline"; import type { DesignPipeline, PipelineEvent } from "../A00_Common/design/logic/design_pipeline";
@@ -38,31 +35,24 @@ export interface CrossPreviewContext {
} }
export interface CrossPreview { export interface CrossPreview {
/** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */ /** 묶음에 cross 다시 부탁(같은 입력이면 안 셈). */
schedule: () => void; schedule: () => void;
/** /**
* 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). * 그리기가 잰 낡음 지문 — 돌려주는 값 = 묶음 cross 차례를 기다리는 중인가. 차례가 지난 뒤에도 남은 낡음은
* 돌려주는 값 = 다시 셈을 기다리는 중인가. 다시 셈 뒤에도 남은 낡음은 false — 곡선은 그리고 * false — 곡선은 그리고 그 측점만 표시(342-1 · 사용자 확정 10-16). `_draft` = 옛 갈래(노선 초안) · 같은 길.
* 그 측점만 표시(342-1 · 09-03 「새 값만 보여주기」를 바꿈 · 사용자 확정 10-16).
* `draft` = 노선 초안 동안 — 서버에 안 묻고 묶음 cross 차례만 기다림(349-0f).
*/ */
scheduleIfStale: (key: string, draft?: boolean) => boolean; scheduleIfStale: (key: string, _draft?: boolean) => boolean;
/** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */ /** 패널을 걷을 때 기다림 타이머를 끈다. */
dispose: () => void; dispose: () => void;
} }
export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
let timer = 0;
let seq = 0;
let lastStale = "";
/** 다시 셈 예약 · 진행 중 — 이 동안의 낡음은 「다시 계산 중」. */
let busy = false;
let warned = ""; let warned = "";
const release = holdScreenAlignment((routeId) => const release = holdScreenAlignment((routeId) =>
routeId === ctx.routeId() ? ctx.alignment() : null, routeId === ctx.routeId() ? ctx.alignment() : null,
); );
/** 노선 초안 동안 기다리는 묶음 차례 — 그 차례 cross(또는 뒤 단계)가 오면 풂. */ /** 기다리는 묶음 차례 — 그 차례 cross(또는 뒤 단계)가 오면 풂. */
let awaiting: number | null = null; let awaiting: number | null = null;
let waitTimer = 0; let waitTimer = 0;
let watched: DesignPipeline | null = null; let watched: DesignPipeline | null = null;
@@ -91,58 +81,16 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
); );
}; };
const preview: CrossPreview = { return {
scheduleIfStale(key, draft = false) { scheduleIfStale(key) {
if (draft) { if (awaiting === null) warnLeft(key);
if (awaiting === null) warnLeft(key); return key !== "" && awaiting !== null;
return key !== "" && awaiting !== null;
}
if (key && key !== lastStale) preview.schedule();
else if (!busy) warnLeft(key);
lastStale = key;
return key !== "" && busy;
},
schedule() {
if (!ctx.detail() || ctx.routeId() === null) return;
busy = true;
window.clearTimeout(timer);
timer = window.setTimeout(() => {
const detail = ctx.detail();
const routeId = ctx.routeId();
if (!detail || routeId === null) return void (busy = false);
const current = (seq += 1);
// 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가
// 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화).
const isCurrent = (): boolean =>
current === seq && ctx.detail() === detail && ctx.routeId() === routeId;
void refreshCrossDesigns({
projectId: ctx.projectId,
routeId,
detail,
edits: ctx.edits(),
alignment: ctx.alignment(),
// 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다.
shouldApply: isCurrent,
})
.then((updated) => {
if (current === seq) busy = false;
// 남은 낡음이 있으면 바뀐 것이 없어도 다시 그림 — 「다시 계산 중」에 멈추지 않게(342-1)
if (updated.length || lastStale) ctx.onApplied(updated);
})
.catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
if (current !== seq) return;
busy = false;
if (lastStale) ctx.onApplied([]);
});
}, ctx.debounceMs());
}, },
schedule: () => pipelineCross(ctx.projectId),
dispose() { dispose() {
window.clearTimeout(timer);
window.clearTimeout(waitTimer); window.clearTimeout(waitTimer);
stopGeneration(); stopGeneration();
release(); release();
}, },
}; };
return preview;
} }
+9 -38
View File
@@ -54,7 +54,6 @@ import {
import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile_UI_Profile_Data"; import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { useStationKeys } from "./B06_Section_Station_Keys"; import { useStationKeys } from "./B06_Section_Station_Keys";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
@@ -168,35 +167,10 @@ export function staleAgainstScreen(detail: SectionDetailResponse, routeId: numbe
}); });
} }
/**
* 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1).
* 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙).
*/
export async function refreshStaleDesigns(
input: Omit<CrossRefreshInput, "edits" | "shouldApply">,
): Promise<number[]> {
const draft = readAlignmentDraft(input.routeId);
if (!draft && !staleAgainstScreen(input.detail, input.routeId)) return [];
const saved = (
input.detail.longitudinal.profile_alignment as
| {
edits?: {
station_offsets?: Record<string, number>;
curve_radii?: Record<string, number>;
};
}
| undefined
)?.edits;
const edits = draft ?? {
station_offsets: saved?.station_offsets ?? {},
curve_radii: saved?.curve_radii ?? {},
};
return refreshCrossDesigns({ ...input, edits });
}
/** /**
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. * 돌려주는 값은 설계 · 면적이 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
* 부르는 곳은 계산 묶음 cross 단계 하나(`design_shared` savedCross · 387-4) — 화면은 `pipelineCross` 로 부탁.
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
*/ */
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> { export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
@@ -206,16 +180,13 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<num
if (sections.length && sections.every((section) => section.preview)) return []; if (sections.length && sections.every((section) => section.preview)) return [];
useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3) useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3)
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
const updated = refreshLocally(input) ?? (await refreshFromServer(input)); if (!refreshLocally(input)) await refreshFromServer(input);
// 구조물이 선 측점은 폐회로 면적이 정본이다 — 설계를 갈아 끼운 **이 자리에서 한 번** 다시 // 구조물이 선 측점은 폐회로 면적이 정본이다 — **이 자리 한 곳**에서 늘 다시 얹는다(387-4 · 카드 그리기는
// 얹는다. 부르는 쪽마다 얹으면 B06 흐름만 얹고 B05 는 안 얹어 두 보기의 유토 뱃지가 // 안 얹음 · 설계가 그대로여도 조정창 4축 · 구조물 제원이 바뀌면 면적이 바뀜 · 6 ms/67 측점).
// 갈렸다(계획서 24-2a · 실측 B05 −14,144.5 · B06 −12,533.2㎥). applyStructureAreaRows(
if (updated.length) { input.detail.cross_sections,
applyStructureAreaRows( structureAreaRows(input.detail.cross_sections),
input.detail.cross_sections, );
structureAreaRows(input.detail.cross_sections),
);
}
return sections return sections
.filter((section) => JSON.stringify(section.design) !== before.get(section)) .filter((section) => JSON.stringify(section.design) !== before.get(section))
.map((section) => section.chainage_m); .map((section) => section.chainage_m);
@@ -0,0 +1,83 @@
/* =============================================================================
* B06_Section_Section_Adjusts.ts
* 저장 안 한 조정창 값(세션 · 측점 누가거리 열쇠)을 상세 설계 칸에 얹음 — 상세 가게가 읽을 때 한 번(387-4).
*
* 조정창(`_UI_Page_Station_Controls` · `_Ford_Controls` · `_Span_Control`)이 고칠 때 캐시에 얹는 것과 같은
* 칸 · 같은 꼴. 예전에는 고칠 때만 얹어 새로고침 · B05 만 연 페이지는 세션 값을 몰랐음 — 카드 그리기가 면적을
* 고쳐 들어 B06 을 연 뒤에만 면적이 바뀜(B05 만 연 면적 ≠ B06). 조정창은 세션 → 설계 순으로 읽으니 겹쳐도 같음.
* ========================================================================== */
import { readState } from "../A00_Common/b_page_state";
import type { CrossDesign, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom";
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
type Cells = Record<string, unknown>;
type Obj = Record<string, unknown>;
const INLET = ["auto", "revet", "I", "L", "U"];
const isObj = (value: unknown): value is Obj => !!value && typeof value === "object";
const count = (value: unknown): number =>
Number.isInteger(value) && (value as number) > 0 ? (value as number) : 0;
/** 조정창 읽기와 같은 꼴 — 옛 숫자 = x 이동량 · d 0 = 기본 자리(null). */
function wallAdjust(value: unknown): WallAdjust | null {
if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value };
if (!isObj(value)) return null;
const partial = value as Partial<WallAdjust>;
return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null };
}
export function withDraftAdjusts(
detail: SectionDetailResponse,
projectId: string,
routeId: number,
): void {
const read = (name: Parameters<typeof readState>[0]): Cells =>
readState<Cells>(name, projectId, routeId) ?? {};
const revet = read("revetx");
const basin = read("basinadjust");
const inlet = read("inletstruct");
const extra = read("extrawall");
const spans = read("extraspan");
const ford = read("fordadjust");
const box = read("boxadjust");
const link = read("revetlink") as { detached?: Cells; grade?: Cells };
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
const own = (cells: Cells): Array<[string, unknown]> =>
Object.entries(cells)
.filter(([key]) => key.startsWith(`${at}:`))
.map(([key, value]) => [key.slice(at.length + 1), value]);
for (const [role, value] of own(revet)) {
const adjust = wallAdjust(value);
if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust };
}
if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string))
design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"];
if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] };
if (at in extra || `b${at}` in extra)
design.extra_wall_counts = { outlet: count(extra[at]), basin: count(extra[`b${at}`]) };
for (const [wall, value] of own(spans)) {
const span = value as { lengthM: number; beforeM: number; afterM: number } | null;
if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue;
design.extra_spans = {
...(design.extra_spans ?? {}),
[wall]: { length_m: span.lengthM, before_m: span.beforeM, after_m: span.afterM },
};
}
if (isObj(link.detached) && at in link.detached)
design.revet_link_detached = !!link.detached[at];
if (isObj(link.grade) && at in link.grade) design.revet_follow_grade = !!link.grade[at];
if (isObj(ford[at])) design.ford_adjust = fordAdjustOf(ford[at]);
const sides = box[at] as { left?: Obj; right?: Obj } | undefined;
if (isObj(sides))
design.box_adjust = {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right },
};
}
}
+2
View File
@@ -28,6 +28,7 @@ import {
withDraftStations, withDraftStations,
} from "./B06_Section_Section_Drafts"; } from "./B06_Section_Section_Drafts";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import { withDraftAdjusts } from "./B06_Section_Section_Adjusts";
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
@@ -71,6 +72,7 @@ async function withDraftWalls(
await loadSavedPlusStations(projectId); await loadSavedPlusStations(projectId);
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2) await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
await withDraftShapes(detail, projectId); await withDraftShapes(detail, projectId);
withDraftAdjusts(detail, projectId, routeId); // 저장 안 한 조정창 4축 · 구간 · 형식(387-4 · B05 만 열어도)
return withStructureAreas(detail); return withStructureAreas(detail);
} }
+1 -1
View File
@@ -281,7 +281,7 @@ function areaRowOf(
/** /**
* 구조물이 선 측점의 절·성토 면적 — **카드를 그리지 않은 측점까지** 전부 낸다. * 구조물이 선 측점의 절·성토 면적 — **카드를 그리지 않은 측점까지** 전부 낸다.
* 화면 그리기(`applyStructureAreas`)와 같은 계산이며, [저장]·[확정]과 초기값 산출 * 다시 셈 창구 · 상세 가게(화면이 보는 면적 · 카드 그리기는 안 얹음 · 387-4)와 [저장]·[확정] · 초기값 산출
* (Node 진입점)이 이 한 벌을 함께 쓴다. * (Node 진입점)이 이 한 벌을 함께 쓴다.
*/ */
export function structureAreaRows( export function structureAreaRows(
+1 -5
View File
@@ -42,7 +42,6 @@ import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies";
import { import {
appendCardFrame, appendCardFrame,
appendGroundProfile, appendGroundProfile,
applyStructureAreas,
drawnContentBounds, drawnContentBounds,
} from "./B06_Section_UI_Cross_View_Frame"; } from "./B06_Section_UI_Cross_View_Frame";
import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford";
@@ -507,10 +506,7 @@ export function createCrossSectionCard(
fordLayout?.designTrim ?? fordLayout?.designTrim ??
boxLayout?.designTrim ?? boxLayout?.designTrim ??
ownDesignTrim; ownDesignTrim;
// 구조물이 선 측점의 절·성토 면적 — **그려지는 폐회로**의 넓이로 고쳐 든다 // 구조물 측점 면적(그려지는 폐회로)은 다시 셈 창구 · 상세 가게가 같은 배치 셈으로 얹음 — 그리기는 안 고침(387-4).
// (2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 것이 아니다). 그림과 면적이
// 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다.
applyStructureAreas(section, designTrim);
const designFrom = plotLayer.childElementCount; const designFrom = plotLayer.childElementCount;
appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim, { appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim, {
layer: svg, layer: svg,
@@ -5,7 +5,6 @@
* ========================================================================== */ * ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { CrossSection } from "./B06_Section_Api_Fetch";
import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas";
import { CROSS_PAD, L, svgElement, svgText, validElevation } from "./B06_Section_UI_Section_Common"; import { CROSS_PAD, L, svgElement, svgText, validElevation } from "./B06_Section_UI_Section_Common";
type Sample = CrossSection["samples"][number]; type Sample = CrossSection["samples"][number];
@@ -88,43 +87,6 @@ export function appendCardFrame(
); );
} }
/**
* 구조물이 만든 폐회로 면적을 측점 설계에 반영한다(2026-09-06 사용자 확정).
*
* 표준 설계선만 보는 기본 계산은 구조물이 있는지 모른다 — 기슭막이·세월교·BOX가 서면
* 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그리므로 폐회로가 달라진다.
* 트림이 없으면(구조물 없는 측점) 아무것도 하지 않는다.
*
* 조작 중 즉시 반영용이다. 정본은 [저장]·[확정] 때 서버가 같은 코드
* (`B06_Section_Structure_Areas_Node.ts`)로 다시 계산해 얹는다(2026-09-06).
*/
export function applyStructureAreas(
section: CrossSection,
trim: DesignTrim | undefined | null,
): void {
const design = section.design;
if (!trim || !design || !Array.isArray(design.design_line) || design.design_line.length < 2) {
return;
}
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const areas = computeStructureAreas({
designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
rockBoundaryOffsetM:
typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null,
});
if (!areas) return;
design.cut_area_m2 = Number(areas.cutAreaM2.toFixed(4));
design.fill_area_m2 = Number(areas.fillAreaM2.toFixed(4));
if (typeof design.cut_soil_area_m2 === "number") {
design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4));
}
}
/** 원지반선 — 끊긴 샘플(무효 표고)마다 폴리선을 나눠 그린다. `toY` = 표고 → 화면 y(배율 반영). */ /** 원지반선 — 끊긴 샘플(무효 표고)마다 폴리선을 나눠 그린다. `toY` = 표고 → 화면 y(배율 반영). */
export function appendGroundProfile( export function appendGroundProfile(
plotLayer: SVGElement, plotLayer: SVGElement,
+14 -67
View File
@@ -4,7 +4,7 @@
* 동작은 그대로다(2026-09-13 분리). * 동작은 그대로다(2026-09-13 분리).
* - `handleDesignChange` : 버튼 선택 즉시 반영 + 세션 초안 기록 + 재계산 * - `handleDesignChange` : 버튼 선택 즉시 반영 + 세션 초안 기록 + 재계산
* - `recomputeIfRock` : 암 경계·암 절토 경사 변경 후 암 측점만 재계산 * - `recomputeIfRock` : 암 경계·암 절토 경사 변경 후 암 측점만 재계산
* - `reconcileStaleDesigns`: 낡은 design 일괄 재계산(B05 와 같은 창구) * - `reconcileStaleDesigns`: 횡단 다시 셈을 계산 묶음에 부탁(cross 단계 한 곳 · 387-4)
* - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기 * - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기
* - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영] * - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영]
* 고친 뒤 계산 묶음 cross 입력도 다시(`pipelineCross` · 노선 초안 동안 카드 = 묶음 새 노선 상세 · 365-2). * 고친 뒤 계산 묶음 cross 입력도 다시(`pipelineCross` · 노선 초안 동안 카드 = 묶음 새 노선 상세 · 365-2).
@@ -15,21 +15,20 @@ import {
writeCrossDesignChoice, writeCrossDesignChoice,
writeShoulderExtend, writeShoulderExtend,
} from "./B06_Section_Cross_Design_Session"; } from "./B06_Section_Cross_Design_Session";
import { showToast } from "@ui/ui_template_elements";
import { refreshCrossDesigns, staleAgainstScreen } from "./B06_Section_Cross_Refresh";
import { import {
bermSpansFromStructures, bermSpansFromStructures,
readBermSpans, readBermSpans,
writeBermSpans, writeBermSpans,
} from "./B06_Section_UI_Page_Persist"; } from "./B06_Section_UI_Page_Persist";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { showToast } from "@ui/ui_template_elements";
import { L } from "./B06_Section_UI_Page_Common"; import { L } from "./B06_Section_UI_Page_Common";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail"; import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail";
import { useStructureIds } from "./B06_Section_Station_Keys"; import { useStructureIds } from "./B06_Section_Station_Keys";
import { pipelineCross } from "../A00_Common/design/design_pipeline_page"; import { pipelineCross, reconcilePagePipeline } from "../A00_Common/design/design_pipeline_page";
import { forceCross } from "../A00_Common/design/design_pipeline_cross";
export interface DesignSyncContext { export interface DesignSyncContext {
projectId: string | null; projectId: string | null;
@@ -120,74 +119,22 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
await reconcileStaleDesigns({ force: true }); await reconcileStaleDesigns({ force: true });
} }
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 다시 셈 — 지금 값을 「사용자 선택」으로 /** 암 경계 · 암 경사 변경 뒤 다시 셈(암 측점 2단계 무릎 · 단면적) — 지금 값을 「사용자 선택」으로
* 박지 않음(자동 판정이 굳으면 노선이 바뀌어도 재판정 안 됨 · 346-2). 암 경계는 세션에서 읽음. */ * 박지 않음(자동 판정이 굳으면 노선이 바뀌어도 재판정 안 됨 · 346-2). 암 경계는 세션에서 읽음. */
function recomputeIfRock(chainageM: number): void { function recomputeIfRock(_chainageM: number): void {
const design = ctx void reconcileStaleDesigns({ force: true });
.detail()
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01)?.design;
if (design && design.ground_type !== "soil") void reconcileStaleDesigns({ force: true });
else if (projectId) pipelineCross(projectId);
} }
/** /**
* 로드 시 stale design을 최신 엔진·최신 종단 계획고로 자동 재계산한다(E-1 + N-6). * 횡단 다시 셈 = 계산 묶음 cross 단계 **한 곳**(387-4) — 여기서는 묶음에 부탁하고 그 차례가 끝날 때까지
* 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이 * 기다림. 입력이 같으면 안 셈 · `force` = 입력 칸 밖 세션 고침(노견 · 소단 · 단면 유형 · 조정창)이라 한 번 더.
* 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선 * 바뀐 카드는 묶음 알림(`onCrossDesignsUpdated`)이 다시 그림.
* (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API
* 호출이 없다.
*
* 재계산은 측점별 순차 호출이 아니라 **일괄 프리뷰 1회**로 한다(2026-08-04 사용자 확인
* — 예전 for-await 루프는 stale 측점 수만큼 왕복하며 카드가 하나씩 바뀌어 "이력 재생"처럼
* 보였고 제일 느렸다). 편집 델타는 B05 세션 초안이 있으면 그것을(두 화면 동일 계획선),
* 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형·
* 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다.
*/ */
async function reconcileStaleDesigns(options?: { force?: boolean }): Promise<void> { async function reconcileStaleDesigns(options?: { force?: boolean }): Promise<void> {
const sectionDetail = ctx.detail(); if (!projectId) return;
const currentRouteId = ctx.routeId(); if (options?.force) forceCross();
if (!sectionDetail || !projectId || currentRouteId === null) return; pipelineCross(projectId);
const draft = readAlignmentDraft(currentRouteId); await reconcilePagePipeline(projectId);
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
//
// 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
// 판정 · 다시 셈 모두 종단도가 쥔 선(측점 한 벌) 기준 — 저장분 선끼리 견주면 화면과 갈림(342-1).
if (!options?.force && !draft && !staleAgainstScreen(sectionDetail, currentRouteId)) return;
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장).
try {
const alignment = sectionDetail.longitudinal.profile_alignment as
| {
edits?: {
station_offsets?: Record<string, number>;
curve_radii?: Record<string, number>;
};
}
| undefined;
const edits = draft ?? {
station_offsets: alignment?.edits?.station_offsets ?? {},
curve_radii: alignment?.edits?.curve_radii ?? {},
};
// 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서
// 다른 값이 된다(2026-09-03 사용자 지시로 일원화).
const updated = await refreshCrossDesigns({
projectId,
routeId: currentRouteId,
detail: sectionDetail,
edits,
});
// 카드는 한꺼번에 갈아 끼운다 — 측점마다 `refreshCard` 를 부르면 그때마다 종단
// 그래프·유토곡선까지 다시 그려 측점 수만큼 화면이 멈췄다(2026-09-12).
ctx.view().refreshCards(updated);
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
}
pipelineCross(projectId); // 세션 고침 · 표준 → 묶음(같은 입력이면 안 셈)
} }
/** /**
+90
View File
@@ -0,0 +1,90 @@
/* 387-4 헬퍼 — 저장 안 한 조정창 값(세션)을 상세 가게가 얹는지(`B06_Section_Section_Adjusts`) · 얹은 뒤 구조물
* 면적(`structureAreaRows`)이 그 값으로 바뀌는지 · 다른 노선 칸은 안 얹는지. 짝 = `test_384_4_cross_window.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } };
function loadTs(file) {
const full = path.resolve(file);
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 localRequire = (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG;
if (STUBS[request]) return STUBS[request];
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, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
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() };
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const { structureAreaRows } = loadTs(B06("B06_Section_Structure_Layouts"));
const { withDraftAdjusts } = loadTs(B06("B06_Section_Section_Adjusts"));
const { writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts"));
const sections = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b06_186_1_pipe_292.json"), "utf8"),
).cross_sections;
const detail = { cross_sections: sections };
const section = sections.find((s) => s.culvert);
const at = section.chainage_m.toFixed(2);
const area = () => {
const row = structureAreaRows(sections).find((r) => r.chainage_m === section.chainage_m);
return row ? [row.cut_area_m2, row.fill_area_m2] : null;
};
const out = { before: area() };
// 다른 노선 칸 — 안 얹음
writeState("revetx", { [`${at}:outlet`]: { x: 3, d: null, h: null, m: null } }, "p", 8);
withDraftAdjusts(detail, "p", 7);
out.otherRoute = section.design.revet_adjust?.outlet ?? null;
// 이 노선 — 유출 벽 4축 · 유입 형식 · 다단 수
writeState("revetx", { [`${at}:outlet`]: { x: 1.5, d: 0, h: null, m: null } }, "p", 7);
writeState("inletstruct", { [at]: "L" }, "p", 7);
writeState("extrawall", { [at]: 2 }, "p", 7);
withDraftAdjusts(detail, "p", 7);
out.laid = {
revet: section.design.revet_adjust.outlet,
inlet: section.design.inlet_structure,
extra: section.design.extra_wall_counts,
};
out.after = area();
withDraftAdjusts(detail, "p", 7);
out.again = area();
process.stdout.write(JSON.stringify(out));
+7 -7
View File
@@ -1,8 +1,8 @@
"""PLAN 317-1 — B05 [3D 업데이트]가 낡은 횡단을 먼저 다시 셈(요구 213). """PLAN 317-1 — B05 [3D 업데이트]가 낡은 횡단을 먼저 다시 셈(요구 213).
- 낡음 지문(`staleKey`) — 낡은 측점이 있으면 지문 · 모두 맞으면 "" - 낡음 지문(`staleKey`) — 낡은 측점이 있으면 지문 · 모두 맞으면 ""
- 그릴 때 잰 지문이 새것일 때만 다시 셈 예약(같은 낡음 되풀이 · 서버 폴백 반복 막음) - 그릴 때 잰 낡음은 다시 셈을 직접 안 부름 — 계산 묶음 cross 한 곳이 셈(387-4 · ▲ 한 번 = cross 한 번)
- 그래프가 그릴 때 지문을 패널에 넘김 · 단추는 예상형상을 짓기 전에 낡은 설계부터 다시 셈 - 그래프가 그릴 때 지문을 패널에 넘김 · 단추는 예상형상을 짓기 전에 묶음 차례(다시 셈)를 기다림
실제 TS 를 `helper_317_stale_preview.cjs` 가 Node 로 돌린다(다시 셈 창구는 흉내). 실제 TS 를 `helper_317_stale_preview.cjs` 가 Node 로 돌린다(다시 셈 창구는 흉내).
""" """
@@ -47,9 +47,9 @@ def test_stale_key_marks_only_stale_sections(result: dict) -> None:
assert result["key_fresh"] == "" assert result["key_fresh"] == ""
def test_same_stale_key_schedules_once(result: dict) -> None: def test_stale_key_never_refreshes_directly(result: dict) -> None:
# a → 부름 · a(같음) → 안 부름 · "" → 안 부름(되돌림) · a(다시 낡음) → 부름 · b → 부름 # 낡음이 와도 미리 셈은 다시 셈 창구를 안 부름 — 묶음 cross 한 곳(387-4)
assert result["calls"] == [1, 1, 1, 2, 3] assert result["calls"] == [0, 0, 0, 0, 0]
def test_draw_and_button_wiring() -> None: def test_draw_and_button_wiring() -> None:
@@ -65,5 +65,5 @@ def test_draw_and_button_wiring() -> None:
assert "pendingRecalc: pending," in render assert "pendingRecalc: pending," in render
corridor = _read("B05_Profile", "B05_Profile_UI_Corridor.ts") corridor = _read("B05_Profile", "B05_Profile_UI_Corridor.ts")
body = corridor.split("export async function refreshCorridor", 1)[1].split("\n}\n", 1)[0] body = corridor.split("export async function refreshCorridor", 1)[1].split("\n}\n", 1)[0]
assert "await refreshStaleDesigns({ projectId, routeId, detail })" in body assert "await reconcilePagePipeline(projectId)" in body
assert body.index("refreshStaleDesigns") < body.index("ensureCorridor") assert body.index("reconcilePagePipeline") < body.index("ensureCorridor")
+6 -5
View File
@@ -99,11 +99,12 @@ def test_국부_연산_바뀐_측점만(out):
def test_남은_낡음은_곡선을_그리고_경고(out): def test_남은_낡음은_곡선을_그리고_경고(out):
assert out["settleFirst"] is True # 다시 셈 기다림 # 387-4 — 미리 셈은 다시 셈을 안 함(묶음 cross 한 곳) · 기다릴 묶음 차례가 없으면 곧바로 곡선 + 경고
assert out["settleSameKey"] is True # 다시 셈이 못 고침 assert out["settleFirst"] is False
assert out["settleAfter"] is False # 기다림 끝 — 곡선 그림 assert out["settleSameKey"] is True
assert out["settleAfter"] is False
assert out["settleWarns"] == 1 assert out["settleWarns"] == 1
assert out["settleRedraws"] >= 1 # 바뀐 것 없어도 다시 그림 assert out["settleRedraws"] == 0
assert out["settleClear"] is False assert out["settleClear"] is False
@@ -116,7 +117,7 @@ def test_흙_배분_실패_열쇠_다시_보냄_받은_열쇠는_안_물음(out)
def test_전체_측점_반영은_한_번_선택_안_박음(out): def test_전체_측점_반영은_한_번_선택_안_박음(out):
assert out["applyAllCalls"] == [out["total"]] assert out["applyAllCalls"] == [] # 387-4 — 화면이 직접 안 셈 · 묶음 cross 가 셈
assert out["applyAllChoiceKeys"] == 0 assert out["applyAllChoiceKeys"] == 0
@@ -0,0 +1,86 @@
"""387-4(384 단계 4) 횡단 다시 셈 창구 하나 · 면적 얹기 한 곳.
· 다시 셈(`refreshCrossDesigns`) 부르는 곳 = 계산 묶음 cross 단계 하나(design_shared savedCross) — 종단 미리
셈 · B06 설계 맞춤 · 3D 업데이트는 묶음에 부탁(`pipelineCross` · `reconcilePagePipeline`) · ▲ 한 번 = cross 한 번
· 화면(횡단 보기 · 카드 그리기)은 구조물 면적을 안 고침 — 다시 셈 창구 · 상세 가게만 얹음(카드 안 그려도 면적 같음)
· 저장 안 한 조정창 값(세션)은 상세 가게가 읽을 때 얹음 — B05 만 연 페이지도 같은 면적 · 다른 노선 칸은 안 얹음
./venv/Scripts/python.exe -m pytest resources/tester/test_384_4_cross_window.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_384_4_adjusts.cjs"
def _code(relative: str) -> str:
text = (ROOT / relative).read_text(encoding="utf-8")
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
return re.sub(r"(?m)^\s*//.*$|\s//\s.*$", "", text)
def _screen_and_page_ts() -> list[Path]:
files = list((ROOT / "A00_Common" / "design").rglob("*.ts"))
files += (ROOT / "B05_Profile").rglob("B05_Profile_*.ts")
files += (ROOT / "B06_Section").rglob("B06_Section_*.ts")
return files
def test_다시_셈_부르는_곳_하나() -> None:
callers = [
str(p.relative_to(ROOT))
for p in _screen_and_page_ts()
for _ in re.findall(r"(?<!function )refreshCrossDesigns\(", _code(str(p.relative_to(ROOT))))
]
assert callers == [str(Path("A00_Common/design/design_shared.ts"))]
def test_화면은_면적_안_고침() -> None:
hits = [
str(p.relative_to(ROOT))
for p in _screen_and_page_ts()
if ("_UI_" in p.name or p.name.startswith("design_view"))
and re.search(r"applyStructureArea\w*\(", _code(str(p.relative_to(ROOT))))
]
assert hits == []
def test_화면은_묶음에_부탁() -> None:
sync = _code("B06_Section/B06_Section_UI_Page_Design_Sync.ts")
assert "pipelineCross(projectId)" in sync and "await reconcilePagePipeline(projectId)" in sync
preview = _code("B05_Profile/B05_Profile_UI_Profile_Preview.ts")
assert "pipelineCross(ctx.projectId)" in preview
corridor = _code("B05_Profile/B05_Profile_UI_Corridor.ts")
assert "await reconcilePagePipeline(projectId)" in corridor
# 입력 칸 밖 세션 고침(노견 · 소단 · 조정창)은 「한 번 더」 표시로 같은 입력이어도 cross 다시
cross = _code("A00_Common/design/design_pipeline_cross.ts")
assert "export const forceCross" in cross and "|${forced}`" in cross
@pytest.fixture(scope="module")
def adjusts() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_세션_조정값을_상세_가게가_얹음(adjusts: dict) -> None:
assert adjusts["otherRoute"]["x"] != 3 # 다른 노선 칸은 안 얹음
assert adjusts["laid"] == {
"revet": {"x": 1.5, "d": None, "h": None, "m": None}, # d 0 = 기본 자리(조정창 읽기와 같음)
"inlet": "L",
"extra": {"outlet": 2, "basin": 0},
}
# 얹은 값으로 구조물 면적이 바뀜 · 두 번 얹어도 같음
assert adjusts["after"] != adjusts["before"]
assert adjusts["again"] == adjusts["after"]
@@ -99,7 +99,8 @@ def test_source_shared_rule_and_b05_entry_recompute():
assert "scheduleCrossPreview();" in B05_PANEL assert "scheduleCrossPreview();" in B05_PANEL
# B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다 # B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다
# (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다). # (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다).
# 판정은 종단도가 쥔 화면 선 기준(342-1 · 공용 규칙 hasStaleDesigns 를 그 선으로 부름) # 다시 셈은 계산 묶음 cross 한 곳(387-4) — B06 은 판정 · 다시 셈 없이 묶음에 부탁
assert "staleAgainstScreen(sectionDetail, currentRouteId)" in B06_PAGE assert "pipelineCross(projectId)" in B06_PAGE
assert "staleAgainstScreen" not in B06_PAGE
assert "two_stage_slope === undefined" in COMMON assert "two_stage_slope === undefined" in COMMON
assert "two_stage_slope === undefined" not in B06_PAGE assert "two_stage_slope === undefined" not in B06_PAGE
@@ -47,8 +47,11 @@ def test_cross_refresh_is_single_entry():
assert "export async function refreshCrossDesigns" in REFRESH assert "export async function refreshCrossDesigns" in REFRESH
assert "readStandardCrossSession(projectId)" in REFRESH assert "readStandardCrossSession(projectId)" in REFRESH
assert "readRockBoundarySession(projectId, routeId)" in REFRESH assert "readRockBoundarySession(projectId, routeId)" in REFRESH
# 부르는 곳 = 계산 묶음 cross 단계 하나(design_shared savedCross · 387-4) — 화면은 묶음에 부탁
shared = _read("A00_Common/design/design_shared.ts")
assert shared.count("refreshCrossDesigns(") == 1
for source in (B05_PREVIEW, B06_PAGE): for source in (B05_PREVIEW, B06_PAGE):
assert "refreshCrossDesigns(" in source assert "refreshCrossDesigns(" not in source
assert "previewCrossDesigns(" not in source assert "previewCrossDesigns(" not in source
@@ -75,3 +78,6 @@ def test_cross_refresh_lays_structure_areas_once():
body = body[: body.index("\n}\n")] body = body[: body.index("\n}\n")]
assert "applyStructureAreaRows(" in body and "structureAreaRows(" in body assert "applyStructureAreaRows(" in body and "structureAreaRows(" in body
assert "applyStructureAreaRows(" not in B05_PREVIEW assert "applyStructureAreaRows(" not in B05_PREVIEW
# 화면(횡단 보기 · 카드 그리기)은 면적을 안 고침 — 카드 안 그려도 면적 같음(387-4)
assert "applyStructureAreaRows(" not in B06_PAGE
assert "applyStructureAreas" not in _read("B06_Section/B06_Section_UI_Cross_View.ts")