feat(B05/B06): 종횡단 상세 프론트 공유 캐시 + 계획선 호 통과 기하

페이지 간 캐시 공유
- B06_wf3_ProfileCross_Section_Store.ts 신설: 종횡단 상세를 projectId:routeId
  키로 들고 있는 모듈 싱글턴 캐시. B05와 B06이 같은 객체 참조를 보므로 B06이
  측점 설계를 제자리 갱신하면 B05가 다음 그리기에서 그대로 본다 — 횡단을 고친
  뒤 B05 유토곡선이 옛 값으로 그려지던 문제의 원인이 페이지별 개별 fetch였다.
- 두 페이지 진입을 loadSectionDetail()로 통일(동시 호출은 Promise 공유).
- 정본이 다시 쓰이는 조작에 캐시 갱신: 횡단 재생성 → replaceSectionDetail,
  계획선 편집 저장 → invalidateSectionDetail.

계획선 호 기하
- 배관 지점(지면선 × 배관 세로선 교점)이 호 위에 오도록 변화점 표고를 반복
  보정. 대칭 종단곡선은 꼭짓점을 지나지 않아 중앙종거만큼 어긋나 있었다.
  시작점·종점과 각 호, 호와 호 사이는 직선이 접선으로 잇는다.
- 호 반경 기본값을 설계 기준의 종단곡선 최소 반경으로 지정하고 curve_radii
  편집 델타에 실어 B05 테이블에서 사용자가 그대로 고칠 수 있게 했다.

유토곡선 Y축
- B05에만 있던 종단용 sticky 표고축 탓에 가로로 훑으면 유토곡선 눈금은 흘러가고
  표고축만 남아 Y축이 높이로 읽혔다. createMassHaulChart에 onAxis 콜백을 더해
  유토곡선용 sticky 누가토량 축을 따로 고정.

