refactor(472-2 2-B 걷어내기): B06 안 쓰는 길 지움 · 서버 Node 묶음 화면 · 세션 끌림 정리

- POST /sections/{r}/cross-design 엔드포인트 · CrossDesignResponse(파이썬 · TS) · CrossDesignRequest(TS) · TS computeCrossDesign 지움 — 부름 0(파이썬 CrossDesignRequest 는 test_316 이 써서 둠)
- 바깥 부름 0 export 지움 — readCrossDesignChoice · currentStationScope · culvertRequiredHalfWidth · calculateYScale · mountSectionStructureMenu · Culvert_Const 다섯 · outletSlopeFactory
- PIPE_DISPLAY_NAME = common_util_structure_catalog 로 — Section_Common 이 IrregularStations(화면) 안 끔
- Api_Fetch 세션 · 사진첩 창구 = 새 B06_Section_Api_Context.ts(fetchSectionContext · fetchCurrentRouteId · seedStandardCross) — Api_Fetch 가 b_page_state · Profile_History 안 끔
- 시험 경로 따라 고침(seedStandardCross 글 검사 · 헬퍼 흉내 모듈 이름 · 라우터 목록 시험)
- 위키 줄 번호 · 지운 엔드포인트 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 08:54:41 +09:00
co-authored by Claude Opus 5.5
parent 33ce7ce842
commit a6a1b92132
25 changed files with 82 additions and 409 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ export function forgetPageData(projectId: string, leaving = false): void {
/** 횡단 context(세션 캐시 · 새 요청 없음) — 노선 id 칸을 채움 · 두 보기가 이것으로만 받음. 서버 창구는 부를 때
* 불러옴(묶음 · 초안 측점이 끄는 이 파일이 화면 모듈을 안 끌게). */
export async function pageSectionContext(projectId: string): Promise<SectionContextResponse> {
const { fetchSectionContext } = await import("../../B06_Section/B06_Section_Api_Fetch");
const { fetchSectionContext } = await import("../../B06_Section/B06_Section_Api_Context");
const context = await fetchSectionContext(projectId);
setPageRouteId(projectId, context.route_id); // 노선 id 한 칸(design_page_route)
return context;
+1 -1
View File
@@ -19,7 +19,7 @@ import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
import { readState, writeState } from "../b_page_state";
import { currentRoute, navigateTo, remountRoute } from "../router";
import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch";
import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Context";
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
@@ -11,6 +11,7 @@
* ========================================================================== */
import { stationPartsLabel } from "../common_util/common_util_station";
import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog";
import {
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
ESCAPE_ROUTE_WIDTHS_M,
@@ -54,8 +55,6 @@ export interface IrregularStation {
/** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 종류 값(구 데이터 판정 키라 유지 —
* 화면 표시 이름은 "배수관"이다, 2026-08-17 사용자 지시). */
export const PIPE_STRUCTURE_NAME = "배관";
/** 화면에 보이는 이름. */
export const PIPE_DISPLAY_NAME = "배수관";
/** 배수관으로 볼 항목인가. 드롭다운 종류가 "배관"이거나 손으로 이름을 "배관"·"배수관"이라
* 적은 것도 관 지점 정본을 따르게 한다 — 아니면 [초기화]로 지워지지 않고 배수유역도와
+58
View File
@@ -0,0 +1,58 @@
/* =============================================================================
* B06_Section_Api_Context.ts
* 종횡단 설정값(`GET …/sections/context`)의 **세션 캐시 창구** — 화면 쪽.
*
* `B06_Section_Api_Fetch.ts` 에서 갈라냄(472-2) — 서버 Node 묶음이 Api_Fetch 를 끌어도
* 세션(`b_page_state`) · 되돌리기 사진첩(`Profile_History`)을 함께 끌지 않게.
* ========================================================================== */
import { clearState, readState, stateKey, writeState } from "../A00_Common/b_page_state";
import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History";
import { requestSectionContext } from "./B06_Section_Api_Fetch";
import type { SectionContextResponse } from "./B06_Section_Api_Types";
/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */
/** 종횡단 설정값 — ④ 계산 결과라 세션에 담아 두고 화면을 오갈 때마다 다시 묻지 않는다
* (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다. */
export async function fetchSectionContext(projectId: string): Promise<SectionContextResponse> {
const cached = readState<SectionContextResponse>("section-context", projectId);
if (cached) return seedStandardCross(projectId, cached);
const fresh = await requestSectionContext(projectId);
writeState("section-context", fresh, projectId);
return seedStandardCross(projectId, fresh);
}
/** 서버가 지금 보는 노선 id — 세션 캐시를 건너뛰고 묻는다. [저장] · [확정] 앞에서 화면이 쥔
* 노선과 대조한다(PLAN 76-1). 받은 설정값으로 캐시도 새로 둔다. */
export async function fetchCurrentRouteId(projectId: string): Promise<number | null> {
const fresh = await requestSectionContext(projectId);
writeState("section-context", fresh, projectId);
return fresh.route_id;
}
/**
* 저장된 표준 횡단면을 **세션이 비어 있을 때만** 채운다(2026-09-07).
*
* 브라우저 횡단 계산은 `세션 ?? config 기본값` 으로 서는데, 표준단면 세션값은 그 탭에서만
* 산다. 그래서 **탭을 새로 열면** 화면은 config 기본값으로, 서버는 저장분으로 계산해
* 같은 측점이 갈렸다(실측 — 용화 route 169 저장분은 암반 횡단경사 **5%**·측구 상단폭
* **0.9m**, config 기본값은 **3%**·**0.69m**).
*
* 여기가 두 화면(B05·B06)이 함께 지나는 유일한 자리라 이 한 곳에서 채운다. 사용자가
* 그 탭에서 고친 값이 있으면 **건드리지 않는다** — 초안이 언제나 우선이다.
*/
function seedStandardCross(
projectId: string,
context: SectionContextResponse,
): SectionContextResponse {
const stored = context.stored_standard_cross_section;
if (stored && readState<unknown>("std-cross", projectId) === null) {
writeState("std-cross", stored, projectId);
seedPageHistory(stateKey("std-cross", projectId)); // 씨앗 — 되돌리기 걸음 아님(364-1)
}
return context;
}
export function clearSectionContextCache(projectId: string): void {
clearState("section-context", projectId);
}
+5 -66
View File
@@ -16,15 +16,11 @@
* 기존 호출 코드가 깨지지 않도록 여기서 그대로 다시 내보낸다.
* ========================================================================== */
import { clearState, readState, stateKey, writeState } from "../A00_Common/b_page_state";
import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import { t as L } from "@ui/ui_template_locale";
import type { CrossDesignChoice } from "./B06_Section_Cross_Design_Session";
import type {
CrossDesign,
CrossDesignRequest,
CrossDesignResponse,
CrossSection,
CrossSectionPatch,
SectionConfirmResponse,
@@ -62,56 +58,11 @@ async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
}
}
/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */
/** 종횡단 설정값 — ④ 계산 결과라 세션에 담아 두고 화면을 오갈 때마다 다시 묻지 않는다
* (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다. */
export async function fetchSectionContext(projectId: string): Promise<SectionContextResponse> {
const cached = readState<SectionContextResponse>("section-context", projectId);
if (cached) return seedStandardCross(projectId, cached);
const fresh = await requestJson<SectionContextResponse>(
`/projects/${projectId}/sections/context`,
{ method: "GET" },
);
writeState("section-context", fresh, projectId);
return seedStandardCross(projectId, fresh);
}
/** 서버가 지금 보는 노선 id — 세션 캐시를 건너뛰고 묻는다. [저장] · [확정] 앞에서 화면이 쥔
* 노선과 대조한다(PLAN 76-1). 받은 설정값으로 캐시도 새로 둔다. */
export async function fetchCurrentRouteId(projectId: string): Promise<number | null> {
const fresh = await requestJson<SectionContextResponse>(
`/projects/${projectId}/sections/context`,
{ method: "GET" },
);
writeState("section-context", fresh, projectId);
return fresh.route_id;
}
/**
* 저장된 표준 횡단면을 **세션이 비어 있을 때만** 채운다(2026-09-07).
*
* 브라우저 횡단 계산은 `세션 ?? config 기본값` 으로 서는데, 표준단면 세션값은 그 탭에서만
* 산다. 그래서 **탭을 새로 열면** 화면은 config 기본값으로, 서버는 저장분으로 계산해
* 같은 측점이 갈렸다(실측 — 용화 route 169 저장분은 암반 횡단경사 **5%**·측구 상단폭
* **0.9m**, config 기본값은 **3%**·**0.69m**).
*
* 여기가 두 화면(B05·B06)이 함께 지나는 유일한 자리라 이 한 곳에서 채운다. 사용자가
* 그 탭에서 고친 값이 있으면 **건드리지 않는다** — 초안이 언제나 우선이다.
*/
function seedStandardCross(
projectId: string,
context: SectionContextResponse,
): SectionContextResponse {
const stored = context.stored_standard_cross_section;
if (stored && readState<unknown>("std-cross", projectId) === null) {
writeState("std-cross", stored, projectId);
seedPageHistory(stateKey("std-cross", projectId)); // 씨앗 — 되돌리기 걸음 아님(364-1)
}
return context;
}
export function clearSectionContextCache(projectId: string): void {
clearState("section-context", projectId);
/** 종횡단 설정값 한 번 묻기(캐시 없음) — 세션 캐시 창구는 `B06_Section_Api_Context.ts`(472-2). */
export function requestSectionContext(projectId: string): Promise<SectionContextResponse> {
return requestJson<SectionContextResponse>(`/projects/${projectId}/sections/context`, {
method: "GET",
});
}
/** 경로의 종단면 요약을 조회한다. */
@@ -161,18 +112,6 @@ export async function regenerateSections(
);
}
/** 측점 표준횡단 설계(지반유형·단면유형)를 즉시 계산·저장하고 잠정 결과를 반환한다. */
export async function computeCrossDesign(
projectId: string,
routeId: number,
request: CrossDesignRequest,
): Promise<CrossDesignResponse> {
return requestJson<CrossDesignResponse>(
`/projects/${projectId}/sections/${routeId}/cross-design`,
{ method: "POST", body: JSON.stringify(request) },
);
}
/** 경로의 종·횡단면을 확정한다. 표준 횡단면 설정값·측점별 세션 보관값을 함께 저장할 수 있다. */
export async function confirmSections(
projectId: string,
-29
View File
@@ -550,35 +550,6 @@ export interface CrossDesign {
pipe_length_m?: number;
}
export interface CrossDesignResponse {
status: string;
chainage_m: number;
design: CrossDesign;
}
export interface CrossDesignRequest {
chainage_m: number;
ground_type: GroundType;
section_mode: SectionMode;
ditch_side?: DitchSide | null;
/** 측구 형식(일반/L형). L형은 암 지반에서만 허용된다. */
ditch_type?: DitchType;
/** 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산된다. */
paved?: boolean;
/** 암 경계선 오프셋(m, 지면선 기준 하향 음수). 암 지반 2단계 절토 무릎 계산용. */
rock_boundary_offset_m?: number | null;
/** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */
two_stage_slope?: boolean;
/** 이 측점만 쓰는 암 절토 경사비(1:n 의 n). 없으면 표준값(2026-09-07). */
cut_slope_ratio?: number | null;
/** 옛 이름 — 결과값이 그대로 실려 오던 자리(호환). 새 요청은 `ditch_choice` 를 쓴다. */
ditch_enabled?: boolean | null;
/** 측구를 둘지 **사용자가 정한 선택**. `null`/미지정 = 자동 판정(2026-09-09 정리). */
ditch_choice?: boolean | null;
/** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */
standard_cross_section?: StandardCrossSection;
}
/** 확정 시 측점별 data.design에 병합할 프론트 세션 보관값. */
export interface CrossSectionPatch {
chainage_m: number;
@@ -31,15 +31,6 @@ export interface CrossDesignChoice {
const readAll = (projectId: string, routeId: number) =>
readKeyed<CrossDesignChoice>("crossdesign", projectId, routeId);
/** 이 측점의 선택값(없으면 null). */
export function readCrossDesignChoice(
projectId: string,
routeId: number,
chainageM: number,
): CrossDesignChoice | null {
return readAll(projectId, routeId).get(chainageKey(chainageM)) ?? null;
}
/** 선택값을 세션에 담는다. 같은 측점의 앞선 값은 덮어쓴다. */
export function writeCrossDesignChoice(
projectId: string,
-120
View File
@@ -22,12 +22,6 @@ from B05_Profile.B05_Profile_Engine_Sections import (
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes
from B06_Section.B06_Section_Engine_Design import (
compute_cross_design,
curve_widening_args,
stored_shoulder_extend,
)
from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges
from B06_Section.B06_Section_Repository import (
count_cross_sections,
create_longitudinal_section,
@@ -40,26 +34,13 @@ from B06_Section.B06_Section_Repository import (
get_workflow_route_context,
insert_cross_sections,
list_recent_company_projects,
update_cross_section_design,
)
from B06_Section.B06_Section_Router_Design import (
PREVIEW_DESIGN_FIELDS as _PREVIEW_DESIGN_FIELDS,
)
from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
ford_drop_at,
ford_surface_drops,
paved_at,
pavement_ranges,
stored_berm,
stored_cut_slope,
)
from B06_Section.B06_Section_Router_Design import (
attach_default_designs as _attach_default_designs,
)
from B06_Section.B06_Section_Router_Design import (
read_cross_design_inputs as _read_cross_design_inputs,
)
from B06_Section.B06_Section_Router_Design import (
recompute_designs_for_alignment as _recompute_designs_for_alignment,
)
@@ -76,8 +57,6 @@ from B06_Section.B06_Section_Schema import (
CompanyStandardResponse,
CrossDesignPreviewRequest,
CrossDesignPreviewResponse,
CrossDesignRequest,
CrossDesignResponse,
HaulEquipmentLimit,
SectionContextResponse,
SectionDetailResponse,
@@ -597,102 +576,3 @@ async def preview_cross_designs(
status_code=500,
content={"status": "error", "message": "횡단 설계 프리뷰 계산 중 오류가 발생했습니다."},
)
@router.post("/{project_id}/sections/{route_id}/cross-design", response_model=CrossDesignResponse)
async def compute_cross_section_design(
project_id: UUID, route_id: int, request: CrossDesignRequest
) -> CrossDesignResponse | JSONResponse:
"""측점 표준횡단 설계(지반유형·단면유형)를 즉시 계산해 잠정치로 저장한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
if not longitudinal:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
)
stored_path = await get_project_storage_relative_path(connection, project_id)
# 저장분을 **계산 전에** 읽는다 — 소단은 사용자 조작값이면서 **기하 입력**이라,
# 나중에 키만 베껴 붙이면 설계선·면적은 계단 없이 나오고 `berm` 값만 남아
# 서로 어긋난다(2026-09-07). 값을 나르는 다른 사용자 키와 다른 점이다.
stored_designs = await get_cross_section_designs(connection, route_id)
stored_design: dict[str, Any] | None = None
for record in stored_designs:
if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01:
candidate = record.get("design")
stored_design = candidate if isinstance(candidate, dict) else None
break
project_root = Path(resolve_stored_project_path(stored_path))
samples, design_elevation, pavement_suggested, cross_record = await asyncio.to_thread(
_read_cross_design_inputs,
project_root,
str(longitudinal["longitudinal_file_path"]),
request.chainage_m,
)
design = compute_cross_design(
samples,
design_elevation,
ground_type=request.ground_type,
section_mode=request.section_mode,
ditch_side=request.ditch_side,
ditch_type=request.ditch_type,
paved=paved_at(request.chainage_m, pavement_ranges(project_root)),
standard=request.standard_cross_section,
rock_boundary_offset_m=request.rock_boundary_offset_m,
two_stage_slope=request.two_stage_slope,
# 측점별 암 절토 경사 — 요청값이 없으면 저장분에서 잇는다(2026-09-07).
cut_slope_ratio=request.cut_slope_ratio or stored_cut_slope(stored_design or {}),
ditch_enabled=request.ditch_enabled,
ditch_choice=request.ditch_choice,
surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)),
berm=stored_berm(stored_design or {}),
**curve_widening_args(cross_record),
**stored_shoulder_extend(stored_design or {}), # 노견 확장(139-6)
**tilt_args(request.chainage_m, tilt_ranges(project_root)),
)
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
design["status"] = "provisional"
# 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다.
design["pavement_suggested"] = pavement_suggested
# 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이
# 지우지 않게 한다(2026-08-06). 목록을 여기 다시 적지 않는다 — 서버의 한 벌은
# `B06_Section_Router_Design.USER_TOUCHED_KEYS` 다(2026-09-07). 예전에는 여기에
# 따로 적어 두어 `extra_spans` 가 빠져 있었다.
if stored_design is not None:
for key in USER_TOUCHED_KEYS:
if stored_design.get(key) is not None:
design[key] = stored_design[key]
async with pool.acquire() as connection:
await connection.begin()
try:
updated = await update_cross_section_design(
connection,
route_id=route_id,
chainage_m=request.chainage_m,
design=design,
project_id=project_id,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
if not updated:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "해당 측점 횡단 레코드가 없습니다."},
)
return CrossDesignResponse(chainage_m=request.chainage_m, design=design)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception(
"B06 측점 횡단 설계 계산 실패: project_id=%s route_id=%s", project_id, route_id
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "횡단 설계 계산 중 오류가 발생했습니다."},
)
-8
View File
@@ -63,14 +63,6 @@ class CrossDesignRequest(BaseModel):
standard_cross_section: dict[str, Any] | None = None
class CrossDesignResponse(BaseModel):
"""측점 표준횡단 설계 계산 결과(잠정치)."""
status: str = "success"
chainage_m: float
design: dict[str, Any]
class BasinAdjustPatch(BaseModel):
"""집수정 내부 치수와 기본 위치 대비 이동량."""
-3
View File
@@ -232,9 +232,6 @@ export function registeredKeys(
return sections.map((s, i) => (list && nearest(list, s.chainage_m)?.key) ?? computed[i].key);
}
/** 지금 노선(마지막으로 세운 목록) — 세션 열쇠만 아는 저장소(암 경계 · 암 경사)가 씀. */
export const currentStationScope = () => current;
function nearest(list: Keyed[], chainageM: number): Keyed | undefined {
let best: Keyed | undefined;
for (const entry of list) {
@@ -22,11 +22,6 @@ import {
import { EXTRA_SPAN_DEFAULT } from "./logic/B06_Section_UI_Cross_Culvert_Const";
import type { StructureSpan } from "./logic/B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import {
designInterpolator,
groundInterpolator,
slopeToeOffset,
} from "./logic/B06_Section_UI_Cross_Culvert_Solve";
/**
* 기슭막이 4축 조작 제어(2026-08-22 확정) — 벽을 눌러 고르고 조정창에서
* 좌우(x)·상하(d, 성토선 대각)·높이(h)·재질(m)을 만진다. 고른 벽은 여기 담아 두어
@@ -648,26 +643,3 @@ function adjustsInput(
),
};
}
/**
* **구조물 없는 상태**의 설계 절·성토선이 원지반과 만나는 지점이 들어오는 데
* 필요한 표시 반폭(m, 여유 0.5 포함) — 2026-08-22 사용자 확정 줌 기준.
* 구조물(기슭막이·집수정)이 어디로 가든 화면 기준은 지형·설계선이 정한다.
*/
export function culvertRequiredHalfWidth(section: CrossSection): number | null {
const design = section.design;
if (!design) return null;
const groundAt = groundInterpolator(section.samples);
const designAt = designInterpolator(design.design_line);
const edges = design.road_edges;
if (!groundAt || !designAt || !edges) return null;
const offsets = section.samples.map((sample) => sample.offset_m ?? 0);
const limits = { left: Math.max(...offsets), right: Math.min(...offsets) };
let extent = 0;
for (const side of ["left", "right"] as const) {
const edge = side === "left" ? edges.left : edges.right;
const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limits[side]);
extent = Math.max(extent, Math.abs(toe));
}
return extent > 0 ? extent + 0.5 : null;
}
+2 -36
View File
@@ -8,15 +8,9 @@
* ========================================================================== */
import { PIPE_TYPES } from "@config/config_frontend";
import { PIPE_DISPLAY_NAME } from "../B05_Profile/B05_Profile_UI_IrregularStations";
import { pipeTitleKinds } from "../common_util/common_util_structure_catalog";
import { PIPE_DISPLAY_NAME, pipeTitleKinds } from "../common_util/common_util_structure_catalog";
import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design";
import type {
DesignProfile,
LongitudinalSection,
SectionDetailResponse,
SectionSample,
} from "./B06_Section_Api_Fetch";
import type { DesignProfile, LongitudinalSection, SectionSample } from "./B06_Section_Api_Fetch";
export type { CrossDesignChange };
export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void;
@@ -274,34 +268,6 @@ export function longitudinalMaxChainage(data: LongitudinalSection): number {
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
}
/**
* 종·횡단 공통 Y스케일. `longHeightPx`는 종단면도의 실제 렌더 높이로, 패널 리사이즈로
* 종단도가 줄면 그 높이를 넘겨야 표고 범위가 잘리지 않는다(기본은 고정 높이).
*/
export function calculateYScale(
detail: SectionDetailResponse,
longHeightPx: number = LONG_HEIGHT,
): YScaleOptions | undefined {
const elevations = [
...detail.longitudinal.samples.map((sample) => sample.elevation_m),
...detail.cross_sections.flatMap((section) =>
section.samples.map((sample) => sample.elevation_m),
),
...(detail.longitudinal.design_profiles ?? []).flatMap((profile) =>
profile.samples.map((sample) => sample.elevation_m),
),
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
if (!elevations.length) return undefined;
const globalMinElevation = Math.min(...elevations);
const globalMaxElevation = Math.max(...elevations);
const plotHeight = Math.max(longHeightPx - LONG_PAD.top - LONG_PAD.bottom, 1);
return {
pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1),
globalMinElevation,
globalMaxElevation,
};
}
/**
* **보이는 구간의 표고 최저·최고**(종단 그래프 세로 자동 맞춤, 2026-09-04 사용자 확정).
*
@@ -1,21 +1,14 @@
/* =============================================================================
* B06_Section_UI_Section_View_Menu.ts
* B06 종단 그래프 위 **우클릭 메뉴** — B05 와 같은 부품(`mountStructureMenu`)을 그대로 쓴다.
*
* 왜 여기서도 여나(2026-09-12 사용자) — B05·B06 은 한 페이지다. 같은 그래프를 보면서
* 한쪽에서만 넣고 뺄 수 있으면 사용자가 화면을 오가야 하고 직관도 깨진다.
* B06 종단 그래프 구조물 조작 길(넣기 · 빼기 · 옮기기) — 페이지가 물려 준다.
*
* 정본에 바로 쓰지 않는다 — 추가·삭제·이동은 **세션에 예약**하고 [저장]·[확정]에서
* 관 정본(`pipe_points.json`)으로 나간다(CLAUDE.md 5장). 세부 배수유역 재분할은 그때
* 서버가 관 목록을 다시 받아 처리한다.
* ========================================================================== */
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations";
import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
import { pickGroups } from "../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import { listPick } from "../common_util/common_util_structure_catalog";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
/** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */
@@ -87,49 +80,3 @@ function moveStructureMark(
export function isPipeMark(structure: StructureInstance): boolean {
return String(structure.structure_id ?? "").startsWith("pipe-");
}
/**
* 종단 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 그릴 때마다 새로 만들어지므로 이
* 함수도 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다(B05 와 같은 규칙).
*/
export function mountSectionStructureMenu(
host: HTMLElement,
input: {
structures: ReadonlyArray<StructureInstance>;
types: ReadonlyArray<StructureType>;
x: (chainageM: number) => number;
chainageAt: (px: number) => number;
maxChainageM: number;
edit: SectionStructureEdit;
},
): void {
// 메뉴가 쓰는 최소 정보만 옮겨 담는다 — B06 에는 B05 의 비정규 측점 목록이 없다.
const stations: IrregularStation[] = input.structures.map((structure) => {
const chainage = structureAnchorM(structure);
const pipe = isPipeMark(structure);
return {
id: String(structure.structure_id),
station: 0,
remainder: 0,
chainage_m: chainage,
structure:
input.types.find((type) => type.type_id === structure.type_id)?.name ??
String(structure.type_id),
origin: pipe ? "pipe" : "user",
} as IrregularStation;
});
mountStructureMenu(host, {
stations,
x: input.x,
chainageAt: input.chainageAt,
maxChainageM: input.maxChainageM,
onRemove: (station) => {
if (station.origin === "pipe") input.edit.removePipe(station.chainage_m);
else input.edit.removeStructure(station.id);
},
onAddPipe: (chainageM) => input.edit.addPipe(chainageM),
structureGroups: () => pickGroups(input.types, listPick("b06")), // B06 패널 거르기와 같음(129-1 · 322-1)
onAddStructureType: (chainageM, typeId) => input.edit.addStructureType(chainageM, typeId),
});
}
@@ -28,9 +28,6 @@ export const REVET_THICKNESS_M = 0.45;
* 못 가른 자리만 이 값. 돌쌓기 벽은 `B06_Section_UI_Cross_Lean.leanOf`(품셈 13-4-4 [주]⑪ 표준경사). */
export const REVET_LEAN_RATIO = 0.3;
/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */
export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2;
/** 성토 위 이음 = 벽 윗면 사면 쪽 끝에서 이만큼 안(m · 371-1). */
export const WALL_FILL_TOP_INSET_M = 0.1;
@@ -122,8 +119,6 @@ export function revetFormOf(m: string | null | undefined, specForm?: string | nu
return m || specForm || REVET_FORM_DEFAULT;
}
export const REVET_MATERIALS: RevetMaterial[] = ["dry", "wet", "concrete"];
export function materialLabel(material: RevetMaterial): string {
return material === "dry" ? "메쌓기" : material === "wet" ? "찰쌓기" : "콘크리트";
}
@@ -195,7 +190,6 @@ export const EXTRA_TIER_SLOPE_M = 1.0;
* (2026-08-21 사용자 지시).
*/
export const FILL_SLOPE_RATIO_MIN = 1.2;
export const FILL_SLOPE_RATIO_MAX = 2.0;
/** 성토사면 길이 한계(m) — `성토_비탈면.md` §2 "5m 이내". 초과 시 옹벽·석축 등 의무. */
export const FILL_SLOPE_MAX_LENGTH_M = 5.0;
@@ -221,13 +215,6 @@ export const PIPE_WALL_DEFAULT_RUN_M =
export const pipeWallDefaultD = (adjust: { x: number }): number =>
Math.abs(adjust.x) > 1e-9 ? PIPE_WALL_DEFAULT_RUN_M : 0;
/**
* 성토 전체고(노견 − 원지반) 기준(m) — 이 높이를 넘으면 1:1.2로도 사면이 5m를 넘어
* 구조물이 필요해진다(3m × √(1+1.2²) = 4.69m, 3.2m에서 5.0m). 기슭막이를 자연 사면
* 끝이 아니라 **사면길이 5m 지점**으로 끌어올리는 판정에 쓴다(2026-08-21 사용자 지시).
*/
export const FILL_STRUCTURE_HEIGHT_M = 3.0;
/**
* 성토 사면이 성립하는 **최소 성토고**(m) — 노견 − 벽 이음선 상단.
*
@@ -237,13 +224,6 @@ export const FILL_STRUCTURE_HEIGHT_M = 3.0;
*/
export const FILL_MIN_RISE_M = 0.3;
/**
* 집수정 바닥 여유(m) — **관 하단 아래**로 두는 깊이. 상부 여유는 기슭막이와 같은
* `REVET_FREEBOARD_M`(0.5)을 쓴다. 집수정 깊이 = 바닥 여유 + 관경 + 상부 여유
* (2026-08-21 사용자 확정 — 종전에는 성토 경사선과의 교점으로 높이를 잡았다).
*/
export const BASIN_BOTTOM_CLEARANCE_M = 0.5;
/**
* 집수정 내부 높이(m) — 바닥판이 있는 ㄴ(L)·ㄷ형의 **바닥 윗면~벽 상단** 깊이.
* 프로젝트 기본값 1.2m(2026-08-22 사용자 확정). 외부 기준은 확인되지 않았다.
@@ -196,26 +196,6 @@ export function cutLength(corners: { inlet: PipeEnd; outlet: PipeEnd }): number
);
}
/**
* 유출 벽을 그 자리에 세웠을 때의 성토 사면(물매 1:n·사면길이)을 재는 함수를 만든다.
* 관 길이 정수 맞춤이 벽을 옮겨도 되는 자리인지 판정하는 데 쓴다.
*/
export function outletSlopeFactory(
info: { edge: { offset_m: number; elevation_m: number }; outward: number },
invertAt: (offset: number) => number,
wallHeight: number,
): (offset: number, thickness: number) => { ratio: number; lengthM: number } {
return (offset, thickness) => {
const joint = offset + info.outward * (thickness / 2);
const rise = info.edge.elevation_m - (invertAt(offset) + wallHeight);
const run = Math.abs(joint - info.edge.offset_m);
return {
ratio: rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY,
lengthM: Math.hypot(run, Math.max(rise, 0)),
};
};
}
/**
* 기슭막이 **자동 자리 = 노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정).
* 노견 끝에서 바깥으로 훑어, 표준 높이 벽으로 ① 성토고 하한(FILL_MIN_RISE_M)과
@@ -204,3 +204,6 @@ export function shapeNeeds(typeId: string): readonly string[] {
/** 관 공식 이름의 관종들(정의 선택지) — 표시 이름이 「배수관」으로 줄일 이름을 알아봄. */
export const pipeTitleKinds = (): readonly string[] => pipeKinds;
/** 배수관의 화면 표시 이름(2026-08-17 사용자 지시) — 목록 · 종 · 횡단 표기가 같이 씀. */
export const PIPE_DISPLAY_NAME = "배수관";
@@ -32,5 +32,5 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| B06 폼 관 측점 · 옵션 고침 | `pipeFacilities` → 세션 `pipes` · 옮긴 자리(정수 m)로 옵션 · 캐시 얹기 · 집계 찾음 | B05 관 옮김과 같음 | 배수유역 다시 나눔 | 관 차례 `PUT /drainage/pipe-points` | 14 | `B06_Section_UI_Page_Structures_Panel.ts:300-345 · 536-560` |
| B06 구조물 패널 | 보기 `structures` → 시설 줄 `set` → 세션 `structures` | B05 구조물과 같음 | — | `PUT /route/structures` | 11 | `B06_Section_UI_Page_Structures_Panel.ts:211-227 · 582-598` |
| B06 평면 패널 끌기 | B05 교점 끌기와 같음(같은 편집기를 옮겨 붙임) | 같음 · B06 쪽 갈아끼움이 먼저 | 같음 | `/route/replan` | 11 | `design_plan_peek.ts:61-77` · `B05_Profile_UI_RouteEdit.ts:290-293 · 599-601` · `B06_Section_UI_Page_Station_Swap.ts:35` |
| 넓힘(표본 폭 넘음) | 없음 — 확인창 뒤 바로 서버 | 응답 뒤 상세 강제 다시 받음 | `/sections/{노선}/regenerate` | **저장 밖** — DB 바로 씀(이미 정본) | 3 | `B06_Section_UI_Page_Common.ts:32-63` · `B06_Section_Router.py:401-411` · `B06_Section_Router_Widen.py:80` |
| 넓힘(표본 폭 넘음) | 없음 — 확인창 뒤 바로 서버 | 응답 뒤 상세 강제 다시 받음 | `/sections/{노선}/regenerate` | **저장 밖** — DB 바로 씀(이미 정본) | 3 | `B06_Section_UI_Page_Common.ts:32-63` · `B06_Section_Router.py:378-388` · `B06_Section_Router_Widen.py:80` |
| [측점 만들기] | 없음 — 바로 서버 | 응답 뒤 상세 비움 · 강제 다시 받음 · 알림 | `/section/missing-stations` | **저장 밖** — 종단 · 횡단 파일 바로 씀 | 3 | `B06_Section_UI_Missing_Stations.ts:86-138` · `B06_Section_Router_Stations.py:236-296 · 365-407` |
+1 -1
View File
@@ -45,7 +45,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| 내용 | 자리(파일:줄) | 상태 |
|---|---|---|
| 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:401-411` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 |
| 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 |
| [저장] 9 차례 · 차례마다 따로 커밋 — 실패 = 앞 차례만 저장 | `design_save.ts:253-299` | 472-6 |
| `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 |
| 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠 | `design_save.ts:187-188` | 추정 |
-1
View File
@@ -20,7 +20,6 @@ source: ["docs/raw/verification/2026-07-19_verify_B05_B06_UI_Station_Improvement
| `GET /{project_id}/sections/detail` | `B06_Section_Router.py` | 종단 결과와 다수 횡단면을 일괄 조회하고 배수관 측점에는 `culvert` 세트 제원을 결합 |
| `GET /{project_id}/sections/road-widths` | `B06_Section_Router.py` | 임도 등급별 법정 최소 도로폭 정보(`FOREST_ROAD_MIN_WIDTH_M`) 조회 (B05 프론트엔드가 측점 가로선 반폭 계산 시 호출) |
| `POST /{project_id}/sections/{route_id}/regenerate` | `B06_Section_Router.py:85` | 횡단 반폭 재생성 요청 (`SectionRegenerateRequest` 매핑) |
| `POST /{project_id}/sections/{route_id}/cross-design` | `B06_Section_Router.py` | 측점별 지반·단면·측구와 집수정 `basin_adjust`(폭·높이·좌우·상하)를 `data.design`에 저장 (`CrossDesignRequest` → `CrossDesignResponse`) |
| `GET /{project_id}/recent-company-projects` | `B06_Section_Router.py` | 동일 회사 최근 5개 프로젝트 목록 반환 API |
| `GET /{project_id}/standard-cross-section-from/{target_id}` | `B06_Section_Router.py` | 지정 타 프로젝트의 표준단면 설계값 로드 API |
@@ -15,8 +15,6 @@ last_updated: 2026-08-02
## 2. 주요 연동 API 함수
* `getSectionsDetail(projectId)`:
* `GET /sections/detail` 호출. 종단면도 지반선/계획선 및 다수 횡단면 데이터를 일괄 응답받습니다.
* `crossDesignOne(projectId, routeId, data)`:
* `POST /sections/{routeId}/cross-design` 호출. 측점별 지반유형/단면유형/측구/암반경계선 높이 갱신 시 실시간 단면적 계산 및 저장을 수행합니다.
* `regenerateSections(projectId, routeId, width)`:
* `POST /sections/{routeId}/regenerate` 호출. 횡단 반폭 재생성을 비동기로 요청합니다.
* `confirmSections(projectId, routeId, massHaul)`:
@@ -51,7 +51,7 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
"../A00_Common/design/design_page_data": loadTs("A00_Common/design/design_page_data.ts", {
"../../B04_PreProcess/B04_PreProcess_Api_Fetch": serverFetch,
"../../B05_Profile/B05_Profile_Api_Structures": serverStructures,
"../../B06_Section/B06_Section_Api_Fetch": {
"../../B06_Section/B06_Section_Api_Context": {
fetchSectionContext: async () => ({ route_id: 1 }),
},
"./design_page_route": loadTs("A00_Common/design/design_page_route.ts"), // 노선 id 한 칸(386-2b)
+1 -1
View File
@@ -23,7 +23,7 @@ const STUBS = {
},
},
// 횡단 context(세션 캐시) — 노선 id 한 칸(386-2b)
"../../B06_Section/B06_Section_Api_Fetch": {
"../../B06_Section/B06_Section_Api_Context": {
fetchSectionContext: async (projectId) => ({ route_id: projectId === "p-none" ? null : 304 }),
},
"../../B05_Profile/B05_Profile_Api_Structures": {
@@ -20,7 +20,7 @@ if str(PROJECT_ROOT) not in sys.path:
from B06_Section.B06_Section_Schema import SectionContextResponse # noqa: E402
_FETCH = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Fetch.ts"
_FETCH = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Context.ts" # 472-2 세션 창구 갈라냄
_ROUTER = PROJECT_ROOT / "B06_Section" / "B06_Section_Router.py"
@@ -51,7 +51,6 @@ def test_확정_payload_도_손나열이_없다():
def test_서버_목록이_한_벌이다():
"""라우터가 목록을 다시 적지 않고 그 한 벌을 쓴다."""
"""라우터가 목록을 다시 적지 않는다(목록을 쓰던 `cross-design` 엔드포인트는 472-2 에서 지움)."""
router = (ROOT / "B06_Section" / "B06_Section_Router.py").read_text(encoding="utf-8")
assert "USER_TOUCHED_KEYS" in router
assert router.count('"revet_follow_grade"') == 0, "라우터에 목록 사본이 남아 있음"
+3 -1
View File
@@ -211,7 +211,9 @@ def test_저장된_표준단면이_브라우저까지_간다():
from B06_Section.B06_Section_Schema import SectionContextResponse
assert "stored_standard_cross_section" in SectionContextResponse.model_fields
fetch = (PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Fetch.ts").read_text(encoding="utf-8")
fetch = (PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Context.ts").read_text(
encoding="utf-8"
)
assert "function seedStandardCross(" in fetch
assert fetch.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움"