feat(횡단): 좌측 [소단] 패널 — 구간에 놓고 빼기 + 확정 뒤에도 남게 함 (계획서 3-9)
사용자가 소단을 **직접 놓는** 화면을 만들었음. 프로그램이 「붕괴 우려 지역」을 판정하지 않고, 법정 기준 셋 중 무엇도 자동 적용하지 않음(2026-09-07 사용자 확정). 폼은 C군 구간형 구조물과 **같은 꼴** — 기준 측점 + 전·후 거리로 종단 범위를 잡고 폭·간격·기울기를 함께 받음. 기본값 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 2°. 새 모듈 `B06_Section_UI_Berm_Panel.ts` 로 뺐음 — 좌측 페이지가 이미 700줄을 넘어 있어 거기 더 넣지 않고 두 줄만 꽂음(그 파일 분리는 별건). 세션에는 **구간 목록**으로 둠. 측점별로 펴서 저장하면 「어디부터 어디까지 놓았나」를 되짚을 수 없음. 읽는 자리에서 펴고, 서버에는 측점키 dict 로 실어 보냄. **확정 뒤에도 계단이 남게** — 소단 제원을 설계 결과에 되싣고(`berm`), `USER_TOUCHED_KEYS` 에 넣어 재계산이 지우지 않게 함. 서버 재계산은 세션값이 없으면 저장분을 씀(`stored_berm`). ⚠ 소단은 사용자 조작값이면서 **기하 입력**이라 다른 사용자 키와 다름 — 계산 뒤에 키만 베껴 붙이면 설계선·면적은 계단 없이 나오고 `berm` 값만 남아 서로 어긋남. 그래서 단측점 설계 경로는 저장분을 **계산 전에** 읽도록 순서를 바꿨고, 포장 강제·세월교 노면 하강 재계산 경로에도 저장분 소단을 실었음. `extra_spans` 가 그렇게 빠져 있던 자리와 같음. 자체검증 — 새 시험 2건(저장분만으로 같은 설계가 나오는지 · 값이 없거나 손상되면 None). 전체 541 passed · 18 skipped. TS 타입 검사·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@ import { computeCrossDesign } from "@util/common_util_cross_design";
|
|||||||
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
||||||
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
|
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
|
||||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||||
import { readBermSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||||
import type { BermSpec } from "@util/common_util_cross_berm";
|
import type { BermSpec } from "@util/common_util_cross_berm";
|
||||||
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
||||||
import {
|
import {
|
||||||
@@ -81,6 +81,8 @@ export const USER_TOUCHED_KEYS = [
|
|||||||
"extra_spans",
|
"extra_spans",
|
||||||
"revet_link_detached",
|
"revet_link_detached",
|
||||||
"revet_follow_grade",
|
"revet_follow_grade",
|
||||||
|
// 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9).
|
||||||
|
"berm",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
||||||
@@ -133,6 +135,25 @@ function readRockOffsets(projectId: string, routeId: number): Map<number, number
|
|||||||
return offsets;
|
return offsets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 소단 구간을 **측점별 제원**으로 편다 — 서버는 측점키로 받기 때문이다.
|
||||||
|
* 구간 자체는 세션에 그대로 두어 「어디부터 어디까지 놓았나」를 잃지 않는다.
|
||||||
|
*/
|
||||||
|
function bermPayload(
|
||||||
|
projectId: string,
|
||||||
|
routeId: number,
|
||||||
|
detail: SectionDetailResponse,
|
||||||
|
): Record<string, { width_m: number; interval_m: number; slope_deg: number }> {
|
||||||
|
const spans = readBermSpans(projectId, routeId);
|
||||||
|
const out: Record<string, { width_m: number; interval_m: number; slope_deg: number }> = {};
|
||||||
|
if (!spans.length) return out;
|
||||||
|
for (const section of detail.cross_sections) {
|
||||||
|
const spec = bermSpecAt(spans, section.chainage_m);
|
||||||
|
if (spec) out[rockKey(section.chainage_m).toFixed(2)] = spec;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 브라우저 안에서 전 측점을 다시 계산한다(정상 경로).
|
* 브라우저 안에서 전 측점을 다시 계산한다(정상 경로).
|
||||||
*
|
*
|
||||||
@@ -165,9 +186,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
|||||||
const rockDefault = readRockBoundaryDefault(projectId);
|
const rockDefault = readRockBoundaryDefault(projectId);
|
||||||
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
||||||
// 순간 계단이 사라진다(계획서 3-9).
|
// 순간 계단이 사라진다(계획서 3-9).
|
||||||
const berms = readBermSession(projectId, input.routeId);
|
const bermSpans = readBermSpans(projectId, input.routeId);
|
||||||
const bermAt = (chainageM: number): BermSpec | null => {
|
const bermAt = (chainageM: number): BermSpec | null => {
|
||||||
const spec = berms[rockKey(chainageM).toFixed(2)];
|
const spec = bermSpecAt(bermSpans, chainageM);
|
||||||
return spec
|
return spec
|
||||||
? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg }
|
? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg }
|
||||||
: null;
|
: null;
|
||||||
@@ -254,7 +275,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
|
|||||||
{
|
{
|
||||||
fullDesigns: true,
|
fullDesigns: true,
|
||||||
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
|
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
|
||||||
berms: readBermSession(projectId, routeId),
|
berms: bermPayload(projectId, routeId, detail),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (shouldApply && !shouldApply()) return [];
|
if (shouldApply && !shouldApply()) return [];
|
||||||
|
|||||||
@@ -780,6 +780,14 @@ def compute_cross_design(
|
|||||||
if drop > 0:
|
if drop > 0:
|
||||||
# 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다.
|
# 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다.
|
||||||
result["surface_drop_m"] = round(drop, 4)
|
result["surface_drop_m"] = round(drop, 4)
|
||||||
|
if berm is not None:
|
||||||
|
# 소단 제원을 설계에 되싣는다 — 세션이 비어도(확정 뒤·다른 PC) 저장분만으로
|
||||||
|
# 계단이 다시 서야 한다. 암 경계선 오프셋을 echo 하는 것과 같은 까닭이다.
|
||||||
|
result["berm"] = {
|
||||||
|
"width_m": round(float(berm.width_m), 4),
|
||||||
|
"interval_m": round(float(berm.interval_m), 4),
|
||||||
|
"slope_deg": round(float(berm.slope_deg), 4),
|
||||||
|
}
|
||||||
if paved:
|
if paved:
|
||||||
result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4)
|
result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4)
|
||||||
# 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다.
|
# 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from B06_Section.B06_Section_Router_Design import (
|
|||||||
USER_TOUCHED_KEYS,
|
USER_TOUCHED_KEYS,
|
||||||
ford_drop_at,
|
ford_drop_at,
|
||||||
ford_surface_drops,
|
ford_surface_drops,
|
||||||
|
stored_berm,
|
||||||
)
|
)
|
||||||
from B06_Section.B06_Section_Router_Design import (
|
from B06_Section.B06_Section_Router_Design import (
|
||||||
attach_default_designs as _attach_default_designs,
|
attach_default_designs as _attach_default_designs,
|
||||||
@@ -607,6 +608,16 @@ async def compute_cross_section_design(
|
|||||||
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
|
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
|
||||||
)
|
)
|
||||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
|
# 저장분을 **계산 전에** 읽는다 — 소단은 사용자 조작값이면서 **기하 입력**이라,
|
||||||
|
# 나중에 키만 베껴 붙이면 설계선·면적은 계단 없이 나오고 `berm` 값만 남아
|
||||||
|
# 서로 어긋난다(2026-09-07). 값을 나르는 다른 사용자 키와 다른 점이다.
|
||||||
|
stored_designs = await get_cross_section_designs(connection, route_id)
|
||||||
|
stored_design: dict[str, Any] | None = None
|
||||||
|
for record in stored_designs:
|
||||||
|
if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01:
|
||||||
|
candidate = record.get("design")
|
||||||
|
stored_design = candidate if isinstance(candidate, dict) else None
|
||||||
|
break
|
||||||
project_root = Path(resolve_stored_project_path(stored_path))
|
project_root = Path(resolve_stored_project_path(stored_path))
|
||||||
samples, design_elevation, pavement_suggested, cross_record = await asyncio.to_thread(
|
samples, design_elevation, pavement_suggested, cross_record = await asyncio.to_thread(
|
||||||
_read_cross_design_inputs,
|
_read_cross_design_inputs,
|
||||||
@@ -627,27 +638,22 @@ async def compute_cross_section_design(
|
|||||||
two_stage_slope=request.two_stage_slope,
|
two_stage_slope=request.two_stage_slope,
|
||||||
ditch_enabled=request.ditch_enabled,
|
ditch_enabled=request.ditch_enabled,
|
||||||
surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)),
|
surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)),
|
||||||
|
berm=stored_berm(stored_design or {}),
|
||||||
**curve_widening_args(cross_record),
|
**curve_widening_args(cross_record),
|
||||||
)
|
)
|
||||||
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
|
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
|
||||||
design["status"] = "provisional"
|
design["status"] = "provisional"
|
||||||
# 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다.
|
# 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다.
|
||||||
design["pavement_suggested"] = pavement_suggested
|
design["pavement_suggested"] = pavement_suggested
|
||||||
|
# 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이
|
||||||
|
# 지우지 않게 한다(2026-08-06). 목록을 여기 다시 적지 않는다 — 서버의 한 벌은
|
||||||
|
# `B06_Section_Router_Design.USER_TOUCHED_KEYS` 다(2026-09-07). 예전에는 여기에
|
||||||
|
# 따로 적어 두어 `extra_spans` 가 빠져 있었다.
|
||||||
|
if stored_design is not None:
|
||||||
|
for key in USER_TOUCHED_KEYS:
|
||||||
|
if stored_design.get(key) is not None:
|
||||||
|
design[key] = stored_design[key]
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
# 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이
|
|
||||||
# 지우지 않게 한다(2026-08-06).
|
|
||||||
stored_designs = await get_cross_section_designs(connection, route_id)
|
|
||||||
for record in stored_designs:
|
|
||||||
if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01:
|
|
||||||
stored_design = record.get("design")
|
|
||||||
if isinstance(stored_design, dict):
|
|
||||||
# 목록을 여기 다시 적지 않는다 — 서버의 한 벌은
|
|
||||||
# `B06_Section_Router_Design.USER_TOUCHED_KEYS` 다(2026-09-07).
|
|
||||||
# 예전에는 여기에 따로 적어 두어 `extra_spans` 가 빠져 있었다.
|
|
||||||
for key in USER_TOUCHED_KEYS:
|
|
||||||
if stored_design.get(key) is not None:
|
|
||||||
design[key] = stored_design[key]
|
|
||||||
break
|
|
||||||
await connection.begin()
|
await connection.begin()
|
||||||
try:
|
try:
|
||||||
updated = await update_cross_section_design(
|
updated = await update_cross_section_design(
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ USER_TOUCHED_KEYS = (
|
|||||||
"extra_spans",
|
"extra_spans",
|
||||||
"revet_link_detached",
|
"revet_link_detached",
|
||||||
"revet_follow_grade",
|
"revet_follow_grade",
|
||||||
|
# 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9).
|
||||||
|
"berm",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -193,6 +195,7 @@ def enforce_pavement_ranges(
|
|||||||
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
||||||
ditch_enabled=design.get("ditch_enabled"),
|
ditch_enabled=design.get("ditch_enabled"),
|
||||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||||
|
berm=stored_berm(design),
|
||||||
**curve_widening_args(section),
|
**curve_widening_args(section),
|
||||||
)
|
)
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
@@ -244,6 +247,7 @@ def enforce_ford_surface_drops(
|
|||||||
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
||||||
ditch_enabled=design.get("ditch_enabled"),
|
ditch_enabled=design.get("ditch_enabled"),
|
||||||
surface_drop_m=wanted,
|
surface_drop_m=wanted,
|
||||||
|
berm=stored_berm(design),
|
||||||
**curve_widening_args(section),
|
**curve_widening_args(section),
|
||||||
)
|
)
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
@@ -342,6 +346,21 @@ def attach_default_designs(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def stored_berm(stored: dict[str, Any]) -> BermSpec | None:
|
||||||
|
"""저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC)."""
|
||||||
|
spec = stored.get("berm")
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return BermSpec(
|
||||||
|
width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)),
|
||||||
|
interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)),
|
||||||
|
slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def recompute_designs_for_alignment(
|
def recompute_designs_for_alignment(
|
||||||
longitudinal: dict[str, Any],
|
longitudinal: dict[str, Any],
|
||||||
cross_sections: list[dict[str, Any]],
|
cross_sections: list[dict[str, Any]],
|
||||||
@@ -398,7 +417,7 @@ def recompute_designs_for_alignment(
|
|||||||
two_stage_slope=bool(stored.get("two_stage_slope", True)),
|
two_stage_slope=bool(stored.get("two_stage_slope", True)),
|
||||||
ditch_enabled=stored.get("ditch_enabled"),
|
ditch_enabled=stored.get("ditch_enabled"),
|
||||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||||
berm=session_berms.get(key),
|
berm=session_berms.get(key) or stored_berm(stored),
|
||||||
**curve_widening_args(section),
|
**curve_widening_args(section),
|
||||||
)
|
)
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B06_Section_UI_Berm_Panel.ts
|
||||||
|
* 좌측 [소단] 패널 — 사용자가 **구간에 소단을 놓고 빼는** 자리 (계획서 3-9).
|
||||||
|
*
|
||||||
|
* 왜 자동이 아닌가 (2026-09-07 사용자 확정) — 소단 규격이 법령·도로·사방 기준마다 갈려
|
||||||
|
* (별표2 사면길이 2~3m마다 폭 50~100㎝ / KDS 높이 5m마다 폭 1m / 사방 절토고 3~5m마다
|
||||||
|
* 폭 0.5m 이상) 어느 것을 자동 적용해도 다른 설계가 된다. 그래서 **프로그램이 판정하지
|
||||||
|
* 않고 사용자가 놓는다**. 「붕괴 우려 지역」 판정도 하지 않는다.
|
||||||
|
*
|
||||||
|
* 입력 꼴은 **C군 구간형 구조물과 같다** — 기준 측점 + 전·후 거리로 종단 범위를 잡고,
|
||||||
|
* 제원(폭·간격·기울기)을 함께 받는다. 값은 세션 초안(`berm`)에 쌓이고 [저장]·[확정]에서
|
||||||
|
* 정본으로 나간다(CLAUDE.md 5장 데이터 3층).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||||
|
import {
|
||||||
|
BERM_DEFAULT_INTERVAL_M,
|
||||||
|
BERM_DEFAULT_SLOPE_DEG,
|
||||||
|
BERM_DEFAULT_WIDTH_M,
|
||||||
|
} from "@util/common_util_cross_berm";
|
||||||
|
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||||
|
import { formatStation } from "../B05_Profile/B05_Profile_Util_Station";
|
||||||
|
import { buildGroup } from "./B06_Section_UI_Page_Common";
|
||||||
|
import { readBermSpans, writeBermSpans, type BermSpan } from "./B06_Section_UI_Page_Persist";
|
||||||
|
|
||||||
|
/** 기준측점 앞뒤 기본 거리(m) — C군 구간형 폼과 같은 값(길이 10m). */
|
||||||
|
const DEFAULT_BEFORE_M = 5;
|
||||||
|
const DEFAULT_AFTER_M = 5;
|
||||||
|
|
||||||
|
export interface BermPanelDeps {
|
||||||
|
/** 측점간격(m) — 측점 칸이 「3+15」 꼴을 읽고 쓰는 데 쓴다. */
|
||||||
|
getInterval: () => number;
|
||||||
|
/** 지금 대상 — 없으면 패널은 그려지되 저장하지 않는다. */
|
||||||
|
target: () => { projectId: string; routeId: number } | null;
|
||||||
|
/** 목록이 바뀌었을 때 — 전 측점 재계산을 부르는 자리. */
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BermPanel {
|
||||||
|
root: HTMLElement;
|
||||||
|
/** 프로젝트·노선이 정해진 뒤 세션값을 다시 읽어 목록을 그린다. */
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberField(
|
||||||
|
label: string,
|
||||||
|
value: number,
|
||||||
|
step: string,
|
||||||
|
): ReturnType<typeof createInputField> {
|
||||||
|
const field = createInputField({ label, type: "number" });
|
||||||
|
field.input.step = step;
|
||||||
|
field.input.min = "0";
|
||||||
|
field.input.value = String(value);
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBermPanel(deps: BermPanelDeps): BermPanel {
|
||||||
|
const root = buildGroup("소단");
|
||||||
|
|
||||||
|
const anchor = stationFields("기준 측점", () => deps.getInterval());
|
||||||
|
const beforeField = numberField("기준측점 전 (m)", DEFAULT_BEFORE_M, "0.5");
|
||||||
|
const afterField = numberField("기준측점 후 (m)", DEFAULT_AFTER_M, "0.5");
|
||||||
|
const widthField = numberField("폭 (m)", BERM_DEFAULT_WIDTH_M, "0.1");
|
||||||
|
const intervalField = numberField("간격(사면길이) (m)", BERM_DEFAULT_INTERVAL_M, "0.5");
|
||||||
|
const slopeField = numberField("안쪽 기울기 (°)", BERM_DEFAULT_SLOPE_DEG, "0.5");
|
||||||
|
|
||||||
|
const spanRow = document.createElement("div");
|
||||||
|
spanRow.className = "b05-structure__grid";
|
||||||
|
spanRow.append(beforeField.root, afterField.root);
|
||||||
|
|
||||||
|
const specRow = document.createElement("div");
|
||||||
|
specRow.className = "b05-structure__grid";
|
||||||
|
specRow.append(widthField.root, intervalField.root);
|
||||||
|
|
||||||
|
const slopeRow = document.createElement("div");
|
||||||
|
slopeRow.className = "b05-structure__grid";
|
||||||
|
slopeRow.append(slopeField.root);
|
||||||
|
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
list.className = "b05-route__irregular-list";
|
||||||
|
|
||||||
|
let selected = -1;
|
||||||
|
|
||||||
|
const addButton = createButton({ label: "추가", variant: "filled", onClick: () => add() });
|
||||||
|
const removeButton = createButton({ label: "삭제", variant: "ghost", onClick: () => remove() });
|
||||||
|
removeButton.classList.add("is-danger");
|
||||||
|
removeButton.disabled = true;
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "b06-profile__field-row";
|
||||||
|
actions.append(addButton, removeButton);
|
||||||
|
|
||||||
|
root.append(anchor.wrap, spanRow, specRow, slopeRow, actions, list);
|
||||||
|
|
||||||
|
function spans(): BermSpan[] {
|
||||||
|
const target = deps.target();
|
||||||
|
return target ? readBermSpans(target.projectId, target.routeId) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(next: BermSpan[]): void {
|
||||||
|
const target = deps.target();
|
||||||
|
if (!target) return;
|
||||||
|
writeBermSpans(target.projectId, target.routeId, next);
|
||||||
|
render();
|
||||||
|
deps.onChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(field: { input: HTMLInputElement }, fallback: number): number {
|
||||||
|
const parsed = Number(field.input.value);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(): void {
|
||||||
|
const interval = deps.getInterval();
|
||||||
|
const chainage = anchor.read(interval, true);
|
||||||
|
if (chainage === null) {
|
||||||
|
showToast("소단을 놓을 기준 측점을 넣어 주세요.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const width = readNumber(widthField, BERM_DEFAULT_WIDTH_M);
|
||||||
|
const gap = readNumber(intervalField, BERM_DEFAULT_INTERVAL_M);
|
||||||
|
if (width <= 0 || gap <= 0) {
|
||||||
|
showToast("소단 폭과 간격은 0보다 커야 합니다.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const before = readNumber(beforeField, DEFAULT_BEFORE_M);
|
||||||
|
const after = readNumber(afterField, DEFAULT_AFTER_M);
|
||||||
|
save([
|
||||||
|
...spans(),
|
||||||
|
{
|
||||||
|
start_m: Math.max(chainage - before, 0),
|
||||||
|
end_m: chainage + after,
|
||||||
|
width_m: width,
|
||||||
|
interval_m: gap,
|
||||||
|
slope_deg: readNumber(slopeField, BERM_DEFAULT_SLOPE_DEG),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
selected = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(): void {
|
||||||
|
if (selected < 0) return;
|
||||||
|
const next = spans().filter((_span, index) => index !== selected);
|
||||||
|
selected = -1;
|
||||||
|
save(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): void {
|
||||||
|
const interval = deps.getInterval();
|
||||||
|
const current = spans();
|
||||||
|
list.replaceChildren();
|
||||||
|
removeButton.disabled = selected < 0 || selected >= current.length;
|
||||||
|
if (!current.length) {
|
||||||
|
const empty = document.createElement("li");
|
||||||
|
empty.className = "b05-route__irregular-empty";
|
||||||
|
empty.textContent = "놓은 소단이 없습니다.";
|
||||||
|
list.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.forEach((span, index) => {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.className = "b05-route__irregular-item";
|
||||||
|
item.classList.toggle("is-selected", index === selected);
|
||||||
|
const where = document.createElement("strong");
|
||||||
|
where.textContent = `${formatStation(span.start_m, interval)}~${formatStation(span.end_m, interval)}`;
|
||||||
|
const what = document.createElement("span");
|
||||||
|
what.textContent = `폭 ${span.width_m}m · ${span.interval_m}m마다`;
|
||||||
|
item.append(where, what);
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
selected = index === selected ? -1 : index;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
list.append(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
reload(): void {
|
||||||
|
selected = -1;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,10 +4,7 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
|||||||
import { stateKey } from "../A00_Common/b_page_state";
|
import { stateKey } from "../A00_Common/b_page_state";
|
||||||
import { navigateTo } from "../A00_Common/router";
|
import { navigateTo } from "../A00_Common/router";
|
||||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||||
import type {
|
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||||
StructureInstance,
|
|
||||||
StructureType,
|
|
||||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
|
||||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||||
@@ -26,6 +23,7 @@ import {
|
|||||||
} from "./B06_Section_Api_Fetch";
|
} from "./B06_Section_Api_Fetch";
|
||||||
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||||
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
|
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
|
||||||
|
import { createBermPanel } from "./B06_Section_UI_Berm_Panel";
|
||||||
import {
|
import {
|
||||||
confirmCurrentSections,
|
confirmCurrentSections,
|
||||||
createRockBoundaryStore,
|
createRockBoundaryStore,
|
||||||
@@ -144,8 +142,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
|||||||
// 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화).
|
// 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화).
|
||||||
// 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행].
|
// 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행].
|
||||||
let structureMarksSink:
|
let structureMarksSink:
|
||||||
| ((structures: StructureInstance[], types: StructureType[]) => void)
|
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
|
||||||
| null = null;
|
|
||||||
const structuresPanel = createB06StructuresPanel({
|
const structuresPanel = createB06StructuresPanel({
|
||||||
projectId,
|
projectId,
|
||||||
// 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자).
|
// 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자).
|
||||||
@@ -209,7 +206,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
|||||||
const leftForm = document.createElement("div");
|
const leftForm = document.createElement("div");
|
||||||
leftForm.className = "b06-profile__form";
|
leftForm.className = "b06-profile__form";
|
||||||
// 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준.
|
// 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준.
|
||||||
leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock);
|
// 소단은 사용자가 구간에 놓는다(2026-09-07 확정) — 폼은 전용 모듈에 있다(700줄 제한).
|
||||||
|
const bermPanel = createBermPanel({
|
||||||
|
getInterval: () => stationInterval ?? 20,
|
||||||
|
target: () =>
|
||||||
|
projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null,
|
||||||
|
onChange: () => void reconcileStaleDesigns({ force: true }),
|
||||||
|
});
|
||||||
|
leftForm.append(structuresPanel.root, viewGroup, bermPanel.root, standardGroup, actionDock);
|
||||||
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
|
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
|
||||||
attachCollapsible(leftForm);
|
attachCollapsible(leftForm);
|
||||||
|
|
||||||
@@ -719,6 +723,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
|||||||
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
|
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
|
||||||
stationInterval = storedOptions.station_interval_m;
|
stationInterval = storedOptions.station_interval_m;
|
||||||
renderSectionDetail();
|
renderSectionDetail();
|
||||||
|
bermPanel.reload(); // 노선이 정해진 뒤라야 세션에서 소단 목록을 읽을 수 있다.
|
||||||
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
|
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
|
||||||
updateActionState();
|
updateActionState();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
|||||||
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||||
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
|
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||||
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
|
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
|
||||||
import { readState } from "../A00_Common/b_page_state";
|
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||||
import {
|
import {
|
||||||
applyStructureAreaRows,
|
applyStructureAreaRows,
|
||||||
STRUCTURE_AREA_KEYS,
|
STRUCTURE_AREA_KEYS,
|
||||||
@@ -60,17 +60,42 @@ export interface BermSessionSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 세션에 쌓인 소단 제원(측점키 → 제원). 없거나 손상되면 빈 객체.
|
* 사용자가 놓은 소단 **한 구간** — 종단 범위 + 제원.
|
||||||
|
*
|
||||||
|
* 사용자는 측점 하나가 아니라 **구간**에 놓는다(2026-09-07 확정: 「길이 + 기준측점 전·후」).
|
||||||
|
* 그래서 세션에는 구간 목록으로 두고, 측점별 제원은 읽는 자리에서 편다 — 구간을 측점으로
|
||||||
|
* 펴서 저장하면 나중에 「어디부터 어디까지 놓았나」를 되짚을 수 없다.
|
||||||
|
*/
|
||||||
|
export interface BermSpan extends BermSessionSpec {
|
||||||
|
start_m: number;
|
||||||
|
end_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션에 쌓인 소단 구간 목록. 없거나 손상되면 빈 목록.
|
||||||
*
|
*
|
||||||
* 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어
|
* 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어
|
||||||
* 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9).
|
* 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9).
|
||||||
*/
|
*/
|
||||||
export function readBermSession(
|
export function readBermSpans(projectId: string, routeId: number): BermSpan[] {
|
||||||
projectId: string,
|
const stored = readState<BermSpan[]>("berm", projectId, routeId);
|
||||||
routeId: number,
|
return Array.isArray(stored) ? stored : [];
|
||||||
): Record<string, BermSessionSpec> {
|
}
|
||||||
const stored = readState<Record<string, BermSessionSpec>>("berm", projectId, routeId);
|
|
||||||
return stored && typeof stored === "object" ? stored : {};
|
export function writeBermSpans(projectId: string, routeId: number, spans: BermSpan[]): void {
|
||||||
|
writeState("berm", spans, projectId, routeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */
|
||||||
|
export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null {
|
||||||
|
const found = spans.find(
|
||||||
|
(span) =>
|
||||||
|
chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 &&
|
||||||
|
chainageM <= Math.max(span.start_m, span.end_m) + 1e-6,
|
||||||
|
);
|
||||||
|
return found
|
||||||
|
? { width_m: found.width_m, interval_m: found.interval_m, slope_deg: found.slope_deg }
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */
|
/** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */
|
||||||
|
|||||||
Reference in New Issue
Block a user