검증: solve 재실행 후 배관 4개 자리에서 계획고=지반고(오차 ≤0.0001m), 호 비겹침
확인. 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:28:23 +09:00
co-authored by Claude Opus 5
parent a9741277cf
commit 1ee6516dd5
7 changed files with 155 additions and 13 deletions
@@ -31,6 +31,9 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Alignment import (
ALIGNMENT_SCHEMA_VERSION,
AlignmentPolicy,
build_alignment,
build_curves,
chainage_key,
evaluate,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Solver import (
grade_limits,
@@ -125,10 +128,15 @@ def design_pipe_anchored_profile(
) -> tuple[dict[str, Any], dict[str, Any]]:
"""배관 배치 측점을 변화점으로 삼는 1차 계획선.
변화점 표고는 그 자리의 지반고다 — 계획선이 지면선과 교차하는 지점에 배관이 앉는다.
시·종점은 지반고에 사용자 오프셋을 더한다(기존 계약 유지). 종단곡선 R은
`build_alignment`이 기본값(`default_curve_radius_m`)으로 얹고, 기울기 위반은
막지 않고 경고로 남긴다 — 배관 위치가 우선이고 조정은 사용자 몫이다.
기하 규칙(2026-08-03 사용자 확정): **배관 자리(지면선과 배관 세로선의 교점)가 호 위에
있어야 한다.** 시작점·종점과 각 호, 호와 호 사이는 직선이 접선(tangent)으로 잇는다.
대칭 종단곡선은 원래 변화점(꼭짓점)을 지나지 않으므로, 변화점 표고를 반복 보정해
**곡선이 정확히 배관 지반고를 통과**하도록 맞춘다(중앙종거만큼 꼭짓점을 밀어낸다).
호 반경은 설계 기준의 종단곡선 최소 반경(`options.min_vertical_radius_m`,
config_system 법정 기준 해석값)을 기본으로 각 변화점에 지정하며, 이 값은 편집
델타(curve_radii)로 저장돼 사용자가 B05 테이블에서 그대로 고칠 수 있다.
기울기 위반은 막지 않고 경고로 남긴다 — 배관 위치가 우선이고 조정은 사용자 몫이다.
"""
options.validate()
chainage, ground = ground_profile(longitudinal)
@@ -172,10 +180,30 @@ def design_pipe_anchored_profile(
raise ValueError("계획선 변화점으로 쓸 배관 배치 측점이 없습니다.")
base_s = np.array([0.0, *anchors, total], dtype=np.float64)
# 배관 자리 고 = 지반고(지면선 교차). 시·종점만 오프셋을 얹는다.
base_z = np.interp(base_s, chainage, ground)
base_z[0] = fixed[0]
base_z[-1] = fixed[1]
# 목표: 배관 자리 계획고 = 지반고(지면선 교차점이 호 위). 시·종점만 오프셋을 얹는다.
target = np.interp(base_s, chainage, ground)
target[0] = fixed[0]
target[-1] = fixed[1]
# 각 배관 변화점의 호 반경 기본값 — 설계 기준의 종단곡선 최소 반경.
anchor_radii = {chainage_key(a): float(options.min_vertical_radius_m) for a in anchors}
edits = dict(edits or {})
edits["curve_radii"] = {**anchor_radii, **(edits.get("curve_radii") or {})}
# 대칭 종단곡선은 꼭짓점(변화점)을 지나지 않는다 — 곡선이 배관 지반고를 통과하도록
# 변화점 표고를 반복 보정한다(호가 안쪽으로 파고드는 중앙종거만큼 꼭짓점을 밀어낸다).
# 오차는 반복마다 중앙종거의 고차항만 남아 수 회면 mm 아래로 떨어진다.
base_z = target.copy()
radii_for_iter = edits["curve_radii"]
for _ in range(12):
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii_for_iter)
plan_at = evaluate(base_s, base_z, curves, base_s)
error = target - plan_at
error[0] = 0.0
error[-1] = 0.0
if float(np.max(np.abs(error))) < 1e-4:
break
base_z = base_z + error
alignment = build_alignment(
base_s=base_s,
+8 -2
View File
@@ -44,10 +44,13 @@ import {
} from "./B05_wf2_Route_UI_IrregularStations";
import {
fetchSectionContext,
fetchSectionDetail,
type SectionDetailResponse,
type SectionStation,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import {
invalidateSectionDetail,
loadSectionDetail,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Section_Store";
import "./B05_wf2_Route_UI_Style.css";
type GradeClass = RoutePanelValues["gradeClass"];
@@ -440,7 +443,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
let detail: SectionDetailResponse;
profilePanel.setLoading("종단면 자료를 불러오는 중…");
try {
detail = await fetchSectionDetail(activeProjectId, routeId);
// 공유 캐시 — B06이 이미 받아 뒀으면 같은 객체를 재사용해 두 페이지가 항상 같은 값을 본다.
detail = await loadSectionDetail(activeProjectId, routeId);
} catch {
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
currentSectionDetail = null;
@@ -582,6 +586,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
try {
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
await profilePanel.save();
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다.
invalidateSectionDetail(activeProjectId);
// 비정규 측점(구조물)이 있으면 확정 시 그 횡단까지 생성하도록 지표 샘플러 입력을 함께 보낸다.
await confirmRoute(activeProjectId, {
filter_key: latest?.surface_params.source_filter,
@@ -83,6 +83,8 @@ export interface RouteMassHaulDrawParams {
heightPx: number;
selectedStationId: string | null;
onSelectStation: (stationId: string) => void;
/** 가로 스크롤에도 왼쪽에 고정되는 Y축(누가토량)을 그리도록 눈금을 되돌려 준다. */
onAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void;
}
export interface RouteMassHaulPanel {
@@ -209,6 +211,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
params.widthPx,
params.onSelectStation,
haulPlan,
params.onAxis,
);
legendLayer.style.top = `${params.legendTopPx}px`;
legendLayer.append(
@@ -569,6 +569,10 @@ export function createRouteProfilePanel(
}
// 유토곡선 SVG는 종단 그래프와 **같은 부모의 형제**로 넣는다. 감싸는 상자를 하나라도
// 끼우면 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점선이 어긋난다.
// 유토곡선의 Y축(누가토량)도 종단 표고축과 같은 방식으로 왼쪽에 고정한다 —
// 안 그러면 가로로 훑을 때 유토곡선 눈금은 흘러가고 종단 표고축만 남아,
// 유토곡선 Y축이 "높이"로 읽힌다(2026-08-03 사용자 보고).
let massYAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
const massHaulChart =
alignment && designProfiles[0]
? massHaul.draw({
@@ -587,6 +591,9 @@ export function createRouteProfilePanel(
axisX: LONG_PAD.left,
},
legendTopPx: chartHeight + 6,
onAxis: (axis) => {
massYAxis = axis;
},
stationInterval: stationIntervalM ?? 1,
widthPx: width,
heightPx: tableHeight,
@@ -625,7 +632,13 @@ export function createRouteProfilePanel(
splitBar.addEventListener("pointermove", onMove);
splitBar.addEventListener("pointerup", onUp);
});
canvas.append(splitBar, massHaulChart);
// 종단 그래프와 같은 문법으로 감싼다 — sticky Y축 앵커의 기준(position: relative)이 필요하다.
const massWrap = document.createElement("div");
massWrap.className = "b05-profile__chart";
massWrap.style.height = `${tableHeight}px`;
massWrap.append(massHaulChart);
if (massYAxis) massWrap.append(buildStickyYAxis(massYAxis, tableHeight));
canvas.append(splitBar, massWrap);
}
body.replaceChildren(canvas);
body.scrollLeft = scrollLeft;
@@ -0,0 +1,78 @@
/* =============================================================================
* B06_wf3_ProfileCross_Section_Store.ts
* 종횡단 상세(SectionDetailResponse)의 **프론트 공유 캐시** — B05·B06이 같은 객체를 본다.
*
* ── 왜 필요한가 (2026-08-03 사용자 확정) ─────────────────────────────
* B05(노선·계획선)와 B06(종횡단 설계)은 같은 영구저장소 데이터의 두 창이다. 페이지마다
* 따로 fetch해 제각각 들고 있으면, B06에서 지반유형·암 경계를 고친 뒤 B05로 넘어갔을 때
* 횡단 기준 유토곡선이 옛 값으로 그려진다(사용자가 실제로 겪은 문제). 여기서 한 번 받아
* **같은 객체 참조**를 두 페이지에 주면, B06이 측점 설계를 제자리 갱신하는 순간 B05가
* 다음 그리기에서 그대로 본다 — 별도 동기화 코드가 필요 없다.
*
* ── 수명 규칙 ─────────────────────────────────────────────────────
* 키는 `projectId:routeId`. SPA 모듈 싱글턴이라 페이지를 오가도 살아 있고, 새로고침이면
* 사라져 영구저장소에서 다시 받는다(영구저장소가 항상 정본). 서버가 파일을 통째로 다시
* 쓰는 조작(재생성·계획선 편집 저장·확정)은 그 응답/재조회로 `replace`·`invalidate`한다.
* ========================================================================== */
import type { SectionDetailResponse } from "./B06_wf3_ProfileCross_Api_Fetch";
import { fetchSectionDetail } from "./B06_wf3_ProfileCross_Api_Fetch";
const cache = new Map<string, SectionDetailResponse>();
const pending = new Map<string, Promise<SectionDetailResponse>>();
function keyOf(projectId: string, routeId: number): string {
return `${projectId}:${routeId}`;
}
/**
* 상세를 가져온다 — 캐시가 있으면 **같은 객체**를 즉시 돌려주고, 없으면 한 번만 fetch한다
* (동시 호출은 같은 Promise를 공유). `force`면 캐시를 버리고 다시 받는다.
*/
export async function loadSectionDetail(
projectId: string,
routeId: number,
options?: { force?: boolean },
): Promise<SectionDetailResponse> {
const key = keyOf(projectId, routeId);
if (!options?.force) {
const cached = cache.get(key);
if (cached) return cached;
const inFlight = pending.get(key);
if (inFlight) return inFlight;
}
const request = fetchSectionDetail(projectId, routeId)
.then((detail) => {
cache.set(key, detail);
return detail;
})
.finally(() => {
pending.delete(key);
});
pending.set(key, request);
return request;
}
/** 서버가 새 상세를 통째로 돌려준 조작(재생성 등) 뒤 캐시를 그 값으로 바꾼다. */
export function replaceSectionDetail(
projectId: string,
routeId: number,
detail: SectionDetailResponse,
): void {
cache.set(keyOf(projectId, routeId), detail);
}
/**
* 캐시를 비운다. routeId를 생략하면 그 프로젝트 전부 —
* 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다.
*/
export function invalidateSectionDetail(projectId: string, routeId?: number): void {
if (routeId !== undefined) {
cache.delete(keyOf(projectId, routeId));
return;
}
const prefix = `${projectId}:`;
for (const key of [...cache.keys()]) {
if (key.startsWith(prefix)) cache.delete(key);
}
}
@@ -22,7 +22,6 @@ import {
saveSections,
type CrossSectionPatch,
fetchSectionContext,
fetchSectionDetail,
getSections,
regenerateSections,
type SectionContextResponse,
@@ -45,6 +44,7 @@ import {
import "./B06_wf3_ProfileCross_UI_Style.css";
import "./B06_wf3_ProfileCross_UI_Style_Cross.css";
import "./B06_wf3_ProfileCross_UI_Style_Cross_Areas.css";
import { loadSectionDetail, replaceSectionDetail } from "./B06_wf3_ProfileCross_Section_Store";
import "@util/common_util_mass_haul.css";
function L(key: keyof typeof ui_locales): string {
@@ -380,6 +380,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
showLoadingOverlay();
try {
sectionDetail = await regenerateSections(projectId, currentRouteId, width);
// 서버가 정본을 통째로 다시 썼다 — 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다.
replaceSectionDetail(projectId, currentRouteId, sectionDetail);
appliedHalfWidth = width;
renderSectionDetail();
showToast(L("B06_Profile_Regenerate_Success"), "success");
@@ -531,7 +533,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
renderMessage(L("B06_Profile_Calculate_In_B05"));
return;
}
sectionDetail = await fetchSectionDetail(projectId, context.route_id);
// 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심).
sectionDetail = await loadSectionDetail(projectId, context.route_id);
// 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정
const summaryData = existing.longitudinal.data as {
options?: {
+11
View File
@@ -282,6 +282,12 @@ export function createMassHaulChart(
minimumWidthPx: number,
onSelectStation: (stationId: string) => void,
haulPlan: HaulPlan | null,
/**
* Y축 눈금을 호출한 쪽에 알려 준다 — 가로 스크롤에도 왼쪽에 **고정되는 축**을 따로
* 그리려면 SVG와 같은 눈금값이 필요하다(B05 종단 패널이 그렇게 쓴다).
* 이 값을 안 쓰는 화면(B06)은 콜백을 넘기지 않으면 된다.
*/
onAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void,
): SVGSVGElement {
const svg = svgElement("svg", {
class: "b06-section__chart b06-masshaul",
@@ -311,9 +317,11 @@ export function createMassHaulChart(
// 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
const axisTicks: Array<{ y: number; label: string }> = [];
for (const ratio of tickRatios) {
const gridY = MASS_PAD_TOP + ratio * plotHeight;
const value = max - ratio * span;
axisTicks.push({ y: gridY, label: formatVolume(value) });
svg.append(
svgElement("line", {
x1: axisX,
@@ -332,6 +340,9 @@ export function createMassHaulChart(
}
const zeroY = y(0);
// 0선은 격자보다 진하게 따로 그리므로 고정 축 눈금에도 함께 실어 준다.
axisTicks.push({ y: zeroY, label: "0" });
onAxis?.({ padLeft: axisX, ticks: axisTicks });
const visible = series.filter((entry) => visibleKeys.has(entry.key));
/** 주어진 가로 구간에서 표시 중인 곡선이 지나는 세로 범위. 말풍선·balloon 배치가 함께 쓴다. */