diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 2c411ffe8..37d5c757f 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -64,6 +64,8 @@ export const STATE_REGISTRY = { "m02-combo-panel-width": { bucket: "pref", scope: "global" }, /** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */ "m02-card-pills": { bucket: "pref", scope: "global" }, + /** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */ + "m01-logic-panel": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/A00_Common/design/design_panel.ts b/A00_Common/design/design_panel.ts index 76094010f..08333ed9b 100644 --- a/A00_Common/design/design_panel.ts +++ b/A00_Common/design/design_panel.ts @@ -112,6 +112,13 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De const dockParts: HTMLElement[] = []; const dock = createDesignDock(view, dockParts, ctx.dock); + // 칸은 공용 스크롤 칸(쪼갠 뒤) 안에 둔다 — 쪼개기 전이면 패널 루트의 단추 묶음 앞. + const place = (element: HTMLElement): void => { + const scroll = root.querySelector(":scope > .ui-sidebar-scroll"); + if (scroll) scroll.append(element); + else root.insertBefore(element, dock.root); + }; + const show = (target: DesignView): void => { const wanted = new Set(panelSectionsFor(target).map((def) => def.id)); for (const def of panelSectionsFor(target)) { @@ -120,12 +127,12 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De const sectionRoot = "root" in made ? made.root : made; if ("root" in made && made.dock) dock.root.prepend(made.dock); built.set(def.id, sectionRoot); - root.insertBefore(sectionRoot, dock.root); + place(sectionRoot); } // 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게. for (const def of panelSectionsFor(target)) { const element = built.get(def.id); - if (element) root.insertBefore(element, dock.root); + if (element) place(element); } built.forEach((element, id) => { // 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다. @@ -144,7 +151,9 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De dock, setView: (next) => { dock.setView(next); - root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form"; + // 클래스 통째 덮기 금지 — 공용 쪼개기가 붙인 ui-sidebar-fill 이 지워져 단추 묶음이 바닥에서 뜬다. + root.classList.toggle("b05-route__panel", next === "profile"); + root.classList.toggle("b06-profile__form", next !== "profile"); show(next); }, }; diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 18fe1a8ba..622ceb954 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -12,6 +12,7 @@ import { ROUTES } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; +import { splitSidebarActions } from "@ui/ui_template_overlay"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav"; import { navigateTo } from "../router"; @@ -229,6 +230,9 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel { shared.view(), ); shared.left.append(panel.root); + // 틀이 먼저 섰으면 좌측 자리가 빈 채로 지나갔다 — 붙인 지금 [스크롤 칸][아래 고정 단추]로 쪼갠다. + const body = shared.left.closest(".ui-workflow-overlay__body"); + if (body) splitSidebarActions(body); shared.panel = panel; return panel; } diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 0c677c35d..4443b06a8 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -178,7 +178,6 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null) settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () => navigateTo(ROUTES.M01_MASTER_DATA), ), - m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", "master", desc), m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", "master", desc), m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), @@ -193,10 +192,7 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null) L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"), L("B01_Dashboard_PermPersonalWrite"), ].join(" · "); - wrap.append( - m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", root, desc), - m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc), - ); + wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc)); return wrap; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index b7cffb06d..33b9467c9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -4,7 +4,8 @@ * * Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든 * 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선 - * (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다. + * (점선) 둘 다 잰다 — 첫 점을 찍은 노선을 기억해 둘째 점(과 커서 미리보기)은 그 노선에만 + * 붙는다 — 두 노선이 겹쳐 다른 노선이 위에 있어도 무시한다. 길이 = 두 점 누가거리의 차. * * 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는 * 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를 @@ -56,6 +57,8 @@ export interface MeasureTool { hint: () => string; /** 재고 있나 — 작은 창을 띄울지 정하는 값. */ active: () => boolean; + /** 첫 점만 찍혔을 때 커서 자리 미리보기(첫 점 노선 위). 바뀌었으면 true — 다시 그린다. */ + preview: (px: number, py: number) => boolean; /** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */ pick: (px: number, py: number) => Promise; /** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */ @@ -109,6 +112,8 @@ export function bindMeasureControls(params: MeasureControlsParams): { export function createMeasureTool(params: MeasureToolParams): MeasureTool { /** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */ let picked: MeasurePoint[] = []; + /** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */ + let hover: MeasureMark | null = null; const hint = (): string => { if (picked.length === 0) return ""; @@ -133,10 +138,12 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { ); }; - /** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */ + /** 클릭에 가장 가까운 노선 위 점. 첫 점이 찍혀 있으면 **그 노선만** 본다(겹친 자리에서 튀지 않게). */ const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => { const lines = params.lines(); - const hits = (["planned", "expected"] as const) + const candidates: MeasureLine[] = + picked.length === 1 ? [picked[0].line] : ["planned", "expected"]; + const hits = candidates .map((line) => { const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX); if (!hit) return null; @@ -145,30 +152,42 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { }) .filter((hit): hit is NonNullable => hit !== null); if (!hits.length) return null; - const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined; - const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a)); + const best = hits.reduce((a, b) => (b.distance < a.distance ? b : a)); return { point: best.point, chainageM: best.chainageM, line: best.line }; }; return { - marks: () => - picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })), + marks: () => [ + ...picked.map((entry) => ({ + point: entry.point, + chainageM: entry.chainageM, + line: entry.line, + })), + ...(picked.length === 1 && hover ? [hover] : []), + ], hint, active: () => picked.length > 0, + preview(px, py) { + const next = picked.length === 1 ? hitAt(px, py) : null; + if (!next && !hover) return false; + hover = next; + return true; + }, clear() { + hover = null; if (picked.length === 0) return; picked = []; params.onChange(); }, async pick(px, py) { const hit = hitAt(px, py); + hover = null; if (!hit) { picked = []; // 노선을 빗나가면 재던 것을 접는다. params.onChange(); return; } - const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line); - picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; + picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; params.onChange(); if (picked.length < 2) return; const asked = picked; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index a3eb75eb0..ac87885c4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -71,7 +71,11 @@ export interface RouteEditPointerParams { /** 돌린 지도의 화면 좌표를 돌리기 전 자리로. */ unrotate: (x: number, y: number) => [number, number]; contourStepM: () => number; - measure: { pick: (px: number, py: number) => unknown; clear: () => void }; + measure: { + pick: (px: number, py: number) => unknown; + preview: (px: number, py: number) => boolean; + clear: () => void; + }; /** 재기 단추가 켜져 있나 — 켜져 있으면 노드 위에서도 잰다. */ measureMode: () => boolean; crossPreview: { open: (chainageM: number) => unknown; refresh: () => unknown }; @@ -287,6 +291,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP params.draw(); return; } + // 재는 중 — 둘째 점 미리보기는 첫 점 노선 위에만 붙는다. + if ((event.shiftKey || params.measureMode()) && params.measure.preview(px, py)) params.draw(); const overNode = nodeAt(px, py); // 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다. canvas.style.cursor = diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index caa6c66c3..a7ebab158 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -187,7 +187,7 @@ .b05-routeedit__cross-station { color: var(--color-text-secondary); - font-size: var(--text-caption); + font-size: inherit; /* 44-2 — 제목(횡단)과 같은 크기 */ } /* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③). @@ -235,7 +235,7 @@ .b05-routeedit__cross-key { fill: var(--color-text-secondary, #94a3b8); - font-size: 11px; + font-size: 5.5px; /* 44-2 — 지금(11px)의 절반 */ } .b05-routeedit__cross-key.is-design { diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index f487a6d78..5b0aaa349 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -190,6 +190,12 @@ def get_logic(key: str) -> dict: return _call(store.logic, key) +@router.get("/logic/source") +def get_logic_source(src: str) -> dict: + """로직 원문 — 출처 글로 찾은 품셈 본문 절(읽기만).""" + return _call(make.source, src) + + @router.get("/logic/auto") def get_logic_auto(key: str) -> dict: """견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님) · 흐름 로직은 설계 예시값.""" diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 76dc319d9..c5ab2df45 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -103,6 +103,79 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi return {"total": len(hits), "sections": hits[: min(max(limit, 1), 1000)]} +# ── 로직 원문(출처 글 → 품셈 본문 절) ────────────────────────────────── +_NUMBER = re.compile(r"(? list[dict]: + """md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌.""" + out: list[dict] = [] + for line in lines: + text = line.strip() + if not text or text.startswith("