feat(B06): 횡단 카드 [상세] 모달 — 구조물 글 · CAD 꼴 그림 · B07 횡단 수량표(138-4)
- 측구 줄 맨 오른쪽 [상세] · 투명 막 · 카드 안 가리는 넓은 쪽 · 카드 다시 그리면 갱신 - 구조물 글 structure_note — USER_TOUCHED_KEYS 두 벌 · CrossSectionPatch · 세션 초안 → [저장] - 표 값 = 서버 cross-quantity-table 한 길(B07 _quantity_table 을 캐시 설계로) · 칸 배치 B07 그대로 - test_138_cross_detail Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
@@ -111,6 +111,8 @@ export const STATE_REGISTRY = {
|
||||
},
|
||||
|
||||
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
|
||||
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
|
||||
"structure-notes": { bucket: "draft", scope: "route" },
|
||||
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
|
||||
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
|
||||
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
|
||||
|
||||
@@ -285,3 +285,29 @@ export async function previewCrossDesigns(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** B07 횡단 수량표 한 장(138-4 · 서버 `cross_quantity_sheet`) — 값 · 글 · 칸 배치. */
|
||||
export interface CrossQuantitySheet {
|
||||
quantity_table: Record<string, number | null>;
|
||||
/** B07 표에 찍히는 글 그대로(값 없음 = 「-」). */
|
||||
text: Record<string, string>;
|
||||
layout: {
|
||||
header: Array<[string, string]>;
|
||||
left: Array<[string | null, string | null, string | null]>;
|
||||
middle: Array<[string | null, string, string]>;
|
||||
right: Array<[string | null, string | null]>;
|
||||
};
|
||||
}
|
||||
|
||||
/** 캐시 설계로 B07 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */
|
||||
export async function fetchCrossQuantityTable(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
chainageM: number,
|
||||
design: CrossDesign | null | undefined,
|
||||
): Promise<CrossQuantitySheet> {
|
||||
return requestJson<CrossQuantitySheet>(
|
||||
`/projects/${projectId}/sections/${routeId}/cross-quantity-table`,
|
||||
{ method: "POST", body: JSON.stringify({ chainage_m: chainageM, design: design ?? null }) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -464,6 +464,8 @@ export interface CrossDesign {
|
||||
pavement_thickness_m?: number;
|
||||
/** B05 법정 경사 분석의 포장 제안 여부(사용자 토글과 무관하게 유지). */
|
||||
pavement_suggested?: boolean;
|
||||
/** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */
|
||||
structure_note?: string;
|
||||
/** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */
|
||||
road_edges: {
|
||||
left: { offset_m: number; elevation_m: number };
|
||||
@@ -605,4 +607,6 @@ export interface CrossSectionPatch {
|
||||
fill_area_m2?: number;
|
||||
cut_soil_area_m2?: number;
|
||||
cut_rock_area_m2?: number;
|
||||
/** 측점 구조물 글(138-4) — 빈 글 = 지움. */
|
||||
structure_note?: string;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ export const USER_TOUCHED_KEYS = [
|
||||
"berm",
|
||||
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
|
||||
"cut_slope_ratio_user",
|
||||
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
|
||||
"structure_note",
|
||||
] as const;
|
||||
|
||||
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""B06 횡단 [상세] 모달의 **B07 횡단 수량표 값** 창구(138-4).
|
||||
|
||||
값은 B07 횡단도가 쓰는 `_quantity_table` 한 벌을 **브라우저 캐시 설계**로 돌린 것이다 —
|
||||
화면이 TS 로 다시 짜지 않는다(CLAUDE.md 5장 ③ 서버 단독). 칸 배치도 B07 표 정의를 그대로
|
||||
실어 보내 화면은 그리기만 한다. **표시 전용** — 정본을 쓰지 않는다. 모달을 열 때 부른다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B06_Section.B06_Section_Repository import get_longitudinal_section
|
||||
from B06_Section.B06_Section_Router_Design import read_cross_design_inputs
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _format
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
_CROSS_HEADER_KEYS,
|
||||
_CROSS_LEFT_ROWS,
|
||||
_CROSS_MIDDLE_ROWS,
|
||||
_CROSS_RIGHT_ROWS,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
||||
|
||||
|
||||
class CrossQuantityTableRequest(BaseModel):
|
||||
chainage_m: float = Field(..., ge=0)
|
||||
# 브라우저 캐시의 그 측점 설계(조작 반영본) — 없으면 원본 칸만.
|
||||
design: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""B07 횡단 수량표 한 장 — 값(숫자) · 글(B07 표와 같은 꼴) · 칸 배치."""
|
||||
table = _quantity_table(source, design)
|
||||
text = {
|
||||
key: _format(value) if isinstance(value, (int, float)) else "-"
|
||||
for key, value in table.items()
|
||||
}
|
||||
return {
|
||||
"quantity_table": table,
|
||||
"text": text,
|
||||
"layout": {
|
||||
"header": [list(row) for row in _CROSS_HEADER_KEYS],
|
||||
"left": [list(row) for row in _CROSS_LEFT_ROWS],
|
||||
"middle": [list(row) for row in _CROSS_MIDDLE_ROWS],
|
||||
"right": [list(row) for row in _CROSS_RIGHT_ROWS],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/{route_id}/cross-quantity-table", response_model=None)
|
||||
async def cross_quantity_table(
|
||||
project_id: UUID, route_id: int, request: CrossQuantityTableRequest
|
||||
) -> JSONResponse:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
if not longitudinal or not stored_path:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
|
||||
)
|
||||
try:
|
||||
*_, source = await asyncio.to_thread(
|
||||
read_cross_design_inputs,
|
||||
Path(resolve_stored_project_path(stored_path)),
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
request.chainage_m,
|
||||
)
|
||||
sheet = cross_quantity_sheet(source, request.design)
|
||||
except (FileNotFoundError, ValueError) as error:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"횡단 수량표 계산 실패: project_id=%s chainage=%s", project_id, request.chainage_m
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "횡단 수량표를 만들지 못했습니다."},
|
||||
)
|
||||
return JSONResponse(content={"status": "success", **sheet})
|
||||
@@ -148,6 +148,8 @@ USER_TOUCHED_KEYS = (
|
||||
# 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다
|
||||
# (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」).
|
||||
"cut_slope_ratio_user",
|
||||
# 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
|
||||
"structure_note",
|
||||
)
|
||||
|
||||
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
|
||||
|
||||
@@ -192,6 +192,8 @@ class CrossSectionPatch(BaseModel):
|
||||
box_length_m: float | None = Field(default=None, ge=0)
|
||||
# B08 자재 집계표 세월교 관 한 련 길이(131) — 그린 관 길이 1 m 올림(m) · 같은 길.
|
||||
ford_pipe_length_m: float | None = Field(default=None, ge=0)
|
||||
# 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움.
|
||||
structure_note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class SectionConfirmRequest(BaseModel):
|
||||
|
||||
@@ -27,6 +27,7 @@ export {
|
||||
appendRockBoundaryOverlay,
|
||||
} from "./B06_Section_UI_Cross_Design_Surface";
|
||||
export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line";
|
||||
import { openCrossDetail, refreshCrossDetail } from "./B06_Section_UI_Cross_Detail";
|
||||
|
||||
/** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */
|
||||
interface ReflowCard {
|
||||
@@ -455,7 +456,14 @@ export function buildDesignControls(
|
||||
const morePanel = document.createElement("div");
|
||||
morePanel.className = "b06-design__more-panel";
|
||||
more.append(moreSummary, morePanel);
|
||||
bar.append(slopeDirSeg, ...moveable, more);
|
||||
// [상세](138-4) — 맨 오른쪽 · 넘침 정리(moveable) 밖이라 늘 보임
|
||||
const detail = document.createElement("button");
|
||||
detail.type = "button";
|
||||
detail.className = "b06-design__btn b06-design__detail";
|
||||
detail.textContent = L("B06_Design_Detail");
|
||||
detail.addEventListener("click", () => openCrossDetail(section));
|
||||
bar.append(slopeDirSeg, ...moveable, more, detail);
|
||||
refreshCrossDetail(section);
|
||||
|
||||
// 넘침 정리는 **세 토막**으로 나눈다 — 쓰기(reset) → 읽기(measure) → 쓰기(apply).
|
||||
// 카드 안에서 한 장씩 하면 쓰기 뒤 읽기가 카드 수만큼 반복돼 브라우저가 레이아웃을
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail.ts
|
||||
* 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4).
|
||||
*
|
||||
* 맨 위 = 측점 구조물 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄) ·
|
||||
* 가운데 = 카드 그림 복제(어두운 바탕 · CAD 꼴) · 아래 = B07 횡단 수량표(값 · 칸 배치 모두
|
||||
* 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름).
|
||||
* 막은 투명(누름 통과) · 자리는 그 카드를 안 가리는 넓은 쪽(좌/우 → 위/아래).
|
||||
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B06_Section_UI_Style_Cross_Detail.css";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch";
|
||||
import type {
|
||||
CrossQuantitySheet,
|
||||
CrossSection,
|
||||
SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common";
|
||||
|
||||
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
|
||||
export interface CrossDetailHost {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
}
|
||||
|
||||
let host: CrossDetailHost | null = null;
|
||||
export function setCrossDetailHost(next: CrossDetailHost): void {
|
||||
host = next;
|
||||
}
|
||||
|
||||
/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */
|
||||
|
||||
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
|
||||
|
||||
/** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */
|
||||
export function readStructureNotes(projectId: string, routeId: number): Map<number, string> {
|
||||
const raw = readState<Record<string, string>>("structure-notes", projectId, routeId) ?? {};
|
||||
return new Map(Object.entries(raw).map(([key, text]) => [Number(key), String(text)]));
|
||||
}
|
||||
|
||||
function noteOf(section: CrossSection): string {
|
||||
const routeId = host?.routeId();
|
||||
if (host && routeId !== null && routeId !== undefined) {
|
||||
const draft = readState<Record<string, string>>("structure-notes", host.projectId, routeId);
|
||||
const text = draft?.[noteKey(section.chainage_m)];
|
||||
if (text !== undefined) return text;
|
||||
}
|
||||
return section.design?.structure_note ?? "";
|
||||
}
|
||||
|
||||
function writeNote(section: CrossSection, text: string): void {
|
||||
// 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음
|
||||
if (section.design) section.design.structure_note = text;
|
||||
const routeId = host?.routeId();
|
||||
if (!host || routeId === null || routeId === undefined) return;
|
||||
const all = readState<Record<string, string>>("structure-notes", host.projectId, routeId) ?? {};
|
||||
all[noteKey(section.chainage_m)] = text;
|
||||
writeState("structure-notes", all, host.projectId, routeId);
|
||||
}
|
||||
|
||||
/* ── 자리 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const GAP_PX = 12;
|
||||
const MIN_WIDTH_PX = 360;
|
||||
const MAX_WIDTH_PX = 720;
|
||||
|
||||
/** 카드를 안 가리는 넓은 쪽에 붙임 — 좌우가 좁으면 위아래. */
|
||||
function place(dialog: HTMLElement, card: HTMLElement | null): void {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const rect = card?.getBoundingClientRect();
|
||||
const style = dialog.style;
|
||||
style.position = "fixed";
|
||||
style.margin = "0";
|
||||
if (!rect) {
|
||||
style.width = `${Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)}px`;
|
||||
style.left = `${(vw - Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)) / 2}px`;
|
||||
style.top = `${GAP_PX}px`;
|
||||
style.maxHeight = `${vh - GAP_PX * 2}px`;
|
||||
return;
|
||||
}
|
||||
const left = rect.left;
|
||||
const right = vw - rect.right;
|
||||
if (Math.max(left, right) >= MIN_WIDTH_PX + GAP_PX * 2) {
|
||||
const width = Math.min(MAX_WIDTH_PX, Math.max(left, right) - GAP_PX * 2);
|
||||
const top = Math.min(Math.max(rect.top, GAP_PX), vh - GAP_PX - 240);
|
||||
style.width = `${width}px`;
|
||||
style.left = `${right >= left ? rect.right + GAP_PX : rect.left - GAP_PX - width}px`;
|
||||
style.top = `${Math.max(top, GAP_PX)}px`;
|
||||
style.maxHeight = `${vh - Math.max(top, GAP_PX) - GAP_PX}px`;
|
||||
return;
|
||||
}
|
||||
const width = Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2);
|
||||
style.width = `${width}px`;
|
||||
style.left = `${Math.min(Math.max(rect.left, GAP_PX), vw - GAP_PX - width)}px`;
|
||||
const below = vh - rect.bottom;
|
||||
if (below >= rect.top) {
|
||||
style.top = `${rect.bottom + GAP_PX}px`;
|
||||
style.maxHeight = `${Math.max(below - GAP_PX * 2, 160)}px`;
|
||||
} else {
|
||||
style.top = `${GAP_PX}px`;
|
||||
style.maxHeight = `${Math.max(rect.top - GAP_PX * 2, 160)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */
|
||||
|
||||
function cell(tag: "th" | "td", text: string, span?: { col?: number; row?: number }): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
element.textContent = text;
|
||||
if (span?.col) element.setAttribute("colspan", String(span.col));
|
||||
if (span?.row) element.setAttribute("rowspan", String(span.row));
|
||||
return element;
|
||||
}
|
||||
|
||||
/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우). */
|
||||
export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement {
|
||||
const { layout, text } = sheet;
|
||||
const value = (key: string | null): string => (key ? (text[key] ?? "-") : "");
|
||||
const table = document.createElement("table");
|
||||
table.className = "b06-detail__qtable";
|
||||
const titleRow = table.insertRow();
|
||||
titleRow.append(cell("th", title, { col: 8 }));
|
||||
const headRow = table.insertRow();
|
||||
for (const [label, key] of layout.header)
|
||||
headRow.append(cell("th", label), cell("td", value(key)));
|
||||
layout.left.forEach(([groupL, subL, keyL], index) => {
|
||||
const row = table.insertRow();
|
||||
if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }));
|
||||
else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }));
|
||||
if (subL !== null) row.append(cell("th", subL));
|
||||
row.append(cell("td", value(keyL)));
|
||||
const [groupM, subM, keyM] = layout.middle[index];
|
||||
if (groupM !== null) row.append(cell("th", groupM, { row: 2 }));
|
||||
row.append(cell("th", subM), cell("td", value(keyM)));
|
||||
const [labelR, keyR] = layout.right[index];
|
||||
row.append(cell("th", labelR ?? ""), cell("td", value(keyR)));
|
||||
});
|
||||
return table;
|
||||
}
|
||||
|
||||
/* ── 모달 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
interface OpenDetail {
|
||||
stationId: string;
|
||||
section: CrossSection;
|
||||
paint: () => void;
|
||||
loadTable: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
let current: OpenDetail | null = null;
|
||||
let tableTimer = 0;
|
||||
|
||||
const cardOf = (section: CrossSection): HTMLElement | null =>
|
||||
document.getElementById(`cross-${section.station_id}`);
|
||||
|
||||
function titleOf(section: CrossSection): string {
|
||||
const stations = host?.detail()?.longitudinal.stations ?? [];
|
||||
return stationLabel(section.chainage_m, inferStationInterval(stations));
|
||||
}
|
||||
|
||||
/** [상세] — 이미 열린 것은 닫고 이 측점으로 엶. */
|
||||
export function openCrossDetail(section: CrossSection): void {
|
||||
current?.close();
|
||||
const title = titleOf(section);
|
||||
let tableSeq = 0;
|
||||
openModal({
|
||||
title: `${title} 상세`,
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "b06-detail",
|
||||
backdropClass: "b06-detail__backdrop",
|
||||
mount(body, close) {
|
||||
const dialog = body.parentElement as HTMLElement;
|
||||
const note = document.createElement("textarea");
|
||||
note.className = "ui-input b06-detail__note";
|
||||
note.rows = 3;
|
||||
note.placeholder = "구조물 메모 — 이 측점에 남길 글";
|
||||
note.value = noteOf(section);
|
||||
note.addEventListener("input", () => writeNote(state.section, note.value));
|
||||
const cad = document.createElement("div");
|
||||
cad.className = "b06-detail__cad";
|
||||
const tableBox = document.createElement("div");
|
||||
tableBox.className = "b06-detail__table";
|
||||
body.append(note, cad, tableBox);
|
||||
|
||||
const state: OpenDetail = {
|
||||
stationId: section.station_id,
|
||||
section,
|
||||
paint() {
|
||||
const card = cardOf(state.section);
|
||||
const svg = card?.querySelector("svg.b06-section__chart");
|
||||
cad.replaceChildren(svg ? svg.cloneNode(true) : document.createTextNode("그림 없음"));
|
||||
place(dialog, card);
|
||||
},
|
||||
loadTable() {
|
||||
const projectId = host?.projectId;
|
||||
const routeId = host?.routeId();
|
||||
if (!projectId || routeId === null || routeId === undefined) {
|
||||
tableBox.textContent = "노선 정보 없음";
|
||||
return;
|
||||
}
|
||||
const seq = ++tableSeq;
|
||||
tableBox.classList.add("is-loading");
|
||||
fetchCrossQuantityTable(
|
||||
projectId,
|
||||
routeId,
|
||||
state.section.chainage_m,
|
||||
state.section.design,
|
||||
)
|
||||
.then((sheet) => {
|
||||
if (seq !== tableSeq || current !== state) return;
|
||||
tableBox.replaceChildren(buildQuantityTable(sheet, title));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (seq !== tableSeq || current !== state) return;
|
||||
tableBox.textContent = `수량표를 못 받음 — ${error instanceof Error ? error.message : error}`;
|
||||
})
|
||||
.finally(() => seq === tableSeq && tableBox.classList.remove("is-loading"));
|
||||
},
|
||||
close() {
|
||||
forget();
|
||||
close();
|
||||
},
|
||||
};
|
||||
current = state;
|
||||
tableBox.textContent = "수량표 불러오는 중…";
|
||||
state.paint();
|
||||
state.loadTable();
|
||||
},
|
||||
// 닫기 단추 · Esc 도 같은 정리를 거침
|
||||
beforeClose: () => {
|
||||
forget();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 열린 모달 기억을 지움(닫힐 때). */
|
||||
function forget(): void {
|
||||
window.clearTimeout(tableTimer);
|
||||
current = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드를 다시 그릴 때마다 부름(`buildDesignControls`) — 그 측점 모달이 열려 있으면 새 카드로
|
||||
* 그림을 다시 뜨고 표는 조작이 멈춘 뒤 한 번 다시 받음(최신 응답만).
|
||||
*/
|
||||
export function refreshCrossDetail(section: CrossSection): void {
|
||||
const open = current;
|
||||
if (!open || open.stationId !== section.station_id) return;
|
||||
open.section = section;
|
||||
requestAnimationFrame(() => current === open && open.paint());
|
||||
window.clearTimeout(tableTimer);
|
||||
tableTimer = window.setTimeout(() => current === open && open.loadTable(), 400);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { L } from "./B06_Section_UI_Page_Common";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
|
||||
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail";
|
||||
|
||||
export interface DesignSyncContext {
|
||||
projectId: string | null;
|
||||
@@ -42,6 +43,8 @@ export interface DesignSyncController {
|
||||
|
||||
export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
const { projectId } = ctx;
|
||||
// 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세
|
||||
if (projectId) setCrossDetailHost({ projectId, routeId: ctx.routeId, detail: ctx.detail });
|
||||
|
||||
/**
|
||||
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
structureAreaRows,
|
||||
} from "./B06_Section_Structure_Layouts";
|
||||
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
||||
import { readStructureNotes } from "./B06_Section_UI_Cross_Detail";
|
||||
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
|
||||
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||||
@@ -386,6 +387,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
// 자동으로 되돌아가지 않으므로(켬/끔 둘 뿐) 지금은 손해가 없다(2026-09-09).
|
||||
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
|
||||
});
|
||||
// 측점 구조물 글(138-4 · [상세] 모달) — 세션 초안 그대로(빈 글 = 지움)
|
||||
const routeId = ctx.routeId();
|
||||
if (ctx.projectId && routeId !== null)
|
||||
readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => {
|
||||
patchFor(chainage).structure_note = note;
|
||||
});
|
||||
if (detail) {
|
||||
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다
|
||||
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Style_Cross_Detail.css
|
||||
* 횡단 카드 [상세] 단추 · 모달(138-4) — 투명 막(누름 통과) · 글 칸 · CAD 꼴 그림 · B07 수량표.
|
||||
* ========================================================================== */
|
||||
|
||||
/* 측구 줄 맨 오른쪽 — ⋯ 가 보이면 그 옆, 숨으면 혼자 오른쪽 끝 */
|
||||
.b06-design__detail {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.b06-design__more:not([hidden]) ~ .b06-design__detail {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* 막은 투명 · 누름은 아래 화면으로 — 모달 몸만 받음 */
|
||||
.ui-modal__backdrop.b06-detail__backdrop {
|
||||
background-color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b06-detail__backdrop > .ui-modal.b06-detail {
|
||||
pointer-events: auto;
|
||||
overflow: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.b06-detail__note {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* 가벼운 CAD 꼴 — 어두운 바탕 · 카드 그림 복제에 다크 색 토큰을 덮어씀 */
|
||||
.b06-detail__cad {
|
||||
--color-bg: #111111;
|
||||
--color-surface: #111111;
|
||||
--color-surface-raised: #1a1a1a;
|
||||
--color-border: #3a3a3a;
|
||||
--color-text: #f2f2f2;
|
||||
--color-text-body: #e6e6e6;
|
||||
--color-text-secondary: #b8b8b8;
|
||||
--color-text-muted: #8b8b8b;
|
||||
--color-primary: #c9a8ff;
|
||||
--color-warning: #ffd34d;
|
||||
--color-danger: #ff5f57;
|
||||
--color-success: #4cd37b;
|
||||
border-radius: var(--radius-cards);
|
||||
background: #111111;
|
||||
color: #f2f2f2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b06-detail__cad > svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* B07 횡단 수량 산출표 — 칸 배치 그대로 */
|
||||
.b06-detail__table.is-loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.b06-detail__qtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.b06-detail__qtable th,
|
||||
.b06-detail__qtable td {
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b06-detail__qtable th {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b06-detail__qtable tr:first-child > th {
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ from B06_Section.B06_Section_Router_Confirm import (
|
||||
from B06_Section.B06_Section_Router_HaulPlan import (
|
||||
router as b06_section_haul_plan_router,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cross_table_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router
|
||||
@@ -645,6 +646,7 @@ app.include_router(b06_section_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_stations_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_cross_table_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_design_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_frame_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_plan_kinds_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B06 횡단 [상세] 모달(PLAN 138-4) — 구조물 글 새 키 저장 왕복 · B07 수량표 값 POST 한 길.
|
||||
|
||||
- 글(`structure_note`): 브라우저 세션 초안 → [저장] patch → 서버 스키마 → 정본 병합 →
|
||||
재계산(포장 · 세월교 · 사토장)에서도 남음.
|
||||
- 표: `cross-quantity-table` 이 B07 `_quantity_table` 을 캐시 설계로 돌린 값과 칸 배치를 줌.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_138_cross_detail.py -q
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from B06_Section import B06_Section_Router_CrossTable as table_mod # noqa: E402
|
||||
from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import QUANTITY_VALUE_KEYS # noqa: E402
|
||||
|
||||
# ── 글 저장 왕복 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_글은_patch_스키마를_지나_정본_병합_값으로_남는다():
|
||||
patch = CrossSectionPatch(chainage_m=40.0, structure_note="L형 측구 · 집수정 보강")
|
||||
dumped = patch.model_dump()
|
||||
# 확정 · 저장 경로와 같은 걸름(`_apply_section_edits`) — 최상위 None 만 걷음
|
||||
merged = {k: v for k, v in dumped.items() if k != "chainage_m" and v is not None}
|
||||
assert merged == {"structure_note": "L형 측구 · 집수정 보강"}
|
||||
# 빈 글 = 지움 — 걸러지지 않고 실림
|
||||
empty = CrossSectionPatch(chainage_m=40.0, structure_note="").model_dump()
|
||||
assert empty["structure_note"] == ""
|
||||
|
||||
|
||||
def test_글은_재계산에서도_남는다(monkeypatch):
|
||||
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 100.0)])
|
||||
monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: [])
|
||||
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
|
||||
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
|
||||
monkeypatch.setattr(
|
||||
design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]}
|
||||
)
|
||||
sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note": "메모"}}]
|
||||
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1
|
||||
assert sections[0]["design"]["structure_note"] == "메모"
|
||||
|
||||
|
||||
def test_브라우저가_글을_저장_patch_에_싣는다():
|
||||
persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "readStructureNotes(" in persist and ".structure_note = note" in persist
|
||||
state = (PROJECT_ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
|
||||
assert '"structure-notes": { bucket: "draft", scope: "route" }' in state
|
||||
|
||||
|
||||
# ── 수량표 값 ────────────────────────────────────────────────────────────
|
||||
|
||||
SOURCE = {"chainage_m": 40.0, "center_z": 101.5, "samples": []}
|
||||
DESIGN = {
|
||||
"design_elevation_m": 100.25,
|
||||
"cut_soil_area_m2": 1.234,
|
||||
"cut_rock_area_m2": 0.5,
|
||||
"fill_area_m2": 2.0,
|
||||
}
|
||||
|
||||
|
||||
def test_표_값은_B07_수량표_한_벌():
|
||||
sheet = table_mod.cross_quantity_sheet(SOURCE, DESIGN)
|
||||
table = sheet["quantity_table"]
|
||||
assert set(QUANTITY_VALUE_KEYS) <= set(table)
|
||||
assert table["ground"] == 101.5 and table["planned"] == 100.25
|
||||
assert round(table["cut"], 6) == 1.25 and table["fill"] == 0.0
|
||||
assert table["cut_soil"] == 1.234 and table["embankment"] == 2.0
|
||||
# 글은 B07 표에 찍히는 꼴 그대로 · 값 없음 = 「-」
|
||||
assert sheet["text"]["cut_soil"] == "1.23"
|
||||
assert sheet["text"]["fence"] == "-"
|
||||
layout = sheet["layout"]
|
||||
assert [row[1] for row in layout["header"]] == ["ground", "planned", "fill", "cut"]
|
||||
assert len(layout["left"]) == len(layout["middle"]) == len(layout["right"]) == 6
|
||||
|
||||
|
||||
def test_설계_없으면_원본_칸만():
|
||||
sheet = table_mod.cross_quantity_sheet(SOURCE, None)
|
||||
assert sheet["quantity_table"]["ground"] == 101.5
|
||||
assert sheet["quantity_table"]["planned"] is None
|
||||
assert sheet["text"]["cut_soil"] == "-"
|
||||
|
||||
|
||||
class _Pool:
|
||||
def acquire(self):
|
||||
pool = self
|
||||
|
||||
class _Ctx:
|
||||
async def __aenter__(self):
|
||||
return pool
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
return _Ctx()
|
||||
|
||||
|
||||
def test_POST_한_길이_캐시_설계로_표를_낸다(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def longitudinal(connection, project_id, route_id):
|
||||
return {"longitudinal_file_path": "B05_Profile/route/longitudinal.json"}
|
||||
|
||||
async def storage(connection, project_id):
|
||||
return "proj"
|
||||
|
||||
def read_inputs(root, long_path, chainage):
|
||||
seen["chainage"] = chainage
|
||||
return [], 100.0, False, SOURCE
|
||||
|
||||
monkeypatch.setattr(table_mod, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(table_mod, "get_longitudinal_section", longitudinal)
|
||||
monkeypatch.setattr(table_mod, "get_project_storage_relative_path", storage)
|
||||
monkeypatch.setattr(table_mod, "resolve_stored_project_path", lambda rel: "C:/x")
|
||||
monkeypatch.setattr(table_mod, "read_cross_design_inputs", read_inputs)
|
||||
app = FastAPI()
|
||||
app.include_router(table_mod.router)
|
||||
client = TestClient(app)
|
||||
url = "/api/projects/00000000-0000-0000-0000-000000000001/sections/7/cross-quantity-table"
|
||||
response = client.post(url, json={"chainage_m": 40.0, "design": DESIGN})
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert seen["chainage"] == 40.0
|
||||
assert body["quantity_table"]["cut_soil"] == 1.234
|
||||
assert body["text"]["planned"] == "100.25"
|
||||
|
||||
# 측점 파일이 없으면 404 · 사유 그대로
|
||||
def missing(*args):
|
||||
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
|
||||
|
||||
monkeypatch.setattr(table_mod, "read_cross_design_inputs", missing)
|
||||
response = client.post(url, json={"chainage_m": 41.0, "design": None})
|
||||
assert response.status_code == 404
|
||||
assert "횡단 상세 파일" in response.json()["message"]
|
||||
@@ -474,6 +474,7 @@ export const ui_locales_b2 = {
|
||||
],
|
||||
B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"],
|
||||
B06_Cross_Columns: ["한 줄 카드 수(1~6)", "Cards per row (1-6)"],
|
||||
B06_Design_Detail: ["상세", "Detail"],
|
||||
B06_Design_Paved_Suggested: [
|
||||
"종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)",
|
||||
"Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",
|
||||
|
||||
Reference in New Issue
Block a user