Merge remote-tracking branch 'origin/dev' into sub_laptop_5
This commit is contained in:
@@ -65,6 +65,7 @@ export function createDesignDock(
|
||||
divider.className = "b05-structure__divider";
|
||||
const row = document.createElement("div");
|
||||
row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions";
|
||||
row.dataset.helpRow = "";
|
||||
|
||||
const press = (name: keyof DesignDockActions) => (): void => {
|
||||
void Promise.resolve(actions[name].run());
|
||||
|
||||
@@ -136,8 +136,9 @@ export function subscribeSelection(projectId: string | null, fn: SelectionListen
|
||||
}
|
||||
|
||||
/**
|
||||
* 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 이미 적힌 부재키를 지킨다 — 3D 로
|
||||
* 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다.
|
||||
* 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 **같은 것**을 다시 적을 때만 부재키를 지킨다 —
|
||||
* 3D 로 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다.
|
||||
* 다른 구조물로 옮기면 버리고, 빈 값("")을 주면 지운다(53-1).
|
||||
* `from` 은 낸 쪽 이름 — 구독자가 제 것을 되받아 다시 그리지 않게 거른다.
|
||||
* 값이 그대로면 알리지 않고, 알림 도중 다시 낸 값은 적기만 한다(되울림 방지).
|
||||
*/
|
||||
@@ -157,7 +158,17 @@ export function selectDesign(
|
||||
before.structureId === selection.structureId &&
|
||||
(options.partKey === undefined || options.partKey === before.partKey);
|
||||
if (same) return;
|
||||
const key = options.partKey ?? before?.partKey;
|
||||
// 부재키는 같은 것을 다시 적을 때만 지킴 — 다른 구조물로 옮기면 옛 부재(조정창)가 따라오지 않음(53-1)
|
||||
const sameTarget =
|
||||
before !== null &&
|
||||
before.chainageM !== null &&
|
||||
isNear(before.chainageM, selection.chainageM) &&
|
||||
before.structureId === selection.structureId;
|
||||
// 빈 부재키("") = 부재를 풂
|
||||
const key =
|
||||
options.partKey === ""
|
||||
? undefined
|
||||
: (options.partKey ?? (sameTarget ? before?.partKey : undefined));
|
||||
writeState(
|
||||
"structure-pick",
|
||||
{ at: selection.chainageM, key, sid: selection.structureId ?? undefined },
|
||||
|
||||
@@ -182,7 +182,18 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
return wrap;
|
||||
});
|
||||
box.body.append(grid(...fields));
|
||||
if (c.required) box.body.append(slotOf(c.id));
|
||||
if (c.required) {
|
||||
const slot = slotOf(c.id);
|
||||
// B06 조정 강조(테두리 · 이름표)는 칸 상자에 붙음 — 다시 그려도 이어 감(53-1)
|
||||
const prev = slot.closest("fieldset");
|
||||
if (prev?.classList.contains("is-active")) {
|
||||
const legend = box.root.querySelector("legend")!;
|
||||
legend.dataset.baseLabel = legend.textContent ?? "";
|
||||
legend.textContent = prev.querySelector("legend")?.textContent ?? legend.textContent;
|
||||
box.root.classList.add("is-active");
|
||||
}
|
||||
box.body.append(slot);
|
||||
}
|
||||
if (links) {
|
||||
const key = linkOf(c, index + 1);
|
||||
const row = buildSummaryRow();
|
||||
|
||||
@@ -203,11 +203,15 @@ export async function mountCrossView(
|
||||
view: () => sectionView,
|
||||
});
|
||||
|
||||
let structuresRefreshSeq = 0;
|
||||
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
|
||||
async function refreshDetailForStructures(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
|
||||
try {
|
||||
sectionDetail = await loadSectionDetail(projectId, currentRouteId);
|
||||
const fresh = await loadSectionDetail(projectId, currentRouteId);
|
||||
if (seq !== structuresRefreshSeq) return;
|
||||
sectionDetail = fresh;
|
||||
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가
|
||||
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측).
|
||||
if (sectionDetail) {
|
||||
@@ -358,6 +362,11 @@ export async function mountCrossView(
|
||||
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
|
||||
// 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다.
|
||||
if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" });
|
||||
// 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면
|
||||
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
|
||||
const pipeAt = structuresPanel.currentChainage();
|
||||
const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`);
|
||||
if (mark) sectionView.selectMark(mark);
|
||||
structureSelection.syncInletStructure();
|
||||
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
||||
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
|
||||
@@ -436,6 +445,10 @@ export async function mountCrossView(
|
||||
structuresPanel.prefer(handoff?.structureId ?? null);
|
||||
sectionView.focusStation(stationId);
|
||||
structuresPanel.prefer(null);
|
||||
// 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1)
|
||||
const sid = handoff?.structureId;
|
||||
if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid))
|
||||
structuresPanel.selectStructure(sid);
|
||||
},
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
|
||||
@@ -495,6 +508,8 @@ export async function mountCrossView(
|
||||
// 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다.
|
||||
host.onShow(() => {
|
||||
if (currentRouteId === null) return;
|
||||
// 떠난 사이 종단 보기가 폼을 다른 것으로 바꿨을 수 있음 — 같은 넘김값도 다시 적용(53-1)
|
||||
appliedPick = null;
|
||||
void structuresPanel.load().then(restoreStructurePick);
|
||||
void refreshDetailForStructures().then(() => reconcileStaleDesigns());
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createHelpButton } from "@ui/ui_template_help";
|
||||
import { isBlank } from "@util/common_util_validate";
|
||||
import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name";
|
||||
@@ -299,7 +300,8 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
||||
// 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-general-block__actions b02-proj__actions";
|
||||
actions.append(cancelBtn, submitBtn);
|
||||
// 좌측 패널이 없어 [?](54-2)는 아래 단추 줄 맨 앞
|
||||
actions.append(createHelpButton(), cancelBtn, submitBtn);
|
||||
const card = createCard({ body: [grid, actions], raised: true });
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b02-proj",
|
||||
|
||||
@@ -1,77 +1,18 @@
|
||||
/* =============================================================================
|
||||
* B03_FileInput_UI_Guide.ts
|
||||
* 파일 입력 좌측 안내 패널 (2026-09-03 사용자 지시).
|
||||
* 파일 입력 좌측 패널 (2026-09-03 사용자 지시).
|
||||
*
|
||||
* 종전에는 고르는 방법·필요한 파일 목록이 **선택 영역과 카드 안에** 길게 들어가 있어
|
||||
* 카드 한 장이 220px씩 차지했다. 안내는 한 번 읽으면 되는 것이라 다른 단계(B04·B05)와
|
||||
* 같은 자리 — 공용 오버레이의 좌측 패널(`createWorkflowOverlays.optionsContent`) — 로
|
||||
* 옮기고, 본문은 고르는 자리만 남긴다.
|
||||
*
|
||||
* 패널 양식은 **B04~B07과 같은 공용 양식**을 그대로 쓴다(2026-09-03 사용자 지시):
|
||||
* 패널 루트 `{page}__form`, 문단은 `ui-collapsible ui-sidebar-section` + 제목에
|
||||
* `ui-collapsible__title`. 제목 행을 누르면 접히는 동작·캐럿·외곽선이 전부 공용 것이다.
|
||||
* 안내 글(필요한 파일 · 고르는 방법 · 알아 둘 것)은 [?] 설명서 `resources/help/B03/*.md` 로
|
||||
* 옮김(54-2). 패널에는 현황 배너와 아래 단추 줄([?] 자리)만 남김.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 안내 문단 하나 — 공용 접기 컨테이너(B06 `buildGroup`과 같은 조립). */
|
||||
function section(
|
||||
titleKey: keyof typeof ui_locales,
|
||||
itemKeys: (keyof typeof ui_locales)[],
|
||||
): HTMLElement {
|
||||
const group = document.createElement("section");
|
||||
group.className = "b03-file__guide-group ui-collapsible ui-sidebar-section";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.className = "b03-file__guide-legend ui-collapsible__title";
|
||||
title.textContent = L(titleKey);
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "b03-file__guide-list";
|
||||
for (const key of itemKeys) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = L(key);
|
||||
list.append(item);
|
||||
}
|
||||
|
||||
group.append(title, list);
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌측 패널 본문 — 필요한 파일 · 고르는 방법 · 알아 둘 것.
|
||||
*
|
||||
* `statusNote`(재접속 현황 배너)는 「고르는 방법」 끝에 붙는다 — 프로젝트가 이미 완료라
|
||||
* 다시 올리면 교체된다는 안내라서, 고르기 전에 읽을 것들과 같은 자리에 둔다
|
||||
* (2026-09-03 사용자 지시).
|
||||
*/
|
||||
/** 좌측 패널 본문 — `statusNote`(재접속 현황 배너) + 아래 단추 줄(공용 [?] 가 붙음). */
|
||||
export function createInputGuide(statusNote?: HTMLElement): HTMLElement {
|
||||
const guide = document.createElement("div");
|
||||
guide.className = "b03-file__form";
|
||||
const howTo = section("B03_Guide_How_Title", [
|
||||
"B03_Guide_How_Drop",
|
||||
"B03_Guide_How_Card",
|
||||
"B03_Guide_How_Temp",
|
||||
]);
|
||||
if (statusNote) howTo.append(statusNote);
|
||||
guide.append(
|
||||
section("B03_Guide_Files_Title", [
|
||||
"B03_Guide_Files_Route",
|
||||
"B03_Guide_Files_Terrain",
|
||||
"B03_Guide_Files_Optional",
|
||||
]),
|
||||
howTo,
|
||||
section("B03_Guide_Notes_Title", [
|
||||
"B03_Guide_Notes_Replace",
|
||||
"B03_Guide_Notes_Crs",
|
||||
"B03_Guide_Notes_LasFree",
|
||||
]),
|
||||
);
|
||||
attachCollapsible(guide);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
if (statusNote) guide.append(statusNote);
|
||||
guide.append(actions);
|
||||
return guide;
|
||||
}
|
||||
|
||||
@@ -310,10 +310,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 좌측 안내 패널 (2026-09-03) — 본문에서 뺀 설명이 사는 자리.
|
||||
양식은 B04~B07 좌측 패널과 **같은 공용 양식**이다: 루트 `__form`, 문단은
|
||||
`ui-collapsible ui-sidebar-section`(외곽선·접힘·캐럿은 공용 CSS 몫), 제목은
|
||||
`__group-legend` 크기. 여기서는 값만 맞추고 새로 만들지 않는다. --- */
|
||||
/* --- 좌측 패널 — 현황 배너 + 아래 단추 줄(안내 글은 [?] 설명서로 옮김 · 54-2) --- */
|
||||
.b03-file__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -321,36 +318,6 @@
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b03-file__guide-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin: 0;
|
||||
padding: var(--spacing-16);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b03-file__guide-legend {
|
||||
margin: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 글자 크기·색도 공용 양식 그대로 — B04 좌측 패널 본문(`b04-surface__check`)과 같은
|
||||
`--text-body-sm` · `--color-text-body`(2026-09-03 사용자 지시: 양식 = 글자 크기 포함). */
|
||||
.b03-file__guide-list {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b03-file__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,6 +8,7 @@ longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -33,6 +34,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_REVET,
|
||||
PipePoint,
|
||||
load_pipe_points_file,
|
||||
parse_pipe_points,
|
||||
pipe_anchor_clearances,
|
||||
route_signature,
|
||||
)
|
||||
@@ -205,6 +207,56 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
|
||||
return points
|
||||
|
||||
|
||||
def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None:
|
||||
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음."""
|
||||
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
||||
return None
|
||||
if pipe.facility == PIPE_FACILITY_PIPE:
|
||||
values = pipe.options or {}
|
||||
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
||||
diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM))
|
||||
return f"{kind} D{diameter}"
|
||||
definition = types.get(pipe.facility)
|
||||
return definition.name if definition is not None else None
|
||||
|
||||
|
||||
def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None:
|
||||
"""횡단 측점 라벨을 **지금 관 지점 종류**로 맞춘다(54-4).
|
||||
|
||||
라벨은 측점 생성 때 적혀 파일에 남는다 — 저장으로 종류만 바꾸면(배관 → BOX암거) 측점은
|
||||
다시 안 만들어져 카드 제목이 옛 종류로 남는다. 관 지점 라벨(종류 이름 · 「관종 D관경」)인
|
||||
측점만 고치고 다른 구조물 라벨은 건드리지 않는다.
|
||||
"""
|
||||
path = (
|
||||
project_root
|
||||
/ "B04_PreProcess"
|
||||
/ DRAINAGE_CACHE_DIRNAME
|
||||
/ DRAINAGE_EDITS_DIRNAME
|
||||
/ DRAINAGE_PIPE_POINTS_FILENAME
|
||||
)
|
||||
try:
|
||||
points = parse_pipe_points(json.loads(path.read_text(encoding="utf-8")).get("points"))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return
|
||||
types = structure_type_map()
|
||||
pipe_names = {definition.name for definition in types.values() if definition.managed_by}
|
||||
for section in cross_sections:
|
||||
label = section.get("structure")
|
||||
if not isinstance(label, str) or not (
|
||||
label in pipe_names or re.fullmatch(r"\S+ D\d+", label)
|
||||
):
|
||||
continue
|
||||
chainage = float(section.get("chainage_m") or 0.0)
|
||||
pipe = next((p for p in points if abs(p.chainage_m - chainage) < 0.02), None)
|
||||
if pipe is None:
|
||||
continue
|
||||
fresh = pipe_station_label(pipe, types)
|
||||
if fresh is None:
|
||||
section.pop("structure", None)
|
||||
else:
|
||||
section["structure"] = fresh
|
||||
|
||||
|
||||
def resolve_extra_stations(
|
||||
project_root: Path, pipes: list[PipePoint]
|
||||
) -> tuple[tuple[float, str], ...]:
|
||||
@@ -224,18 +276,9 @@ def resolve_extra_stations(
|
||||
types = structure_type_map()
|
||||
derived: list[tuple[float, str]] = []
|
||||
for pipe in pipes:
|
||||
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
||||
label = pipe_station_label(pipe, types)
|
||||
if label is None:
|
||||
continue
|
||||
if pipe.facility == PIPE_FACILITY_PIPE:
|
||||
values = pipe.options or {}
|
||||
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
||||
diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM))
|
||||
label = f"{kind} D{diameter}"
|
||||
else:
|
||||
definition = types.get(pipe.facility)
|
||||
if definition is None:
|
||||
continue
|
||||
label = definition.name
|
||||
# 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 —
|
||||
# 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은
|
||||
# 기준 한 곳(관이 노선을 가로지르는 점형).
|
||||
|
||||
@@ -88,6 +88,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
|
||||
spec.basin_length_m ?? "",
|
||||
spec.basin_before_m ?? "",
|
||||
spec.basin_after_m ?? "",
|
||||
spec.basin_height_m ?? "",
|
||||
].join(",");
|
||||
const basin = section.design?.basin_adjust;
|
||||
const revet = section.design?.revet_adjust;
|
||||
|
||||
@@ -381,16 +381,11 @@ export function createFacilityOptionsForm(
|
||||
// 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
|
||||
const paveForm = createSpecForm(specLinks, { onChange: () => emit() });
|
||||
|
||||
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
|
||||
const saveNote = document.createElement("p");
|
||||
saveNote.className = "b05-structure__owner-note";
|
||||
saveNote.textContent =
|
||||
"[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀";
|
||||
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
|
||||
|
||||
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
|
||||
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
|
||||
root.append(
|
||||
saveNote,
|
||||
pipeForm.root,
|
||||
fordForm.root,
|
||||
paveForm.root,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
|
||||
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
|
||||
|
||||
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -245,39 +244,18 @@ export interface RevetSideGroup {
|
||||
slot: HTMLElement;
|
||||
}
|
||||
|
||||
/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호).
|
||||
* ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */
|
||||
function sabangBasisLines(): string[] {
|
||||
const { items, scope } = revetmentSabang;
|
||||
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
|
||||
return [
|
||||
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
|
||||
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
|
||||
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
|
||||
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
|
||||
scope,
|
||||
];
|
||||
}
|
||||
|
||||
export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup {
|
||||
const box = group("기슭막이");
|
||||
const kind = document.createElement("select");
|
||||
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
|
||||
// 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정).
|
||||
const fields = createRevetmentFields(keys, { height: "" });
|
||||
const basis = document.createElement("p");
|
||||
basis.className = "b05-route__note";
|
||||
basis.replaceChildren(
|
||||
...sabangBasisLines().flatMap((line, index) =>
|
||||
index ? [document.createElement("br"), line] : [line],
|
||||
),
|
||||
);
|
||||
// 치수 근거 글(사방기술교본)은 [?] 설명서 `구조물_기슭막이.md` 로 옮김(54-2)
|
||||
const slot = document.createElement("div");
|
||||
slot.className = "b05-structure__adjust-slot";
|
||||
box.body.append(
|
||||
grid(labeled("구조", kind), fields.formField),
|
||||
...fields.rows,
|
||||
basis,
|
||||
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
|
||||
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
|
||||
slot,
|
||||
|
||||
@@ -88,7 +88,8 @@ export function restoreStructurePick(
|
||||
selectStation(handoff.at);
|
||||
// 구조물 id 가 적혀 있을 때만 구조물로 연다 — 관 목록이 아직 안 실렸을 때 측점만으로 구조물을
|
||||
// 짐작하면 그 측점을 덮는 다른 구조물이 열린다(관은 목록이 실릴 때 위 `selectStation` 이 연다).
|
||||
if (!structures.hasSelection() && handoff.structureId) {
|
||||
// id 가 있으면 측점이 연 관보다 먼저다(resolveSelection ① · 53-1)
|
||||
if (handoff.structureId) {
|
||||
structures.selectAt({ chainageM: handoff.at, structureId: handoff.structureId });
|
||||
}
|
||||
return structures.hasSelection();
|
||||
|
||||
@@ -9,23 +9,13 @@
|
||||
|
||||
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
|
||||
|
||||
export function field(
|
||||
labelText: string,
|
||||
input: HTMLElement,
|
||||
basis?: string | null,
|
||||
): HTMLLabelElement {
|
||||
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = labelText;
|
||||
// 칸 밑 근거 글(`default_basis`)은 [?] 설명서 `구조물_<종류>.md` 로 옮김(54-2)
|
||||
wrapper.append(caption, input);
|
||||
// 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정).
|
||||
if (basis) {
|
||||
const note = document.createElement("small");
|
||||
note.className = "b05-structure__basis";
|
||||
note.textContent = basis;
|
||||
wrapper.append(note);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,6 @@ export interface StructuresFormElements {
|
||||
body: HTMLElement;
|
||||
groupSelect: HTMLSelectElement;
|
||||
typeSelect: HTMLSelectElement;
|
||||
/** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */
|
||||
ownerNote: HTMLElement;
|
||||
startFields: StationFields;
|
||||
anchorFields: StationFields;
|
||||
endFields: StationFields;
|
||||
@@ -113,13 +111,6 @@ export function buildStructuresForm(options: {
|
||||
typeRow.className = "b05-structure__grid";
|
||||
typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect));
|
||||
|
||||
// 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라
|
||||
// (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자:
|
||||
// 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다.
|
||||
const ownerNote = document.createElement("p");
|
||||
ownerNote.className = "b05-structure__owner-note";
|
||||
ownerNote.hidden = true;
|
||||
|
||||
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
|
||||
const optionRow = document.createElement("div");
|
||||
optionRow.className = "b05-structure__grid";
|
||||
@@ -178,7 +169,6 @@ export function buildStructuresForm(options: {
|
||||
|
||||
body.append(
|
||||
typeRow,
|
||||
ownerNote,
|
||||
positionRow,
|
||||
summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류)
|
||||
positionDivider,
|
||||
@@ -195,7 +185,6 @@ export function buildStructuresForm(options: {
|
||||
body,
|
||||
groupSelect,
|
||||
typeSelect,
|
||||
ownerNote,
|
||||
startFields,
|
||||
anchorFields,
|
||||
endFields,
|
||||
|
||||
@@ -78,7 +78,6 @@ export function createStructuresSection(
|
||||
body,
|
||||
groupSelect,
|
||||
typeSelect,
|
||||
ownerNote,
|
||||
startFields,
|
||||
anchorFields,
|
||||
endFields,
|
||||
@@ -233,12 +232,8 @@ export function createStructuresSection(
|
||||
startFields.wrap.hidden = !isInterval;
|
||||
endFields.wrap.hidden = !isInterval;
|
||||
primary.disabled = !type;
|
||||
// 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면
|
||||
// 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자).
|
||||
ownerNote.textContent = type?.design_owner
|
||||
? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.`
|
||||
: "";
|
||||
ownerNote.hidden = !type?.design_owner;
|
||||
// [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김)
|
||||
root.dataset.helpTopic = type ? `구조물_${type.name}` : "";
|
||||
// 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임
|
||||
anchorFields.wrap.hidden = isInterval && !showsAnchor(type!);
|
||||
anchorFields.wrap.querySelector("span")!.textContent = isInterval
|
||||
@@ -326,6 +321,8 @@ export function createStructuresSection(
|
||||
commitTempPipe();
|
||||
syncOptionLock();
|
||||
syncRangeDisplay();
|
||||
// 고르던 항목의 측점도 값 — 다른 칸처럼 바로 반영(53-1)
|
||||
if (positionConfirmed) liveCommit();
|
||||
}
|
||||
|
||||
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */
|
||||
|
||||
@@ -50,6 +50,8 @@ export function bindStructuresEvents(ctx: StructuresEventContext): void {
|
||||
// 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시:
|
||||
// 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다).
|
||||
ctx.syncFacilityForm();
|
||||
// 고른 관 지점의 시설 종류를 바꿈(54-4) — 실시간 반영으로 목록 · 알약 · 횡단도가 바로 따라옴
|
||||
if (ctx.selectedPipeChainage() !== null && ctx.currentType()?.managed_by) ctx.liveCommit();
|
||||
if (ctx.editingId()) {
|
||||
ctx.setEditingId(null);
|
||||
ctx.syncButtons();
|
||||
|
||||
@@ -101,11 +101,7 @@ export function renderOptionFields(
|
||||
// 정의 그룹 상자로 옮겨 갈 숫자 칸 = 명세 컨테이너와 같은 [-][숫자][+](52-18)
|
||||
const grouped =
|
||||
option.input === "number" && !!type.groups?.some((g) => g.options.includes(option.key));
|
||||
const wrap = field(
|
||||
label,
|
||||
grouped ? stepper(input as HTMLInputElement, 0.1) : input,
|
||||
option.default_basis,
|
||||
);
|
||||
const wrap = field(label, grouped ? stepper(input as HTMLInputElement, 0.1) : input);
|
||||
if (grouped) wrap.htmlFor = input.id; // 라벨 대상 = 숫자 칸(첫 버튼으로 새지 않게)
|
||||
wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3)
|
||||
// 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로
|
||||
|
||||
@@ -215,13 +215,6 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */
|
||||
.b05-structure__basis {
|
||||
color: var(--color-text-muted, #9aa1ad);
|
||||
font-size: var(--text-caption, 12px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
.b05-structure__position-row {
|
||||
|
||||
@@ -207,6 +207,8 @@ export interface CulvertSideSpec {
|
||||
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
|
||||
basin_before_m?: number | null;
|
||||
basin_after_m?: number | null;
|
||||
/** 집수정 높이(m) — 좌측 폼 「높이」. 없으면 횡단도 자동(I형 노견 접점 · ㄴ·ㄷ형 내공 높이). */
|
||||
basin_height_m?: number | null;
|
||||
/** 기슭막이 전면 기울기(1:n) 종전값 — 그림은 `revetWallSpec().lean`(표준경사 표)을 씀. */
|
||||
face_slope?: number;
|
||||
/** 독립 기슭막이 기울기 판정 칸(저장 원본) — 설치 측 · 성토/절토 · 전면 기울기(B08 과 같은 칸). */
|
||||
|
||||
@@ -185,6 +185,8 @@ def _side_spec(
|
||||
options.get("inlet_basin_after_m"),
|
||||
_number(defaults.get("inlet_basin_after_m"), None),
|
||||
)
|
||||
# 집수정 높이(좌측 폼 · 비면 횡단도 자동) — 횡단도 집수정 벽 높이.
|
||||
spec["basin_height_m"] = _number(options.get("inlet_basin_height_m"), None)
|
||||
return spec
|
||||
|
||||
# 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음).
|
||||
|
||||
@@ -16,6 +16,7 @@ from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_gra
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
prune_stale_cross_files,
|
||||
relabel_pipe_stations,
|
||||
run_section_generation,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
@@ -269,6 +270,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
# 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다.
|
||||
# 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points).
|
||||
attach_culvert_sets(root, cross_sections)
|
||||
relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4)
|
||||
# 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 —
|
||||
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
|
||||
attach_wall_structures(root, cross_sections)
|
||||
|
||||
@@ -22,6 +22,13 @@ import {
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import {
|
||||
attachWallSpecs,
|
||||
wallSpecsFrom,
|
||||
@@ -55,6 +62,7 @@ async function withDraftWalls(
|
||||
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
|
||||
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
|
||||
// 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인).
|
||||
await withDraftPipeKinds(detail, projectId);
|
||||
if (!drafts || !drafts.length) return withStructureAreas(detail);
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
const names = new Map(
|
||||
@@ -77,6 +85,66 @@ async function withDraftWalls(
|
||||
return withStructureAreas(detail);
|
||||
}
|
||||
|
||||
/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */
|
||||
const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const;
|
||||
const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] =>
|
||||
facility === "ford_bridge"
|
||||
? "ford"
|
||||
: facility === "box_culvert"
|
||||
? "box"
|
||||
: facility === "ford_pavement"
|
||||
? "ford_pavement"
|
||||
: "culvert";
|
||||
|
||||
/**
|
||||
* 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다.
|
||||
* 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식
|
||||
* (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다.
|
||||
*/
|
||||
async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise<void> {
|
||||
const pipes = readPendingPipes(projectId);
|
||||
if (!pipes?.length) return;
|
||||
const sections = detail.cross_sections as unknown as Array<Record<string, unknown>>;
|
||||
const near = (spec: Record<string, unknown>, section: Record<string, unknown>, at: number) =>
|
||||
Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M;
|
||||
/** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */
|
||||
const fits = (pipe: (typeof pipes)[number], key: string, spec: Record<string, unknown>) =>
|
||||
key === facilityKey(pipe.facility ?? "pipe") &&
|
||||
(key !== "culvert" || (spec.hidden_pipe === true) === (pipe.facility === "revetment"));
|
||||
const stale = pipes.filter((pipe) => {
|
||||
const here = sections.flatMap((section) =>
|
||||
FACILITY_KEYS.flatMap((key) => {
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : [];
|
||||
}),
|
||||
);
|
||||
return here.length > 0 && !here.some(([key, spec]) => fits(pipe, key, spec));
|
||||
});
|
||||
if (!stale.length) return;
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
const registry = registryDefaults(types);
|
||||
for (const section of sections) {
|
||||
for (const key of FACILITY_KEYS) {
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key];
|
||||
}
|
||||
// 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 물넘이 = 없음)
|
||||
// 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점(물넘이로 지웠다 되돌린 때)
|
||||
const pipe = stale.find((entry) => {
|
||||
const gap = Math.abs(entry.chainage_m - Number(section.chainage_m));
|
||||
return section.structure ? gap < PIPE_MATCH_M : gap < 0.01;
|
||||
});
|
||||
if (!pipe) continue;
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
const values = { ...registry.pipe, ...pipe.options };
|
||||
if (facility === "ford_pavement") delete section.structure;
|
||||
else if (facility === "pipe")
|
||||
section.structure = `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`;
|
||||
else section.structure = types.find((type) => type.type_id === facility)?.name ?? facility;
|
||||
}
|
||||
attachCulvertSets(sections, buildCulvertSets(stale, registry));
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다.
|
||||
*
|
||||
|
||||
@@ -109,6 +109,13 @@ function clearSideSlots(): void {
|
||||
// 추가 기슭막이 칸은 고른 다단 벽이 있을 때만 서 있는다 — 비면 그룹째 내린다.
|
||||
if (target === sideSlots?.extra) group.hidden = true;
|
||||
}
|
||||
// 칸이 다른 명세로 옮겨 간 뒤 옛 상자에 남은 강조까지 끔(53-1)
|
||||
document
|
||||
.querySelectorAll<HTMLElement>("fieldset.is-active > legend[data-base-label]")
|
||||
.forEach((legend) => {
|
||||
legend.parentElement!.classList.remove("is-active");
|
||||
setGroupLabel(legend.parentElement!, "");
|
||||
});
|
||||
}
|
||||
|
||||
/** 행 DOM을 제자리 표식과 함께 옮긴다 — 숨은 행은 그대로 둔다. */
|
||||
@@ -161,8 +168,8 @@ function relocateSideRows(panelRoot: HTMLElement): void {
|
||||
// 이름표에 지금 만지는 구조물을 적는다 — "유입구 (기슭막이)", "유출구 (기슭막이(독립))".
|
||||
// 추가 기슭막이 칸은 이름 자체가 그 단이라 괄호 안에 단 번호가 온다(2026-08-30 지시 1).
|
||||
setGroupLabel(group, panelRoot.dataset.panelTitle ?? "");
|
||||
// 다단은 칸이 새로 서는 것이라 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2).
|
||||
if (side === "extra") group.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
// 고른 칸이 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2 · 유입 · 유출도 53-1).
|
||||
group.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function ensureDock(): void {
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface BasinBuildInput {
|
||||
* (2026-08-30 사용자 확정: 집수정은 그대로 두고 세월교만 세운다).
|
||||
*/
|
||||
leanRatio?: number;
|
||||
/** 벽 높이(m) — 좌측 폼 집수정 「높이」. 없으면 I형 노견 접점 · ㄴ·ㄷ형 내공 높이. */
|
||||
heightM?: number | null;
|
||||
}
|
||||
|
||||
/** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */
|
||||
@@ -143,10 +145,12 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
|
||||
const memberT = input.memberThicknessM ?? BASIN_MEMBER_THICKNESS_M;
|
||||
const floorThickness = input.floorThicknessM ?? memberT;
|
||||
// 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값).
|
||||
// 폼 「높이」를 적으면 세 형태 모두 그 높이.
|
||||
const wallHeight =
|
||||
shape === "I"
|
||||
input.heightM ??
|
||||
(shape === "I"
|
||||
? Math.max(diameterM + REVET_FREEBOARD_M, edge.elevation_m - anchor.elevation)
|
||||
: adjust.innerHeightM;
|
||||
: adjust.innerHeightM);
|
||||
// 기움은 **수직 기준 반전** — 상단이 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20).
|
||||
// bottomElevation을 내리면 면 기울기를 그대로 연장한다(I형 근입용).
|
||||
const defaultBottom = anchor.elevation - floorThickness;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* B06_Section_UI_Cross_Culvert_Geom.ts
|
||||
* 배수관 세트(배관·기슭막이·성토부) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
|
||||
* 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 기슭막이는 전면
|
||||
* 1:n 평행사변형(n = 품셈 표준경사 · `leanOf`), 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다
|
||||
* 1:n 평행사변형(n = 품셈 표준경사 · `outlineLeanOf`), 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다
|
||||
* (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
revetTargetHeight,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { leanOf } from "./B06_Section_UI_Cross_Lean";
|
||||
import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean";
|
||||
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type {
|
||||
BasinLayout,
|
||||
@@ -110,6 +110,8 @@ export function computeCulvertLayout(
|
||||
};
|
||||
const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST;
|
||||
const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin);
|
||||
const basinHeightM =
|
||||
(culvert.inlet.basin_height_m ?? 0) > 0 ? culvert.inlet.basin_height_m : null;
|
||||
|
||||
// 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백).
|
||||
const uphill = section.uphill_side ?? "left";
|
||||
@@ -157,7 +159,7 @@ export function computeCulvertLayout(
|
||||
const basinReason = culvert.hidden_pipe ? null : choiceReason;
|
||||
// ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치 — 바닥(=관 invert)이 따라 오른다.
|
||||
if (basinReason && basinShape !== "I") {
|
||||
inlet.elevation = inletInfo.edge.elevation_m - basinAdjust.innerHeightM;
|
||||
inlet.elevation = inletInfo.edge.elevation_m - (basinHeightM ?? basinAdjust.innerHeightM);
|
||||
} else if (basinReason) {
|
||||
// I형 — 관 시작점(내공 1.0m 자리) invert = 그 x의 원지반(토피 상한 이내).
|
||||
const startOffset =
|
||||
@@ -172,10 +174,10 @@ export function computeCulvertLayout(
|
||||
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
|
||||
const adjInlet = adjustOf(revetShift?.inlet);
|
||||
const adjOutlet = adjustOf(revetShift?.outlet);
|
||||
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5).
|
||||
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 높이) — 형태는 안 무늬만(외곽 같음).
|
||||
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
|
||||
const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
|
||||
const lean = leanOf({
|
||||
const lean = outlineLeanOf({
|
||||
form: wall.form,
|
||||
height_m: wall.pureHeight,
|
||||
section_mode: section.design?.section_mode,
|
||||
@@ -343,6 +345,7 @@ export function computeCulvertLayout(
|
||||
groundAt,
|
||||
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
|
||||
adjust: basinAdjust,
|
||||
heightM: basinHeightM,
|
||||
});
|
||||
basin = built.basin;
|
||||
basinPipeEnd = built.pipeEnd;
|
||||
@@ -624,9 +627,9 @@ export function computeCulvertLayout(
|
||||
)
|
||||
: null;
|
||||
|
||||
// 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈).
|
||||
// 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 높이마다 표준경사(형태 무관 · B08 은 다단을 안 셈).
|
||||
const extraLean = (form: string, pureHeightM: number): number =>
|
||||
leanOf({
|
||||
outlineLeanOf({
|
||||
form,
|
||||
height_m: pureHeightM,
|
||||
section_mode: section.design?.section_mode,
|
||||
|
||||
@@ -11,8 +11,14 @@
|
||||
|
||||
import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope";
|
||||
import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json";
|
||||
import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */
|
||||
export function leanOf(input: WallLeanInput): number {
|
||||
return wallLeanRatio(masonrySlope, input);
|
||||
}
|
||||
|
||||
/** 배관 흙막이 외곽 기울기 — 형태와 무관(형태는 안 무늬만) · 기본 형태의 표준경사. */
|
||||
export function outlineLeanOf(input: WallLeanInput): number {
|
||||
return leanOf({ ...input, form: REVET_FORM_DEFAULT });
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export function applyPipeOptionsToCache(
|
||||
own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
|
||||
own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
|
||||
own(culvert.inlet, "basin_after_m", num("inlet_basin_after_m"));
|
||||
own(culvert.inlet, "basin_height_m", num("inlet_basin_height_m"));
|
||||
// 연장이 바뀌면 옆 측점 링크도 달라진다 — 그 구조물이 덮는 범위만 다시 그린다.
|
||||
const reach = Math.max(
|
||||
culvert.inlet.revet_before_m ?? 0,
|
||||
|
||||
@@ -38,8 +38,10 @@ import {
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import {
|
||||
coversChainage,
|
||||
NO_SELECTION,
|
||||
PIPE_MATCH_M,
|
||||
readSelection,
|
||||
resolveSelection,
|
||||
selectDesign,
|
||||
type DesignSelection,
|
||||
@@ -172,6 +174,23 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
pushMarks();
|
||||
if (deps.projectId) writePendingStructures(deps.projectId, structures);
|
||||
deps.onStructuresChanged?.();
|
||||
// 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1)
|
||||
const picked = readSelection(deps.projectId);
|
||||
const moved = picked?.structureId
|
||||
? structures.find((entry) => entry.structure_id === picked.structureId)
|
||||
: undefined;
|
||||
if (moved && picked?.chainageM != null && !coversChainage(moved, picked.chainageM)) {
|
||||
const id = moved.structure_id ?? null;
|
||||
preferredId = id;
|
||||
deps.focusChainage(structureAnchorM(moved));
|
||||
preferredId = null;
|
||||
deps.markStructure?.(id); // 카드 고름이 옛 자리로 알약을 풀었으면 다시 세움
|
||||
selectDesign(
|
||||
deps.projectId,
|
||||
{ chainageM: structureAnchorM(moved), structureId: id },
|
||||
{ from: "b06" },
|
||||
);
|
||||
}
|
||||
},
|
||||
onSelect: (structure) => {
|
||||
if (!structure) {
|
||||
@@ -245,18 +264,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
}
|
||||
// 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이
|
||||
// [저장] 때 옛 값으로 되살아난다. 옮기기 전 자리로 찾는다(저장 시점 목록 기준).
|
||||
deps.applyPipeOptions?.(fromChainageM, patch);
|
||||
const hit = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
const was = hit?.facility ?? "pipe";
|
||||
const facility = attributes.facility ?? was;
|
||||
// 종류를 바꿈(54-4) — 옛 종류 그림 캐시에 새 종류 칸을 얹지 않음 · 옛 칸은 안 이음
|
||||
if (facility === was) deps.applyPipeOptions?.(fromChainageM, patch);
|
||||
if (hit) {
|
||||
const facility = hit.facility ?? "pipe";
|
||||
hit.options =
|
||||
mergeFacilityOptions({ facility, options: hit.options }, { facility, options: patch })
|
||||
.options ?? {};
|
||||
mergeFacilityOptions(
|
||||
{ facility: was, options: hit.options },
|
||||
{ facility, options: patch },
|
||||
).options ?? {};
|
||||
hit.facility = facility;
|
||||
}
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
if (facility !== was) {
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`)
|
||||
}
|
||||
},
|
||||
onPipeRemove: (chainageM) => removePipeAt(chainageM),
|
||||
onPipeSelect: (chainageM) => {
|
||||
@@ -561,11 +589,17 @@ export function wireStructureSelection(
|
||||
|
||||
const origRevet = stationControls.revetOffset.select;
|
||||
stationControls.revetOffset.select = (chainageM, key) => {
|
||||
markWallSelecting();
|
||||
// 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1)
|
||||
const nested = wallSelecting;
|
||||
// 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1)
|
||||
if (key) markWallSelecting();
|
||||
origRevet(chainageM, key);
|
||||
if (key) remember?.(ownerChainageOf(chainageM), key);
|
||||
if (key) panel.showAtChainage(ownerChainageOf(chainageM));
|
||||
else panel.releasePipe();
|
||||
else if (!nested) {
|
||||
remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림
|
||||
panel.releasePipe();
|
||||
}
|
||||
if (key) syncInletStructure();
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
@@ -573,22 +607,30 @@ export function wireStructureSelection(
|
||||
|
||||
const origFord = stationControls.ford.select;
|
||||
stationControls.ford.select = (chainageM, role) => {
|
||||
markWallSelecting();
|
||||
const nested = wallSelecting;
|
||||
if (role) markWallSelecting();
|
||||
origFord(chainageM, role);
|
||||
if (role) remember?.(chainageM, role);
|
||||
if (role) panel.showAtChainage(chainageM);
|
||||
else panel.releasePipe();
|
||||
else if (!nested) {
|
||||
remember?.(chainageM, "");
|
||||
panel.releasePipe();
|
||||
}
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다
|
||||
// (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1).
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
const origBox = stationControls.box.select;
|
||||
stationControls.box.select = (chainageM, role) => {
|
||||
markWallSelecting();
|
||||
const nested = wallSelecting;
|
||||
if (role) markWallSelecting();
|
||||
origBox(chainageM, role);
|
||||
if (role) remember?.(chainageM, role);
|
||||
if (role) panel.showAtChainage(chainageM);
|
||||
else panel.releasePipe();
|
||||
else if (!nested) {
|
||||
remember?.(chainageM, "");
|
||||
panel.releasePipe();
|
||||
}
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type DesignPageShared,
|
||||
} from "../A00_Common/design/design_shared";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { coversChainage } from "../A00_Common/design/design_selection";
|
||||
import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import type {
|
||||
CrossSection,
|
||||
@@ -133,6 +134,8 @@ export function createSectionProfile(
|
||||
let profile: DesignProfile | null = null;
|
||||
let detail: SectionDetailResponse | null = null;
|
||||
let marks: ReadonlyArray<StructureInstance> = [];
|
||||
/** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */
|
||||
let markedId: string | null = null;
|
||||
let types: ReadonlyArray<StructureType> = [];
|
||||
let stations: IrregularStation[] = [];
|
||||
let intervalM = 20;
|
||||
@@ -258,6 +261,7 @@ export function createSectionProfile(
|
||||
profile?.redraw();
|
||||
},
|
||||
selectMark(structureId) {
|
||||
markedId = structureId;
|
||||
profile?.setSelectedStructure(structureId);
|
||||
},
|
||||
showCard(section) {
|
||||
@@ -270,6 +274,10 @@ export function createSectionProfile(
|
||||
(entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M,
|
||||
);
|
||||
profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id);
|
||||
// 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1)
|
||||
const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined;
|
||||
if (!pipe && marked && coversChainage(marked, section.chainage_m))
|
||||
profile.setSelectedStructure(markedId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,10 +118,7 @@ export function buildStandardDiagram(): HTMLElement {
|
||||
text(252, 196, L("B06_Std_Diagram_Fill"), "b06-diag__label-sm", "middle"),
|
||||
);
|
||||
|
||||
// 모식도 설명 글은 [?] 설명서 `B06/표준_횡단면.md` 로 옮김(54-2)
|
||||
wrap.append(svg);
|
||||
const caption = document.createElement("p");
|
||||
caption.className = "b06-std__diagram-caption";
|
||||
caption.textContent = L("B06_Std_Diagram_Caption");
|
||||
wrap.append(caption);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -276,6 +276,7 @@ export function createStandardPanel(
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-std";
|
||||
root.dataset.helpTopic = "표준_횡단면"; // [?] 설명서 항목(54-2 · 이 상자 안내 글은 그 파일로 옮김)
|
||||
|
||||
// 변수 위치 안내 모식도(고정 도형): 각 설정값이 횡단면 어느 위치인지 표시(작업 C-5).
|
||||
root.append(buildStandardDiagram());
|
||||
@@ -367,9 +368,6 @@ export function createStandardPanel(
|
||||
const legend = document.createElement("summary");
|
||||
legend.className = "b06-std__legend";
|
||||
legend.textContent = L("B06_Std_Detail_Title");
|
||||
const note = document.createElement("p");
|
||||
note.className = "b06-std__note";
|
||||
note.textContent = L("B06_Std_LType_Note");
|
||||
fieldset.append(
|
||||
legend,
|
||||
buildScope("common"),
|
||||
@@ -377,7 +375,6 @@ export function createStandardPanel(
|
||||
buildScope("rock"),
|
||||
buildDivider("B06_Std_Section_PavedOnly"),
|
||||
buildScope("paved"),
|
||||
note,
|
||||
);
|
||||
return fieldset;
|
||||
};
|
||||
|
||||
@@ -173,12 +173,6 @@
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b06-std__diagram-caption {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ─── 다른 프로젝트에서 불러오기 (작업 C-6) ───────────────────────── */
|
||||
.b06-std__loader {
|
||||
display: flex;
|
||||
@@ -307,12 +301,6 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b06-std__note {
|
||||
margin: 0;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b06-std__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
|
||||
@@ -430,6 +430,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김).
|
||||
const confirmButtonRow = document.createElement("div");
|
||||
confirmButtonRow.className = "b07-drawing-actions__row";
|
||||
confirmButtonRow.dataset.helpRow = "";
|
||||
confirmButtonRow.append(frameEditor.button, confirmButton);
|
||||
confirmActions.append(frameEditor.banner, confirmButtonRow);
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ function sideSpec(
|
||||
num(defaults.inlet_basin_before_m, null),
|
||||
);
|
||||
spec.basin_after_m = num(options.inlet_basin_after_m, num(defaults.inlet_basin_after_m, null));
|
||||
spec.basin_height_m = num(options.inlet_basin_height_m, null);
|
||||
return spec;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""[?] 설명서 글(54-2) — `resources/help/<페이지>/*.md` 를 부를 때마다 읽어 줌.
|
||||
|
||||
사용자가 메모장으로 파일을 고치면 화면 고침 없이 다음에 열 때 바뀐 글이 보임.
|
||||
파일 이름 순서 = 모달 왼쪽 차례 순서 · 제목 = 첫 `# ` 줄(없으면 파일 이름).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter(prefix="/api/help", tags=["help"])
|
||||
|
||||
HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help"
|
||||
PAGE_RE = re.compile(r"^B0[2-9]$")
|
||||
|
||||
|
||||
def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]:
|
||||
if not PAGE_RE.match(page):
|
||||
raise HTTPException(status_code=404, detail="없는 페이지")
|
||||
folder = root / page
|
||||
items = []
|
||||
for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
head = next((line[2:].strip() for line in text.splitlines() if line.startswith("# ")), "")
|
||||
items.append({"key": path.stem, "title": head or path.stem, "text": text})
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{page}")
|
||||
async def get_help(page: str) -> dict[str, list[dict[str, str]]]:
|
||||
return {"items": read_help(page)}
|
||||
@@ -149,3 +149,14 @@
|
||||
ㄴ. 지난 정의 값이라는 항목은 필요없음. 향후 다 정리하고 사용하지 않는 코드들은 삭제 예정
|
||||
ㄷ. 추가 기슭막이(단)이 흙막이 추가 흙막이 추가에 대한 내용이었음. 별도 버튼으로 추가하는게 아님. 상단 컨테이너의 흙막이 옵션중 추가 기슭막이(단)의 값에 따라 생성되는데 이때 추가되는 기슭막이의 이름을 흙막이로 변경하고 선택되었을때 보여주는게 아니라 항상 보이게
|
||||
ㄹ. 도수로/돌붙임은 일단 유지
|
||||
|
||||
(10-6 사용자 · 공통 덧)
|
||||
공통
|
||||
- 기존 배관에서 박스암거로 변경할 경우 횡단도의 그림이 업데이트 안됨. 확인 및 업데이트
|
||||
- 추가로 관련된 정보 중 확인해줘야 할 내용이 기존에 화면의 B05/B06의 실시간 제어와 동작 속도/보안 개선을 위해 많은 작업이 있었는데 좌측 패널 통합 이후 많이 느려진 걸로 보임. 재확인. 이건 확인부터 하는게 좋겠음.
|
||||
|
||||
(10-6 사용자 · 추가 요청)
|
||||
- 사면 안정 그룹의 종류들 전체가 컨테이너 구성이 안되어 있음. 현재 기준 측점을 제외한 옵션들을 하나의 컨테이너에 넣고 그 안에 집계 항목 지정 넣을것
|
||||
|
||||
(10-6 사용자 · 추가 요청)
|
||||
- 안전부대용지 그룹의 사토장의 기준측점 표현이 예전 코드입. 토취장과 동일하게 구성
|
||||
|
||||
@@ -81,6 +81,7 @@ from common_util.common_util_auth import (
|
||||
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
|
||||
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
|
||||
from common_util.common_util_dev_unlock_router import router as dev_unlock_router
|
||||
from common_util.common_util_help_router import router as help_router
|
||||
from common_util.common_util_resource_monitor import sample_resources_loop
|
||||
from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop
|
||||
from config.config_db import close_db_pool, get_db_pool, init_db_pool
|
||||
@@ -661,6 +662,8 @@ app.include_router(m02_project_items_router, dependencies=protected)
|
||||
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
||||
# 그 위에 서버가 환경까지 한 번 더 본다.
|
||||
app.include_router(dev_unlock_router, dependencies=protected_with_company)
|
||||
# [?] 설명서 글(54-2) — 로그인만 · 파일을 부를 때마다 읽음.
|
||||
app.include_router(help_router, dependencies=protected)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 프로젝트 등록 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 파일입력 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,5 @@
|
||||
# 필요한 파일
|
||||
|
||||
- 계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.
|
||||
- 지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.
|
||||
- 지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 고르는 방법
|
||||
|
||||
- 여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.
|
||||
- 카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.
|
||||
- [임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 알아 둘 것
|
||||
|
||||
- 이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.
|
||||
- 작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.
|
||||
- 라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.
|
||||
@@ -0,0 +1,9 @@
|
||||
# 전처리 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 종단설계 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · BOX암거
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,13 @@
|
||||
# 구조물 · 기슭막이
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
|
||||
## 치수 근거
|
||||
|
||||
- 높이 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 설계자 입력 · 비우면 미확정(금액에 안 들어감)
|
||||
- 계획비탈 1:0.3~0.5(사방기술교본 2-나:129 · 3-가:151) — 제안값은 품셈 표준경사
|
||||
- 둑마루 두께 0.3~0.5m(사방기술교본 2-나:143 · 3-가:201 · 3-가:219(콘크리트기슭막이) · 2-나:135(콘크리트 0.3m)) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식
|
||||
- 뒷채움 조약돌 두께 — 사방기술교본 2-나:133(「사방품셈 규정에 의하여」) → 품셈 13-4-4 [주]⑨ 원문 L7164~7171
|
||||
- ⚠ 사방(계류) 교본 기준 — 임도 배수관 유입·유출부 적용은 확인 남음
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 돌쌓기(메)
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 돌쌓기(찰)
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 물넘이포장
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 배관
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 비탈면 녹화
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **공법**(횡단설계) — 「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 세월교
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 옹벽
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **높이** — 기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
@@ -0,0 +1,3 @@
|
||||
# 구조물 · 측구(옆도랑)
|
||||
|
||||
- 횡단 설계에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 큰돌쌓기
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
|
||||
@@ -0,0 +1,9 @@
|
||||
# 구조물 · 흙막이
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **단 수(다단)** — 1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)
|
||||
- **기준 올림(사면 위로)** — 0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)
|
||||
- **기준 좌우 이동** — 0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)
|
||||
- **조달**(횡단설계) — 「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음
|
||||
@@ -0,0 +1,9 @@
|
||||
# 횡단설계 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 표준 횡단면 설정
|
||||
|
||||
## 변수 위치 안내
|
||||
|
||||
- 표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).
|
||||
|
||||
## 표준횡단면 상세값
|
||||
|
||||
- L형 측구는 각 횡단면도에서 선택합니다.
|
||||
@@ -0,0 +1,9 @@
|
||||
# 상세설계 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 수량산출 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 원가계산 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -80,8 +80,10 @@ sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { partKey: "own", f
|
||||
out.store1 = sel.readSelection(P);
|
||||
sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { from: "b06" });
|
||||
out.sameQuiet = heard.length; // 같은 값 · 부재키 생략 → 알리지 않음
|
||||
sel.selectDesign(P, { chainageM: 205.2, structureId: "wall" }, { from: "b06" });
|
||||
out.keyKept = sel.readSelection(P)?.partKey; // 같은 것을 다시 적으면 부재키를 지킨다
|
||||
sel.selectDesign(P, { chainageM: 173.09, structureId: null }, { from: "b05" });
|
||||
out.keyKept = sel.readSelection(P)?.partKey; // 부재키를 안 주면 지킨다(옛 규칙)
|
||||
out.keyDropped = sel.readSelection(P)?.partKey ?? null; // 다른 구조물로 옮기면 버린다(53-1)
|
||||
sel.selectDesign(P, sel.NO_SELECTION, { from: "b05" });
|
||||
out.cleared = sel.readSelection(P);
|
||||
out.heardFrom = heard.map((h) => [h.chainageM, h.structureId, h.from]);
|
||||
@@ -150,7 +152,7 @@ out.apply = {
|
||||
};
|
||||
|
||||
// ── B05 진입 때 되살리기 — 구조물 id 가 있을 때만 구조물로 연다 ─────────────
|
||||
const restoreRun = (pickValue) => {
|
||||
const restoreRun = (pickValue, stationOpens = false) => {
|
||||
sel.selectDesign(P, sel.NO_SELECTION);
|
||||
if (pickValue) sel.selectDesign(P, pickValue.selection, { partKey: pickValue.key });
|
||||
const calls = [];
|
||||
@@ -163,15 +165,17 @@ const restoreRun = (pickValue) => {
|
||||
},
|
||||
};
|
||||
const controls = { select: (p) => calls.push(["3d", p.chainageM, p.key ?? null]) };
|
||||
const done = pickSession.restoreStructurePick(controls, P, section, (c) =>
|
||||
calls.push(["station", c]),
|
||||
);
|
||||
const done = pickSession.restoreStructurePick(controls, P, section, (c) => {
|
||||
calls.push(["station", c]);
|
||||
opened = stationOpens; // 같은 측점 관이 먼저 열림
|
||||
});
|
||||
return { calls, done };
|
||||
};
|
||||
out.restore = {
|
||||
pipeOnly: restoreRun({ selection: { chainageM: 85.05, structureId: null } }),
|
||||
structure: restoreRun({ selection: { chainageM: 205, structureId: "wall" }, key: "own" }),
|
||||
nothing: restoreRun(null),
|
||||
structureOverPipe: restoreRun({ selection: { chainageM: 440.24, structureId: "mirror" } }, true),
|
||||
};
|
||||
sel.selectDesign(P, sel.NO_SELECTION);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""54-2 [?] 설명서 — 서버가 resources/help/<페이지>/*.md 를 부를 때마다 읽음(고치면 바로 바뀜)."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from common_util.common_util_help_router import HELP_ROOT, read_help
|
||||
|
||||
|
||||
def test_every_page_has_manual_first():
|
||||
for n in range(2, 10):
|
||||
items = read_help(f"B0{n}")
|
||||
assert items and items[0]["key"] == "00_설명서"
|
||||
|
||||
|
||||
def test_title_order_and_live_edit():
|
||||
tmp_path = Path(tempfile.mkdtemp())
|
||||
page = tmp_path / "B05"
|
||||
page.mkdir()
|
||||
(page / "10_배관.md").write_text("# 배관\n\n본문", encoding="utf-8")
|
||||
(page / "00_설명서.md").write_text("제목 없음", encoding="utf-8")
|
||||
items = read_help("B05", tmp_path)
|
||||
assert [i["key"] for i in items] == ["00_설명서", "10_배관"]
|
||||
assert [i["title"] for i in items] == ["00_설명서", "배관"]
|
||||
(page / "10_배관.md").write_text("# 배관\n\n고친 글", encoding="utf-8")
|
||||
assert "고친 글" in read_help("B05", tmp_path)[1]["text"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05"])
|
||||
def test_bad_page_refused(page):
|
||||
with pytest.raises(HTTPException):
|
||||
read_help(page)
|
||||
|
||||
|
||||
def test_root_is_repo_resources():
|
||||
assert HELP_ROOT.parts[-2:] == ("resources", "help")
|
||||
@@ -105,13 +105,19 @@ def test_세션에_적고_읽는다(out):
|
||||
|
||||
def test_부재키를_안_주면_지키고_해제하면_비운다(out):
|
||||
assert out["keyKept"] == "own"
|
||||
assert out["keyDropped"] is None # 다른 구조물로 옮기면 옛 부재가 안 따라옴(53-1)
|
||||
assert out["cleared"] is None
|
||||
|
||||
|
||||
def test_같은_값은_알리지_않고_출처를_함께_알린다(out):
|
||||
assert out["sameQuiet"] == 1
|
||||
assert out["heardFrom"] == [[205, "wall", "b06"], [173.09, None, "b05"], [None, None, "b05"]]
|
||||
assert out["afterOff"] == 3 # 구독을 끊으면 더는 안 들림
|
||||
assert out["heardFrom"] == [
|
||||
[205, "wall", "b06"],
|
||||
[205.2, "wall", "b06"],
|
||||
[173.09, None, "b05"],
|
||||
[None, None, "b05"],
|
||||
]
|
||||
assert out["afterOff"] == 4 # 구독을 끊으면 더는 안 들림
|
||||
|
||||
|
||||
def test_알림_도중_다시_낸_값은_적기만_한다(out):
|
||||
@@ -172,6 +178,13 @@ def test_B05_되살리기_적힌_것이_없으면_끝난_것(out):
|
||||
assert out["restore"]["nothing"] == {"calls": [], "done": True}
|
||||
|
||||
|
||||
def test_B05_되살리기_같은_측점_관보다_구조물_id가_먼저(out):
|
||||
# B06 에서 고른 반사경(22+0) — 측점이 관을 먼저 열어도 id 의 구조물로 연다(53-1)
|
||||
over = out["restore"]["structureOverPipe"]
|
||||
assert over["calls"][-1] == ["selectAt", 440.24, "mirror"]
|
||||
assert over["done"] is True
|
||||
|
||||
|
||||
# ── 배선 ───────────────────────────────────────────────────────────────
|
||||
def test_횡단도_벽_구체_눌러도_폼은_같은_길_하나():
|
||||
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""54-4 — 저장으로 관 지점 종류만 바꾸면 횡단 측점 라벨(카드 제목)이 새 종류를 따라감.
|
||||
|
||||
측점은 다시 안 만들어지므로 라벨은 파일에 옛 종류로 남음 — 상세 조회가 지금 관 지점으로 고침.
|
||||
다른 구조물 라벨(옹벽 등)은 건드리지 않음.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations
|
||||
from common_util.common_util_drainage_pipes import parse_pipe_points
|
||||
|
||||
|
||||
def _write_points(root, points):
|
||||
edits = root / "B04_PreProcess" / "drainage" / "edits"
|
||||
edits.mkdir(parents=True, exist_ok=True)
|
||||
(edits / "pipe_points.json").write_text(json.dumps({"points": points}), encoding="utf-8")
|
||||
|
||||
|
||||
def _sections():
|
||||
return [
|
||||
{"chainage_m": 80.0},
|
||||
{"chainage_m": 85.05, "structure": "파형강관 D900"},
|
||||
{"chainage_m": 120.0, "structure": "옹벽"},
|
||||
{"chainage_m": 173.09, "structure": "세월교"},
|
||||
{"chainage_m": 258.12, "structure": "파형강관 D800"},
|
||||
]
|
||||
|
||||
|
||||
def test_relabel_follows_saved_kind(tmp_path):
|
||||
_write_points(
|
||||
tmp_path,
|
||||
[
|
||||
{"chainage_m": 85.05, "source": "spacing", "facility": "box_culvert"},
|
||||
{"chainage_m": 120.0, "source": "user", "facility": "box_culvert"}, # 옹벽 라벨 자리
|
||||
{
|
||||
"chainage_m": 173.09,
|
||||
"source": "stream",
|
||||
"facility": "pipe",
|
||||
"options": {"pipe_kind": "흄관", "pipe_diameter_mm": 1200},
|
||||
},
|
||||
{"chainage_m": 258.12, "source": "spacing", "facility": "ford_pavement"},
|
||||
],
|
||||
)
|
||||
sections = _sections()
|
||||
relabel_pipe_stations(tmp_path, sections)
|
||||
labels = {s["chainage_m"]: s.get("structure") for s in sections}
|
||||
assert labels == {
|
||||
80.0: None,
|
||||
85.05: "BOX암거",
|
||||
120.0: "옹벽", # 관 지점 라벨이 아닌 측점은 그대로
|
||||
173.09: "흄관 D1200",
|
||||
258.12: None, # 물넘이포장 = 라벨 없음(측점 생성 규칙과 같음)
|
||||
}
|
||||
|
||||
|
||||
def test_relabel_matches_generation_rule(tmp_path):
|
||||
points = [
|
||||
{"chainage_m": 85.05, "source": "spacing", "options": {"pipe_diameter_mm": 1000}},
|
||||
{"chainage_m": 173.09, "source": "stream", "facility": "ford_bridge"},
|
||||
]
|
||||
_write_points(tmp_path, points)
|
||||
sections = [
|
||||
{"chainage_m": 85.05, "structure": "세월교"},
|
||||
{"chainage_m": 173.09, "structure": "BOX암거"},
|
||||
]
|
||||
relabel_pipe_stations(tmp_path, sections)
|
||||
generated = dict(resolve_extra_stations(tmp_path, parse_pipe_points(points)))
|
||||
assert [s["structure"] for s in sections] == [generated[85.05], generated[173.09]]
|
||||
|
||||
|
||||
def test_relabel_without_file_keeps_labels(tmp_path):
|
||||
sections = _sections()
|
||||
relabel_pipe_stations(tmp_path, sections)
|
||||
assert sections == _sections()
|
||||
@@ -0,0 +1,607 @@
|
||||
/* 53-1 — B06 횡단도에서 구조물을 누르면 좌측 패널이 그 구조물로 켜지는가(전수)
|
||||
* 시작 화면: B06 횡단설계(#/b06-section) · 창마다 자기 검증 프로젝트 · 로그인 상태
|
||||
* 부르는 법: orca eval 은 30초 안팎에 끊김 — 단계는 시작만 하고 결과는 따로 읽음
|
||||
* orca eval --expression "window.__m531='setup'; $(cat 이 파일)" → 초안 사진 + 표 20종 중 없는 종류를 세션에만 넣음(저장 안 함)
|
||||
* orca eval --expression "window.__m531='check'; $(cat 이 파일)" → 전수 목록(벽 · 구체 · 카드 · 같은 측점 · 값 · 옮기기)
|
||||
* orca eval --expression "window.__m531='handoff'; $(cat 이 파일)" → 저장 안 하고 B06 → B05 → B06
|
||||
* orca eval --expression "window.__m531='restore'; $(cat 이 파일)" → 초안을 사진대로 · 뒤에 orca reload
|
||||
* 결과: orca eval --expression "JSON.stringify(window.__r531)" (끝나기 전엔 null)
|
||||
* 쓰는 것: sessionStorage 만(서버 저장 없음 · 공유 storage 안 씀)
|
||||
* 기대 수치: check · handoff 의 bad = [] · rows 마다 ok = true
|
||||
* ① 종류 · 기준 측점 = 누른 것 ② 컨테이너 몫(유입 · 유출 · N단 · 좌우)이면 그 상자 강조 + 보임
|
||||
* ③ 목록 강조 1 · 종단 알약 1 ④ 좌측 값 → 횡단 다시 그림 ⑤ 다시 누름 = 풂 · 빈 곳 · 같은 측점 · 옮기기 · B05 ↔ B06
|
||||
*/
|
||||
(() => {
|
||||
const MODE = window.__m531 || "check";
|
||||
window.__r531 = null;
|
||||
const PROJECT = localStorage.getItem("frd_current_project_id");
|
||||
const SNAP = "ui531-snap";
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 20000) => {
|
||||
for (let t = 0; t < ms; t += 250) {
|
||||
if (fn()) return true;
|
||||
await w(250);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const sec = () => document.querySelector(".b05-structure-section");
|
||||
const selects = () => [...sec().querySelectorAll(".b05-structure__grid select")];
|
||||
const fire = (el, v) => {
|
||||
el.value = v;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
};
|
||||
const flat = (el) => el.innerText.replace(/\s+/g, " ").trim();
|
||||
const listRows = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
|
||||
const cards = () => [...document.querySelectorAll("article.b06-cross-card")];
|
||||
const chainOf = (card) => {
|
||||
const m = card.id.match(/(\d{9})(\d{3})$/);
|
||||
return m ? Number(m[1]) + Number(m[2]) / 1000 : NaN;
|
||||
};
|
||||
const selectedCard = () => document.querySelector(".b06-cross-card--selected");
|
||||
const click = (el) => el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
/** 지금 좌측 패널 · 목록 · 알약 · 선택 칸. */
|
||||
const state = () => {
|
||||
const pickKey = Object.keys(sessionStorage).find(
|
||||
(k) => k.includes("structure-pick") && k.includes(PROJECT),
|
||||
);
|
||||
let pick = null;
|
||||
try {
|
||||
pick = JSON.parse(sessionStorage.getItem(pickKey));
|
||||
} catch {
|
||||
/* 없음 */
|
||||
}
|
||||
const box = [...document.querySelectorAll("fieldset.is-active")].map((f) => {
|
||||
const r = f.getBoundingClientRect();
|
||||
let sc = f.parentElement;
|
||||
while (sc && !/(auto|scroll)/.test(getComputedStyle(sc).overflowY)) sc = sc.parentElement;
|
||||
const b = sc ? sc.getBoundingClientRect() : { top: 0, bottom: innerHeight };
|
||||
const seen = r.height > 0 && r.top < Math.min(b.bottom, innerHeight) && r.bottom > b.top;
|
||||
return { legend: flat(f.querySelector("legend")), seen };
|
||||
});
|
||||
const anchor = sec().querySelectorAll(".b05-structure__position-row > label")[2];
|
||||
return {
|
||||
type: selects()[1]?.value ?? "",
|
||||
anchor: anchor ? [...anchor.querySelectorAll("input")].map((i) => i.value).join("+") : "",
|
||||
pick,
|
||||
box,
|
||||
list: listRows()
|
||||
.filter((li) => li.classList.contains("is-selected"))
|
||||
.map(flat),
|
||||
pill: [...document.querySelectorAll(".b05-structure__mark.is-selected")].map((m) => m.title),
|
||||
card: selectedCard()?.id ?? null,
|
||||
};
|
||||
};
|
||||
const bad = [];
|
||||
const known = []; // 고치지 않은 흠(횡단 그리기 몫) — 보고에 올림
|
||||
const rows = [];
|
||||
const judge = (name, ok, s, note = "") => {
|
||||
rows.push({
|
||||
name,
|
||||
ok,
|
||||
note,
|
||||
type: s.type,
|
||||
key: s.pick?.key ?? null,
|
||||
box: s.box,
|
||||
list: s.list,
|
||||
pill: s.pill.length,
|
||||
card: s.card?.slice(-9) ?? null,
|
||||
});
|
||||
if (!ok) bad.push(name);
|
||||
};
|
||||
/** 부재키 → 강조돼야 할 상자 이름. own(사면 구조물)은 컨테이너가 없음. */
|
||||
const boxFor = (key) =>
|
||||
key === "inlet"
|
||||
? /유입|좌|우/
|
||||
: key === "outlet"
|
||||
? /유출|좌|우/
|
||||
: /^b?extra/.test(key ?? "")
|
||||
? /추가/
|
||||
: key === "left" || key === "right"
|
||||
? /유입|유출/
|
||||
: null;
|
||||
const boxOk = (s) => {
|
||||
const want = boxFor(s.pick?.key);
|
||||
if (!want) return s.box.length === 0;
|
||||
return s.box.length === 1 && want.test(s.box[0].legend) && s.box[0].seen;
|
||||
};
|
||||
const commonOk = (s) =>
|
||||
!!s.type && s.list.length === 1 && s.pill.length === 1 && !!s.pick && boxOk(s);
|
||||
const hitsOf = (card) => [
|
||||
...card.querySelectorAll(".b06-chart__culvert-revet-hit, .b06-chart__box-hit"),
|
||||
];
|
||||
const uniqueHits = (card) => [...new Set(hitsOf(card))];
|
||||
/** 카드 고름을 풂(같은 카드 다시 누름 = 풂). */
|
||||
const clearCard = async () => {
|
||||
const c = selectedCard();
|
||||
if (c) {
|
||||
click(c.querySelector("svg"));
|
||||
await w(900);
|
||||
}
|
||||
};
|
||||
const openProfile = async () => {
|
||||
const prof = document.querySelector(".b05-route-profile");
|
||||
if (prof?.classList.contains("is-collapsed")) {
|
||||
document.querySelector(".ui-workflow-overlay__handle--bottom")?.click();
|
||||
await w(2500);
|
||||
}
|
||||
// 뒤에 깔린 탭은 그리기가 밀림 — 알약이 설 때까지 크기 알림을 보냄
|
||||
for (let i = 0; i < 20 && !document.querySelector(".b05-structure__mark"); i++) {
|
||||
dispatchEvent(new Event("resize"));
|
||||
await w(500);
|
||||
}
|
||||
};
|
||||
/** 세션에만 넣는 종류(표 01~08) — 이미 있으면 건너뜀. [군, 종류, 측점, +m, 길이(구간형)] */
|
||||
const ADD = [
|
||||
["G", "pavement_concrete", 22, 0, 10],
|
||||
["A", "cross_underdrain", 22, 0],
|
||||
["E", "mirror", 22, 0],
|
||||
["A", "ford_pavement", 3, 0],
|
||||
["A", "open_ditch", 5, 0],
|
||||
["A", "cross_drain_exposed", 6, 0],
|
||||
["E", "evac_road", 9, 0],
|
||||
["E", "guardrail", 11, 0, 10],
|
||||
["E", "curb", 14, 0],
|
||||
["E", "hazard_sign", 15, 0],
|
||||
["E", "position_sign", 18, 0],
|
||||
["E", "water_intake", 19, 0],
|
||||
["E", "completion_sign", 20, 0],
|
||||
["E", "barrier_gate", 21, 0],
|
||||
["E", "spoil_bank", 24, 0],
|
||||
["E", "borrow_pit", 25, 0],
|
||||
["호환", "etc", 27, 0],
|
||||
];
|
||||
const addOne = async ([g, t, no, plus, len]) => {
|
||||
const btn = (label) => [...sec().querySelectorAll("button")].find((b) => flat(b) === label);
|
||||
btn("리셋")?.click();
|
||||
await w(400);
|
||||
const n = listRows().length; // A군은 측점을 넣는 순간 임시로 목록에 섬
|
||||
const [gs, ts] = selects();
|
||||
fire(gs, g);
|
||||
await w(300);
|
||||
if (![...ts.options].some((o) => o.value === t)) return `${t}: 종류 없음`;
|
||||
fire(ts, t);
|
||||
await w(1200);
|
||||
const labels = [...sec().querySelectorAll(".b05-structure__position-row > label")];
|
||||
const anchor = labels[2].querySelectorAll("input");
|
||||
fire(anchor[0], String(no));
|
||||
await w(200);
|
||||
fire(anchor[1], String(plus));
|
||||
await w(300);
|
||||
const field = (re) =>
|
||||
[...sec().querySelectorAll("label")]
|
||||
.filter((l) => l.offsetParent)
|
||||
.find((l) => re.test(flat(l)))
|
||||
?.querySelector("input");
|
||||
if (len) {
|
||||
// 구간형 — 길이 · 전(시작 = 기준 − 전), 사토장은 시작 · 종료 칸
|
||||
if (field(/^길이/)) {
|
||||
fire(field(/^길이/), String(len));
|
||||
fire(field(/^기준측점 전/), String(len / 2));
|
||||
}
|
||||
if (!labels[1].hidden) {
|
||||
const [s0, s1] = labels[1].querySelectorAll("input");
|
||||
const [e0, e1] = labels[3].querySelectorAll("input");
|
||||
fire(s0, String(no - 1));
|
||||
fire(s1, "10");
|
||||
fire(e0, String(no));
|
||||
fire(e1, "10");
|
||||
}
|
||||
await w(300);
|
||||
} else if (!labels[1].hidden) {
|
||||
const [s0, s1] = labels[1].querySelectorAll("input");
|
||||
const [e0, e1] = labels[3].querySelectorAll("input");
|
||||
fire(s0, String(no - 1));
|
||||
fire(s1, "10");
|
||||
fire(e0, String(no));
|
||||
fire(e1, "10");
|
||||
await w(300);
|
||||
}
|
||||
btn("추가")?.click();
|
||||
await w(1500);
|
||||
return listRows().length > n ? null : `${t}: 안 들어감`;
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
// 뒤에 깔린 탭(ORCA)은 화면 프레임이 안 돎 — rAF 를 기다리는 넘김 · 스크롤이 멈춤. 시험 동안만 시간으로 대신
|
||||
let frame = false;
|
||||
requestAnimationFrame(() => (frame = true));
|
||||
await w(300);
|
||||
if (!frame && !window.__raf531) {
|
||||
window.__raf531 = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16);
|
||||
}
|
||||
await until(() => listRows().length > 0 && cards().length > 0);
|
||||
await w(1000);
|
||||
const out = { mode: MODE, project: PROJECT };
|
||||
|
||||
if (MODE === "setup") {
|
||||
if (!sessionStorage.getItem(SNAP))
|
||||
sessionStorage.setItem(
|
||||
SNAP,
|
||||
JSON.stringify(
|
||||
Object.entries(sessionStorage).filter(([k]) => k.startsWith("aislo:draft:")),
|
||||
),
|
||||
);
|
||||
const have = new Set(listRows().map((li) => flat(li).replace(/^[\d+~ ]+/, "")));
|
||||
out.added = [];
|
||||
for (const item of ADD) {
|
||||
const [g, t] = item;
|
||||
selects()[0] && fire(selects()[0], g);
|
||||
await w(200);
|
||||
const name = [...selects()[1].options].find((o) => o.value === t)?.text ?? t;
|
||||
if ([...have].some((h) => h.startsWith(name))) continue;
|
||||
const err = await addOne(item);
|
||||
out.added.push(err ?? name);
|
||||
if (err) bad.push(err);
|
||||
}
|
||||
sec()
|
||||
.querySelectorAll("button")
|
||||
.forEach((b) => flat(b) === "리셋" && b.click());
|
||||
// 다단 벽(추가 기슭막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로)
|
||||
out.tier = null;
|
||||
for (const card of cards().filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) {
|
||||
if (/추가\(성토부\)/.test(card.innerHTML)) {
|
||||
out.tier = card.id;
|
||||
break;
|
||||
}
|
||||
const outlet = () =>
|
||||
[
|
||||
...document.getElementById(card.id).querySelectorAll(".b06-chart__culvert-revet-hit"),
|
||||
].find((h) => /유출/.test(h.querySelector("title")?.textContent ?? ""));
|
||||
click(outlet());
|
||||
await w(1400);
|
||||
[...sec().querySelectorAll(".b06-structure-panel__struct--tier button")]
|
||||
.find((b) => b.offsetParent && /추가/.test(b.title))
|
||||
?.click();
|
||||
await w(2500);
|
||||
const added = /추가\(성토부\)/.test(document.getElementById(card.id).innerHTML);
|
||||
click(outlet()); // 풂
|
||||
await w(1000);
|
||||
if (added) {
|
||||
out.tier = card.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!out.tier) bad.push("다단 벽 못 세움");
|
||||
out.rows = listRows().map(flat);
|
||||
}
|
||||
|
||||
if (MODE === "check") {
|
||||
await openProfile();
|
||||
// ── 가. 횡단도에 그려진 고를 거리 전부 — 다른 카드에서 처음 누름 · 같은 카드 다른 부재 ──
|
||||
await clearCard();
|
||||
const targets = cards().filter((c) => hitsOf(c).length);
|
||||
for (const card of targets) {
|
||||
const n = uniqueHits(card).length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const hit = uniqueHits(document.getElementById(card.id))[i];
|
||||
const before = state();
|
||||
click(hit);
|
||||
await w(1400);
|
||||
let s = state();
|
||||
// 이미 고른 부재였으면 풀렸을 것 — 한 번 더 눌러 고름
|
||||
if (!s.pick?.key && before.pick?.key) {
|
||||
click(uniqueHits(document.getElementById(card.id))[i]);
|
||||
await w(1400);
|
||||
s = state();
|
||||
}
|
||||
judge(`벽 ${card.id.slice(-9)}#${i}`, commonOk(s) && s.card === card.id, s);
|
||||
}
|
||||
}
|
||||
// ── 나. 같은 부재 다시 누름 = 풂(카드 고름은 남음) ──
|
||||
const t0 = targets[0];
|
||||
if (t0) {
|
||||
click(uniqueHits(t0)[0]);
|
||||
await w(1400);
|
||||
if (!state().pick?.key) {
|
||||
click(uniqueHits(document.getElementById(t0.id))[0]);
|
||||
await w(1400);
|
||||
}
|
||||
click(uniqueHits(document.getElementById(t0.id))[0]);
|
||||
await w(1400);
|
||||
const s = state();
|
||||
judge("다시 누름 = 풂", !s.pick?.key && s.box.length === 0 && s.card === t0.id, s);
|
||||
}
|
||||
// ── 다. 빈 곳 — 구조물 카드 · 빈 카드 · 다시 누름 = 카드 풂 ──
|
||||
await clearCard();
|
||||
const pipeCard = targets.find((c) => /유입 집수정|유출 기슭막이/.test(c.innerHTML));
|
||||
if (pipeCard) {
|
||||
click(pipeCard.querySelector("svg"));
|
||||
await w(1400);
|
||||
let s = state();
|
||||
judge("빈 곳(구조물 카드)", !!s.type && !s.pick?.key && s.box.length === 0, s);
|
||||
click(document.getElementById(pipeCard.id).querySelector("svg"));
|
||||
await w(1400);
|
||||
s = state();
|
||||
judge("빈 곳 다시 = 카드 풂", !s.card && !s.type && !s.pick, s);
|
||||
}
|
||||
const used = new Set(listRows().map((li) => flat(li).match(/^(\d+)\+/)?.[1]));
|
||||
const empty = cards().find((c) => !used.has(String(Math.floor(chainOf(c) / 20))));
|
||||
if (empty) {
|
||||
click(empty.querySelector("svg"));
|
||||
await w(1400);
|
||||
const s = state();
|
||||
judge("빈 카드", !s.type && s.box.length === 0 && s.card === empty.id, s);
|
||||
await clearCard();
|
||||
}
|
||||
// ── 라. 표 20종 — 목록으로 골라 종류 확인 → 그 카드 빈 곳 눌러 같은 것이 켜지나 ──
|
||||
const rowsNow = listRows().map(flat);
|
||||
for (const text of rowsNow) {
|
||||
const li = listRows().find((l) => flat(l) === text);
|
||||
if (!li) continue;
|
||||
await clearCard();
|
||||
(li.querySelector("button") ?? li).click();
|
||||
await w(1400);
|
||||
const viaList = state();
|
||||
const card = viaList.card && document.getElementById(viaList.card);
|
||||
judge(
|
||||
`목록 ${text}`,
|
||||
!!viaList.type && viaList.list.length === 1 && !!card && viaList.pill.length === 1,
|
||||
viaList,
|
||||
);
|
||||
if (!card) continue;
|
||||
await clearCard();
|
||||
click(card.querySelector("svg"));
|
||||
await w(1400);
|
||||
const viaCard = state();
|
||||
const same = viaCard.type === viaList.type;
|
||||
if (same)
|
||||
judge(`카드 ${text}`, viaCard.list.length === 1 && viaCard.pill.length === 1, viaCard);
|
||||
// 같은 카드에 관(그려진 것)이 있으면 관이 먼저 — 그림 없는 구조물은 목록으로만(흠 · 그리기 몫)
|
||||
else if (uniqueHits(card).length && viaCard.type)
|
||||
known.push(`카드 ${text} → ${viaCard.list[0] ?? viaCard.type}`);
|
||||
else judge(`카드 ${text}`, false, viaCard, `카드는 ${viaCard.type || "빈 것"}`);
|
||||
}
|
||||
await clearCard();
|
||||
// ── 마. 같은 측점 여럿 — 목록 구조물 → 그 카드 관 벽 → 다시 목록 ──
|
||||
const shared = rowsNow.find(
|
||||
(t) =>
|
||||
!/배관/.test(t) &&
|
||||
rowsNow.some((u) => /배관/.test(u) && u.split(" ")[0] === t.split(" ")[0]),
|
||||
);
|
||||
if (shared) {
|
||||
const pipeRow = rowsNow.find(
|
||||
(u) => /배관/.test(u) && u.split(" ")[0] === shared.split(" ")[0],
|
||||
);
|
||||
const pick = (t) => {
|
||||
const li = listRows().find((l) => flat(l) === t);
|
||||
(li.querySelector("button") ?? li).click();
|
||||
};
|
||||
pick(pipeRow);
|
||||
await w(1400);
|
||||
const pc = selectedCard();
|
||||
pick(shared);
|
||||
await w(1400);
|
||||
let s = state();
|
||||
judge(`같은 측점: 목록 ${shared}`, s.type !== "pipe" && !s.pick?.key && !!s.pick?.sid, s);
|
||||
const wall = pc && uniqueHits(document.getElementById(pc.id))[0];
|
||||
if (wall) {
|
||||
click(wall);
|
||||
await w(1400);
|
||||
s = state();
|
||||
judge("같은 측점: 그 카드 관 벽", s.type === "pipe" && commonOk(s), s);
|
||||
}
|
||||
pick(shared);
|
||||
await w(1400);
|
||||
s = state();
|
||||
judge("같은 측점: 다시 목록", s.type !== "pipe" && !s.pick?.key && !!s.pick?.sid, s);
|
||||
}
|
||||
// ── 바. 좌측 값 → 횡단 다시 그림 → 되돌림(관 유출 형태 · 세월교 련 수 · 박스 유입 구조) ──
|
||||
const titles = (card) =>
|
||||
[...document.getElementById(card.id).querySelectorAll("title")].map((t) => t.textContent);
|
||||
const ctlOf = (key) =>
|
||||
[...sec().querySelectorAll(`[data-spec-item="${key}"]`)]
|
||||
.find((e) => e.offsetParent)
|
||||
?.querySelector("input, select");
|
||||
const bodyCase = async (card, key, value, re) => {
|
||||
await clearCard();
|
||||
click(uniqueHits(document.getElementById(card.id))[0]);
|
||||
await w(1400);
|
||||
const ctl = ctlOf(key);
|
||||
if (!ctl) return judge(`값 → 횡단: ${key}`, false, state(), "칸 없음");
|
||||
const was = ctl.value;
|
||||
const before = titles(card).join("|");
|
||||
fire(ctl, value);
|
||||
await w(3000);
|
||||
const after = titles(card).join("|");
|
||||
judge(
|
||||
`값 → 횡단: ${key}=${value}`,
|
||||
re(after) && after !== before && boxOk(state()),
|
||||
state(),
|
||||
);
|
||||
fire(ctlOf(key), was);
|
||||
await w(3000);
|
||||
const back = titles(card).join("|");
|
||||
// 빈 칸 = 자동(24-4a) — 서버가 채운 기본 연장을 앞단이 지워 「연장」 표기만 빠짐(흠 · 저장하면 다시 섬)
|
||||
const noSpan = (t) => t.replace(/ · 연장 [\d.]+m/g, "");
|
||||
if (back !== before && noSpan(back) === noSpan(before))
|
||||
known.push(
|
||||
`값 되돌림 ${key}: 벽 설명의 「연장」 표기 빠짐(서버 기본값 · 저장 뒤 다시 섬)`,
|
||||
);
|
||||
judge(`값 되돌림: ${key}`, noSpan(back) === noSpan(before), state());
|
||||
};
|
||||
// 형태 칸은 구조를 고른 관에서만 섬 — 그 칸이 서는 관을 찾음
|
||||
for (const c of targets.filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) {
|
||||
await clearCard();
|
||||
click(uniqueHits(document.getElementById(c.id))[0]);
|
||||
await w(1400);
|
||||
if (!ctlOf("outlet.form")) continue;
|
||||
await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 기슭막이 콘크리트/.test(t));
|
||||
break;
|
||||
}
|
||||
const fordCard = targets.find((c) => /련/.test(titles(c).join("|")));
|
||||
if (fordCard) await bodyCase(fordCard, "ford.count", "3", (t) => /3련/.test(t));
|
||||
const boxCard = targets.find((c) => c.querySelector(".b06-chart__box-hit"));
|
||||
if (boxCard)
|
||||
await bodyCase(
|
||||
boxCard,
|
||||
"inlet.structure",
|
||||
"없음",
|
||||
(t) => (t.match(/날개벽 구간/g) ?? []).length === 1,
|
||||
);
|
||||
// ── 사. 옮기기 — 사면 구조물 기준 측점 +2 → 목록 · 카드 · 알약 · 선택 칸이 따라감 → 되돌림 ──
|
||||
await clearCard();
|
||||
// 사면 구조물 차례로 — 필수 칸이 빈 옛 구조물은 측점을 바꿔도 반영이 막히고 그 칸으로 포커스만 감(흠)
|
||||
const input = () =>
|
||||
sec()
|
||||
.querySelectorAll(".b05-structure__position-row > label")[2]
|
||||
.querySelectorAll("input")[0];
|
||||
const move = async (v) => {
|
||||
const el = input();
|
||||
el.focus();
|
||||
fire(el, String(v));
|
||||
el.blur();
|
||||
el.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
await w(8000);
|
||||
return state();
|
||||
};
|
||||
for (const c of targets.filter((c) =>
|
||||
/독립 기슭막이/.test(c.querySelector(".b06-chart__culvert-revet title")?.textContent ?? ""),
|
||||
)) {
|
||||
await clearCard();
|
||||
click(uniqueHits(document.getElementById(c.id))[0]);
|
||||
await w(1400);
|
||||
const s0 = state();
|
||||
const no = Number(input().value);
|
||||
let s = await move(no + 2);
|
||||
if (s.list[0] === s0.list[0]) {
|
||||
const stuck = flat(document.activeElement?.closest("label") ?? document.body).slice(
|
||||
0,
|
||||
12,
|
||||
);
|
||||
known.push(`옮기기 ${s0.list[0]}: 반영 안 됨 — 빈 필수 칸 「${stuck}」 으로 포커스만`);
|
||||
await move(no);
|
||||
continue;
|
||||
}
|
||||
judge(
|
||||
"옮기기: 목록 · 카드 · 알약 · 선택 칸",
|
||||
s.list[0]?.startsWith(`${no + 2}+`) &&
|
||||
Math.abs(chainOf(document.getElementById(s.card)) - (no + 2) * 20) < 1 &&
|
||||
s.pill.length === 1 &&
|
||||
s.pick?.sid === s0.pick?.sid &&
|
||||
Math.abs(s.pick.at - (no + 2) * 20) < 1,
|
||||
s,
|
||||
);
|
||||
s = await move(no);
|
||||
judge(
|
||||
"옮기기 되돌림",
|
||||
s.list[0] === s0.list[0] &&
|
||||
Math.abs(chainOf(document.getElementById(s.card)) - no * 20) < 1 &&
|
||||
s.pill.length === 1,
|
||||
s,
|
||||
);
|
||||
break;
|
||||
}
|
||||
await clearCard();
|
||||
}
|
||||
|
||||
if (MODE === "handoff") {
|
||||
await openProfile();
|
||||
const go = async (hash) => {
|
||||
location.hash = hash;
|
||||
await until(() => listRows().length > 0 && selects()[1]?.value);
|
||||
await w(5000);
|
||||
return state();
|
||||
};
|
||||
if (!location.hash.includes("b06")) await go("#/b06-section");
|
||||
// 같은 측점에 다른 구조물이 있는 관을 고름(없으면 아무 관)
|
||||
const texts = listRows().map(flat);
|
||||
const pipeRow =
|
||||
texts.find(
|
||||
(t) =>
|
||||
/배관/.test(t) &&
|
||||
texts.some((u) => !/배관/.test(u) && u.split(" ")[0] === t.split(" ")[0]),
|
||||
) ?? texts.find((t) => /배관/.test(t));
|
||||
await clearCard();
|
||||
for (let i = 0; i < 2 && !selectedCard(); i++) {
|
||||
// 목록 다시 누름 = 풂 — 이미 골라져 있었으면 한 번 더
|
||||
const li = listRows().find((l) => flat(l) === pipeRow);
|
||||
(li.querySelector("button") ?? li).click();
|
||||
await w(1400);
|
||||
}
|
||||
const pipeCard = selectedCard();
|
||||
const outletHit = () =>
|
||||
uniqueHits(document.getElementById(pipeCard.id)).find((h) =>
|
||||
/유출/.test(h.querySelector("title")?.textContent ?? ""),
|
||||
);
|
||||
await clearCard();
|
||||
click(outletHit());
|
||||
await w(1400);
|
||||
const a = state();
|
||||
const b05 = await go("#/b05-profile");
|
||||
const b06 = await go("#/b06-section");
|
||||
judge(
|
||||
"관 유출 → B05",
|
||||
b05.type === a.type && b05.anchor === a.anchor && b05.list[0] === a.list[0],
|
||||
b05,
|
||||
);
|
||||
judge(
|
||||
"B05 → B06 유출 그대로",
|
||||
b06.pick?.key === "outlet" && boxOk(b06) && b06.list[0] === a.list[0],
|
||||
b06,
|
||||
);
|
||||
// 같은 측점 다른 구조물을 목록으로 — 부재키가 따라오지 않고 그 구조물이 열림
|
||||
const station = a.list[0]?.split(" ")[0];
|
||||
const other = listRows().find(
|
||||
(li) => flat(li).startsWith(station + " ") && !/배관/.test(flat(li)),
|
||||
);
|
||||
if (other) {
|
||||
const otherText = flat(other);
|
||||
(other.querySelector("button") ?? other).click();
|
||||
await w(1400);
|
||||
const c = state();
|
||||
const b05b = await go("#/b05-profile");
|
||||
const b06b = await go("#/b06-section");
|
||||
judge("같은 측점 구조물 → B05", b05b.type === c.type && !b05b.pick?.key, b05b);
|
||||
judge("B05 → B06 그 구조물", b06b.type === c.type && b06b.box.length === 0, b06b);
|
||||
// B06 에서 관 카드를 고른 채 B05 에서 같은 측점 다른 구조물을 고르고 돌아옴 — 카드가 이미 골라져 있어도 그 구조물
|
||||
const pick = (t) => {
|
||||
const row = listRows().find((l) => flat(l) === t);
|
||||
(row.querySelector("button") ?? row).click();
|
||||
};
|
||||
pick(pipeRow);
|
||||
await w(1400);
|
||||
await go("#/b05-profile");
|
||||
pick(otherText);
|
||||
await w(1400);
|
||||
const d = state();
|
||||
const b06c = await go("#/b06-section");
|
||||
judge(
|
||||
"B06 카드 골라진 채 B05 에서 다른 것 → B06",
|
||||
b06c.type === d.type && b06c.type !== "pipe",
|
||||
b06c,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (MODE === "restore") {
|
||||
const snap = sessionStorage.getItem(SNAP);
|
||||
if (!snap) out.error = "사진 없음";
|
||||
else {
|
||||
for (const k of Object.keys(sessionStorage))
|
||||
if (k.startsWith("aislo:draft:") || k.includes("section-detail"))
|
||||
sessionStorage.removeItem(k);
|
||||
for (const [k, v] of JSON.parse(snap)) sessionStorage.setItem(k, v);
|
||||
sessionStorage.removeItem(SNAP);
|
||||
const sorted = (list) => JSON.stringify([...list].sort(([a], [b]) => a.localeCompare(b)));
|
||||
out.same =
|
||||
sorted(Object.entries(sessionStorage).filter(([k]) => k.startsWith("aislo:draft:"))) ===
|
||||
sorted(JSON.parse(snap));
|
||||
}
|
||||
}
|
||||
|
||||
if (window.__raf531) {
|
||||
window.requestAnimationFrame = window.__raf531;
|
||||
delete window.__raf531;
|
||||
}
|
||||
out.count = rows.length;
|
||||
out.bad = bad;
|
||||
out.known = known;
|
||||
out.rows = out.rows ?? rows;
|
||||
return out;
|
||||
};
|
||||
run()
|
||||
.then((out) => (window.__r531 = out))
|
||||
.catch((e) => (window.__r531 = { error: String(e?.stack ?? e) }));
|
||||
return "started";
|
||||
})();
|
||||
@@ -0,0 +1,114 @@
|
||||
/* 54-2 — [?] 설명서: B02~B09 좌측 패널 아래 단추 묶음 맨 왼쪽 · 모달 = 왼쪽 차례 · 오른쪽 파일 글 ·
|
||||
* 구조물 종류를 고르면 그 항목 먼저 · 패널 안 설명 글 0
|
||||
* 시작 화면: 아무 B 화면(로그인 · 환경 배정 storage 프로젝트가 현재 프로젝트)
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) · 1분 넘으면 끊기므로
|
||||
* `window.__help542 = ["b02-proj-register", …];` 를 앞에 붙여 네 화면씩 두 번
|
||||
* 쓰는 것: 없음(주소 이동 · 누르기 · 모달 닫기 · 끝나면 처음 주소로)
|
||||
* 파일 고침 반영: 셸이 `resources/help/B04/99_임시.md` 를 만들고(글 A) 부름 → 글 B 로 고치고 다시 부름 →
|
||||
* `temp` 가 A → B 로 바뀌면 통과 · 뒤에 파일 지움(없으면 temp = null)
|
||||
* 기대 수치:
|
||||
* - pages 8 · 줄마다 btn = 1 · first = true(묶음 맨 왼쪽) · inLeft = true(B02 는 아래 단추 줄) ·
|
||||
* modal = true · toc[0] = 「… 설명서」 · text = 그 설명서 제목 글
|
||||
* - B03 toc = 설명서 + 필요한 파일 · 고르는 방법 · 알아 둘 것 · B06 toc 에 「표준 횡단면 설정」
|
||||
* - pipeTopic = 「구조물 · 배관」(B05 에서 배관을 고른 채 [?]) · leftovers = 0(옮긴 글이 패널에 없음)
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 15000) => {
|
||||
for (let t = 0; t < ms; t += 200) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await w(200);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const MOVED = [
|
||||
"[저장]은 이 폼에 있는 칸만",
|
||||
"에서 관리 — 여기서 넣어도",
|
||||
"사방기술교본",
|
||||
"표준 편절편성 단면 모식도",
|
||||
"L형 측구는 각 횡단면도에서",
|
||||
"끌어 놓으면 확장자로",
|
||||
"shapefile 한 벌",
|
||||
"작업 좌표계는 라이다 PRJ",
|
||||
"칸이 생기기 전의 동작",
|
||||
"소광리 도면 H=2.0",
|
||||
];
|
||||
const start = location.hash;
|
||||
const vis = (n) => n && n.offsetParent !== null;
|
||||
const leftText = () =>
|
||||
document.querySelector(".ui-workflow-overlay__panel--title")?.innerText ?? "";
|
||||
const openModal = async (btn) => {
|
||||
btn.click();
|
||||
const box = await until(() => document.querySelector(".ui-help .ui-help__text h2"));
|
||||
const res = {
|
||||
modal: !!box,
|
||||
toc: [...document.querySelectorAll(".ui-help__link")].map((b) => b.innerText.trim()),
|
||||
text: box?.innerText ?? null,
|
||||
active: document.querySelector(".ui-help__text")?.dataset.helpKey ?? null,
|
||||
};
|
||||
const temp = [...document.querySelectorAll(".ui-help__link")].find((b) =>
|
||||
b.dataset.helpKey?.startsWith("99_"),
|
||||
);
|
||||
if (temp) {
|
||||
temp.click();
|
||||
res.temp = document.querySelector(".ui-help__text p")?.innerText ?? null;
|
||||
}
|
||||
document.querySelector(".ui-help .ui-modal__top button")?.click();
|
||||
await w(200);
|
||||
res.closed = !document.querySelector(".ui-help");
|
||||
return res;
|
||||
};
|
||||
const out = { pages: [] };
|
||||
// orca eval 은 한 번에 1분쯤에서 끊김 — 앞에 `window.__help542 = ["b05-profile", …];` 를 붙이면 그 화면만
|
||||
const routes = window.__help542 ?? [
|
||||
"b02-proj-register",
|
||||
"b03-file-input",
|
||||
"b04-preprocess",
|
||||
"b05-profile",
|
||||
"b06-section",
|
||||
"b07-design-detail",
|
||||
"b08-quantity",
|
||||
"b09-estimation",
|
||||
];
|
||||
let prev = null;
|
||||
for (const route of routes) {
|
||||
location.hash = `#/${route}`;
|
||||
await w(3000); // 불러오는 동안의 임시 틀(B05 등)이 실제 화면으로 바뀔 때까지
|
||||
// 앞 화면 단추가 빠지고 이 화면 단추가 설 때까지
|
||||
const btn = await until(() =>
|
||||
[...document.querySelectorAll(".ui-help-btn")].find((b) => vis(b) && b !== prev),
|
||||
);
|
||||
prev = btn;
|
||||
await w(1500);
|
||||
const row = btn?.parentElement;
|
||||
const res = {
|
||||
page: route.slice(0, 3).toUpperCase(),
|
||||
btn: document.querySelectorAll(".ui-help-btn").length,
|
||||
first: !!row && row.firstElementChild === btn,
|
||||
inLeft: !!btn?.closest(".ui-sidebar-actions") || !!btn?.closest(".b02-proj__actions"),
|
||||
leftovers: MOVED.filter((s) => leftText().includes(s)),
|
||||
};
|
||||
if (btn) Object.assign(res, await openModal(btn));
|
||||
if (route === "b05-profile") {
|
||||
// 배관 하나를 골라 [?] — 구조물 종류 항목이 먼저 열림(이미 골라져 있으면 그대로 · 끝나면 처음 상태로)
|
||||
const picked = () => document.querySelector('[data-help-topic="구조물_배관"]');
|
||||
const was = !!picked();
|
||||
const item = await until(() =>
|
||||
[...document.querySelectorAll(".b05-route__irregular-item")].find((li) =>
|
||||
li.innerText.includes("배관"),
|
||||
),
|
||||
);
|
||||
if (!was) item?.click();
|
||||
await until(picked, 8000);
|
||||
await w(800);
|
||||
res.leftoversPipe = MOVED.filter((s) => leftText().includes(s));
|
||||
const again = await openModal([...document.querySelectorAll(".ui-help-btn")].find(vis));
|
||||
res.pipeTopic = again.text;
|
||||
if (!was) item?.click(); // 고름 풂
|
||||
}
|
||||
out.pages.push(res);
|
||||
}
|
||||
location.hash = start;
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,194 @@
|
||||
/* 54-3 — B06 횡단도 배관 그림: 집수정 높이 → 형상 · 흙막이 여섯 형태 = 외곽 같음 · 안 무늬만 다름
|
||||
* 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub7_검증) · 목록에 「배관 1」(4+5)
|
||||
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m543 = "단계";`
|
||||
* basin → 유입 구조 집수정 · I · L · ㄷ형마다 높이 1.0 → 2.0 → 그림 높이(m, SVG 실좌표 ÷ 세로 눈금)
|
||||
* soil → 유입 · 유출 흙막이 형태 여섯 → 벽 외곽 꼭짓점(SVG) 같음 · 무늬(해칭 묶음) 서로 다름
|
||||
* extra → 추가 흙막이(다단) — 상세의 배관 측점마다 기하를 형태 여섯으로 돌려 외곽 꼭짓점 같음
|
||||
* 쓰는 것: 좌측 폼 값(캐시만 · 저장 안 함) — 단계 끝에 원래 값으로 되돌림
|
||||
* 기대 수치: basin 형태마다 h1 ≈ 1.0 · h2 ≈ 2.0 · ratio ≈ 2.0 / soil sameOutline true · distinctHatch 6 /
|
||||
* extra sameLean true · sameOutlineAtSameHeight true(다단이 선 측점 1곳 이상) · heightCapped = 지형 맞춤 키가
|
||||
* 형태 높이 한계에 걸린 측점(키만 다름)
|
||||
*/
|
||||
(async () => {
|
||||
const MODE = window.__m543 || "basin";
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 20000) => {
|
||||
for (let t = 0; t < ms; t += 200) {
|
||||
if (fn()) return true;
|
||||
await w(200);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const spec = () =>
|
||||
[...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null);
|
||||
const ctl = (c, item) =>
|
||||
spec()
|
||||
?.querySelector(`[data-spec-container="${c}"] [data-spec-item="${c}.${item}"]`)
|
||||
?.querySelector("select, input");
|
||||
const set = async (c, item, v) => {
|
||||
const el = ctl(c, item);
|
||||
el.value = v;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await w(900);
|
||||
};
|
||||
const pickPipe = async () => {
|
||||
await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0);
|
||||
if (spec()) return;
|
||||
const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
|
||||
/배관 1$/.test(l.textContent.trim()),
|
||||
);
|
||||
(li.querySelector("button") ?? li).click();
|
||||
await until(() => !!spec());
|
||||
await w(800);
|
||||
};
|
||||
const card = () => document.querySelector(".b06-cross-card--selected svg");
|
||||
/** 세로 눈금 — 같은 x 에 놓인 숫자 글(표고)에서 1m 당 픽셀. */
|
||||
const pxPerM = () => {
|
||||
const nums = [...card().querySelectorAll("text")]
|
||||
.filter((t) => /^-?\d+(\.\d+)?$/.test(t.textContent.trim()))
|
||||
.map((t) => ({
|
||||
v: Number(t.textContent),
|
||||
x: t.getAttribute("x"),
|
||||
y: Number(t.getAttribute("y")),
|
||||
}));
|
||||
const byX = {};
|
||||
for (const n of nums) (byX[n.x] ??= []).push(n);
|
||||
const col = Object.values(byX)
|
||||
.filter((g) => new Set(g.map((n) => n.y)).size > 1)
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
const a = col[0];
|
||||
const b = col[col.length - 1];
|
||||
return Math.abs(b.y - a.y) / Math.abs(b.v - a.v);
|
||||
};
|
||||
const pts = (poly) =>
|
||||
poly
|
||||
.getAttribute("points")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((p) => p.split(",").map(Number));
|
||||
const minY = (poly) => Math.min(...pts(poly).map((p) => p[1]));
|
||||
const round = (v) => Math.round(v * 100) / 100;
|
||||
const out = { mode: MODE };
|
||||
await pickPipe();
|
||||
|
||||
if (MODE === "basin") {
|
||||
const keep = ["structure", "form", "height"].map((k) => ctl("inlet", k).value);
|
||||
await set("inlet", "structure", "집수정");
|
||||
out.shapes = {};
|
||||
for (const shape of ["I", "L", "U"]) {
|
||||
await set("inlet", "form", shape);
|
||||
const hs = [];
|
||||
for (const h of ["1.0", "2.0"]) {
|
||||
await set("inlet", "height", h);
|
||||
const k = pxPerM();
|
||||
const parts = [...card().querySelectorAll(".b06-chart__culvert-basin")];
|
||||
const title = (p) => p.querySelector("title")?.textContent ?? "";
|
||||
const wall = parts.find((p) => / 벽 /.test(title(p)));
|
||||
const floor = parts.find((p) => / 바닥 /.test(title(p)));
|
||||
const label = [...card().querySelectorAll(".b06-chart__culvert-label")].find((t) =>
|
||||
t.textContent.startsWith("집수정"),
|
||||
);
|
||||
// ㄴ · ㄷ = 바닥 윗면 ~ 벽 상단 · I = 관 유입 기준(라벨 자리 + 0.2) ~ 벽 상단
|
||||
const base = floor ? minY(floor) : Number(label.getAttribute("y")) - 11 - 0.2 * k;
|
||||
hs.push({ name: title(wall).match(/\((.+?)\)/)?.[1], h: round((base - minY(wall)) / k) });
|
||||
}
|
||||
out.shapes[shape] = {
|
||||
name: hs[0].name,
|
||||
h1: hs[0].h,
|
||||
h2: hs[1].h,
|
||||
ratio: round(hs[1].h / hs[0].h),
|
||||
};
|
||||
}
|
||||
await set("inlet", "structure", keep[0]);
|
||||
await set("inlet", "form", keep[1]);
|
||||
await set("inlet", "height", keep[2]);
|
||||
out.restored = ["structure", "form", "height"].map((k) => ctl("inlet", k).value);
|
||||
}
|
||||
|
||||
if (MODE === "soil") {
|
||||
for (const [side, word] of [
|
||||
["inlet", "유입"],
|
||||
["outlet", "유출"],
|
||||
]) {
|
||||
// 구조가 비면 형태 칸이 안 섬 — 흙막이로 고른 뒤 끝에 되돌림
|
||||
const keepStructure = ctl(side, "structure").value;
|
||||
if (keepStructure !== "기슭막이") await set(side, "structure", "기슭막이");
|
||||
const forms = [...ctl(side, "form").options].map((o) => o.value).filter(Boolean);
|
||||
out.forms = forms;
|
||||
const keep = ctl(side, "form").value;
|
||||
const rows = [];
|
||||
for (const form of forms) {
|
||||
await set(side, "form", form);
|
||||
const poly = [...card().querySelectorAll(".b06-chart__culvert-revet")].find((p) =>
|
||||
(p.querySelector("title")?.textContent ?? "").startsWith(`${word} 기슭막이`),
|
||||
);
|
||||
const hatch = poly.nextElementSibling?.nextElementSibling;
|
||||
rows.push({
|
||||
form,
|
||||
title: poly.querySelector("title").textContent.slice(0, 40),
|
||||
outline: pts(poly)
|
||||
.map((p) => p.map(round).join(","))
|
||||
.join(" "),
|
||||
hatch: `${hatch?.childElementCount}:${hatch?.innerHTML.length}`,
|
||||
});
|
||||
}
|
||||
await set(side, "form", keep);
|
||||
if (keepStructure !== "기슭막이") await set(side, "structure", keepStructure);
|
||||
out[side] = {
|
||||
sameOutline: rows.every((r) => r.outline === rows[0].outline),
|
||||
distinctHatch: new Set(rows.map((r) => r.hatch)).size,
|
||||
outline: rows[0].outline,
|
||||
rows: rows.map((r) => [r.form, r.hatch, r.title]),
|
||||
restored: ctl(side, "structure").value === keepStructure,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (MODE === "extra") {
|
||||
const modUrl = (part) =>
|
||||
performance
|
||||
.getEntriesByType("resource")
|
||||
.map((e) => e.name)
|
||||
.filter((n) => n.includes(part))
|
||||
.pop();
|
||||
const geom = await import(modUrl("/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts"));
|
||||
const key = Object.keys(sessionStorage).find((k) => k.includes("section-detail"));
|
||||
const detail = JSON.parse(sessionStorage.getItem(key));
|
||||
const forms = geom.REVET_FORMS;
|
||||
out.checked = 0;
|
||||
out.sameLean = true;
|
||||
out.sameOutlineAtSameHeight = true;
|
||||
// 지형 맞춤 키는 형태 높이 한계(메 2.0 · 찰 3.0 · 콘 5.0 — 교본)에서 멈춤 — 같은 키끼리만 외곽 대조
|
||||
out.heightCapped = [];
|
||||
for (const section of detail.cross_sections ?? detail.value?.cross_sections ?? []) {
|
||||
if (!section.culvert || section.culvert.hidden_pipe) continue;
|
||||
const rows = forms.map((m) => {
|
||||
const walls =
|
||||
geom.computeCulvertLayout(section, section.samples, { extras: [{ m }, { m }] })
|
||||
?.extraWalls ?? [];
|
||||
return {
|
||||
m,
|
||||
lean: walls.map((wall) => wall.lean).join(","),
|
||||
height: walls.map((wall) => round(wall.height)).join(","),
|
||||
outline: JSON.stringify(
|
||||
walls.map((wall) => wall.points.map((p) => [round(p.offset), round(p.elevation)])),
|
||||
),
|
||||
};
|
||||
});
|
||||
if (rows[0].outline === "[]") continue;
|
||||
out.checked += 1;
|
||||
if (!rows.every((r) => r.lean === rows[0].lean)) out.sameLean = false;
|
||||
for (const r of rows) {
|
||||
const twin = rows.find((o) => o.height === r.height);
|
||||
if (twin.outline !== r.outline) out.sameOutlineAtSameHeight = false;
|
||||
}
|
||||
const heights = [...new Set(rows.map((r) => r.height))];
|
||||
if (heights.length > 1)
|
||||
out.heightCapped.push({
|
||||
chainage: section.chainage_m,
|
||||
byForm: rows.map((r) => `${r.m}=${r.height}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,211 @@
|
||||
/* 54-4 — 좌측 패널에서 같은 자리 관 지점 시설 종류를 바꿀 때 B06 횡단도 · 종단 알약 · 목록이 바로 새 종류
|
||||
* 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub4_검증) · 로그인 · 목록에 「4+5 배관 1」(85.05 m)
|
||||
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m544 = "단계";`
|
||||
* setup → 처음 관 지점 정본(서버)을 세션 `__544_orig` 에 남김 · 4+5 관 고름 · 종단 도크 폄
|
||||
* cycle → 저장 전 네 종류 서로 바꾸기 12길(배관 → BOX → 세월교 → 물넘이 → 배관 → 세월교 → BOX →
|
||||
* 물넘이 → 세월교 → 배관 → 물넘이 → BOX → 배관) — 길마다 횡단 카드 모양 · 제목 · 목록 · 알약 · 초안 칸
|
||||
* save → BOX암거로 바꾸고 [저장] → 저장 뒤 카드 · 목록 · 알약 = BOX
|
||||
* reopen → (orca reload 뒤) 저장분이 BOX 로 다시 섬 · BOX → 배관 바꾸기도 바로 따라옴(저장 뒤 길)
|
||||
* restore → 처음 정본을 관 초안에 되얹고 [저장] → 서버 관 지점 = 처음과 같음
|
||||
* 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__544_out_{단계}")` 로 읽음
|
||||
* 쓰는 것: 검증 프로젝트 관 지점 정본(save · restore) · sessionStorage
|
||||
* 기대 수치: 모든 단계 fails 0 · restore same true
|
||||
*/
|
||||
(async () => {
|
||||
const MODE = window.__m544 || "cycle";
|
||||
const raf = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16);
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const AT = 85.05;
|
||||
const pid = localStorage.getItem("frd_current_project_id");
|
||||
const api = async (u) => (await fetch(u, { credentials: "include" })).json();
|
||||
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
|
||||
const NAME = {
|
||||
pipe: "배관",
|
||||
box_culvert: "BOX암거",
|
||||
ford_bridge: "세월교",
|
||||
ford_pavement: "물넘이포장",
|
||||
};
|
||||
const CHIP = {
|
||||
pipe: "배수관",
|
||||
box_culvert: "BOX암거",
|
||||
ford_bridge: "세월교",
|
||||
ford_pavement: null,
|
||||
};
|
||||
/** 그 종류에만 있는 칸 — 다른 종류로 바꾼 뒤 초안에 남으면 옛 칸이 섞인 것. */
|
||||
const OWN = {
|
||||
pipe: ["inlet_revet_form", "outlet_revet_form", "inlet_basin_length_m"], // 세월교도 inlet_type 을 씀(날개벽)
|
||||
box_culvert: ["body_width_m", "body_height_m"],
|
||||
ford_bridge: ["pipe_count"],
|
||||
ford_pavement: [],
|
||||
};
|
||||
const typeSel = () =>
|
||||
document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1];
|
||||
const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
|
||||
const row = () => rows().find((li) => /^4\+5 (배관|BOX암거|세월교|물넘이포장)/.test(txt(li)));
|
||||
const card = () =>
|
||||
[...document.querySelectorAll(".b06-cross-card")]
|
||||
.map((c) => ({ c, at: Number(txt(c).split(" ")[1]?.replace("m", "")) }))
|
||||
.sort((a, b) => Math.abs(a.at - AT) - Math.abs(b.at - AT))[0]?.c;
|
||||
const pill = () =>
|
||||
[...document.querySelectorAll(".b05-structure__mark")].find(
|
||||
(m) => m.dataset.structureId === `pipe-${AT.toFixed(2)}`,
|
||||
);
|
||||
const draft = () => {
|
||||
const raw = sessionStorage.getItem(`aislo:draft:pipes:${pid}`);
|
||||
const v = raw ? JSON.parse(raw) : null;
|
||||
return (v?.value ?? v ?? []).find((p) => Math.abs(p.chainage_m - AT) < 0.51) ?? null;
|
||||
};
|
||||
const openDock = async () => {
|
||||
if (pill()) return;
|
||||
const handle = document.querySelector(".ui-workflow-overlay__handle--bottom");
|
||||
if (handle?.getAttribute("aria-expanded") !== "true") handle?.click();
|
||||
for (let k = 0; k < 20 && !pill(); k++) await w(300);
|
||||
};
|
||||
const pick = async () => {
|
||||
for (let k = 0; k < 40 && !row(); k++) await w(500);
|
||||
if (!row()?.classList.contains("is-selected")) {
|
||||
row()?.click();
|
||||
await w(2500);
|
||||
}
|
||||
};
|
||||
const until = async (fn, ms = 8000) => {
|
||||
for (let t = 0; t < ms; t += 200) {
|
||||
if (fn()) return true;
|
||||
await w(200);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
/** 횡단 카드 그림이 그 종류 모양인가 — 클래스 · 툴팁(svg title) 첫 낱말로 가름. */
|
||||
const shapeOf = (c) => {
|
||||
const svg = c.querySelector("svg");
|
||||
const cls = [...svg.querySelectorAll("[class]")].map((x) => x.getAttribute("class"));
|
||||
const tips = [...svg.querySelectorAll("title")].map((x) => x.textContent);
|
||||
if (tips.some((t) => t.startsWith("BOX암거"))) return "box_culvert";
|
||||
if (cls.some((k) => k.includes("ford-slab"))) return "ford_bridge";
|
||||
if (cls.some((k) => k.includes("culvert-revet") || k.includes("culvert-pipe"))) return "pipe";
|
||||
return "none";
|
||||
};
|
||||
const check = (to, label) => {
|
||||
const c = card();
|
||||
const got = {
|
||||
shape: c ? shapeOf(c) : "카드 없음",
|
||||
chip: c ? [...c.querySelectorAll(".b06-cross-card__structure")].map(txt) : [],
|
||||
notes: c ? [...c.querySelectorAll(".b06-cross-card__warning")].map(txt) : [],
|
||||
row: row() ? txt(row()) : null,
|
||||
pill: pill() ? `${txt(pill())}|${pill().title}` : null,
|
||||
type: typeSel().value,
|
||||
facility: draft()?.facility,
|
||||
keys: Object.keys(draft()?.options ?? {}),
|
||||
};
|
||||
const want = to === "ford_pavement" ? "none" : to;
|
||||
if (got.shape !== want) fail(`${label}: 모양 ${got.shape} ≠ ${want}`);
|
||||
if (CHIP[to] ? got.chip.join() !== CHIP[to] : got.chip.length)
|
||||
fail(`${label}: 제목 ${got.chip}`);
|
||||
if (to === "ford_pavement" && !got.notes.some((n) => n.includes("물넘이포장")))
|
||||
fail(`${label}: 물넘이 알림 없음 ${got.notes}`);
|
||||
if (got.notes.some((n) => Object.values(NAME).some((v) => v !== NAME[to] && n.includes(v))))
|
||||
fail(`${label}: 옛 종류 알림 ${got.notes}`);
|
||||
if (!got.row?.startsWith(`4+5 ${NAME[to]}`)) fail(`${label}: 목록 ${got.row}`);
|
||||
if (!got.pill || !got.pill.includes(NAME[to])) fail(`${label}: 알약 ${got.pill}`);
|
||||
if (got.type !== to) fail(`${label}: 폼 종류 ${got.type}`);
|
||||
if (!["setup", "save", "reopen"].includes(MODE) && got.facility !== to)
|
||||
fail(`${label}: 초안 종류 ${got.facility}`);
|
||||
for (const [kind, keys] of Object.entries(OWN))
|
||||
if (kind !== to && keys.some((k) => got.keys.includes(k)))
|
||||
fail(`${label}: 옛 ${kind} 칸 섞임 ${got.keys.filter((k) => keys.includes(k))}`);
|
||||
return got;
|
||||
};
|
||||
const change = async (to) => {
|
||||
const t = typeSel();
|
||||
t.value = to;
|
||||
t.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await until(() => card() && shapeOf(card()) === (to === "ford_pavement" ? "none" : to));
|
||||
await w(600);
|
||||
};
|
||||
const save = async () => {
|
||||
[...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click();
|
||||
await until(() => document.querySelector(".ui-toast, [class*='toast']"), 60000);
|
||||
await w(4000);
|
||||
return document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null;
|
||||
};
|
||||
const out = { mode: MODE, fails: [] };
|
||||
const fail = (what) => out.fails.push(what);
|
||||
|
||||
if (MODE === "setup") {
|
||||
const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
|
||||
sessionStorage.setItem("__544_orig", JSON.stringify(pipes));
|
||||
out.orig = pipes.find((p) => Math.abs(p.chainage_m - AT) < 0.51);
|
||||
await pick();
|
||||
await openDock();
|
||||
out.start = check(typeSel().value, "처음");
|
||||
} else if (MODE === "cycle") {
|
||||
await pick();
|
||||
await openDock();
|
||||
const path = [
|
||||
"pipe",
|
||||
"box_culvert",
|
||||
"ford_bridge",
|
||||
"ford_pavement",
|
||||
"pipe",
|
||||
"ford_bridge",
|
||||
"box_culvert",
|
||||
"ford_pavement",
|
||||
"ford_bridge",
|
||||
"pipe",
|
||||
"ford_pavement",
|
||||
"box_culvert",
|
||||
"pipe",
|
||||
];
|
||||
if (typeSel().value !== path[0]) fail(`처음 종류 ${typeSel().value}`);
|
||||
out.steps = [];
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const label = `${i} ${path[i - 1]}→${path[i]}`;
|
||||
await change(path[i]);
|
||||
const got = check(path[i], label);
|
||||
out.steps.push(
|
||||
`${label} ${got.shape} ${got.chip.join("/") || "-"} | ${got.row} | ${got.pill}`,
|
||||
);
|
||||
}
|
||||
out.ways = new Set(path.slice(1).map((to, i) => `${path[i]}→${to}`)).size;
|
||||
} else if (MODE === "save") {
|
||||
await pick();
|
||||
await openDock();
|
||||
await change("box_culvert");
|
||||
out.toast = await save();
|
||||
await openDock();
|
||||
out.after = check("box_culvert", "저장 뒤");
|
||||
const saved = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
|
||||
out.server = saved.find((p) => Math.abs(p.chainage_m - AT) < 0.51)?.facility;
|
||||
if (out.server !== "box_culvert") fail(`서버 종류 ${out.server}`);
|
||||
} else if (MODE === "reopen") {
|
||||
await pick();
|
||||
await openDock();
|
||||
out.reopened = check("box_culvert", "다시 연 BOX");
|
||||
await change("pipe");
|
||||
out.toPipe = check("pipe", "저장 뒤 BOX→배관");
|
||||
} else if (MODE === "restore") {
|
||||
const orig = JSON.parse(sessionStorage.getItem("__544_orig") ?? "null");
|
||||
if (!orig) fail("처음 정본 없음 — setup 먼저");
|
||||
else {
|
||||
const points = orig.map(({ lonlat, ...rest }) => rest); // 좌표는 서버가 다시 계산(DetailPipeInput)
|
||||
sessionStorage.setItem(`aislo:draft:pipes:${pid}`, JSON.stringify(points));
|
||||
location.reload();
|
||||
return "reload — 다시 부르면 저장"; // 새로 고친 뒤 `__m544 = "restore2"`
|
||||
}
|
||||
} else if (MODE === "restore2") {
|
||||
await pick();
|
||||
out.toast = await save();
|
||||
const orig = JSON.parse(sessionStorage.getItem("__544_orig"));
|
||||
const now = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
|
||||
const pick2 = (list) =>
|
||||
list.map((p) => [p.chainage_m, p.facility ?? "pipe", JSON.stringify(p.options ?? {})]);
|
||||
out.same = JSON.stringify(pick2(now)) === JSON.stringify(pick2(orig));
|
||||
if (!out.same) fail("되돌린 정본이 처음과 다름");
|
||||
out.at = now.find((p) => Math.abs(p.chainage_m - AT) < 0.51);
|
||||
}
|
||||
window.requestAnimationFrame = raf;
|
||||
out.failCount = out.fails.length;
|
||||
sessionStorage.setItem(`__544_out_${MODE}`, JSON.stringify(out));
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,84 @@
|
||||
/* [?] 설명서(54-2) — 단추는 글자 폭만 · 모달은 왼쪽 차례 · 오른쪽 글 */
|
||||
.ui-sidebar-actions .ui-help-btn,
|
||||
.ui-help-btn {
|
||||
flex: none;
|
||||
width: auto;
|
||||
min-width: 2.25rem;
|
||||
padding-inline: var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-help.ui-modal {
|
||||
width: min(960px, 92vw);
|
||||
height: var(--modal-max, 80vh);
|
||||
}
|
||||
|
||||
.ui-help .ui-modal__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-help__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: var(--spacing-16);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-help__toc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-right: var(--spacing-8);
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ui-help__link {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-help__link:hover,
|
||||
.ui-help__link.is-active {
|
||||
background: color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.ui-help__text {
|
||||
overflow-y: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ui-help__text h2 {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.ui-help__text h3 {
|
||||
margin: var(--spacing-16) 0 var(--spacing-4);
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.ui-help__text h4,
|
||||
.ui-help__text h5 {
|
||||
margin: var(--spacing-12) 0 var(--spacing-4);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.ui-help__text p,
|
||||
.ui-help__text ul,
|
||||
.ui-help__text ol {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-help__text ul,
|
||||
.ui-help__text ol {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/* =============================================================================
|
||||
* ui_template_help.ts
|
||||
* [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용.
|
||||
* 글 = `resources/help/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜).
|
||||
* 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막
|
||||
* (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서).
|
||||
* 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_help.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton, el } from "./ui_template_elements";
|
||||
import { openModal } from "./ui_template_modal";
|
||||
|
||||
interface HelpItem {
|
||||
key: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" };
|
||||
|
||||
function currentPage(): string {
|
||||
const n = /^#\/b0(\d)/i.exec(location.hash)?.[1];
|
||||
return n ? `B0${n}` : "";
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(
|
||||
/[&<>"]/g,
|
||||
(c) => `&${{ "&": "amp", "<": "lt", ">": "gt", '"': "quot" }[c]};`,
|
||||
);
|
||||
}
|
||||
|
||||
function inline(text: string): string {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`(.+?)`/g, "<code>$1</code>");
|
||||
}
|
||||
|
||||
/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 1.) · 문단 · **굵게** · `코드` */
|
||||
export function renderHelpMarkdown(text: string): string {
|
||||
const out: string[] = [];
|
||||
let list: "ul" | "ol" | null = null;
|
||||
let para: string[] = [];
|
||||
const flush = (): void => {
|
||||
if (para.length) out.push(`<p>${para.map(inline).join("<br>")}</p>`);
|
||||
para = [];
|
||||
if (list) out.push(`</${list}>`);
|
||||
list = null;
|
||||
};
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
const head = /^(#{1,4})\s+(.*)$/.exec(line);
|
||||
const item = /^(?:([-*])|\d+\.)\s+(.*)$/.exec(line);
|
||||
if (!line) flush();
|
||||
else if (head) {
|
||||
flush();
|
||||
out.push(`<h${head[1].length + 1}>${inline(head[2])}</h${head[1].length + 1}>`);
|
||||
} else if (item) {
|
||||
const kind = item[1] ? "ul" : "ol";
|
||||
if (para.length || list !== kind) flush();
|
||||
if (!list) out.push(`<${kind}>`);
|
||||
list = kind;
|
||||
out.push(`<li>${inline(item[2])}</li>`);
|
||||
} else {
|
||||
if (list) flush();
|
||||
para.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function visibleTopics(): string[] {
|
||||
const panel = document.querySelector(".ui-workflow-overlay__panel--title") ?? document;
|
||||
return [...panel.querySelectorAll<HTMLElement>("[data-help-topic]")]
|
||||
.filter((node) => node.offsetParent !== null)
|
||||
.map((node) => node.dataset.helpTopic ?? "");
|
||||
}
|
||||
|
||||
async function fetchItems(page: string): Promise<HelpItem[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/help/${page}`, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
return response.ok ? ((await response.json()) as { items: HelpItem[] }).items : [];
|
||||
}
|
||||
|
||||
/** B05 · B06 = 한 페이지(구조물 폼 공유) — 제 폴더 뒤에 짝 폴더 항목(짝의 설명서는 뺌)도 보임 */
|
||||
const PAIR: Record<string, string> = { B05: "B06", B06: "B05" };
|
||||
|
||||
async function openHelp(): Promise<void> {
|
||||
const page = currentPage();
|
||||
const pair = PAIR[page];
|
||||
const [own, other] = await Promise.all([fetchItems(page), pair ? fetchItems(pair) : []]);
|
||||
const items = [...own, ...other.filter((item) => !own.some((o) => o.key === item.key))];
|
||||
const topics = visibleTopics();
|
||||
const first = [...topics].reverse().find((key) => items.some((item) => item.key === key));
|
||||
openModal({
|
||||
title: `${TEXT.title} · ${page}`,
|
||||
closeLabel: TEXT.close,
|
||||
dialogClass: "ui-help",
|
||||
mount: (body) => {
|
||||
const article = el("article", { className: "ui-help__text" });
|
||||
const toc = el("nav", { className: "ui-help__toc" });
|
||||
const show = (key: string): void => {
|
||||
const item = items.find((entry) => entry.key === key);
|
||||
article.innerHTML = item ? renderHelpMarkdown(item.text) : `<p>${TEXT.empty}</p>`;
|
||||
article.dataset.helpKey = key;
|
||||
toc
|
||||
.querySelectorAll("button")
|
||||
.forEach((b) => b.classList.toggle("is-active", b.dataset.helpKey === key));
|
||||
};
|
||||
for (const item of items) {
|
||||
const link = el("button", {
|
||||
className: "ui-help__link",
|
||||
text: item.title,
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
link.dataset.helpKey = item.key;
|
||||
link.addEventListener("click", () => show(item.key));
|
||||
toc.append(link);
|
||||
}
|
||||
body.append(el("div", { className: "ui-help__grid", children: [toc, article] }));
|
||||
show(first ?? items[0]?.key ?? "");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createHelpButton(): HTMLButtonElement {
|
||||
const button = createButton({
|
||||
label: TEXT.button,
|
||||
variant: "ghost",
|
||||
onClick: () => void openHelp(),
|
||||
});
|
||||
button.classList.add("ui-help-btn");
|
||||
button.title = button.ariaLabel = TEXT.tip;
|
||||
return button;
|
||||
}
|
||||
|
||||
/** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */
|
||||
export function placeHelpButton(actions: HTMLElement): void {
|
||||
if (actions.querySelector(".ui-help-btn")) return;
|
||||
(actions.querySelector<HTMLElement>("[data-help-row]") ?? actions).prepend(createHelpButton());
|
||||
}
|
||||
@@ -228,46 +228,6 @@ export const ui_locales_b1 = {
|
||||
"Upload the required planned route, terrain, and point cloud files.",
|
||||
],
|
||||
B03_File_Select_Label: ["입력 파일 선택", "Select input files"],
|
||||
/* --- 좌측 안내 패널 (2026-09-03) --- */
|
||||
B03_Guide_Files_Title: ["필요한 파일", "Files you need"],
|
||||
B03_Guide_Files_Route: [
|
||||
"계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.",
|
||||
"Planned route — the five shapefile parts (.shp, .shx, .dbf, .cpg, .prj).",
|
||||
],
|
||||
B03_Guide_Files_Terrain: [
|
||||
"지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.",
|
||||
"Terrain — one LAS/LAZ point cloud plus the PRJ projection and TFW world file.",
|
||||
],
|
||||
B03_Guide_Files_Optional: [
|
||||
"지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.",
|
||||
"The terrain TIF (DEM) is optional — surface analysis runs without it.",
|
||||
],
|
||||
B03_Guide_How_Title: ["고르는 방법", "How to pick"],
|
||||
B03_Guide_How_Drop: [
|
||||
"여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.",
|
||||
"Drop several files at once — each is assigned to a card by its extension.",
|
||||
],
|
||||
B03_Guide_How_Card: [
|
||||
"카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.",
|
||||
"Use a card's [Select] to set one slot, or ✕ to clear it.",
|
||||
],
|
||||
B03_Guide_How_Temp: [
|
||||
"[임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.",
|
||||
"[Temporary storage] pulls files you staged on the dashboard.",
|
||||
],
|
||||
B03_Guide_Notes_Title: ["알아 둘 것", "Before you upload"],
|
||||
B03_Guide_Notes_Replace: [
|
||||
"이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.",
|
||||
"Uploading again to a finished project replaces the existing analysis.",
|
||||
],
|
||||
B03_Guide_Notes_Crs: [
|
||||
"작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.",
|
||||
"The LiDAR PRJ sets the working CRS — a mismatched route CRS blocks the upload.",
|
||||
],
|
||||
B03_Guide_Notes_LasFree: [
|
||||
"라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.",
|
||||
"With no LiDAR, turn on [Design without LAS] and work from sheet contours.",
|
||||
],
|
||||
B03_File_Selected_Title: ["선택한 파일", "Selected files"],
|
||||
B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."],
|
||||
B03_File_Upload_Button: ["파일 업로드", "Upload files"],
|
||||
|
||||
@@ -528,10 +528,6 @@ export const ui_locales_b2 = {
|
||||
B06_Std_Field_CrossSlopeMax: ["횡단경사 최대(%)", "Cross slope max (%)"],
|
||||
B06_Std_Field_CutSlope: ["절토경사(1:n)", "Cut slope (1:n)"],
|
||||
B06_Std_Field_FillSlope: ["성토경사(1:n)", "Fill slope (1:n)"],
|
||||
B06_Std_LType_Note: [
|
||||
"L형 측구는 각 횡단면도에서 선택합니다.",
|
||||
"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: [
|
||||
@@ -563,10 +559,6 @@ export const ui_locales_b2 = {
|
||||
B06_Std_Diagram_Fill: ["성토경사", "Fill slope"],
|
||||
B06_Std_Diagram_CrossSlope: ["횡단경사", "Cross slope"],
|
||||
B06_Std_Diagram_Center: ["중심선(계획고)", "Centerline (design elev.)"],
|
||||
B06_Std_Diagram_Caption: [
|
||||
"표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).",
|
||||
"Standard cut-fill section schematic — shows where each value applies (not to scale).",
|
||||
],
|
||||
|
||||
/* --- B07_DesignDetail 상세 설계 --- */
|
||||
B07_Design_Title: ["상세설계", "Detail Design"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./ui_template_overlay.css";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { t } from "./ui_template_locale";
|
||||
import { placeHelpButton } from "./ui_template_help";
|
||||
import { makePanelDraggable } from "./ui_template_overlay_drag";
|
||||
// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을
|
||||
// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다.
|
||||
@@ -126,6 +127,7 @@ export function splitSidebarActions(body: HTMLElement): void {
|
||||
while (container.firstChild && container.firstChild !== actions)
|
||||
scroll.append(container.firstChild);
|
||||
container.prepend(scroll);
|
||||
placeHelpButton(actions);
|
||||
body.classList.add("ui-sidebar-split");
|
||||
// body → 부모 사이 중간 요소들이 높이를 이어주도록 표시한다(플렉스 세로 확장).
|
||||
for (let el = container; el && el !== body; el = el.parentElement as HTMLElement) {
|
||||
|
||||
@@ -162,6 +162,12 @@ export function createStepBar(
|
||||
return bar;
|
||||
}
|
||||
|
||||
function helpOnlyRow(): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ui-sidebar-actions";
|
||||
return row;
|
||||
}
|
||||
|
||||
export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLayoutHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-workflow-layout";
|
||||
@@ -185,7 +191,8 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
|
||||
});
|
||||
const overlays = createWorkflowOverlays({
|
||||
title: options.title,
|
||||
optionsContent: options.leftPanel,
|
||||
// 좌측 패널이 없는 화면(B08 · B09)도 [?] 줄은 둠(54-2)
|
||||
optionsContent: options.leftPanel ?? helpOnlyRow(),
|
||||
progressContent,
|
||||
onOptionsOpenChange: (isOpen) => root.classList.toggle("is-options-open", isOpen),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user