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/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_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index c3ad245fe..a1279c3b3 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -373,16 +373,11 @@ export function createFacilityOptionsForm( // 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ── const paveForm = createSpecForm(specLinks, { onChange: () => emit() }); - // 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정). - const saveNote = document.createElement("p"); - saveNote.className = "b05-structure__owner-note"; - saveNote.textContent = - "[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀"; + // 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`. // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) — // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( - saveNote, pipeForm.root, fordForm.root, paveForm.root, 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_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..2dd7f201e 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 diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 320f151ef..7c9b09543 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_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_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/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/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/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/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), });