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:
@@ -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,
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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 배치가 함께 쓴다. */
|
||||
|
||||
Reference in New Issue
Block a user