From 1ee6516dd5598aa8a7e1d2fc9e75bfa0a04f0afc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 3 Aug 2026 20:28:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05/B06):=20=EC=A2=85=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=BA=90=EC=8B=9C=20+=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=84=A0=20=ED=98=B8=20=ED=86=B5=EA=B3=BC=20=EA=B8=B0=ED=95=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 페이지 간 캐시 공유 - 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) --- .../B05_wf2_Route_Engine_Grade_Profile.py | 44 +++++++++-- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 10 ++- .../B05_wf2_Route_UI_Profile_MassHaul.ts | 3 + .../B05_wf2_Route_UI_Profile_Panel.ts | 15 +++- .../B06_wf3_ProfileCross_Section_Store.ts | 78 +++++++++++++++++++ .../B06_wf3_ProfileCross_UI_Page.ts | 7 +- common_util/common_util_mass_haul_view.ts | 11 +++ 7 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_Section_Store.ts diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py index 02e5c0c8..1de46689 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Profile.py @@ -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, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index ed893af0..907dd9b4 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -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 { 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 { try { // 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다. await profilePanel.save(); + // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다. + invalidateSectionDetail(activeProjectId); // 비정규 측점(구조물)이 있으면 확정 시 그 횡단까지 생성하도록 지표 샘플러 입력을 함께 보낸다. await confirmRoute(activeProjectId, { filter_key: latest?.surface_params.source_filter, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts index 1bcb3107..44981851 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts @@ -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( diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 0b005b63..67ca5af8 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Section_Store.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Section_Store.ts new file mode 100644 index 00000000..e3b4147f --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Section_Store.ts @@ -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(); +const pending = new Map>(); + +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 { + 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); + } +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 5af3f0f7..30429e29 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -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 { 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 { 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?: { diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index ffe04228..a64a68cf 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -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 배치가 함께 쓴다. */