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();
|
history.record();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리
|
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||||
* 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물
|
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */
|
||||||
* 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */
|
|
||||||
function listed(): AlignmentBase | null {
|
function listed(): AlignmentBase | null {
|
||||||
if (!storedBase || !detail) return storedBase;
|
if (!storedBase || !detail) return storedBase;
|
||||||
const server = detail.longitudinal.stations;
|
const server = detail.longitudinal.stations;
|
||||||
@@ -393,10 +392,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
detail: () => detail,
|
detail: () => detail,
|
||||||
routeId: () => routeId,
|
routeId: () => routeId,
|
||||||
edits: () => store.edits(),
|
edits: () => store.edits(),
|
||||||
|
alignment: () => alignment,
|
||||||
debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS),
|
debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS),
|
||||||
onApplied: (updated) => {
|
onApplied: (updated) => {
|
||||||
draw();
|
draw();
|
||||||
callbacks?.onCrossDesignsUpdated?.(updated);
|
if (updated.length) callbacks?.onCrossDesignsUpdated?.(updated);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const scheduleCrossPreview = (): void => crossPreview.schedule();
|
const scheduleCrossPreview = (): void => crossPreview.schedule();
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export type HaulPlanPayload = HaulPlan | null;
|
|||||||
const SETTLE_MS = 400;
|
const SETTLE_MS = 400;
|
||||||
/** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */
|
/** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */
|
||||||
const TIMEOUT_MS = 20000;
|
const TIMEOUT_MS = 20000;
|
||||||
|
/** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */
|
||||||
|
const KEPT_PLANS = 8;
|
||||||
|
|
||||||
export interface HaulPlanPrefetch {
|
export interface HaulPlanPrefetch {
|
||||||
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
|
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
|
||||||
@@ -48,23 +50,28 @@ export function createHaulPlanPrefetch(
|
|||||||
let sentKey: string | null = null;
|
let sentKey: string | null = null;
|
||||||
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
|
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
|
||||||
let applying = false;
|
let applying = false;
|
||||||
|
/** 받은 배분 — 열쇠(누가토량 결과 전체) → 배분 · 오래된 것부터 버림. */
|
||||||
|
const kept = new Map<string, HaulPlanPayload>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 입력이 달라졌는지 가리는 짧은 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
|
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
|
||||||
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
|
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
|
||||||
* 누가토량이 바뀌면 마지막 누계와 측점 수 중 하나는 반드시 달라진다.
|
* 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
|
||||||
*/
|
*/
|
||||||
function keyOf(result: unknown): string {
|
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";
|
if (!Array.isArray(points) || !points.length) return "empty";
|
||||||
const last = points[points.length - 1]?.cumulative_volume_m3 ?? 0;
|
return JSON.stringify(points);
|
||||||
const mid = points[Math.floor(points.length / 2)]?.cumulative_volume_m3 ?? 0;
|
|
||||||
return `${points.length}:${last.toFixed(3)}:${mid.toFixed(3)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
const route = routeId();
|
||||||
if (!route) return;
|
if (!route) return forget(seq);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
|
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
@@ -78,11 +85,14 @@ export function createHaulPlanPrefetch(
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) return;
|
if (!response.ok) return forget(seq);
|
||||||
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
|
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
|
||||||
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
|
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
|
||||||
if (seq !== sequence) return;
|
if (seq !== sequence) return;
|
||||||
plan = payload.haul_plan ?? null;
|
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`)만으로는
|
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
|
||||||
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
|
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
|
||||||
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
|
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
|
||||||
@@ -94,6 +104,7 @@ export function createHaulPlanPrefetch(
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
|
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
|
||||||
|
forget(seq);
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(abort);
|
window.clearTimeout(abort);
|
||||||
}
|
}
|
||||||
@@ -109,7 +120,10 @@ export function createHaulPlanPrefetch(
|
|||||||
sequence += 1;
|
sequence += 1;
|
||||||
const seq = sequence;
|
const seq = sequence;
|
||||||
window.clearTimeout(timer);
|
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,
|
current: () => plan,
|
||||||
dispose() {
|
dispose() {
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ export interface RouteMassHaulDrawParams {
|
|||||||
* 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다.
|
* 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다.
|
||||||
*/
|
*/
|
||||||
pendingRecalc?: boolean;
|
pendingRecalc?: boolean;
|
||||||
|
/** 다시 셈 뒤에도 남은 낡은 측점 — 곡선은 그리고 이 측점만 표시(342-1 · 사용자 확정 10-16). */
|
||||||
|
staleChainages?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteMassHaulPanel {
|
export interface RouteMassHaulPanel {
|
||||||
@@ -379,11 +381,19 @@ export function createRouteMassHaulPanel(
|
|||||||
// 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만
|
// 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만
|
||||||
// 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다.
|
// 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다.
|
||||||
if (params.pendingRecalc) {
|
if (params.pendingRecalc) {
|
||||||
|
pendingChip.textContent = "다시 계산 중…";
|
||||||
if (drawnOnce) pendingChip.hidden = false;
|
if (drawnOnce) pendingChip.hidden = false;
|
||||||
else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.");
|
else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.");
|
||||||
return;
|
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과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
|
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
|
||||||
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
||||||
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
* 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다.
|
* 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment";
|
import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||||
import { refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh";
|
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";
|
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||||
|
|
||||||
export interface CrossPreviewContext {
|
export interface CrossPreviewContext {
|
||||||
@@ -18,6 +19,8 @@ export interface CrossPreviewContext {
|
|||||||
detail: () => SectionDetailResponse | null;
|
detail: () => SectionDetailResponse | null;
|
||||||
routeId: () => number | null;
|
routeId: () => number | null;
|
||||||
edits: () => AlignmentEdits;
|
edits: () => AlignmentEdits;
|
||||||
|
/** 화면이 쥔 계획선(측점 한 벌) — 다시 셈 · B06 · 3D 가 같은 선을 받음(342-1). */
|
||||||
|
alignment: () => ProfileAlignment | null;
|
||||||
/** 부를 때마다 읽음 — 보기마다 다름. */
|
/** 부를 때마다 읽음 — 보기마다 다름. */
|
||||||
debounceMs: () => number;
|
debounceMs: () => number;
|
||||||
/** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */
|
/** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */
|
||||||
@@ -27,8 +30,12 @@ export interface CrossPreviewContext {
|
|||||||
export interface CrossPreview {
|
export interface CrossPreview {
|
||||||
/** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */
|
/** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */
|
||||||
schedule: () => void;
|
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;
|
dispose: () => void;
|
||||||
}
|
}
|
||||||
@@ -37,19 +44,34 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
|
|||||||
let timer = 0;
|
let timer = 0;
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
let lastStale = "";
|
let lastStale = "";
|
||||||
|
/** 다시 셈 예약 · 진행 중 — 이 동안의 낡음은 「다시 계산 중」. */
|
||||||
|
let busy = false;
|
||||||
|
let warned = "";
|
||||||
|
const release = holdScreenAlignment((routeId) =>
|
||||||
|
routeId === ctx.routeId() ? ctx.alignment() : null,
|
||||||
|
);
|
||||||
|
|
||||||
const preview: CrossPreview = {
|
const preview: CrossPreview = {
|
||||||
scheduleIfStale(key) {
|
scheduleIfStale(key) {
|
||||||
if (key && key !== lastStale) preview.schedule();
|
if (key && key !== lastStale) preview.schedule();
|
||||||
|
else if (key && !busy && key !== warned) {
|
||||||
|
warned = key;
|
||||||
|
console.warn(
|
||||||
|
`[횡단] 다시 셈 뒤에도 낡은 측점 ${staleChainages(key).length}곳 — 곡선은 그리고 그 측점만 표시`,
|
||||||
|
staleChainages(key),
|
||||||
|
);
|
||||||
|
}
|
||||||
lastStale = key;
|
lastStale = key;
|
||||||
|
return key !== "" && busy;
|
||||||
},
|
},
|
||||||
schedule() {
|
schedule() {
|
||||||
if (!ctx.detail() || ctx.routeId() === null) return;
|
if (!ctx.detail() || ctx.routeId() === null) return;
|
||||||
|
busy = true;
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
timer = window.setTimeout(() => {
|
timer = window.setTimeout(() => {
|
||||||
const detail = ctx.detail();
|
const detail = ctx.detail();
|
||||||
const routeId = ctx.routeId();
|
const routeId = ctx.routeId();
|
||||||
if (!detail || routeId === null) return;
|
if (!detail || routeId === null) return void (busy = false);
|
||||||
const current = (seq += 1);
|
const current = (seq += 1);
|
||||||
// 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가
|
// 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가
|
||||||
// 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화).
|
// 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화).
|
||||||
@@ -60,19 +82,26 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
|
|||||||
routeId,
|
routeId,
|
||||||
detail,
|
detail,
|
||||||
edits: ctx.edits(),
|
edits: ctx.edits(),
|
||||||
|
alignment: ctx.alignment(),
|
||||||
// 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다.
|
// 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다.
|
||||||
shouldApply: isCurrent,
|
shouldApply: isCurrent,
|
||||||
})
|
})
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
if (updated.length) ctx.onApplied(updated);
|
if (current === seq) busy = false;
|
||||||
|
// 남은 낡음이 있으면 바뀐 것이 없어도 다시 그림 — 「다시 계산 중」에 멈추지 않게(342-1)
|
||||||
|
if (updated.length || lastStale) ctx.onApplied(updated);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
|
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
|
||||||
|
if (current !== seq) return;
|
||||||
|
busy = false;
|
||||||
|
if (lastStale) ctx.onApplied([]);
|
||||||
});
|
});
|
||||||
}, ctx.debounceMs());
|
}, ctx.debounceMs());
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
|
release();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return preview;
|
return preview;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "../B06_Section/B06_Section_UI_Longitudinal";
|
} from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||||
import {
|
import {
|
||||||
LONG_PAD,
|
LONG_PAD,
|
||||||
|
staleChainages,
|
||||||
staleKey,
|
staleKey,
|
||||||
windowElevationRange,
|
windowElevationRange,
|
||||||
} from "../B06_Section/B06_Section_UI_Section_Common";
|
} from "../B06_Section/B06_Section_UI_Section_Common";
|
||||||
@@ -59,8 +60,9 @@ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel"
|
|||||||
/** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */
|
/** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */
|
||||||
export interface ProfileRenderContext {
|
export interface ProfileRenderContext {
|
||||||
body: HTMLElement;
|
body: HTMLElement;
|
||||||
/** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1). */
|
/** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1).
|
||||||
onStaleDesigns?: (key: string) => void;
|
* 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */
|
||||||
|
onStaleDesigns?: (key: string) => boolean;
|
||||||
callbacks: RouteProfilePanelCallbacks | undefined;
|
callbacks: RouteProfilePanelCallbacks | undefined;
|
||||||
massHaul: { draw: (params: RouteMassHaulDrawParams) => void };
|
massHaul: { draw: (params: RouteMassHaulDrawParams) => void };
|
||||||
tableOverlay: {
|
tableOverlay: {
|
||||||
@@ -230,7 +232,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
|||||||
longitudinal: { design_profiles: designProfiles },
|
longitudinal: { design_profiles: designProfiles },
|
||||||
cross_sections: detail.cross_sections,
|
cross_sections: detail.cross_sections,
|
||||||
});
|
});
|
||||||
ctx.onStaleDesigns?.(stale);
|
const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";
|
||||||
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
||||||
const graphData = normalizedLongitudinal(longitudinal);
|
const graphData = normalizedLongitudinal(longitudinal);
|
||||||
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
|
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
|
||||||
@@ -424,7 +426,8 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
|||||||
// 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분
|
// 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분
|
||||||
// (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로
|
// (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로
|
||||||
// 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고).
|
// 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고).
|
||||||
pendingRecalc: stale !== "",
|
pendingRecalc: pending,
|
||||||
|
staleChainages: pending ? [] : staleChainages(stale),
|
||||||
axis: {
|
axis: {
|
||||||
maxChainageM: maxChainageOf(longitudinal),
|
maxChainageM: maxChainageOf(longitudinal),
|
||||||
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { computeCrossDesign } from "@util/common_util_cross_design";
|
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 { previewCrossDesigns } from "./B06_Section_Api_Fetch";
|
||||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||||
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
planElevationAt,
|
planElevationAt,
|
||||||
toAlignmentBase,
|
toAlignmentBase,
|
||||||
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
} 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 { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
|
||||||
import { readState } from "../A00_Common/b_page_state";
|
import { readState } from "../A00_Common/b_page_state";
|
||||||
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||||
@@ -64,6 +65,8 @@ export interface CrossRefreshInput {
|
|||||||
/** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */
|
/** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */
|
||||||
detail: SectionDetailResponse;
|
detail: SectionDetailResponse;
|
||||||
edits: CrossRefreshEdits;
|
edits: CrossRefreshEdits;
|
||||||
|
/** 화면이 쥔 계획선(측점 한 벌 · 342-1) — 없으면 종단도가 맡긴 선 → 저장분 + `edits`. */
|
||||||
|
alignment?: ProfileAlignment | null;
|
||||||
/**
|
/**
|
||||||
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
|
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
|
||||||
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
|
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
|
||||||
@@ -149,10 +152,33 @@ function preserveUserFields(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
|
* 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리.
|
||||||
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
|
* 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다
|
||||||
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
|
* (유토곡선 「다시 계산하는 중」 머묾 · 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).
|
* 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1).
|
||||||
* 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙).
|
* 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙).
|
||||||
@@ -161,7 +187,7 @@ export async function refreshStaleDesigns(
|
|||||||
input: Omit<CrossRefreshInput, "edits" | "shouldApply">,
|
input: Omit<CrossRefreshInput, "edits" | "shouldApply">,
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
const draft = readAlignmentDraft(input.routeId);
|
const draft = readAlignmentDraft(input.routeId);
|
||||||
if (!draft && !hasStaleDesigns(input.detail)) return [];
|
if (!draft && !staleAgainstScreen(input.detail, input.routeId)) return [];
|
||||||
const saved = (
|
const saved = (
|
||||||
input.detail.longitudinal.profile_alignment as
|
input.detail.longitudinal.profile_alignment as
|
||||||
| {
|
| {
|
||||||
@@ -179,6 +205,11 @@ export async function refreshStaleDesigns(
|
|||||||
return refreshCrossDesigns({ ...input, edits });
|
return refreshCrossDesigns({ ...input, edits });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
|
||||||
|
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
|
||||||
|
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
|
||||||
|
*/
|
||||||
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
|
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
|
||||||
const sections = input.detail.cross_sections;
|
const sections = input.detail.cross_sections;
|
||||||
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
|
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 {
|
function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||||
const { projectId, detail, edits } = input;
|
const { projectId, detail, edits } = input;
|
||||||
const stored = readAlignment(detail.longitudinal);
|
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;
|
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
|
||||||
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
|
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
|
||||||
|
|
||||||
const alignment = buildAlignment(toAlignmentBase(stored), {
|
|
||||||
station_offsets: edits.station_offsets ?? {},
|
|
||||||
curve_radii: edits.curve_radii ?? {},
|
|
||||||
});
|
|
||||||
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
|
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
|
||||||
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
|
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
|
||||||
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
|
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
|
||||||
@@ -367,69 +403,66 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
|||||||
const choice = choiceAt(section.chainage_m);
|
const choice = choiceAt(section.chainage_m);
|
||||||
const extendLeft = extendAt(section.chainage_m, design, "left");
|
const extendLeft = extendAt(section.chainage_m, design, "left");
|
||||||
const extendRight = extendAt(section.chainage_m, design, "right");
|
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;
|
let next;
|
||||||
try {
|
try {
|
||||||
next = computeCrossDesign(
|
next = computeCrossDesign(section.samples ?? [], elevation, options);
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
|
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections import OrderedDict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -37,6 +40,16 @@ router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
|||||||
_NPM_SCRIPT = "build:server-calc"
|
_NPM_SCRIPT = "build:server-calc"
|
||||||
# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다.
|
# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다.
|
||||||
_MAX_POINTS = 5000
|
_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)
|
@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)
|
factors = await conversion_factors_for(project_id)
|
||||||
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
|
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
|
||||||
limits = await haul_limits_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:
|
try:
|
||||||
output = await asyncio.to_thread(
|
output = await asyncio.to_thread(
|
||||||
run_bundle_json,
|
run_bundle_json,
|
||||||
BUNDLE,
|
BUNDLE,
|
||||||
_NPM_SCRIPT,
|
_NPM_SCRIPT,
|
||||||
{
|
{"haul_plan_for": result, "context": context},
|
||||||
"haul_plan_for": result,
|
|
||||||
"context": _mass_haul_context(haul_inputs, factors, limits),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
|
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
|
||||||
@@ -85,5 +100,14 @@ async def compute_haul_plan(
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
|
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})
|
return JSONResponse(content={"status": "success", "haul_plan": plan})
|
||||||
|
|||||||
@@ -15,14 +15,13 @@ import {
|
|||||||
writeShoulderExtend,
|
writeShoulderExtend,
|
||||||
} from "./B06_Section_Cross_Design_Session";
|
} from "./B06_Section_Cross_Design_Session";
|
||||||
import { showToast } from "@ui/ui_template_elements";
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
|
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 { 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 { 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";
|
||||||
@@ -167,7 +166,8 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
|||||||
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
|
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
|
||||||
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
|
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
|
||||||
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
|
// 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장).
|
// (CLAUDE.md 5장).
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ export function staleKey(
|
|||||||
.join("|");
|
.join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 낡음 지문(`staleKey`)에 든 측점 누가거리 — 다시 셈 뒤에도 남은 측점을 표시할 때(342-1). */
|
||||||
|
export function staleChainages(key: string): number[] {
|
||||||
|
return key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : [];
|
||||||
|
}
|
||||||
|
|
||||||
interface StaleCandidate {
|
interface StaleCandidate {
|
||||||
chainage_m: number;
|
chainage_m: number;
|
||||||
// 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314)
|
// 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const STUBS = {
|
|||||||
refreshCalls += 1;
|
refreshCalls += 1;
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
|
holdScreenAlignment: () => () => {}, // 화면 선 맡김(342-1) — 이 시험 몫 아님
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
globalThis.window = { setTimeout: (fn) => (fn(), 1), clearTimeout: () => {} };
|
globalThis.window = { setTimeout: (fn) => (fn(), 1), clearTimeout: () => {} };
|
||||||
@@ -63,6 +64,7 @@ const fordFresh = { ...fordStale, design: { ...fordStale.design, tilted: true }
|
|||||||
detail: () => detailOf([fordStale]),
|
detail: () => detailOf([fordStale]),
|
||||||
routeId: () => 1,
|
routeId: () => 1,
|
||||||
edits: () => ({ station_offsets: {}, curve_radii: {} }),
|
edits: () => ({ station_offsets: {}, curve_radii: {} }),
|
||||||
|
alignment: () => null,
|
||||||
debounceMs: () => 0,
|
debounceMs: () => 0,
|
||||||
onApplied: () => {},
|
onApplied: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ def test_draw_and_button_wiring() -> None:
|
|||||||
panel = _read("A00_Common", "design", "design_profile.ts")
|
panel = _read("A00_Common", "design", "design_profile.ts")
|
||||||
assert "onStaleDesigns: crossPreview.scheduleIfStale" in panel
|
assert "onStaleDesigns: crossPreview.scheduleIfStale" in panel
|
||||||
render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts")
|
render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts")
|
||||||
assert "ctx.onStaleDesigns?.(stale);" in render
|
# 다시 셈 뒤에도 남은 낡음은 기다림 끝 — 곡선은 그리고 그 측점만 표시(342-1)
|
||||||
assert 'pendingRecalc: stale !== ""' in render
|
assert 'const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";' 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 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
|
assert "scheduleCrossPreview();" in B05_PANEL
|
||||||
# B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다
|
# B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다
|
||||||
# (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다).
|
# (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" in COMMON
|
||||||
assert "two_stage_slope === undefined" not in B06_PAGE
|
assert "two_stage_slope === undefined" not in B06_PAGE
|
||||||
|
|||||||
Reference in New Issue
Block a user