feat(b06): 횡단도 바로 반영 — 새로 놓거나 옮긴 관 · 구조물의 횡단 카드를 [저장] 전에 보임 (273-2)

- 서버: 읽기 전용 미리 뜨기 POST /section/preview-stations — 로그인 · 회사 · 프로젝트 권한 그대로 · 한 번에 60곳 · 파일 · DB 안 씀 · 저장과 같은 길(sample_structure_sections · compute_default_designs crosses)
- 화면: 초안이 심는 자리 중 횡단 없는 곳만 묻고 한 번 뜬 자리는 기억(B06_Section_Section_Drafts) · 카드 이름 · 관 그림 얹음 · 늦은 옛 응답 무시 · 배수 재계산이 관경을 정하면 카드 다시 얹음 · B06 관 옮김도 카드 다시
- [저장]은 지금처럼 서버가 같은 계산으로 정본에 씀 — 미리 본 카드와 같은 값
- 알림 글: 횡단도는 바로 보이고 세부 배수유역만 저장 뒤 다시 셈
- 시험: test_273_2 새로 · 177-3 헬퍼에 revealPick 자리 채움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 20:48:32 +09:00
co-authored by Claude Opus 5.5
parent 887c89bbf8
commit dfd7427c46
14 changed files with 642 additions and 105 deletions
+8 -64
View File
@@ -18,9 +18,7 @@
* ========================================================================== */
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
fetchStructures,
fetchStructureTypes,
readPendingStructures,
} from "../B05_Profile/B05_Profile_Api_Structures";
@@ -41,11 +39,8 @@ import {
pavementSpecsFrom,
type PavementStructureInput,
} from "@util/common_util_structure_pavement";
import {
plantedChainages,
SAME_STATION_M,
setGeneralStations,
} from "../common_util/common_util_structure_station";
import { setGeneralStations } from "../common_util/common_util_structure_station";
import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
@@ -69,9 +64,10 @@ function keyOf(projectId: string, routeId: number): string {
async function withDraftWalls(
detail: SectionDetailResponse,
projectId: string,
routeId: number,
): Promise<SectionDetailResponse> {
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
await withDraftStations(detail, projectId);
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
const drafts = readPendingStructures(projectId);
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
@@ -108,58 +104,6 @@ async function withDraftWalls(
return withStructureAreas(detail);
}
/** 저장된 관 지점 — 구조물 초안만 있을 때 심는 자리 셈에 씀. 저장 뒤(`invalidateSectionDetail`)
* 다시 받음 · 고칠 때마다 서버에 안 물음. */
const savedPipes = new Map<
string,
Promise<Awaited<ReturnType<typeof fetchDetailPipePoints>>["pipe_points"]>
>();
function savedPipesOf(projectId: string) {
let hit = savedPipes.get(projectId);
if (!hit) {
hit = fetchDetailPipePoints(projectId).then((res) => res.pipe_points);
hit.catch(() => savedPipes.delete(projectId));
savedPipes.set(projectId, hit);
}
return hit;
}
/** 초안이 빼 둔 옛 자리 측점 — 초안이 다시 그 자리를 심거나 초안이 없어지면 돌려 놓음. */
const parkedSections = new WeakMap<
SectionDetailResponse,
SectionDetailResponse["cross_sections"]
>();
/**
* 옮긴 관 · 구조물의 옛 자리 측점(서버가 다시 맞추기 전)을 횡단 카드 · 유토곡선에서 뺀다 — 초안이
* 심는 자리(`plantedChainages` · 서버와 같은 규칙)에 없는 서버 구조물 측점(272 · 측점 한 벌). 초안이
* 없으면 서버 목록이 지금 것이라 다 돌려 놓음. [저장] 뒤는 서버가 맞춘 새 상세라 뺄 것이 없음.
*/
async function withDraftStations(detail: SectionDetailResponse, projectId: string): Promise<void> {
const pipesDraft = readPendingPipes(projectId);
const structuresDraft = readPendingStructures(projectId);
let planted: number[] | null = null;
if (pipesDraft || structuresDraft) {
const [pipes, structures, types] = await Promise.all([
pipesDraft ?? savedPipesOf(projectId),
structuresDraft ?? fetchStructures(projectId).then((res) => res.structures),
fetchStructureTypes(true),
]).catch(() => [null, null, null] as const);
if (pipes && structures && types) planted = plantedChainages(pipes, structures, types);
}
const all = [...detail.cross_sections, ...(parkedSections.get(detail) ?? [])].sort(
(a, b) => a.chainage_m - b.chainage_m,
);
const stale = (section: (typeof all)[number]): boolean =>
planted !== null &&
section.kind === "irregular" &&
!section.grid &&
planted.every((mark) => Math.abs(mark - section.chainage_m) >= SAME_STATION_M);
detail.cross_sections.splice(0, detail.cross_sections.length, ...all.filter((s) => !stale(s)));
parkedSections.set(detail, all.filter(stale));
}
/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */
const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const;
const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] =>
@@ -244,21 +188,21 @@ export async function loadSectionDetail(
const key = keyOf(projectId, routeId);
if (!options?.force) {
const cached = cache.get(key);
if (cached) return withDraftWalls(cached, projectId);
if (cached) return withDraftWalls(cached, projectId, routeId);
const inFlight = pending.get(key);
if (inFlight) return inFlight;
// 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다.
const stored = readState<SectionDetailResponse>("section-detail", projectId, routeId);
if (stored) {
cache.set(key, stored);
return withDraftWalls(stored, projectId);
return withDraftWalls(stored, projectId, routeId);
}
}
const request = fetchSectionDetail(projectId, routeId)
.then(async (detail) => {
cache.set(key, detail);
writeState("section-detail", detail, projectId, routeId);
return withDraftWalls(detail, projectId);
return withDraftWalls(detail, projectId, routeId);
})
.finally(() => {
pending.delete(key);
@@ -283,7 +227,7 @@ export function replaceSectionDetail(
* 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다.
*/
export function invalidateSectionDetail(projectId: string, routeId?: number): void {
savedPipes.delete(projectId);
forgetDraftStations(projectId);
if (routeId !== undefined) {
cache.delete(keyOf(projectId, routeId));
clearState("section-detail", projectId, routeId);