fix(종횡단): 342-1 계획선 한 벌 · 남은 낡음 표시 · 흙 배분 열쇠
- 횡단 다시 셈이 화면이 쥔 선(측점 한 벌 · 관 지점 · 옮긴 꼭짓점)을 받음 — 종단도가 선을 맡기고 B06 진입 정합 · 3D [업데이트] 앞 다시 셈도 같은 선 · 낡음 판정도 그 선 기준(staleAgainstScreen) - 다시 셈 뒤에도 낡은 측점이 남으면 유토곡선은 그리고 그 측점만 표시 · 콘솔 경고 한 줄 · 흙 배분도 건너뛰지 않음(09-03 「새 값만 보여주기」 바꿈 · 사용자 확정 10-16) - 흙 배분: 실패한 열쇠 다시 보냄 · 받은 열쇠(누가토량 결과 전체)는 다시 안 물음 · 서버도 같은 열쇠 기억 · 묶음 실패는 500 - 옛 시험 셋 새 동작으로 맞춤 · 서버 열쇠 시험 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<string, HaulPlanPayload>();
|
||||
|
||||
/**
|
||||
* 입력이 달라졌는지 가리는 짧은 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
|
||||
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
|
||||
* `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<void> {
|
||||
/** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */
|
||||
function forget(seq: number): void {
|
||||
if (seq === sequence) sentKey = null;
|
||||
}
|
||||
|
||||
async function send(result: unknown, seq: number, key: string): Promise<void> {
|
||||
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() {
|
||||
|
||||
@@ -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 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
||||
|
||||
@@ -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<CrossRefreshInput, "edits" | "shouldApply">,
|
||||
): Promise<number[]> {
|
||||
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<number[]> {
|
||||
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<number, num
|
||||
function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
const { projectId, detail, edits } = input;
|
||||
const stored = readAlignment(detail.longitudinal);
|
||||
if (!stored) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
|
||||
// 선 = 화면이 쥔 선 → 종단도가 맡긴 선 → 저장분 + 편집(종단도가 아직 없을 때) · 342-1
|
||||
const alignment =
|
||||
input.alignment ??
|
||||
screenLine?.(input.routeId) ??
|
||||
(stored
|
||||
? buildAlignment(toAlignmentBase(stored), {
|
||||
station_offsets: edits.station_offsets ?? {},
|
||||
curve_radii: edits.curve_radii ?? {},
|
||||
})
|
||||
: null);
|
||||
if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
|
||||
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
|
||||
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
|
||||
|
||||
const alignment = buildAlignment(toAlignmentBase(stored), {
|
||||
station_offsets: edits.station_offsets ?? {},
|
||||
curve_radii: edits.curve_radii ?? {},
|
||||
});
|
||||
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
|
||||
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
|
||||
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
|
||||
@@ -367,69 +403,66 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
const choice = choiceAt(section.chainage_m);
|
||||
const extendLeft = extendAt(section.chainage_m, design, "left");
|
||||
const extendRight = extendAt(section.chainage_m, design, "right");
|
||||
const elevation = planElevationAt(alignment, section.chainage_m);
|
||||
const options: CrossDesignOptions = {
|
||||
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,
|
||||
};
|
||||
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; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user