perf(B05/B06): 계획선 프리뷰 응답 1.09MB → 6KB, 곡선 셀 기본 표시 원복

프리뷰 성능
- 느린 원인은 응답 크기였다(영구저장소 저장·재로드는 하지 않는다). 상세를 통째로
  돌려주느라 1.09MB였고, 유토곡선이 실제로 읽는 설계 필드만 남겨 6KB로 줄였다.
  제외: 지반선 원시 샘플, 설계선 좌표(250KB), 선형 구조(87KB), 계획선 샘플(85KB).
  계획선은 화면이 이미 같은 규칙으로 계산해 들고 있고, 설계선 좌표는 B06 상세가 준다.
- 프론트는 온 필드만 덮어쓰는 부분 갱신으로 바꿔 안 온 값을 지우지 않는다.
  디바운스 400 → 250ms.

곡선 L·R 표시 원복
- 배관 자리는 구조물 측점이라 테이블에 기본으로 안 나오는 것이 맞다(사용자 정정).
  격자 밖 변화점 셀을 걷어냈다 — 선택하면 buildSelectedColumn이 곡선 L·R을 보여 준다.

검증: 응답 1,091,003 B → 6,007 B, 계산값 동일(절토 16.4㎡ / 성토 48.3㎡).
typecheck·vite build·ruff·B03 테스트 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 20:47:35 +09:00
co-authored by Claude Opus 5
parent cffd65bd75
commit 644ec64bd2
6 changed files with 72 additions and 60 deletions
@@ -91,7 +91,7 @@ const MAX_PANEL_HEIGHT_RATIO = 0.9;
const TABLE_ROW_COUNT = 12;
/** 계획고 편집 후 횡단 재계산을 서버에 묻기까지 기다리는 시간(ms).
* ▲/▼ 길게 누르기(초당 10회)로 요청이 쏟아지지 않게 마지막 값만 보낸다. */
const CROSS_PREVIEW_DEBOUNCE_MS = 400;
const CROSS_PREVIEW_DEBOUNCE_MS = 250;
/**
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
@@ -432,9 +432,17 @@ export function createRouteProfilePanel(
.then((next) => {
if (seq !== crossPreviewSeq || !detail || routeId !== targetRouteId) return;
// 공유 캐시가 들고 있는 **같은 객체**를 제자리 갱신한다 — B06이 이 객체를 그대로
// 보므로 페이지를 넘어가도 다시 받을 필요가 없다.
detail.cross_sections = next.cross_sections;
detail.longitudinal.design_profiles = next.longitudinal.design_profiles;
// 보므로 페이지를 넘어가도 다시 받을 필요가 없다. 응답에는 바뀌는 값(설계·계획선)만
// 오므로 지반선 샘플은 건드리지 않는다.
const designByChainage = new Map(
next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
for (const section of detail.cross_sections) {
const patch = designByChainage.get(section.chainage_m.toFixed(3));
// 부분 갱신 — 온 필드만 덮어쓴다. 설계선 좌표 등 안 온 값은 B06이 상세를
// 받을 때 정확한 값으로 채워지므로 여기서 지우지 않는다.
if (patch && section.design) section.design = { ...section.design, ...patch };
}
draw();
})
.catch(() => {
@@ -292,12 +292,7 @@ function curveTitle(curve: AlignmentCurve): string {
* 곡선이 없는 측점에도 **빈 칸을 만든다**. 변화점에만 셀을 두면 세로 구분선이 띄엄띄엄
* 끊겨 위쪽 측점값 행들과 격자가 맞지 않는다.
*/
function buildCurveRows(
options: ProfileTableOptions,
centers: number[],
x: (chainage: number) => number,
cellWidth: number,
): HTMLElement[] {
function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLElement[] {
const { alignment, onCurveRadiusChange } = options;
const lengthRow = createRow(
"b05-profile-table__row--curve is-group-start",
@@ -344,42 +339,6 @@ function buildCurveRows(
placeCell(radiusRow, centers[index], radiusCell);
});
// 규칙 측점 격자에 없는 변화점(배관 자리처럼 임의 chainage에 승격된 점)의 곡선도
// **기본으로** 값을 보여 준다 — 격자에만 셀을 두면 배관 변화점의 R·L이 표에서 통째로
// 빠져 "R이 안 들어간 것"처럼 읽힌다(2026-08-03 사용자 보고).
const stationKeys = new Set(alignment.stations.map((row) => row.chainage_m.toFixed(3)));
alignment.curves.forEach((curve) => {
const key = curve.chainage_m.toFixed(3);
if (stationKeys.has(key)) return;
const lengthCell = element("span", "b05-profile-table__curve is-floating");
const radiusCell = element("span", "b05-profile-table__curve is-floating");
radiusCell.append(
curveInput(
curve,
curve.r_m.toFixed(1),
`종단곡선 반경 R (m)
${curveTitle(curve)}`,
(r) => r,
),
);
lengthCell.append(
curveInput(
curve,
curve.l_m.toFixed(2),
`종단곡선 길이 L (m)
${curveTitle(curve)}`,
(l) => (curve.l_m > 1e-9 ? (l * curve.r_m) / curve.l_m : null),
),
);
[lengthCell, radiusCell].forEach((cell) => {
if (curve.skip_allowed) cell.classList.add("is-optional");
if (curve.omitted) cell.classList.add("is-omitted");
cell.style.width = `${cellWidth}px`;
});
placeCell(lengthRow, x(curve.chainage_m), lengthCell);
placeCell(radiusRow, x(curve.chainage_m), radiusCell);
});
function curveInput(
curve: AlignmentCurve,
value: string,
@@ -581,7 +540,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
});
table.append(row);
});
table.append(...buildCurveRows(options, centers, x, cellWidth));
table.append(...buildCurveRows(options, centers));
// 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다.
// 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다.
-7
View File
@@ -1146,10 +1146,3 @@
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
outline: 1px solid var(--color-primary);
}
/* 규칙 측점 격자 밖 변화점(배관 자리)의 곡선 R·L 셀 — 격자 셀 위에 얹혀 값을 보인다. */
.b05-profile-table__curve.is-floating {
z-index: 2;
background: var(--color-surface-raised);
outline: 1px solid var(--color-border);
}
@@ -457,6 +457,16 @@ export interface ProfileAlignmentEdits {
curve_radii: Record<string, number>;
}
/**
* 계획선 편집 프리뷰 결과 — **바뀌는 것만** 온다.
* 횡단 지반선 원시 샘플은 계획고와 무관해 그대로이므로 싣지 않는다(응답 1MB → 수십 KB).
*/
export interface CrossDesignPreviewResponse {
status: string;
/** 유토곡선이 읽는 설계 필드만 담긴 부분 갱신값(설계선 좌표 등은 오지 않는다). */
designs: Array<{ chainage_m: number; design: Partial<CrossDesign> }>;
}
/**
* 계획선 편집을 반영해 **계획선 + 전 측점 횡단 설계**를 다시 계산해 받는다(서버 저장 없음).
*
@@ -469,8 +479,8 @@ export async function previewCrossDesigns(
routeId: number,
edits: ProfileAlignmentEdits,
standardCrossSection?: StandardCrossSection,
): Promise<SectionDetailResponse> {
return requestJson<SectionDetailResponse>(
): Promise<CrossDesignPreviewResponse> {
return requestJson<CrossDesignPreviewResponse>(
`/projects/${projectId}/sections/${routeId}/cross-design/preview`,
{
method: "POST",
@@ -39,6 +39,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
CompanyStandardProject,
CompanyStandardResponse,
CrossDesignPreviewRequest,
CrossDesignPreviewResponse,
CrossDesignRequest,
CrossDesignResponse,
HaulEquipmentLimit,
@@ -486,6 +487,21 @@ def _attach_default_designs(
continue
#: 계획선 프리뷰 응답에 실을 설계 필드 — 유토곡선 계산(면적·지반유형·자연방토)과
#: 계획고 변경 감지에 쓰는 것만. 나머지(설계선 좌표 등)는 B06이 상세를 받을 때 온다.
_PREVIEW_DESIGN_FIELDS = (
"ground_type",
"roadbed_width_m",
"cut_area_m2",
"cut_soil_area_m2",
"cut_rock_area_m2",
"cut_rock_kind",
"fill_area_m2",
"fill_ground_slope",
"design_elevation_m",
)
def _recompute_designs_for_alignment(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
@@ -534,11 +550,11 @@ def _recompute_designs_for_alignment(
@router.post(
"/{project_id}/sections/{route_id}/cross-design/preview",
response_model=SectionDetailResponse,
response_model=CrossDesignPreviewResponse,
)
async def preview_cross_designs(
project_id: UUID, route_id: int, request: CrossDesignPreviewRequest
) -> SectionDetailResponse | JSONResponse:
) -> CrossDesignPreviewResponse | JSONResponse:
"""계획선 편집 델타로 계획선과 전 측점 횡단 설계를 다시 계산해 돌려준다(저장 없음).
B05에서 계획고를 끄는 동안 횡단 단면적·유토곡선이 함께 움직이게 하는 프리뷰 경로다.
@@ -574,8 +590,22 @@ async def preview_cross_designs(
)
await asyncio.to_thread(rebuild)
return SectionDetailResponse(
**detail, balloon_offsets=_read_balloon_offsets(longitudinal_row)
# 계획고를 끄는 동안 오가는 값이라 **유토곡선이 실제로 읽는 필드만** 싣는다.
# 지반선 샘플·설계선 좌표(design_line)·선형 구조를 다 담으면 1MB가 넘어 편집이 굼떠진다
# (2026-08-03 사용자 지적). 계획선 자체는 화면이 이미 같은 규칙으로 계산해 들고 있다.
return CrossDesignPreviewResponse(
designs=[
{
"chainage_m": section.get("chainage_m"),
"design": {
key: section["design"].get(key)
for key in _PREVIEW_DESIGN_FIELDS
if key in section["design"]
},
}
for section in detail["cross_sections"]
if section.get("design")
],
)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
@@ -191,3 +191,15 @@ class CrossDesignPreviewRequest(BaseModel):
def edits(self) -> dict[str, Any]:
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
class CrossDesignPreviewResponse(BaseModel):
"""계획선 편집 프리뷰 결과 — **바뀌는 것만** 담는다.
계획고를 끄는 동안 오가는 값이라 **유토곡선이 실제로 읽는 설계 필드만** 담는다.
지반선 샘플·설계선 좌표·선형 구조까지 실으면 1MB가 넘어 편집이 굼떠지고
(2026-08-03 사용자 지적), 계획선 자체는 화면이 같은 규칙으로 이미 계산해 들고 있다.
"""
status: str = "ok"
designs: list[dict[str, Any]] = Field(default_factory=list)