diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index d330bf546..050c3c7db 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -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()); diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index 4e7884a1d..d4c7a3d17 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -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 }, diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index b7ece0aed..dd0a7f38c 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -182,7 +182,18 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return wrap; }); box.body.append(grid(...fields)); - if (c.required) box.body.append(slotOf(c.id)); + if (c.required) { + const slot = slotOf(c.id); + // B06 조정 강조(테두리 · 이름표)는 칸 상자에 붙음 — 다시 그려도 이어 감(53-1) + const prev = slot.closest("fieldset"); + if (prev?.classList.contains("is-active")) { + const legend = box.root.querySelector("legend")!; + legend.dataset.baseLabel = legend.textContent ?? ""; + legend.textContent = prev.querySelector("legend")?.textContent ?? legend.textContent; + box.root.classList.add("is-active"); + } + box.body.append(slot); + } if (links) { const key = linkOf(c, index + 1); const row = buildSummaryRow(); diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 8936e69fd..e98171105 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -203,11 +203,15 @@ export async function mountCrossView( view: () => sectionView, }); + let structuresRefreshSeq = 0; /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; + const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4) try { - sectionDetail = await loadSectionDetail(projectId, currentRouteId); + const fresh = await loadSectionDetail(projectId, currentRouteId); + if (seq !== structuresRefreshSeq) return; + sectionDetail = fresh; // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가 // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측). if (sectionDetail) { @@ -358,6 +362,11 @@ export async function mountCrossView( const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); // 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다. if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" }); + // 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면 + // 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1) + const pipeAt = structuresPanel.currentChainage(); + const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); + if (mark) sectionView.selectMark(mark); structureSelection.syncInletStructure(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 // (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다. @@ -436,6 +445,10 @@ export async function mountCrossView( structuresPanel.prefer(handoff?.structureId ?? null); sectionView.focusStation(stationId); structuresPanel.prefer(null); + // 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1) + const sid = handoff?.structureId; + if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid)) + structuresPanel.selectStructure(sid); }, refreshCard: (chainageM) => sectionView.refreshCard(chainageM), revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), @@ -495,6 +508,8 @@ export async function mountCrossView( // 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다. host.onShow(() => { if (currentRouteId === null) return; + // 떠난 사이 종단 보기가 폼을 다른 것으로 바꿨을 수 있음 — 같은 넘김값도 다시 적용(53-1) + appliedPick = null; void structuresPanel.load().then(restoreStructurePick); void refreshDetailForStructures().then(() => reconcileStaleDesigns()); }); diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 68faf6897..eb8496792 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -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", diff --git a/B03_FileInput/B03_FileInput_UI_Guide.ts b/B03_FileInput/B03_FileInput_UI_Guide.ts index f0e408303..062f21290 100644 --- a/B03_FileInput/B03_FileInput_UI_Guide.ts +++ b/B03_FileInput/B03_FileInput_UI_Guide.ts @@ -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; } diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 2f4b0d405..be437104d 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -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; diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index f804f7e13..c2c8c88c8 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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군 구간형과 같은 규칙). 나머지 관 시설은 # 기준 한 곳(관이 노선을 가로지르는 점형). diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index 8569f1579..fa25e4f4d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -88,6 +88,7 @@ export function structureHashParts(section: CrossSection): Array 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, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index c001a7137..42654c90d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -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, diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts index d4de1c8c0..585175a53 100644 --- a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -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(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index eaf354609..3ea90331b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -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; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index d2d4889bb..7aa35eb32 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -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, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 160806a93..c09b500c0 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -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`. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 163e45ed3..140499f9b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -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(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 312e0ca25..703d97378 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -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) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로 diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index ac4c69368..65ba8eb49 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -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 { diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index d0a4b7d47..174801264 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -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 과 같은 칸). */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 87fb59003..a9660dc40 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -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 걷음). diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 526138242..5190e78c2 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 41ce275ee..2eb75a1db 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -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 { + const pipes = readPendingPipes(projectId); + if (!pipes?.length) return; + const sections = detail.cross_sections as unknown as Array>; + const near = (spec: Record, section: Record, 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) => + 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 | 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 | 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)); +} + /** * 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다. * diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 672a2c3a0..83e8ec038 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -109,6 +109,13 @@ function clearSideSlots(): void { // 추가 기슭막이 칸은 고른 다단 벽이 있을 때만 서 있는다 — 비면 그룹째 내린다. if (target === sideSlots?.extra) group.hidden = true; } + // 칸이 다른 명세로 옮겨 간 뒤 옛 상자에 남은 강조까지 끔(53-1) + document + .querySelectorAll("fieldset.is-active > legend[data-base-label]") + .forEach((legend) => { + legend.parentElement!.classList.remove("is-active"); + setGroupLabel(legend.parentElement!, ""); + }); } /** 행 DOM을 제자리 표식과 함께 옮긴다 — 숨은 행은 그대로 둔다. */ @@ -161,8 +168,8 @@ function relocateSideRows(panelRoot: HTMLElement): void { // 이름표에 지금 만지는 구조물을 적는다 — "유입구 (기슭막이)", "유출구 (기슭막이(독립))". // 추가 기슭막이 칸은 이름 자체가 그 단이라 괄호 안에 단 번호가 온다(2026-08-30 지시 1). setGroupLabel(group, panelRoot.dataset.panelTitle ?? ""); - // 다단은 칸이 새로 서는 것이라 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2). - if (side === "extra") group.scrollIntoView({ block: "nearest", behavior: "smooth" }); + // 고른 칸이 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2 · 유입 · 유출도 53-1). + group.scrollIntoView({ block: "nearest" }); } function ensureDock(): void { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index 744c21cb3..ef11f4d65 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 059d186f5..69fc5d3ec 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -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, diff --git a/B06_Section/B06_Section_UI_Cross_Lean.ts b/B06_Section/B06_Section_UI_Cross_Lean.ts index e6daa9d8d..5607cab81 100644 --- a/B06_Section/B06_Section_UI_Cross_Lean.ts +++ b/B06_Section/B06_Section_UI_Cross_Lean.ts @@ -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 }); +} diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 73970a199..1d3c8d98f 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -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, diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 59b988b01..0002fef70 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -38,8 +38,10 @@ import { } from "../common_util/common_util_summary_item"; import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { + coversChainage, NO_SELECTION, PIPE_MATCH_M, + readSelection, resolveSelection, selectDesign, type DesignSelection, @@ -172,6 +174,23 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); deps.onStructuresChanged?.(); + // 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1) + const picked = readSelection(deps.projectId); + const moved = picked?.structureId + ? structures.find((entry) => entry.structure_id === picked.structureId) + : undefined; + if (moved && picked?.chainageM != null && !coversChainage(moved, picked.chainageM)) { + const id = moved.structure_id ?? null; + preferredId = id; + deps.focusChainage(structureAnchorM(moved)); + preferredId = null; + deps.markStructure?.(id); // 카드 고름이 옛 자리로 알약을 풀었으면 다시 세움 + selectDesign( + deps.projectId, + { chainageM: structureAnchorM(moved), structureId: id }, + { from: "b06" }, + ); + } }, onSelect: (structure) => { if (!structure) { @@ -245,18 +264,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } // 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이 // [저장] 때 옛 값으로 되살아난다. 옮기기 전 자리로 찾는다(저장 시점 목록 기준). - deps.applyPipeOptions?.(fromChainageM, patch); const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); + const was = hit?.facility ?? "pipe"; + const facility = attributes.facility ?? was; + // 종류를 바꿈(54-4) — 옛 종류 그림 캐시에 새 종류 칸을 얹지 않음 · 옛 칸은 안 이음 + if (facility === was) deps.applyPipeOptions?.(fromChainageM, patch); if (hit) { - const facility = hit.facility ?? "pipe"; hit.options = - mergeFacilityOptions({ facility, options: hit.options }, { facility, options: patch }) - .options ?? {}; + mergeFacilityOptions( + { facility: was, options: hit.options }, + { facility, options: patch }, + ).options ?? {}; + hit.facility = facility; } persistPipes(); section.setPipeFacilities(pipeFacilities); + if (facility !== was) { + pushMarks(); + deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`) + } }, onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { @@ -561,11 +589,17 @@ export function wireStructureSelection( const origRevet = stationControls.revetOffset.select; stationControls.revetOffset.select = (chainageM, key) => { - markWallSelecting(); + // 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1) + const nested = wallSelecting; + // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) + if (key) markWallSelecting(); origRevet(chainageM, key); if (key) remember?.(ownerChainageOf(chainageM), key); if (key) panel.showAtChainage(ownerChainageOf(chainageM)); - else panel.releasePipe(); + else if (!nested) { + remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림 + panel.releasePipe(); + } if (key) syncInletStructure(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다. refreshAdjustSlots(); @@ -573,22 +607,30 @@ export function wireStructureSelection( const origFord = stationControls.ford.select; stationControls.ford.select = (chainageM, role) => { - markWallSelecting(); + const nested = wallSelecting; + if (role) markWallSelecting(); origFord(chainageM, role); if (role) remember?.(chainageM, role); if (role) panel.showAtChainage(chainageM); - else panel.releasePipe(); + else if (!nested) { + remember?.(chainageM, ""); + panel.releasePipe(); + } // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다 // (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1). refreshAdjustSlots(); }; const origBox = stationControls.box.select; stationControls.box.select = (chainageM, role) => { - markWallSelecting(); + const nested = wallSelecting; + if (role) markWallSelecting(); origBox(chainageM, role); if (role) remember?.(chainageM, role); if (role) panel.showAtChainage(chainageM); - else panel.releasePipe(); + else if (!nested) { + remember?.(chainageM, ""); + panel.releasePipe(); + } // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. refreshAdjustSlots(); }; diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index ce2ace55f..00b985789 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -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 = []; + /** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */ + let markedId: string | null = null; let types: ReadonlyArray = []; let stations: IrregularStation[] = []; let intervalM = 20; @@ -258,6 +261,7 @@ export function createSectionProfile( profile?.redraw(); }, selectMark(structureId) { + markedId = structureId; profile?.setSelectedStructure(structureId); }, showCard(section) { @@ -270,6 +274,10 @@ export function createSectionProfile( (entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M, ); profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id); + // 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1) + const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined; + if (!pipe && marked && coversChainage(marked, section.chainage_m)) + profile.setSelectedStructure(markedId); }, }; } diff --git a/B06_Section/B06_Section_UI_Standard_Diagram.ts b/B06_Section/B06_Section_UI_Standard_Diagram.ts index e6c37a58b..4c409b174 100644 --- a/B06_Section/B06_Section_UI_Standard_Diagram.ts +++ b/B06_Section/B06_Section_UI_Standard_Diagram.ts @@ -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; } diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index b564cd922..71c09b3ae 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -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; }; diff --git a/B06_Section/B06_Section_UI_Style.css b/B06_Section/B06_Section_UI_Style.css index 89fed6003..c4d3492ec 100644 --- a/B06_Section/B06_Section_UI_Style.css +++ b/B06_Section/B06_Section_UI_Style.css @@ -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); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index e40721b11..5cd7b9bca 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -430,6 +430,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // (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); diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index b9fb617e7..296dcf02c 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -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; } diff --git a/common_util/common_util_help_router.py b/common_util/common_util_help_router.py new file mode 100644 index 000000000..1f9c4be92 --- /dev/null +++ b/common_util/common_util_help_router.py @@ -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)} diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 2b12931aa..740f9584f 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -149,3 +149,14 @@ ㄴ. 지난 정의 값이라는 항목은 필요없음. 향후 다 정리하고 사용하지 않는 코드들은 삭제 예정 ㄷ. 추가 기슭막이(단)이 흙막이 추가 흙막이 추가에 대한 내용이었음. 별도 버튼으로 추가하는게 아님. 상단 컨테이너의 흙막이 옵션중 추가 기슭막이(단)의 값에 따라 생성되는데 이때 추가되는 기슭막이의 이름을 흙막이로 변경하고 선택되었을때 보여주는게 아니라 항상 보이게 ㄹ. 도수로/돌붙임은 일단 유지 + +(10-6 사용자 · 공통 덧) +공통 + - 기존 배관에서 박스암거로 변경할 경우 횡단도의 그림이 업데이트 안됨. 확인 및 업데이트 + - 추가로 관련된 정보 중 확인해줘야 할 내용이 기존에 화면의 B05/B06의 실시간 제어와 동작 속도/보안 개선을 위해 많은 작업이 있었는데 좌측 패널 통합 이후 많이 느려진 걸로 보임. 재확인. 이건 확인부터 하는게 좋겠음. + +(10-6 사용자 · 추가 요청) + - 사면 안정 그룹의 종류들 전체가 컨테이너 구성이 안되어 있음. 현재 기준 측점을 제외한 옵션들을 하나의 컨테이너에 넣고 그 안에 집계 항목 지정 넣을것 + +(10-6 사용자 · 추가 요청) + - 안전부대용지 그룹의 사토장의 기준측점 표현이 예전 코드입. 토취장과 동일하게 구성 diff --git a/main.py b/main.py index 13dd66cd9..3a2c31b48 100644 --- a/main.py +++ b/main.py @@ -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) # ───────────────────────────────────────────────────────────────────────── diff --git a/resources/help/B02/00_설명서.md b/resources/help/B02/00_설명서.md new file mode 100644 index 000000000..ce3f51ea9 --- /dev/null +++ b/resources/help/B02/00_설명서.md @@ -0,0 +1,9 @@ +# 프로젝트 등록 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B03/00_설명서.md b/resources/help/B03/00_설명서.md new file mode 100644 index 000000000..bf1fc7923 --- /dev/null +++ b/resources/help/B03/00_설명서.md @@ -0,0 +1,9 @@ +# 파일입력 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B03/10_필요한_파일.md b/resources/help/B03/10_필요한_파일.md new file mode 100644 index 000000000..4fe4b1b22 --- /dev/null +++ b/resources/help/B03/10_필요한_파일.md @@ -0,0 +1,5 @@ +# 필요한 파일 + +- 계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개. +- 지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW. +- 지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다. diff --git a/resources/help/B03/20_고르는_방법.md b/resources/help/B03/20_고르는_방법.md new file mode 100644 index 000000000..02333ba83 --- /dev/null +++ b/resources/help/B03/20_고르는_방법.md @@ -0,0 +1,5 @@ +# 고르는 방법 + +- 여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다. +- 카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다. +- [임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다. diff --git a/resources/help/B03/30_알아_둘_것.md b/resources/help/B03/30_알아_둘_것.md new file mode 100644 index 000000000..e80291a60 --- /dev/null +++ b/resources/help/B03/30_알아_둘_것.md @@ -0,0 +1,5 @@ +# 알아 둘 것 + +- 이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다. +- 작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다. +- 라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다. diff --git a/resources/help/B04/00_설명서.md b/resources/help/B04/00_설명서.md new file mode 100644 index 000000000..bcb9a433f --- /dev/null +++ b/resources/help/B04/00_설명서.md @@ -0,0 +1,9 @@ +# 전처리 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B05/00_설명서.md b/resources/help/B05/00_설명서.md new file mode 100644 index 000000000..52d74d9b4 --- /dev/null +++ b/resources/help/B05/00_설명서.md @@ -0,0 +1,9 @@ +# 종단설계 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B05/구조물_BOX암거.md b/resources/help/B05/구조물_BOX암거.md new file mode 100644 index 000000000..b6993e1e3 --- /dev/null +++ b/resources/help/B05/구조물_BOX암거.md @@ -0,0 +1,5 @@ +# 구조물 · BOX암거 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_기슭막이.md b/resources/help/B05/구조물_기슭막이.md new file mode 100644 index 000000000..de7432ce8 --- /dev/null +++ b/resources/help/B05/구조물_기슭막이.md @@ -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 +- ⚠ 사방(계류) 교본 기준 — 임도 배수관 유입·유출부 적용은 확인 남음 diff --git a/resources/help/B05/구조물_돌쌓기(메).md b/resources/help/B05/구조물_돌쌓기(메).md new file mode 100644 index 000000000..1f447c41f --- /dev/null +++ b/resources/help/B05/구조물_돌쌓기(메).md @@ -0,0 +1,6 @@ +# 구조물 · 돌쌓기(메) + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜ diff --git a/resources/help/B05/구조물_돌쌓기(찰).md b/resources/help/B05/구조물_돌쌓기(찰).md new file mode 100644 index 000000000..3fcd36e38 --- /dev/null +++ b/resources/help/B05/구조물_돌쌓기(찰).md @@ -0,0 +1,6 @@ +# 구조물 · 돌쌓기(찰) + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜ diff --git a/resources/help/B05/구조물_물넘이포장.md b/resources/help/B05/구조물_물넘이포장.md new file mode 100644 index 000000000..048d76751 --- /dev/null +++ b/resources/help/B05/구조물_물넘이포장.md @@ -0,0 +1,5 @@ +# 구조물 · 물넘이포장 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_배관.md b/resources/help/B05/구조물_배관.md new file mode 100644 index 000000000..53872704d --- /dev/null +++ b/resources/help/B05/구조물_배관.md @@ -0,0 +1,5 @@ +# 구조물 · 배관 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_비탈면 녹화.md b/resources/help/B05/구조물_비탈면 녹화.md new file mode 100644 index 000000000..07695f068 --- /dev/null +++ b/resources/help/B05/구조물_비탈면 녹화.md @@ -0,0 +1,5 @@ +# 구조물 · 비탈면 녹화 + +## 칸 기본값의 뜻 + +- **공법**(횡단설계) — 「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인) diff --git a/resources/help/B05/구조물_세월교.md b/resources/help/B05/구조물_세월교.md new file mode 100644 index 000000000..f3c8824ee --- /dev/null +++ b/resources/help/B05/구조물_세월교.md @@ -0,0 +1,5 @@ +# 구조물 · 세월교 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_옹벽.md b/resources/help/B05/구조물_옹벽.md new file mode 100644 index 000000000..d8ffe2a85 --- /dev/null +++ b/resources/help/B05/구조물_옹벽.md @@ -0,0 +1,6 @@ +# 구조물 · 옹벽 + +## 칸 기본값의 뜻 + +- **높이** — 기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음 +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 diff --git a/resources/help/B05/구조물_측구(옆도랑).md b/resources/help/B05/구조물_측구(옆도랑).md new file mode 100644 index 000000000..9d3736fe3 --- /dev/null +++ b/resources/help/B05/구조물_측구(옆도랑).md @@ -0,0 +1,3 @@ +# 구조물 · 측구(옆도랑) + +- 횡단 설계에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다. diff --git a/resources/help/B05/구조물_큰돌쌓기.md b/resources/help/B05/구조물_큰돌쌓기.md new file mode 100644 index 000000000..05311185e --- /dev/null +++ b/resources/help/B05/구조물_큰돌쌓기.md @@ -0,0 +1,6 @@ +# 구조물 · 큰돌쌓기 + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜ diff --git a/resources/help/B05/구조물_흙막이.md b/resources/help/B05/구조물_흙막이.md new file mode 100644 index 000000000..5fc4f56de --- /dev/null +++ b/resources/help/B05/구조물_흙막이.md @@ -0,0 +1,9 @@ +# 구조물 · 흙막이 + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **단 수(다단)** — 1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌) +- **기준 올림(사면 위로)** — 0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌) +- **기준 좌우 이동** — 0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌) +- **조달**(횡단설계) — 「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음 diff --git a/resources/help/B06/00_설명서.md b/resources/help/B06/00_설명서.md new file mode 100644 index 000000000..0308a208e --- /dev/null +++ b/resources/help/B06/00_설명서.md @@ -0,0 +1,9 @@ +# 횡단설계 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B06/표준_횡단면.md b/resources/help/B06/표준_횡단면.md new file mode 100644 index 000000000..2ac92b0ae --- /dev/null +++ b/resources/help/B06/표준_횡단면.md @@ -0,0 +1,9 @@ +# 표준 횡단면 설정 + +## 변수 위치 안내 + +- 표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님). + +## 표준횡단면 상세값 + +- L형 측구는 각 횡단면도에서 선택합니다. diff --git a/resources/help/B07/00_설명서.md b/resources/help/B07/00_설명서.md new file mode 100644 index 000000000..6fc3ddd3c --- /dev/null +++ b/resources/help/B07/00_설명서.md @@ -0,0 +1,9 @@ +# 상세설계 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B08/00_설명서.md b/resources/help/B08/00_설명서.md new file mode 100644 index 000000000..291ac50b7 --- /dev/null +++ b/resources/help/B08/00_설명서.md @@ -0,0 +1,9 @@ +# 수량산출 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B09/00_설명서.md b/resources/help/B09/00_설명서.md new file mode 100644 index 000000000..4297e096b --- /dev/null +++ b/resources/help/B09/00_설명서.md @@ -0,0 +1,9 @@ +# 원가계산 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/tester/helper_b05_b06_selection.cjs b/resources/tester/helper_b05_b06_selection.cjs index 8892c7e2f..e38ec452e 100644 --- a/resources/tester/helper_b05_b06_selection.cjs +++ b/resources/tester/helper_b05_b06_selection.cjs @@ -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); diff --git a/resources/tester/test_54_2_help_files.py b/resources/tester/test_54_2_help_files.py new file mode 100644 index 000000000..480fda24a --- /dev/null +++ b/resources/tester/test_54_2_help_files.py @@ -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") diff --git a/resources/tester/test_b05_b06_selection_single.py b/resources/tester/test_b05_b06_selection_single.py index 13728118d..33b63cf3e 100644 --- a/resources/tester/test_b05_b06_selection_single.py +++ b/resources/tester/test_b05_b06_selection_single.py @@ -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") diff --git a/resources/tester/test_b05_pipe_station_relabel.py b/resources/tester/test_b05_pipe_station_relabel.py new file mode 100644 index 000000000..d31a94891 --- /dev/null +++ b/resources/tester/test_b05_pipe_station_relabel.py @@ -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() diff --git a/resources/tester/ui_checks/53-1_횡단_고르기.js b/resources/tester/ui_checks/53-1_횡단_고르기.js new file mode 100644 index 000000000..c6025557e --- /dev/null +++ b/resources/tester/ui_checks/53-1_횡단_고르기.js @@ -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"; +})(); diff --git a/resources/tester/ui_checks/54-2_도움말.js b/resources/tester/ui_checks/54-2_도움말.js new file mode 100644 index 000000000..f3592c5b0 --- /dev/null +++ b/resources/tester/ui_checks/54-2_도움말.js @@ -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); +})(); diff --git a/resources/tester/ui_checks/54-3_배관_횡단그림.js b/resources/tester/ui_checks/54-3_배관_횡단그림.js new file mode 100644 index 000000000..71ac4f824 --- /dev/null +++ b/resources/tester/ui_checks/54-3_배관_횡단그림.js @@ -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); +})(); diff --git a/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js b/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js new file mode 100644 index 000000000..92cc1c07c --- /dev/null +++ b/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js @@ -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); +})(); diff --git a/ui_template/ui_template_help.css b/ui_template/ui_template_help.css new file mode 100644 index 000000000..7c9f3f69e --- /dev/null +++ b/ui_template/ui_template_help.css @@ -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; +} diff --git a/ui_template/ui_template_help.ts b/ui_template/ui_template_help.ts new file mode 100644 index 000000000..712310b81 --- /dev/null +++ b/ui_template/ui_template_help.ts @@ -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, "$1") + .replace(/`(.+?)`/g, "$1"); +} + +/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 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(`

${para.map(inline).join("
")}

`); + para = []; + if (list) out.push(``); + 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(`${inline(head[2])}`); + } else if (item) { + const kind = item[1] ? "ul" : "ol"; + if (para.length || list !== kind) flush(); + if (!list) out.push(`<${kind}>`); + list = kind; + out.push(`
  • ${inline(item[2])}
  • `); + } 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("[data-help-topic]")] + .filter((node) => node.offsetParent !== null) + .map((node) => node.dataset.helpTopic ?? ""); +} + +async function fetchItems(page: string): Promise { + 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 = { B05: "B06", B06: "B05" }; + +async function openHelp(): Promise { + 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) : `

    ${TEXT.empty}

    `; + 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("[data-help-row]") ?? actions).prepend(createHelpButton()); +} diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 69fe4d017..ddf13ae38 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -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"], diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index bcfd5c848..7dec8e9ff 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"], diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index e25fb4236..d1201a47c 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -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) { diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 08b3604cb..fb5a9c0e5 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -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), });