This commit is contained in:
2026-07-25 13:56:46 +09:00
parent 3ba6c6a4c2
commit 6bbed32f35
7 changed files with 223 additions and 73 deletions
@@ -195,6 +195,8 @@ class _SectionGeometry:
self._ground_at = ground_at
self._rock_offset = rock_boundary_offset_m or 0.0
self._rock_knee: dict[str, tuple[float, float] | None] = {}
# 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4).
self._cut_cross: dict[str, float | None] = {}
self.ditch_type = ditch_type
# 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약).
slope = cross_slope_pct / 100.0
@@ -311,6 +313,41 @@ class _SectionGeometry:
self._rock_knee[side] = result
return result
def _cut_slope_z(self, side: str, dist: float) -> float:
"""절토 사면선 표고(2단계 무릎 반영). 지반 교차 클램프는 하지 않는다."""
start_dist, start_z = self._slope_start(side)
knee = self.knee(side) if self.two_stage else None
if knee is not None:
knee_dist, knee_z = knee
if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사
return start_z + (dist - start_dist) / self.cut_ratio
return knee_z + (dist - knee_dist) / self.soil_cut_ratio # 토사 구간: 완만
return start_z + (dist - start_dist) / self.cut_ratio
def cut_cross_dist(self, side: str) -> float | None:
"""절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4).
지면과 1회 교차하면 그다음 경사(2단계 전환 포함)는 의미가 없으므로 교차점에서
절토를 종료한다. 시작(노면 끝)부터 사면이 지반 위면 교차거리=시작(절토 없음),
끝까지 못 만나면 None.
"""
if side in self._cut_cross:
return self._cut_cross[side]
result: float | None = None
if self._ground_at is not None:
start_dist, _start_z = self._slope_start(side)
step = 0.05
dist = start_dist
max_dist = start_dist + 500.0
while dist <= max_dist:
signed = dist if side == "left" else -dist
if self._cut_slope_z(side, dist) - self._ground_at(signed) >= 0:
result = dist
break
dist += step
self._cut_cross[side] = result
return result
def design_z(self, offset_m: float, ground_m: float) -> float:
"""offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종)."""
side = "left" if offset_m >= 0 else "right"
@@ -339,16 +376,11 @@ class _SectionGeometry:
dist = abs(offset_m)
run = dist - start_dist
if role == "cut":
knee = self.knee(side) if self.two_stage else None
if knee is not None:
knee_dist, knee_z = knee
if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사
slope_line = start_z + (dist - start_dist) / self.cut_ratio
else: # 토사 구간(경계 위): 무릎에서 토사 경사로 완만
slope_line = knee_z + (dist - knee_dist) / self.soil_cut_ratio
else:
slope_line = start_z + run / self.cut_ratio
return min(slope_line, ground_m)
# 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4).
cross = self.cut_cross_dist(side)
if cross is not None and dist >= cross:
return ground_m
return min(self._cut_slope_z(side, dist), ground_m)
fill_line = start_z - run / self.fill_ratio
return max(fill_line, ground_m)
@@ -362,6 +394,13 @@ class _SectionGeometry:
knee = self.knee(side) if role == "cut" else None
if knee is not None:
points.append(knee[0] if side == "left" else -knee[0])
# 절토 사면·지반 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4).
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role == "cut":
cross = self.cut_cross_dist(side)
if cross is not None:
points.append(cross if side == "left" else -cross)
return points
@@ -47,9 +47,11 @@ export function sectionModeLabel(mode: SectionMode | undefined): string {
const key = MODE_OPTIONS.find(([value]) => value === mode)?.[1];
return key ? ui_locales[key][currentLanguageIndex] : "-";
}
// 좌/우 버튼 = 노면 횡단 경사 방향(물을 모으는 쪽 = 측구가 있다면 그 위치). 양성도 측구는
// 없지만 "생략된 측구"를 가정한 경사 방향을 여기서 정한다(N-2-3).
const DITCH_OPTIONS: Array<[DitchSide, keyof typeof ui_locales]> = [
["left", "B06_Design_Ditch_Left"],
["right", "B06_Design_Ditch_Right"],
["left", "B06_Design_SlopeDir_Left"],
["right", "B06_Design_SlopeDir_Right"],
];
const DITCH_TYPE_OPTIONS: Array<[DitchType, keyof typeof ui_locales]> = [
["standard", "B06_Design_DitchType_Standard"],
@@ -236,8 +238,9 @@ export function buildDesignControls(
// 컨트롤 상호작용이 카드 선택 클릭으로 전파되지 않게 한다.
bar.addEventListener("click", (event) => event.stopPropagation());
// 측구 방향 선택이 필요한 경우 = 양절(both_cut). 편절은 절토측 자동, 양성은 측구 없음.
const needsDitch = (): boolean => state.mode === "both_cut";
// 경사 방향 선택이 필요한 경우 = 양절(both_cut)·양성(both_fill). 편절은 절토측 자동.
// 양성은 측구가 없어도 노면 기울기 방향(생략된 측구 방향)을 여기서 지정한다(N-2-3).
const needsDitch = (): boolean => state.mode === "both_cut" || state.mode === "both_fill";
const emit = (): void => {
if (!state.ground || !state.mode) return;
// L형 측구는 암 전용 — 토사로 되돌리면 일반측구로 강등해 서버 거부를 예방한다.
@@ -254,15 +257,10 @@ export function buildDesignControls(
};
// 지반유형: 제목행 배치용으로 분리 반환(D-6). 라벨 삭제(3번) — 버튼만.
const groundSegment = segment(
"",
GROUND_OPTIONS,
state.ground,
(value) => {
state.ground = value;
emit();
},
);
const groundSegment = segment("", GROUND_OPTIONS, state.ground, (value) => {
state.ground = value;
emit();
});
groundSegment.classList.add("b06-design__seg--header");
// 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성).
// 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6).
@@ -271,19 +269,17 @@ export function buildDesignControls(
// 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다.
const ditchOn = state.ditchEnabled ?? design?.ditch_enabled ?? true;
// 측구 방향(좌/우): 양절(both_cut)에서 활성. 편절은 절토측 자동, 양성은 측구 없음. 라벨 삭제(E-7).
bar.append(
segment(
"",
DITCH_OPTIONS,
state.ditch,
(value) => {
state.ditch = value;
emit();
},
state.mode !== "both_cut",
L("B06_Design_Disabled_BothCutOnly"),
),
// 경사 방향(좌/우): 양절·양성에서 활성. 항상 인라인 노출(오버플로 대상 아님). 라벨 삭제(E-7).
const slopeDirSeg = segment(
"",
DITCH_OPTIONS,
state.ditch,
(value) => {
state.ditch = value;
emit();
},
!needsDitch(),
L("B06_Design_Disabled_BothCutOnly"),
);
// 측구 생성 토글(D-1): 측구 있는 단면에서만 활성. 버튼명 "측구" 고정, 컬러로 상태 표시(E-7).
const ditchToggle = toggle(
@@ -298,20 +294,17 @@ export function buildDesignControls(
!hasDitch,
L("B06_Design_Disabled_NoDitch"),
);
bar.append(ditchToggle.wrap);
// 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성.
bar.append(
segment(
L("B06_Design_DitchType_Legend"),
DITCH_TYPE_OPTIONS,
state.ditchType,
(value) => {
state.ditchType = value;
emit();
},
!rockCut || !ditchOn,
!ditchOn ? L("B06_Design_Disabled_NoDitchType") : L("B06_Design_Disabled_RockCut"),
),
const ditchTypeSeg = segment(
L("B06_Design_DitchType_Legend"),
DITCH_TYPE_OPTIONS,
state.ditchType,
(value) => {
state.ditchType = value;
emit();
},
!rockCut || !ditchOn,
!ditchOn ? L("B06_Design_Disabled_NoDitchType") : L("B06_Design_Disabled_RockCut"),
);
// 2단계 경사 토글: 암 지반 + 절토 단면에서만 활성. 활성="복합경사"/비활성="단경사"(E-7).
const twoStage = toggle(
@@ -326,18 +319,11 @@ export function buildDesignControls(
!rockCut,
L("B06_Design_Disabled_RockCut"),
);
bar.append(twoStage.wrap);
// 포장 토글: 라벨 삭제, 버튼명 활성="포장"/비활성="비포장"(컬러 유지) — E-7.
const paved = toggle(
"",
state.paved,
L("B06_Design_Paved_On"),
L("B06_Design_Paved_Off"),
() => {
state.paved = !state.paved;
emit();
},
);
const paved = toggle("", state.paved, L("B06_Design_Paved_On"), L("B06_Design_Paved_Off"), () => {
state.paved = !state.paved;
emit();
});
// B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다.
if (design?.pavement_suggested) {
paved.button.title = L("B06_Design_Paved_Suggested");
@@ -347,10 +333,42 @@ export function buildDesignControls(
badge.title = L("B06_Design_Paved_Suggested");
paved.wrap.append(badge);
}
bar.append(paved.wrap);
// 오버플로 드롭다운(N-2-2): 컨트롤이 카드 폭에서 한 행을 넘치면 뒤쪽부터 우측 "⋯"
// 드롭다운(세로 배치)으로 옮겨 다음 행으로 밀리는 것을 막는다. 경사 방향은 항상 인라인,
// 이동 우선순위(뒤에서부터): 포장 → 복합경사 → 측구형식 → 측구 토글.
const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap, paved.wrap];
const more = document.createElement("details");
more.className = "b06-design__more";
const moreSummary = document.createElement("summary");
moreSummary.className = "b06-design__more-summary";
moreSummary.textContent = "⋯";
moreSummary.title = L("B06_Design_More");
const morePanel = document.createElement("div");
morePanel.className = "b06-design__more-panel";
more.append(moreSummary, morePanel);
bar.append(slopeDirSeg, ...moveable, more);
const reflow = (): void => {
// 후보 전부 인라인 복귀 → more 숨김 → 넘치면 뒤에서부터 패널로 이동.
for (const element of moveable) bar.insertBefore(element, more);
morePanel.replaceChildren();
more.hidden = true;
if (bar.clientWidth <= 0) return;
for (
let index = moveable.length - 1;
index >= 0 && bar.scrollWidth > bar.clientWidth + 1;
index -= 1
) {
more.hidden = false;
morePanel.insertBefore(moveable[index], morePanel.firstChild);
}
};
const overflowObserver = new ResizeObserver(() => reflow());
overflowObserver.observe(bar);
requestAnimationFrame(reflow);
// 암 경계선 제어는 그래프 X축 제목 행으로 이동(E-7, Cross_View에서 배치).
// 절·성토 면적 readout은 그래프 중상단 오버레이로 이동(E-4, Cross_View에서 배치).
return { bar, groundSegment };
}
@@ -190,6 +190,25 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
}
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
async function applyPanelToAll(): Promise<void> {
if (!sectionDetail) return;
const targets = sectionDetail.cross_sections.filter((section) => section.design);
if (!targets.length) return;
showLoadingOverlay();
try {
for (const section of targets) {
const change = changeFromDesign(section.chainage_m);
if (change) await handleDesignChange(section.chainage_m, change);
}
showToast(L("B06_Std_ApplyAll_Success"), "success");
} finally {
hideLoadingOverlay();
}
}
/* ── 암 경계선 오프셋(측점별) 세션 저장소 ─────────────────────────────
* 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시
* cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다.
@@ -396,7 +415,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
rockBoundaryStep = context.rock_boundary_step_m;
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
standardPanel = createStandardPanel(projectId, context.standard_cross_section);
standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll);
standardPanelSlot.append(standardPanel.root);
if (context.route_id === null) {
@@ -149,6 +149,9 @@ export function createSectionView(
1,
Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)),
);
// CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수를 JS가 명시해 일치시킨다.
grid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`;
cachedCardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount;
cachedRowHeight.clear();
if (detail.cross_sections.length) {
@@ -158,6 +158,7 @@ const FIELD_SPECS: NumberFieldSpec[] = [
export function createStandardPanel(
projectId: string,
defaults: StandardCrossSection,
onApplyAll?: () => void | Promise<void>,
): StandardPanelController {
// 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다.
const sessionValue = readSession(projectId);
@@ -247,6 +248,17 @@ export function createStandardPanel(
});
const actions = document.createElement("div");
actions.className = "b06-std__actions";
// [전체 반영](N-2-1): 패널 수치를 design 보유 전 측점에 서버 재계산으로 일괄 반영한다.
// 측점별 버튼 선택값(지반/단면 등)은 보존하고 표준단면 수치만 갱신한다.
if (onApplyAll) {
actions.append(
createButton({
label: L("B06_Std_ApplyAll"),
variant: "filled",
onClick: () => void onApplyAll(),
}),
);
}
actions.append(resetButton);
root.append(body, loader, actions);
@@ -320,10 +320,11 @@
font-weight: var(--font-weight-bold);
}
/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) — 좌측 여유 추가(2번). */
/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) — 좌측 여유 추가(2번).
한 행 고정(nowrap): 넘치는 컨트롤은 다음 행으로 밀지 않고 "⋯" 드롭다운으로 이동(N-2-2). */
.b06-design {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: var(--spacing-8) var(--spacing-16);
padding: var(--spacing-8) 0 var(--spacing-8) var(--spacing-16);
@@ -331,13 +332,60 @@
margin-bottom: var(--spacing-8);
}
/* 오버플로 드롭다운(N-2-2): 넘친 컨트롤을 담는 우측 맞춤 "⋯" 메뉴. 세로 배치. */
.b06-design__more {
position: relative;
flex: 0 0 auto;
margin-left: auto;
}
.b06-design__more-summary {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 22px;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
list-style: none;
}
.b06-design__more-summary::-webkit-details-marker {
display: none;
}
.b06-design__more[open] .b06-design__more-summary {
color: var(--color-surface);
background: var(--color-royal-amethyst);
border-color: var(--color-royal-amethyst);
}
.b06-design__more-panel {
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 3;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface-raised);
box-shadow: var(--shadow-sm);
}
.b06-design__seg {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.b06-design__seg-legend {
font-size: 0.72rem;
color: var(--color-text-secondary);
+18 -7
View File
@@ -806,10 +806,10 @@ export const ui_locales = {
B06_Design_Ground_Ripping: ["리핑암", "Ripping rock"],
B06_Design_Ground_Blasting: ["발파암", "Blasting rock"],
B06_Design_Mode_Legend: ["단면유형", "Section type"],
B06_Design_Mode_LeftCut: ["좌 절토", "Left cut"],
B06_Design_Mode_RightCut: ["우 절토", "Right cut"],
B06_Design_Mode_BothCut: ["양", "Both cut"],
B06_Design_Mode_BothFill: ["양", "Both fill"],
B06_Design_Mode_LeftCut: ["편측 성토", "One-side fill"],
B06_Design_Mode_RightCut: ["편측 성토", "One-side fill"],
B06_Design_Mode_BothCut: ["양측 절토", "Both cut"],
B06_Design_Mode_BothFill: ["양측 성토", "Both fill"],
B06_Design_Ditch_Legend: ["측구위치", "Ditch side"],
B06_Design_Ditch_Left: ["좌", "Left"],
B06_Design_Ditch_Right: ["우", "Right"],
@@ -829,9 +829,12 @@ export const ui_locales = {
B06_Design_Ditch_On: ["측구", "Ditch"],
B06_Design_Ditch_Off: ["측구", "Ditch"],
B06_Design_Disabled_BothCutOnly: [
"양절 단면에서만 선택할 수 있습니다.",
"Available only for both-cut sections.",
"양측 절토·양측 성토 단면에서만 경사 방향을 바꿀 수 있습니다.",
"Slope direction is adjustable only for both-cut and both-fill sections.",
],
B06_Design_More: ["더보기", "More"],
B06_Design_SlopeDir_Left: ["좌경사", "Slope left"],
B06_Design_SlopeDir_Right: ["우경사", "Slope right"],
B06_Design_Disabled_RockCut: [
"암(리핑/발파) 지반의 절토 단면에서만 사용할 수 있습니다.",
"Available only for rock ground with a cut section.",
@@ -880,10 +883,18 @@ export const ui_locales = {
"L-type ditch is chosen per cross-section drawing.",
],
B06_Std_Reset: ["기본값 복원", "Restore defaults"],
B06_Std_ApplyAll: ["전체 측점 반영", "Apply to all stations"],
B06_Std_ApplyAll_Success: [
"패널 설정을 전체 측점에 반영했습니다.",
"Applied panel settings to all stations.",
],
B06_Std_Load_Title: ["다른 프로젝트에서 불러오기", "Load from another project"],
B06_Std_Load_Select: ["프로젝트 선택", "Select project"],
B06_Std_Load_Placeholder: ["— 프로젝트 선택 —", "— Select a project —"],
B06_Std_Load_Empty: ["같은 회사에 불러올 설계값이 없습니다.", "No saved designs in your company."],
B06_Std_Load_Empty: [
"같은 회사에 불러올 설계값이 없습니다.",
"No saved designs in your company.",
],
B06_Std_Load_Loading: ["불러오는 중…", "Loading…"],
B06_Std_Load_Apply: ["현재 설정에 적용", "Apply to current settings"],
B06_Std_Load_Applied: ["적용되었습니다.", "Applied."],