diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 432d57736..84fe62bc7 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -342,9 +342,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { history.record(); } - /** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리 - * 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물 - * 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */ + /** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이 + * 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */ function listed(): AlignmentBase | null { if (!storedBase || !detail) return storedBase; const server = detail.longitudinal.stations; @@ -393,10 +392,11 @@ export function createDesignProfile(ctx: DesignProfileContext) { detail: () => detail, routeId: () => routeId, edits: () => store.edits(), + alignment: () => alignment, debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS), onApplied: (updated) => { draw(); - callbacks?.onCrossDesignsUpdated?.(updated); + if (updated.length) callbacks?.onCrossDesignsUpdated?.(updated); }, }); const scheduleCrossPreview = (): void => crossPreview.schedule(); diff --git a/B05_Profile/B05_Profile_Api_HaulPlan.ts b/B05_Profile/B05_Profile_Api_HaulPlan.ts index 60a15c8ef..16595b035 100644 --- a/B05_Profile/B05_Profile_Api_HaulPlan.ts +++ b/B05_Profile/B05_Profile_Api_HaulPlan.ts @@ -21,6 +21,8 @@ export type HaulPlanPayload = HaulPlan | null; const SETTLE_MS = 400; /** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */ const TIMEOUT_MS = 20000; +/** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */ +const KEPT_PLANS = 8; export interface HaulPlanPrefetch { /** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */ @@ -48,23 +50,28 @@ export function createHaulPlanPrefetch( let sentKey: string | null = null; /** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */ let applying = false; + /** 받은 배분 — 열쇠(누가토량 결과 전체) → 배분 · 오래된 것부터 버림. */ + const kept = new Map(); /** - * 입력이 달라졌는지 가리는 짧은 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 + * 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 * `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측). - * 누가토량이 바뀌면 마지막 누계와 측점 수 중 하나는 반드시 달라진다. + * 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1). */ function keyOf(result: unknown): string { - const points = (result as { points?: Array<{ cumulative_volume_m3?: number }> })?.points; + const points = (result as { points?: unknown[] })?.points; if (!Array.isArray(points) || !points.length) return "empty"; - const last = points[points.length - 1]?.cumulative_volume_m3 ?? 0; - const mid = points[Math.floor(points.length / 2)]?.cumulative_volume_m3 ?? 0; - return `${points.length}:${last.toFixed(3)}:${mid.toFixed(3)}`; + return JSON.stringify(points); } - async function send(result: unknown, seq: number): Promise { + /** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */ + function forget(seq: number): void { + if (seq === sequence) sentKey = null; + } + + async function send(result: unknown, seq: number, key: string): Promise { const route = routeId(); - if (!route) return; + if (!route) return forget(seq); const controller = new AbortController(); const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS); try { @@ -78,11 +85,14 @@ export function createHaulPlanPrefetch( signal: controller.signal, }, ); - if (!response.ok) return; + if (!response.ok) return forget(seq); const payload = (await response.json()) as { haul_plan?: HaulPlanPayload }; // 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다. if (seq !== sequence) return; plan = payload.haul_plan ?? null; + kept.delete(key); + kept.set(key, plan); + if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string); // 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는 // 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다 // (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건). @@ -94,6 +104,7 @@ export function createHaulPlanPrefetch( } } catch { // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다. + forget(seq); } finally { window.clearTimeout(abort); } @@ -109,7 +120,10 @@ export function createHaulPlanPrefetch( sequence += 1; const seq = sequence; window.clearTimeout(timer); - timer = window.setTimeout(() => void send(pending, seq), SETTLE_MS); + // 받아 둔 열쇠면 묻지 않고 그대로 — 그리는 쪽은 이 뒤에 `current()` 를 읽는다. + const known = kept.get(key); + if (known !== undefined) return void (plan = known); + timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS); }, current: () => plan, dispose() { diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index d219230b3..d04fbde36 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -141,6 +141,8 @@ export interface RouteMassHaulDrawParams { * 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다. */ pendingRecalc?: boolean; + /** 다시 셈 뒤에도 남은 낡은 측점 — 곡선은 그리고 이 측점만 표시(342-1 · 사용자 확정 10-16). */ + staleChainages?: number[]; } export interface RouteMassHaulPanel { @@ -379,11 +381,19 @@ export function createRouteMassHaulPanel( // 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만 // 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다. if (params.pendingRecalc) { + pendingChip.textContent = "다시 계산 중…"; if (drawnOnce) pendingChip.hidden = false; else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); return; } - pendingChip.hidden = true; + const stale = params.staleChainages ?? []; + pendingChip.hidden = !stale.length; + pendingChip.textContent = stale.length + ? `낡은 측점 ${stale.length}곳 · ${stale + .slice(0, 5) + .map((chainage) => `${chainage.toFixed(2)} m`) + .join(" · ")}${stale.length > 5 ? " …" : ""}` + : "다시 계산 중…"; // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index b1e9db86a..4ed712a3b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -9,8 +9,9 @@ * 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다. * ========================================================================== */ -import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment"; -import { refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; +import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; +import { holdScreenAlignment, refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; +import { staleChainages } from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; export interface CrossPreviewContext { @@ -18,6 +19,8 @@ export interface CrossPreviewContext { detail: () => SectionDetailResponse | null; routeId: () => number | null; edits: () => AlignmentEdits; + /** 화면이 쥔 계획선(측점 한 벌) — 다시 셈 · B06 · 3D 가 같은 선을 받음(342-1). */ + alignment: () => ProfileAlignment | null; /** 부를 때마다 읽음 — 보기마다 다름. */ debounceMs: () => number; /** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */ @@ -27,8 +30,12 @@ export interface CrossPreviewContext { export interface CrossPreview { /** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */ schedule: () => void; - /** 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). */ - scheduleIfStale: (key: string) => void; + /** + * 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). + * 돌려주는 값 = 다시 셈을 기다리는 중인가. 다시 셈 뒤에도 남은 낡음은 false — 곡선은 그리고 + * 그 측점만 표시(342-1 · 09-03 「새 값만 보여주기」를 바꿈 · 사용자 확정 10-16). + */ + scheduleIfStale: (key: string) => boolean; /** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */ dispose: () => void; } @@ -37,19 +44,34 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { let timer = 0; let seq = 0; let lastStale = ""; + /** 다시 셈 예약 · 진행 중 — 이 동안의 낡음은 「다시 계산 중」. */ + let busy = false; + let warned = ""; + const release = holdScreenAlignment((routeId) => + routeId === ctx.routeId() ? ctx.alignment() : null, + ); const preview: CrossPreview = { scheduleIfStale(key) { if (key && key !== lastStale) preview.schedule(); + else if (key && !busy && key !== warned) { + warned = key; + console.warn( + `[횡단] 다시 셈 뒤에도 낡은 측점 ${staleChainages(key).length}곳 — 곡선은 그리고 그 측점만 표시`, + staleChainages(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; + if (!detail || routeId === null) return void (busy = false); const current = (seq += 1); // 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가 // 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화). @@ -60,19 +82,26 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { routeId, detail, edits: ctx.edits(), + alignment: ctx.alignment(), // 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다. shouldApply: isCurrent, }) .then((updated) => { - if (updated.length) ctx.onApplied(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()); }, dispose() { window.clearTimeout(timer); + release(); }, }; return preview; diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index af784b046..60bca4a84 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -15,6 +15,7 @@ import { } from "../B06_Section/B06_Section_UI_Longitudinal"; import { LONG_PAD, + staleChainages, staleKey, windowElevationRange, } from "../B06_Section/B06_Section_UI_Section_Common"; @@ -59,8 +60,9 @@ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel" /** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */ export interface ProfileRenderContext { body: HTMLElement; - /** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1). */ - onStaleDesigns?: (key: string) => void; + /** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1). + * 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */ + onStaleDesigns?: (key: string) => boolean; callbacks: RouteProfilePanelCallbacks | undefined; massHaul: { draw: (params: RouteMassHaulDrawParams) => void }; tableOverlay: { @@ -230,7 +232,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { longitudinal: { design_profiles: designProfiles }, cross_sections: detail.cross_sections, }); - ctx.onStaleDesigns?.(stale); + const pending = ctx.onStaleDesigns?.(stale) ?? stale !== ""; // 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다. const graphData = normalizedLongitudinal(longitudinal); // 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m @@ -424,7 +426,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분 // (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로 // 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고). - pendingRecalc: stale !== "", + pendingRecalc: pending, + staleChainages: pending ? [] : staleChainages(stale), axis: { maxChainageM: maxChainageOf(longitudinal), // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index dfe0e8ad5..e58b1b3e0 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -25,7 +25,7 @@ * ========================================================================== */ import { computeCrossDesign } from "@util/common_util_cross_design"; -import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; +import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; @@ -46,6 +46,7 @@ import { planElevationAt, toAlignmentBase, } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; import { readState } from "../A00_Common/b_page_state"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; @@ -64,6 +65,8 @@ export interface CrossRefreshInput { /** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */ detail: SectionDetailResponse; edits: CrossRefreshEdits; + /** 화면이 쥔 계획선(측점 한 벌 · 342-1) — 없으면 종단도가 맡긴 선 → 저장분 + `edits`. */ + alignment?: ProfileAlignment | null; /** * 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나 * 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다. @@ -149,10 +152,33 @@ function preserveUserFields( } /** - * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. - * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. - * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). + * 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리. + * 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다 + * (유토곡선 「다시 계산하는 중」 머묾 · 341 부록 2 4장). B05 · B06 · 3D 가 모두 이 선을 받는다. */ +type ScreenLine = (routeId: number) => ProfileAlignment | null; +let screenLine: ScreenLine | null = null; + +/** 종단도가 자기 선을 맡김 — 돌려준 함수로 거둠(자기 것일 때만). */ +export function holdScreenAlignment(line: ScreenLine): () => void { + screenLine = line; + return () => { + if (screenLine === line) screenLine = null; + }; +} + +/** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */ +export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean { + const screen = screenLine?.(routeId); + if (!screen) return hasStaleDesigns(detail); + return hasStaleDesigns({ + longitudinal: { + design_profiles: [toDesignProfile(screen, detail.longitudinal.design_profiles?.[0])], + }, + cross_sections: detail.cross_sections, + }); +} + /** * 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1). * 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙). @@ -161,7 +187,7 @@ export async function refreshStaleDesigns( input: Omit, ): Promise { const draft = readAlignmentDraft(input.routeId); - if (!draft && !hasStaleDesigns(input.detail)) return []; + if (!draft && !staleAgainstScreen(input.detail, input.routeId)) return []; const saved = ( input.detail.longitudinal.profile_alignment as | { @@ -179,6 +205,11 @@ export async function refreshStaleDesigns( return refreshCrossDesigns({ ...input, edits }); } +/** + * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. + * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. + * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). + */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { const sections = input.detail.cross_sections; const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); @@ -290,14 +321,19 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map 0 ? Number(section.ford?.overflow_depth_m) : 0, + // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). + planRadiusM: section.plan_radius_m ?? null, + curveOuterSide: + section.curve_outer_side === "left" || section.curve_outer_side === "right" + ? section.curve_outer_side + : null, + curveWideningM: section.curve_widening_m ?? null, + shoulderExtendLeftM: extendLeft, + shoulderExtendRightM: extendRight, + // 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라 + // 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다). + // 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다. + spoilFill: + typeof design.spoil_fill_width_m === "number" && + design.spoil_fill_width_m > 0 && + (design.spoil_fill_side === "left" || design.spoil_fill_side === "right") + ? { + side: design.spoil_fill_side, + widthM: design.spoil_fill_width_m, + slopeRatioN: null, + } + : null, + }; let next; try { - next = computeCrossDesign( - section.samples ?? [], - planElevationAt(alignment, section.chainage_m), - { - groundType: - choice?.ground_type ?? - (typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"), - sectionMode: - choice?.section_mode ?? - (typeof design.section_mode === "string" ? design.section_mode : "left_cut"), - ditchSide: - choice?.ditch_side ?? - (typeof design.ditch_side === "string" ? design.ditch_side : null), - // 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다. - ditchType: - choice?.ditch_type ?? - (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), - paved: pavedBySpecs(section), - // 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원) - tilted: Boolean(section.ford_pavement || section.underdrain), - tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null, - standard, - berm: bermAt(section.chainage_m, design), - rockBoundaryOffsetM, - cutSlopeRatio: cutSlopeAt(section.chainage_m, design), - twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope), - // ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고, - // 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다. - ditchChoice: choice - ? (choice.ditch_choice ?? null) - : typeof design.ditch_choice === "boolean" - ? design.ditch_choice - : null, - ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, - // 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314) - surfaceDropM: - Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0, - // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). - planRadiusM: section.plan_radius_m ?? null, - curveOuterSide: - section.curve_outer_side === "left" || section.curve_outer_side === "right" - ? section.curve_outer_side - : null, - curveWideningM: section.curve_widening_m ?? null, - shoulderExtendLeftM: extendLeft, - shoulderExtendRightM: extendRight, - // 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라 - // 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다). - // 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다. - spoilFill: - typeof design.spoil_fill_width_m === "number" && - design.spoil_fill_width_m > 0 && - (design.spoil_fill_side === "left" || design.spoil_fill_side === "right") - ? { - side: design.spoil_fill_side, - widthM: design.spoil_fill_width_m, - slopeRatioN: null, - } - : null, - }, - ); + next = computeCrossDesign(section.samples ?? [], elevation, options); } catch { continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도). } diff --git a/B06_Section/B06_Section_Router_HaulPlan.py b/B06_Section/B06_Section_Router_HaulPlan.py index fcb3c5854..b3643bad6 100644 --- a/B06_Section/B06_Section_Router_HaulPlan.py +++ b/B06_Section/B06_Section_Router_HaulPlan.py @@ -15,7 +15,10 @@ from __future__ import annotations import asyncio +import hashlib +import json import logging +from collections import OrderedDict from typing import Any from uuid import UUID @@ -37,6 +40,16 @@ router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) _NPM_SCRIPT = "build:server-calc" # 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다. _MAX_POINTS = 5000 +# 열쇠(누가토량 결과 + 계수 · 장비 경계) → 배분 — 같은 열쇠면 Node 를 다시 안 띄움 +# (342-1 · 부록 4 R25). ponytail: 프로세스 메모리 · 최근 몇 벌만 — 재시작 뒤 첫 열기는 +# 다시 셈(_derived 저장본은 343 틀 뒤). +_KEPT: OrderedDict[str, Any] = OrderedDict() +_KEPT_MAX = 32 + + +def _plan_key(result: dict[str, Any], context: Any) -> str: + body = json.dumps({"result": result, "context": context}, sort_keys=True, default=str) + return hashlib.sha1(body.encode("utf-8")).hexdigest() @router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None) @@ -69,15 +82,17 @@ async def compute_haul_plan( factors = await conversion_factors_for(project_id) # 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다. limits = await haul_limits_for(project_id) + context = _mass_haul_context(haul_inputs, factors, limits) + key = _plan_key(result, context) + if key in _KEPT: + _KEPT.move_to_end(key) + return JSONResponse(content={"status": "success", "haul_plan": _KEPT[key]}) try: output = await asyncio.to_thread( run_bundle_json, BUNDLE, _NPM_SCRIPT, - { - "haul_plan_for": result, - "context": _mass_haul_context(haul_inputs, factors, limits), - }, + {"haul_plan_for": result, "context": context}, ) except Exception: logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id) @@ -85,5 +100,14 @@ async def compute_haul_plan( status_code=500, content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, ) - plan = output.get("haul_plan") if isinstance(output, dict) else None + # 묶음 실패(None)는 실패로 — 성공 + 빈 배분으로 주면 화면이 그 열쇠를 다시 안 물었다(342-1). + if not isinstance(output, dict): + return JSONResponse( + status_code=500, + content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, + ) + plan = output.get("haul_plan") + _KEPT[key] = plan + if len(_KEPT) > _KEPT_MAX: + _KEPT.popitem(last=False) return JSONResponse(content={"status": "success", "haul_plan": plan}) diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index f1836b9c0..b406d57c9 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -15,14 +15,13 @@ import { writeShoulderExtend, } from "./B06_Section_Cross_Design_Session"; import { showToast } from "@ui/ui_template_elements"; -import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; +import { refreshCrossDesigns, staleAgainstScreen } from "./B06_Section_Cross_Refresh"; import { bermSpansFromStructures, readBermSpans, writeBermSpans, } from "./B06_Section_UI_Page_Persist"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; -import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { L } from "./B06_Section_UI_Page_Common"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; @@ -167,7 +166,8 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { // 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에 // 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06 // 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다. - if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return; + // 판정 · 다시 셈 모두 종단도가 쥔 선(측점 한 벌) 기준 — 저장분 선끼리 견주면 화면과 갈림(342-1). + if (!options?.force && !draft && !staleAgainstScreen(sectionDetail, currentRouteId)) return; // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // (CLAUDE.md 5장). try { diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 9fc75d028..3d8a3a94e 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -133,6 +133,11 @@ export function staleKey( .join("|"); } +/** 낡음 지문(`staleKey`)에 든 측점 누가거리 — 다시 셈 뒤에도 남은 측점을 표시할 때(342-1). */ +export function staleChainages(key: string): number[] { + return key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : []; +} + interface StaleCandidate { chainage_m: number; // 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314) diff --git a/resources/tester/helper_317_stale_preview.cjs b/resources/tester/helper_317_stale_preview.cjs index 2e2fe9ab6..3a8f936c9 100644 --- a/resources/tester/helper_317_stale_preview.cjs +++ b/resources/tester/helper_317_stale_preview.cjs @@ -15,6 +15,7 @@ const STUBS = { refreshCalls += 1; return []; }, + holdScreenAlignment: () => () => {}, // 화면 선 맡김(342-1) — 이 시험 몫 아님 }, }; globalThis.window = { setTimeout: (fn) => (fn(), 1), clearTimeout: () => {} }; @@ -63,6 +64,7 @@ const fordFresh = { ...fordStale, design: { ...fordStale.design, tilted: true } detail: () => detailOf([fordStale]), routeId: () => 1, edits: () => ({ station_offsets: {}, curve_radii: {} }), + alignment: () => null, debounceMs: () => 0, onApplied: () => {}, }); diff --git a/resources/tester/test_317_3d_update.py b/resources/tester/test_317_3d_update.py index c7d59d199..d4c20a1f8 100644 --- a/resources/tester/test_317_3d_update.py +++ b/resources/tester/test_317_3d_update.py @@ -56,8 +56,9 @@ def test_draw_and_button_wiring() -> None: panel = _read("A00_Common", "design", "design_profile.ts") assert "onStaleDesigns: crossPreview.scheduleIfStale" in panel render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") - assert "ctx.onStaleDesigns?.(stale);" in render - assert 'pendingRecalc: stale !== ""' in render + # 다시 셈 뒤에도 남은 낡음은 기다림 끝 — 곡선은 그리고 그 측점만 표시(342-1) + assert 'const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";' in render + assert "pendingRecalc: pending," in render corridor = _read("B05_Profile", "B05_Profile_UI_Corridor.ts") body = corridor.split("export async function refreshCorridor", 1)[1].split("\n}\n", 1)[0] assert "await refreshStaleDesigns({ projectId, routeId, detail })" in body diff --git a/resources/tester/test_342_1_haul_plan_key.py b/resources/tester/test_342_1_haul_plan_key.py new file mode 100644 index 000000000..9a916d7e1 --- /dev/null +++ b/resources/tester/test_342_1_haul_plan_key.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""342-1 흙 배분 서버 — 같은 열쇠(누가토량 결과 + 계수 · 장비 경계)면 Node 를 다시 안 띄움. + +묶음 실패는 500 — 성공 + 빈 배분으로 주면 화면이 그 열쇠를 다시 안 물었음. +""" + +import asyncio +import json +import sys +from pathlib import Path +from uuid import uuid4 + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B06_Section import B06_Section_Router_HaulPlan as router # noqa: E402 + + +def _patch(monkeypatch, outputs: list) -> list: + calls: list = [] + + async def _none(_project_id): + return {} + + def _run(_bundle, _script, payload): + calls.append(payload) + return outputs.pop(0) + + monkeypatch.setattr(router, "haul_inputs_for", _none) + monkeypatch.setattr(router, "conversion_factors_for", _none) + monkeypatch.setattr(router, "haul_limits_for", _none) + monkeypatch.setattr(router, "_mass_haul_context", lambda *_: {"limits": []}) + monkeypatch.setattr(router, "run_bundle_json", _run) + router._KEPT.clear() + return calls + + +def _post(result: dict): + response = asyncio.run(router.compute_haul_plan(uuid4(), 7, {"result": result})) + return response.status_code, json.loads(response.body) + + +def test_같은_열쇠는_Node_한_번(monkeypatch): + calls = _patch(monkeypatch, [{"haul_plan": {"tag": "a"}}, {"haul_plan": {"tag": "b"}}]) + a = {"points": [{"cumulative_volume_m3": 1.0}]} + b = {"points": [{"cumulative_volume_m3": 2.0}]} + assert _post(a) == (200, {"status": "success", "haul_plan": {"tag": "a"}}) + assert _post(a)[1]["haul_plan"] == {"tag": "a"} + assert len(calls) == 1 + assert _post(b)[1]["haul_plan"] == {"tag": "b"} + assert len(calls) == 2 + + +def test_묶음_실패는_500_기억_안_함(monkeypatch): + calls = _patch(monkeypatch, [None, {"haul_plan": None}]) + a = {"points": [{"cumulative_volume_m3": 1.0}]} + assert _post(a)[0] == 500 + assert _post(a) == (200, {"status": "success", "haul_plan": None}) + assert len(calls) == 2 diff --git a/resources/tester/test_b05_stale_design_sync.py b/resources/tester/test_b05_stale_design_sync.py index 6e8532e29..d9527e41b 100644 --- a/resources/tester/test_b05_stale_design_sync.py +++ b/resources/tester/test_b05_stale_design_sync.py @@ -99,6 +99,7 @@ def test_source_shared_rule_and_b05_entry_recompute(): assert "scheduleCrossPreview();" in B05_PANEL # B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다 # (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다). - assert "hasStaleDesigns(sectionDetail)" in B06_PAGE + # 판정은 종단도가 쥔 화면 선 기준(342-1 · 공용 규칙 hasStaleDesigns 를 그 선으로 부름) + assert "staleAgainstScreen(sectionDetail, currentRouteId)" in B06_PAGE assert "two_stage_slope === undefined" in COMMON assert "two_stage_slope === undefined" not in B06_PAGE