Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-10-19 09:29:15 +09:00
20 changed files with 298 additions and 87 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ B05 · B06 한 페이지 뼈대 — 주소 둘(`#/b05-profile` · `#/b06-section
서버 Node 묶음:
- 입구 `logic/design_calc_node.ts`(`build:design-calc` · 계산 등록부 `logic/design_calc_registry.ts` 의 `node:` 줄 · 시험 `test_472_3_calc_registry.py`) · 끌려감 = `design_pick_match` · `design_pipe_ids` · `design_selection`(corridor · server-calc · b07-extent) · `design_store` · `design_structure_{box,ford,ford_pavement,guard,pipe,road,spec}`(b07-extent · b07-note) · 상위 `A00_Common/b_page_state`
- 입구 `logic/design_calc_node.ts`(`build:design-calc` · 계산 등록부 `logic/design_calc_registry.ts` 의 `node:` 줄 · 시험 `test_472_3_calc_registry.py`) · 끌려감 = `design_pick_match` · `design_pipe_ids` · `design_selection`(b07-extent) · `design_store` · `design_structure_{box,ford,ford_pavement,guard,pipe,road,spec}`(b07-extent · b07-note) · 상위 `A00_Common/b_page_state`
- 끌려가는 파일 모듈 맨 위에 DOM · window · sessionStorage 금지 · 고치면 해당 build 다시 지어 시험
남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도):
+1 -7
View File
@@ -6,7 +6,7 @@
* ① 고름 · 그림 둘 다 구조물 id 가 있으면 id 가 같아야(관 = 세션 id `design_pipe_ids` · 옮겨도 그대로)
* ② 아니면 같은 자리(`PIPE_MATCH_M` — 같은 자리 거리는 이 한 값)
* ③ 고른 부분(부재키)이 있으면 그 부분만 · 부분을 안 골랐으면 그 구조물 그림 모두
* DOM 은 `data-structure-id` · `data-part`(`tagShape`) — 부분 그림(관 · 벽 · 날개벽 · 물넘이 콘크리트)은
* DOM 은 `data-structure-id` · `data-part`(`common_util_pick_tag.tagShape`) — 부분 그림(관 · 벽 · 날개벽 · 물넘이 콘크리트)은
* 조정창 손잡이가 같은 고름으로 칠함(`paintPicked` 는 부분 없는 형상만 · 307 「측점 길은 부분 안 켬」).
* ========================================================================== */
@@ -32,12 +32,6 @@ export function pickLights(selection: PickSelection, tag: PickTag): boolean {
return !selection.partKey || tag.part === selection.partKey;
}
/** 그린 형상에 신원표를 닮(부분 없으면 통째 형상). */
export function tagShape(el: Element, structureId: string | null, part?: string | null): void {
if (structureId) el.setAttribute("data-structure-id", structureId);
if (part) el.setAttribute("data-part", part);
}
/** 카드 안 통째 형상 칠하기 — 고른 구조물 id 와 같은 형상만. 부분 그림(`data-part`) · 관 계열(`pipe-`) ·
* 노면공 포장층은 제 손잡이(조정창 · 포장층 고름)가 칠함 — 측점 길(알약 · 목록 · 측점선)은 부분을 안 켬(307). */
export function paintPicked(root: ParentNode, selection: PickSelection): void {
+3
View File
@@ -10,6 +10,7 @@
* 프로젝트가 없으면(시험 · 도움 파일) 옛 자리 이름.
* ========================================================================== */
import { setPipeTagSource } from "../../common_util/common_util_pick_tag";
import { readState, writeState } from "../b_page_state";
import { isNear, nearestWithin, readSelection, selectDesign } from "./design_selection";
@@ -26,6 +27,8 @@ let active: string | null = null;
/** 지금 프로젝트 — 종단 · 횡단 보기가 열 때. */
export function usePipeIds(projectId: string | null): void {
active = projectId;
// 그림 신원표(common_util_pick_tag)가 이 장부를 씀 — 프로젝트 없으면 옛 자리 이름(pipeIdAt 와 같음)
setPipeTagSource(projectId ? (chainageM) => pipeIdAt(chainageM) : null);
}
const bookOf = (projectId: string): Book =>
+3 -35
View File
@@ -14,7 +14,6 @@
import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans";
import { readState, writeState } from "../b_page_state";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import type {
StructureInstance,
StructureType,
@@ -24,7 +23,6 @@ import {
readAlignment,
toDesignProfile,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common";
@@ -86,11 +84,7 @@ import { createProfileOverlays } from "./design_profile_overlays";
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
import { CROSS_PREVIEW_DEBOUNCE_MS, type DesignProfileContext } from "./design_profile_context";
import { pageRouteId } from "./design_page_route";
import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
import { createProfileFrame } from "./design_profile_frame"; // 판 뼈대 · 종단 CSS(700줄 한계)
export type {
DesignOverlayBoard,
@@ -104,30 +98,8 @@ export type { DesignProfileContext } from "./design_profile_context";
export function createDesignProfile(ctx: DesignProfileContext) {
const { projectId, onSelectStation, onResetAll, callbacks } = ctx;
adoptLegacyProfileKeys();
const root = document.createElement("section");
root.className = "b05-route-profile";
root.dataset.view = ctx.view;
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시).
const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단");
const balanceBar = document.createElement("div");
balanceBar.className = "b05-route-profile__balance";
const body = document.createElement("div");
body.className = "b05-route-profile__body";
const empty = document.createElement("p");
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
// 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성.
// 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
bodyWrap.append(balanceBar, body, progress.root);
const { root, panelHandle, balanceBar, body, empty, progress, bodyWrap, content, trailing } =
createProfileFrame(ctx.view);
// 유토곡선 · 테이블 — 종단 칸 바닥 겹침 판(2026-08-04 · 392-2) · 위 경계 리사이저로 높이 조절.
// 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고
// (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은
@@ -144,8 +116,6 @@ export function createDesignProfile(ctx: DesignProfileContext) {
body,
onChange: subPanelChanged,
});
const content = document.createElement("div");
content.className = "b05-route-profile__content";
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
let minCoverTargets: MinCoverPoint[] = [];
/** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제).
@@ -270,8 +240,6 @@ export function createDesignProfile(ctx: DesignProfileContext) {
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
const elevationHold = createElevationHold(body, () => draw());
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
trailing.append(profileZoom.bar);
/** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */
const withToolLead = (wrap: HTMLElement): HTMLElement => {
+45
View File
@@ -0,0 +1,45 @@
/* =============================================================================
* A00_Common/design/design_profile_frame.ts
* 종단도 한 벌의 **판 뼈대**(DOM 틀) — `design_profile.ts` 에서 갈라냄(700줄 한계 · 472-5).
* 그리기 · 편집 · 상태는 저쪽 몫 · 여기는 칸만 만들어 돌려줌.
* ========================================================================== */
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createProgressCircle } from "@ui/ui_template_progress";
import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
export function createProfileFrame(view: string) {
const root = document.createElement("section");
root.className = "b05-route-profile";
root.dataset.view = view;
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시).
const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단");
const balanceBar = document.createElement("div");
balanceBar.className = "b05-route-profile__balance";
const body = document.createElement("div");
body.className = "b05-route-profile__body";
const empty = document.createElement("p");
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
// 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성.
// 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
bodyWrap.append(balanceBar, body, progress.root);
const content = document.createElement("div");
content.className = "b05-route-profile__content";
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
return { root, panelHandle, balanceBar, body, empty, progress, bodyWrap, content, trailing };
}
+47
View File
@@ -15,6 +15,7 @@ import aiomysql
import numpy as np
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions
from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
AlignmentPolicy,
build_curves,
@@ -23,6 +24,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import (
_annotate_pavement_suggestions,
_load_pipe_points,
_load_route_polyline,
find_cross_file,
@@ -421,6 +423,47 @@ def _follow_moved_stations(
return {"id": profile["id"], **profile["summary"]}
#: 구조물 측점을 새로 떠 갈음할 때 같은 자리 옛 줄에서 들고 가는 칸(472 덧) — 상단측 출처
#: (`user` = 손 지정 · 값도 옛 줄) · 포장 제안 표기(`_annotate_pavement_suggestions` · 새 줄 없음).
STATION_CARRY_KEYS = (
"uphill_side_source",
"pavement_suggested",
"pavement_grade_pct",
"pavement_grade_limit_pct",
)
def _carry_station_fields(
old: dict[float, dict[str, Any]], fresh: dict[float, dict[str, Any]]
) -> list[dict[str, Any]]:
"""같은 자리 옛 줄의 `STATION_CARRY_KEYS` 를 새 줄에 얹음 · 사용자 상단측은 새 자동값을 이김.
반환 = 옛 줄 없는 새 자리(포장 표기를 지금 계획선으로 셀 줄)."""
born = []
for key, station in fresh.items():
before = old.get(key)
if before is None:
born.append(station)
continue
for name in STATION_CARRY_KEYS:
if name in before and name not in station:
station[name] = before[name]
if before.get("uphill_side_source") == "user" and before.get("uphill_side"):
station["uphill_side"] = before["uphill_side"]
return born
def _pavement_for(data: dict[str, Any], stations: list[dict[str, Any]]) -> None:
"""새 자리 측점의 포장 제안 표기 — 종단 생성과 같은 셈 · 기준 = 계획선 `criteria`."""
profiles = data.get("design_profiles") or []
criteria = (profiles[0].get("criteria") if profiles else None) or {}
names = GradeDesignOptions.__dataclass_fields__.keys() - {"warnings"}
try:
options = GradeDesignOptions(**{k: v for k, v in criteria.items() if k in names})
except TypeError:
return # 옛 종단 — 기준 칸이 모자람
_annotate_pavement_suggestions({"design_profiles": profiles, "stations": stations}, options)
def _merge_irregular_into_longitudinal(
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
) -> dict[str, Any] | None:
@@ -451,6 +494,9 @@ def _merge_irregular_into_longitudinal(
if station.get("kind") in ("irregular", "curve")
]
fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
born = _carry_station_fields(
{round(float(s.get("chainage_m", 0.0)), 6): s for s in stations}, fresh
)
kept: dict[float, dict[str, Any]] = {}
left: list[dict[str, Any]] = []
for station in stations:
@@ -470,6 +516,7 @@ def _merge_irregular_into_longitudinal(
data["stations"] = sorted(kept.values(), key=lambda s: float(s.get("chainage_m", 0.0)))
previous = [round(float(station.get("chainage_m", 0.0)), 6) for station in stations]
summary = _follow_moved_stations(data, before, previous)
_pavement_for(data, born) # 계획선을 옮긴 뒤 — 새 자리 경사는 지금 계획선으로
atomic_write_json(path, data)
_relabel_cross_files(path.parent.parent / "cross_sections", left)
return summary
+1 -1
View File
@@ -19,7 +19,7 @@
서버 Node 묶음:
- 입구 `B05_Profile_Corridor_Node.ts`(package.json `build:corridor`) · 끌어감 = `UI_Corridor_*`(Build · Envelope · Frames · Station · Structures) · `UI_IrregularStations` · B06 `UI_Cross_*` 구조물 · A00 `b_page_state` · `design_pick_match` · `design_pipe_ids` · `design_selection`
- 입구 `B05_Profile_Corridor_Node.ts`(package.json `build:corridor`) · 끌어감 = `UI_Corridor_*`(Build · Envelope · Frames · Station · Structures) · B06 `UI_Cross_*` 구조물(화면 · 세션 안 끔 · `test_472_3_calc_registry.py`)
- `build:b07-extent` · `build:b07-note` 가 `Api_Structures` · `Api_Pipes_Draft` · `UI_Structures_Balloon` · `UI_Structures_Define` 를 끌어감
- 묶음이 끄는 TS 를 고치면 그 build 다시 지어 시험 · 그 파일 모듈 맨 위에 DOM · window · sessionStorage 금지
+2 -3
View File
@@ -9,8 +9,7 @@
* 콘크리트 색은 세월교 바닥판 클래스를 그대로 쓴다.
* ========================================================================== */
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import { tagShape } from "../A00_Common/design/design_pick_match";
import { pipeTagId, tagShape } from "../common_util/common_util_pick_tag";
import type { BoxLayout, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
@@ -221,7 +220,7 @@ export function appendBoxOverlay(
event.stopPropagation();
onSelectSide(role);
});
tagShape(hit, box.chainage_m != null ? pipeIdAt(box.chainage_m) : null, role); // 신원표(323-5)
tagShape(hit, box.chainage_m != null ? pipeTagId(box.chainage_m) : null, role); // 신원표(323-5)
hits.set(role, hit);
layer.append(hit);
}
+2 -3
View File
@@ -10,8 +10,7 @@
* 집수정) → ③ 배관 → ④ 유출구측 성토부선(보호공 삭제 — 2026-08-22)
* ========================================================================== */
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import { tagShape } from "../A00_Common/design/design_pick_match";
import { pipeTagId, tagShape } from "../common_util/common_util_pick_tag";
import {
REVET_EMBED_DEPTH_M,
FILL_SLOPE_RATIO_MIN,
@@ -91,7 +90,7 @@ export function appendCulvertOverlay(
): RevetHighlightSetter {
const { culvert, pipe, pipeCorners } = layout;
// 신원표(323-5) — 관 세션 id · 부분 = 조정창 부재키
const ownerId = culvert.chainage_m != null ? pipeIdAt(culvert.chainage_m) : null;
const ownerId = culvert.chainage_m != null ? pipeTagId(culvert.chainage_m) : null;
const diameter = culvert.diameter_m;
const roleLabel = (role: "inlet" | "outlet" | "extra") =>
role === "inlet" ? "유입" : role === "outlet" ? "유출" : "추가(성토부)";
@@ -22,6 +22,7 @@ import {
import { EXTRA_SPAN_DEFAULT } from "./logic/B06_Section_UI_Cross_Culvert_Const";
import type { StructureSpan } from "./logic/B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { LocaleKey } from "@ui/ui_template_locale";
/**
* 기슭막이 4축 조작 제어(2026-08-22 확정) — 벽을 눌러 고르고 조정창에서
* 좌우(x)·상하(d, 성토선 대각)·높이(h)·재질(m)을 만진다. 고른 벽은 여기 담아 두어
@@ -113,8 +114,12 @@ export interface ExtraWallControl {
syncCount: (chainageM: number, built: number, side?: ExtraSide) => void;
}
import { showToast } from "@ui/ui_template_elements";
import { L } from "./B06_Section_UI_Section_Common";
/** 한계에 잘린 조작 알림 — 화면(`Cross_View`)이 토스트를 꽂음 · 서버 Node 묶음은 안 꽂음(조작 중인 벽이
* 없어 부를 일도 없음). 묶음이 화면 코드(토스트 · 글)를 안 끌게 갈라냄(472-2). */
let notice: (key: LocaleKey) => void = () => {};
export function setCulvertNotice(fn: (key: LocaleKey) => void): void {
notice = fn;
}
/**
* 배수관 세트 기하 계산 + 제어 상태 동기화.
@@ -455,7 +460,7 @@ export function computeCardCulvert(
const applied = activeRevet === "inlet" ? layout.revetShift.inlet : layout.revetShift.outlet;
const asked = activeRevet === "inlet" ? requested?.inlet : requested?.outlet;
if (asked?.d != null && applied.d != null && Math.abs(asked.d - applied.d) > 0.05) {
showToast(L("B06_Cross_Revet_Limit_Up"), "info");
notice("B06_Cross_Revet_Limit_Up");
}
}
// 종단경사 반영은 **3D 전용** 스위치다(2026-08-24 사용자 확인). 횡단도는 언제나
@@ -488,7 +493,7 @@ export function computeCardCulvert(
Math.abs(requested.lateralM - applied.lateralM) > 0.05 ||
Math.abs(requested.slopeM - applied.slopeM) > 0.05;
if (moved) {
if (activeRevet === "inlet") showToast(L("B06_Cross_Basin_Limit_Pipe"), "info");
if (activeRevet === "inlet") notice("B06_Cross_Basin_Limit_Pipe");
inletStructure.updateAdjust(section.chainage_m, applied);
}
}
@@ -518,13 +523,10 @@ export function computeCardCulvert(
// 축별로 요청이 잘렸으면 왜 안 됐는지 토스트로 알린다(조작 중인 벽만).
if (activeRevet === role) {
if (held("x") && Math.abs(requested.x - applied.x) > 0.05) {
showToast(
L(
requested.x < applied.x
? "B06_Cross_Revet_Limit_Inward"
: "B06_Cross_Revet_Limit_Outward",
),
"info",
notice(
requested.x < applied.x
? "B06_Cross_Revet_Limit_Inward"
: "B06_Cross_Revet_Limit_Outward",
);
}
if (
@@ -533,9 +535,8 @@ export function computeCardCulvert(
applied.d != null &&
Math.abs(requested.d - applied.d) > 0.05
) {
showToast(
L(requested.d < applied.d ? "B06_Cross_Revet_Limit_Up" : "B06_Cross_Revet_Limit_Down"),
"info",
notice(
requested.d < applied.d ? "B06_Cross_Revet_Limit_Up" : "B06_Cross_Revet_Limit_Down",
);
}
}
+2 -3
View File
@@ -9,8 +9,7 @@
* ④ 계류측 성토부선·절토선. 관 위 성토 채움은 그리지 않는다(2026-08-25 사용자).
* ========================================================================== */
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import { tagShape } from "../A00_Common/design/design_pick_match";
import { pipeTagId, tagShape } from "../common_util/common_util_pick_tag";
import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
import {
appendPipeInfoText,
@@ -56,7 +55,7 @@ export function appendFordOverlay(
onSelectWall?: (key: FordPick) => void,
): FordHighlightSetter {
const { ford } = layout;
const ownerId = ford.chainage_m != null ? pipeIdAt(ford.chainage_m) : null; // 신원표(323-5)
const ownerId = ford.chainage_m != null ? pipeTagId(ford.chainage_m) : null; // 신원표(323-5)
const toXY = (point: OffsetPoint): [number, number] => [
x(point.offset),
toDisplayY(point.elevation),
@@ -14,8 +14,7 @@
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface";
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import { tagShape } from "../A00_Common/design/design_pick_match";
import { pipeTagId, tagShape } from "../common_util/common_util_pick_tag";
import { appendChuteBand, chuteRun } from "./B06_Section_UI_Cross_Wall_Hatch";
/** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */
@@ -119,7 +118,7 @@ export function appendFordPavementOverlay(
.forEach((el) => {
el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement");
// 형상 고름 표식(287-5) — 관 지점 알약 id 와 같은 글
if (typeof spec.chainage_m === "number") tagShape(el, pipeIdAt(spec.chainage_m), "concrete"); // 관 세션 id · 부분(323-5)
if (typeof spec.chainage_m === "number") tagShape(el, pipeTagId(spec.chainage_m), "concrete"); // 관 세션 id · 부분(323-5)
});
// 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) · 성토 쪽 = 성토선 연장(315-2)
const depth = spec.depth_m ?? 0;
@@ -16,7 +16,7 @@
* 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다.
* ========================================================================== */
import { tagShape } from "../A00_Common/design/design_pick_match";
import { tagShape } from "../common_util/common_util_pick_tag";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
FILL_SLOPE_RATIO_MIN,
+5 -1
View File
@@ -46,7 +46,8 @@ import {
} from "./B06_Section_UI_Cross_View_Frame";
import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire";
import { computeCardCulvert, setCulvertNotice } from "./B06_Section_UI_Cross_Culvert_Wire";
import { showToast } from "@ui/ui_template_elements";
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
@@ -87,6 +88,9 @@ import {
effectiveCardHalfWidth,
} from "./B06_Section_UI_Cross_View_Metrics";
// 관 세트 조작이 한계에 잘린 까닭 = 토스트(조작 중인 벽만 · Wire 가 부름).
setCulvertNotice((key) => showToast(L(key), "info"));
/**
* 횡단 카드 요소. 선택 표시와 면적 강조를 **카드를 다시 만들지 않고** 갈아 끼우는 핸들을 단다
* — 카드를 새로 만들면 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화되기 때문이다.
+26
View File
@@ -0,0 +1,26 @@
/* =============================================================================
* common_util/common_util_pick_tag.ts
* 그린 형상의 신원표(323-5) — 구조물 그림(카드 · 서버 Node 묶음)이 쓰는 몫만.
*
* 관 id 는 세션 장부(`design_pipe_ids`)가 정함 — 화면이 열 때(`usePipeIds`) `setPipeTagSource` 로 꽂음.
* 안 꽂혔으면(서버 Node · 시험) 옛 자리 이름 `pipe-{누가거리}` — 장부가 프로젝트를 모를 때와 같은 값.
* 묶음이 세션(`b_page_state`)을 안 끌게 갈라냄(472-2).
* ========================================================================== */
let pipeSource: ((chainageM: number) => string) | null = null;
/** 관 id 원천 — 화면이 세션 장부를 꽂음(null = 옛 자리 이름). */
export function setPipeTagSource(source: ((chainageM: number) => string) | null): void {
pipeSource = source;
}
/** 그 자리 관의 신원표 id. */
export function pipeTagId(chainageM: number): string {
return pipeSource ? pipeSource(chainageM) : `pipe-${chainageM.toFixed(2)}`;
}
/** 그린 형상에 신원표를 닮(부분 없으면 통째 형상). */
export function tagShape(el: Element, structureId: string | null, part?: string | null): void {
if (structureId) el.setAttribute("data-structure-id", structureId);
if (part) el.setAttribute("data-part", part);
}
@@ -258,3 +258,7 @@
1. b05/b06의 조작과 데이터 흐름 프론트에서 계산하는지 백엔드에서 계산하는지를 브레인 참고문서를 하나 만들어서 코더들이 b05/b06을 수정할때 참고하게하자.
2. 재정리를 당연히 해야함. 단계적으로 계획서를 세워서 진행하고 데이터 저장의 관점을 보면 보안과 사용자 편의성을 위해 충돌이남. 그래서 로직들은 프론트와 백엔드에서 동작하게 하는 로직을 동시에 구성하고 최종적으로 하나의관리코드에서 선택적으로 할수 있게 반영하는건 어때? 물런 코드가 하나면 유지보수에 좋을테니 더 좋고 어때?
## 요구 274 (10-19 · 사용자 · 472-7 물음 답)
1. 측구는 변경이 되었으니 사용자가 이전에 선택한 값른 의미가 없어짐.
+1
View File
@@ -23,6 +23,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| 두 보기가 보는 파생값은 페이지 한 벌이 숨은 동안도 셈 · 보기는 그리기만 · 접힘 = 그리기만 건너뜀 | `design_facility_lane.ts:1-12` · `B05_Profile_UI_Profile_Render.ts:211-226` | `test_272_hidden_summary` · `test_271_facility_lane` |
| 되돌리기 걸음 = 사용자 조작으로 값이 실제로 바뀐 때만 · 빈 칸(「""」「{}」「null」) = 칸 없음 · 빈 맵 persist = 칸 지움 | `B05_Profile_UI_Profile_History.ts:50 · 235-236` · `B06_Section_UI_Page_Link_Session.ts:97-105 · 153` | `test_272_history_empty_cells` |
| 짝 없는 새 측점(구조물 · 추가 측점 · 새 관) = 계획선 꼭짓점 안 세움 · 짝 있는 옮긴 측점 = 꼭짓점 따라감 | `common_util_structure_station.ts:356` · `B05_Profile_Router_Confirm.py:306-400` | `test_427_1_follow_seat` · `test_459_route_seat_mirror` |
| 구조물 측점 다시 뜸([저장] 상단측 차례 · 측점 맞춤) = 같은 자리 옛 줄의 상단측 출처(`user` 면 값도) · 포장 제안 표기 들고 감 · 새 자리 포장 표기 = 지금 계획선 | `B05_Profile_Router_Confirm.py:426-465` | `test_472_station_user_fields` |
| [저장] · [확정] = 서버가 입력만으로 정본 다시 셈 · 브라우저 결과(면적 · 표본 · 측점) 받아 적지 않음 | `design_save.ts:58-68` · `B06_Section_Router_Confirm.py:203-215` | `test_b06_save_recompute` |
| 자동저장(디바운스 PUT) 없음 · 화면 잠금 = [저장] · [확정] · [초기화] · 추천 [적용]만 | `design_save.ts:249` | — |
| 측점 열쇠 = `common_util_station_key` 한 벌 · 노선 바뀜 = 관 · 구조물 좌표(x · y) 따라감 · 누가거리 다시 매김 | `common_util_station_key.ts:32` · `common_util_route_follow.ts:291` | `test_344_3_station_key_mirror` · `test_410_follow_xy` |
@@ -31,14 +31,10 @@ const ford = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pa
{ "./logic/B06_Section_UI_Cross_Culvert_Const": {} },
),
"./B06_Section_UI_Cross_Design_Surface": { appendPavementOverlay() {} },
// 관 세션 id · 신원표(323-5) — 프로젝트 없음 = 옛 자리 이름
"../A00_Common/design/design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` },
"../A00_Common/design/design_pick_match": {
tagShape: (el, id, part) => {
if (id) el.setAttribute("data-structure-id", id);
if (part) el.setAttribute("data-part", part);
},
},
// 관 신원표(323-5 · 472-2 에서 common_util 로) — 진짜 · 장부 안 꽂음 = 옛 자리 이름
"../common_util/common_util_pick_tag": loadTs(
path.join(ROOT, "common_util", "common_util_pick_tag.ts"),
),
});
// 286-3 도수로 · 돌붙임 길이 — 유출(좌 · 우가 높음) 성토사면 1:1.5(5 m 내려감 · 사면 길이 √(7.5²+5²) ≈ 9.01)
const sets = loadTs(path.join(ROOT, "common_util", "common_util_culvert_sets.ts"));
+3 -7
View File
@@ -49,15 +49,11 @@ pytestmark = pytest.mark.skipif(
reason="프론트엔드 의존성 미설치",
)
# 화면 · 세션 끌림(목록 1.3) — 472-2 2-B 가 정리(PIPE_DISPLAY_NAME → common_util · Api_Fetch 세션 ·
# 구조물 그림이 고른 것 읽기 · 사진첩).
# 화면 · 세션 끌림(목록 1.3) — 472-2 2-B 가 걷음. 남은 줄 = 헬퍼 정규식 헛잡음
# (Corridor_Structures_Hash 머리 주석 속 「import」부터 잡아 아래 `import type` 을 값으로 셈) —
# 헬퍼를 고치면 빈 목록.
SESSION_PULLS = (
"A00_Common/b_page_state.ts",
"A00_Common/design/design_pick_match.ts",
"A00_Common/design/design_pipe_ids.ts",
"A00_Common/design/design_selection.ts",
"B06_Section/B06_Section_Api_Fetch.ts",
"B06_Section/B06_Section_UI_Section_Common.ts",
"config/config_frontend.ts",
"ui_template/ui_template_elements.ts",
"ui_template/ui_template_elements_base.ts",
@@ -0,0 +1,130 @@
"""PLAN 472 덧 (요구 272) — 구조물 측점을 다시 떠도 사용자 상단측 · 포장 제안 표기가 남음.
증상 — [저장] 상단측 차례(`POST /route/confirm` → `_append_irregular_cross_sections`) · 측점 맞춤
(`ensure_structure_stations`)이 `_merge_irregular_into_longitudinal` 로 구조물 측점 줄을 새 줄로
통째 갈음 → 같은 자리 옛 줄의 `uphill_side_source: user`(상단측 손 지정 · 값은 자동으로 돌아감) ·
`pavement_*`(포장 제안 표기)가 사라짐(검증_sub8 측점 351).
고침 — 같은 자리(누가거리) 옛 줄에서 `STATION_CARRY_KEYS` 를 들고 감 · 사용자 상단측은 새 자동값을
이김 · 옛 줄 없는 새 자리는 포장 표기를 지금 계획선으로 셈.
X6 — 옮긴 측점이 들고 가는 횡단 입력 TS `ROW_CARRY_KEYS` = 파이썬 `CARRY_INPUT_KEYS`(거울).
./venv/Scripts/python.exe -m pytest resources/tester/test_472_station_user_fields.py -q
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Router_Confirm import ( # noqa: E402
_merge_irregular_into_longitudinal,
)
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS # noqa: E402
RELATIVE = "B06_Section/longitudinal/longitudinal.json"
CRITERIA = {
"max_grade_pct": 14.0,
"max_reverse_grade_pct": 5.0,
"min_vertical_radius_m": 100.0,
"min_curve_length_m": 20.0,
"min_tangent_length_m": 20.0,
"vertical_curve_skip_delta_pct": 5.0,
"design_speed_kph": 20,
"terrain_type": "special",
"paved": False,
"main_direction": "auto",
"balance_segment_length_m": None,
"start_elevation_offset_m": 0.0,
"end_elevation_offset_m": 0.0,
"enforce_pipe_clearance": False,
"resolved_main_direction": "ascending",
}
def _row(at: float, kind: str = "regular", **extra) -> dict:
return {"chainage_m": at, "kind": kind, "station_id": f"s{at}", "uphill_side": "left", **extra}
def _pave(grade: float) -> dict:
return {
"pavement_suggested": grade > 14.0,
"pavement_grade_pct": grade,
"pavement_grade_limit_pct": 14.0,
}
def _write(tmp_path: Path) -> Path:
# 계획선 0 → 100 m 고르게 20 % 오르막(포장 상한 14 % 넘음)
samples = [{"chainage_m": float(s), "elevation_m": 0.2 * s} for s in range(0, 101, 5)]
stations = [
_row(0.0, "bp", uphill_side_source="user", **_pave(20.0)),
_row(20.0, uphill_side_source="user", **_pave(20.0)),
_row(
35.0,
"irregular",
structure="관 D1000",
uphill_side="right",
uphill_side_source="user",
**_pave(20.0),
), # fmt: skip
_row(40.0, uphill_side="right", uphill_side_source="user", **_pave(20.0)),
_row(100.0, "ep", uphill_side_source="user", **_pave(20.0)),
]
path = tmp_path / RELATIVE
path.parent.mkdir(parents=True)
longitudinal = {
"stations": stations,
"samples": samples,
"design_profiles": [{"criteria": CRITERIA, "samples": samples}],
}
path.write_text(json.dumps(longitudinal), encoding="utf-8")
return path
def _fresh(at: float, **extra) -> dict:
"""새로 뜬 구조물 측점 — 상단측은 지형 자동값 · 포장 표기 · 출처 칸 없음."""
return {"chainage_m": at, "kind": "irregular", "station_id": f"n{at}", "uphill_side": "left",
"structure": "관 D1000", **extra} # fmt: skip
def _merged(tmp_path: Path, fresh: list[dict]) -> dict[float, dict]:
path = _write(tmp_path)
_merge_irregular_into_longitudinal(tmp_path, RELATIVE, fresh)
rows = json.loads(path.read_text(encoding="utf-8"))["stations"]
return {row["chainage_m"]: row for row in rows}
def test_같은_자리_다시_떠도_사용자_상단측_포장_표기_남음(tmp_path) -> None:
rows = _merged(tmp_path, [_fresh(35.0)])
row = rows[35.0]
assert row["station_id"] == "n35.0" # 기하 · 글은 새 줄
assert row["uphill_side"] == "right" and row["uphill_side_source"] == "user"
assert row["pavement_suggested"] is True and row["pavement_grade_pct"] == 20.0
def test_격자_자리에_구조물이_얹혀도_사용자_상단측_남음(tmp_path) -> None:
rows = _merged(tmp_path, [_fresh(35.0), _fresh(40.0, grid=True)])
assert rows[40.0]["uphill_side"] == "right" and rows[40.0]["uphill_side_source"] == "user"
assert rows[40.0]["pavement_grade_limit_pct"] == 14.0
def test_새_자리는_포장_표기를_지금_계획선으로(tmp_path) -> None:
rows = _merged(tmp_path, [_fresh(55.0)])
assert 35.0 not in rows # 옛 구조물 측점은 걷힘
row = rows[55.0]
assert row["uphill_side"] == "left" and "uphill_side_source" not in row # 새 자리 = 지형 자동
assert row["pavement_suggested"] is True
assert row["pavement_grade_pct"] == 20.0 and row["pavement_grade_limit_pct"] == 14.0
def test_X6_옮긴_측점_횡단_입력_TS_파이썬_한_벌() -> None:
source = (ROOT / "B06_Section/logic/B06_Section_Station_Swap.ts").read_text(encoding="utf-8")
body = re.search(r"export const ROW_CARRY_KEYS = \[(.*?)\] as const;", source, re.S)
assert body is not None
assert tuple(re.findall(r'"([a-z_0-9]+)"', body.group(1))) == CARRY_INPUT_KEYS