18 Commits
Author SHA1 Message Date
eomsangdon 3bd18875f0 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-10-06 10:29:19 +09:00
eomsangdonandClaude Opus 5.5 3e4635f146 chore(sync): 새어 온 docs·main.py 담음 — origin/main_laptop_1 과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
2026-10-06 10:29:08 +09:00
eomsangdon 7854a1020b Merge remote-tracking branch 'origin/sub_laptop_4' into sub_desktop_1 2026-10-06 10:28:17 +09:00
eomsangdonandClaude Opus 5.5 1e02ecc196 fix(structure): 관 지점 시설 종류 바꾸기 뒤 B06 횡단도 · 알약 · 목록 바로 새 종류(54-4)
- 좌측 패널 종류 바꿈 = 고른 관 지점 실시간 반영
- B06 관 고침이 시설 종류를 받음 · 옛 종류 칸 안 이음 · 알약 · 횡단 다시 그림
- 횡단 상세 읽기에 초안 종류 얹기(짝 산식 common_util_culvert_sets) · 카드 제목 라벨 포함
- 상세 조회가 측점 라벨을 지금 관 지점 종류로(저장 뒤 다시 열기)
- 구조물 다시 그림 최신 요청만 채택
- 시험: test_b05_pipe_station_relabel · ui_checks 54-4_종류바꾸기_횡단

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-06 10:26:34 +09:00
eomsangdon beef5811ac Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_7 2026-10-06 10:26:02 +09:00
eomsangdonandClaude Opus 5.5 e01c0efde6 chore(plan): 10-6 사토장 기준 측점 요청 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-06 10:25:50 +09:00
eomsangdonandClaude Opus 5.5 eafec12d85 chore(plan): 10-6 사면안정 컨테이너 요청 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-06 10:24:08 +09:00
eomsangdonandClaude Opus 5.5 26c2e8b05f fix(b06): 횡단도 구조물 고르기 → 좌측 패널 연결 점검 · 고침(53-1)
- 다른 측점 부재를 누를 때 옛 카드 선택 풀기가 폼 · 선택 칸을 비우던 것 막음(첫 누름부터 잡힘)
- 컨테이너 상자 강조가 폼 다시 그리기에 지워지던 것 · 옛 상자 강조 남던 것 · 유입 · 유출도 보이게 스크롤
- 고른 구조물 기준 측점 고침 = 바로 반영 · 횡단 카드 · 알약 · 선택 칸이 따라감
- 부재키는 같은 것을 다시 적을 때만 지킴 · 풀면 지움(B05 ↔ B06 오갈 때 옛 조정창 안 열림)
- 카드 고름이 일반 측점 · 이음 카드 알약을 지우던 것 → 연 구조물 알약 지킴
- B05 되살리기 = 구조물 id 가 같은 측점 관보다 먼저 · B06 다시 들어올 때 넘김값 다시 적용
- 시험: ui_checks/53-1_횡단_고르기.js(점검 116 · 흠 0) · 선택 시험 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-06 10:20:37 +09:00
eomsangdon 49e1d4ae4b Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-06 10:18:42 +09:00
eomsangdonandClaude Opus 5.5 73e8d4ea56 feat(help): B02~B09 좌측 패널 [?] 설명서 모달 · 패널 안 설명 글을 파일로 옮김(54-2)
- [?] = 좌측 패널 아래 단추 묶음 맨 왼쪽(공용 한 벌 · B02 는 아래 단추 줄 · B08·B09 는 [?] 줄만)
- 모달 = 왼쪽 차례 · 오른쪽 글 · 고른 구조물 종류 · 연 상자 항목 먼저
- 글 = resources/help/<B0N>/*.md · 서버가 열 때마다 읽음(파일만 고치면 반영) · 설명서는 뼈대
- 옮김 = B03 안내 셋 · B05 구조물 저장 규칙 · 측구 주인 · 기슭막이 근거 · 칸 기본값 근거 · B06 표준 횡단면 두 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-06 10:17:07 +09:00
eomsangdon 75fb557a7b Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-06 10:08:41 +09:00
eomsangdonandClaude Opus 5.5 2256f2f4fa fix(section): B06 배관 횡단도 집수정 높이 연결 · 흙막이 형태별 외곽 통일(54-3)
- 좌측 폼 집수정 「높이」(inlet_basin_height_m)를 관 제원 basin_height_m 로 실음(파이썬 · TS 짝 · 폼 → 캐시) — I · L · ㄷ형 벽 높이가 그 값을 따름 · 비면 종전 자동
- 배관 벽 · 다단 벽 전면 기울기를 형태와 무관한 기본 형태 표준경사로(outlineLeanOf) — 형태는 안 무늬만 다름
- 3D 코리도 해시에 집수정 높이 더함
- ui_checks/54-3_배관_횡단그림.js — 높이 1.0 → 2.0 비 2.0 · 여섯 형태 외곽 같음 · 무늬 6 가지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-06 10:06:49 +09:00
eomsangdonandClaude Opus 5.5 6233daab11 chore(plan): 10-6 공통 덧(종류 바꾸기 횡단 그림 · 속도 · 보안 재확인) 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-06 09:57:43 +09:00
eomsangdonandClaude Opus 5.5 bcc3cbb407 chore(plan): 10-6 구조물별 수정 요청 1차(공통 · 배관) 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-06 09:52:23 +09:00
eomsangdon 2379a22289 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-06 09:18:37 +09:00
eomsangdonandClaude Opus 5.5 5b351e8c68 fix(structure): B05 · B06 좌측 구조물 목록 · 하이라이트 점검 흠 고침(53-2)
- B05 목록 · 구조물 알약 누르기에 3D 강조가 안 따라옴 → 목록 고름 · 관 고름이 3D 도 그 측점으로
- 물넘이포장(측점선 없음) 목록 · 알약 · 카드 고름에 알약이 안 켜짐 → 관 알약 id 로 바로 켬
- B06 정규 측점 · 측점 사이 구조물을 고르면 카드가 켜지며 알약이 꺼짐 → 알약은 카드 뒤에 · 측점선 짝 판정이 규칙 측점 기준점도 봄
- B06 목록에서 다시 눌러 풀어도 알약이 남음 → 풂 = 알약도 끔
- B06 목록에서 푼 뒤 같은 카드 관 알약을 누르면 아무것도 안 골라짐 → 카드 다시 고름
- 알약 번호 · 툴팁 · 벌룬이 기타 손 이름 대신 「기타 N」 → 목록과 같은 이름
- 구조물집계표 기타 열 머리: 빈 이름이 「기타 구조물」 · 빈칸 글 → 「기타」(집계 건 이름과 같게)
- [저장] 뒤 고른 구조물 강조가 풀림(다시 열면 되살아나 어긋남) → 같은 id 면 그대로 고름
- 목록 줄 마우스 올림 테두리
- 시험: ui_checks/53-2_목록_강조.js · test_53_2_etc_table_name.py · test_design_page_single 판정 자리 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-06 09:16:55 +09:00
eomsangdonandClaude Opus 5.5 771e78ffc2 chore(plan): 구조물 표 08 기타 목적 원문(10-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-06 08:36:27 +09:00
eomsangdonandClaude Opus 5.5 9c20c08327 chore(plan): 10-6 답 · 좌측 패널 점검 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-06 08:10:20 +09:00
87 changed files with 2489 additions and 315 deletions
+1
View File
@@ -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());
+5 -5
View File
@@ -53,7 +53,7 @@ import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile
import {
structureGroupMenuItems,
stationIdAtStructure as stationIdAtStructureOf,
structureIdAtStation as structureIdAtStationOf,
structureIdKeeping,
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import {
@@ -261,9 +261,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
const structureIdAtStation = (stationId: string | null): string | null =>
stationId !== null && stationIdAtStructure(selectedStructureId) === stationId
? selectedStructureId
: structureIdAtStationOf(stationId, irregularStations, structures);
structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail);
const stationIdAtStructure = (structureId: string | null): string | null =>
stationIdAtStructureOf(structureId, irregularStations, structures);
@@ -626,8 +624,10 @@ export function createDesignProfile(ctx: DesignProfileContext) {
},
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
setSelectedStructure(structureId: string | null) {
// 지금 측점(B06 카드)이 그 구조물 자리면 측점선도 그대로(53-2)
selectedStructureId = structureId;
selectedStationId = stationIdAtStructure(structureId);
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
selectedStationId = keep ? selectedStationId : stationIdAtStructure(structureId);
draw();
},
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
+14 -3
View File
@@ -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 },
+12 -1
View File
@@ -176,7 +176,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();
+16 -1
View File
@@ -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) {
@@ -355,6 +359,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 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
@@ -433,6 +442,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),
@@ -492,6 +505,8 @@ export async function mountCrossView(
// 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다.
host.onShow(() => {
if (currentRouteId === null) return;
// 떠난 사이 종단 보기가 폼을 다른 것으로 바꿨을 수 있음 — 같은 넘김값도 다시 적용(53-1)
appliedPick = null;
void structuresPanel.load().then(restoreStructurePick);
void refreshDetailForStructures().then(() => reconcileStaleDesigns());
});
+14 -3
View File
@@ -31,7 +31,7 @@ import {
restoreStructurePick,
wireStructurePick,
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection";
import { NO_SELECTION, PIPE_MATCH_M, nearestWithin, selectDesign } from "./design_selection";
import type { DesignPageHost } from "./design_page";
import {
bindDesignView,
@@ -123,6 +123,15 @@ export async function mountProfileView(
profilePanel.setMinCoverPipes(pipes);
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
};
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2). */
const selectGraphAt = (id: string | null, chainageM: number | null): void => {
const bare =
id === null && chainageM !== null
? lastPipes?.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M)
: undefined;
if (bare) profilePanel.setSelectedStructure(`pipe-${bare.chainage_m.toFixed(2)}`);
else profilePanel.setSelectedStation(id);
};
// 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b).
const profilePanel = createRouteProfilePanel(
shared,
@@ -205,7 +214,7 @@ export async function mountProfileView(
selectionSyncing = value;
},
selectMarker: (id) => viewer.markers.selectStation(id),
selectGraph: (id) => profilePanel.setSelectedStation(id),
selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null),
selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM),
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM),
@@ -299,10 +308,11 @@ export async function mountProfileView(
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
profilePanel.setSelectedStation(id);
selectGraphAt(id, chainage);
syncBasinHighlight(id);
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
profilePanel.drainage.selectPipeAtChainage(chainage);
viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2)
} finally {
selectionSyncing = false;
}
@@ -315,6 +325,7 @@ export async function mountProfileView(
selectionSyncing = true;
try {
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
} finally {
selectionSyncing = false;
}
+3 -1
View File
@@ -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",
+8 -67
View File
@@ -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;
}
+1 -34
View File
@@ -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;
+54 -11
View File
@@ -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;
@@ -373,16 +373,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,
@@ -154,6 +154,30 @@ export function structureIdAtStation(
return hit?.structure_id ?? null;
}
/** 측점선이 골라질 때 켤 알약 — 지금 고른 알약이 그 측점 자리면 그대로 둔다(같은 측점
* 여럿 · 24-6b). 규칙 측점(B06 카드)도 기준점 누가거리로 맞춰 본다 — 안 보면 카드가 켜지며
* 고른 구조물 알약이 꺼졌다(53-2). */
export function structureIdKeeping(
stationId: string | null,
selectedId: string | null,
stations: IrregularStation[],
structures: StructureInstance[],
detail: {
longitudinal: { stations: ReadonlyArray<{ station_id: string; chainage_m: number }> };
} | null,
): string | null {
const selected = structures.find((item) => item.structure_id === selectedId);
const at = detail?.longitudinal.stations.find(
(entry) => entry.station_id === stationId,
)?.chainage_m;
const keeps =
stationId !== null &&
!!selected &&
(stationIdAtStructure(selectedId, stations, structures) === stationId ||
(at !== undefined && Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M));
return keeps ? selectedId : structureIdAtStation(stationId, stations, structures);
}
/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */
export function stationIdAtStructure(
structureId: string | null,
+3 -3
View File
@@ -23,8 +23,8 @@ export interface SelectionSyncPorts {
setSyncing: (value: boolean) => void;
/** 3D 뷰어 마커 선택. */
selectMarker: (stationId: string | null) => void;
/** 종단 그래프 세로선 선택. */
selectGraph: (stationId: string | null) => void;
/** 종단 그래프 세로선 선택 — 누가거리는 세로선 없는 시설(물넘이포장)의 알약을 켜는 데 씀. */
selectGraph: (stationId: string | null, chainageM?: number | null) => void;
/** 좌측 구조물 폼 선택(누가거리 기준). */
selectSidebar: (chainageM: number | null) => void;
/** 배수유역도 세부유역 강조(누가거리 기준). */
@@ -98,7 +98,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
ports.setSyncing(true);
try {
ports.selectMarker(id);
ports.selectGraph(id);
ports.selectGraph(id, chainageM);
// 투영 측점이 아직 없어도(방금 넣은 임시 관 — 재계산 전) 누가거리는 그대로
// 넘긴다. null을 넘기면 사이드가 "해제"로 받아 임시 배치를 스스로 취소한다
// (2026-08-18 임시 배치 자기 삭제 버그).
@@ -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,
@@ -56,8 +56,8 @@ export function numberDuplicateNames(names: ReadonlyArray<string>): string[] {
});
}
/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름). */
function etcName(structure: StructureInstance): string | null {
/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름) · 알약도 씀(53-2). */
export function etcName(structure: StructureInstance): string | null {
const name = structure.type_id === "etc" ? String(structure.options?.name ?? "").trim() : "";
return name || null;
}
@@ -19,7 +19,7 @@ import {
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
@@ -133,7 +133,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
(left, right) => structureAnchorM(left) - structureAnchorM(right),
);
const numberedNames = numberDuplicateNames(
anchorSorted.map((entry) => typeMap.get(entry.type_id)?.name ?? entry.type_id),
anchorSorted.map(
(entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id,
),
);
const displayNames = new Map(
anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]),
@@ -143,7 +145,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
const id = structure.structure_id;
if (!id) return;
const type = typeMap.get(structure.type_id);
const rawName = type?.name ?? structure.type_id;
const rawName = etcName(structure) ?? type?.name ?? structure.type_id; // 기타 = 손 이름(목록과 같게)
const displayName = displayNames.get(id) ?? rawName;
// "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지).
const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : "";
+8 -12
View File
@@ -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`. */
@@ -556,11 +553,10 @@ export function createStructuresSection(
// 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인).
structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry })));
// 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다.
if (applyingLive) renderList();
else {
loadForm(null);
renderList();
}
// [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2).
const kept = structures.find((entry) => entry.structure_id === editingId) ?? null;
if (!applyingLive) loadForm(editingId === null ? null : kept);
renderList();
},
getStructures: () => [...structures],
setPipeFacilities(pipes) {
@@ -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) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로
+4
View File
@@ -395,6 +395,10 @@
cursor: pointer;
}
.b05-route__irregular-item:hover {
border-color: var(--color-royal-amethyst, rgb(109 40 217));
}
.b05-route__irregular-item.is-selected {
border-color: var(--color-royal-amethyst, rgb(109 40 217));
background: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 12%, transparent);
@@ -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 {
+2
View File
@@ -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 걷음).
+2
View File
@@ -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)
+68
View File
@@ -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));
}
/**
* 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다.
*
+9 -2
View File
@@ -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을 제자리 표식과 함께 옮긴다 — 숨은 행은 그대로 둔다. */
@@ -156,8 +163,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,
+6
View File
@@ -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,
@@ -170,14 +172,35 @@ 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) return;
deps.markStructure?.(structure.structure_id ?? null);
if (!structure) {
deps.markStructure?.(null); // 풂 = 알약도 끔(53-2)
return;
}
// 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다.
preferredId = structure.structure_id ?? null;
deps.focusChainage(structureAnchorM(structure));
preferredId = null;
// 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2)
deps.markStructure?.(structure.structure_id ?? null);
},
getInterval: deps.stationInterval,
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
@@ -239,22 +262,30 @@ 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) => {
deps.markStructure?.(null); // 알약 강조는 관 측점선 짝으로(같은 측점 구조물 강조를 푼다).
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
selectDesign(
deps.projectId,
@@ -262,6 +293,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
{ from: "b06" },
);
if (chainageM !== null) deps.focusChainage(chainageM);
// 알약 = 그 관 알약(같은 측점 구조물 말고 · 측점선 없는 물넘이포장도) — 카드 뒤에(53-2)
deps.markStructure?.(chainageM === null ? null : `pipe-${chainageM.toFixed(2)}`);
},
},
});
@@ -491,6 +524,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
if (hit.pipeChainageM !== null) {
currentChainageM = hit.pipeChainageM;
refreshSpecDefaults(hit.pipeChainageM);
deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); // 측점선 없는 물넘이포장도(53-2)
}
section.selectAt(selection);
return { chainageM, structureId: hit.structureId };
@@ -552,11 +586,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();
@@ -564,22 +604,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;
@@ -196,9 +199,14 @@ export function createSectionProfile(
const hit = marks.find((entry) => entry.structure_id === structureId);
if (!hit) return;
const edit = host.edit();
if (!isPipeMark(hit) && hit.structure_id && edit)
if (!isPipeMark(hit) && hit.structure_id && edit) {
edit.selectStructure(hit.structure_id);
else host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
return;
}
// 목록에서 풀어도 카드는 남음 — 같은 카드면 「다시 보기」만 돼 관이 안 열렸다(53-2)
host.selectCard(null);
host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
profile?.setSelectedStructure(structureId); // 측점선 없는 물넘이포장도 그 알약
},
onStructureMarkMove: (structureId, toChainageM) => {
const edit = host.edit();
@@ -253,6 +261,7 @@ export function createSectionProfile(
profile?.redraw();
},
selectMark(structureId) {
markedId = structureId;
profile?.setSelectedStructure(structureId);
},
showCard(section) {
@@ -265,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;
}
+1 -4
View File
@@ -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;
};
-12
View File
@@ -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);
+5 -1
View File
@@ -103,6 +103,10 @@ def collect_items(
definition = types.get(item.type_id)
if definition is not None and definition.managed_by:
continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈
options = _with_defaults(definition, dict(item.options or {}))
label = etc_label(item.type_id, item.options)
if label: # 표 머리(이름마다 펼침)도 손 이름 · 비면 「기타」(53-2)
options["name"] = label["그룹"]
items.append(
{
"type_id": item.type_id,
@@ -110,7 +114,7 @@ def collect_items(
"chainage_m": float(item.chainage_m),
"start_m": item.start_m,
"end_m": item.end_m,
"options": _with_defaults(definition, dict(item.options or {})),
"options": options,
"summary_item": item.summary_item,
"복사본": item.structure_id or f"#{index}",
"group_items": item.group_items,
+1
View File
@@ -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;
}
+34
View File
@@ -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)}
@@ -15,3 +15,5 @@
비고 수기 입력 (상세 설계) 도수로(or 돌붙임) 관보호공, 옹벽, 돌쌓기, 흙막이, 큰톨쌓기 가 같은 형상으로 보임.
(가드레일은 표 07 덧과 같음)
사용자(10-6): 기타 항목의 목적은 우리 프로그램에 등록되지 않은 구조물을 수기입력하여 등록하기 위함임.
@@ -122,3 +122,41 @@
맹암거처럼 cad연결이나 횡단도에 그릴지 말지는 이번 수정 후에 작업할거야. 너는 B05/B06 좌측 패널의 생성과 기존 로직 연결에만 신경쓸것.
나는 이제 자러 갈거니 너가 다른 워크트리들을 잘 제어해서 계획서의 내용을 반영하고 최종검증을 진행 후 아티팩트로 결과를 보여주는데 만약 개선이 필요하다고 판단되는 부분이 있다면 계획서에 내용을 추가해서 개선할것.
(10-6 사용자)
1. 내가 넣어준 선택지 외에는 다른 정보들은 필요없는 상황임.
2. 각 워크트리는 자기의 검증프로젝트를 가지며 수정해도 상관없음.
3. 기타 표가 뭐임? 내가 넣어준거에는 없는데?
4. 물넘이 경사는 내가 다시 요청하겠음.
5. 세월교도 보고 내가 판단하겠음.
6. 현재 만들어진 프로젝트는 이전 로직으로 만들어진 프로젝트로 필요시 재생성
7. 현재 횡단도의 구조물을 선택하면 좌측 패널 활성화부분 재점검
8. 좌측 패널의 좌측 하단의 구조물리스트와 하이라이트 기능들 점검
(10-6 사용자 · 구조물별 수정 요청 1차)
1. 공통
- 좌측 패널의 컨테이너 내부 구조물 조합 항목의 고르기 버튼의 이름은 '선택'으로 변경
- 패널 내부 설명은 공간이 부족하니 별도로 빼서 작성할 예정 ? 버튼으로 연결하여 별도의 모달창으로 작업예정이며 각페이지에는 설명서를 넣을 예정. 내부에 들어갈 말은 나도 수정할 수 있게 별도로 페이지별 항목별 별도의 파일로 관리. 여기서 ? 버튼의 위치는 좌측 패널의 좌측하단 초기화 버튼이 있는 그룹들 중 제일 좌측에 존재. 다른 B02 ~ B09 까지 공통사항
- 횡단도
2. 배관
- 관컨테이너 : 관 길이의 사용자의 입력을 받는 정보가 아니라 보여주는 정보임. 라벨은 유지하고 내부 내용은 자동계산으로 변경, 관 길이 항목은 소수점 없음
- 관 보호공(유입) 컨테이너 :
ㄱ. 집수정은 길이, 기준측점 전/후의 내용이 필요 없음.
ㄴ. 높이에 따라 집수정의 형상이 변경 연결이 안되어 있음.
ㄷ. 흙막이의 돌쌓기(찰)의 경우 다른 형태 대비 모양이 변함. 모양은 같고 형태에 따라 내부 모양만 변하는게 맞음.
- 관 보호공(유출) :
ㄱ. '관길이 15m 좌우 자동 자리 ...' 설명 필요 없음
ㄴ. 지난 정의 값이라는 항목은 필요없음. 향후 다 정리하고 사용하지 않는 코드들은 삭제 예정
ㄷ. 추가 기슭막이(단)이 흙막이 추가 흙막이 추가에 대한 내용이었음. 별도 버튼으로 추가하는게 아님. 상단 컨테이너의 흙막이 옵션중 추가 기슭막이(단)의 값에 따라 생성되는데 이때 추가되는 기슭막이의 이름을 흙막이로 변경하고 선택되었을때 보여주는게 아니라 항상 보이게
ㄹ. 도수로/돌붙임은 일단 유지
(10-6 사용자 · 공통 덧)
공통
- 기존 배관에서 박스암거로 변경할 경우 횡단도의 그림이 업데이트 안됨. 확인 및 업데이트
- 추가로 관련된 정보 중 확인해줘야 할 내용이 기존에 화면의 B05/B06의 실시간 제어와 동작 속도/보안 개선을 위해 많은 작업이 있었는데 좌측 패널 통합 이후 많이 느려진 걸로 보임. 재확인. 이건 확인부터 하는게 좋겠음.
(10-6 사용자 · 추가 요청)
- 사면 안정 그룹의 종류들 전체가 컨테이너 구성이 안되어 있음. 현재 기준 측점을 제외한 옵션들을 하나의 컨테이너에 넣고 그 안에 집계 항목 지정 넣을것
(10-6 사용자 · 추가 요청)
- 안전부대용지 그룹의 사토장의 기준측점 표현이 예전 코드입. 토취장과 동일하게 구성
+3
View File
@@ -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)
# ─────────────────────────────────────────────────────────────────────────
+9
View File
@@ -0,0 +1,9 @@
# 프로젝트 등록 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+9
View File
@@ -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 @@
# 고르는 방법
- 여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.
- 카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.
- [임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.
+5
View File
@@ -0,0 +1,5 @@
# 알아 둘 것
- 이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.
- 작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.
- 라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.
+9
View File
@@ -0,0 +1,9 @@
# 전처리 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+9
View File
@@ -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 @@
# 구조물 · 물넘이포장
## 저장
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
+5
View File
@@ -0,0 +1,5 @@
# 구조물 · 배관
## 저장
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
@@ -0,0 +1,5 @@
# 구조물 · 비탈면 녹화
## 칸 기본값의 뜻
- **공법**(횡단설계) — 「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)
@@ -0,0 +1,5 @@
# 구조물 · 세월교
## 저장
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
+6
View File
@@ -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 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음
+9
View File
@@ -0,0 +1,9 @@
# 횡단설계 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+9
View File
@@ -0,0 +1,9 @@
# 표준 횡단면 설정
## 변수 위치 안내
- 표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).
## 표준횡단면 상세값
- L형 측구는 각 횡단면도에서 선택합니다.
+9
View File
@@ -0,0 +1,9 @@
# 상세설계 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+9
View File
@@ -0,0 +1,9 @@
# 수량산출 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+9
View File
@@ -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,26 @@
# -*- coding: utf-8 -*-
"""기타 집계표 머리(이름마다 펼침) = 목록 이름 — 손 이름 · 비면 「기타」 — PLAN 53-2.
화면은 ORCA `ui_checks/53-2_목록_강조.js`.
"""
from M02_MasterTemplete import M02_Table_Fill as fill
from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물
_save,
world,
)
SOURCE = {"종류": "etc", "펼침": ["name"]}
def test_기타_표_머리는_손_이름_비면_기타(world): # noqa: F811 — 고정물
base = {"type_id": "etc", "placement": "point"}
named = {**base, "structure_id": "e1", "chainage_m": 20.0, "options": {"name": " 배수판 "}}
same = {**base, "structure_id": "e2", "chainage_m": 40.0, "options": {"name": "배수판"}}
space = {**base, "structure_id": "e3", "chainage_m": 60.0, "options": {"name": " "}}
none = {**base, "structure_id": "e4", "chainage_m": 80.0, "options": {}}
_save(world["root"], [named, same, space, none], [])
items = {i["복사본"]: i for i in fill.collect_items(world["root"])}
heads = {key: fill._expansion(SOURCE, items[key]) for key in ("e1", "e2", "e3", "e4")}
assert heads == {"e1": ("배수판",), "e2": ("배수판",), "e3": ("기타",), "e4": ("기타",)}
assert [items[key]["그룹"] for key in ("e1", "e3", "e4")] == ["배수판", "기타", "기타"]
+38
View File
@@ -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()
+5 -1
View File
@@ -77,7 +77,11 @@ def test_창_키_듣기를_뗌():
def test_겹친_알약은_누른_것을_그대로():
profile = _read("A00_Common/design/design_profile.ts")
assert "stationIdAtStructure(selectedStructureId) === stationId" in profile
assert "structureIdKeeping(stationId, selectedStructureId" in profile
# 판정은 `_Profile_Structures` 로 옮김 — 비정규 측점 짝 · 규칙 측점(B06 카드) 기준점(53-2)
rule = _read("B05_Profile/B05_Profile_UI_Profile_Structures.ts")
assert "stationIdAtStructure(selectedId, stations, structures) === stationId" in rule
assert "Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M" in rule
def test_진행_단계_두_칸이_보기를_따라감():
@@ -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,334 @@
/* 53-2 — B05 · B06 좌측 하단 구조물 목록 · 하이라이트 전수 점검
* 시작 화면: B05 종단설계(#/b05-profile) 또는 B06 횡단설계 · 검증 프로젝트(sub4_검증) · 로그인 · 종단 도크 펼침
* B05 3D 는 [3D 업데이트] 뒤(코리도 부재 신원표가 있어야 함)
* 자료: 구조물 정본에 표 20종 + 숨긴 종단배수(측구) · 옛 계류사방(골막이 · 기슭막이) · 옛 대피소 · 기타 손 이름 ·
* 같은 측점 여럿(4+5 = 배관 · 메쌓기 기준점 · 경계석 / 9+0 = 반사경 · 번호판)
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m532 = "단계";`
* names → 목록 이름 = 정본 · 레지스트리로 지은 이름(기준점 차례 · 같은 이름 번호) · 측점 글
* list → 줄마다 누르기: 그 줄만 강조 · 폼 종류 · 알약 하나(그 구조물) · B05 3D(그 측점 부재) · B06 카드(그 측점) · 다시 누르면 다 풀림
* pill → 알약마다 누르기: 그 줄 강조 · 목록 칸 안에 보임 · 알약 그대로 켜짐
* card → (B06) 구조물 기준점 측점 카드 누르기: 그 측점 줄 강조 · 알약 켜짐
* three → (B05) 3D 부재 누르기(화면 좌표 · 실제 포인터 이벤트): 그 측점 줄 강조 · 목록 칸 안
* misc → 마우스 올림 규칙(목록 줄 · 알약) · 목록 스크롤(칸보다 김 · 끝 줄 알약 → 보임)
* edit → (B05) 반사경 2+10 [추가] → 차례 · 번호 · 강조 풀림 → 그 줄 [삭제] → 처음 목록 → 옹벽 고르고 [저장]
* → 차례 그대로 · 강조 그대로 (서버 저장 · 검증 프로젝트)
* reopen → (orca reload 뒤 · orca screenshot 한 번 — 안 그려진 탭은 rAF 이 멈춰 「보이게 스크롤」이 안 돎)
* 옹벽 강조 · 알약 되살아남 · 목록 칸 안
* (B05 · B06 모두 돌려 names · list 결과가 같은지 대조)
* 긴 단계(list · pill · card · three)는 연결이 끊길 수 있음 — `void` 로 띄우고
* `sessionStorage.getItem("__532_out_{단계}_{b05|b06}")` 로 읽음
* 쓰는 것: 화면 조작만(sessionStorage 선택 키) — 끝나면 고른 것 풀어 둠
* ORCA 탭이 안 그려지는 동안 requestAnimationFrame 이 멈춤(목록 「보이게 스크롤」이 그 다음 프레임에 돎) —
* 단계 동안만 setTimeout 으로 대신 돌리고 끝에 되돌림
* 기대 수치: 모든 단계 fails 0
*/
(async () => {
const MODE = window.__m532 || "names";
const raf = window.requestAnimationFrame;
window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16);
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const pid = localStorage.getItem("frd_current_project_id");
const api = async (u) => (await fetch(u, { credentials: "include" })).json();
const vis = (el) => !!el && el.getClientRects().length > 0;
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
const isB06 = location.hash.includes("b06");
const list = () => document.querySelector(".b05-route__irregular-list");
const items = () => [...list().querySelectorAll("li.b05-route__irregular-item")];
const selRows = () => items().filter((li) => li.classList.contains("is-selected"));
const typeSel = () =>
document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1];
const marks = () => [...document.querySelectorAll(".b05-structure__mark")].filter(vis);
const litPills = () =>
marks()
.filter((m) => m.classList.contains("is-selected"))
.map((m) => m.dataset.structureId);
const cardSel = () =>
[...document.querySelectorAll(".b06-cross-card--selected")]
.filter(vis)
.map((c) => Number(txt(c).split(" ")[1]?.replace("m", "")));
const tags = () => {
const out = [];
window.__corridorScene?.scene.traverse((o) => {
if (!o.isMesh || typeof o.userData?.chainageM !== "number") return;
const ms = Array.isArray(o.material) ? o.material : [o.material];
out.push({ o, c: o.userData.chainageM, lit: ms.some((m) => m.emissive?.getHex()) });
});
return out;
};
const lit3D = () => [
...new Set(
tags()
.filter((t) => t.lit)
.map((t) => t.c.toFixed(2)),
),
];
const inView = (li) => {
const a = li.getBoundingClientRect();
const b = list().getBoundingClientRect();
return a.top >= b.top - 1 && a.bottom <= b.bottom + 1;
};
// 기대 목록 — 정본 둘(구조물 · 관 지점)을 기준점 오름차순으로(목록과 같은 규칙)
const types = (await api("/api/projects/structure-types?include_disabled=true")).types;
const typeName = (id) => types.find((t) => t.type_id === id)?.name ?? id;
const interval = 20;
const sta = (m) => {
let n = Math.floor((m + 1e-6) / interval);
let r = m - n * interval;
if (Math.abs(r) < 0.05) r = 0;
if (r >= interval - 0.05) ((n += 1), (r = 0));
return `${n}+${Math.round(r)}`;
};
const structures = (await api(`/api/projects/${pid}/route/structures`)).structures;
const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
const rows = [
...structures.map((s) => ({
anchor: s.chainage_m ?? s.start_m,
type: s.type_id === "refuge" ? "evac_road" : s.type_id, // 옛 대피소는 대피로 폼으로 엶(52-7)
pill: s.structure_id,
name: (s.type_id === "etc" && String(s.options?.name ?? "").trim()) || typeName(s.type_id),
station: s.placement === "interval" ? `${sta(s.start_m)}~${sta(s.end_m)}` : sta(s.chainage_m),
})),
...pipes.map((p) => ({
anchor: p.chainage_m,
type: p.facility ?? "pipe",
pill: `pipe-${p.chainage_m.toFixed(2)}`,
name: typeName(p.facility ?? "pipe"),
station: sta(p.chainage_m),
})),
].sort((a, b) => a.anchor - b.anchor);
const total = {};
rows.forEach((r) => (total[r.name] = (total[r.name] ?? 0) + 1));
const seen = {};
rows.forEach((r) => {
if (total[r.name] > 1) r.name = `${r.name} ${(seen[r.name] = (seen[r.name] ?? 0) + 1)}`;
r.text = `${r.station} ${r.name}`;
});
const clearPick = async () => {
for (let k = 0; k < 2 && selRows().length; k++) {
selRows()[0].click();
await w(900);
}
};
const out = { mode: MODE, view: isB06 ? "b06" : "b05", fails: [] };
const fail = (what) => out.fails.push(what);
if (MODE === "reopen") {
// 다시 연 화면 — 저장 전에 고른 것이 되살아났는가(세션 고름)
const sel = selRows();
out.restored = sel.map(txt);
if (sel.length !== 1 || !/옹벽$/.test(txt(sel[0]))) fail(`되살린 강조 ${out.restored}`);
else if (!inView(sel[0])) fail("되살린 줄이 목록 칸 밖");
const pills = litPills();
if (pills.length !== 1 || pills[0] !== rows.find((r) => /옹벽$/.test(r.text))?.pill)
fail(`되살린 알약 ${pills}`);
}
await clearPick();
if (MODE === "names") {
const got = items().map(txt);
out.rows = got.length;
out.expected = rows.length;
rows.forEach((r, i) => got[i] !== r.text && fail(`${i}: ${got[i]} ≠ ${r.text}`));
out.names = got;
} else if (MODE === "list") {
const tagCs = new Set(tags().map((t) => t.c.toFixed(2)));
for (const [i, r] of rows.entries()) {
items()[i].click();
await w(isB06 ? 1500 : 900);
const sel = selRows();
if (sel.length !== 1 || sel[0] !== items()[i]) fail(`${r.text}: 줄 강조 ${sel.map(txt)}`);
if (typeSel()?.value !== r.type) fail(`${r.text}: 폼 종류 ${typeSel()?.value}`);
const pills = litPills();
if (pills.length !== 1 || pills[0] !== r.pill) fail(`${r.text}: 알약 ${pills}`);
if (!isB06) {
const want = [...tagCs].filter((c) => Math.abs(c - r.anchor) < 0.01);
const got = lit3D();
if (want.join() !== got.join()) fail(`${r.text}: 3D ${got} ≠ ${want}`);
} else {
// 카드 = 기준점에 가장 가까운 측점 하나(카드 글 「측점 누가거리m …」)
const card = cardSel();
if (card.length !== 1 || Math.abs(card[0] - r.anchor) > interval / 2)
fail(`${r.text}: 카드 ${card}`);
}
items()[i].click(); // 다시 누름 = 풂
await w(900);
if (selRows().length || litPills().length || (!isB06 && lit3D().length))
fail(`${r.text}: 풂 뒤 남음 ${selRows().length}/${litPills()}/${lit3D()}`);
}
out.checked = rows.length;
} else if (MODE === "pill") {
for (const r of rows) {
const pill = marks().find((m) => m.dataset.structureId === r.pill);
if (!pill) {
fail(`${r.text}: 알약 없음`);
continue;
}
pill.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await w(isB06 ? 1500 : 900);
const sel = selRows();
if (sel.length !== 1 || txt(sel[0]) !== r.text) fail(`${r.text}: 줄 ${sel.map(txt)}`);
else if (!inView(sel[0])) fail(`${r.text}: 목록 칸 밖`);
if (litPills().join() !== r.pill) fail(`${r.text}: 알약 ${litPills()}`);
await clearPick();
}
out.checked = rows.length;
} else if (MODE === "card") {
// 측점마다 카드 하나 — 기준점이 그 측점인 줄 중 하나가 켜져야 함(같은 측점 여럿은 52-15 규칙)
const cards = [...document.querySelectorAll(".b06-cross-card")];
let n = 0;
for (const card of cards) {
const at = Number(txt(card).split(" ")[1]?.replace("m", ""));
const here = rows.filter((r) => Math.abs(r.anchor - at) < 0.51);
if (!here.length) continue;
// 관 시설 범위 안 카드는 관이 먼저(계곡 통과 시설 우선 · resolveSelection)
here.push(...rows.filter((r) => r.pill.startsWith("pipe-") && Math.abs(r.anchor - at) < 5));
n += 1;
card.scrollIntoView({ block: "nearest" });
await w(300);
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await w(1500);
const sel = selRows();
const hit = here.find((r) => sel.length === 1 && txt(sel[0]) === r.text);
if (!hit) fail(`${at}: 줄 ${sel.map(txt)} (후보 ${here.map((r) => r.text)})`);
else if (litPills().join() !== hit.pill) fail(`${at}: 알약 ${litPills()} ≠ ${hit.pill}`);
if (sel.length && !inView(sel[0])) fail(`${at}: 목록 칸 밖`);
await clearPick();
}
out.checked = n;
} else if (MODE === "three") {
const S = window.__corridorScene;
const canvas = [...document.querySelectorAll("canvas")]
.filter(vis)
.sort((a, b) => b.width * b.height - a.width * a.height)[0];
const done = new Set();
let n = 0;
// 종단 도크가 캔버스 아래를 덮음 — 단계 동안 접고 끝에 다시 폄
const handle = document.querySelector(".ui-workflow-overlay__handle--bottom");
handle?.click();
await w(1500);
const o = S.toScene(0, 0, 0); // 장면 → 모델: x = x − o.x · y = −(z − o.z) · z = y − o.y
for (const t of tags()) {
const key = t.c.toFixed(2);
if (done.has(key)) continue;
const box = new S.THREE.Box3().setFromObject(t.o);
const c = box.getCenter(new S.THREE.Vector3());
S.lookAt(c.x - o.x, -(c.z - o.z), c.y - o.y, 25, "top"); // 그 부재 위에서 내려다봄
S.camera.updateMatrixWorld();
const p = c.clone().project(S.camera);
const rect = canvas.getBoundingClientRect();
const x = rect.left + ((p.x + 1) / 2) * rect.width;
const y = rect.top + ((1 - p.y) / 2) * rect.height;
const top = document.elementFromPoint(x, y);
if (top !== canvas) continue; // 패널에 가린 부재는 건너뜀
const ray = new S.THREE.Raycaster();
ray.setFromCamera(new S.THREE.Vector2(p.x, p.y), S.camera);
const first = ray.intersectObject(S.scene, true).find((h) => h.object.isMesh);
if (first?.object.userData?.chainageM !== t.c) continue; // 앞에 다른 물체
done.add(key);
n += 1;
const ev = (type) =>
new PointerEvent(type, {
bubbles: true,
clientX: x,
clientY: y,
button: 0,
pointerId: 7,
isPrimary: true,
});
window.dispatchEvent(ev("pointerdown"));
canvas.dispatchEvent(ev("pointerdown"));
canvas.dispatchEvent(ev("pointerup"));
await w(1200);
const sel = selRows();
const here = rows.filter((r) => Math.abs(r.anchor - t.c) < 0.51);
if (!here.some((r) => sel.length === 1 && txt(sel[0]) === r.text))
fail(`3D ${key}: 줄 ${sel.map(txt)} (후보 ${here.map((r) => r.text)})`);
else if (!inView(sel[0])) fail(`3D ${key}: 목록 칸 밖`);
if (!lit3D().includes(key)) fail(`3D ${key}: 부재 강조 ${lit3D()}`);
await clearPick();
}
handle?.click();
await w(1500);
out.tagged = new Set(tags().map((t) => t.c.toFixed(2))).size;
out.checked = n;
} else if (MODE === "misc") {
const rules = [...document.styleSheets].flatMap((s) => {
try {
return [...s.cssRules].map((r) => r.selectorText ?? "");
} catch {
return [];
}
});
if (!rules.includes(".b05-route__irregular-item:hover")) fail("목록 줄 마우스 올림 규칙 없음");
if (!rules.includes(".b05-structure__mark:hover")) fail("알약 마우스 올림 규칙 없음");
const l = list();
out.scroll = { scrollH: l.scrollHeight, clientH: l.clientHeight };
if (l.scrollHeight <= l.clientHeight) fail("목록이 칸보다 짧음 — 스크롤 시험 안 됨");
for (const r of [rows[rows.length - 1], rows[0], rows[Math.floor(rows.length / 2)]]) {
marks()
.find((m) => m.dataset.structureId === r.pill)
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await w(isB06 ? 1500 : 900);
const sel = selRows()[0];
if (!sel || !inView(sel)) fail(`${r.text}: 스크롤 뒤 안 보임`);
await clearPick();
}
out.checked = 5;
} else if (MODE === "edit") {
// 추가(반사경 2+10 · 폼) → 차례 · 강조 풀림 → 그 줄 고르고 삭제 → 처음과 같음 → 옹벽 고르고 [저장]
const before = items().map(txt);
const sec = document.querySelector(".b05-structure-section");
const fire = (el, v) => {
el.value = v;
el.dispatchEvent(new Event("change", { bubbles: true }));
};
const [g, t] = sec.querySelectorAll(".b05-structure__grid select");
fire(g, "E");
await w(300);
fire(t, "mirror");
await w(800);
const anchor = sec
.querySelectorAll(".b05-structure__position-row > label")[2]
.querySelectorAll("input");
fire(anchor[0], "2");
fire(anchor[1], "10");
await w(300);
sec.querySelector(".is-primary").click(); // 추가
await w(1500);
const added = items().map(txt);
out.added = added.slice(0, 3);
if (added.length !== before.length + 1) fail(`추가 뒤 줄 수 ${added.length}`);
if (added[1] !== "2+10 반사경 1" || !added.includes("9+0 반사경 2"))
fail(`추가 차례 · 번호 ${added.slice(0, 3)} · 9+0 ${added.find((x) => x.startsWith("9+0"))}`);
if (selRows().length || litPills().length) fail("추가 뒤 강조 남음");
items()[1].click();
await w(900);
if (txt(selRows()[0] ?? list()) !== "2+10 반사경 1") fail("새 줄 고르기");
if (litPills().length !== 1) fail(`새 줄 알약 ${litPills()}`);
sec.querySelector(".is-danger").click(); // 삭제
await w(1500);
const removed = items().map(txt);
if (removed.join("|") !== before.join("|")) fail("삭제 뒤 목록이 처음과 다름");
if (selRows().length || litPills().length) fail("삭제 뒤 강조 남음");
// 옹벽 고른 채 [저장] — 저장 뒤 차례 그대로 · 강조 그대로
items()
.find((li) => /옹벽$/.test(txt(li)))
.click();
await w(900);
[...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click();
for (let k = 0; k < 120 && !document.querySelector(".ui-toast, [class*='toast']"); k++)
await w(500);
await w(3000);
out.toast = document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null;
const saved = items().map(txt);
if (saved.join("|") !== before.join("|")) fail("저장 뒤 목록 차례가 다름");
out.afterSave = selRows().map(txt);
if (out.afterSave.length !== 1 || !/옹벽$/.test(out.afterSave[0])) fail("저장 뒤 강조 풀림");
out.checked = 9;
}
window.requestAnimationFrame = raf;
out.failCount = out.fails.length;
sessionStorage.setItem(`__532_out_${MODE}_${out.view}`, JSON.stringify(out)); // 긴 단계는 여기서 읽음
return JSON.stringify(out);
})();
@@ -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);
})();
+84
View File
@@ -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;
}
+146
View File
@@ -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());
}
-40
View File
@@ -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"],
-8
View File
@@ -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"],
+2
View File
@@ -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) {
+8 -1
View File
@@ -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),
});