diff --git a/.gitignore b/.gitignore index cfc42947f..36a98043f 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ config/formula_node/ config/spreadsheet_node/ # 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물. config/b07_extent_node/ +# 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1). +config/b07_note_node/ # graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름 # (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」). diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 0399b7ad2..1a3396462 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -57,8 +57,6 @@ export const STATE_REGISTRY = { legacy: () => "b05-route-drainage-collapsed", }, "drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" }, - /** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */ - "drainage-visible": { bucket: "pref", scope: "global" }, /** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */ "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" }, @@ -110,6 +108,8 @@ export const STATE_REGISTRY = { /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ "structure-notes": { bucket: "draft", scope: "route" }, + /** B06 횡단 [상세] 모달 표 위 CAD 칸(260-1) — 측점키(0.01 m) → CAD 엔티티 묶음. [저장]이 내보냄. */ + "structure-note-cads": { bucket: "draft", scope: "route" }, /** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */ "detail-table": { bucket: "draft", scope: "route" }, /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ diff --git a/A00_Common/b_project_switch.ts b/A00_Common/b_project_switch.ts new file mode 100644 index 000000000..3ffaef671 --- /dev/null +++ b/A00_Common/b_project_switch.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * A00_Common/b_project_switch.ts + * 다른 프로젝트에 들어갈 때 **그 밖 프로젝트의 캐시**를 지운다(계획서 290-1 · 요구 183-1). + * + * 지우는 것 — 이 탭 세션의 다른 프로젝트 칸(초안 · 결과 · 고름 · 옛 키 · 페이지별 칸)과 + * 3D 지형 보관함(IndexedDB). 남기는 것 — 지금 프로젝트 것 · 계정 취향(pref, localStorage). + * 다른 프로젝트에 저장 안 한 고침(초안)이 있으면 확인창 — 「남기기」면 그 초안만 둔다. + * 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식). + * ========================================================================== */ + +import { showConfirmDialog } from "@ui/ui_template_elements"; +import { purgeOtherProjects } from "./b_asset_cache"; +import { STATE_REGISTRY, type StateEntry } from "./b_page_state"; + +/** 이 탭이 마지막으로 들어온 프로젝트. */ +const ENTERED_KEY = "aislo:entered-project"; + +const MARK_P = "\u0001"; +const MARK_R = "\u0002"; + +/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */ +function projectPrefixes(): Array<{ prefix: string; draft: boolean }> { + const out: Array<{ prefix: string; draft: boolean }> = []; + Object.entries(STATE_REGISTRY as Record).forEach(([name, entry]) => { + if (entry.bucket === "pref" || entry.scope === "global") return; + const draft = entry.bucket === "draft"; + const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; + out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft }); + const legacy = entry.legacy?.(MARK_P, MARK_R); + const at = legacy?.indexOf(MARK_P) ?? -1; + if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft }); + }); + return out; +} + +const filled = (value: string | null): boolean => + value !== null && value !== "" && value !== "{}" && value !== "[]"; + +export interface ProjectPurgePlan { + /** 저장 안 한 고침이 남은 다른 프로젝트. */ + draftProjects: string[]; + /** 지울 키 — `keep` 프로젝트의 초안은 뺀다. */ + doomed: (keep: ReadonlySet) => string[]; +} + +/** 세션 칸(키 → 값)에서 다른 프로젝트 몫을 가른다 — 저장소를 만지지 않는다. */ +export function planProjectPurge( + session: Record, + projectId: string, +): ProjectPurgePlan { + const prefixes = projectPrefixes(); + const owners = new Map(); + Object.keys(session).forEach((key) => { + const hit = prefixes.find((item) => key.startsWith(item.prefix)); + if (!hit) return; + const project = key.slice(hit.prefix.length).split(":")[0]; + if (project) owners.set(key, { project, draft: hit.draft }); + }); + const others = new Set( + [...owners.values()].map((owner) => owner.project).filter((id) => id !== projectId), + ); + const draftProjects = [ + ...new Set( + [...owners.entries()] + .filter(([key, owner]) => owner.draft && others.has(owner.project) && filled(session[key])) + .map(([, owner]) => owner.project), + ), + ]; + return { + draftProjects, + // 등록표 밖 칸도 키 마디에 다른 프로젝트 번호가 있으면 그 프로젝트 몫으로 본다. + doomed: (keep) => + Object.keys(session).filter((key) => { + const owner = owners.get(key); + if (owner) return others.has(owner.project) && !(owner.draft && keep.has(owner.project)); + return key.split(":").some((part) => others.has(part) && !keep.has(part)); + }), + }; +} + +function sessionEntries(): Record { + const out: Record = {}; + try { + for (let i = 0; i < window.sessionStorage.length; i += 1) { + const key = window.sessionStorage.key(i); + const value = key === null ? null : window.sessionStorage.getItem(key); + if (key !== null && value !== null) out[key] = value; + } + } catch { + /* 저장소가 막힌 브라우저 — 지울 것이 없다. */ + } + return out; +} + +/** + * 프로젝트에 들어감 — 들어온 표식이 다르면 그 밖 프로젝트 캐시를 지운다. 실패해도 이동은 막지 않는다. + * `nameOf` 는 확인창에 적을 프로젝트 이름. + */ +export async function enterProject( + projectId: string, + nameOf: (projectId: string) => Promise, +): Promise { + try { + if (window.sessionStorage.getItem(ENTERED_KEY) === projectId) return; + const plan = planProjectPurge(sessionEntries(), projectId); + const keep = new Set(); + for (const other of plan.draftProjects) { + const name = await nameOf(other).catch(() => other); + const drop = await showConfirmDialog( + `「${name}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기`, + "지우고 열기", + "남기기", + ); + if (!drop) keep.add(other); + } + plan.doomed(keep).forEach((key) => window.sessionStorage.removeItem(key)); + window.sessionStorage.setItem(ENTERED_KEY, projectId); + await purgeOtherProjects(projectId); + } catch { + /* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */ + } +} diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index bb293e404..72301e3d8 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -61,6 +61,10 @@ export interface SvgZoomPanOptions { maxScale?: number; /** 이전 상태 — 다시 그려도 배율이 살아남게. */ initial?: ZoomPanState; + /** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */ + homeScale?: number; + /** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */ + home?: ZoomPanState; /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ onChange?: (state: ZoomPanState) => void; /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ @@ -85,9 +89,17 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { height: Math.max(heightPx - pad.top - pad.bottom, 1), }; const maxScale = options.maxScale ?? 8; - let scale = initial?.scale ?? 1; - let tx = initial?.tx ?? 0; - let ty = initial?.ty ?? 0; + const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale); + const home: ZoomPanState = options.home ?? { + scale: homeScale, + tx: (plot.x + plot.width / 2) * (1 - homeScale), + ty: (plot.y + plot.height / 2) * (1 - homeScale), + }; + /** 축소 한계 — 1배 · 처음 보기가 더 작으면 그만큼. */ + const minScale = Math.min(1, home.scale); + let scale = initial?.scale ?? home.scale; + let tx = initial?.tx ?? home.tx; + let ty = initial?.ty ?? home.ty; const applyTransform = (): void => { layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`); onChange?.({ scale, tx, ty }); @@ -124,7 +136,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { /** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */ const zoomAt = (factor: number, cx: number, cy: number): void => { - const next = Math.min(maxScale, Math.max(1, scale * factor)); + const next = Math.min(maxScale, Math.max(minScale, scale * factor)); tx = cx - ((cx - tx) / scale) * next; ty = cy - ((cy - ty) / scale) * next; scale = next; @@ -148,6 +160,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { if (event.button === 1) event.preventDefault(); }); svg.addEventListener("pointerdown", (event) => { + // 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1) + moved = false; // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. if (!(options.panStart ? options.panStart(event) : event.button === 1)) return; event.preventDefault(); @@ -180,7 +194,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { /* 이미 해제됨 */ } }; - if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) { + if (scale !== 1 || tx !== 0 || ty !== 0) { clampPan(); applyTransform(); } @@ -207,9 +221,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { } const currentScale = (): number => scale; const reset = (): void => { - scale = 1; - tx = 0; - ty = 0; + ({ scale, tx, ty } = home); applyTransform(); }; // 더블클릭 원복. diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index 94f204bcf..87f23a048 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -10,6 +10,7 @@ import { fetchSurfaceStatus, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache"; +import { enterProject } from "./b_project_switch"; import { navigateTo } from "./router"; export interface WorkflowState { @@ -93,14 +94,20 @@ export async function dropGoneProject(): Promise { const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE]; export function goToWorkflowStage(projectId: string, route: RoutePath): void { - localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); - // 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다. - clearWorkflowStateCache(); - if (!PRELOAD_REQUIRED_ROUTES.includes(route)) { - navigateTo(route); - return; - } - void routeAfterPreloadCheck(projectId, route); + // 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1). + void enterProject( + projectId, + async (id) => (await fetchWorkflowState(id)).project_name || id, + ).then(() => { + localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); + // 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다. + clearWorkflowStateCache(); + if (!PRELOAD_REQUIRED_ROUTES.includes(route)) { + navigateTo(route); + return; + } + void routeAfterPreloadCheck(projectId, route); + }); } /** diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index 65d0609db..605935355 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -50,7 +50,7 @@ display: none; } -.b05-route-profile__trailing > .b05-route-profile__popout { +.b05-route-profile__tools > .b05-route-profile__popout { padding: var(--btn-bar-padding); font-size: var(--btn-bar-font-size); } diff --git a/A00_Common/design/design_panel.ts b/A00_Common/design/design_panel.ts index 08333ed9b..0c3418605 100644 --- a/A00_Common/design/design_panel.ts +++ b/A00_Common/design/design_panel.ts @@ -82,15 +82,16 @@ export function createSectionFrame( heading.textContent = title; if (view === "profile") { // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). + // ui-panel-form: 좌측 패널 컨테이너 공용 양식(269 · 「구조물 배치」 기준 칸 · 라벨 · 단추) — 두 보기 모두. // 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫. - root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; + root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`; heading.className = "ui-collapsible__title"; const body = document.createElement("div"); body.className = "b05-route__panel-body"; root.append(heading, body); return { root, body }; } - root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; + root.className = `b06-profile__group ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`; heading.className = "b06-profile__group-legend ui-collapsible__title"; root.append(heading); return { root, body: root }; diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts index 13c71b83e..f03582571 100644 --- a/A00_Common/design/design_pick_profile.ts +++ b/A00_Common/design/design_pick_profile.ts @@ -46,7 +46,8 @@ const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6; /** * 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1) - * ② 고른 측점 그대로(일반 카드 → 구조물 측점) ③ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1). + * ② 그 자리 구조물 측점(같은 m 면 측점 하나 · 구조물이 위 · 272 — 일반 카드 id 는 종단도가 숨긴 측점) + * ③ 고른 측점 그대로(일반 카드) ④ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1). */ export function profilePickOf( selection: DesignSelection | null, @@ -55,16 +56,16 @@ export function profilePickOf( if (!selection || selection.chainageM === null) return { stationId: null }; if (selection.structureId) return { structureId: selection.structureId }; const at = selection.chainageM; + const irregular = source.irregular(); + const exact = irregular.find((entry) => sameAt(entry.chainage_m, at)); + if (exact) return { stationId: irregularStationId(exact.id) }; const card = source.sections().find((entry) => sameAt(entry.chainage_m, at)); if (card) return { stationId: card.station_id }; - const irregular = source.irregular(); - const own = - irregular.find((entry) => sameAt(entry.chainage_m, at)) ?? - nearestWithin( - irregular.filter((entry) => entry.origin === "pipe"), - at, - (entry) => entry.chainage_m, - ); + const own = nearestWithin( + irregular.filter((entry) => entry.origin === "pipe"), + at, + (entry) => entry.chainage_m, + ); if (own) return { stationId: irregularStationId(own.id) }; const target = graphTargetAt(null, at, source.pipes(), source.sections()); return "pipeChainageM" in target diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 7dda48d16..4a5146951 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -42,7 +42,6 @@ import type { } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { buildAlignment, - chainageKey, emptyEdits, toAlignmentBase, } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; @@ -70,18 +69,19 @@ import { createElevationHold, createProfileDrag, } from "../../B05_Profile/B05_Profile_UI_Profile_Drag"; +import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; import { - planCurveWarning, - sampleAt, - snapToMasterStations, -} from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; + irregularGraphStations, + maxChainageOf, +} from "../../B05_Profile/B05_Profile_UI_Profile_Layout"; +import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import type { ElevationWindowResult } from "@util/common_util_chart_ywindow"; import { frameOf } from "@util/common_util_view_window"; import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots"; import { noticeDroppedEdits } from "./design_save"; import { createProfileOverlays } from "./design_profile_overlays"; -import { bindProfileViewport } from "./design_profile_viewport"; +import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport"; import "../../B06_Section/B06_Section_UI_Style.css"; // SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 // 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. @@ -103,8 +103,8 @@ export interface DesignProfileContext { view: DesignView; /** 오른쪽 판 — 없으면 판 없이 종단 열만. */ side?: DesignSideFactory; - /** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */ - sideTool?: (board: DesignSideBoard) => HTMLElement; + /** 도구줄 [초기화] 바로 왼쪽에 놓는 단추([새 창]) — 다시 그려도 그 자리. */ + toolLead?: HTMLElement; /** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */ onSelectStation: (stationId: string | null) => void; /** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */ @@ -219,6 +219,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지). let stationDisplay = { station: 0, cumulative: 0 }; let base: AlignmentBase | null = null; + /** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */ + let storedBase: AlignmentBase | null = null; let alignment: ProfileAlignment | null = null; let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ @@ -231,9 +233,16 @@ export function createDesignProfile(ctx: DesignProfileContext) { /** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로 * 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */ const structureIdAtStation = (stationId: string | null): string | null => - structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail); + structureIdKeeping( + stationId, + selectedStructureId, + irregularStations, + structures, + detail, + structureTypes, + ); const stationIdAtStructure = (structureId: string | null): string | null => - stationIdAtStructureOf(structureId, irregularStations, structures); + stationIdAtStructureOf(structureId, irregularStations, structures, structureTypes); /** * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). @@ -258,7 +267,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { planCurve: planCurveWarning(alignment, detail), minCoverViolations, balanceBar, - tools: tools.render(), + tools: withToolLead(tools.render()), trailing, alignment, legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), @@ -280,11 +289,14 @@ export function createDesignProfile(ctx: DesignProfileContext) { let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; /** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */ const elevationHold = createElevationHold(body, () => draw()); - // 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리. - const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null; const trailing = document.createElement("div"); trailing.className = "b05-route-profile__trailing"; - trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : [])); + trailing.append(profileZoom.bar); + /** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */ + const withToolLead = (wrap: HTMLElement): HTMLElement => { + if (ctx.toolLead) wrap.prepend(ctx.toolLead); + return wrap; + }; /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */ const { tools, history, handleToolPick, setListening } = createPanelTools({ @@ -330,6 +342,33 @@ export function createDesignProfile(ctx: DesignProfileContext) { history.record(); } + /** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리 + * 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물 + * 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */ + function listed(): AlignmentBase | null { + if (!storedBase || !detail) return storedBase; + const server = detail.longitudinal.stations; + const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal)); + const list = oneStationList(server, planted); + const after = new Set(list.map((station) => station.chainage_m.toFixed(3))); + const before = new Set(server.map((station) => station.chainage_m.toFixed(3))); + const gone = server + .filter((station) => station.kind === "irregular" && !station.grid) + .map((station) => station.chainage_m) + .filter((chainage) => !after.has(chainage.toFixed(3))); + const born = list + .map((station) => station.chainage_m) + .filter((chainage) => !before.has(chainage.toFixed(3))); + return { + ...storedBase, + basePvi: followMovedNodes(storedBase.basePvi, gone, born), + stations: list.map((station) => ({ + station_id: station.station_id, + chainage_m: station.chainage_m, + })), + }; + } + /** * 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만 * 실제 렌더링하도록 모은다. @@ -432,6 +471,14 @@ export function createDesignProfile(ctx: DesignProfileContext) { }); } + /** 다른 영역에서 고른 자리가 화면 밖이면 가운데로(칠한 뒤 · `design_profile_viewport`). */ + const reveal = createSelectionReveal(body); + const revealPick = (): void => + reveal( + (selectedStationId ?? selectedStructureId) + ? `${selectedStationId}|${selectedStructureId}` + : null, + ); // 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계). const unbindViewport = bindProfileViewport({ body, @@ -472,7 +519,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { savedEdits = stored?.edits ?? emptyEdits(); store = createProfileEditStore(routeId, savedEdits, () => rebuild()); } - base = stored ? toAlignmentBase(stored) : null; + storedBase = stored ? toAlignmentBase(stored) : null; + base = listed(); // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). if (carried) store.replace(carried); alignment = base ? buildAlignment(base, store.edits()) : null; @@ -521,6 +569,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17). selectedStructureId = structureIdAtStation(stationId); draw(); + revealPick(); }, /** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */ setEnforceMinCover(enforce: boolean) { @@ -535,9 +584,11 @@ export function createDesignProfile(ctx: DesignProfileContext) { * 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19 * 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */ setGradeLimit(maxGradePct: number) { - if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return; + if (!base || !storedBase || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return; if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return; - base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } }; + const policy = { ...base.policy, max_grade_pct: maxGradePct }; + storedBase = { ...storedBase, policy }; + base = { ...base, policy }; alignment = buildAlignment(base, store.edits()); draw(); }, @@ -547,7 +598,9 @@ export function createDesignProfile(ctx: DesignProfileContext) { }, /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ setIrregularStations(stations: IrregularStation[]) { - irregularStations = snapToMasterStations(alignment, stations); + irregularStations = stations; + base = listed(); + if (base) alignment = buildAlignment(base, store.edits()); draw(); }, /** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */ @@ -572,6 +625,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId; selectedStationId = keep ? selectedStationId : own; draw(); + revealPick(); }, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ setStationDisplay(next: { station: number; cumulative: number }) { @@ -583,10 +637,12 @@ export function createDesignProfile(ctx: DesignProfileContext) { * 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다. */ resetStationEdit(chainageM: number, moveTo?: number) { - const key = chainageKey(chainageM); + // 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272) const edits = store.edits(); - if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return; - store.resetStation(chainageM, moveTo); + const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find( + (entry) => Math.abs(Number(entry) - chainageM) < 0.006, + ); + if (key !== undefined) store.resetStation(Number(key), moveTo); }, isDirty: () => store.dirty(), /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ @@ -596,8 +652,9 @@ export function createDesignProfile(ctx: DesignProfileContext) { noticeDroppedEdits(saved); const next = saved.profile_alignment as ProfileAlignment | undefined; if (next?.base_pvi?.length) { - base = toAlignmentBase(next); - alignment = next; + storedBase = toAlignmentBase(next); + base = listed(); + alignment = buildAlignment(base!, store.edits()); if (detail) detail.longitudinal.profile_alignment = next; } store.markSaved(); @@ -606,6 +663,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { clear() { detail = null; base = null; + storedBase = null; alignment = null; selectedStationId = null; balanceBar.replaceChildren(); diff --git a/A00_Common/design/design_profile_overlays.ts b/A00_Common/design/design_profile_overlays.ts index eb60de408..235596d52 100644 --- a/A00_Common/design/design_profile_overlays.ts +++ b/A00_Common/design/design_profile_overlays.ts @@ -28,8 +28,8 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) { /** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). * 유토곡선을 접어도 이 값은 남아야 한다. */ const massBadge = createMassHaulBadge(); - // 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다. - massBadge.root.style.setProperty("--mass-haul-badge-top", "34px"); + // 그래프 위 도구줄 · 측점 ▲ 줄(바닥 47px) 아래로 내려 겹치지 않게 한다(289). + massBadge.root.style.setProperty("--mass-haul-badge-top", "52px"); bodyWrap.append(massBadge.root); let massHaulSummary: MassHaulSummaryValues | null = null; // 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면 diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts index ddf5b743d..ce69d30d5 100644 --- a/A00_Common/design/design_profile_popout.ts +++ b/A00_Common/design/design_profile_popout.ts @@ -13,27 +13,25 @@ import type { DesignProfile } from "./design_profile"; /** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */ const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"]; -/** 종단 도구줄 [배수유역도] 단추 뒤에 [새 창] 단추를 붙인다 — 두 보기가 같은 한 벌을 쓴다. */ +/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */ export function attachProfilePopout( profile: DesignProfile, slot: HTMLElement, - sideTool: HTMLElement | null, + button: HTMLButtonElement, ): void { const { root } = profile; let popup: Window | null = null; - const button = document.createElement("button"); button.type = "button"; button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; - button.textContent = "새 창"; - button.title = "종단 하단 패널을 브라우저 새 창으로 띄움"; + button.textContent = "창 분할"; + button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합"; button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open())); - sideTool?.after(button); const restore = (): void => { if (!root.classList.contains("is-popout")) return; root.classList.remove("is-popout"); - button.textContent = "새 창"; + button.textContent = "창 분할"; slot.append(root); popup = null; requestAnimationFrame(profile.redraw); @@ -71,7 +69,7 @@ export function attachProfilePopout( doc.body.className = document.body.className; doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`; root.classList.add("is-popout"); - button.textContent = "제자리로"; + button.textContent = "창 결합"; doc.body.replaceChildren(root); // 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정). diff --git a/A00_Common/design/design_profile_viewport.ts b/A00_Common/design/design_profile_viewport.ts index 3be92e541..54346620c 100644 --- a/A00_Common/design/design_profile_viewport.ts +++ b/A00_Common/design/design_profile_viewport.ts @@ -68,3 +68,33 @@ export function bindProfileViewport(deps: ProfileViewportDeps): () => void { resizeObserver.observe(body); return () => resizeObserver.disconnect(); } + +/** 고른 것이 보이는 칸 가장자리에서 이만큼(폭 비율) 안쪽이면 그대로 둔다. */ +const REVEAL_EDGE = 0.1; + +/** + * 고른 측점선(없으면 고른 구조물 알약)이 종단도 보이는 칸 밖이면 가운데로 민다 — 다른 영역(횡단 카드 · + * 3D · 배수유역도 · 좌측 목록)에서 고른 자리도 종단에서 바로 보이게. 칠한 결과(강조된 선 · 알약)를 + * 보고 옮긴다 · 같은 고름을 다시 칠할 때는 안 밂(사용자가 옮겨 본 자리를 지킴) · 그래프 안에서 + * 누른 것은 이미 보이는 자리라 안 움직임. `key` = 고름 열쇠(풀면 null). + */ +export function createSelectionReveal(body: HTMLElement): (key: string | null) => void { + let shown: string | null = null; + return (key) => { + if (key === null || key === shown) { + shown = key; + return; + } + const mark = + body.querySelector(".b06-chart__station--selected") ?? + body.querySelector(".b05-structure__mark.is-selected"); + if (!mark || body.clientWidth <= 0) return; // 아직 안 그려짐 — 다음 칠하기에서 + shown = key; + const box = mark.getBoundingClientRect(); + const view = body.getBoundingClientRect(); + const at = box.left + box.width / 2 - view.left; + if (at >= view.width * REVEAL_EDGE && at <= view.width * (1 - REVEAL_EDGE)) return; + // 바로 옮김 — 고른 뒤 잇따르는 다시 그리기가 부드러운 이동을 끊음(다시 그리기는 지금 자리를 지킴) + body.scrollLeft += at - view.width / 2; + }; +} diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 7da9b42ab..9296eb899 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -21,7 +21,7 @@ import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch"; import { createStructuresSection, type StructuresSection } from "../b_structures_section"; import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; -import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"; +import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"; import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignProfile, type DesignProfile } from "./design_profile"; import { attachProfilePopout } from "./design_profile_popout"; @@ -150,12 +150,12 @@ function toAll( /** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */ export function sharedProfile(shared: DesignPageShared): DesignProfile { if (shared.profile) return shared.profile; - let sideTool: HTMLElement | null = null; + const popoutButton = document.createElement("button"); const profile = createDesignProfile({ projectId: shared.projectId, view: shared.view(), side: drainageSide, - sideTool: (board) => (sideTool = drainageSideTool(board)), + toolLead: popoutButton, onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId), onResetAll: () => bound(shared).profile?.onResetAll?.(), callbacks: { @@ -178,7 +178,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { }, }); shared.profileSlot.append(profile.root); - attachProfilePopout(profile, shared.profileSlot, sideTool); + attachProfilePopout(profile, shared.profileSlot, popoutButton); shared.profile = profile; return profile; } diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts index 5a7f1963e..474f37a92 100644 --- a/A00_Common/design/design_structure_ford_calc.ts +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -15,7 +15,11 @@ * (같은 풀이 · n · S 를 받음) · 거울 시험 test_73_11_b08_hydro_box_ford. * ========================================================================== */ +import { FORD_PAVEMENT_DEFAULT_WIDTH_M } from "@config/config_frontend"; import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; +import type { SpecForm } from "./design_structure_form"; +import { CHUTE_ITEMS } from "./design_structure_guard"; +import type { SpecContainer, SpecItem } from "./design_structure_spec"; /** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */ export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null { @@ -23,9 +27,93 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null; } +/** + * 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(52-4 · 65-4 · 65-5 · 287-3). + * · 폭을 바꾸면 높이 = 필요 수심 · 높이를 비우거나 그 아래로 넣으면 필요 수심으로. + * · 새 시설(`arm(true)`) 빈 높이 = 유량이 오면 필요 수심(사용자가 넣은 것처럼 — [추가] · [수정]이 읽음). + * · 상자 끝 안내 — 「필요 수심 n 이상」 · 「설계유량 없음 — 수기 입력」(유량이 없으면 손 입력). + * `box` = 폭 · 높이 칸이 든 상자 id(세월교 `ford` · 물넘이 `slope`) · `flow` = 담당 유역 설계유량. + */ +export function overflowSync( + form: () => Pick, + box: string, + flow: () => number | null, + emit: () => void, +) { + let pending = false; + const values = () => form().state()[box]?.[0] ?? {}; + const width = () => Number(values().width ?? ""); + const need = (): number | null => fordHeightM(flow(), width()); + return { + arm: (fresh: boolean): void => { + pending = fresh; + }, + onChange: (c: SpecContainer, item: SpecItem): void => { + if (c.id === box && item.id === "height") pending = false; + const want = c.id === box ? need() : null; + const height = values().height ?? ""; + const low = height === "" || !(Number(height) >= (want ?? 0)); + if (want !== null && (item.id === "width" || (item.id === "height" && low))) + form().setValue(box, "height", want.toFixed(2)); + emit(); + }, + /** 유량이 왔거나 시설을 올림 — 새 시설 빈 높이를 채우고 안내를 다시 그림. */ + fill: (): void => { + const want = pending && !values().height ? need() : null; + if (want === null) return form().refresh(); + form().setValue(box, "height", want.toFixed(2)); + pending = false; + }, + /** 상자 끝 안내 글(폼 `fields` 훅). */ + fields: (c: SpecContainer): HTMLElement[] => { + if (c.id !== box) return []; + const hint = document.createElement("p"); + hint.className = "b05-structure__flow-hint"; + hint.textContent = fordHeightHint(flow(), width()); + return [hint]; + }, + }; +} + /** 높이 빈 칸 안내. */ export function fordHeightHint(designFlowM3s: number | null, widthM: number): string { const need = fordHeightM(designFlowM3s, widthM); if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`; return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 수기 입력"; } + +/** 도수로 · 돌붙임 면적(㎡ · 287-1) = 폭 × 길이 · 0.01 반올림. */ +export function chuteAreaM2(widthM: number, lengthM: number): number { + return Math.round(widthM * lengthM * 100) / 100; +} + +/** + * 물넘이 포장 · 세월교 도수로/돌붙임 길이 ↔ 면적(287-1 · 294-4) — 길이를 넣으면 면적 = 폭 × 길이(폭 = 띠가 + * 도로 방향으로 걸치는 월류 폭 · 비면 물넘이 기본 폭 · 배관 · 박스는 폭 정의 없어 자동 안 함) · 길이를 비우면 면적 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만 + * 다시 셈. 길이는 그림(사면 따라 그 길이) · 면적은 수량(B08 돌붙임)이 씀. + */ +export function syncChuteArea( + form: Pick, + c: SpecContainer, + item: SpecItem, + /** 도수로 상자 · 월류 폭 상자 — 물넘이 `chute` · `slope` · 세월교 `chute_add` · `ford`(294-4). */ + chuteBox = "chute", + widthBox = "slope", +): void { + const byLength = c.id === chuteBox && item.id === "length_m"; + if (!byLength && !(c.id === widthBox && item.id === "width")) return; + const chute = form.state()[chuteBox]?.[0]; + if (!chute) return; + const length = Number(chute.length_m ?? ""); + if (!(chute.length_m ?? "").trim() || !(length > 0)) { + if (byLength) + form.setValue(chuteBox, "area", CHUTE_ITEMS.find((i) => i.id === "area")?.default ?? ""); + return; + } + const width = Number(form.state()[widthBox]?.[0]?.width ?? ""); + form.setValue( + chuteBox, + "area", + String(chuteAreaM2(width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M, length)), + ); +} diff --git a/A00_Common/design/design_structure_ford_pavement.ts b/A00_Common/design/design_structure_ford_pavement.ts index 8f3f729bf..2f0f602f1 100644 --- a/A00_Common/design/design_structure_ford_pavement.ts +++ b/A00_Common/design/design_structure_ford_pavement.ts @@ -2,13 +2,16 @@ * design_structure_ford_pavement.ts * 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로. * - * 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임). - * 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure` · + * 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 다른 구조물과 + * 같은 칸 `CHUTE_ITEMS` + 맨 아래 길이 · 286-3). + * 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_*` · * 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`). * 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음. - * import 없음 — 시험이 이 파일만 옮겨 돌림. + * 길이(`ford_chute_{n}_length_m`) = 횡단 · 3D 띠를 사면 따라 그릴 길이 — 그림 전용(수량 · 내역 · 일위대가에 안 씀) · + * 비면 원지반까지. * ========================================================================== */ +import { CHUTE_ITEMS } from "./design_structure_guard"; import type { StructureSpec } from "./design_structure_spec"; export const FORD_PAVEMENT_SPEC: StructureSpec = { @@ -32,20 +35,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = { prefix: "ford_chute", link: "gfordpavechute", max: 1, - // 구조(기본 도수로) · 두께(cm · 기본 45 · 173-5) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2) - items: [ - { - id: "structure", - label: "구조", - kind: "select", - choices: [ - { value: "도수로", label: "도수로" }, - { value: "돌붙임", label: "돌붙임" }, - ], - default: "도수로", - }, - { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" }, - ], + // 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 맨 아래 길이 — 그림 전용 · 286-3 · 294-4) + items: CHUTE_ITEMS, }, ], // 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값) diff --git a/A00_Common/design/design_structure_guard.ts b/A00_Common/design/design_structure_guard.ts index c5d18b956..818acb556 100644 --- a/A00_Common/design/design_structure_guard.ts +++ b/A00_Common/design/design_structure_guard.ts @@ -144,6 +144,8 @@ export const CHUTE_ITEMS: SpecItem[] = [ }, { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" }, { id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" }, + // 길이(294-4 · `{접두}_{n}_length_m`) — 띠를 사면 따라 그릴 길이 · 그림 전용(수량 · 내역 · 일위대가에 안 씀) + { id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 }, ]; /** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */ diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 1f4239ca2..4aaa34321 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -37,6 +37,7 @@ import { } from "../../B06_Section/B06_Section_UI_Page_Persist"; import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; +import { paintMapPick } from "./design_pick_profile"; import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync"; @@ -342,15 +343,15 @@ export async function mountCrossView( addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM), removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM), addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId), - removeStructure: (structureId) => { - structuresPanel.removeStructureById(structureId); - }, + removeStructure: (structureId) => void structuresPanel.removeStructureById(structureId), movePipe: (fromChainageM, toChainageM) => structuresPanel.movePipeTo(fromChainageM, toChainageM), moveStructure: (structureId, toChainageM) => { structuresPanel.moveStructureTo(structureId, toChainageM); }, selectStructure: (structureId) => structuresPanel.selectStructure(structureId), + selectPipe: (chainageM) => structuresPanel.selectPipeAt(chainageM), // 측점 없는 관(요구 178) + pipesChanged: () => (structuresPanel.adoptFordHeights(), void refreshDetailForStructures()), }); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { @@ -391,9 +392,8 @@ export async function mountCrossView( releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2) return; } - const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null; const preferred = structuresPanel.preferred() !== null; - const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); + const opened = structuresPanel.showAtChainage(structureSelection.formChainageOf(target)); // 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1) const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null }; if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" }); @@ -505,6 +505,7 @@ export async function mountCrossView( appliedPick = null; restoreStructurePick(); }, + paintMap: (selection) => shared.profile?.side && paintMapPick(shared.profile.side, selection), }); /** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */ function releaseCross(): void { diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 2c2113c78..be81dae68 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -122,6 +122,7 @@ export async function mountProfileView( bridge.setPipes(pipes); panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem)); profilePanel.setMinCoverPipes(pipes); + snapOld(); restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. }; /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) · @@ -174,7 +175,9 @@ export async function mountProfileView( // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). onStructureSelect: (structureId) => { const chainage = bridge.pipeMarkChainage(structureId); - rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null); + // 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178) + const keepId = chainage === null || bridge.stationlessPipeMark(structureId); + rememberPick(bridge.markChainage(structureId), keepId ? structureId : null); if (chainage !== null) { panel.structures.selectPipeByChainage(chainage); selectStationOfPipe(chainage); @@ -245,6 +248,14 @@ export async function mountProfileView( selectStationOfPipe(chainageM); }; + let oldSnapped = false; + /** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */ + function snapOld(): void { + if (oldSnapped || restoring || !lastPipes) return; + oldSnapped = true; + panel.structures.snapOld(); + } + /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ function restorePick(): void { @@ -653,6 +664,7 @@ export async function mountProfileView( } finally { progress.remove(); restoring = false; + snapOld(); restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. } diff --git a/B01_Dashboard/B01_Dashboard_UI_Knowledge_Chat.ts b/B01_Dashboard/B01_Dashboard_UI_Knowledge_Chat.ts index a30d9844e..5f35ee9ff 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Knowledge_Chat.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Knowledge_Chat.ts @@ -58,11 +58,17 @@ export function buildKnowledgeChat(): HTMLElement { input.rows = 2; input.required = true; input.maxLength = 500; - input.placeholder = "메시지를 입력하세요"; + input.placeholder = "메시지를 입력하세요 (Shift+Enter 줄바꿈)"; input.setAttribute("aria-label", "질문"); const submit = document.createElement("button"); submit.type = "submit"; submit.textContent = "보내기"; + input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + submit.click(); + } + }); form.append(input, submit); panel.append(header, messages, form); wrap.append(toggle, panel); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index de11a4c05..f4f62046f 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -536,8 +536,8 @@ .b01-knowledge-chat__toggle, .b01-knowledge-chat__form button { border: 0; - background: var(--color-royal-amethyst, #6843a7); - color: #fff; + background: var(--color-primary); + color: var(--color-primary-text); font: inherit; cursor: pointer; } @@ -565,7 +565,7 @@ border-radius: 12px; background: var(--color-surface, #fff); box-shadow: 0 8px 28px #0003; - color: var(--color-text-primary, #222); + color: var(--color-text-body); overflow: hidden; overscroll-behavior: contain; } @@ -588,8 +588,8 @@ height: 34px; place-items: center; border-radius: 50%; - background: var(--color-royal-amethyst, #6843a7); - color: #fff; + background: var(--color-primary); + color: var(--color-primary-text); } .b01-knowledge-chat__avatar svg { @@ -631,14 +631,15 @@ .b01-knowledge-chat__user { align-self: flex-end; border-bottom-right-radius: 4px; - background: var(--color-royal-amethyst, #6843a7); - color: #fff; + background: var(--color-primary); + color: var(--color-primary-text); } .b01-knowledge-chat__reply { align-self: flex-start; + border: 1px solid var(--color-border); border-bottom-left-radius: 4px; - background: var(--color-background, #f4f4f4); + background: var(--color-surface-raised); } .b01-knowledge-chat__reply details { @@ -722,7 +723,7 @@ .b01-knowledge-source-modal__content code { padding: 0.1em 0.35em; border-radius: 4px; - background: var(--color-background, #f4f4f4); + background: var(--color-surface); } .b01-knowledge-source-modal__content .ui-markdown-table { @@ -746,7 +747,7 @@ } .b01-knowledge-source-modal__content th { - background: var(--color-background, #f4f4f4); + background: var(--color-surface); } .b01-knowledge-chat__form { @@ -765,11 +766,17 @@ padding: 8px; border: 1px solid var(--color-border); border-radius: 6px; + background: var(--color-canvas); + color: var(--color-text-body); font: inherit; resize: vertical; overflow-y: auto; } +.b01-knowledge-chat__form textarea::placeholder { + color: var(--color-text-muted); +} + .b01-knowledge-chat__form button { padding: 9px 14px; border-radius: 8px; diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index c2c8c88c8..7f8987fae 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -29,7 +29,6 @@ from B05_Profile.B05_Profile_Structures_Schema import ( structure_type_map, ) from common_util.common_util_drainage_pipes import ( - PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_PIPE, PIPE_FACILITY_REVET, PipePoint, @@ -208,9 +207,7 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None: - """관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음.""" - if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT: - return None + """관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖(물넘이포장 포함 · 286-1) = 종류 이름.""" if pipe.facility == PIPE_FACILITY_PIPE: values = pipe.options or {} kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE) @@ -267,11 +264,12 @@ def resolve_extra_stations( (2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다 — B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다. - 물넘이포장은 비정규 측점으로 올리지 않는다 — 종단 그래프 하단 구조물 표시로만 다룬다 - (2026-08-28 사용자 확정). + 물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1). 라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를 되짚기 때문이다(B05_Profile_Structures_Migration). + TS 짝 = `common_util_structure_station.ts` `plantedChainages`(화면 측점 한 벌 · 272) · + 거울 시험 = `resources/tester/test_272_1_station_list.py`. """ types = structure_type_map() derived: list[tuple[float, str]] = [] @@ -549,7 +547,7 @@ def run_section_generation( } -def generate_irregular_sections( +def sample_structure_sections( project_root: Path, route_data_path: str, filter_key: str, @@ -559,16 +557,15 @@ def generate_irregular_sections( extra_stations: tuple[tuple[float, str], ...], options: SectionGenerationOptions | None = None, crs: str | None = None, -) -> list[dict[str, Any]]: - """비정규 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다. +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀. - 기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다. 격자 측점과 똑같은 접선·오프셋 - 샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각 - `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로 - 필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요). + 격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점 + (`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기 + (`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2). """ if not extra_stations: - return [] + return [], [] base = options or SectionGenerationOptions() merged_options = SectionGenerationOptions( station_interval_m=base.station_interval_m, @@ -589,18 +586,48 @@ def generate_irregular_sections( # 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다. design_curves=_load_design_curves(project_root), ) - irregular_stations = [ - station - for station in result["longitudinal"]["stations"] - if station.get("kind") == "irregular" - ] + + def structural(entry: dict[str, Any]) -> bool: + return entry.get("kind") == "irregular" or bool(entry.get("structure")) + + return ( + [station for station in result["longitudinal"]["stations"] if structural(station)], + [cross for cross in result["cross_sections"] if structural(cross)], + ) + + +def generate_irregular_sections( + project_root: Path, + route_data_path: str, + filter_key: str, + method: str, + smooth: bool, + *, + extra_stations: tuple[tuple[float, str], ...], + options: SectionGenerationOptions | None = None, + crs: str | None = None, +) -> list[dict[str, Any]]: + """구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음). + + 기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272). + 각각 `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로 + 필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요). + """ + irregular_stations, crosses = sample_structure_sections( + project_root, + route_data_path, + filter_key, + method, + smooth, + extra_stations=extra_stations, + options=options, + crs=crs, + ) if not irregular_stations: return [] cross_dir = project_root / _STAGE_SUBDIR / "cross_sections" cross_dir.mkdir(parents=True, exist_ok=True) - for cross_section in result["cross_sections"]: - if cross_section.get("kind") != "irregular": - continue + for cross_section in crosses: cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"])) atomic_write_json(cross_file, cross_section) return irregular_stations diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 722796819..2505e8535 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -415,8 +415,10 @@ def generate_sections( kind = "bp" elif abs(float(value) - total) <= 1e-6: kind = "ep" - elif rounded in extras and rounded not in regular_rounded: - # 격자와 겹치지 않는 비정규 측점만 irregular. 규칙 격자와 겹치면 규칙 측점 우선. + elif rounded in extras: + # 구조물 측점이 위(272 · 요구 167) — 격자와 같은 m 여도 구조물 측점(irregular) 하나 · + # 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림 + # (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`). kind = "irregular" else: kind = "regular" @@ -466,9 +468,10 @@ def generate_sections( "uphill_side": uphill_side, "frame": frame, } - # 구조물은 kind와 무관하게 부착한다(규칙 격자와 겹쳐 regular가 돼도 구조물 정보는 유지). if rounded in extras: station["structure"] = extras[rounded] + if rounded in regular_rounded: + station["grid"] = True # 숨은 일반 측점 자리(272) stations.append(station) samples = [] diff --git a/B05_Profile/B05_Profile_Repository.py b/B05_Profile/B05_Profile_Repository.py index 156c39dcc..540348c59 100644 --- a/B05_Profile/B05_Profile_Repository.py +++ b/B05_Profile/B05_Profile_Repository.py @@ -241,6 +241,45 @@ async def update_longitudinal_grade_summary( ) +async def sync_cross_section_rows( + connection: aiomysql.Connection, route_id: int, chainages: list[float] +) -> int: + """종단 정본 측점에 횡단 행을 맞춘다 — 정본에 없는 측점의 행(옮긴 · 지운 구조물 측점의 옛 자리). + + 0.5 m 안에 행 없는 새 측점이 있으면 그리로 옮긴다(옛 소수 → 정수 m · 사용자 설계 값 그대로 · + 면적은 저장 뒤 서버 재계산) · 나머지는 지운다 — 남기면 수량(B08 토적)이 옛 자리 측점을 그대로 + 셈(272). 같은 측점 = 0.01 m 안(`_rowless_station_chainages` 와 같은 폭). 측점 목록이 비면 + 아무것도 안 건드린다. 옮기거나 지운 행 수. + """ + if not chainages: + return 0 + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT id, chainage_m FROM cross_sections WHERE route_id = %s", (route_id,) + ) + rows = [(int(row[0]), float(row[1])) for row in await cursor.fetchall()] + orphans = [ + (row_id, at) for row_id, at in rows if all(abs(at - c) >= 0.01 for c in chainages) + ] + free = [c for c in chainages if all(abs(c - at) >= 0.01 for _, at in rows)] + pairs = sorted( + (abs(c - at), row_id, c) for row_id, at in orphans for c in free if abs(c - at) <= 0.5 + ) + moved: dict[int, float] = {} + for _gap, row_id, chainage in pairs: + if row_id not in moved and chainage not in moved.values(): + moved[row_id] = chainage + for row_id, chainage in moved.items(): + await cursor.execute( + "UPDATE cross_sections SET chainage_m = %s WHERE id = %s", (chainage, row_id) + ) + dropped = [row_id for row_id, _ in orphans if row_id not in moved] + if dropped: + marks = ", ".join(["%s"] * len(dropped)) + await cursor.execute(f"DELETE FROM cross_sections WHERE id IN ({marks})", dropped) + return len(orphans) + + async def get_surface_crs_epsg( connection: aiomysql.Connection, project_id: UUID, surface_model_id: int ) -> int | None: diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 97668b447..0fde4d5a6 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -6,6 +6,7 @@ import asyncio import json +import math from pathlib import Path from typing import Any from uuid import UUID @@ -18,11 +19,16 @@ from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profi from B05_Profile.B05_Profile_Engine_Sections import ( _load_pipe_points, _load_route_polyline, + find_cross_file, generate_irregular_sections, resolve_extra_stations, ) from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions -from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg +from B05_Profile.B05_Profile_Repository import ( + get_surface_crs_epsg, + sync_cross_section_rows, + update_longitudinal_grade_summary, +) from B05_Profile.B05_Profile_Schema import RouteConfirmRequest from B06_Section.B06_Section_Repository import ( get_latest_section_options, @@ -101,6 +107,7 @@ async def sync_uphill_overrides_into_designs( compute_cross_design, curve_widening_args, stored_shoulder_extend, + stored_tilt, ) from B06_Section.B06_Section_Repository import get_cross_section_designs from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs @@ -169,6 +176,7 @@ async def sync_uphill_overrides_into_designs( surface_drop_m=ford_drop_at(float(chainage), ford_drops), **curve_widening_args(cross_record), **stored_shoulder_extend(design), # 노견 확장(139-6) + **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) ) next_design["status"] = design.get("status", "provisional") next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) @@ -193,12 +201,16 @@ async def sync_uphill_overrides_into_designs( _SAME_STATION_M = 0.006 -def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[str, Any] | None: +def _follow_moved_stations( + data: dict[str, Any], before: list[float], previous: list[float] | None = None +) -> dict[str, Any] | None: """사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다. 남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에 - 편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안)만 옮기고 나머지는 지운다. - 바뀌었으면 새 등급 요약(DB 몫)을, 아니면 `None`. + 편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤 + 자리(272)만 옮기고 나머지는 지운다. + 측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을, + 아니면 `None`. """ stored = data.get("profile_alignment") if not isinstance(stored, dict) or not stored.get("base_pvi"): @@ -218,6 +230,11 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st ), None, ) + # 옛 소수 측점 → 정수 m 맞춤(272 · 요구 167) — 맞춘 자리가 새 측점이면 꼭짓점 · 편집도 + # 그리로(지우면 계획선이 그 자리에서 바뀜 · 화면 `followMovedNodes` 와 짝) + snapped = chainage_key(math.floor(old + 0.5)) + if near is None and snapped in after_keys and snapped not in before_keys: + near = float(snapped) moves[chainage_key(old)] = None if near is None else chainage_key(near) def moved(value: float) -> tuple[bool, str | None]: @@ -254,7 +271,15 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st changed = True if new_key is not None: base.append({**node, "chainage_m": float(new_key)}) - if not changed: + # 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) · + # 줄이 없는 판은 맞추기 전 측점(`previous`)과 견줌 + rows = stored.get("stations") + listed = ( + [round(float(row.get("chainage_m", 0.0)), 6) for row in rows] + if isinstance(rows, list) + else previous + ) + if not changed and listed in (None, [round(value, 6) for value in after]): return None stored["base_pvi"] = base alignment, profile = rebuild_alignment_profile(data, next_edits) @@ -270,12 +295,17 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st def _merge_irregular_into_longitudinal( project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]] ) -> dict[str, Any] | None: - """종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬). + """종단 정본 stations 의 구조물 측점을 **지금 목록 전체로 다시 맞춘다**(272 · 요구 167). - B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과 - (엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 규칙 측점·표고 샘플은 - 손대지 않는다. 계획선은 사라진 비정규 측점 자리만 고친다(`_follow_moved_stations` — - 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 재확정해도 중복이 생기지 않는다. + `irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은 + `grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도), + 구조물이 떠난 격자 자리(`grid`)는 일반 측점으로 되돌리고, 새 목록의 격자 자리 측점은 그 + 격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 표고 샘플은 손대지 않는다. + B06 상세는 종단 파일의 stations 에 맞는 cross 파일만 노출하고 남은 파일을 지운다 + (`prune_stale_cross_files`). 계획선은 사라진 구조물 측점 자리만 고친다(`_follow_moved_stations` + — 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 다시 맞춰도 중복이 생기지 않는다. + TS 짝 = `common_util_structure_station.ts` `oneStationList`(화면 측점 한 벌) · 거울 시험 = + `resources/tester/test_272_1_station_list.py`. """ root = project_root.resolve() path = (root / longitudinal_file_path).resolve() @@ -290,15 +320,53 @@ def _merge_irregular_into_longitudinal( for station in stations if station.get("kind") == "irregular" ] - regular = [station for station in stations if station.get("kind") != "irregular"] - merged = regular + list(irregular_stations) - merged.sort(key=lambda station: float(station.get("chainage_m", 0.0))) - data["stations"] = merged - summary = _follow_moved_stations(data, before) + fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations} + kept: dict[float, dict[str, Any]] = {} + left: list[dict[str, Any]] = [] + for station in stations: + if station.get("kind") == "irregular" and not station.get("grid"): + continue # 옛 구조물 측점 — 새 목록이 갈음 + key = round(float(station.get("chainage_m", 0.0)), 6) + if key not in fresh and ("structure" in station or station.get("grid")): + # 구조물이 떠난 격자 · 시종점 자리 — 일반 측점으로(옛 판 「regular + 글」 포함) + station = {k: v for k, v in station.items() if k not in ("grid", "structure")} + if station.get("kind") == "irregular": + station["kind"] = "regular" + left.append(station) + kept[key] = station + kept.update(fresh) + data["stations"] = sorted(kept.values(), key=lambda s: float(s.get("chainage_m", 0.0))) + previous = [round(float(station.get("chainage_m", 0.0)), 6) for station in stations] + summary = _follow_moved_stations(data, before, previous) atomic_write_json(path, data) + _relabel_cross_files(path.parent.parent / "cross_sections", left) return summary +async def drop_rows_off_stations( + connection: aiomysql.Connection, route_id: int, project_root: Path, longitudinal_path: str +) -> None: + """측점을 다시 맞춘 뒤 — 종단 정본에 없는 측점의 횡단 행을 새 측점으로 옮기거나 지운다(272).""" + path = (project_root.resolve() / longitudinal_path).resolve() + stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or [] + await sync_cross_section_rows( + connection, route_id, [float(station["chainage_m"]) for station in stations] + ) + + +def _relabel_cross_files(cross_dir: Path, stations: list[dict[str, Any]]) -> None: + """일반 측점으로 돌아온 자리의 횡단 파일 글(종류 · 구조물)도 맞춘다 — 카드 제목(272).""" + for station in stations: + cross_path = find_cross_file(cross_dir, float(station.get("chainage_m", 0.0))) + if not cross_path.is_file(): + continue + cross = json.loads(cross_path.read_text(encoding="utf-8")) + cross.pop("structure", None) + cross.pop("grid", None) + cross["kind"] = station.get("kind") + atomic_write_json(cross_path, cross) + + #: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해. #: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다. SAMPLING_SNAPSHOT_NAME = "sampling.json" @@ -373,14 +441,20 @@ async def _append_irregular_cross_sections( crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, ) + # 빈 목록이어도 맞춤 — 옛 구조물 측점 · 떠난 격자 자리 글을 지워야 함(272) irregular_stations = await asyncio.to_thread(_generate) - if not irregular_stations: - return longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) if longitudinal: - await asyncio.to_thread( + summary = await asyncio.to_thread( _merge_irregular_into_longitudinal, project_root, str(longitudinal["longitudinal_file_path"]), irregular_stations, ) + if summary is not None: # 계획선을 다시 셈 — DB 등급 요약도 같이(B06 측점 만들기와 같은 길) + await update_longitudinal_grade_summary( + connection, route_id=route["id"], grade_summary=summary + ) + await drop_rows_off_stations( + connection, route["id"], project_root, str(longitudinal["longitudinal_file_path"]) + ) diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 804107d7f..0578c7334 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -268,7 +268,8 @@ class RouteConfirmRequest(BaseModel): """경로 확정 요청. 확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값 - (solve 때 쓰던 필터/방법/모델)을 함께 받는다. 비정규 측점이 없으면 재생성 없이 확정만 한다. + (solve 때 쓰던 필터/방법/모델)을 함께 받는다. 셋이 다 오면 구조물 측점을 서버 정본 목록 + 전체로 다시 맞춘다(빈 목록이어도 · 272). 모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다. """ @@ -286,12 +287,8 @@ class RouteConfirmRequest(BaseModel): return tuple((item.chainage_m, item.structure) for item in self.irregular_stations) def can_regenerate(self) -> bool: - return bool( - self.irregular_stations - and self.filter_key - and self.method - and self.surface_model_id is not None - ) + # 화면 목록이 비어도 맞춤 — 측점 목록은 서버 정본에서 뜨고, 옛 구조물 측점을 지워야 함(272) + return bool(self.filter_key and self.method and self.surface_model_id is not None) class RouteConfirmResponse(BaseModel): diff --git a/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts b/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts index 771f51404..3530d33cc 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts @@ -181,7 +181,8 @@ function fnv1a(text: string): string { // (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다. // 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이 // 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다. -const BUILD_VERSION = 93; +// 94 = 물넘이포장 노면을 파지 않음(287-4) — 설계 노면이 콘크리트 윗면 · 저장본을 만료한다. +const BUILD_VERSION = 94; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/B05_Profile_UI_Corridor_Station.ts index 4ef2cdb40..400218fa7 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station.ts @@ -9,7 +9,6 @@ * (2026-08-23 사용자 지시)의 핵심. * ========================================================================== */ -import { fordDeckElevationAt } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; @@ -227,22 +226,8 @@ export function classifyStation(section: CrossSection): StationPieces | null { if (!design || !line || line.length < 2) return null; const roadL = design.road_edges.left.offset_m; const roadR = design.road_edges.right.offset_m; - // 물넘이포장 — 노면(노견 포함)이 파인다. 횡단도와 **같은 산식**으로 표고를 내려 - // 3D도 같은 자리에 같은 깊이로 팬다(2026-08-28 사용자 지시). 노면 밖 비탈은 그대로라 - // 노견 끝에 파임 벽(수직 단차)이 선다 — 횡단도 그림과 같은 표현이다. - const ford = section.ford_pavement; - const carved = - ford && ford.depth_m - ? line.map((point) => - point.offset_m <= roadL + 1e-9 && point.offset_m >= roadR - 1e-9 - ? { - ...point, - elevation_m: fordDeckElevationAt(design, ford, point.offset_m, point.elevation_m), - } - : point, - ) - : line; - const sorted = [...carved].sort((a, b) => a.offset_m - b.offset_m); + // 물넘이포장 — 설계 노면이 곧 콘크리트 윗면(287-4 · 서버가 지대 낮은 쪽 한 경사로 냄) · 파지 않음 + const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m); const cwL = design.carriageway_edges?.left.offset_m ?? roadL; const cwR = design.carriageway_edges?.right.offset_m ?? roadR; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index c39573839..2a6b0c3fc 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -487,6 +487,8 @@ export function buildCorridorStructures( chuteBands( fordLayout.sides.filter((side) => side.role === "outlet").flatMap((s) => s.fillSegments), section.ford?.chute, + undefined, + fordLayout.ground, ).forEach((band) => pushSwept("chute", band, half, half, null, "chute")); // 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면** // (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은 @@ -595,7 +597,12 @@ export function buildCorridorStructures( ); } // 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3). - chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment).forEach((band) => + chuteBands( + layout.outletFill.segments, + section.culvert?.chute, + isChuteSegment, + layout.ground, + ).forEach((band) => pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"), ); // 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다). diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts index 65f01341d..01a49e1aa 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts @@ -182,6 +182,8 @@ export function boxSolids( const chutes: CorridorStructure[] = chuteBands( layout.sides.filter((side) => side.wingRole === "outlet").flatMap((side) => side.fillSegments), box.chute, + undefined, + layout.ground, ).map((band) => ({ chainage_m: chainage, kind: "chute", diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts index 20cbd37d8..cedbba41f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts @@ -10,7 +10,11 @@ import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import type { OffsetPoint } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; -import { chuteBandOutline } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch"; +import { + chuteBandOutline, + chuteRun, + groundLine, +} from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch"; import { outletFillLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; /** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */ @@ -25,10 +29,11 @@ export function chuteBands( segments: ReadonlyArray, chute: ChuteSpec | null | undefined, keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut", + /** 원지반 — 길이가 사면보다 길면 이어 그림(294-4 · 횡단 띠와 같은 `chuteRun`). */ + ground?: readonly OffsetPoint[], ): OffsetPoint[][] { if (!chute) return []; - return segments.flatMap((segment) => { - if (!keep(segment)) return []; + return chuteRun(segments.filter(keep), chute.length_m, ground).flatMap((segment) => { const band = chuteBandOutline(segment.points, chute.thickness_m); return band ? [band.outline] : []; }); @@ -51,6 +56,12 @@ export function fordPavementChute( ); if (owner !== section) return null; } - const bands = chuteBands([{ points: outletFillLine(section.design) }], pave.chute); + const ground = groundLine(section.samples ?? []); + const bands = chuteBands( + [{ points: outletFillLine(section.design, ground) }], + pave.chute, + undefined, + ground, + ); return bands.length ? { bands, halfM: Math.max(pave.span_m, 0.5) / 2 } : null; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index bd4e7aa1d..6cbfcd72f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -16,7 +16,10 @@ import type { /** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */ function chuteParts(chute: ChuteSpec | null | undefined): Array { - return chute ? ["ch", chute.structure, chute.thickness_m] : []; + if (!chute) return []; + // 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로 + const base = ["ch", chute.structure, chute.thickness_m]; + return chute.length_m ? [...base, chute.length_m] : base; } /** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index fb5437511..a9a65e47b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -56,7 +56,6 @@ import { type SpecForm, type SpecLinks, } from "../A00_Common/design/design_structure_form"; -import type { SpecContainer, SpecItem } from "../A00_Common/design/design_structure_spec"; import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; import { BOX_SPEC, @@ -65,7 +64,7 @@ import { boxOptionsIn, } from "../A00_Common/design/design_structure_box"; import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford"; -import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; +import { overflowSync, syncChuteArea } from "../A00_Common/design/design_structure_ford_calc"; import { pipeDrawn, pipeExtraCount, @@ -372,38 +371,24 @@ export function createFacilityOptionsForm( // 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) — // 물넘이 포장도 같은 키 · 같은 계산(65-5) · 새 시설은 유량이 오면 빈 높이를 필요 수심으로(65-4) let fordFlow: number | null = null; - let heightPending = false; - const overflow = (form: () => SpecForm, box: string) => { - const values = () => form().state()[box]?.[0] ?? {}; - const need = (): number | null => fordHeightM(fordFlow, Number(values().width ?? "")); - return { - onChange: (c: SpecContainer, item: SpecItem) => { - if (c.id === box && item.id === "height") heightPending = false; - const want = c.id === box ? need() : null; - const height = values().height ?? ""; - const low = height !== "" && !(Number(height) >= (want ?? 0)); - if (want !== null && (item.id === "width" || (item.id === "height" && low))) - form().setValue(box, "height", want.toFixed(2)); - emit(); - }, - /** 새 시설 빈 높이 = 필요 수심(사용자가 넣은 것처럼 · 알림 없음 — [추가] · [수정]이 읽음). */ - fill: () => { - const want = heightPending && !values().height ? need() : null; - if (want === null) return; - form().setValue(box, "height", want.toFixed(2)); - heightPending = false; - }, - }; - }; - const fordFlowBox = overflow(() => fordForm, "ford"); + const fordFlowBox = overflowSync( + () => fordForm, + "ford", + () => fordFlow, + emit, + ); + const guardFields = guardChoiceFields( + () => chainage, + () => fordForm.refresh(), + () => fordForm.recount(), + ); // 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1) const fordForm: SpecForm = createSpecForm(specLinks, { - onChange: fordFlowBox.onChange, - fields: guardChoiceFields( - () => chainage, - () => fordForm.refresh(), - () => fordForm.recount(), + onChange: (c, item) => ( + syncChuteArea(fordForm, c, item, "chute_add", "ford"), + fordFlowBox.onChange(c, item) ), + fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)], count: guardSoilCount( () => chainage, () => fordForm.state().outlet?.[0]?.structure, @@ -485,8 +470,16 @@ export function createFacilityOptionsForm( // ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도(+ 월류 폭 · 높이 · 65-5) · 도수로/돌붙임 · // 옛 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ── - const paveFlowBox = overflow(() => paveForm, "slope"); - const paveForm: SpecForm = createSpecForm(specLinks, { onChange: paveFlowBox.onChange }); + const paveFlowBox = overflowSync( + () => paveForm, + "slope", + () => fordFlow, + emit, + ); + const paveForm: SpecForm = createSpecForm(specLinks, { + onChange: (c, item) => (syncChuteArea(paveForm, c, item), paveFlowBox.onChange(c, item)), + fields: paveFlowBox.fields, + }); // 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`. @@ -603,7 +596,7 @@ export function createFacilityOptionsForm( }, setFacility(facility, options = {}, designFlow = null, definition = null, fresh = false) { current = facility; - heightPending = fresh; + [fordFlowBox, paveFlowBox].forEach((box) => box.arm(fresh)); hiddenOptions.remember(options); fordFlow = designFlow ?? null; if (facility === null) { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts index f412bcb3a..5a0b67e48 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts @@ -19,6 +19,14 @@ const FORD_KINDS: Record = { ford_pavement: FORD_PAVEMENT_DEFAULT_WIDTH_M, }; +/** 프로젝트마다 높이를 기다리는 측점 — 배수유역도 판 · B06 [추가] 가 같은 표를 씀(298-1). */ +const waits = new Map>(); +export function fordWaitsOf(projectId: string | null): Set { + const key = projectId ?? ""; + if (!waits.has(key)) waits.set(key, new Set()); + return waits.get(key) as Set; +} + /** 새로 놓으면 높이를 기다리는 종류 — 높이 칸이 빈 세월교 · 물넘이포장. */ export function waitsFordHeight( facility: string | undefined, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index a56742384..c3f1a57aa 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -17,6 +17,7 @@ import { import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; +import { clampWheelScale } from "./B05_Profile_UI_Drainage_ZoomRange"; import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import type { createMapContextMenu } from "@ui/ui_template_context_menu"; @@ -33,6 +34,8 @@ export interface DrainageInteractParams { setScale: (value: number) => void; /** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */ getMeta: () => VWorldMeta | null; + /** 줌아웃 한계 배율 — 노선 기준(계획서 281). */ + getMinScale: () => number; getOffset: () => { x: number; y: number }; setOffset: (x: number, y: number) => void; scheduleDraw: () => void; @@ -62,7 +65,13 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { // 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시). const view = currentView(); const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16); - const scale = Math.min(maxScale, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87))); + // 하한은 노선 기준(계획서 281) — 도엽 전체까지 빠지지 않는다. + const scale = clampWheelScale( + prevScale * (event.deltaY > 0 ? 1.15 : 0.87), + prevScale, + params.getMinScale(), + maxScale, + ); params.setScale(scale); // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). const ratio = scale / prevScale; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 3e5118aa8..532bbf907 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -36,16 +36,18 @@ import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report"; -import { fillFordHeights, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill"; +import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; +import { computeZoomOutLimit } from "./B05_Profile_UI_Drainage_ZoomRange"; import { mountDrainageToggles, DRAINAGE_LAYERS, fetchDrainageLayers, createProgressReporter, + analyzeFailed, fitCanvasToViewport, fitViewToRoute, observeViewportSize, @@ -179,8 +181,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let frameView: Window = window; let loadSequence = 0; let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택 - /** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */ - const fordWaits = new Set(); + /** 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1 · B06 [추가]도 298-1). */ + const fordWaits = () => fordWaitsOf(projectId); let canvasWidth = 0; let canvasHeight = 0; let canvasDpr = 0; @@ -357,7 +359,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra })), ); // 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음). - const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits); + const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits()); facilityStore.replaceFromResponse(response.pipe_points); if (fordFilled) rememberPipes(); // 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직 @@ -390,8 +392,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra } catch (error) { if (seq !== requestSeq) return; status.hidden = false; - status.textContent = - error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed"); + status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed")); } finally { if (seq === requestSeq) { analyzeButton.disabled = false; @@ -461,6 +462,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra offsetY = fitted.offsetY; } + function zoomOutLimit(): number { + const view = currentView(); + return computeZoomOutLimit(meta, routePoints, basins, view.width, view.height); + } + async function loadLayers(): Promise { if (!projectId) return; const activeProjectId = projectId; @@ -517,6 +523,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra scale = value; }, getMeta: () => meta, + getMinScale: zoomOutLimit, getOffset: () => ({ x: offsetX, y: offsetY }), setOffset: (x, y) => { offsetX = x; @@ -540,8 +547,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra offsetY = next.offsetY; }, redraw: () => { - // 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다. - if (fitPending) fitToRoute(); + // 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다. 늘려서 줌아웃 한계 밖이면 노선 맞춤(281-2). + if (fitPending || scale < zoomOutLimit() * 0.999) fitToRoute(); scheduleDraw(); }, }); @@ -612,7 +619,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1) // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. facilityStore.set(chainageM, attributes ?? null); - if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits.add(chainageM); + if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits().add(chainageM); pipeEditor.addAtChainage(chainageM); }, updatePipeFacility(fromChainageM, toChainageM, attributes) { @@ -623,7 +630,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const previous = facilityStore.get(fromChainageM); facilityStore.set(fromChainageM, null); facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes)); - if (fordWaits.delete(fromChainageM)) fordWaits.add(toChainageM); + if (fordWaits().delete(fromChainageM)) fordWaits().add(toChainageM); if (Math.abs(fromChainageM - toChainageM) > 0.005) { // 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고, // attach가 새 위치로 시설 정보를 승계한다. diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 88036c07d..868e450c5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -9,6 +9,7 @@ import { stateKey } from "../A00_Common/b_page_state"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; +import { showToast } from "@ui/ui_template_elements"; import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { @@ -154,7 +155,7 @@ export function renderBasinRows( .replace("{relief}", basin.relief_m.toFixed(1)) .replace("{flow}", String(Math.round(basin.flow_length_m))) .replace("{pipe}", pipe); - row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(1)); + row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(0)); // 정수 m(167) // 산출 근거(도달시간·강우강도·설계유량)는 툴팁 둘째 줄로 붙인다 — 행이 길어지지 않게. if (basin.tc_minutes != null && basin.design_flow_m3s != null) { row.title += @@ -515,6 +516,13 @@ export function createProgressReporter(progress: { }; } +/** 세부유역 요청 실패 — 지도 칸 문구를 돌려주고 토스트 한 줄(뒤에서 다시 나눔이 조용히 안 끝나게 · 298-1). */ +export function analyzeFailed(error: unknown, fallback: string): string { + const text = error instanceof Error ? error.message : fallback; + showToast(text === fallback ? text : `${fallback} ${text}`, "error"); + return text; +} + /** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */ export function pipeMarkerColor(basins: ReadonlyArray, chainageM: number): string { const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < PIPE_MATCH_M); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Side.ts b/B05_Profile/B05_Profile_UI_Drainage_Side.ts index ca78ebeb4..08f7476d9 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Side.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Side.ts @@ -12,8 +12,7 @@ * · 관 자리 읽기 · 쓰기(되돌리기) = `pipeChainages()` · `setPipeChainages()` * · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems` * · 구간 띠 · 노선 = `setIntervalSpans()` · `setRoute()` - * 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역 - * 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다. + * 두 보기(종단 · 횡단)에서 같은 판을 끼운다 — 판은 늘 보이고 접기 · 펼치기는 판 안 손잡이 하나로. * ========================================================================== */ import "./B05_Profile_UI_Style_Drainage.css"; // 홀로 서려면 판이 자기 모양을 들고 온다. @@ -23,71 +22,11 @@ import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; /** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`)에 projectId · view 를 더한 것. */ export type DrainageSideHost = DesignSideHost; -/** 종단도 side 판 = 배수유역 판 창구 그대로 + 켜고 끔. */ -export type DrainageSideBoard = DesignSideBoard & { - /** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */ - setVisible: (visible: boolean) => void; - isVisible: () => boolean; - /** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */ - onVisibleChange: (listener: (visible: boolean) => void) => () => void; -}; - -export interface DrainageSideOptions { - /** 처음 켤지(기본 켬). */ - visible?: boolean; -} - -/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. */ -export function createDrainageSide( - host: DrainageSideHost, - options: DrainageSideOptions = {}, -): DrainageSideBoard { - const panel = createDrainagePanel(host); - const listeners = new Set<(visible: boolean) => void>(); - let visible = true; - - function setVisible(next: boolean): void { - if (next === visible) return; - visible = next; - panel.root.hidden = !visible; - listeners.forEach((listener) => listener(visible)); - } - // 처음 상태는 알림 없이 맞춘다. - if (options.visible === false) { - visible = false; - panel.root.hidden = true; - } - +/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. 접기 · 펼치기는 판 손잡이 몫. */ +export function createDrainageSide(host: DrainageSideHost): DesignSideBoard { return { - ...panel, - // 보기가 바뀌어도 판은 다시 안 만든다 — 켬 · 끔은 끼우는 쪽이 `setVisible` 로 정한다. + ...createDrainagePanel(host), + // 보기가 바뀌어도 판은 다시 안 만든다. setView: () => {}, - setVisible, - isVisible: () => visible, - onVisibleChange(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - dispose() { - listeners.clear(); - panel.dispose(); - }, }; } - -/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 판을 뒤집는다. */ -export function createDrainageToggleButton(side: DrainageSideBoard): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "ui-btn ui-btn--ghost b05-drainage__toggle"; - button.textContent = "배수유역도"; - button.title = "배수유역도 판을 켜고 끕니다"; - const sync = (visible: boolean): void => { - button.setAttribute("aria-pressed", String(visible)); - button.classList.toggle("is-active", visible); - }; - sync(side.isVisible()); - side.onVisibleChange(sync); - button.addEventListener("click", () => side.setVisible(!side.isVisible())); - return button; -} diff --git a/B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts b/B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts new file mode 100644 index 000000000..85846e75e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_ZoomRange.ts + * 배수유역 지도의 줌아웃 한계(가장 작은 배율) — 도엽 전체가 아니라 **노선 기준**(계획서 281). + * + * 한계 = 노선 범위를 가운데 두고 ZOOM_OUT_ROUTE_FACTOR 배로 넓힌 범위가 화면에 들어오는 배율. + * 유역 폴리곤이 그 밖으로 나가면 유역까지 넓힌다. 처음 보기(노선 + 여유)보다 좁히지 않고, + * 도엽 전체(배율 1)보다 멀리는 안 간다. 노선이 없으면 종전 꼴(ZOOM_OUT_FALLBACK_SCALE). + * 화면 그리기 없는 순수 셈 — 시험이 Node 로 바로 돌린다. + * ========================================================================== */ + +import type { DetailBasin, VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + computeMapRect, + computeRouteView, + createNormalizer, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; + +/** 노선 범위를 몇 배까지 멀리 볼지 — 키우면 더 멀리 빠진다(사용자가 바꿀 수 있는 값). */ +export const ZOOM_OUT_ROUTE_FACTOR = 2; + +/** 노선이 없을 때의 줌아웃 한계 — 도엽 전체의 절반까지(종전 값). */ +export const ZOOM_OUT_FALLBACK_SCALE = 0.5; + +/** 줌아웃 한계 배율. 지도 메타가 없거나 노선이 두 점 미만이면 종전 값. */ +export function computeZoomOutLimit( + meta: VWorldMeta | null, + routePoints: ReadonlyArray<{ x: number; y: number }>, + basins: ReadonlyArray>, + width: number, + height: number, + factor: number = ZOOM_OUT_ROUTE_FACTOR, +): number { + if (!meta || routePoints.length < 2 || width <= 0 || height <= 0) { + return ZOOM_OUT_FALLBACK_SCALE; + } + const route = { + x_min: Math.min(...routePoints.map((point) => point.x)), + x_max: Math.max(...routePoints.map((point) => point.x)), + y_min: Math.min(...routePoints.map((point) => point.y)), + y_max: Math.max(...routePoints.map((point) => point.y)), + }; + // 도엽 정규 좌표(0~1, 위가 0)로 모아 잰다 — 노선(m)과 유역(경위도)을 한 자로 본다. + const spanX = meta.width_meters || 1; + const spanY = meta.height_meters || 1; + const centerX = ((route.x_min + route.x_max) / 2 - meta.x_min) / spanX; + const centerY = 1 - ((route.y_min + route.y_max) / 2 - meta.y_min) / spanY; + const halfW = ((route.x_max - route.x_min) / spanX) * (factor / 2); + const halfH = ((route.y_max - route.y_min) / spanY) * (factor / 2); + let x0 = centerX - halfW; + let x1 = centerX + halfW; + let y0 = centerY - halfH; + let y1 = centerY + halfH; + const normalizer = createNormalizer(meta); + basins.forEach((basin) => + basin.polygon_lonlat.forEach(([lon, lat]) => { + const nx = (lon - normalizer.lonMin) / normalizer.lonRange; + const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange; + x0 = Math.min(x0, nx); + x1 = Math.max(x1, nx); + y0 = Math.min(y0, ny); + y1 = Math.max(y1, ny); + }), + ); + // 배율 s 에서 화면에 드는 정규 폭 = 화면 폭 / (지도 사각형 폭 · s). + const mapRect = computeMapRect(meta, width, height); + const fit = Math.min( + width / (mapRect.width * Math.max(x1 - x0, 1e-9)), + height / (mapRect.height * Math.max(y1 - y0, 1e-9)), + ); + // 처음 보기(노선 + 여유 200m)는 늘 한계 안 — 짧은 노선에서 처음 화면보다 좁아지지 않게. + const initial = computeRouteView(meta, route, width, height).scale; + return Math.max(1, Math.min(fit, initial)); +} + +/** 휠 한 번의 새 배율 — 한계 안에서 멈춘다. 이미 한계 밖이면 더 빠지지만 않게(튀지 않게) 둔다. */ +export function clampWheelScale( + next: number, + previous: number, + minScale: number, + maxScale: number, +): number { + return Math.min(maxScale, Math.max(Math.min(minScale, previous), next)); +} diff --git a/B05_Profile/B05_Profile_UI_Page_Stations.ts b/B05_Profile/B05_Profile_UI_Page_Stations.ts index da83c9639..5f6a9a3d1 100644 --- a/B05_Profile/B05_Profile_UI_Page_Stations.ts +++ b/B05_Profile/B05_Profile_UI_Page_Stations.ts @@ -4,7 +4,7 @@ * 종단 보기 본체(`design_view_profile`)가 700줄을 넘어 여기로 뗐다(계획서 24-3b). * ========================================================================== */ -import { dropStationsNear } from "./B05_Profile_Util_Station"; +import { oneStationList } from "../common_util/common_util_structure_station"; import { interpolateIrregularStations } from "./B05_Profile_UI_Page_Helpers"; import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; @@ -24,7 +24,7 @@ export function stationLinesOf(input: StationLinesInput) { // 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고 // 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다 // (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다. - const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular"); + const regular = oneStationList(detail.longitudinal.stations, []); const injected = interpolateIrregularStations( regular, input.irregular, @@ -48,9 +48,8 @@ export function stationLinesOf(input: StationLinesInput) { return best.elevation_m; }; // 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용. - // 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와 - // 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06). - return [...dropStationsNear(regular, injected), ...injected].map((station) => { + // 측점 한 벌(272) — 같은 m 면 구조물 측점 하나 · 종단 그래프 · 표와 같은 목록(`oneStationList`). + return oneStationList(detail.longitudinal.stations, injected).map((station) => { const design = designAt(station.chainage_m); return { ...station, diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 70d0400fa..4b8f39300 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -34,6 +34,13 @@ import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers"; import { followedStation } from "./B05_Profile_UI_Profile_Alignment"; import { followSnapped } from "./B05_Profile_UI_Structures_Snap"; +import { plantsStation } from "./B05_Profile_UI_Profile_Structures"; +import { + pipeStationMarks, + SAME_STATION_M, + structureStationMarks, + type StationMark, +} from "../common_util/common_util_structure_station"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -41,6 +48,9 @@ export interface PipeProjection { effective_diameter_mm: number | null; facility: PipeFacility; options?: Record; + /** 독립 기슭막이 구간 — 시작 · 끝도 측점을 심는다(272 · 서버와 같은 규칙). */ + start_m?: number | null; + end_m?: number | null; } export interface StructuresBridgeDeps { @@ -58,17 +68,28 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { /** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */ let irregularStations: IrregularStation[] = []; - function pipesToStations( - pipes: Array<{ - chainage_m: number; - effective_diameter_mm: number | null; - facility: PipeFacility; - }>, - ): IrregularStation[] { + /** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */ + function stationAt( + chainage: number, + ): Pick { const interval = deps.panel().values().stationInterval || 20; - return pipes.map((pipe) => { - const station = Math.floor(pipe.chainage_m / interval); - const remainder = pipe.chainage_m - station * interval; + const station = Math.floor(chainage / interval); + return { station, remainder: chainage - station * interval, chainage_m: chainage }; + } + + /** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서 + * 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */ + const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] => + marks + .filter((mark) => mark.role !== "anchor") + .map((mark) => ({ + id: `${id}:${mark.role}`, + ...stationAt(mark.chainage_m), + structure: label, + })); + + function pipesToStations(pipes: ReadonlyArray): IrregularStation[] { + return pipes.flatMap((pipe) => { const label = pipe.facility === "pipe" ? structureLabel({ @@ -77,24 +98,20 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm), }) : FACILITY_NAMES[pipe.facility]; - return { - id: `pipe-${pipe.chainage_m.toFixed(2)}`, - station, - remainder, - chainage_m: pipe.chainage_m, - structure: label, - structureType: "배관", - origin: "pipe", - }; + const id = `pipe-${pipe.chainage_m.toFixed(2)}`; + return [ + { + id, + ...stationAt(pipe.chainage_m), + structure: label, + structureType: "배관" as const, + origin: "pipe" as const, + }, + ...spanStations(id, label, pipeStationMarks(pipe)), + ]; }); } - /** 물넘이포장은 비정규 측점이 아니다 — 횡단도로 표현할 것이 없어 종단 알약으로만 - * 다룬다(2026-08-28 사용자 확정). 관 정본 대조(setPipeChainages)는 전체 목록을 써야 - * 물넘이가 지워지지 않으므로, **표시·서버 전송 쪽에서만** 뺀다. */ - const withoutFordPavement = (stations: IrregularStation[]): IrregularStation[] => - stations.filter((station) => station.structure !== FACILITY_NAMES.ford_pavement); - /** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */ function clearProjectedStations(): void { pipeStations = []; @@ -115,13 +132,13 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ); const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3))); gone.forEach((station) => { - // 없으면 맞춘 자리의 새 측점 — [정수 맞춤]이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1) + // 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272) const same = followedStation(station, gone, born) ?? followSnapped(station, born); deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); }); irregularStations = stations; deps.renderStationLines(); - deps.profilePanel().setIrregularStations(withoutFordPavement(stations)); + deps.profilePanel().setIrregularStations(stations); // 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다. // 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다. deps @@ -143,29 +160,40 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { * 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거· * 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */ let pipeStations: IrregularStation[] = []; - let structureTypeMap = new Map(); + let structureTypeMap = new Map< + string, + { type_id: string; group: string; name: string; placement: string; managed_by: string | null } + >(); - /** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */ + /** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations` + * 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */ function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] { - const interval = deps.panel().values().stationInterval || 20; - return structures - .filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A") - .map((structure) => { - const chainage = structure.chainage_m ?? structure.start_m ?? 0; - const station = Math.floor(chainage / interval); - return { - id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`, - station, - remainder: chainage - station * interval, - chainage_m: chainage, - structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id, - }; - }); + return structures.flatMap((structure) => { + const type = structureTypeMap.get(structure.type_id); + const marks = structureStationMarks(structure, type); + const anchor = marks.find((mark) => mark.role === "anchor"); + if (!anchor) return []; + const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`; + const label = type?.name ?? structure.type_id; + return [ + { id, ...stationAt(anchor.chainage_m), structure: label }, + ...spanStations(id, label, marks), + ]; + }); } - /** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */ + /** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이 + * 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을 + * 지우지 않게) · 물넘이포장도 측점을 심음(286-1). */ function syncCrossDrainStations(): void { - applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]); + const kept: IrregularStation[] = []; + for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) { + const taken = + station.origin !== "pipe" && + kept.some((other) => Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M); + if (!taken) kept.push(station); + } + applyIrregularStations(kept); } /** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null). @@ -326,7 +354,16 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { deps.profilePanel().setStructureTypes(allTypes); // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. structureTypeMap = new Map( - allTypes.map((type) => [type.type_id, { group: type.group, name: type.name }]), + allTypes.map((type) => [ + type.type_id, + { + type_id: type.type_id, + group: type.group, + name: type.name, + placement: type.placement, + managed_by: type.managed_by, + }, + ]), ); structureRevision = stored.revision; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29). @@ -362,7 +399,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { }); }, /** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */ - irregularStations: () => withoutFordPavement(irregularStations), + irregularStations: () => irregularStations, /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group, /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */ @@ -376,6 +413,11 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { clearProjectedStations, /** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */ pipeMarkChainage, + /** 측점 없는 관 알약(물넘이포장)인가 — 고름 값에 알약 id 를 남길지 가름(요구 178). */ + stationlessPipeMark: (structureId: string | null): boolean => { + const mark = pipeMarks.find((entry) => entry.structure_id === structureId); + return !!mark && !plantsStation(mark, []); + }, /** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */ markChainage, /** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Guard.ts b/B05_Profile/B05_Profile_UI_Profile_Guard.ts index 6314fb40d..d909d0821 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Guard.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Guard.ts @@ -1,7 +1,7 @@ /* ============================================================================= * B05_Profile_UI_Profile_Guard.ts - * 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고, - * 구조물 측점을 종단 정본 측점으로 맞추기. + * 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고 + * (구조물 측점 맞추기는 측점 한 벌 `oneStationList` 로 갈음 · 272). * * 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다 * (계획서 24-2c). 본문 로직과 수치는 그대로이고 화면 상태를 인자로 받는다. @@ -9,11 +9,6 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; -import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; - -/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는 - * mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */ -const STATION_SNAP_TOLERANCE_M = 0.1; /** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */ export function sampleAt( @@ -64,36 +59,3 @@ export function planCurveWarning( }); return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null; } - -/** - * 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04). - * - * 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`, - * 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든 - * 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이 - * 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고). - * - * 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다. - * 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다. - */ -export function snapToMasterStations( - alignment: ProfileAlignment | null, - stations: IrregularStation[], -): IrregularStation[] { - const master = alignment?.stations; - if (!master?.length) return stations; - return stations.map((station) => { - let best: number | null = null; - let gap = STATION_SNAP_TOLERANCE_M; - for (const node of master) { - const distance = Math.abs(node.chainage_m - station.chainage_m); - if (distance <= gap) { - gap = distance; - best = node.chainage_m; - } - } - return best === null || best === station.chainage_m - ? station - : { ...station, chainage_m: best }; - }); -} diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index b940a155a..67488241b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -8,7 +8,7 @@ * 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다. * ========================================================================== */ -import { dropStationsNear } from "./B05_Profile_Util_Station"; +import { oneStationList } from "../common_util/common_util_structure_station"; import { createLongitudinalProfile, longitudinalMinimumWidth, @@ -225,18 +225,12 @@ export function renderProfile(ctx: ProfileRenderContext): void { : (longitudinal.design_profiles ?? []); // 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다. const graphData = normalizedLongitudinal(longitudinal); - // 종단 정본(확정 시 병합분)에 들어 있는 비정규 측점은 걷어낸다 — 화면의 정본은 사이드바 - // 목록이고, 관을 옮기면 그 목록만 따라온다. 둘을 겹쳐 그리면 옮기기 전 자리의 세로선이 - // 그대로 남는다(2026-08-02 사용자 보고). - const regular = graphData.stations.filter((station) => station.kind !== "irregular"); + // 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m + // 일반 측점은 숨김(구조물이 위) · 표 · ▲▼ · 3D 와 같은 목록(`oneStationList`). const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal)); - // 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 세로선·라벨이 두 겹으로 - // 겹쳐 읽히지 않는다(2026-09-06). 남는 쪽은 구조물 측점이다. const graphLongitudinal = { ...graphData, - stations: [...dropStationsNear(regular, injected), ...injected].sort( - (a, b) => a.chainage_m - b.chainage_m, - ), + stations: oneStationList(graphData.stations, injected), }; // 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04 // 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다. diff --git a/B05_Profile/B05_Profile_UI_Profile_SideTool.ts b/B05_Profile/B05_Profile_UI_Profile_SideTool.ts index 5565caeab..85e81c343 100644 --- a/B05_Profile/B05_Profile_UI_Profile_SideTool.ts +++ b/B05_Profile/B05_Profile_UI_Profile_SideTool.ts @@ -1,24 +1,11 @@ /* ============================================================================= * B05_Profile_UI_Profile_SideTool.ts - * 종단도 오른쪽 판(배수유역도)을 두 보기에 **같은 모양 · 같은 켬끔 값**으로 끼운다 - * (계획서 24-2c · 24-2e). 판 본체는 `_Drainage_Side`(sub7), 켬 · 끔 값은 `drainage-visible`. + * 종단도 오른쪽 판(배수유역도)을 두 보기에 **같은 모양**으로 끼운다(계획서 24-2c · 24-2e). + * 판 본체는 `_Drainage_Side`(sub7). 판은 늘 보이고 접기 · 펼치기는 판 안 손잡이 하나로(282-1). * ========================================================================== */ -import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; -import type { DesignSideBoard, DesignSideFactory } from "../A00_Common/design/design_profile"; -import { - createDrainageSide, - createDrainageToggleButton, - type DrainageSideBoard, -} from "./B05_Profile_UI_Drainage_Side"; +import type { DesignSideFactory } from "../A00_Common/design/design_profile"; +import { createDrainageSide } from "./B05_Profile_UI_Drainage_Side"; -/** 오른쪽 판 — 켬 · 끔은 두 보기가 같은 값을 쓴다(기본 켬). */ -export const drainageSide: DesignSideFactory = (host) => - createDrainageSide(host, { visible: readStateRaw("drainage-visible") !== "false" }); - -/** 켬 · 끔 단추 — 누를 때마다 값을 적는다. */ -export function drainageSideTool(board: DesignSideBoard): HTMLElement { - const side = board as DrainageSideBoard; - side.onVisibleChange((visible) => writeStateRaw("drainage-visible", String(visible))); - return createDrainageToggleButton(side); -} +/** 오른쪽 판 — 두 보기가 같은 판을 쓴다. */ +export const drainageSide: DesignSideFactory = (host) => createDrainageSide(host); diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 5649dbd79..0ff9bc61a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -15,9 +15,17 @@ import { isPipeStation, type IrregularStation, } from "./B05_Profile_UI_IrregularStations"; -import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "./B05_Profile_Api_Structures"; import type { PickGroup } from "./B05_Profile_UI_Structures_Panel_Types"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { + pipeStationMarks, + structureStationMarks, +} from "../common_util/common_util_structure_station"; /** 사이드 「구조물 배치」 군 · 종류 드롭다운 목록(`section.pickGroups()`)을 그대로 구조물군 → 종류 * 2단 메뉴 항목으로(129-1 — 종단그래프 · 배수유역도 · 3D · B06 종단 공용 · 가린 군 · 종류는 뺌). @@ -157,6 +165,7 @@ export function structureIdKeeping( detail: { longitudinal: { stations: ReadonlyArray<{ station_id: string; chainage_m: number }> }; } | null, + types: ReadonlyArray = [], ): string | null { const selected = structures.find((item) => item.structure_id === selectedId); const at = detail?.longitudinal.stations.find( @@ -165,20 +174,36 @@ export function structureIdKeeping( const keeps = stationId !== null && !!selected && - (stationIdAtStructure(selectedId, stations, structures) === stationId || + (stationIdAtStructure(selectedId, stations, structures, types) === stationId || (at !== undefined && Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M)); return keeps ? selectedId : structureIdAtStation(stationId, stations, structures); } -/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */ +/** 제 측점을 심는 알약인가 — 272 측점 심기 한 벌(A군 · C · D군 구간형 · 관 = 물넘이포장까지 · 286-1). + * 안 심는 구조물 알약은 같은 자리 · 근처 측점선을 짝으로 켜지 않음(요구 178) · 종류를 모르면 옛 길. */ +export function plantsStation( + structure: StructureInstance, + types: ReadonlyArray, +): boolean { + if (String(structure.structure_id ?? "").startsWith("pipe-")) + return ( + pipeStationMarks({ chainage_m: structureAnchorM(structure), facility: structure.type_id }) + .length > 0 + ); + const type = types.find((entry) => entry.type_id === structure.type_id); + return !type || structureStationMarks(structure, type).length > 0; +} + +/** 알약(구조물) id → 측점선 id. 제 측점을 안 심는 구조물(B군 · 표지 등)이면 null. */ export function stationIdAtStructure( structureId: string | null, stations: IrregularStation[], structures: StructureInstance[], + types: ReadonlyArray = [], ): string | null { if (structureId === null) return null; const structure = structures.find((item) => item.structure_id === structureId); - if (!structure) return null; + if (!structure || !plantsStation(structure, types)) return null; const anchor = structureAnchorM(structure); const station = stations.find((entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M); return station ? irregularStationId(station.id) : null; diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index 420d00159..4403fcdda 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -1,10 +1,10 @@ /* ============================================================================= * B05_Profile_UI_Profile_Tools.ts - * 종단 계획선 도구줄 — [초기화] · [↶][↷] · [직선화] · [쉬프트] · [▲][▼]. + * 종단 계획선 도구줄 — ([새 창]) · [초기화] · [↶][↷] · [직선화] · [쉬프트] · [▲][▼]. * ([초기화]는 2026-09-03 추가 — 마지막 [저장] 시점 기준이며 전체 초기화가 아니다.) * - * 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 같은 줄 오른쪽 - * [배수유역도] · [새 창] 과 같다(공용 토큰 `--btn-bar-*`). + * 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 맨 앞 + * [새 창] 과 같다(공용 토큰 `--btn-bar-*`). [새 창] 은 종단도가 그릴 때마다 맨 앞에 옮겨 붙인다(282-2). * * 선택 흐름: * [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체. diff --git a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts index 9c49247d6..0cebd0aa0 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts @@ -8,9 +8,12 @@ * 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`) * · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀) * · 값 `auto` = 「(자동)」 · 빈 값은 건너뜀(기본값으로 안 바꿈 · 기본값은 새 구조물 칸에 실제 값) + * · `structureOptionLines`(166-1) = 같은 컨테이너의 **보이는 칸 전부** 「제목: 이름 값 · …」 — B06 [상세] CAD 칸 + * 미리 넣음 · B07 짝(서버 Node `B07_DesignDetail_Note_Options_Node`)이 같이 씀 * ========================================================================== */ import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import { shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box"; import { withLegacyGuards } from "../A00_Common/design/design_structure_ford"; @@ -18,6 +21,8 @@ import { branchOf, choiceLabel, choicesOf, + containerTitle, + itemLabel, readState, type Instance, type SpecContainer, @@ -46,6 +51,14 @@ export function balloonLines( return values.length ? [first, values.join(SEP)] : [first]; } +/** 명세 이름 바꿈 표 — 배관은 정의 파일의 옛 이름 「흙막이 추가」보다 명세 이름 「흙막이」가 앞섬(54-1). */ +function specNames(structure: StructureInstance, type: StructureType | undefined) { + const names = type?.spec_names ?? {}; + if (structure.type_id !== "pipe") return names; + const { soil_add: _old, ...pipeNames } = names; + return pipeNames; +} + /** 명세 컨테이너 값 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름(빈 값 = ""). */ function specLines( spec: StructureSpec, @@ -53,12 +66,7 @@ function specLines( type: StructureType | undefined, count?: ContainerCount, ): string[] { - let names = type?.spec_names ?? {}; - if (structure.type_id === "pipe") { - // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) - const { soil_add: _old, ...pipeNames } = names; - names = pipeNames; - } + const names = specNames(structure, type); const state = formState(spec, structure.type_id, structure.options ?? {}); return spec.containers.flatMap((c) => instancesOf(c, state[c.id] ?? [], count).map((values) => { @@ -129,3 +137,76 @@ function groupLines( return raw !== undefined && raw !== "" ? `${raw}${option.unit ?? ""}` : ""; }); } + +/** 「이름 (단위)」 칸 이름 + 값 → 「이름 값단위」(예 높이 (m) + 1.3 = 높이 1.3m) · 단위 없는 이름은 그대로 띄움. */ +function nameValue(label: string, shown: string): string { + const hit = /^(.*?)\s*\(([^()]+)\)$/.exec(label); + return hit && shown !== AUTO_MARK ? `${hit[1]} ${shown}${hit[2]}` : `${label} ${shown}`; +} + +/** + * CAD 칸 미리 넣음 옵션 줄(166-1) — 좌측 패널 폼과 같은 컨테이너 · 이름 · 보이는 칸 전부 · 빈 칸 뺌. + * 명세 종류 = 컨테이너(선택 컨테이너는 저장된 인스턴스마다 · 뒤에 번호)마다 「제목: 이름 값 · 이름 값」 · + * 그 밖 종류 = 정의 그룹마다(그룹 형상 칸 포함) + 그룹 밖 칸은 종류 이름 줄 · 값 하나 없는 상자는 줄 없음. + */ +export function structureOptionLines( + structure: Pick, + type: StructureType | undefined, +): string[] { + const options = structure.options ?? {}; + const line = (title: string, parts: string[]): string[] => { + const kept = parts.filter(Boolean); + return kept.length ? [`${title}: ${kept.join(SEP)}`] : []; + }; + const spec = STRUCTURE_SPECS[structure.type_id]; + if (spec) { + const names = specNames(structure as StructureInstance, type); + const state = formState(spec, structure.type_id, options); + return spec.containers.flatMap((c) => + (state[c.id] ?? []).flatMap((values, index) => { + const group = type?.groups?.find((g) => g.id === c.link)?.name; + const title = containerTitle(c, names, group) + (c.required ? "" : ` ${index + 1}`); + const parts = c.items + .filter((item) => item.kind !== "auto" && (!item.by || branchOf(item, values))) + .map((item) => { + // 필수 상자 빈 칸 = 폼이 채워 보이는 기본값(177-2 · 예 동물이동형 유) · 선택 상자는 안내뿐이라 뺌 + const empty = !values[item.id]?.trim() && c.required && item.default; + const filled = empty ? { ...values, [item.id]: item.default ?? "" } : values; + const shown = specValue(c, item, filled, names); + return shown ? nameValue(itemLabel(c, item, names), shown) : ""; + }); + return line(title, parts); + }), + ); + } + const byKey = new Map((type?.options ?? []).map((option) => [option.key, option])); + const valueOf = (key: string, unit?: string | null): string => { + const raw = options[key]; + if (raw === undefined || raw === null || raw === "") return ""; + return raw === "auto" ? AUTO_MARK : `${raw}${unit ?? ""}`; + }; + const optionParts = (keys: string[]): string[] => + keys.map((key) => { + const option = byKey.get(key); + if (!option || option.visible === false || option.enabled === false) return ""; + const shown = valueOf(key, option.unit); + return shown ? `${option.label} ${shown}` : ""; + }); + const groups = type?.groups ?? []; + const grouped = new Set(groups.flatMap((group) => group.options)); + return [ + ...groups.flatMap((group) => + line(group.name, [ + ...optionParts(group.options), + ...shapeFields(group.shape).map((field) => { + const shown = valueOf(shapeKey(group.id, field.suffix)); + return shown ? nameValue(field.label, shown) : ""; + }), + ]), + ), + ...line( + type?.name ?? structure.type_id, + optionParts((type?.options ?? []).map((o) => o.key).filter((key) => !grouped.has(key))), + ), + ]; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index d1bb93c0d..a0884d884 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -168,13 +168,13 @@ export function stationFields( if (rest < intervalM && Number.isInteger(stationNo)) return; const parts = chainageToStation(stationToChainage(stationNo, rest, intervalM), intervalM); station.value = String(parts.station); - remainder.value = parts.remainder.toFixed(1); + remainder.value = parts.remainder.toFixed(0); // 측점 + 거리 = 정수 m(167) }; // 다른 change 리스너(옵션 잠금·임시 배치)보다 먼저 등록돼 정규화된 값이 읽힌다. [station, remainder].forEach((input) => input.addEventListener("change", normalize)); } - // 칸은 잔여를 소수 1자리로 보임 — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌 - // (표시값을 다시 읽으면 0.1m 로 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨) + // 칸은 잔여를 정수로 보임(167) — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌 + // (표시값을 다시 읽으면 옛 소수 값이 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨) let written: { chainageM: number; station: string; remainder: string } | null = null; return { wrap, @@ -196,7 +196,7 @@ export function stationFields( const valid = Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0; markInvalid(!valid); - // 친 값은 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 안 고친 칸은 위에서 옛 값 그대로 + // 친 값은 정수 m(반올림 · 167) — 안 고친 칸은 위에서 옛 값 그대로 return valid ? snapStructureStation(stationNo * intervalM + rest) : null; }, write(chainageM, intervalM) { @@ -208,7 +208,7 @@ export function stationFields( } const parts = chainageToStation(chainageM, intervalM); station.value = String(parts.station); - remainder.value = parts.remainder.toFixed(1); + remainder.value = parts.remainder.toFixed(0); // 측점 + 거리 = 정수 m(167) written = { chainageM, station: station.value, remainder: remainder.value }; }, clearInvalid: () => markInvalid(false), diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 1240ec5d3..c57c1bc3f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -47,7 +47,11 @@ import { pickable, showsAnchor } from "./B05_Profile_UI_Structures_Define"; import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; -import { movedStructure, snapDock, snapStructureStation } from "./B05_Profile_UI_Structures_Snap"; +import { + movedStructure, + snapOldStations, + snapStructureStation, +} from "./B05_Profile_UI_Structures_Snap"; /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ @@ -177,9 +181,9 @@ export function createStructuresSection( let editingId: string | null = null; /** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */ let selectedPipeChainage: number | null = null; - /** 임시 배치 중인 A군 시설의 누가거리(2026-08-18) — 측점을 넣는 순간 관 지점에 - * 미리 넣어 세부유역·설계유량 미리보기를 얻고, [추가]로만 확정한다. 취소(삭제· - * 리셋·다른 항목 선택·종류 변경)하면 관을 물려 추가 직전 상태로 되돌린다. */ + /** 임시 배치 중인 A군 시설의 누가거리 — 우클릭 「추가」만 관 지점에 미리 넣음(측점 입력은 안 넣음 · 291-1) + * · [추가]로 확정 · 취소(삭제 · 리셋 · 다른 항목 선택 · 종류 변경)하면 관을 물려 추가 직전 상태로. + * 새 세월교 · 물넘이 빈 월류 높이는 [추가] 뒤 재계산 유량으로 채움(126-1 `fordWaits`). */ let tempPipeChainage: number | null = null; /** 위치 입력이 "확정"됐는지 — 측점번호+잔여거리를 다 받고 입력군을 빠져나간 * 순간(또는 폼에 위치를 실어 연 순간)에만 true. 옵션 활성화 시점도 임시 배치와 @@ -290,11 +294,9 @@ export function createStructuresSection( facilityOptions.root.classList.toggle("is-locked", locked); } - /** A군이면 기준 측점(측점번호+잔여거리)으로 임시 배치를 만들거나 옮긴다 - * (세부유역 미리보기, 2026-08-18). 측점 두 칸을 **다 쓰고 빠져나온 뒤**에만 - * 부른다 — 측점번호만 넣고 거리 칸으로 넘어가는 순간 나가면 재계산 동기화가 - * 입력을 끊는다(2026-08-18 사용자 보고). */ - function commitTempPipe(): void { + /** A군 임시 배치 — `create`(우클릭 「추가」)면 만들고, 있으면 기준 측점으로 옮김 · 측점 입력만으론 + * 안 만듦(신규는 [추가] 뒤에만 생김 · 291-1). 측점 두 칸을 다 쓰고 빠져나온 뒤에만 부름. */ + function commitTempPipe(create = false): void { const type = currentType(); if (!type?.managed_by) return; const anchor = anchorFields.read(interval(), false); @@ -302,6 +304,7 @@ export function createStructuresSection( if (anchor === null || (selectedPipeChainage !== null && tempPipeChainage === null)) return; const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; if (tempPipeChainage === null) { + if (!create) return; tempPipeChainage = anchor; callbacks.onPipeAdd(anchor, attributes); } else if (Math.abs(tempPipeChainage - anchor) > 0.005) { @@ -321,7 +324,7 @@ export function createStructuresSection( } /** 위치 입력 확정 — 측점번호+잔여거리를 다 받고 입력군을 빠져나온 순간. - * 옵션 활성화와 A군 임시 배치가 이 한 지점에서 같이 나간다(2026-08-18). */ + * 옵션 활성화(와 우클릭으로 만든 A군 임시 배치 옮기기)가 이 한 지점에서 같이 나간다. */ function commitPosition(): void { positionConfirmed = hasPosition(); commitTempPipe(); @@ -542,13 +545,15 @@ export function createStructuresSection( return { root, facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15) - listRoot: snapDock(list, { - structures, - pipes: () => pipeFacilities, - emit, - movePipe: (from, to, attributes) => callbacks.onPipeUpdate(from, to, attributes), - closeForm: () => resetForm(), - }), + listRoot: list, + snapOld: () => + snapOldStations({ + structures, + pipes: () => pipeFacilities, + emit, + movePipe: (from, to, attributes) => callbacks.onPipeUpdate(from, to, attributes), + closeForm: () => resetForm(), + }), hasSelection: () => editingId !== null || selectedPipeChainage !== null, pickGroups: () => pickGroups(types, canPick), // 우클릭 메뉴 = 이 드롭다운 목록(129-1) setTypes(next, pick = () => true) { @@ -667,7 +672,7 @@ export function createStructuresSection( if (addsAtOnce(type, optionInputs)) return void finishAdd({ type, anchorM: chainageM, structures, commit, pick: this }); // 임시 배치도 즉시 진행(A군). - if (type.managed_by) commitTempPipe(); + if (type.managed_by) commitTempPipe(true); syncButtons(); renderList(); root.scrollIntoView({ block: "nearest" }); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 66e2b3966..7e3c6c64b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -22,10 +22,7 @@ import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures import { asRoad } from "./B05_Profile_UI_Structures_Define"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; -import { - isSnappedStation, - snapStructureStation, -} from "../common_util/common_util_structure_station"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; import { readGroupItems, readSummaryItem, @@ -309,11 +306,9 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis beforeInput?.focus(); return; } - // 시작 · 끝도 정수 m(234-1) — 안 고친 옛 소수 기준이면 옛 셈 그대로([정수 맞춤] 몫) - const fit = (value: number): number => - isSnappedStation(anchor as number) ? snapStructureStation(value) : value; - start = fit(Math.max(anchor - before, 0)); - end = Math.max(fit(start + length), start + Math.min(length, 1)); + // 시작 · 끝도 정수 m · 끝은 1 m 이상(234-1 · 167) + start = snapStructureStation(Math.max(anchor - before, 0)); + end = Math.max(snapStructureStation(start + length), start + 1); } else if (placement === "interval") { start = ctx.startFields.read(step, true); end = ctx.endFields.read(step, true); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 15e4b2be7..04f2dc9cd 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -169,8 +169,8 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { input.addEventListener("change", ctx.handleStationInput), ), ); - // 측점번호+잔여거리가 **둘 다 확정된 순간** 임시 배치·옵션 활성화가 바로 나간다 - // (2026-08-18 사용자 지시 — 입력군을 떠나기 전이라도). 잔여거리 값이 확정되면 + // 측점번호+잔여거리가 **둘 다 확정된 순간** 옵션 활성화가 바로 나간다(입력군을 떠나기 + // 전이라도 · 새 구조물은 [추가] 뒤에만 생김 · 291-1). 잔여거리 값이 확정되면 // 즉시, 측점번호 수정은 잔여거리가 이미 있을 때만 즉시. ctx.anchorFields.remainder.addEventListener("change", () => ctx.commitPosition()); ctx.anchorFields.station.addEventListener("change", () => { @@ -207,7 +207,7 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { ctx.positionRow.addEventListener("keydown", (event) => { // Enter = 입력 확정 — 포커스를 빼서 focusout 경로 하나로 처리한다(측점번호+ - // 잔여거리 합산값이 그대로 임시 배치 좌표가 된다). + // 잔여거리 합산값이 그대로 위치가 된다). if (event.key === "Enter" && document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 676d199a2..71c974d11 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -154,6 +154,8 @@ export interface StructuresSection { /** 배치된 구조물 목록(
    ) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다 * (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */ listRoot: HTMLElement; + /** 옛 소수 측점(관 · 구조물)을 정수 m 로 옮긴다(종단 보기를 열 때 한 번 · 167) — 옮긴 수. */ + snapOld: () => number; /** 서버에서 받은 타입 목록을 채운다(최초 1회). 이름·폼은 넘긴 종류 전부로 풀고, * 고르기 목록엔 `pick` 이 고른 종류만 보임(빼면 전부). */ setTypes: (types: StructureType[], pick?: (type: StructureType) => boolean) => void; diff --git a/B05_Profile/B05_Profile_UI_Structures_Pavement.ts b/B05_Profile/B05_Profile_UI_Structures_Pavement.ts index ed3a7805d..5b94d9e66 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Pavement.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Pavement.ts @@ -63,8 +63,10 @@ export async function splitPavementRange( const segments: Span[] = []; let cursor = start; for (const ford of overlaps) { - if (ford.start - cursor > EPS) segments.push({ start: cursor, end: ford.start }); - cursor = ford.end; + // 경계도 정수 m(167) — 물넘이를 안 덮게 앞 구간 끝은 내림 · 뒤 구간 시작은 올림 + const before = Math.floor(ford.start + EPS); + if (before - cursor > EPS) segments.push({ start: cursor, end: before }); + cursor = Math.ceil(ford.end - EPS); } if (end - cursor > EPS) segments.push({ start: cursor, end }); diff --git a/B05_Profile/B05_Profile_UI_Structures_Snap.ts b/B05_Profile/B05_Profile_UI_Structures_Snap.ts index 3f29a57ac..982a3e666 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Snap.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Snap.ts @@ -1,14 +1,14 @@ /* ============================================================================= * B05_Profile_UI_Structures_Snap.ts - * 구조물 기준 측점 정수 맞춤(234-1) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과 - * 기존 소수 자료를 옮기는 [정수 맞춤] 단추(좌측 구조물 목록 머리 · B05 · B06 한 벌). + * 구조물 기준 측점 정수 맞춤(234-1 · 272) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과 + * 옛 소수 자료를 정수 m 로 옮기는 자동 맞춤(종단 보기를 열 때 한 번 · 단추 없음 · 요구 167). * - * 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 단추는 확인 창 뒤 - * 구조물은 한 번에, 관은 하나씩 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집 - * 따라가기(213-2) · 시설 속성 이어받기가 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다. + * 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 구조물은 한 번에, 관은 하나씩 + * 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집 따라가기(213-2) · 시설 속성 이어받기가 + * 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다(알림 한 줄). * ========================================================================== */ -import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { showToast } from "@ui/ui_template_elements"; import { isSnappedStation, snapStructureStation, @@ -26,8 +26,7 @@ export function movedStructure(target: StructureInstance, toChainageM: number): if (target.placement !== "interval") return { ...target, chainage_m: anchor }; const length = (target.end_m ?? 0) - (target.start_m ?? 0); const start = snapStructureStation(anchor - (structureAnchorM(target) - (target.start_m ?? 0))); - const snappedEnd = snapStructureStation(start + length); - const end = snappedEnd > start ? snappedEnd : start + length; + const end = Math.max(snapStructureStation(start + length), start + 1); // 끝도 정수 · 1 m 이상(167) return { ...target, chainage_m: Math.min(Math.max(anchor, start), end), @@ -55,7 +54,8 @@ export const structureOffGrid = (structure: StructureInstance): boolean => export interface SnapPlan { /** 목록 자리 · 옮긴 모양. */ structures: Array<{ index: number; next: StructureInstance }>; - pipes: Array<{ from: number; to: number; pipe: PipeFacilityItem }>; + /** 옛 자리 → 새 자리 · 시작 · 끝도 맞춘 속성(독립 기슭막이 구간). */ + pipes: Array<{ from: number; to: number; attributes: FacilityAttributes }>; /** 맞춘 자리에 다른 관이 있어 그대로 둔 관 수. */ skipped: number; } @@ -73,16 +73,20 @@ export function snapPlan( const taken = pipes.filter((pipe) => !offGrid(pipe.chainage_m)).map((pipe) => pipe.chainage_m); const plan: SnapPlan = { structures: moved, pipes: [], skipped: 0 }; [...pipes] - .filter((pipe) => offGrid(pipe.chainage_m)) + .filter((pipe) => offGrid(pipe.chainage_m) || offGrid(pipe.start_m) || offGrid(pipe.end_m)) .sort((left, right) => left.chainage_m - right.chainage_m) .forEach((pipe) => { const to = snapStructureStation(pipe.chainage_m); - if (taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) { + if (to !== pipe.chainage_m && taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) { plan.skipped += 1; return; } taken.push(to); - plan.pipes.push({ from: pipe.chainage_m, to, pipe }); + const attributes = pipeAttributes(pipe); + if (attributes.start_m !== undefined) + attributes.start_m = snapStructureStation(attributes.start_m); + if (attributes.end_m !== undefined) attributes.end_m = snapStructureStation(attributes.end_m); + plan.pipes.push({ from: pipe.chainage_m, to, attributes }); }); return plan; } @@ -97,7 +101,7 @@ export const pipeAttributes = (pipe: PipeFacilityItem): FacilityAttributes => ({ group_items: pipe.group_items ?? null, }); -export interface SnapDockContext { +export interface SnapContext { /** 패널이 쥔 구조물 배열(참조) — 옮긴 것을 제자리에 갈아 끼움. */ structures: StructureInstance[]; pipes: () => ReadonlyArray; @@ -109,52 +113,23 @@ export interface SnapDockContext { closeForm: () => void; } -/** [정수 맞춤] 단추를 얹은 목록 뿌리 — 아래 고정 영역에 목록 대신 놓인다. */ -export function snapDock(list: HTMLElement, ctx: SnapDockContext): HTMLElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "b05-route__irregular-btn b05-structure__snap"; - button.textContent = "정수 맞춤"; - button.title = "소수 기준 측점(관 · 구조물)을 정수 m 로 — 일반 측점 1 m 안이면 그 측점"; - button.addEventListener("click", () => void runSnap(ctx)); - const head = document.createElement("div"); - head.className = "b05-structure__snap-row"; - head.append(button); - const dock = document.createElement("div"); - dock.className = "b05-structure__list-dock"; - dock.append(head, list); - return dock; -} - -export async function runSnap(ctx: SnapDockContext): Promise { +/** 옛 소수 측점(관 · 구조물)을 정수 m 로 옮긴다(요구 167 · 확인 창 없이) — 옮긴 수. */ +export function snapOldStations(ctx: SnapContext): number { const plan = snapPlan(ctx.structures, ctx.pipes()); const count = plan.structures.length + plan.pipes.length; - if (!count) { + if (count) { + ctx.closeForm(); + if (plan.structures.length) { + plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next)); + ctx.emit(); + } + plan.pipes.forEach(({ from, to, attributes }) => ctx.movePipe(from, to, attributes)); + } + if (count || plan.skipped) showToast( - plan.skipped - ? `소수 측점 관 ${plan.skipped}건은 맞출 자리에 다른 관이 있어 그대로 둡니다.` - : "소수 기준 측점이 없습니다.", - "info", + (count ? `옛 소수 측점 ${count}건을 정수 m 로 옮겼습니다 — [저장]하면 남습니다.` : "") + + (plan.skipped ? ` 맞출 자리에 다른 관이 있는 관 ${plan.skipped}건은 그대로 둡니다.` : ""), + plan.skipped ? "info" : "success", ); - return 0; - } - const ok = await showConfirmDialog( - `소수 기준 측점 ${count}건(관 ${plan.pipes.length} · 구조물 ${plan.structures.length})을 ` + - "정수 m(일반 측점 1 m 안이면 그 측점)로 옮깁니다. 계획고 편집도 따라가며, [저장]을 눌러야 남습니다.", - "정수 맞춤", - ); - if (!ok) return 0; - ctx.closeForm(); - if (plan.structures.length) { - plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next)); - ctx.emit(); - } - plan.pipes.forEach(({ from, to, pipe }) => ctx.movePipe(from, to, pipeAttributes(pipe))); - showToast( - `정수 맞춤 ${count}건` + - (plan.skipped ? ` · 자리 겹친 관 ${plan.skipped}건은 그대로` : "") + - " — [저장]을 눌러 남기세요.", - "success", - ); return count; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts index e0a1027ca..1f7b9dda4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts @@ -1,9 +1,9 @@ /* ============================================================================= * B05_Profile_UI_Structures_Summary_Row.ts - * 구조물 폼의 「집계 항목」 줄 — 이름(없으면 「미지정」) · [고르기] · [해제] (PLAN 21-4). + * 구조물 폼의 「집계 항목」 줄 — 이름(없으면 「미지정」) · [해제] (PLAN 21-4) · 이름 칸을 누르면 고르기(280-1). * * B05 · B06 · 관 계열 하위 폼이 같은 줄을 쓴다(옵션 · 서브폼 아래 한 줄). 줄은 값만 들고 있고 - * 저장은 패널이 한다 — [고르기]가 모달(21-5 `openItemPicker`)을 부르고 돌려받은 참조를 `set` 한다. + * 저장은 패널이 한다 — 이름 칸(누름 · Enter · Space)이 모달(21-5 `openItemPicker`)을 부르고 돌려받은 참조를 `set` 한다. * 키(SS…)는 앞에 내세우지 않고 툴팁에만 둔다(PLAN 21-0). * ========================================================================== */ @@ -18,29 +18,26 @@ export interface SummaryRow { get: () => SummaryItem | null; /** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */ set: (item: SummaryItem | null) => void; - /** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */ + /** 고르기(이름 칸) · [해제] 배선 — 패널이 한 번 부른다. */ bind: (handlers: { pick: () => void; clear: () => void }) => void; } +const PICK_TIP = "눌러서 고르기"; + export function buildSummaryRow(): SummaryRow { - // 옵션 칸과 같은 2행 꼴(54-17) — 위 제목(`b05-route__field > span`) · 아래 [이름 칸][선택][해제] + // 옵션 칸과 같은 2행 꼴(54-17) — 위 제목(`b05-route__field > span`) · 아래 [이름 칸][해제] const root = document.createElement("div"); root.className = "b05-route__field b05-structure__summary"; root.dataset.b05Summary = "row"; const label = document.createElement("span"); label.className = "b05-structure__summary-label"; label.textContent = "구조물 조합"; - // 이름 = 읽기 전용 입력 칸 — 옵션 입력 칸과 글꼴 · 크기 · 칸 모양이 같음 + // 이름 = 읽기 전용 입력 칸 — 옵션 입력 칸과 글꼴 · 크기 · 칸 모양이 같음 · 누르면 고르기(280-1) const name = document.createElement("input"); name.type = "text"; name.readOnly = true; - name.tabIndex = -1; name.className = "b05-structure__summary-name"; - const pick = document.createElement("button"); - pick.type = "button"; - pick.className = "b05-route__irregular-btn"; - pick.textContent = "선택"; - pick.dataset.b05Summary = "pick"; + name.dataset.b05Summary = "pick"; const clear = document.createElement("button"); clear.type = "button"; clear.className = "b05-route__irregular-btn"; @@ -48,14 +45,14 @@ export function buildSummaryRow(): SummaryRow { clear.dataset.b05Summary = "clear"; const line = document.createElement("div"); line.className = "b05-route__irregular-actions"; - line.append(name, pick, clear); + line.append(name, clear); root.append(label, line); let current: SummaryItem | null = null; const set = (item: SummaryItem | null): void => { current = item; name.value = summaryLabel(item); - name.title = summaryTooltip(item); + name.title = `${summaryTooltip(item)} · ${PICK_TIP}`; name.classList.toggle("is-assigned", item !== null); clear.disabled = item === null; }; @@ -66,7 +63,12 @@ export function buildSummaryRow(): SummaryRow { get: () => current, set, bind(handlers) { - pick.addEventListener("click", () => handlers.pick()); + name.addEventListener("click", () => handlers.pick()); + name.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handlers.pick(); + }); clear.addEventListener("click", () => handlers.clear()); }, }; diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 846d3623c..a874d14e7 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -357,11 +357,11 @@ /* ── 조작 컨트롤 높이 템플릿(2026-08-29 사용자 지시) ────────────────────── * 「구조물 배치」 섹션 안의 입력·셀렉트·스텝 묶음·이식 행이 **한 높이**를 쓴다. - * 값을 바꾸려면 이 변수 하나만 고치면 된다. 기준은 조정창에서 옮겨 온 행의 + * 값은 theme `--panel-control-h` 한 곳(좌측 패널 공용 양식이 함께 씀 · 269). 기준은 조정창에서 옮겨 온 행의 * 컨트롤 높이(26px)다 — 사용자가 그 크기를 기준으로 정했다. */ .b05-structure-section, .b05-drainage__facility { - --b05-control-h: 26px; + --b05-control-h: var(--panel-control-h); } .b05-structure-section input:not([type="checkbox"]), @@ -409,7 +409,7 @@ /* 제목(라벨)과 항목 사이 간격도 한 변수로 묶는다(2026-08-29 사용자 지시) — 기준은 조정창에서 옮겨 온 행(2px·작은 글자). 측점 행은 가로 배치라 열 간격을 그대로 둔다. */ .b05-structure-section { - --b05-label-gap: 2px; + --b05-label-gap: var(--panel-label-gap); } .b05-structure-section .b05-route__field { @@ -436,7 +436,8 @@ } /* 구조물 조합 잇기 줄(PLAN 21-4 · 54-17) — 옵션 칸 꼴(`b05-route__field`) 2행 · 아래 줄은 - * [이름 칸][선택][해제] · 이름 칸이 남는 폭을 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색. */ + * [이름 칸][해제] · 이름 칸이 남는 폭을 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색. + * 이름 칸 = 고르기 단추 노릇(280-1) — 손 커서 · 키보드 초점 테두리. */ .b05-structure__summary[hidden] { display: none; } @@ -445,6 +446,12 @@ flex: 1 1 auto; align-self: center; text-overflow: ellipsis; + cursor: pointer; +} + +.b05-structure__summary-name:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 1px; } /* 연결됨 — 연한 초록 배경 + 초록 테두리 · 글은 본문색 그대로(밝음 · 어두움 같은 토큰). */ @@ -453,10 +460,10 @@ background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface)); } -/* 미연결 — 연한 노랑 배경 + 노랑 테두리(경고 · 밝음 · 어두움 같은 토큰). */ +/* 미연결 — 옅은 회색 배경 + 회색 테두리(구조물 리스트 램프 색 · 밝음 · 어두움 같은 토큰 · 258-3). */ .b05-structure__summary-name:not(.is-assigned) { - border-color: var(--color-warning); - background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + border-color: var(--color-text-muted); + background: color-mix(in srgb, var(--color-text-muted) 12%, var(--color-surface)); } .b05-structure__summary .b05-route__irregular-btn { @@ -651,14 +658,10 @@ color: var(--color-text-secondary); } -/* [정수 맞춤] 단추 줄 — 구조물 목록 머리(234-1) */ -.b05-structure__snap-row { - display: flex; - justify-content: flex-end; - margin-bottom: var(--spacing-4); -} - -.b05-structure__snap { - flex: 0 0 auto; - padding: var(--spacing-4) var(--spacing-8); +/* 월류 높이 안내(287-3) — 필요 수심 · 설계유량 없음 */ +.b05-structure__flow-hint { + grid-column: 1 / -1; + margin: 0; + font-size: 0.78rem; + color: var(--color-text-secondary); } diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 6584eb291..1806537e8 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -497,7 +497,7 @@ background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } -/* 도구줄 오른쪽 묶음 — 가로 배율 조작구 + 오른쪽 판 켬끔 단추(두 보기 같은 자리, 계획서 24-2c). */ +/* 도구줄 오른쪽 묶음 — 가로 배율 조작구(두 보기 같은 자리, 계획서 24-2c). */ .b05-route-profile__trailing { display: flex; flex: 0 0 auto; @@ -506,11 +506,6 @@ margin-left: auto; } -.b05-route-profile__trailing > .b05-drainage__toggle { - padding: var(--btn-bar-padding); - font-size: var(--btn-bar-font-size); -} - .b05-profile__zoom-btn { width: 22px; height: 22px; diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 9b7a7cdaa..75b10640a 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -22,6 +22,7 @@ import type { CrossDesign, CrossDesignRequest, CrossDesignResponse, + CrossSection, CrossSectionPatch, SectionConfirmResponse, SectionContextResponse, @@ -120,6 +121,21 @@ export async function getSections( } /** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */ +/** + * 초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 미리 떠 받는다 — 서버는 아무것도 안 씀 · 캐시에만 얹음 + * ([저장] 때 서버가 같은 계산으로 정본에 씀 · 273-2). + */ +export async function previewStructureSections( + projectId: string, + chainages: number[], +): Promise { + const result = await requestJson<{ cross_sections?: CrossSection[] }>( + `/projects/${projectId}/section/preview-stations`, + { method: "POST", body: JSON.stringify({ chainages }) }, + ); + return result.cross_sections ?? []; +} + export async function fetchSectionDetail( projectId: string, routeId: number, diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 7c09a92ed..f19214b97 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -61,6 +61,7 @@ export type { import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement"; import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment"; import type { PavementEntry } from "@util/common_util_structure_pavement"; +import type { UnderdrainSpec } from "@util/common_util_structure_underdrain"; export type { FordPavementSpec, RevetmentSpec }; export interface SectionContextResponse { @@ -118,6 +119,8 @@ export interface SectionStation { kind: "bp" | "ep" | "regular" | "irregular"; /** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */ structure?: string; + /** 구조물 측점이 얹힌 격자 자리(같은 m 면 측점 하나 · 구조물이 위 · 272) — 구조물이 떠나면 일반 측점. */ + grid?: boolean; center_z: number | null; azimuth_deg: number | null; /** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */ @@ -226,6 +229,8 @@ export interface CulvertSideSpec { export interface ChuteSpec { structure: string; thickness_m: number; + /** 사면 따라 그릴 길이(m · 물넘이 포장 286-3) — 그림 전용 · 없으면 원지반까지. */ + length_m?: number; } /** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */ @@ -316,6 +321,8 @@ export interface BoxSet { export interface CrossSection extends SectionStation { samples: SectionSample[]; + /** 초안 자리를 서버가 미리 떠 준 횡단 — 캐시에만 있음 · [저장]하면 서버가 정본으로 씀(273-2). */ + preview?: boolean; /** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */ design?: CrossDesign; /** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */ @@ -330,6 +337,8 @@ export interface CrossSection extends SectionStation { revetment?: RevetmentSpec; /** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */ pavement?: PavementEntry[]; + /** 맹암거 — 기준 측점(288-1). 높이 · 경사 · 측구 없음 · 노면 기울임 · 돌 무늬 형상. */ + underdrain?: UnderdrainSpec; } export interface SectionDetailResponse { @@ -474,6 +483,8 @@ export interface CrossDesign { pavement_suggested?: boolean; /** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */ structure_note?: string; + /** 표 위 CAD 칸(260-1) — CAD 저장 꼴 · 있으면 B07 이 글 대신 이 엔티티를 표 위에 놓음. */ + structure_note_cad?: CrossNoteCad; /** [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 서버가 다시 셈). */ detail_table?: CrossDetailTable; /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ @@ -487,6 +498,11 @@ export interface CrossDesign { right: { offset_m: number; elevation_m: number }; }; design_elevation_m: number; + /** 물넘이포장 · 맹암거 자리(286-2 · 287-4) — 측구 없음 · 노면 = 지대 낮은 쪽 한 경사(`tilt_pct`). */ + tilted?: boolean; + tilt_pct?: number; + /** 물넘이 낮은 쪽 늘인 노면 끝(294-3) — 그 쪽 절토 사면 대신 노면 · 콘크리트가 원지반까지. */ + tilt_reach?: { side: "left" | "right"; offset_m: number; elevation_m: number }; cut_area_m2: number; /** * 절토 내역(합 = `cut_area_m2`). 지표면~암반 경계선이 토사, 그 아래가 암이다. @@ -625,6 +641,8 @@ export interface CrossSectionPatch { cut_rock_area_m2?: number; /** 측점 구조물 글(138-4) — 빈 글 = 지움. */ structure_note?: string; + /** 표 위 CAD 칸(260-1) — 세션 초안 그대로. */ + structure_note_cad?: CrossNoteCad; /** [상세] 표 손질(179-4) — 세션 초안 그대로. */ detail_table?: CrossDetailTable; } @@ -638,7 +656,7 @@ export type CrossCellSource = /** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */ export interface CrossPickPiece { - line: "design" | "ground" | "spoil" | "structure"; + line: "design" | "ground" | "spoil" | "structure" | "rock"; start: [number, number]; end: [number, number]; } @@ -649,3 +667,9 @@ export interface CrossDetailTable { rows: Array<{ id: string; after: string | null; grouped: boolean; labels: string[] }>; columns: Array<{ id: string; label: string }>; } + +/** [상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(`cad_host` · 엔티티 · 층) 그대로. */ +export interface CrossNoteCad { + entities: Record[]; + layers?: Record[]; +} diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 02bfa8a79..95b11ad4d 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -94,6 +94,8 @@ export const USER_TOUCHED_KEYS = [ "cut_slope_ratio_user", // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + // 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님. + "structure_note_cad", // [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) "detail_table", // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 @@ -357,6 +359,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { choice?.ditch_type ?? (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), paved: pavedBySpecs(section), + // 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원) + tilted: Boolean(section.ford_pavement || section.underdrain), + tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null, standard, berm: bermAt(section.chainage_m, design), rockBoundaryOffsetM, diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 4f50a3c08..67d055929 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -251,7 +251,8 @@ def _side_spec( def _chute(values: dict[str, Any], head: str = "chute_add") -> dict[str, Any] | None: """도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 · 두께(cm → m · 빈 값 30 cm) · 구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림. - TS 짝 `chute`.""" + 길이(`{head}_{n}_length_m` · 286-3) = 사면 따라 그릴 길이 — 그림 전용(수량 · 내역에 안 씀) · + 비면 원지반까지. TS 짝 `chute`.""" pattern = re.compile(rf"^{head}_(\d+)_structure$") picked = sorted( (int(hit.group(1)), str(value).strip()) @@ -263,7 +264,11 @@ def _chute(values: dict[str, Any], head: str = "chute_add") -> dict[str, Any] | n, structure = picked[0] cm = _number(values.get(f"{head}_{n}_thickness"), None) cm = cm if cm and cm > 0 else CHUTE_THICKNESS_CM - return {"structure": structure, "thickness_m": round(cm / 100.0, 3)} + found = {"structure": structure, "thickness_m": round(cm / 100.0, 3)} + length = _number(values.get(f"{head}_{n}_length_m"), None) + if length and length > 0: + found["length_m"] = round(length, 3) + return found def _with_chute( @@ -508,11 +513,10 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]: def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: - """물넘이포장 1개소 — 노면을 월류 폭만큼, 노선 중심에서 월류 높이만큼 판 자리. + """물넘이포장 1개소 — 월류 폭 범위의 노면 = 콘크리트 윗면(287-4). - 구조물을 얹는 것이 아니라 **계획고를 파낸 노면**이라 다른 세트와 형상이 다르다. - 바닥은 유입(상류) 쪽이 높고 유출 쪽이 낮게 기운다 — 경사를 사용자가 비워 두면 - 화면이 노면 일반 기울기로 채운다(2026-08-28 사용자 확정). 파임 폭은 노견까지 전폭. + 설계 노면을 경사각도(`slope_pct`)로 지대 낮은 쪽 한 경사로 기울임(`tilt_ranges` → + `compute_cross_design(tilted=…)`) · 월류 높이(`depth_m`) = 노면 위 점선(그림 전용). """ defaults = _registry_defaults("ford_pavement") values = dict(options or {}) @@ -531,7 +535,7 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: { "type": "ford_pavement", "span_m": span, - # 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). + # 월류 높이 — 노면 위 점선. 없으면 안 그린다(수치를 지어내지 않는다). "depth_m": depth if depth and depth > 0 else None, "slope_pct": slope, "defaulted": _defaulted( @@ -658,24 +662,32 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]] chainage = _number(section.get("chainage_m"), None) if chainage is None: continue - for pipe_chainage, spec in sets.items(): + # ⚠ 키가 다른 시설은 같은 측점에 함께 얹는다(299-1) — 첫 시설 하나에서 멈추면 물넘이포장 + # 연동 범위(폭 절반) 안 측점의 **자기 관**이 빠졌다(95 m 배관이 90 m 물넘이에 가려짐). + # 같은 키끼리는 소유 측점 것 → 흙막이 아닌 자기 시설 → 가까운 것 → 관 지점 차례. + best: dict[str, tuple[tuple[Any, ...], dict[str, Any]]] = {} + for order, (pipe_chainage, spec) in enumerate(sets.items()): # 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다(`_SPAN_LINKED_TYPES`). reach = _CHAINAGE_TOLERANCE_M if spec.get("type") in _SPAN_LINKED_TYPES: reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2 - if abs(chainage - pipe_chainage) <= reach or owner.get(pipe_chainage) == chainage: - # 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. - kind = spec.get("type") - # ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의 - # 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려 - # **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리). - section[_SECTION_KEYS.get(str(kind), "culvert")] = { - **spec, - "chainage_m": pipe_chainage, - } - # 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음. - if spec.get("guard"): - section["culvert"] = {**spec["guard"], "chainage_m": pipe_chainage} - attached += 1 - break + owned = owner.get(pipe_chainage) == chainage + if not (abs(chainage - pipe_chainage) <= reach or owned): + continue + # 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. + entries = [(_SECTION_KEYS.get(str(spec.get("type")), "culvert"), spec, False)] + # 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음 · + # 자기 관과 부딪히면 관 먼저(흙막이가 덮으면 관 그림 · 연장이 빠짐). + if spec.get("guard"): + entries.append(("culvert", spec["guard"], True)) + for key, value, is_guard in entries: + rank = (not owned, is_guard, abs(chainage - pipe_chainage), order) + if key not in best or rank < best[key][0]: + # ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의 + # 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려 + # **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리). + best[key] = (rank, {**value, "chainage_m": pipe_chainage}) + for key, (_, value) in best.items(): + section[key] = value + attached += bool(best) return attached diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index db44611ca..a83fe08e0 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -155,6 +155,13 @@ def stored_shoulder_extend(stored: dict[str, Any] | None) -> dict[str, float]: return out +def stored_tilt(stored: dict[str, Any] | None) -> dict[str, Any]: + """저장분의 구조물 자리 기울기(287-4 · 288-1) — `compute_cross_design(**...)` 로 넘긴다.""" + value = (stored or {}).get("tilt_pct") + pct = float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None + return {"tilted": bool((stored or {}).get("tilted")), "tilt_pct": pct} + + def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]: """측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다. @@ -194,6 +201,8 @@ def compute_cross_design( spoil_fill: dict[str, Any] | None = None, shoulder_extend_left_m: float = 0.0, shoulder_extend_right_m: float = 0.0, + tilted: bool = False, + tilt_pct: float | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -210,6 +219,9 @@ def compute_cross_design( 입력이며, None·45m 이상이면 확폭이 없다. curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다 (2026-09-06 사용자 확정). 값이 없으면 확폭을 넣지 않는다. + tilted · tilt_pct: 물넘이포장 범위 · 맹암거 측점(286-2 · 287-4 · 288-1) — 측구 없음 · + 노면 = `tilt_pct`(비면 표준 횡단경사)로 지대 낮은 쪽 한 경사. 결과에 `tilted` · `tilt_pct` + 로 되실음(`stored_tilt`). TS 짝 `tilted` · `tiltPct`. surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). @@ -243,6 +255,8 @@ def compute_cross_design( paved_group = _resolve_group("paved", standard) # 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"] + if tilted and tilt_pct is not None and float(tilt_pct) > 0: + cross_slope_pct = float(tilt_pct) # 구조물 자리 경사(287-4 · 288-1) resolved_ditch_side = _resolve_ditch_side(section_mode, ditch_side) valid = sorted( @@ -299,6 +313,8 @@ def compute_cross_design( berm=berm, shoulder_extend_left_m=shoulder_extend_left_m, shoulder_extend_right_m=shoulder_extend_right_m, + tilted=bool(tilted), + ground_span=(valid[0][0], valid[-1][0]), ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 @@ -529,6 +545,17 @@ def compute_cross_design( # 뒤에 쓸 자리가 있다. **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.** "cut_slope_segments": geometry.cut_slope_segments(), } + if tilted: + result["tilted"] = True + for side, reach in geometry.reach.items(): # 낮은 쪽 늘인 노면 끝(294-3) + signed = reach if side == "left" else -reach + result["tilt_reach"] = { + "side": side, + "offset_m": round(signed, 4), + "elevation_m": round(geometry.road_z(signed), 4), + } + if tilt_pct is not None: + result["tilt_pct"] = round(float(tilt_pct), 4) if drop > 0: # 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다. result["surface_drop_m"] = round(drop, 4) diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index 3249f58da..e19dffc10 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -62,6 +62,8 @@ class _SectionGeometry: berm: BermSpec | None = None, shoulder_extend_left_m: float = 0.0, shoulder_extend_right_m: float = 0.0, + tilted: bool = False, + ground_span: tuple[float, float] | None = None, ) -> None: half_road = group["road_width_m"] / 2.0 # 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽). @@ -95,6 +97,11 @@ class _SectionGeometry: # 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). slope = cross_slope_pct / 100.0 self.slope_per_offset = -slope if ditch_side == "left" else slope + # 물넘이포장 · 맹암거 자리(287-4 · 288-1) — 노면 = 한 경사로 지대 낮은 쪽(노면 끝 + # 원지반이 낮은 쪽)으로 기움 · 측구 없음(아래). ⚠ TS 짝 `SectionGeometry`. + if tilted and ground_at is not None: + low_left = ground_at(self.left_extent) < ground_at(-self.right_extent) + self.slope_per_offset = -slope if low_left else slope # 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다 # 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서 @@ -144,6 +151,10 @@ class _SectionGeometry: self.has_ditch = auto_ditch if (choice is None or both_fill) else choice #: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. self.ditch_choice = None if both_fill else choice + # 물넘이포장 · 맹암거 자리(286-2 · 288-1) — 측구가 안 섬 · 선택은 그대로(시설을 걷으면 + # 되살아남). + if tilted: + self.has_ditch = False # 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** # 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. @@ -185,6 +196,36 @@ class _SectionGeometry: (edge_offset + outward * top, edge_z), ] + # 물넘이 낮은 쪽 연장(294-3) — 노면이 내려가는 쪽 노면 끝 원지반이 노면보다 높으면(절토) + # 노면을 같은 기울기로 늘여 원지반과 처음 만나는 자리까지(그 쪽 절토 사면 없음) · 반폭 + # (`ground_span`) 안에서 못 만나면 옛 길(절토 사면). ⚠ TS 짝 `SectionGeometry.reach`. + self.reach: dict[str, float] = {} + if tilted and ground_at is not None and abs(self.slope_per_offset) > 1e-12: + self._extend_low_side(ground_at, ground_span) + + def _extend_low_side( + self, ground_at: Callable[[float], float], span: tuple[float, float] | None + ) -> None: + side = "left" if self.slope_per_offset < 0 else "right" + sign = 1.0 if side == "left" else -1.0 + extent = self.left_extent if side == "left" else self.right_extent + limit = (span[1] if side == "left" else -span[0]) if span else extent + 50.0 + + def gap(dist: float) -> float: # > 0 = 원지반이 늘인 노면 위(절토) + return ground_at(sign * dist) - self.road_z(sign * dist) + + if gap(extent) <= 1e-3: + return + step = 0.05 + dist = extent + while dist < limit - 1e-9: + nxt = min(dist + step, limit) + g0, g1 = gap(dist), gap(nxt) + if g1 <= 0: + self.reach[side] = dist + (nxt - dist) * g0 / (g0 - g1) + return + dist = nxt + def road_z(self, offset_m: float) -> float: """노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면.""" return self.z_center + self.slope_per_offset * offset_m @@ -420,6 +461,9 @@ class _SectionGeometry: return points[-1][1] role = self.left_role if side == "left" else self.right_role dist = abs(offset_m) + reach = self.reach.get(side) + if reach is not None: # 늘인 노면 → 그 바깥은 원지반(294-3) + return self.road_z(offset_m) if dist <= reach + 1e-9 else ground_m if role == "cut": # 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4). cross = self.cut_cross_dist(side) @@ -458,6 +502,9 @@ class _SectionGeometry: if cross is not None and offset > cross + 1e-9: break points.append(offset if side == "left" else -offset) + # 늘인 노면 끝(294-3) + for side, reach in self.reach.items(): + points.append(reach if side == "left" else -reach) # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index b3523a2e6..e2dc0da03 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -25,6 +25,7 @@ from B06_Section.B06_Section_Engine_Design import ( compute_cross_design, curve_widening_args, stored_shoulder_extend, + stored_tilt, ) from common_util.common_util_json import atomic_write_json @@ -158,6 +159,7 @@ def extend_unclosed_sections( berm=stored_berm(design), **curve_widening_args(section), **stored_shoulder_extend(design), # 노견 확장(139-6) + **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) ) try: diff --git a/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py index 28b280e5c..30d267132 100644 --- a/B06_Section/B06_Section_Engine_SpoilFill.py +++ b/B06_Section/B06_Section_Engine_SpoilFill.py @@ -23,7 +23,11 @@ from __future__ import annotations from pathlib import Path from typing import Any -from B06_Section.B06_Section_Engine_Design import compute_cross_design, stored_shoulder_extend +from B06_Section.B06_Section_Engine_Design import ( + compute_cross_design, + stored_shoulder_extend, + stored_tilt, +) from common_util.common_util_structure_face_role import structure_face_role #: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다. @@ -217,6 +221,7 @@ def enforce_spoil_fills( }, **curve_widening_args(section), **stored_shoulder_extend(design), # 노견 확장(139-6) + **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) ) except (ValueError, KeyError): return None diff --git a/B06_Section/B06_Section_Engine_Tilt.py b/B06_Section/B06_Section_Engine_Tilt.py new file mode 100644 index 000000000..3fa30b969 --- /dev/null +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -0,0 +1,100 @@ +"""구조물 자리 기울기 — 물넘이포장 · 맹암거 측점의 노면(287-4 · 288-1). + +그 자리 횡단 설계 = 측구 없음 · 노면 = 한 경사로 지대 낮은 쪽(`compute_cross_design(tilted=…, +tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 암거 경사(%) · 비면 표준 횡단경사. + · 물넘이포장 = 관 지점 정본(pipe_points) 범위(기준 ± 월류 폭/2). + · 맹암거 = 구조물 정본(structures.json) A군 점형 · 기준 측점 한 곳 — 횡단 dict 에 + `underdrain`(높이 · 경사)을 얹어 횡단도가 돌 무늬 형상을 그림(TS 짝 + `common_util_structure_underdrain.ts`). +판정 폭 = 포장 범위와 같은 0.02 m(`paved_at`). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets + +logger = logging.getLogger(__name__) + +UNDERDRAIN_TYPE_ID = "cross_underdrain" +_EDGE_TOLERANCE_M = 0.02 + +TiltRange = tuple[float, float, float | None] + + +def _number(value: Any) -> float | None: + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if parsed == parsed else None + + +def load_underdrains(project_root: Path) -> list[dict[str, Any]]: + """구조물 정본의 맹암거 — `{structure_id, chainage_m, height_m, slope_pct}` · + 실패하면 빈 목록.""" + try: + structures = load_structures(str(project_root))[1] + except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 + logger.exception("B06 맹암거를 읽지 못했습니다 (없음으로 본다)") + return [] + found: list[dict[str, Any]] = [] + for structure in structures: + if structure.type_id != UNDERDRAIN_TYPE_ID: + continue + options = structure.options or {} + height = _number(options.get("culvert_height_m")) + slope = _number(options.get("culvert_slope_pct")) + found.append( + { + "structure_id": structure.structure_id, + "chainage_m": float(structure.anchor_m()), + "height_m": height if height and height > 0 else None, + "slope_pct": slope if slope and slope > 0 else None, + } + ) + return found + + +def tilt_ranges(project_root: Path | None) -> list[TiltRange]: + """측구 없이 노면을 지대 낮은 쪽 한 경사로 기울이는 자리 — (시작, 끝, 경사 %).""" + if project_root is None: + return [] + out: list[TiltRange] = [] + for at, spec in load_culvert_sets(project_root).items(): + if spec.get("type") == "ford_pavement": + half = float(spec.get("span_m") or 0.0) / 2.0 + out.append((at - half, at + half, spec.get("slope_pct"))) + for drain in load_underdrains(project_root): + out.append((drain["chainage_m"], drain["chainage_m"], drain["slope_pct"])) + return out + + +def tilt_args(chainage_m: float, ranges: list[TiltRange]) -> dict[str, Any]: + """그 측점의 구조물 자리 기울기 — `compute_cross_design(**...)` 로 넘김 · + 저장분 짝 `stored_tilt`.""" + tol = _EDGE_TOLERANCE_M + hit = next((r for r in ranges if r[0] - tol <= chainage_m <= r[1] + tol), None) + pct = hit[2] if hit else None + return {"tilted": hit is not None, "tilt_pct": float(pct) if pct is not None else None} + + +def attach_underdrains(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: + """맹암거 기준 측점의 횡단 dict 에 `underdrain` 을 얹는다. 얹은 개수를 돌려준다.""" + drains = load_underdrains(project_root) + attached = 0 + for section in cross_sections: + chainage = _number(section.get("chainage_m")) + if chainage is None: + continue + hit = next( + (d for d in drains if abs(d["chainage_m"] - chainage) <= _EDGE_TOLERANCE_M), None + ) + if hit: + section["underdrain"] = dict(hit) + attached += 1 + return attached diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 3247a715e..7c9256058 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -26,6 +26,7 @@ from B06_Section.B06_Section_Engine_Design import ( stored_shoulder_extend, ) from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures +from B06_Section.B06_Section_Engine_Tilt import attach_underdrains, tilt_args, tilt_ranges from B06_Section.B06_Section_Repository import ( count_cross_sections, create_longitudinal_section, @@ -285,6 +286,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). attach_wall_structures(root, cross_sections) attach_pavement_structures(root, cross_sections) # 노면공 형상(88) + attach_underdrains(root, cross_sections) # 맹암거 형상(288-1) return {"longitudinal": longitudinal, "cross_sections": cross_sections} @@ -448,6 +450,7 @@ async def regenerate_sections( attach_culvert_sets(project_root, result["cross_sections"]) attach_wall_structures(project_root, result["cross_sections"]) attach_pavement_structures(project_root, result["cross_sections"]) + attach_underdrains(project_root, result["cross_sections"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) @@ -600,6 +603,7 @@ async def compute_cross_section_design( berm=stored_berm(stored_design or {}), **curve_widening_args(cross_record), **stored_shoulder_extend(stored_design or {}), # 노견 확장(139-6) + **tilt_args(request.chainage_m, tilt_ranges(project_root)), ) # B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다. design["status"] = "provisional" diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 061bbd2e7..eff3ebc12 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -89,20 +89,20 @@ def _rowless_station_chainages( return result -async def _stand_patch_stations( - project_id: UUID, request: SectionConfirmRequest | None, known: list[float] -) -> None: - """행 없는 측점의 횡단 수정 — 버리지 않고 그 측점부터 세운다(B05·B06 한 페이지처럼). +async def _stand_patch_stations(project_id: UUID) -> None: + """저장 · 확정 앞에 구조물 측점을 지금 관 · 구조물 목록으로 맞춘다(B05·B06 한 페이지처럼). - 옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없다. 여기서 - [측점 만들기]와 같은 조건으로 세우면 아래 `_rowless_station_chainages` 가 행을 만들고 - 수정이 그 행에 얹힌다. + 옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없고, 옛 자리 측점은 + 남아 있다 — 행 없는 측점의 수정이 버려지고 옛 측점이 카드 · 수량에 남음. [측점 만들기]와 같은 + 조건으로 세우고 낡은 것을 지우면(맞출 것이 없으면 그냥 돌아옴 · 272) 아래 + `_rowless_station_chainages` 가 행을 만들고 수정이 그 행에 얹힌다. """ - patches = (request.cross_patches or []) if request else [] - if any(all(abs(p.chainage_m - k) >= 0.01 for k in known) for p in patches): - from B06_Section.B06_Section_Router_Stations import ensure_structure_stations + from B06_Section.B06_Section_Router_Stations import ensure_structure_stations + try: await ensure_structure_stations(project_id) + except Exception: # 맞춤이 안 돼도 저장은 진행 — 옛 측점이 남을 뿐(다음 저장에서 다시) + logger.exception("B06 저장: 구조물 측점 맞춤 실패(저장은 진행): project_id=%s", project_id) async def _apply_section_edits( @@ -230,6 +230,9 @@ async def save_sections( if stale: return stale stored_path = await get_project_storage_relative_path(connection, project_id) + # 측점부터 맞춤 — 아래 행 조회가 맞춘(옮기고 지운) 뒤 행을 봄(272) + await _stand_patch_stations(project_id) + async with pool.acquire() as connection: known = await get_cross_section_chainages(connection, route_id) # 행은 있는데 **설계가 빈** 측점도 채운다(2026-09-09). 「빈 설계」란 `design` 이 # 없거나 **`ground_type` 이 없는 것**이다 — 재생성이 행을 다시 쓰면 면적 4키만 @@ -238,7 +241,6 @@ async def save_sections( # ⚠ **값이 있는 행은 건드리지 않는다** — 사용자 조작값·이월분이 거기 있다. missing = await get_cross_sections_missing_design_chainages(connection, route_id) - await _stand_patch_stations(project_id, request, known) project_root = Path(resolve_stored_project_path(stored_path)) rowless = missing + await asyncio.to_thread( _rowless_station_chainages, @@ -311,10 +313,12 @@ async def confirm_sections( if stale: return stale stored_path = await get_project_storage_relative_path(connection, project_id) + await _stand_patch_stations( + project_id + ) # 측점부터 맞춤 — 아래 행 조회가 맞춘 뒤 행을 봄(272) + async with pool.acquire() as connection: missing = await get_cross_sections_missing_design_chainages(connection, route_id) known = await get_cross_section_chainages(connection, route_id) - - await _stand_patch_stations(project_id, request, known) marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter())) project_root = Path(resolve_stored_project_path(stored_path)) # 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면 diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index adab8a3c5..035ec26ff 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -13,7 +13,9 @@ from B06_Section.B06_Section_Engine_Design import ( compute_cross_design, curve_widening_args, stored_shoulder_extend, + stored_tilt, ) +from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges from common_util.common_util_cross_berm import ( BERM_DEFAULT_DEPTH_M, BERM_DEFAULT_INTERVAL_M, @@ -77,12 +79,20 @@ def pavement_ranges(project_root: Path) -> list[tuple[float, float]]: ranges.append((min(float(start), float(end)), max(float(start), float(end)))) except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 logger.exception("B06 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)") - for chainage, spec in load_culvert_sets(project_root).items(): - if spec.get("type") != "ford_pavement": - continue - half = float(spec.get("span_m") or 0.0) / 2.0 - ranges.append((chainage - half, chainage + half)) - return ranges + return ranges + ford_pavement_ranges(project_root) + + +def ford_pavement_ranges(project_root: Path | None) -> list[tuple[float, float]]: + """물넘이포장 범위(기준 ± 월류 폭/2) — 포장 필수 · 측구 없음(286-2). 판정은 `paved_at`.""" + if project_root is None: + return [] + sets = load_culvert_sets(project_root).items() + halves = [ + (at, float(s.get("span_m") or 0.0) / 2.0) + for at, s in sets + if s.get("type") == "ford_pavement" + ] + return [(at - half, at + half) for at, half in halves] def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]: @@ -155,6 +165,8 @@ USER_TOUCHED_KEYS = ( "cut_slope_ratio_user", # 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + # 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님. + "structure_note_cad", # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) "detail_table", # 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`) @@ -205,6 +217,7 @@ def enforce_pavement_ranges( 사용자 조작값(반폭·구조물 조정)은 그대로 승계한다. """ ranges = pavement_ranges(project_root) + tilts = tilt_ranges(project_root) ford_drops = ford_surface_drops(project_root) changed = 0 for section in cross_sections: @@ -213,7 +226,8 @@ def enforce_pavement_ranges( continue chainage = float(section.get("chainage_m", 0.0)) wanted = paved_at(chainage, ranges) - if bool(design.get("paved")) == wanted: + tilt = tilt_args(chainage, tilts) # 물넘이 자리 = 측구 없음 · 기울기(286-2 · 287-4) + if bool(design.get("paved")) == wanted and stored_tilt(design) == tilt: continue try: recomputed = compute_cross_design( @@ -236,6 +250,7 @@ def enforce_pavement_ranges( berm=stored_berm(design), **curve_widening_args(section), **stored_shoulder_extend(design), # 노견 확장(139-6) + **tilt, ) except (ValueError, KeyError): continue @@ -264,6 +279,7 @@ def enforce_design_lines( from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans ford_drops = ford_surface_drops(project_root) + tilts = tilt_ranges(project_root) spans = load_berm_spans(project_root) changed = 0 for section in cross_sections: @@ -294,6 +310,7 @@ def enforce_design_lines( berm=stored_berm(design), **curve_widening_args(section), **stored_shoulder_extend(design), + **tilt_args(chainage, tilts), ) except (ValueError, KeyError): continue @@ -349,6 +366,7 @@ def enforce_ford_surface_drops( berm=stored_berm(design), **curve_widening_args(section), **stored_shoulder_extend(design), # 노견 확장(139-6) + **stored_tilt(design), ) except (ValueError, KeyError): continue @@ -421,6 +439,7 @@ def attach_default_designs( modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(project_root) if project_root else [] + tilts = tilt_ranges(project_root) ford_drops = ford_surface_drops(project_root) for section in cross_sections: if section.get("design"): @@ -439,6 +458,7 @@ def attach_default_designs( rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, surface_drop_m=ford_drop_at(chainage, ford_drops), **curve_widening_args(section), + **tilt_args(chainage, tilts), ) design.update(status="provisional", pavement_suggested=suggested) section["design"] = design @@ -499,6 +519,7 @@ def recompute_designs_for_alignment( modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(project_root) if project_root else [] + tilts = tilt_ranges(project_root) ford_drops = ford_surface_drops(project_root) stored_by_chainage = { round(float(record["chainage_m"]), 3): (record.get("design") or {}) @@ -557,6 +578,7 @@ def recompute_designs_for_alignment( berm=session_berms.get(key) or stored_berm(stored), **curve_widening_args(section), **stored_shoulder_extend(stored), # 노견 확장(139-6) + **tilt_args(chainage, tilts), ) except (ValueError, KeyError): continue @@ -580,9 +602,13 @@ def compute_default_designs( chainages: list[float], standard: dict[str, Any] | None = None, carried: dict[float, dict[str, Any]] | None = None, + crosses: dict[float, dict[str, Any]] | None = None, ) -> list[tuple[float, dict[str, Any]]]: """미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다. + `crosses`(누가거리 round 3 → 횡단 dict)가 오면 파일 대신 그 횡단으로 계산한다 — 아직 정본에 + 없는 B06 미리 뜨기 자리(273-2) · 상단측도 그 횡단 값(종단 정본에 측점이 없음). + standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다. `carried`(누가거리 round 3 → `CARRY_INPUT_KEYS` 값)가 오면 그 측점은 기본값 대신 그 입력으로 계산한다 — 노선 [확인] 체인의 옛 설계 이월(PLAN 74-3). 결과 칸은 늘 새 계획고로 다시 낸다. @@ -597,14 +623,17 @@ def compute_default_designs( default_modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(root) + tilts = tilt_ranges(root) ford_drops = ford_surface_drops(root) results: list[tuple[float, dict[str, Any]]] = [] for chainage_m in chainages: + cross = (crosses or {}).get(round(chainage_m, 3)) cross_path = find_cross_file(cross_dir, chainage_m) - if not cross_path.is_file(): + if cross is None and not cross_path.is_file(): continue try: - cross = json.loads(cross_path.read_text(encoding="utf-8")) + if cross is None: + cross = json.loads(cross_path.read_text(encoding="utf-8")) samples = cross.get("samples") if not isinstance(samples, list): continue @@ -615,7 +644,12 @@ def compute_default_designs( samples, design_elevation_from_longitudinal(longitudinal, chainage_m), ground_type=str(user.get("ground_type") or "ripping_rock"), - section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), + section_mode=default_modes.get( + round(chainage_m, 3), + f"{cross['uphill_side']}_cut" + if cross.get("uphill_side") in ("left", "right") + else "left_cut", + ), ditch_type=str(user.get("ditch_type") or "standard"), # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). paved=paved_at(chainage_m, paved_ranges), @@ -629,6 +663,7 @@ def compute_default_designs( surface_drop_m=ford_drop_at(chainage_m, ford_drops), berm=stored_berm(user), **curve_widening_args(cross), + **tilt_args(chainage_m, tilts), ) for key in ("berm", "cut_slope_ratio_user"): if user.get(key) is not None: diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index 3c8172d2c..9956f49b5 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -27,6 +27,7 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_Grade_Alignment import SAME_STATION_SNAP_M @@ -83,8 +84,8 @@ async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str } -def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]: - """구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록.""" +def _planted(project_root: Path, route_data_path: str) -> list[tuple[float, str]]: + """관 · 구조물 정본이 심는 구조물 측점 가운데 **노선 안**(세울 수 있는 것)만.""" from B05_Profile.B05_Profile_Engine_Sections import ( _load_pipe_points, _load_route_polyline, @@ -92,23 +93,28 @@ def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, A ) polyline = _load_route_polyline(project_root, route_data_path) - pipes = _load_pipe_points(project_root, polyline) - extras = resolve_extra_stations(project_root, pipes) - if not extras: - return [] - existing = _station_chainages(project_root) + extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline)) # 노선 밖(0 미만 · 끝 넘음) = 측점을 못 세움 — 판정에서 뺌(219-1 · 길이 모르면 끝은 안 봄) end_m = sum(math.dist(a[:2], b[:2]) for a, b in zip(polyline, polyline[1:])) - missing = [] - for chainage, label in extras: - value = float(chainage) - if value < -STATION_MATCH_TOLERANCE_M or ( - end_m > 0 and value > end_m + STATION_MATCH_TOLERANCE_M - ): - continue - if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing): - continue - missing.append({"chainage_m": round(value, 3), "label": label}) + return [ + (float(chainage), label) + for chainage, label in extras + if float(chainage) >= -STATION_MATCH_TOLERANCE_M + and (end_m <= 0 or float(chainage) <= end_m + STATION_MATCH_TOLERANCE_M) + ] + + +def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]: + """구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록.""" + planted = _planted(project_root, route_data_path) + if not planted: + return [] + existing = _station_chainages(project_root) + missing = [ + {"chainage_m": round(value, 3), "label": label} + for value, label in planted + if all(abs(value - other) > STATION_MATCH_TOLERANCE_M for other in existing) + ] return sorted(missing, key=lambda item: item["chainage_m"]) @@ -130,6 +136,24 @@ def _station_chainages(project_root: Path) -> list[float]: return values +def _stale_marks(project_root: Path, route_data_path: str) -> int: + """종단 정본의 구조물 측점과 **지금 관 · 구조물**이 어긋난 수 — 옮긴 관의 옛 자리 · 구조물이 + 떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272). 0 이면 맞음.""" + import json + + planted = {round(value, 2) for value, _label in _planted(project_root, route_data_path)} + standing: set[float] = set() + for path in sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + for station in data.get("stations") or []: + if station.get("kind") == "irregular" or station.get("structure"): + standing.add(round(float(station.get("chainage_m") or 0.0), 2)) + return len(standing ^ planted) + + async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None: async def _load(connection): stored = await get_project_storage_relative_path(connection, project_id) @@ -142,6 +166,26 @@ async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None return Path(resolve_stored_project_path(stored)), route +async def _generation_inputs( + project_id: UUID, route: dict[str, Any], snapshot: dict[str, Any] +) -> tuple[Any, int | None, dict[str, Any] | None]: + """측점을 뜰 때 쓰는 저장분 — (횡단 옵션, 좌표계 EPSG, 종단 행) · 만들기 · 미리 뜨기 한 벌.""" + from B06_Section.B06_Section_Repository import ( + get_latest_section_options, + get_longitudinal_section, + ) + + async def _load(connection): + options = await get_latest_section_options(connection, project_id) + crs_epsg = await get_surface_crs_epsg( + connection, project_id, snapshot.get("surface_model_id") + ) + longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) + return options, crs_epsg, longitudinal + + return await run_with_connection(_load) + + async def _regenerate_structure_stations( project_id: UUID, project_root: Path, route: dict[str, Any], snapshot: dict[str, Any] ) -> int: @@ -158,21 +202,10 @@ async def _regenerate_structure_stations( from B05_Profile.B05_Profile_Router_Confirm import ( _merge_irregular_into_longitudinal, _section_options_from_stored, - ) - from B06_Section.B06_Section_Repository import ( - get_latest_section_options, - get_longitudinal_section, + drop_rows_off_stations, ) - async def _load(connection): - options = await get_latest_section_options(connection, project_id) - crs_epsg = await get_surface_crs_epsg( - connection, project_id, snapshot.get("surface_model_id") - ) - longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) - return options, crs_epsg, longitudinal - - stored_options, crs_epsg, longitudinal = await run_with_connection(_load) + stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot) def _regenerate() -> tuple[int, dict[str, Any] | None]: polyline = _load_route_polyline(project_root, str(route["route_data_path"])) @@ -189,13 +222,20 @@ async def _regenerate_structure_stations( crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, ) summary = None - if stations and longitudinal: + if longitudinal: # 빈 목록이어도 맞춤 — 옛 구조물 측점을 지움(272) summary = _merge_irregular_into_longitudinal( project_root, str(longitudinal["longitudinal_file_path"]), stations ) return len(stations), summary made, summary = await asyncio.to_thread(_regenerate) + if longitudinal: + await run_with_connection( + drop_rows_off_stations, + route["id"], + project_root, + str(longitudinal["longitudinal_file_path"]), + ) if summary is not None: # 옮긴 관 자리의 계획선이 다시 셈됨 — DB 등급 요약도 같이(계획선 저장과 같은 길). await run_with_connection( @@ -207,7 +247,7 @@ async def _regenerate_structure_stations( async def ensure_structure_stations(project_id: UUID) -> int: - """빠진 구조물 측점을 세운다 — 횡단 저장이 **행 없는 측점의 수정**을 받을 때 부름. + """빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎). 옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리 횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다. @@ -218,14 +258,18 @@ async def ensure_structure_stations(project_id: UUID) -> int: return 0 project_root, route = paths missing = await asyncio.to_thread(_missing_marks, project_root, str(route["route_data_path"])) - if not missing: + # 빠진 것만이 아니라 낡은 것(옮긴 관 옛 자리 · 떠난 격자 자리)도 맞춤 — 두 벌로 남지 않게(272) + stale = await asyncio.to_thread(_stale_marks, project_root, str(route["route_data_path"])) + if not missing and not stale: return 0 snapshot = await _sampling_conditions(project_id, project_root) if snapshot is None: - logger.warning("B06 저장: 빠진 구조물 측점 %d곳 — 샘플링 조건이 없어 못 세움", len(missing)) + logger.warning( + "B06 저장: 구조물 측점 %d곳을 못 맞춤 — 샘플링 조건 없음", len(missing) or stale + ) return 0 await _regenerate_structure_stations(project_id, project_root, route, snapshot) - return len(missing) + return len(missing) or stale @router.get("/{project_id}/section/missing-stations") @@ -299,3 +343,88 @@ async def create_missing_stations(project_id: UUID) -> JSONResponse: "missing": missing, } ) + + +#: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게. +PREVIEW_MAX_STATIONS = 60 + + +class PreviewStationsRequest(BaseModel): + """미리 뜰 자리(누가거리 m) — 노선 밖 · 숫자 아닌 값은 서버가 버림.""" + + model_config = ConfigDict(extra="forbid") + + chainages: list[float] = Field(default_factory=list, max_length=PREVIEW_MAX_STATIONS) + + +@router.post("/{project_id}/section/preview-stations") +async def preview_structure_sections( + project_id: UUID, request: PreviewStationsRequest +) -> JSONResponse: + """초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 **미리 떠서 돌려만 준다**(273-2 · 요구 168). + + 파일 · DB 에 아무것도 안 쓴다 — 화면이 캐시에 얹어 B06 카드를 [저장] 전에 보이고, [저장] 때 + 서버가 같은 길(`sample_structure_sections` · `compute_default_designs`)로 다시 만들어 정본에 씀. + 샘플링 조건은 측점 만들기와 같다(없으면 지어내지 않고 409). + """ + from B05_Profile.B05_Profile_Engine_Sections import sample_structure_sections + from B05_Profile.B05_Profile_Router_Confirm import _section_options_from_stored + from B06_Section.B06_Section_Router_Design import compute_default_designs + + chainages = sorted({round(c, 3) for c in request.chainages if math.isfinite(c) and c >= 0}) + if not chainages: + return JSONResponse(content={"status": "success", "cross_sections": []}) + try: + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "확정된 노선이 없습니다."}, + ) + project_root, route = paths + snapshot = await _sampling_conditions(project_id, project_root) + if snapshot is None: + return JSONResponse( + status_code=409, content={"status": "error", "message": SNAPSHOT_MISSING} + ) + stored_options, crs_epsg, longitudinal = await _generation_inputs( + project_id, route, snapshot + ) + if not longitudinal: + return JSONResponse(content={"status": "success", "cross_sections": []}) + + def _preview() -> list[dict[str, Any]]: + _stations, crosses = sample_structure_sections( + project_root, + str(route["route_data_path"]), + str(snapshot["filter_key"]), + str(snapshot["method"]), + bool(snapshot.get("smooth")), + extra_stations=tuple((chainage, "") for chainage in chainages), + options=_section_options_from_stored(stored_options), + crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, + ) + wanted = set(chainages) + picked = { + round(float(cross["chainage_m"]), 3): cross + for cross in crosses + if round(float(cross["chainage_m"]), 3) in wanted + } + designs = compute_default_designs( + project_root, + str(longitudinal["longitudinal_file_path"]), + list(picked), + crosses=picked, + ) + for chainage, design in designs: + picked[round(chainage, 3)]["design"] = design + return list(picked.values()) + + sections = await asyncio.to_thread(_preview) + except Exception: + logger.exception("B06 미리 뜨기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "횡단을 미리 뜨지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "cross_sections": sections}) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index fae03161c..c0434106b 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -138,7 +138,7 @@ class BoxAdjustPatch(BaseModel): class CrossPickPiece(BaseModel): """[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고).""" - line: Literal["design", "ground", "spoil", "structure"] + line: Literal["design", "ground", "spoil", "structure", "rock"] start: list[float] = Field(..., min_length=2, max_length=2) end: list[float] = Field(..., min_length=2, max_length=2) @@ -179,6 +179,15 @@ class CrossDetailTable(BaseModel): columns: list[CrossDetailColumn] = Field(default_factory=list, max_length=4) +class CrossNoteCad(BaseModel): + """[상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(엔티티 · 층) 그대로 · B07 이 표 위에 놓음.""" + + model_config = ConfigDict(extra="allow") + + entities: list[dict[str, Any]] = Field(default_factory=list, max_length=2000) + layers: list[dict[str, Any]] | None = None + + class CrossSectionPatch(BaseModel): """확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" @@ -246,6 +255,8 @@ class CrossSectionPatch(BaseModel): ford_pipe_length_m: float | None = Field(default=None, ge=0) # 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움. structure_note: str | None = Field(default=None, max_length=2000) + # 표 위 CAD 칸(260-1) — 엔티티가 빈 칸 = 비움(옛 글 대신 아무것도 안 놓음) + structure_note_cad: CrossNoteCad | None = None # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈 detail_table: CrossDetailTable | None = None diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts new file mode 100644 index 000000000..5454d6385 --- /dev/null +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * B06_Section_Section_Drafts.ts + * 초안 측점 한 벌(272 · 273-2) — 관 · 구조물을 놓거나 옮긴 초안을 공유 상세(횡단 카드 · 유토곡선)에 맞춘다. + * + * · 옛 자리 구조물 측점(서버가 [저장] 때 다시 맞추기 전) = 숨김 — 쥐고 있다가 초안이 다시 그 자리를 + * 심거나 초안이 없어지면 돌려 놓음. + * · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB + * 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음. + * + * 심는 자리 규칙 = `plantedChainages`(서버 `resolve_extra_stations` 짝) · 카드 이름 = 서버 측점 이름 규칙 + * (`pipe_station_label` · 종류 이름) · 관 그림 = 짝 산식 `common_util_culvert_sets`. + * ========================================================================== */ + +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + fetchStructures, + fetchStructureTypes, + readPendingStructures, + type StructureInstance, + type StructureType, +} from "../B05_Profile/B05_Profile_Api_Structures"; +import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { + attachCulvertSets, + buildCulvertSets, + registryDefaults, +} from "@util/common_util_culvert_sets"; +import { + pipeStationMarks, + plantedChainages, + SAME_STATION_M, + structureStationMarks, +} from "../common_util/common_util_structure_station"; +import { + previewStructureSections, + type CrossSection, + type SectionDetailResponse, +} from "./B06_Section_Api_Fetch"; + +type Pipes = PendingPipes; + +const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_STATION_M; +const byChainage = (a: { chainage_m: number }, b: { chainage_m: number }) => + a.chainage_m - b.chainage_m; + +/** 저장된 관 · 구조물 — 한쪽 초안만 있을 때 심는 자리 셈에 씀 · 저장 뒤(`forgetDraftStations`) 다시 받음. */ +const saved = new Map>(); +/** 미리 뜬 횡단(`프로젝트:노선:누가거리`) — 못 뜬 자리는 null(다시 안 물음) · 저장 뒤 비움. */ +const previews = new Map(); +/** 초안이 빼 둔 옛 자리 측점. */ +const parked = new WeakMap(); +/** 상세마다 마지막 맞춤 번호 — 늦게 끝난 옛 맞춤이 새 맞춤을 덮지 않게. */ +const turns = new WeakMap(); + +/** 저장 · 노선 바뀜 뒤 — 쥔 저장분 · 미리 뜬 횡단을 버림. */ +export function forgetDraftStations(projectId: string): void { + saved.delete(projectId); + for (const key of [...previews.keys()]) if (key.startsWith(`${projectId}:`)) previews.delete(key); +} + +function savedOf(projectId: string): Promise<[Pipes, StructureInstance[]]> { + let hit = saved.get(projectId); + if (!hit) { + hit = Promise.all([ + fetchDetailPipePoints(projectId).then((res) => res.pipe_points as Pipes), + fetchStructures(projectId).then((res) => res.structures), + ]); + hit.catch(() => saved.delete(projectId)); + saved.set(projectId, hit); + } + return hit; +} + +async function previewOf( + projectId: string, + routeId: number, + chainages: number[], +): Promise { + const key = (at: number) => `${projectId}:${routeId}:${at.toFixed(3)}`; + const ask = chainages.filter((at) => !previews.has(key(at))); + if (ask.length) { + const got = await previewStructureSections(projectId, ask); + ask.forEach((at) => previews.set(key(at), got.find((s) => near(s.chainage_m, at)) ?? null)); + } + return chainages.flatMap((at) => previews.get(key(at)) ?? []); +} + +/** 미리 뜬 횡단에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */ +function decorate( + fresh: CrossSection[], + pipes: Pipes, + structures: StructureInstance[], + types: StructureType[], +): void { + const registry = registryDefaults(types); + const byId = new Map(types.map((type) => [type.type_id, type])); + const pipeLabel = (pipe: Pipes[number]): string | null => { + const facility = pipe.facility ?? "pipe"; + if (facility !== "pipe") return byId.get(facility)?.name ?? facility; + const values = { ...registry.pipe, ...pipe.options }; + return `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`; + }; + const labels: Array<[number, string]> = [ + ...pipes.flatMap((pipe) => { + const label = pipeLabel(pipe); + return label + ? pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label]) + : []; + }), + ...structures.flatMap((structure) => { + const type = byId.get(structure.type_id); + const label = type?.name ?? structure.type_id; + return structureStationMarks(structure, type).map((m): [number, string] => [ + m.chainage_m, + label, + ]); + }), + ]; + for (const section of fresh) { + section.preview = true; + const hit = labels.find(([at]) => near(at, section.chainage_m)); + if (hit) section.structure = hit[1]; + } + const own = pipes.filter((pipe) => + fresh.some((section) => near(section.chainage_m, pipe.chainage_m)), + ); + attachCulvertSets( + fresh as unknown as Array>, + buildCulvertSets(own, registry), + ); +} + +/** + * 초안에 맞춰 상세의 횡단 목록을 고친다 — 옛 자리 숨김 · 새 자리 미리 뜬 횡단 얹음. 초안이 없으면 + * 서버 목록 그대로(빼 둔 것 돌려 놓음). 미리 뜨기를 못 하면(샘플링 조건 없음 · 끊김) 새 자리는 + * 종전처럼 [저장] 뒤에 섬. + */ +export async function withDraftStations( + detail: SectionDetailResponse, + projectId: string, + routeId: number, +): Promise { + const turn = (turns.get(detail) ?? 0) + 1; + turns.set(detail, turn); + const pipesDraft = readPendingPipes(projectId); + const structuresDraft = readPendingStructures(projectId); + const all = [ + ...detail.cross_sections.filter((section) => !section.preview), + ...(parked.get(detail) ?? []), + ].sort(byChainage); + let live = all; + let stale: CrossSection[] = []; + let fresh: CrossSection[] = []; + if (pipesDraft || structuresDraft) { + const inputs = await Promise.all([ + pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes), + structuresDraft ?? savedOf(projectId).then(([, structures]) => structures), + fetchStructureTypes(true), + ]).catch(() => null); + if (inputs) { + const [pipes, structures, types] = inputs; + const planted = plantedChainages(pipes, structures, types); + const isStale = (section: CrossSection): boolean => + section.kind === "irregular" && + !section.grid && + planted.every((at) => !near(at, section.chainage_m)); + stale = all.filter(isStale); + live = all.filter((section) => !isStale(section)); + const missing = planted.filter( + (at, index) => + planted.findIndex((other) => near(other, at)) === index && + live.every((section) => !near(section.chainage_m, at)), + ); + fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : []; + if (fresh.length) decorate(fresh, pipes, structures, types); + } + } + if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음 + detail.cross_sections.splice( + 0, + detail.cross_sections.length, + ...[...live, ...fresh].sort(byChainage), + ); + parked.set(detail, stale); +} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index d194c8c29..38b9ddbf6 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -40,6 +40,12 @@ import { type PavementStructureInput, } from "@util/common_util_structure_pavement"; import { setGeneralStations } from "../common_util/common_util_structure_station"; +import { + attachUnderdrainSpecs, + underdrainSpecsFrom, + type UnderdrainInput, +} from "../common_util/common_util_structure_underdrain"; +import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; @@ -63,14 +69,21 @@ function keyOf(projectId: string, routeId: number): string { async function withDraftWalls( detail: SectionDetailResponse, projectId: string, + routeId: number, ): Promise { setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1) + await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2) const drafts = readPendingStructures(projectId); // ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이 // 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도 // 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인). await withDraftPipeKinds(detail, projectId); if (!drafts || !drafts.length) return withStructureAreas(detail); + // 맹암거(288-1) — 초안 기준 측점으로 제원을 다시 얹음(짝 `common_util_structure_underdrain`) + attachUnderdrainSpecs( + detail.cross_sections as unknown as Array>, + underdrainSpecsFrom(drafts as unknown as UnderdrainInput[]), + ); const types = await fetchStructureTypes().catch(() => []); // 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`) const paveTypes = new Set(types.filter((type) => type.group === "G").map((type) => type.type_id)); @@ -144,8 +157,8 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri 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관경 · 물넘이 = 없음) - // 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점(물넘이로 지웠다 되돌린 때) + // 카드 제목 구조물 이름 — 서버 `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; @@ -153,8 +166,7 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri 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") + 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; } @@ -185,21 +197,21 @@ export async function loadSectionDetail( const key = keyOf(projectId, routeId); if (!options?.force) { const cached = cache.get(key); - if (cached) return withDraftWalls(cached, projectId); + if (cached) return withDraftWalls(cached, projectId, routeId); const inFlight = pending.get(key); if (inFlight) return inFlight; // 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다. const stored = readState("section-detail", projectId, routeId); if (stored) { cache.set(key, stored); - return withDraftWalls(stored, projectId); + return withDraftWalls(stored, projectId, routeId); } } const request = fetchSectionDetail(projectId, routeId) .then(async (detail) => { cache.set(key, detail); writeState("section-detail", detail, projectId, routeId); - return withDraftWalls(detail, projectId); + return withDraftWalls(detail, projectId, routeId); }) .finally(() => { pending.delete(key); @@ -224,6 +236,7 @@ export function replaceSectionDetail( * 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다. */ export function invalidateSectionDetail(projectId: string, routeId?: number): void { + forgetDraftStations(projectId); if (routeId !== undefined) { cache.delete(keyOf(projectId, routeId)); clearState("section-detail", projectId, routeId); diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index e194f2270..8200b71a0 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -51,6 +51,13 @@ const EXTRA_ROWS = [ const CHUTE_BOX = 'fieldset[data-spec-container="chute_add"], fieldset[data-spec-container="chute"]'; +/** 물넘이포장 「경사각도」 상자 — 콘크리트 형상 고름이 켬(294-2 · 「관」 상자가 없는 시설). */ +const SLOPE_BOX = 'fieldset[data-spec-container="slope"]'; + +/** 숨지 않은(지금 보이는 명세의) 상자 하나. */ +const visibleBox = (selector: string): HTMLElement | undefined => + [...document.querySelectorAll(selector)].find((box) => !box.closest("[hidden]")); + /** 연동을 푼 옆 카드의 「이 측점 높이」 줄 — 어느 칸이든 받음(152-2). */ const OWN_HEIGHT_ROW = ".b06-structure-panel__struct--own-height"; @@ -101,13 +108,17 @@ function slotOf(side: "inlet" | "outlet"): HTMLElement { * 나오지 않는다(2026-08-29 사용자 보고: 단 수·옵션이 사라짐). */ const movedRows: Array<{ row: HTMLElement; anchor: Comment }> = []; -/** 그룹 이름표 — 원래 문구를 한 번만 적어 두고, 선택에 따라 괄호 표기를 붙였다 뗀다. */ +/** 그룹 이름표 — 원래 문구를 한 번만 적어 두고, 선택에 따라 괄호 표기를 붙였다 뗀다. + * 글 칸(첫 글 마디)만 고침 — 이름표에 붙은 [삭제] 단추는 그대로(294-1). */ function setGroupLabel(group: HTMLElement, suffix: string): void { const legend = group.querySelector("legend"); if (!legend || legend.dataset.fixedLabel) return; - legend.dataset.baseLabel ??= legend.textContent ?? ""; + const text = + [...legend.childNodes].find((node) => node.nodeType === Node.TEXT_NODE) ?? + legend.insertBefore(document.createTextNode(""), legend.firstChild); + legend.dataset.baseLabel ??= text.textContent ?? ""; const base = legend.dataset.baseLabel; - legend.textContent = suffix ? `${base} (${suffix})` : base; + text.textContent = suffix ? `${base} (${suffix})` : base; } /** 옮겨 온 행을 **원래 자리로 되돌리고** 슬롯을 비운다. 그룹 강조도 끈다. */ @@ -163,14 +174,12 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { clearSideSlots(); const side = panelRoot.dataset.side; // 세월교 · BOX 본체 = 옮길 줄 없이 그 명세의 본체 상자만 강조(66-1) — 날개벽 칸이 든 명세 폼에서 찾음 - // 관 = 배관 명세 폼의 「관」 상자만 강조(118-1) + // 관 = 배관 명세 폼의 「관」 상자만 강조(118-1) · 물넘이포장 콘크리트 = 「경사각도」 상자(294-2) // 도수로 띠 = 보이는 명세의 「도수로/돌붙임」 상자(배관 · 박스 · 세월교 chute_add · 물넘이 chute · 152-4) if (side === "body" || side === "pipe" || side === "chute") { - const body = + const own = side === "chute" - ? [...document.querySelectorAll(CHUTE_BOX)].find( - (box) => !box.closest("[hidden]"), - ) + ? visibleBox(CHUTE_BOX) : (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet) ?.closest(".b05-structure__spec") ?.querySelector( @@ -178,6 +187,7 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { ? 'fieldset[data-spec-container="pipe"]' : 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]', ); + const body = side === "pipe" && (!own || own.closest("[hidden]")) ? visibleBox(SLOPE_BOX) : own; if (!body || body.closest("[hidden]")) return; setGroupLabel(body, ""); body.classList.add("is-active"); diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index d9e4ff93a..e044cebbb 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -166,6 +166,7 @@ export function appendBoxOverlay( box.chute, x, toDisplayY, + layout.ground, ); // ⑥ 끝 바깥 절 · 성토선 — 세월교 측벽과 같은 체계(79-7). 흙막이 쪽은 기하가 걷어 둠. for (const side of layout.sides) { diff --git a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts index fecab8916..c7014e1b5 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts @@ -16,6 +16,7 @@ * 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다. * ========================================================================== */ +import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch"; import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; import type { BasinLayout, @@ -74,6 +75,8 @@ export interface BoxSideLayout { export interface BoxLayout { box: BoxSet; + /** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */ + ground?: OffsetPoint[]; sides: BoxSideLayout[]; /** 상판 폴리곤(좌·우 표고가 다르면 기울어진다). */ topSlab: OffsetPoint[]; @@ -222,6 +225,7 @@ export function computeBoxLayout( const apronLengthOf = (side: BoxSideLayout): number => side.apron.length ? Math.abs(side.apron[1].offset - side.apron[0].offset) : 0; return { + ground: groundLine(groundSamples), box, sides: [left, right], topSlab: quad(0, barrelTopDrop), diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 6413b563e..226e9014b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -125,6 +125,7 @@ export function appendCulvertOverlay( culvert.chute, x, toDisplayY, + layout.ground, ); for (const segment of layout.outletFill.segments) { // 묻힌 유출 끝 = 3도 수평 절토선(보라 · 면적에도 듦 · 139-4) — 3도 선이 원지반을 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 3e46b469e..0b66c0e2e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -6,6 +6,7 @@ * (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`. * ========================================================================== */ +import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch"; import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; import { revetWallSpec, @@ -575,6 +576,7 @@ export function computeCulvertLayout( const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null; return { + ground: groundLine(groundSamples), inletOptions, culvert, pipe: { inlet: pipeStart, outlet, lengthM, slopePct }, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 8ffe3bed8..1b21ed7cc 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -201,6 +201,8 @@ export interface OutletFillSegment { export interface CulvertLayout { culvert: CulvertSet; + /** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */ + ground?: OffsetPoint[]; pipe: { inlet: OffsetPoint; outlet: OffsetPoint; lengthM: number; slopePct: number }; /** 관 축·법선 단위벡터(그리기가 두께 방향으로 밀 때 쓴다). */ pipeAxis: { axis: OffsetPoint; normal: OffsetPoint }; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 810f0396f..7a3dbac79 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -41,7 +41,8 @@ export interface RevetOffsetControl { * (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다). * 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */ highlightFor: (section: CrossSection) => RevetPick | null; - select: (chainageM: number, key: RevetPick | null) => void; + /** `ownerM` = 폼에 올릴 시설 자리를 누른 쪽이 앎(물넘이 콘크리트 · 294-7) — 없으면 카드로 셈. */ + select: (chainageM: number, key: RevetPick | null, ownerM?: number) => void; /** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */ onSelectionChange?: () => void; /** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */ diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 36575f17f..308d96a8c 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -33,7 +33,7 @@ const HIT_CLASS = "b06-chart__design-hit"; /** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */ export const DESIGN_LINE_ATTR = "data-design-line"; /** 보라 선을 덮는 벽 · 본체 폴리곤. */ -const OCCLUDER_CLASSES = [ +export const OCCLUDER_CLASSES = [ "b06-chart__culvert-revet", "b06-chart__culvert-basin", "b06-chart__ford-slab", @@ -419,7 +419,7 @@ export function attachDesignLinePick(input: { const pieces = visiblePieces(mergeStraightPieces(runs), occluders); if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces)); - // 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김. + // 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1). const firstStructureHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"), ); diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 260405b9b..1cd162b62 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -4,10 +4,11 @@ * * 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 · * 칸 크기가 바뀌면 다시 그림) · - * 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 · - * B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) · - * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) · + * 가운데 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) · * 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`). + * 아래 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 → + * [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) · + * 머리 한 줄 접기(265-1 · 구조물 측점 폄 · 일반 측점 접힘). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). * 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) — * 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3). @@ -16,7 +17,7 @@ import "./B06_Section_UI_Style_Cross_Detail.css"; import { openModal } from "@ui/ui_template_modal"; -import { readState, writeState } from "../A00_Common/b_page_state"; +import { readState } from "../A00_Common/b_page_state"; import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch"; import type { CrossQuantitySheet, @@ -24,27 +25,39 @@ import type { SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { CROSS_PAD, inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common"; -import { DETAIL_TARGETS, highlightDetail, presentKeys } from "./B06_Section_UI_Cross_Detail_Pick"; +import { + DETAIL_TARGETS, + highlightDetail, + outlinePicked, + presentKeys, +} from "./B06_Section_UI_Cross_Detail_Pick"; import { attachSvgZoomPan, type ZoomPanHandle, type ZoomPanState, } from "../A00_Common/b_svg_zoom_pan"; import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; +import { detailHomeView, layerPoints } from "./B06_Section_UI_Cross_Detail_Home"; import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table"; import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; +import { + loadNoteThings, + mountNoteCad, + stationNoLabel, + thingsAt, +} from "./B06_Section_UI_Cross_Detail_Cad"; /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ export interface CrossDetailHost { projectId: string; routeId: () => number | null; detail: () => SectionDetailResponse | null; - /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */ + /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션 + 처음 배율(173-4 · 179-3 · 266-1 · 뷰 `detailChart`). */ chart: ( stationId: string, widthPx: number, heightPx: number, - ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null; } let host: CrossDetailHost | null = null; @@ -52,9 +65,7 @@ export function setCrossDetailHost(next: CrossDetailHost): void { host = next; } -/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */ - -const noteKey = (chainageM: number): string => chainageM.toFixed(2); +/* ── 옛 구조물 글 세션 초안(측점키 0.01 m → 글 · 새 글은 CAD 칸 260) ─────────────── */ /** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */ export function readStructureNotes(projectId: string, routeId: number): Map { @@ -62,26 +73,6 @@ export function readStructureNotes(projectId: string, routeId: number): Map [Number(key), String(text)])); } -function noteOf(section: CrossSection): string { - const routeId = host?.routeId(); - if (host && routeId !== null && routeId !== undefined) { - const draft = readState>("structure-notes", host.projectId, routeId); - const text = draft?.[noteKey(section.chainage_m)]; - if (text !== undefined) return text; - } - return section.design?.structure_note ?? ""; -} - -function writeNote(section: CrossSection, text: string): void { - // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 - if (section.design) section.design.structure_note = text; - const routeId = host?.routeId(); - if (!host || routeId === null || routeId === undefined) return; - const all = readState>("structure-notes", host.projectId, routeId) ?? {}; - all[noteKey(section.chainage_m)] = text; - writeState("structure-notes", all, host.projectId, routeId); -} - /* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */ function cell( @@ -132,18 +123,25 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT if (repick.has(key)) td.dataset.repick = ""; return mark(td, { cell: key, row, col }, isAdded(key)); }; - // 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) + // 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) · 합친 묶음 = 덮는 줄 키 모두(259-3) const nameCell = ( label: string | null, span: { col?: number; row?: number } | undefined, key: string | null, row: string, slot: number, + keys?: Array, ): HTMLElement => { const added = row.startsWith("r"); - const data = { row, label: added ? `${row}:${slot}` : undefined }; + const data = { + row, + label: added ? `${row}:${slot}` : undefined, + keys: keys && keys.length > 1 ? keys.filter(Boolean).join(" ") : undefined, + }; return mark(cell("th", label ?? "", span, key), data, added); }; + const groupKeys = (slots: typeof layout.left, index: number) => + slots.slice(index, index + groupSpan(slots, index)).map((slot) => slot[2]); const table = document.createElement("table"); table.className = "b06-detail__qtable"; const titleRow = table.insertRow(); @@ -158,13 +156,31 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT const row = table.insertRow(); // 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) · 묶음 = 하위 줄만큼(179-4) if (groupL !== null && subL !== null) - row.append(nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)); + row.append( + nameCell( + groupL, + { row: groupSpan(layout.left, index) }, + keyL, + id, + 0, + groupKeys(layout.left, index), + ), + ); else if (groupL !== null) row.append(nameCell(groupL, { col: 2 }, keyL, id, 0)); if (subL !== null) row.append(nameCell(subL, undefined, keyL, id, 0)); row.append(valueCell(keyL, id)); const [groupM, subM, keyM] = layout.middle[index]; if (groupM !== null && subM !== null) - row.append(nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)); + row.append( + nameCell( + groupM, + { row: groupSpan(layout.middle, index) }, + keyM, + id, + 1, + groupKeys(layout.middle, index), + ), + ); else if (groupM !== null) row.append(nameCell(groupM, { col: 2 }, keyM, id, 1)); if (subM !== null) row.append(nameCell(subM, undefined, keyM, id, 1)); row.append(valueCell(keyM, id)); @@ -178,12 +194,14 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT /* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */ /** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 · - * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */ + * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) · + * 처음 · 원복 = 중심점이 칸 가운데 · 그림 전체(가로 = 카드 반폭 266-1 · 세로 = 그 안 그림)(295-2). */ export function attachDetailZoom( clone: SVGSVGElement, axes: CrossAxesOptions | null, initial: ZoomPanState | undefined, onChange: (state: ZoomPanState) => void, + homeScale = 1, ): ZoomPanHandle | null { const layer = clone.querySelector("g.b06-chart__plot-layer"); if (!layer) return null; @@ -203,6 +221,16 @@ export function attachDetailZoom( ); }; follow(initial ?? IDENTITY_VIEW); + const plot = { + x: CROSS_PAD.left, + y: CROSS_PAD.top, + width: width - CROSS_PAD.left - CROSS_PAD.right, + height: height - CROSS_PAD.top - CROSS_PAD.bottom, + }; + const home = + markerLine && Number.isFinite(cx) && Number.isFinite(cy) + ? detailHomeView(layerPoints(layer), [cx, cy], plot, homeScale, 16) + : undefined; return attachSvgZoomPan({ svg: clone, layer, @@ -214,6 +242,8 @@ export function attachDetailZoom( content: { x: -width, y: -height, width: 3 * width, height: 3 * height }, wheelZoom: true, initial, + homeScale, + home, onChange: (state) => { follow(state); onChange(state); @@ -242,7 +272,7 @@ function drawChart( section: CrossSection, widthPx: number, heightPx: number, -): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null { +): { clone: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null { drawing = true; try { const drawn = host?.chart(section.station_id, widthPx, heightPx); @@ -255,7 +285,7 @@ function drawChart( .querySelectorAll(`[clip-path="url(#${id})"]`) .forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`)); }); - return { clone, axes: drawn.axes }; + return { clone, axes: drawn.axes, homeScale: drawn.homeScale }; } finally { drawing = false; } @@ -277,36 +307,80 @@ export function openCrossDetail(section: CrossSection): void { dialogClass: "b06-detail", wide: true, mount(body, close) { - const note = document.createElement("textarea"); - note.className = "ui-input b06-detail__note"; - note.rows = 3; - note.placeholder = "도면 표 위 글 — B07 횡단도 수량표 바로 위에 줄마다 찍힘"; - note.value = noteOf(section); - note.addEventListener("input", () => writeNote(state.section, note.value)); + // 표 위 빈 CAD(260-2) — 높이 고정 · 닫으면 destroy · 머리 한 줄 접기(265-1 · 공용 접기 꼴) · + // 열 때 = 구조물 측점 폄 · 일반 측점 접힘(그 창에서만) · 처음 펼 때 한 번 만듦(접힌 동안 칸 · + // 초안 · [저장] 그대로 — 안 연 칸은 B07 이 같은 짝으로 그림) + const noteBox = document.createElement("section"); + noteBox.className = "b06-detail__notebox ui-collapsible"; + const noteHead = document.createElement("div"); + noteHead.className = "b06-detail__notehead ui-collapsible__title"; + noteHead.textContent = "표 위 CAD"; + const note = document.createElement("div"); + note.className = "b06-detail__note"; + note.style.height = "180px"; + noteBox.append(noteHead, note); + const routeId = host?.routeId(); + const stations = host?.detail()?.longitudinal.stations ?? []; + let noteCad: { destroy: () => void } | null = null; + const openNote = (): void => { + if (noteCad || !host || routeId === null || routeId === undefined) return; + noteCad = mountNoteCad(note, section, () => state.section, { + projectId: host.projectId, + routeId, + station: stationNoLabel(section.chainage_m, inferStationInterval(stations)), + things, + }); + note.querySelector(".cad-host")?.style.setProperty("min-height", "0"); + }; + let touched = false; + noteHead.addEventListener("click", () => { + touched = true; + if (!noteBox.classList.toggle("is-collapsed")) openNote(); + }); + // 접힘으로 시작 → 그 측점에 놓인 구조물이 있으면 폄(사용자가 먼저 누르면 그대로) + noteBox.classList.add("is-collapsed"); + const things = host ? loadNoteThings(host.projectId) : Promise.resolve([]); + void things.then((list) => { + if (touched || current !== state || !thingsAt(list, section.chainage_m).length) return; + noteBox.classList.remove("is-collapsed"); + openNote(); + }); const cad = document.createElement("div"); cad.className = "b06-detail__cad"; const tableBox = document.createElement("div"); tableBox.className = "b06-detail__table"; - body.append(cad, note, tableBox); + // 차례 = 그림 → 표 → CAD 칸(265-1) + body.append(cad, tableBox, noteBox); - // 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) + // 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) · + // 저장된 조각 · 띠 칸 = 그래프에서 고름 + 값 글(259-2) · 고른 칸 묶음 = 바깥 한 줄(259-3) let picked: string | null = null; const showPicked = (): void => { const svg = cad.querySelector("svg"); const present = svg ? presentKeys(svg, state.section.design) : new Set(); - if (picked && !present.has(picked)) picked = null; - tableBox.querySelectorAll("[data-key]").forEach((el) => { - el.classList.toggle("is-picked", el.dataset.key === picked); - el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? "")); + const graph = picked ? tablePick.graph(picked) : null; + if (picked && !graph && !present.has(picked)) picked = null; + tableBox.querySelectorAll("[data-key], [data-cell]").forEach((el) => { + const keys = (el.dataset.keys ?? el.dataset.key ?? el.dataset.cell ?? "").split(" "); + el.classList.toggle("is-picked", picked !== null && keys.includes(picked)); + if (el.dataset.key) + el.toggleAttribute( + "data-empty", + !present.has(el.dataset.key) && !tablePick.graph(el.dataset.key), + ); }); - if (svg) highlightDetail(svg, state.section.design, picked); + outlinePicked(tableBox); + const band = graph && "band" in graph ? graph.band : graph ? null : picked; + if (svg) highlightDetail(svg, state.section.design, band); }; tableBox.addEventListener("click", (event) => { - const el = (event.target as HTMLElement).closest("[data-key]"); - const key = el?.dataset.key; - if (!key || el.hasAttribute("data-empty")) return; + const el = (event.target as HTMLElement).closest("[data-key], [data-cell]"); + const key = el?.dataset.key ?? el?.dataset.cell; + if (!el || !key) return; + if (!tablePick.graph(key) && (!el.dataset.key || el.hasAttribute("data-empty"))) return; picked = picked === key ? null : key; showPicked(); + tablePick.showCell(picked); }); // 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4) @@ -330,7 +404,13 @@ export function openCrossDetail(section: CrossSection): void { else { const { clone } = drawn; axes = drawn.axes; - const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next)); + const handle = attachDetailZoom( + clone, + axes, + zoom, + (next) => (zoom = next), + drawn.homeScale, + ); cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : [])); tablePick.redraw(); // 선 조각 고르기(209-1) } @@ -369,6 +449,7 @@ export function openCrossDetail(section: CrossSection): void { stop: () => { resize.disconnect(); tablePick.detach(); + noteCad?.destroy(); }, }; // 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음 diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts new file mode 100644 index 000000000..f2c9fd852 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -0,0 +1,354 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Cad.ts + * 횡단 [상세] 모달 아래 = 표 위 CAD 칸(260 · 요구 162-21 · 표 아래 접기 265-1). + * + * 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` · + * 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음 + * (`_Engine_Cad_Table.cross_note_cad_entities`). + * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음 + * (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 = + * 「이름 조합 미지정」 · 이어서 좌측 패널 옵션 줄 = `structureOptionLines` · 166-1) · 그 밖 = 빈 도면. + * ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` · + * `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 + * 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`. + * 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. + * ========================================================================== */ + +import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { + fetchStructures, + fetchStructureTypes, + readPendingStructures, +} from "../B05_Profile/B05_Profile_Api_Structures"; +import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon"; +import { + readGroupItems, + readSummaryItem, + summaryLabel, + type SummaryItem, +} from "../common_util/common_util_summary_item"; +import type { CrossNoteCad, CrossSection } from "./B06_Section_Api_Fetch"; +import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const"; + +const noteKey = (chainageM: number): string => chainageM.toFixed(2); + +/** [저장]이 patch 로 싣는 CAD 칸 — 측점(m) → 엔티티 묶음. */ +export function readStructureNoteCads( + projectId: string, + routeId: number, +): Map { + const raw = readState>("structure-note-cads", projectId, routeId); + return new Map(Object.entries(raw ?? {}).map(([key, cad]) => [Number(key), cad])); +} + +function writeNoteCad(section: CrossSection, cad: CrossNoteCad, at: NoteCadPlace): void { + // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 + if (section.design) section.design.structure_note_cad = cad; + const all = readState>( + "structure-note-cads", + at.projectId, + at.routeId, + ); + const next = { ...all, [noteKey(section.chainage_m)]: cad }; + writeState("structure-note-cads", next, at.projectId, at.routeId); +} + +/** 세션 초안 → 저장분 → 없음(null). */ +function noteCadOf(section: CrossSection, at: NoteCadPlace): CrossNoteCad | null { + const draft = readState>( + "structure-note-cads", + at.projectId, + at.routeId, + ); + return draft?.[noteKey(section.chainage_m)] ?? section.design?.structure_note_cad ?? null; +} + +/* ── 엔티티(CAD 저장 꼴 · B07 `_text_entity` · `_circle_entity` 와 같은 꼴) ───────── */ + +const LAYER_ID = "기본"; +const BLANK_LAYER = { id: LAYER_ID, name: "기본", isVisible: true, isLocked: false }; +const FONT = 2.0; +const LINE = 3.0; +/** 옛 글 색 = B07 표 글(`TABLE_LABEL_COLOR`) · 미리 넣음 = 그림처럼 붉은 계열. */ +const NOTE_COLOR = "#e8edf4"; +const CULVERT_COLOR = "#ff7f7f"; + +const newId = (): string => crypto.randomUUID(); + +function textEntity( + label: string, + x: number, + y: number, + color: string, + align: "left" | "center" = "left", +): Record { + return { + id: newId(), + type: "Text", + lineColor: color, + lineWidth: 1, + layerId: LAYER_ID, + shapeData: { + label, + basePoint: { x, y }, + options: { + textDirection: { x: 1, y: 0 }, + textAlign: align, + textColor: color, + fontSize: FONT, + fontFamily: "sans-serif", + }, + }, + }; +} + +/** 옛 글 → 줄마다 Text(B07 `cross_note_lines` 와 같은 줄 나눔 · 빈 줄은 자리만). */ +export function legacyNoteEntities(note: string | undefined): Record[] { + const lines = (note ?? "") + .trim() + .split(/\r?\n/) + .map((line) => line.trimEnd()); + if (!lines.some(Boolean)) return []; + return lines.flatMap((line, index) => + line ? [textEntity(line, 0, -LINE * (index + 0.5), NOTE_COLOR)] : [], + ); +} + +/* ── 미리 넣음(265-2) = 원형 마크(관 주인 측점) + 측점 · 구조물 조합 줄 ─────────────── */ + +/** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */ +const wholeMetres = (value: number | null | undefined): string => + typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : ""; + +/** 측점 이름(B07 `station_no_label` 꼴 · 대문자 NO · 거리는 정수 = 화면 표기). */ +export function stationNoLabel(chainageM: number, intervalM: number): string { + const safe = intervalM > 0 ? intervalM : 1; + let number = Math.floor((chainageM + 1e-6) / safe); + let rest = chainageM - number * safe; + if (rest >= safe - 0.05) { + number += 1; + rest = 0; + } + return Math.abs(rest) < 0.05 ? `NO.${number}` : `NO.${number}+${Math.round(rest)}`; +} + +/** 측점 글 줄 재료 한 건(관 지점 · 구조물 배치) — 이름 = 구조물정의 종류 이름. */ +export interface NoteThing { + name: string; + /** 배관(`facility` pipe) — 원형 마크가 관을 그려 관 본체 조합 줄은 안 씀. */ + pipe: boolean; + /** 놓인 누가거리(관 = 관 자리 · 구조물 = 기준 측점 · 없으면 시작). */ + at: number; + summary_item?: unknown; + group_items?: unknown; + /** 좌측 패널 옵션 줄(166-1 · `structureOptionLines` · B07 짝은 서버 Node 가 같은 함수로 채움). */ + optionLines?: string[]; +} + +/** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */ +const PIPE_BODY_LINK = "grv6lny"; + +/** 그 측점에 놓인 구조물(관 · 구조물 배치 · 기준 측점이 찾는 거리 안) — 구조물 측점 판정 · 글 줄 재료. */ +export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteThing[] => + things.filter((thing) => Math.abs(thing.at - chainageM) < PIPE_MATCH_M); + +/** + * 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 + + * 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 · + * 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만 · 이어서 그 구조물 좌측 패널 옵션 줄(166-1). + */ +export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] { + return thingsAt(things, chainageM).flatMap((thing) => { + const own = readSummaryItem(thing.summary_item); + const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items)); + if (own) slots.unshift(["", own]); + const names = slots + .filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK))) + .map(([, item]) => summaryLabel(item)); + const combos = slots.length + ? [...new Set(names)] + : thing.pipe + ? [] + : [`${thing.name} 조합 미지정`]; + return [...combos, ...(thing.optionLines ?? [])]; + }); +} + +function shape(type: string, shapeData: Record): Record { + return { + id: newId(), + type, + lineColor: CULVERT_COLOR, + lineWidth: 1, + layerId: LAYER_ID, + shapeData, + }; +} + +/** 원형 마크 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) · + * 이웃 측점 = 없음 · 원 안 세 줄(배수관 / Φ관경 / L=관 길이 정수 · 없으면 빈칸) · 지시선 없음(169). */ +function pipeMark(section: CrossSection): Record[] { + const culvert = section.culvert; + if (!culvert || culvert.type !== "pipe" || culvert.body || culvert.hidden_pipe) return []; + if (Math.abs((culvert.chainage_m ?? section.chainage_m) - section.chainage_m) >= PIPE_MATCH_M) + return []; + const [cx, cy, radius] = [6.5, -9.0, 6.5]; + const inside = [ + "배수관", + `Φ${Math.round(culvert.diameter_m * 1000)}m/m`, + `L=${wholeMetres(section.design?.pipe_length_m)}`, + ]; + return [ + shape("Circle", { center: { x: cx, y: cy }, radius }), // 아래 수직 지시선 없음(169) + ...inside.map((line, index) => + textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"), + ), + ]; +} + +/** + * 미리 넣음 — 원형 마크(있으면) + 오른쪽 측점 · 구조물 조합 줄(`lines` = `structureNoteLines`) · + * 마크 없으면 글이 왼쪽 끝부터 · 마크도 줄도 없으면 빈 목록. + */ +export function noteEntities( + section: CrossSection, + station: string, + lines: readonly string[], +): Record[] { + const mark = pipeMark(section); + if (!mark.length && !lines.length) return []; + const x = mark.length ? 15 : 0; + return [ + ...mark, + ...[station, ...lines].map((line, index) => + textEntity(line, x, -LINE * (index + 0.5), CULVERT_COLOR), + ), + ]; +} + +/** 글 줄 재료 — 관(세션 초안 → 정본 `pipe_points.json`) · 구조물(세션 초안 → 정본 `structures.json`) · + * 이름 = 구조물정의 종류 이름(꺼진 종류 포함) · 못 받으면 빈 목록. */ +export async function loadNoteThings(projectId: string): Promise { + try { + const [pipes, structures, types] = await Promise.all([ + readPendingPipes(projectId) ?? + fetchDetailPipePoints(projectId).then((res) => res.pipe_points), + readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures), + fetchStructureTypes(true), + ]); + const byId = new Map(types.map((type) => [type.type_id, type])); + const nameOf = (kind: string): string => byId.get(kind)?.name ?? kind; + const optionLines = (type_id: string, options?: Record): string[] => + structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id)); + const piped = pipes.map((point): NoteThing => { + const kind = point.facility ?? "pipe"; + const { summary_item, group_items } = point; + return { + name: nameOf(kind), + pipe: kind === "pipe", + at: point.chainage_m, + summary_item, + group_items, + optionLines: optionLines(kind, point.options as Record), + }; + }); + const placed = structures.flatMap((item): NoteThing[] => { + const at = item.chainage_m ?? item.start_m; + if (typeof at !== "number" || !Number.isFinite(at)) return []; + const { summary_item, group_items } = item; + return [ + { + name: nameOf(item.type_id), + pipe: false, + at, + summary_item, + group_items, + optionLines: optionLines(item.type_id, item.options), + }, + ]; + }); + return [...piped, ...placed]; + } catch { + return []; + } +} + +/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 미리 넣음(원형 마크 · 구조물 조합 줄) → 빈 도면. + * 옮기거나 넣었으면 초안에 씀. */ +async function firstNoteCad(section: CrossSection, at: NoteCadPlace): Promise { + const stored = noteCadOf(section, at); + if (stored) return stored; + let entities = legacyNoteEntities(section.design?.structure_note); + if (!entities.length) { + const lines = structureNoteLines(await at.things, section.chainage_m); + entities = noteEntities(section, at.station, lines); + } + const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] }; + if (entities.length) writeNoteCad(section, cad, at); + return cad; +} + +/* ── 모달 칸(빈 CAD · 바탕만) ───────────────────────────────────────────── */ + +export interface NoteCadPlace { + projectId: string; + routeId: number; + /** 미리 넣음 첫 줄(`stationNoLabel`). */ + station: string; + /** 글 줄 재료(`loadNoteThings` · 모달이 한 번 받음). */ + things: Promise; +} + +/** + * [상세] 모달 아래 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 · + * 모달을 닫으면 `destroy`. 칸 크기가 바뀌어도 다시 만들지 않음(iframe 이 스스로 맞춤). + */ +export function mountNoteCad( + box: HTMLElement, + first: CrossSection, + section: () => CrossSection, + at: NoteCadPlace, +): { destroy: () => void } { + let closed = false; + const cad = createCadHost({ + title: "표 위 CAD", + onChanged: (dirty) => { + if (!dirty || closed) return; + cad + .requestSave() + .then(({ drawing }) => { + if (!closed) + writeNoteCad(section(), { entities: drawing.entities, layers: drawing.layers }, at); + }) + .catch(() => undefined); + }, + }); + box.append(cad.element); + cad.beginLoading(); + const scope = `b06-detail-note:${at.projectId}:${at.routeId}:${noteKey(first.chainage_m)}`; + void firstNoteCad(first, at).then((drawing) => { + if (closed) return; + const layers = drawing.layers?.length ? drawing.layers : [BLANK_LAYER]; + cad.load( + { entities: drawing.entities, layers }, + null, + false, + {}, + { + recoveryScope: scope, + hostTitle: "표 위 CAD", + bare: true, + }, + ); + }); + return { + destroy: () => { + closed = true; + cad.destroy(); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts index f137535dc..aef35f7bd 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts @@ -5,7 +5,7 @@ * 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py` * (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) · * 거울 시험 `resources/tester/test_209_1_detail_select.py`. - * 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산. + * 나눔(`splitAtCrossings`) · 닫힌 영역(`findFaces` · 259-2)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산. * ========================================================================== */ import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; @@ -16,6 +16,8 @@ export type LineKind = CrossPickPiece["line"]; export interface SplitPiece { line: LineKind; points: Pt[]; + /** 구조물 선 하나(295-3) — 나눈 조각이 같은 번호면 고를 때 한 묶음(닫힌 영역 · 저장은 조각대로). */ + group?: number; } /** 서버 `LINE_TOLERANCE_M` 과 같은 값 — 두 끝이 선에서 이만큼 안이면 「선 위」 · 고리 끝 맞음. */ @@ -44,6 +46,16 @@ export function groundRuns(samples: ReadonlyArray | undefined): P const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) => (raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite)); +/** 암 경계선 — 원지반선 + 오프셋(암 프리셋만 · 카드 `appendRockBoundaryOverlay` · 서버 `rock_runs`). */ +export function rockRuns( + design: CrossDesign | undefined, + samples: ReadonlyArray | undefined, +): Pt[][] { + const offset = design?.rock_boundary_offset_m; + if (design?.geometry_preset !== "rock" || typeof offset !== "number") return []; + return groundRuns(samples).map((run) => run.map(([x, y]): Pt => [x, y + offset])); +} + /** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */ export function pickLines( design: CrossDesign | undefined, @@ -53,6 +65,7 @@ export function pickLines( design: [polyline(design?.design_line)], ground: groundRuns(samples), spoil: [polyline(design?.spoil_fill_line)], + rock: rockRuns(design, samples), }; } @@ -210,12 +223,100 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] { const [c0, c1] = [marks[k], marks[k + 1]]; const inner = pts.slice(Math.floor(c0) + 1, Math.ceil(c1)); const points = [at(c0), ...inner, at(c1)]; - if (pathLength(points) >= MIN_PIECE_M) out.push({ line: line.line, points }); + if (pathLength(points) >= MIN_PIECE_M) + out.push({ + line: line.line, + points, + ...(line.group !== undefined && { group: line.group }), + }); } }); return out; } +/* ── 닫힌 영역(259-2 · 화면 몫) ─────────────────────────────────────────── */ + +/** 나눈 조각으로 둘러싸인 면 하나 — 둘레 조각 번호 · 고리(m) · 넓이(㎡). */ +export interface Face { + edges: number[]; + ring: Pt[]; + area: number; +} + +/** 꺾은선이 그 끝에서 떠나는 방향(라디안) — 아주 짧은 첫 토막은 건너뜀. */ +function leaving(points: Pt[]): number { + const [x0, y0] = points[0]; + const next = points.find((p) => dist(p, points[0]) > 1e-6) ?? points[points.length - 1]; + return Math.atan2(next[1] - y0, next[0] - x0); +} + +/** + * 조각(변)으로 된 평면 그래프의 면 — 끝이 `TOUCH_M` 안이면 같은 마디 · 열린 끝 조각(마디 차수 1)은 + * 거듭 지움(열린 영역 제외) · 각 반변에서 도착 마디의 시계 방향 다음 변으로 돌아 면을 셈 · + * 넓이가 양(반시계)인 것만 = 바깥 면 뺌. + * ponytail: 안에 떠 있는 섬(다리 없음)은 바깥 면의 구멍으로 안 뺌 — 필요하면 포함 판정 더함. + */ +export function findFaces(pieces: SplitPiece[]): Face[] { + const nodes: Pt[] = []; + const nodeOf = (p: Pt): number => { + const found = nodes.findIndex((q) => dist(p, q) <= TOUCH_M); + return found >= 0 ? found : nodes.push(p) - 1; + }; + const ends = pieces.map((piece) => [ + nodeOf(piece.points[0]), + nodeOf(piece.points[piece.points.length - 1]), + ]); + const alive = ends.map(([a, b]) => a !== b); + for (let changed = true; changed;) { + const degree = new Array(nodes.length).fill(0); + ends.forEach(([a, b], i) => alive[i] && (degree[a]++, degree[b]++)); + changed = false; + ends.forEach(([a, b], i) => { + if (alive[i] && (degree[a] < 2 || degree[b] < 2)) [alive[i], changed] = [false, true]; + }); + } + // 반변 h = 2i(앞 → 뒤) · 2i+1(뒤 → 앞) + const path = (h: number): Pt[] => { + const points = pieces[h >> 1].points; + return h & 1 ? [...points].reverse() : points; + }; + const head = (h: number): number => ends[h >> 1][h & 1 ? 0 : 1]; + const out = nodes.map(() => [] as Array<[number, number]>); + ends.forEach(([a, b], i) => { + if (!alive[i]) return; + out[a].push([leaving(path(2 * i)), 2 * i]); + out[b].push([leaving(path(2 * i + 1)), 2 * i + 1]); + }); + out.forEach((list) => list.sort((p, q) => p[0] - q[0])); + const next = (h: number): number => { + const list = out[head(h)]; + const at = list.findIndex(([, e]) => e === (h ^ 1)); + return list[(at - 1 + list.length) % list.length][1]; + }; + const faces: Face[] = []; + const seen = new Set(); + ends.forEach((_, i) => { + if (!alive[i]) return; + for (const start of [2 * i, 2 * i + 1]) { + if (seen.has(start)) continue; + const cycle: number[] = []; + for (let h = start; !seen.has(h) && cycle.length <= 2 * pieces.length; h = next(h)) { + seen.add(h); + cycle.push(h); + } + const ring = cycle.flatMap((h) => path(h).slice(1)); + let twice = 0; + ring.forEach((p, k) => { + const q = ring[(k + 1) % ring.length]; + twice += p[0] * q[1] - q[0] * p[1]; + }); + if (twice / 2 > 1e-9) + faces.push({ edges: [...new Set(cycle.map((h) => h >> 1))], ring, area: twice / 2 }); + } + }); + return faces; +} + /** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */ export function pieceSource(piece: SplitPiece): CrossPickPiece { const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4]; diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Home.ts b/B06_Section/B06_Section_UI_Cross_Detail_Home.ts new file mode 100644 index 000000000..1f98041b0 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Home.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Home.ts + * [상세] 모달 첫 보기(295-2) — 중심점(중심 십자)이 칸 가운데 · 그림 전체가 들어가게 + * 축마다 중심에서 큰 쪽 반폭으로. [⤢] · 더블클릭 원복도 같은 보기. 카드 보기는 그대로. + * + * 가로 반폭 = 카드 반폭(266-1 `homeScale` 이 되좁히던 폭)의 큰 쪽 · 세로 반폭 = 그 가로 안에 + * 그려진 꼭짓점이 중심에서 위 · 아래로 먼 쪽. + * ========================================================================== */ + +import type { ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; + +type Pt = [number, number]; +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** 첫 보기 배율 · 이동 — 중심 `center` 를 `plot` 가운데에. `homeScale` = 1배 대비 카드 반폭 배율. */ +export function detailHomeView( + points: Pt[], + center: Pt, + plot: Rect, + homeScale: number, + maxScale: number, +): ZoomPanState { + const [cx, cy] = center; + const halfX = Math.max(cx - plot.x, plot.x + plot.width - cx) / Math.max(homeScale, 1e-6); + const ys = points.filter(([x]) => Math.abs(x - cx) <= halfX).map(([, y]) => y); + const halfY = Math.max(1, ...ys.map((y) => Math.abs(y - cy))); + const scale = Math.min(plot.width / 2 / halfX, plot.height / 2 / halfY, maxScale); + return { + scale, + tx: plot.x + plot.width / 2 - scale * cx, + ty: plot.y + plot.height / 2 - scale * cy, + }; +} + +/** 그림 층의 꺾은선 · 다각형 꼭짓점(그림 px). */ +export function layerPoints(layer: Element): Pt[] { + return [...layer.querySelectorAll("polyline, polygon")].flatMap((el) => + (el.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)) + .filter((p): p is Pt => p.length === 2 && p.every(Number.isFinite)), + ); +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts index 01f3415c9..f76ffb8cf 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts @@ -6,6 +6,7 @@ * (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려 * `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리. * 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖. + * 고른 칸 묶음 = 바깥 한 줄(`outlinePicked` · 259-3). * ponytail: 판정 선의 구조물 트림 여부는 복제에서 모름 → 자리로만 가름(트림 선은 놓인 구역을 따름). * ========================================================================== */ @@ -75,3 +76,48 @@ export function presentKeys(svg: Element, design: CrossDesign | undefined): Set< Object.keys(DETAIL_TARGETS).filter((key) => targetsOf(svg, design, key).length > 0), ); } + +/* ── 표 강조 바깥 한 줄(259-3) ──────────────────────────────────────────── */ + +/** 직사각형들(좌 · 위 · 우 · 아래)의 합친 모양 바깥 줄 — 격자로 나눠 덮인 칸과 안 덮인 칸 사이 변만(SVG path). */ +export function unionOutline(rects: number[][]): string { + const xs = [...new Set(rects.flatMap((r) => [r[0], r[2]]))].sort((a, b) => a - b); + const ys = [...new Set(rects.flatMap((r) => [r[1], r[3]]))].sort((a, b) => a - b); + const covered = (i: number, j: number): boolean => + i >= 0 && + j >= 0 && + i + 1 < xs.length && + j + 1 < ys.length && + rects.some((r) => r[0] <= xs[i] && xs[i + 1] <= r[2] && r[1] <= ys[j] && ys[j + 1] <= r[3]); + const parts: string[] = []; + for (let i = 0; i + 1 < xs.length; i++) + for (let j = 0; j + 1 < ys.length; j++) { + if (!covered(i, j)) continue; + const [x0, x1, y0, y1] = [xs[i], xs[i + 1], ys[j], ys[j + 1]]; + if (!covered(i, j - 1)) parts.push(`M${x0} ${y0}H${x1}`); + if (!covered(i, j + 1)) parts.push(`M${x0} ${y1}H${x1}`); + if (!covered(i - 1, j)) parts.push(`M${x0} ${y0}V${y1}`); + if (!covered(i + 1, j)) parts.push(`M${x1} ${y0}V${y1}`); + } + return parts.join(""); +} + +/** 고른 칸(`.is-picked`) 묶음에 바깥 한 줄 — 칸마다 테두리를 그리면 붙은 칸에 겹선이 생김. */ +export function outlinePicked(box: HTMLElement): void { + box.querySelector(".b06-detail__pick-outline")?.remove(); + const cells = [...box.querySelectorAll(".is-picked")]; + if (!cells.length) return; + const base = box.getBoundingClientRect(); + const rects = cells.map((cell) => { + const r = cell.getBoundingClientRect(); + return [r.left - base.left, r.top - base.top, r.right - base.left, r.bottom - base.top].map( + (v) => Math.round(v), + ); + }); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "b06-detail__pick-outline"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", unionOutline(rects)); + svg.append(path); + box.append(svg); +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index 2a5aca824..16b25c7a7 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -1,9 +1,14 @@ /* ============================================================================= * B06_Section_UI_Cross_Detail_Select.ts - * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로 + * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로 * 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 · * Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴): - * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」). + * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」). + * 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각) + * 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움. + * 누름 판정(261-3) = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 보통 배율 얇은 띠도 영역 · + * 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘). + * 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`). * 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀). * ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때. * ========================================================================== */ @@ -14,19 +19,27 @@ import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; import { DESIGN_LINE_ATTR, mergeStraightPieces, + OCCLUDER_CLASSES, pieceLabel, } from "./B06_Section_UI_Cross_Design_Pick"; import { + findFaces, + LINE_TOLERANCE_M, pathLength, pieceSource, ringArea, splitAtCrossings, + type Face, type Pt, type SplitPiece, } from "./B06_Section_UI_Cross_Detail_Geom"; const SVG_NS = "http://www.w3.org/2000/svg"; export const PIECE_HIT_CLASS = "b06-detail__piece"; +/** 구조물 선 묶음 걸침 표시(295-3) — 조각 판정 선 위 · 누름은 거리 판정이라 걸침만. */ +export const PIECE_GROUP_CLASS = "b06-detail__piece-group"; +/** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */ +export const FACE_HIT_CLASS = "b06-detail__face"; /** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */ export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt { @@ -39,12 +52,13 @@ export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt { }; } -function mToPx(axes: CrossAxesOptions): (p: Pt) => string { +/** m → 그림 px(`join` 하면 `x,y`). */ +function mToPx(axes: CrossAxesOptions): (p: Pt) => Pt { return ([offset, elevation]) => { const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration; const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter; const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter; - return `${x},${y}`; + return [x, y]; }; } @@ -76,16 +90,26 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli points: points.map(([offset, elevation]) => ({ offset, elevation })), structure: !el.hasAttribute(DESIGN_LINE_ATTR), }); + else if (OCCLUDER_CLASSES.some((name) => el.classList.contains(name))) + // 벽 · 집수정 · 본체 둘레(261-3) — 설계선 끝 · 비탈 밑을 잇는 구조물 선(닫힌 고리) + runs.push({ + points: [...points, points[0]].map(([offset, elevation]) => ({ offset, elevation })), + structure: true, + }); else if (el.classList.contains("b06-chart__cross-profile")) others.push({ line: "ground", points }); else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points }); + else if (el.classList.contains("b06-chart__rock-boundary")) + others.push({ line: "rock", points }); } - const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({ + // 구조물 선(295-3) = 다른 선을 나누는 기준은 그대로 · 자기 조각은 한 묶음(고를 때 통째) + const straight = mergeStraightPieces(runs).map((piece, index): SplitPiece => ({ line: piece.structure ? "structure" : "design", points: [ [piece.from.offset, piece.from.elevation], [piece.to.offset, piece.to.elevation], ], + ...(piece.structure && { group: index }), })); return splitAtCrossings([...straight, ...others]); } @@ -97,36 +121,93 @@ export function toggleSelection(selected: number[], index: number, additive: boo return selected.length === 1 && selected[0] === index ? [] : [index]; } +/** 묶음 고름 바꿈(295-3) — 구조물 선 조각 묶음을 한 몸으로 넣고 빼기 · 하나면 `toggleSelection` 과 같음. */ +export function toggleMembers(selected: number[], members: number[], additive: boolean): number[] { + const all = members.every((m) => selected.includes(m)); + if (additive) + return all + ? selected.filter((i) => !members.includes(i)) + : [...selected, ...members.filter((m) => !selected.includes(m))]; + const same = all && selected.length === members.length; + return same ? [] : [...members]; +} + +/** 같은 묶음 조각 번호 — 묶음 없으면 그 하나. */ +export function groupMembers(pieces: SplitPiece[], index: number): number[] { + const group = pieces[index]?.group; + if (group === undefined) return [index]; + return pieces.flatMap((piece, i) => (piece.group === group ? [i] : [])); +} + const LINE_NAME: Record = { design: "설계선", structure: "구조물 선", ground: "원지반선", spoil: "사토장선", + rock: "암 경계선", }; /** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPiece[]): string { if (!pieces.length) return ""; + // 구조물 선 한 묶음(295-3) = 선 하나 — 첫 조각 앞 끝 ~ 끝 조각 뒤 끝 + const group = pieces[0].group; + if (pieces.length > 1 && group !== undefined && pieces.every((p) => p.group === group)) + pieces = [{ ...pieces[0], points: [pieces[0].points[0], pieces.at(-1)!.points.at(-1)!] }]; if (pieces.length === 1) { const [piece] = pieces; const from = piece.points[0]; const to = piece.points[piece.points.length - 1]; - if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil") + if ( + design && + piece.points.length === 2 && + (piece.line === "design" || piece.line === "structure") + ) return pieceLabel(design, { from: { offset: from[0], elevation: from[1] }, to: { offset: to[0], elevation: to[1] }, structure: piece.line === "structure", }); + const horizontal = Math.abs(to[0] - from[0]); + const height = Math.abs(to[1] - from[1]); + const grade = horizontal > 0 ? `${((height / horizontal) * 100).toFixed(1)}%` : "수직"; return ( - `${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` + - `수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m` + `${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m · 경사비율 ${grade}\n` + + `수평 ${horizontal.toFixed(2)}m · 높이 ${height.toFixed(2)}m` ); } const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0); const area = ringArea(pieces.map((piece) => piece.points)); + const units = new Set(pieces.map((piece) => piece.group ?? piece)).size; return ( - `${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` + - (area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`) + `${units}개 · 길이 합 ${total.toFixed(2)}m\n` + + (area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`) + ); +} + +/** 고른 영역 값 두 줄(259-2) — 넓이 합 / 둘레(고른 둘레 조각 길이 합 · 붙은 영역은 맞닿은 변 뺌). */ +export function regionLabel(faces: Face[], pieces: SplitPiece[]): string { + const area = faces.reduce((sum, face) => sum + face.area, 0); + const around = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0); + const count = faces.length > 1 ? `${faces.length}개 · ` : ""; + return `영역 ${count}면적 ${area.toFixed(2)}㎡\n둘레 ${around.toFixed(2)}m`; +} + +/** 영역 여럿 → 둘레 조각 — 둘 이상이 맞닿은 변은 짝수 번 나와 빠짐. */ +export function regionEdges(faces: Face[]): number[] { + const count = new Map(); + for (const face of faces) + for (const edge of face.edges) count.set(edge, (count.get(edge) ?? 0) + 1); + return [...count].filter(([, n]) => n % 2).map(([edge]) => edge); +} + +/** 그림 조각이 저장된 조각(두 끝 · 어느 방향이든)과 같은 자리인가(259-2 표 칸 → 그래프). */ +export function samePiece(piece: SplitPiece, stored: CrossPickPiece): boolean { + if (piece.line !== stored.line) return false; + const near = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M; + const [a, b] = [piece.points[0], piece.points[piece.points.length - 1]]; + return ( + (near(a, stored.start) && near(b, stored.end)) || (near(a, stored.end) && near(b, stored.start)) ); } @@ -149,9 +230,85 @@ export interface DetailSelect { setMode: (kind: "area" | "length" | null) => void; source: (kind: "area" | "length") => ReturnType; clear: () => void; + /** 저장된 조각을 그림에서 고름(259-2 표 칸 누름) · null = 비움. */ + choose: (stored: CrossPickPiece[] | null) => void; + /** 고름 없이 값 글만(띠 · 259-2) — 다음 누름에 지워짐. */ + note: (text: string | null) => void; detach: () => void; } +/** 선 누름 거리(화면 px) — 이 안이면 선 · 영역 안이면 맞은편 선까지의 ⅓ 안이어야 선(261-3). */ +export const LINE_PICK_PX = 4; + +const segmentGap = (p: Pt, a: Pt, b: Pt): number => { + const d = [b[0] - a[0], b[1] - a[1]]; + const size2 = d[0] ** 2 + d[1] ** 2; + const t = size2 + ? Math.max(0, Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / size2)) + : 0; + return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]); +}; +const pathGap = (p: Pt, path: Pt[]): number => + Math.min(...path.slice(1).map((q, i) => segmentGap(p, path[i], q))); +const inside = (p: Pt, ring: Pt[]): boolean => + ring.reduce((inn, a, i) => { + const b = ring[(i + 1) % ring.length]; + return a[1] > p[1] !== b[1] > p[1] && + p[0] < a[0] + ((p[1] - a[1]) * (b[0] - a[0])) / (b[1] - a[1]) + ? !inn + : inn; + }, false); + +/** + * 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이. + * 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다 + * 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역. + * 고르기 차례(295-1 · 카드와 같은 잣대) = 선 > 작은 면 > 큰 면 · 띠 — `bandArea`(그 자리 면적 띠 + * 넓이 · 그림 px²)가 영역보다 작을 때만 띠(표가 맡음). + */ +export function pickAt( + point: Pt, + scale: number, + paths: Pt[][], + rings: Pt[][], + areas: number[], + pieces: SplitPiece[], + bandArea: number | null = null, +): { piece: number } | { face: number } | { band: true } | null { + const gaps = paths.map((path) => pathGap(point, path) * scale); + const nearest = gaps.reduce((best, gap, i) => (gap < gaps[best] ? i : best), 0); + const ends = (piece: SplitPiece): Pt[] => [ + piece.points[0], + piece.points[piece.points.length - 1], + ]; + const touches = (a: SplitPiece, b: SplitPiece): boolean => + ends(a).some((p) => + ends(b).some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= LINE_TOLERANCE_M), + ); + const across = Math.min( + Infinity, + ...gaps.filter((_, i) => i !== nearest && !touches(pieces[i], pieces[nearest])), + ); + const face = rings + .flatMap((ring, i) => (inside(point, ring) ? [i] : [])) + .sort((a, b) => areas[a] - areas[b])[0]; + if ( + paths.length && + gaps[nearest] <= LINE_PICK_PX && + (face === undefined || gaps[nearest] * 2 <= across) + ) + return { piece: nearest }; + const faceArea = face === undefined ? Infinity : Math.abs(shoelace(rings[face])); + if (bandArea !== null && bandArea < faceArea) return { band: true }; + return face === undefined ? null : { face }; +} + +const shoelace = (ring: Pt[]): number => + ring.reduce((sum, [x0, y0], i) => { + const [x1, y1] = ring[(i + 1) % ring.length]; + return sum + (x0 * y1 - x1 * y0) / 2; + }, 0); + /** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */ const pieceKey = (piece: SplitPiece | undefined): string => piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : ""; @@ -164,11 +321,22 @@ export function attachDetailSelect(input: { picked: (additive: boolean) => void; /** [칸에 넣기] 단추. */ commit: () => void; + /** 면적 고르기 중 그 자리 면적 띠 넓이(그림 px²) · 없음 = null — 영역보다 작을 때만 띠(295-1). */ + band?: (event: MouseEvent) => number | null; }): DetailSelect { const { cad } = input; let pieces: SplitPiece[] = []; let hits: SVGPolylineElement[] = []; + let faces: Face[] = []; + let faceEls: SVGPolygonElement[] = []; + /** 그림 층 · 조각 · 영역 고리(그림 px) — 누름 판정. */ + let layer: SVGGElement | null = null; + let paths: Pt[][] = []; + let rings: Pt[][] = []; let selected: number[] = []; + /** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */ + let chosenFaces: number[] = []; + let noteText: string | null = null; let mode: "area" | "length" | null = null; const label = document.createElement("span"); @@ -186,26 +354,62 @@ export function attachDetailSelect(input: { const show = (): void => { const chosen = selected.map((i) => pieces[i]); hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i))); - label.textContent = selectionLabel(input.design(), chosen); - label.hidden = !chosen.length; + const same = (edges: number[]) => + edges.length === selected.length && edges.every((edge) => selected.includes(edge)); + const filled = chosenFaces.length + ? chosenFaces + : faces.flatMap((face, i) => (same(face.edges) ? [i] : [])); + faceEls.forEach((el, i) => el.classList.toggle("is-selected", filled.includes(i))); + label.textContent = + noteText ?? + (filled.length + ? regionLabel( + filled.map((i) => faces[i]), + chosen, + ) + : selectionLabel(input.design(), chosen)); + label.hidden = !label.textContent; put.hidden = !mode || !selectionSource(mode, chosen); // [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김 const bar = cad.querySelector(".b06-cross-card__zoom"); if (bar && label.parentElement !== bar) bar.prepend(label, put); }; + /** 누른 자리(그림 안만) → 선 · 영역 — 판정 선 굵기 대신 거리로(261-3). */ + const pickOf = (event: MouseEvent): ReturnType => { + const ctm = layer?.getScreenCTM(); + if (!ctm || !layer?.ownerSVGElement?.contains(event.target as Node)) return null; + const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(ctm.inverse()); + const areas = faces.map((face) => face.area); + const band = mode === "area" ? (input.band?.(event) ?? null) : null; + return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces, band); + }; + const onClick = (event: MouseEvent): void => { - const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`); - const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area"; - if (!hit) { - if (!additive && !mode && selected.length) { - selected = []; + const pick = pickOf(event); + const shift = event.shiftKey || event.ctrlKey || event.metaKey; + const additive = shift || mode === "area"; + noteText = null; + if (!pick || !("piece" in pick)) { + if (pick && "face" in pick) { + // 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) · + // 띠보다 좁은 영역이라 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬(295-1) + event.stopImmediatePropagation(); + const index = pick.face; + chosenFaces = toggleSelection(chosenFaces, index, shift); + selected = regionEdges(chosenFaces.map((i) => faces[i])); show(); + input.picked(true); + return; } + if (!additive && !mode) [selected, chosenFaces] = [[], []]; + show(); return; } - event.stopPropagation(); - selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive); + // 선이 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬 + event.stopImmediatePropagation(); + chosenFaces = []; + selected = toggleMembers(selected, groupMembers(pieces, pick.piece), additive); show(); input.picked(additive); }; @@ -216,19 +420,42 @@ export function attachDetailSelect(input: { // 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2) const keep = new Set(selected.map((i) => pieceKey(pieces[i]))); const axes = input.axes(); - const layer = cad.querySelector("g.b06-chart__plot-layer"); + layer = cad.querySelector("g.b06-chart__plot-layer"); pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : []; selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : [])); + chosenFaces = []; + faces = findFaces(pieces); const toPx = axes ? mToPx(axes) : null; - hits = toPx - ? pieces.map((piece) => { - const hit = document.createElementNS(SVG_NS, "polyline"); - hit.setAttribute("points", piece.points.map(toPx).join(" ")); - hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`); - layer?.append(hit); - return hit; - }) - : []; + paths = toPx ? pieces.map((piece) => piece.points.map(toPx)) : []; + rings = toPx ? faces.map((face) => face.ring.map(toPx)) : []; + // 영역 판정 면 먼저(밑) → 조각 판정 선(위) + faceEls = rings.map((ring) => { + const el = document.createElementNS(SVG_NS, "polygon"); + el.setAttribute("points", ring.join(" ")); + el.setAttribute("class", FACE_HIT_CLASS); + layer?.append(el); + return el; + }); + hits = paths.map((path, i) => { + const hit = document.createElementNS(SVG_NS, "polyline"); + hit.setAttribute("points", path.join(" ")); + hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${pieces[i].line}`); + layer?.append(hit); + return hit; + }); + // 구조물 선 묶음 걸침 표시(295-3) — 조각 대신 선 하나로 비침 + new Set(pieces.flatMap((piece) => (piece.group === undefined ? [] : [piece.group]))).forEach( + (group) => { + const members = pieces.flatMap((piece, i) => (piece.group === group ? [i] : [])); + const el = document.createElementNS(SVG_NS, "polyline"); + el.setAttribute( + "points", + [paths[members[0]][0], paths[members.at(-1)!].at(-1)!].join(" "), + ); + el.setAttribute("class", PIECE_GROUP_CLASS); + layer?.append(el); + }, + ); show(); }, setMode(kind) { @@ -241,7 +468,19 @@ export function attachDetailSelect(input: { selected.map((i) => pieces[i]), ), clear() { - selected = []; + [selected, chosenFaces] = [[], []]; + show(); + }, + choose(stored) { + chosenFaces = []; + noteText = null; + selected = stored + ? pieces.flatMap((piece, i) => (stored.some((s) => samePiece(piece, s)) ? [i] : [])) + : []; + show(); + }, + note(text) { + noteText = text; show(); }, detach() { diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts index 24bf4203d..690908d7c 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -9,6 +9,8 @@ * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) — * 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록. * 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기]. + * 닫힌 영역 안 누름 = 그 영역 둘레 조각(259-2) · 면적 고르기 중 띠 자리는 띠가 앞. + * 표 칸 누름(259-2) = 저장된 조각을 그림에서 다시 고름 · 띠 칸 = 값 글(`cellGraph` · `bandLabel`). * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; @@ -19,10 +21,11 @@ import type { CrossDetailTable, CrossSection, } from "./B06_Section_Api_Fetch"; -import type { SectionSample } from "./B06_Section_Api_Types"; +import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom"; -import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select"; +import { attachDetailSelect } from "./B06_Section_UI_Cross_Detail_Select"; +import { pointsArea } from "./B06_Section_UI_Cross_Pick_Order"; /** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ export const MAX_ROWS = 12; @@ -173,6 +176,32 @@ export function areaSourceOf(target: Element): CrossCellSource | null { return band ? { src: "area", key: band[1] } : null; } +/** 그 칸이 그래프에서 무엇을 가리키나(259-2) — 저장된 조각 · 띠 키(저장 띠 출처 → 칸 키) · 없음. */ +export function cellGraph( + table: CrossDetailTable, + key: string, +): { pieces: CrossPickPiece[] } | { band: string } | null { + const source = table.cells[key]; + if (source && "pieces" in source) return { pieces: source.pieces }; + if (source && source.src === "area" && "key" in source) return { band: source.key }; + return null; +} + +const BAND_NAME: Readonly> = { + cut_soil: "깍기(토사)", + cut_rock: "깍기(암석)", + ditch_soil: "측구(토사)", + ditch_rock: "측구(암석)", + embankment: "쌓기", + spoil_fill: "사토장", +}; + +/** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */ +export function bandLabel(key: string, design: CrossDesign | undefined): string | null { + const value = BAND_NAME[key] ? localCellValue({ src: "area", key }, design) : null; + return value === null ? null : `${BAND_NAME[key]} 면적 ${value.toFixed(2)}㎡`; +} + /* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */ const AREA_DESIGN: Readonly> = { @@ -280,7 +309,7 @@ export interface DetailTableHost { let menu: ReturnType | null = null; const PICK_HINT = { - area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소", + area: "면적 띠를 누름 · 또는 닫힌 영역 안 · 선들을 누름 → Enter · Esc 취소", length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소", } as const; @@ -289,6 +318,10 @@ const PICK_HINT = { export function attachDetailTable(host: DetailTableHost): { detach: () => void; redraw: () => void; + /** 칸 그래프 짝(259-2) — 저장된 조각 · 띠 키 · 없음. */ + graph: (key: string) => ReturnType; + /** 표 칸 누름(259-2) — 저장된 조각을 그림에서 고름 · 띠면 값 글 · null = 비움. */ + showCell: (key: string | null) => void; } { menu ??= createMapContextMenu("b06-detail"); const ctxMenu = menu; @@ -323,6 +356,7 @@ export function attachDetailTable(host: DetailTableHost): { const shown = localCellValue(source, section.design, section.samples); const td = tableBox.querySelector(`td[data-cell="${CSS.escape(key)}"]`); if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2); + if ("key" in source) select.note(bandLabel(source.key, section.design)); // 띠도 값 글(259-2) edit((table) => setCell(table, key, source)); }; const commitSelection = (): void => { @@ -354,14 +388,19 @@ export function attachDetailTable(host: DetailTableHost): { if (picking?.kind === "length" && !additive) commitSelection(); }, commit: commitSelection, + band: (event) => { + const hit = bandAt(event); + return hit ? pointsArea(hit.getAttribute("points")) : null; + }, }); - // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음 - const onPick = (event: MouseEvent): void => { - if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`)) - return; - const hit = document + const bandAt = (event: MouseEvent): Element | undefined => + document .elementsFromPoint(event.clientX, event.clientY) .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); + // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4) — 선 · 띠보다 좁은 영역은 위가 막음(261-3 거리 판정 · 295-1) + const onPick = (event: MouseEvent): void => { + if (picking?.kind !== "area") return; + const hit = bandAt(event); const source = hit ? areaSourceOf(hit) : null; if (!source) return; event.stopPropagation(); @@ -426,5 +465,15 @@ export function attachDetailTable(host: DetailTableHost): { tableBox.removeEventListener("keydown", onEnter); }, redraw: () => select.redraw(), + graph: (key) => cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key), + showCell: (key) => { + const target = key + ? cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key) + : null; + select.choose(target && "pieces" in target ? target.pieces : null); + // 저장 조각 = 조각 값 글 · 저장 띠 = 그 띠 · 저장 없음 = 칸 키 띠 + const band = !target ? key : "band" in target ? target.band : null; + select.note(band ? bandLabel(band, host.section().design) : null); + }, }; } diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index fde0f514b..62082530c 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -144,6 +144,7 @@ export function appendFordOverlay( ford.chute, x, toDisplayY, + layout.ground, ); // ④ 계류측 마감선 — 구조물이 원지반 위면 성토부선, 안이면 절토선(집수정과 같은 체계). for (const side of layout.sides) { diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts index 5c4bfb605..b3ba4642f 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts @@ -19,6 +19,7 @@ * 성토측이라도 기슭막이 세트(다단·재질)와는 연계하지 않는다. * ========================================================================== */ +import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch"; import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch"; import { GUARD_CUT_BERM_M, pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const"; import type { @@ -108,6 +109,8 @@ export interface FordSideLayout { export interface FordLayout { ford: FordSet; + /** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */ + ground?: OffsetPoint[]; sides: FordSideLayout[]; /** 두 측벽 사이를 잇는 바닥판. */ slabBridge: OffsetPoint[]; @@ -329,6 +332,7 @@ export function computeFordLayout( const countText = ford.pipe_count > 1 ? ` ${ford.pipe_count}련` : ""; return { + ground: groundLine(groundSamples), ford, sides, slabBridge, diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Linked.ts b/B06_Section/B06_Section_UI_Cross_Ford_Linked.ts new file mode 100644 index 000000000..36c23aa89 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Ford_Linked.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Ford_Linked.ts + * 물넘이포장 연결 표기(294-6) — 월류 폭으로 옆 측점 카드에 걸쳐 그린 포장층 · 월류 높이선을 + * 흙막이 연동 형상(79-6 `.is-linked`)과 같은 꼴로 · 호버 글에 주인 측점. 주인 측점 카드는 그대로 · + * 고름 표식(`data-structure-id`)은 주인 구조물 그대로. + * ========================================================================== */ + +import { stationLabel } from "../common_util/common_util_station"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 연결 꼴을 입힐 물넘이 그림 — 포장층 · 월류 높이선 */ +const LINKED_CLASSES = ["b06-chart__ford-pavement", "b06-chart__ford-deck-plan"]; + +/** `from` 번째 자식부터(방금 그린 물넘이포장) 옆 카드면 연결 꼴 · 주인 측점 글. 붙였으면 true */ +export function markLinkedFordPavement( + svg: SVGElement, + from: number, + section: Pick, + stationInterval: number, +): boolean { + const owner = section.ford_pavement?.chainage_m; + if (typeof owner !== "number" || Math.abs(owner - section.chainage_m) < 1e-3) return false; + const name = `물넘이포장 · ${stationLabel(owner, stationInterval)} 연결`; + for (const el of [...svg.children].slice(from)) { + if (!LINKED_CLASSES.some((className) => el.classList.contains(className))) continue; + el.classList.add("is-linked"); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = name; + el.append(title); + } + return true; +} diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index f833f5624..f6e241919 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -1,75 +1,48 @@ /* ============================================================================= * B06_Section_UI_Cross_Ford_Pavement.ts - * 물넘이포장 — 계획고를 파낸 노면을 횡단도에 그린다(2026-08-28 사용자 확정). + * 물넘이포장 — 횡단도 그림(287-2 · 287-4 · 그림_183). * - * 다른 시설은 구조물을 얹지만 물넘이는 **노면 자체가 내려앉는다**. 그래서 그림도 다르다. - * · 파임 범위 = 노견까지 **전폭**. - * · 깊이 = **노선 중심**에서 잰 월류 높이(`depth_m`). - * · 바닥은 **유입(상류)이 높고 유출이 낮게** 기운다. 경사를 비우면 그 측점의 노면 - * 횡단경사(`cross_slope_pct`)를 쓴다 — 사용자가 폼에서 바꿀 수 있다. - * · 기존 계획고는 **점선**, 물넘이 바닥은 **실선**. - * · 포장 필수 — 일반 포장층과 구분되게 진한 회색 + 빗금으로 채운다. - * - * 유입측 판정은 `section_mode`(상단측 절토 = 등고가 높은 쪽 = 상류)를 따른다. + * · 콘크리트 윗면 = 설계 노면 — 서버(`compute_cross_design(tilted=…)`) · 짝 TS 가 노면을 경사각도 + * (`ford_slope_deg` → `slope_pct`)로 지대 낮은 쪽 한 경사로 기울여 낸 면 · 설계 중심점이 그 위. + * 노면 밖은 설계선 그대로 — 노면 끝에서 성토 · 절토 사면이 이어짐(늘어나지 못한 자리 = 성토). + * · 포장층 = 노면공 포장 형상(`appendPavementOverlay`)을 노견까지. + * · 월류 높이선(회색 점선) = 노면 위로 월류 높이(`depth_m`)만큼 · 노면과 같은 각도. + * · 도수로 · 돌붙임 띠 = 유출(지대 낮은 쪽) 성토사면. * ========================================================================== */ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; -import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; - -const SVG_NS = "http://www.w3.org/2000/svg"; -/** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */ -let hatchSeq = 0; +import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface"; +import { appendChuteBand, chuteRun } from "./B06_Section_UI_Cross_Wall_Hatch"; /** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */ export interface FordPavementSpec { /** 관 지점 누가거리 — 폭 절반 걸친 옆 카드도 같은 값. */ chainage_m?: number; span_m: number; - /** 노선 중심에서 잰 파임 깊이(m). 없으면 그리지 않는다 — 수치를 지어내지 않는다. */ + /** 월류 높이(m) — 노면 위 점선. 없으면 점선을 안 그림(수치를 지어내지 않는다). */ depth_m: number | null; - /** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */ + /** 노면 경사(%) — 경사각도 환산 · 서버 설계 노면이 이 값으로 기욺. */ slope_pct: number | null; /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ defaulted?: string[]; - /** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠. */ - chute?: { structure: string; thickness_m: number }; + /** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠 · 길이 = 사면 따라(286-3 · 비면 원지반까지). */ + chute?: { structure: string; thickness_m: number; length_m?: number }; } -interface Edge { - offset_m: number; - elevation_m: number; -} - -/** 유입측(상류) 부호 — 그 방향으로 갈수록 바닥이 높아진다. */ -function inflowSign(design: CrossDesign, offsetM: number): number { - const inflowLeft = design.section_mode !== "right_cut"; - const towardLeft = offsetM > 0; - return towardLeft === inflowLeft ? 1 : -1; -} - -/** - * 파인 노면의 표고(m). 횡단도와 3D 코리도가 **같은 식**을 써야 두 그림이 어긋나지 않는다. - * 중심에서 깊이만큼 내리고, 유입측으로 갈수록 경사만큼 올린다. - */ -export function fordDeckElevationAt( - design: CrossDesign, - spec: FordPavementSpec, - offsetM: number, - fallbackElevationM = 0, -): number { - const depth = spec.depth_m ?? 0; - const slope = (spec.slope_pct ?? design.cross_slope_pct) / 100; - const center = design.design_elevation_m ?? fallbackElevationM; - return center - depth + slope * Math.abs(offsetM) * inflowSign(design, offsetM); -} - -function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): number { - return fordDeckElevationAt(design, spec, edge.offset_m, edge.elevation_m); +/** 유입(상류)이 좌측(+offset)인가 — 기울인 노면이면 높은 노면 끝 · 아니면 설계선 양 끝 표고가 높은 + * 쪽(286-2) · 같으면 단면유형. */ +export function inflowLeft(design: CrossDesign): boolean { + const edges = design.road_edges; + if (design.tilted && edges && Math.abs(edges.left.elevation_m - edges.right.elevation_m) > 1e-6) + return edges.left.elevation_m > edges.right.elevation_m; + const line = [...(design.design_line ?? [])].sort((a, b) => a.offset_m - b.offset_m); + const gap = line.length >= 2 ? line[line.length - 1].elevation_m - line[0].elevation_m : 0; + return Math.abs(gap) > 1e-3 ? gap > 0 : design.section_mode !== "right_cut"; } function line(points: string[], className: string): SVGPolylineElement { - const polyline = document.createElementNS(SVG_NS, "polyline"); + const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("points", points.join(" ")); polyline.setAttribute("class", className); return polyline; @@ -119,8 +92,9 @@ export function appendFordSurfaceDropPlan( } /** - * 물넘이 파임을 그린다. 그렸으면 true — 호출부는 일반 포장층 박스를 건너뛴다 - * (같은 자리에 두 겹으로 깔리면 색 구분이 사라진다). + * 물넘이포장을 그린다(287-2 · 287-4) — 포장층(노면공 형상 · 노견까지) · 월류 높이선 · 도수로 띠. + * 그렸으면 true — 호출부는 일반 포장층을 건너뜀(두 겹으로 안 깔림). 포장층에 물넘이 표식 + * (`b06-chart__ford-pavement` · 287-5 고름이 노면공 포장 고름과 가름). */ export function appendFordPavementOverlay( svg: SVGElement, @@ -128,76 +102,81 @@ export function appendFordPavementOverlay( design: CrossDesign, x: (offset: number) => number, y: (elevation: number) => number, + /** 원지반 — 도수로 띠가 원지반에 닿으면 끝 · 길이가 사면보다 길면 이어 그림(294-4). */ + ground?: readonly OffsetPoint[], ): boolean { - if (!spec || !spec.depth_m || !design.road_edges) return false; - const { left, right } = design.road_edges; - const bottomLeft = bottomAt(design, spec, left); - const bottomRight = bottomAt(design, spec, right); - - // ① 기존 계획고(점선) — 파기 전 노면이 어디였는지 남긴다. - svg.append( - line( - [ - `${x(left.offset_m)},${y(left.elevation_m)}`, - `${x(right.offset_m)},${y(right.elevation_m)}`, - ], - "b06-chart__ford-deck-plan", - ), - ); - - // ② 포장층 — 바닥선에서 두께만큼 아래로. 일반 포장과 색·빗금으로 구분한다. - const thickness = design.pavement_thickness_m ?? 0.2; - const hatchId = `b06-ford-hatch-${(hatchSeq += 1)}`; - const defs = document.createElementNS(SVG_NS, "defs"); - const pattern = document.createElementNS(SVG_NS, "pattern"); - pattern.setAttribute("id", hatchId); - pattern.setAttribute("patternUnits", "userSpaceOnUse"); - pattern.setAttribute("width", "6"); - pattern.setAttribute("height", "6"); - const stroke = document.createElementNS(SVG_NS, "path"); - stroke.setAttribute("d", "M0,6 L6,0"); - stroke.setAttribute("class", "b06-chart__ford-pavement-hatch"); - pattern.append(stroke); - defs.append(pattern); - svg.append(defs); - - const polygon = document.createElementNS(SVG_NS, "polygon"); - polygon.setAttribute( - "points", - [ - `${x(left.offset_m)},${y(bottomLeft)}`, - `${x(right.offset_m)},${y(bottomRight)}`, - `${x(right.offset_m)},${y(bottomRight - thickness)}`, - `${x(left.offset_m)},${y(bottomLeft - thickness)}`, - ].join(" "), - ); - polygon.setAttribute("class", "b06-chart__ford-pavement"); - polygon.setAttribute("fill", `url(#${hatchId})`); - svg.append(polygon); - - // ③ 물넘이 바닥(실선) — 횡단 기준선이라 가장 위에 올린다. - svg.append( - line( - [`${x(left.offset_m)},${y(bottomLeft)}`, `${x(right.offset_m)},${y(bottomRight)}`], - "b06-chart__ford-deck", - ), - ); - if (spec.chute) appendChuteBand(svg, [{ points: outletFillLine(design) }], spec.chute, x, y); + const edges = fordEdges(design); + if (!spec || !edges) return false; + const from = svg.children.length; + // 노면공 항목은 안 넘김 — 물넘이 자체 포장(노면공 포장 고름과 안 섞임) + appendPavementOverlay(svg, { ...design, paved: true, carriageway_edges: edges }, x, y, null); + [...svg.children] + .slice(from) + .filter((el) => el.getAttribute("class") === "b06-chart__pavement") + .forEach((el) => { + el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement"); + // 형상 고름 표식(287-5) — 관 지점 알약 id 와 같은 글 + if (typeof spec.chainage_m === "number") + el.setAttribute("data-structure-id", `pipe-${spec.chainage_m.toFixed(2)}`); + }); + // 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) + const depth = spec.depth_m ?? 0; + if (depth > 0) + svg.append( + line( + [ + `${x(edges.left.offset_m)},${y(edges.left.elevation_m + depth)}`, + `${x(edges.right.offset_m)},${y(edges.right.elevation_m + depth)}`, + ], + "b06-chart__ford-deck-plan", + ), + ); + if (spec.chute) + appendChuteBand(svg, [{ points: fordChuteLine(design, spec.chute, ground) }], spec.chute, x, y); return true; } -/** - * 유출측(`inflowSign` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라 - * 내려가는 동안만(오르면 절토라 멈춤). 성토가 없으면 빈 선(띠 안 그림). - */ -export function outletFillLine(design: CrossDesign): OffsetPoint[] { +/** 콘크리트 · 노면 양 끝 — 낮은 쪽을 늘였으면(294-3 `tilt_reach`) 그 쪽은 원지반과 만난 자리까지. */ +export function fordEdges(design: CrossDesign): CrossDesign["road_edges"] | undefined { const edges = design.road_edges; + const reach = design.tilt_reach; + if (!edges || !reach) return edges; + const end = { offset_m: reach.offset_m, elevation_m: reach.elevation_m }; + return reach.side === "left" ? { ...edges, left: end } : { ...edges, right: end }; +} + +/** 원지반 꺾은선의 그 오프셋 표고(밖은 끝값). */ +function groundZ(ground: readonly OffsetPoint[], offset: number): number | null { + if (!ground.length) return null; + if (offset <= ground[0].offset) return ground[0].elevation; + for (let i = 1; i < ground.length; i += 1) { + const [a, b] = [ground[i - 1], ground[i]]; + if (offset > b.offset) continue; + const span = b.offset - a.offset; + return span > 1e-9 + ? a.elevation + ((b.elevation - a.elevation) * (offset - a.offset)) / span + : b.elevation; + } + return ground[ground.length - 1].elevation; +} + +/** + * 유출측(낮은 쪽) 성토사면 선(136-2 · 294-4) — 낮은 쪽 끝단(늘인 노면 끝 · 아니면 유출 쪽 노견 끝)에서 + * 설계선을 바깥으로 따라 내려가는 동안만(오르면 절토라 멈춤) · 원지반(`ground`)에 닿으면 끝. + * 성토가 없으면(끝단이 이미 원지반 · 양측 절토 물넘이) 점 하나 = 띠 없음. + */ +export function outletFillLine( + design: CrossDesign, + ground?: readonly OffsetPoint[], +): OffsetPoint[] { + const edges = fordEdges(design); if (!edges || !design.design_line?.length) return []; - const outward = design.section_mode !== "right_cut" ? -1 : 1; // +offset = 좌측 · 유입 반대 - const edge = - outward > 0 - ? Math.max(edges.left.offset_m, edges.right.offset_m) - : Math.min(edges.left.offset_m, edges.right.offset_m); + const reach = design.tilt_reach; + const outLeft = reach ? reach.side === "left" : !inflowLeft(design); // +offset = 좌측 + const outward = outLeft ? 1 : -1; + const edge = outLeft + ? Math.max(edges.left.offset_m, edges.right.offset_m) + : Math.min(edges.left.offset_m, edges.right.offset_m); const points = design.design_line .filter((p) => (p.offset_m - edge) * outward >= -1e-6) .sort((a, b) => (a.offset_m - b.offset_m) * outward); @@ -206,61 +185,24 @@ export function outletFillLine(design: CrossDesign): OffsetPoint[] { const last = line[line.length - 1]; if (last && p.elevation_m > last.elevation + 1e-6) break; line.push({ offset: p.offset_m, elevation: p.elevation_m }); + const z = ground ? groundZ(ground, p.offset_m) : null; + if (z !== null && Math.abs(z - p.elevation_m) < 1e-3) break; // 원지반에 닿음 = 끝 } return line; } /** - * 물넘이 자리 노면 · 측구 설계선을 걷는다(66-2 · 66-3) — 노면은 파인 바닥(실선)과 옛 계획고(점선)가 - * 대신하고, 측구는 서지 않음. `from` = 설계선 그리기 앞의 자식 수(그 뒤에 붙은 선만 본다). - * 측구 자리는 바닥 끝에서 절토 비탈 발까지 실선으로 잇는다. 면적 · 서버 설계선은 그대로(그림만). + * 물넘이 도수로 · 돌붙임 선(286-3 · 294-4) — 낮은 쪽 끝단에서 성토사면 따라 · 길이 비움 = 원지반 닿으면 끝 · + * 길이 넣음 = 그 길이(사면보다 길면 원지반 따라 이어) · 양측 절토(늘인 노면 끝이 원지반) = 길이 없으면 안 그림. + * 횡단 띠 · 3D 띠가 같이 씀(그림 전용). */ -export function hideFordPavedRoad( - layer: SVGElement, - from: number, +export function fordChuteLine( design: CrossDesign, - spec: FordPavementSpec, - x: (offset: number) => number, - y: (elevation: number) => number, -): void { - const edges = design.road_edges; - const designLine = design.design_line; - if (!edges || !designLine?.length) return; - const ditch = design.ditch; - const ditchWidth = - design.ditch_enabled === false || ditch.type === "none" - ? 0 - : ditch.type === "standard" - ? ditch.top_width_m - : ditch.width_m; - // +offset = 좌측 · 측구는 노견 바깥 그 쪽에 붙는다 - const leftEdge = Math.max(edges.left.offset_m, edges.right.offset_m); - const rightEdge = Math.min(edges.left.offset_m, edges.right.offset_m); - const hi = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0); - const lo = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0); - const [xa, xb] = [x(lo), x(hi)]; - const inside = (px: number): boolean => - px >= Math.min(xa, xb) - 0.5 && px <= Math.max(xa, xb) + 0.5; - for (const node of [...layer.children].slice(from)) { - if (node.tagName !== "line" || !node.classList.contains("b06-chart__design-cross")) continue; - if (inside(Number(node.getAttribute("x1"))) && inside(Number(node.getAttribute("x2")))) - node.remove(); - } - if (!(ditchWidth > 0) || !design.ditch_side) return; - const edge = design.ditch_side === "left" ? leftEdge : rightEdge; - const foot = design.ditch_side === "left" ? hi : lo; - const sorted = [...designLine].sort((a, b) => a.offset_m - b.offset_m); - const at = sorted.find((point) => Math.abs(point.offset_m - foot) < 1e-3); - if (!at) return; - const edgeElevation = design.ditch_side === "left" ? edges.left : edges.right; - layer.append( - line( - [ - `${x(edge)},${y(fordDeckElevationAt(design, spec, edge, edgeElevation.elevation_m))}`, - `${x(foot)},${y(at.elevation_m)}`, - ], - "b06-chart__ford-deck", - ), + chute?: { length_m?: number }, + ground?: readonly OffsetPoint[], +): OffsetPoint[] { + return ( + chuteRun([{ points: outletFillLine(design, ground) }], chute?.length_m, ground)[0]?.points ?? [] ); } diff --git a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts new file mode 100644 index 000000000..e3fd89830 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts @@ -0,0 +1,58 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Pick_Order.ts + * 횡단 카드 고르기 차례(295-1) — 고를 자리가 좁은 것이 위(모달 `pickAt` 과 같은 잣대). + * + * 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) · + * 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로. + * 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님. + * ========================================================================== */ + +/** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */ +const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"]; +/** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */ +const LINE_HIT_CLASS = "b06-chart__design-hit"; +/** 끌기로 볼 움직임(px) — 형상 고름(`SHAPE_DRAG_PX`)과 같은 값. */ +const DRAG_PX = 3; + +/** `points` 속성 다각형 넓이(px²) — 점이 셋 못 되면 0. */ +export function pointsArea(points: string | null): number { + const pts = (points ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)) + .filter((p) => p.length === 2 && p.every(Number.isFinite)); + if (pts.length < 3) return 0; + let twice = 0; + pts.forEach(([x0, y0], i) => { + const [x1, y1] = pts[(i + 1) % pts.length]; + twice += x0 * y1 - x1 * y0; + }); + return Math.abs(twice) / 2; +} + +/** 판정면을 넓이 차례로 다시 세우고 보라 선 판정을 맨 위로 · 끌기 누름을 막음. */ +export function stackPickTargets(layer: SVGElement): void { + const kids = [...layer.children]; + const surfaces = kids + .filter((el) => SURFACE_HIT_CLASSES.some((name) => el.classList.contains(name))) + .map((el) => ({ el, area: pointsArea(el.getAttribute("points")) })) + .sort((a, b) => b.area - a.area) + .map(({ el }) => el); + const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS)); + layer.append(...surfaces, ...lines); + let down: { x: number; y: number } | null = null; + layer.addEventListener( + "pointerdown", + (event) => (down = { x: (event as PointerEvent).clientX, y: (event as PointerEvent).clientY }), + true, + ); + layer.addEventListener( + "click", + (event) => { + const { clientX, clientY } = event as MouseEvent; + if (down && Math.hypot(clientX - down.x, clientY - down.y) > DRAG_PX) event.stopPropagation(); + down = null; + }, + true, + ); +} diff --git a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts new file mode 100644 index 000000000..1aa025e3f --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Shape_Pick.ts + * 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2 · 요구 185). + * + * 그린 형상(`data-structure-id` 단 포장층 · 돌 무늬 둘레)마다 투명 판정면 — 누르면 그 구조물을 고름 + * (다른 시설 형상 고름과 같은 창구 `onPick`). 지킬 것: + * ① 보라 손잡이(설계선 판정 · 뒤에 붙음)가 위 — 판정면은 구조물 판정면 바로 앞(아래)에 꽂고 + * 구조물 판정 클래스(`b06-chart__culvert-revet-hit`)를 안 씀(보라 판정이 그 앞에 꽂혀 형상 위로 옴). + * ② 누름만 고름 — 눌렀다 3 px 넘게 움직이면(끌기) 고름 아님. + * ③ 노면공 포장 고름과 안 섞임 — 노면공 범위 표식(`data-pave-type`) 없는 형상만 · 누름은 번지지 않음. + * ④ 얇은 형상(물넘이 콘크리트 · 기본 배율 4 px)은 판정면을 아래쪽으로 `SHAPE_MIN_PX` 두께까지 늘림 — + * 노면선 보라 판정 띠(굵기 10 · 위아래 5) 바깥 아래를 누르면 형상(294-7). + * ========================================================================== */ + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 관 지점이 아닌 구조물(맹암거) 형상 누름 — 카드 밖(구조물 목록)이 id 로 고름(288-2). */ +export const SHAPE_PICK_EVENT = "b06-structure-shape-pick"; +/** 끌기로 볼 움직임(px) — 이보다 크면 고름 아님. */ +export const SHAPE_DRAG_PX = 3; +/** 형상 판정면 최소 두께(그림 px) — 보라 판정 띠 아래 반(5)보다 넉넉히(294-7). */ +export const SHAPE_MIN_PX = 12; + +type XY = [number, number]; +const parse = (points: string): XY[] => + points + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number) as XY) + .filter((p) => p.length === 2 && p.every(Number.isFinite)); +const insideOf = (ring: XY[], [x, y]: XY): boolean => + ring.reduce((inn, [ax, ay], i) => { + const [bx, by] = ring[(i + 1) % ring.length]; + return ay > y !== by > y && x < ax + ((y - ay) * (bx - ax)) / (by - ay) ? !inn : inn; + }, false); + +/** 얇은 형상 → 판정면 꼭짓점 — 바로 아래가 형상 밖인 꼭짓점(아랫변)만 내려 두께를 `minPx` 로. + * 두께 = 넓이 ÷ 가로 폭(기운 노면도 세로 두께로 잼). */ +export function thickenDown(points: string, minPx = SHAPE_MIN_PX): string { + const ring = parse(points); + if (ring.length < 3) return points; + const xs = ring.map(([x]) => x); + const area = Math.abs( + ring.reduce((sum, [x0, y0], i) => { + const [x1, y1] = ring[(i + 1) % ring.length]; + return sum + x0 * y1 - x1 * y0; + }, 0) / 2, + ); + const drop = minPx - area / Math.max(Math.max(...xs) - Math.min(...xs), 1e-9); + if (drop <= 0) return points; + const midX = ring.reduce((sum, [x]) => sum + x, 0) / ring.length; + const e = 1e-3; + return ring + .map(([x, y]): XY => { + const probe: XY = [x + (midX > x ? e : midX < x ? -e : 0), y + e]; + return insideOf(ring, probe) ? [x, y] : [x, y + drop]; + }) + .map((p) => p.join(",")) + .join(" "); +} + +/** 판정면을 꽂고 강조 setter 를 돌려줌 — 형상이 없으면 null. */ +export function attachShapePick( + layer: SVGElement, + onPick?: (structureId: string) => void, +): ((active: boolean) => void) | null { + const shapes = [...layer.querySelectorAll("[data-structure-id]")].filter( + (el) => !el.hasAttribute("data-pave-type"), + ); + if (!shapes.length) return null; + const kids = [...layer.children]; + const firstStructureHit = kids.find((el) => + el.classList.contains("b06-chart__culvert-revet-hit"), + ); + for (const shape of onPick ? shapes : []) { + const id = shape.getAttribute("data-structure-id") ?? ""; + const hit = document.createElementNS(SVG_NS, "polygon"); + hit.setAttribute("points", thickenDown(shape.getAttribute("points") ?? "")); + hit.setAttribute("class", "b06-chart__shape-hit"); + let down: { x: number; y: number } | null = null; + hit.addEventListener("pointerdown", (event) => { + down = { x: event.clientX, y: event.clientY }; + }); + hit.addEventListener("click", (event) => { + const moved = + down && Math.hypot(event.clientX - down.x, event.clientY - down.y) > SHAPE_DRAG_PX; + down = null; + if (moved) return; // 끌기 = 고름 아님(②) + event.stopPropagation(); + onPick?.(id); + }); + layer.insertBefore(hit, firstStructureHit ?? null); + } + return (active) => shapes.forEach((shape) => shape.classList.toggle("is-active", active)); +} + +/** 형상 누름 창구(287-5 · 288-2) — 관 지점(`pipe-…` · 물넘이포장) = 그 물넘이 누가거리로 고름(카드의 다른 관 · + * 연동 관 아님 · 294-7) · 그 밖(맹암거) = id 를 실은 알림을 위로 띄움(구조물 목록이 받음). */ +export function shapePickRoute( + from: Element, + pickPipe: (chainageM: number) => void, +): (structureId: string) => void { + return (structureId) => { + if (structureId.startsWith("pipe-")) return pickPipe(Number(structureId.slice(5))); + from.dispatchEvent(new CustomEvent(SHAPE_PICK_EVENT, { bubbles: true, detail: structureId })); + }; +} + +/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴). */ +export function highlightShapes(root: ParentNode, structureId: string | null): void { + for (const shape of root.querySelectorAll("[data-structure-id]")) { + shape.classList.toggle( + "is-active", + !!structureId && shape.getAttribute("data-structure-id") === structureId, + ); + } +} diff --git a/B06_Section/B06_Section_UI_Cross_Underdrain.ts b/B06_Section/B06_Section_UI_Cross_Underdrain.ts new file mode 100644 index 000000000..162887718 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Underdrain.ts @@ -0,0 +1,137 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Underdrain.ts + * 맹암거 횡단 형상(288-1 · 요구 183-3). + * + * · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음. + * · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도. + * · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남. + * · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(흙막이 돌 그리기 `drawStones`). + * 높이가 비면 그리지 않음(수치를 지어내지 않음). + * ========================================================================== */ + +import type { CrossDesign } from "./B06_Section_Api_Fetch"; +import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { drawStones } from "./B06_Section_UI_Cross_Wall_Hatch"; +import type { UnderdrainSpec } from "@util/common_util_structure_underdrain"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +let clipSeq = 0; + +type Edge = { offset_m: number; elevation_m: number }; + +/** 한쪽 둘레(노면 끝 → 옵셋선과 만나는 자리) — `outward` = 바깥 부호(+1 좌 · −1 우). */ +function sidePath( + edge: Edge, + outward: 1 | -1, + line: readonly Edge[], + roadGrade: number, + heightM: number, +): OffsetPoint[] { + const start = { offset: edge.offset_m, elevation: edge.elevation_m }; + const offZ = (o: number) => edge.elevation_m + roadGrade * (o - edge.offset_m) - heightM; + const outside = line + .filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6) + .sort((a, b) => (a.offset_m - b.offset_m) * outward); + const first = outside[0]; + if (!first) return [start, { offset: start.offset, elevation: offZ(start.offset) }]; + if (first.elevation_m >= edge.elevation_m) { + // 절토 — 사면을 안쪽 아래로 늘여 옵셋선과 만남 + const k = (first.elevation_m - edge.elevation_m) / (first.offset_m - edge.offset_m); + const d = Math.abs(k - roadGrade) < 1e-9 ? 0 : -heightM / (k - roadGrade); + const offset = edge.offset_m + d; + return [start, { offset, elevation: offZ(offset) }]; + } + // 성토 — 사면을 따라 내려가다 옵셋선을 만나는 자리 + const path: OffsetPoint[] = [start]; + let prev = edge; + for (const point of outside) { + const fa = prev.elevation_m - offZ(prev.offset_m); + const fb = point.elevation_m - offZ(point.offset_m); + if (fb <= 0) { + const t = fa / (fa - fb); + const offset = prev.offset_m + (point.offset_m - prev.offset_m) * t; + path.push({ offset, elevation: offZ(offset) }); + return path; + } + path.push({ offset: point.offset_m, elevation: point.elevation_m }); + prev = point; + } + path.push({ offset: prev.offset_m, elevation: offZ(prev.offset_m) }); + return path; +} + +/** 맹암거 형상 둘레 — 좌 노면 끝 → 우 노면 끝 → 우 둘레 → 옵셋선 → 좌 둘레(위로). 높이 없으면 null. */ +export function underdrainOutline( + design: CrossDesign, + heightM: number | null, +): OffsetPoint[] | null { + const edges = design.road_edges; + if (!edges || !(heightM && heightM > 0)) return null; + const { left, right } = edges; + const span = left.offset_m - right.offset_m; + if (!(Math.abs(span) > 1e-9)) return null; + const grade = (left.elevation_m - right.elevation_m) / span; + const line = design.design_line ?? []; + const leftPath = sidePath(left, 1, line, grade, heightM); + const rightPath = sidePath(right, -1, line, grade, heightM); + return [ + { offset: left.offset_m, elevation: left.elevation_m }, + ...rightPath, + ...leftPath.slice(1).reverse(), + ]; +} + +/** 맹암거 형상을 그린다(돌 무늬 · 둘레 클립). 그렸으면 그 무리 — 둘레에 고름 표식(`data-structure-id` · 288-2). */ +export function appendUnderdrainOverlay( + svg: SVGElement, + spec: UnderdrainSpec | undefined, + design: CrossDesign, + x: (offset: number) => number, + y: (elevation: number) => number, +): SVGGElement | null { + const outline = spec ? underdrainOutline(design, spec.height_m) : null; + if (!spec || !outline) return null; + const px = (p: OffsetPoint): [number, number] => [x(p.offset), y(p.elevation)]; + const points = outline.map((p) => px(p).join(",")).join(" "); + const group = document.createElementNS(SVG_NS, "g") as SVGGElement; + group.setAttribute("class", "b06-chart__underdrain"); + const body = document.createElementNS(SVG_NS, "polygon"); + body.setAttribute("points", points); + body.setAttribute("class", "b06-chart__underdrain-body"); + if (spec.structure_id) body.setAttribute("data-structure-id", spec.structure_id); + clipSeq += 1; + const clipId = `b06-underdrain-clip-${clipSeq}`; + const clip = document.createElementNS(SVG_NS, "clipPath"); + clip.setAttribute("id", clipId); + const clipShape = document.createElementNS(SVG_NS, "polygon"); + clipShape.setAttribute("points", points); + clip.append(clipShape); + const stones = document.createElementNS(SVG_NS, "g"); + stones.setAttribute("clip-path", `url(#${clipId})`); + const height = spec.height_m!; + const offsets = outline.map((p) => p.offset); + const [lo, hi] = [Math.min(...offsets), Math.max(...offsets)]; + const { left, right } = design.road_edges; + const grade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m); + const roadZ = (o: number) => left.elevation_m + grade * (o - left.offset_m); + const at = (u: number, v: number): OffsetPoint => { + const offset = lo + (hi - lo) * u; + return { offset, elevation: roadZ(offset) - height * (v + 0.5) }; + }; + const [ax, ay] = px(at(0, 0)); + const [bx, by] = px(at(1, 0)); + drawStones( + stones, + { + spanM: Math.hypot(hi - lo, grade * (hi - lo)), + pixelsPerMeter: Math.abs(x(1) - x(0)) || 1, + point: (u, v) => px(at(u, v)), + leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI, + }, + false, + height, + ); + group.append(body, clip, stones); + svg.append(group); + return group; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 9571b8df4..2fb647b19 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -8,6 +8,7 @@ * ========================================================================== */ import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard"; +import { markLinkedFordPavement } from "./B06_Section_UI_Cross_Ford_Linked"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { appendCrossAreaBands, @@ -17,12 +18,14 @@ import { } from "./B06_Section_UI_Cross_Areas"; import { appendFordPavementOverlay, - hideFordPavedRoad, appendFordSurfaceDropPlan, } from "./B06_Section_UI_Cross_Ford_Pavement"; import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { attachShapePick, shapePickRoute } from "./B06_Section_UI_Cross_Shape_Pick"; +import { stackPickTargets } from "./B06_Section_UI_Cross_Pick_Order"; +import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; -import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch"; +import { attachChuteBandPick, groundLine } from "./B06_Section_UI_Cross_Wall_Hatch"; import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill"; import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment"; import { @@ -229,13 +232,14 @@ export function createCrossSectionCard( }, selectCard: () => onSelect(section.station_id), }); - const toggleRevet = (key: RevetPick): void => { + const toggleRevet = (key: RevetPick, ownerM?: number): void => { const wasSelected = isSelected; activeRevet = activeRevet === key ? null : key; showRevetControl(activeRevet !== null); // 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를 // 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다. - revetOffset?.select(section.chainage_m, activeRevet); + // `ownerM` = 폼에 올릴 시설 자리(물넘이 콘크리트 = 그 물넘이 · 294-7) + revetOffset?.select(section.chainage_m, activeRevet, ownerM); card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? activeRevet); if (activeRevet !== null && activeArea !== null) { activeArea = null; @@ -317,7 +321,11 @@ export function createCrossSectionCard( setChipActive(null); onAreaSelect?.(section.station_id, null); } - if (!isSelected) onSelect(section.station_id); + if (!isSelected) { + // 첫 누름 = 이 선 — 남은 부재 고름(도수로 띠 등)이 카드 고름에 되살아나 선을 풀지 않게(295-4) + if (activeRevet === null) revetOffset?.select(section.chainage_m, null); + onSelect(section.station_id); + } } setLineActive(next); }; @@ -445,15 +453,21 @@ export function createCrossSectionCard( // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. // 물넘이포장은 노면 자체가 파인 자리라 포장층도 그 바닥을 따른다 — 그렸으면 일반 // 포장 박스는 건너뛴다(두 겹으로 깔리면 색 구분이 사라진다). + const fordFrom = plotLayer.children.length; const fordPaved = appendFordPavementOverlay( plotLayer, section.ford_pavement, section.design, x, toDisplayY, + groundLine(section.samples ?? []), ); + // 월류 폭으로 걸친 옆 카드 = 흙막이 연동 꼴 · 주인 측점 글(294-6) + markLinkedFordPavement(plotLayer, fordFrom, section, stationInterval); if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement); + // 맹암거(288-1) — 노면 아래 암거 높이만큼 돌 무늬 형상 + appendUnderdrainOverlay(plotLayer, section.underdrain, section.design, x, toDisplayY); // 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면" // 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자). appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY, fordLayout); @@ -513,16 +527,6 @@ export function createCrossSectionCard( [...plotLayer.children] .slice(designFrom) .forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, "")); - // 물넘이 자리 = 노면 · 측구 설계선 걷음(66-2 · 66-3 — 옛 계획고 점선이 드러남) - if (fordPaved && section.ford_pavement) - hideFordPavedRoad( - plotLayer, - designFrom, - section.design, - section.ford_pavement, - x, - toDisplayY, - ); // 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서 // 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다. appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY); @@ -585,6 +589,19 @@ export function createCrossSectionCard( }; setChuteActive(highlightRevet === "chute"); } + // 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2) — 다른 시설과 같은 창구 · 보라 손잡이 아래 · 누름만 + const setShapeActive = attachShapePick( + plotLayer, + revetOffset && shapePickRoute(svg, (at) => toggleRevet("pipe", at)), + ); + if (setShapeActive) { + const baseSetActive = setRevetActive; + setRevetActive = (key) => { + baseSetActive(key); + setShapeActive(key === "pipe"); + }; + setShapeActive(highlightRevet === "pipe"); + } // 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2). const lineDesign = section.design; linePick = attachDesignLinePick({ @@ -598,6 +615,7 @@ export function createCrossSectionCard( }); setLineActive = linePick.setActive; setLineActive(pickedLineAt(section.station_id)); + stackPickTargets(plotLayer); // 고르기 차례 = 좁은 것 위 · 보라 선 맨 위(295-1) } // 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한). diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts index ab62c86d3..2b402938b 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts @@ -349,6 +349,77 @@ export function chuteBandOutline( return { top, normals, outline: [...top, ...bottom.reverse()] }; } +/** 꺾은선 앞에서부터 `lengthM` 까지(쓴 길이와 함께). */ +function trimPolyline( + points: readonly OffsetPoint[], + lengthM: number, +): { points: OffsetPoint[]; used: number } { + const out = points.slice(0, 1); + let used = 0; + for (let i = 1; i < points.length && lengthM - used > 1e-9; i += 1) { + const [a, b] = [points[i - 1], points[i]]; + const step = Math.hypot(b.offset - a.offset, b.elevation - a.elevation); + if (step < 1e-9) continue; + const t = Math.min((lengthM - used) / step, 1); + out.push({ + offset: a.offset + (b.offset - a.offset) * t, + elevation: a.elevation + (b.elevation - a.elevation) * t, + }); + used += step * t; + } + return { points: out, used }; +} + +/** 원지반 샘플 → 꺾은선(유효 점만 · 오프셋 차례). */ +export function groundLine( + samples: ReadonlyArray<{ + offset_m?: number | null; + elevation_m?: number | null; + valid?: boolean; + }>, +): OffsetPoint[] { + return samples + .filter((s) => s.valid !== false && s.offset_m != null && s.elevation_m != null) + .map((s) => ({ offset: s.offset_m as number, elevation: s.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); +} + +/** + * 도수로 · 돌붙임 선 길이(294-4) — 선들을 차례로 사면 따라 `lengthM` 까지 자름 · 사면보다 길면 마지막 끝에서 + * 원지반(`ground`)을 바깥으로 따라 이어 그 길이까지 · 길이 없으면 그대로(원지반 닿으면 끝). 점 하나뿐인 선 + * (사면 없음 · 끝이 이미 원지반)은 길이가 있으면 원지반 따라 · 없으면 안 그림. 그림 전용(수량 · 내역 무관). + */ +export function chuteRun( + lines: readonly L[], + lengthM?: number | null, + ground?: readonly OffsetPoint[], +): L[] { + const drawn = (list: L[]) => list.filter((line) => line.points.length > 1); + if (!(lengthM && lengthM > 0) || !lines.length) return drawn([...lines]); + let left = lengthM; + const out: L[] = []; + for (const line of lines) { + if (left <= 1e-9) break; + const cut = trimPolyline(line.points, left); + out.push({ ...line, points: cut.points }); + left -= cut.used; + } + const last = out[out.length - 1]; + if (left > 1e-9 && ground?.length && last) { + const first = lines[0].points[0]; + const end = last.points[last.points.length - 1]; + const outward = Math.sign(end.offset - first.offset) || Math.sign(end.offset) || 1; + const along = [ + end, + ...ground + .filter((p) => (p.offset - end.offset) * outward > 1e-6) + .sort((a, b) => (a.offset - b.offset) * outward), + ]; + out.push({ ...last, points: trimPolyline(along, left).points }); + } + return drawn(out); +} + /** 띠가 서는 유출 조각(152-3) — 성토 조각 + 내림 · 수평 절토 조각(3도 절토선 · 끝 ≤ 시작). * 3도 선이 못 만나 복합경사로 오르는 절토 조각은 뺌. B06 관 · 흙막이 띠와 3D 띠가 같은 거름. */ export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }): boolean { @@ -365,13 +436,16 @@ export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] } */ export function appendChuteBand( layer: SVGElement, - lines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>, - chute: { structure: string; thickness_m: number }, + allLines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>, + chute: { structure: string; thickness_m: number; length_m?: number }, x: (offset: number) => number, toDisplayY: (elevation: number) => number, + /** 원지반 — 길이가 사면보다 길면 이어 그림(294-4). */ + ground?: readonly OffsetPoint[], ): void { const thickness = chute.thickness_m; if (!(thickness > 0)) return; + const lines = chuteRun(allLines, chute.length_m, ground); const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)]; for (const line of lines) { @@ -383,7 +457,8 @@ export function appendChuteBand( band.setAttribute("class", "b06-chart__culvert-pitching"); const title = document.createElementNS(SVG_NS, "title"); const along = line.kind === "cut" ? "3도 절토선" : "성토사면"; - title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 원지반까지`; + const reach = chute.length_m ? `길이 ${chute.length_m.toFixed(2)}m` : "원지반까지"; + title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 ${reach}`; band.append(title); clipSeq += 1; const clipId = `b06-chute-clip-${clipSeq}`; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 0d99b5e12..db7fb66a3 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -35,6 +35,7 @@ import { readShoulderExtends, } from "./B06_Section_Cross_Design_Session"; import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; +import { readStructureNoteCads } from "./B06_Section_UI_Cross_Detail_Cad"; import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; @@ -413,6 +414,11 @@ export function collectSectionEdits(ctx: SectionPersistContext): { readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => { patchFor(chainage).structure_note = note; }); + // 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 세션 초안 그대로(빈 엔티티 = 비움) + if (ctx.projectId && routeId !== null) + readStructureNoteCads(ctx.projectId, routeId).forEach((cad, chainage) => { + patchFor(chainage).structure_note_cad = cad; + }); // [상세] 표 손질(179-4) — 세션 초안 그대로 · 값은 서버가 그릴 때 다시 셈 if (ctx.projectId && routeId !== null) readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => { diff --git a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts index 239649672..d3b87e236 100644 --- a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts @@ -8,9 +8,11 @@ * 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음. * 풀기(Esc · 카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 · 범위 테두리 · * 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4). + * 한 벌 배수유역도(관 마커 · 유역 · 측점 마름모)는 낸 쪽과 무관하게 늘 값대로 칠함 — 횡단 보기 안 + * 누름(카드 · 목록)도 지도에 이어짐(270 · 종단 보기 `paintMapPick` 과 같은 규칙). * ========================================================================== */ -import { subscribeSelection } from "../A00_Common/design/design_selection"; +import { subscribeSelection, type DesignSelection } from "../A00_Common/design/design_selection"; import { bindEscapeRelease } from "../A00_Common/design/design_pick_escape"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; @@ -27,6 +29,8 @@ export interface CrossPickDeps { }; /** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */ reapply: () => void; + /** 배수유역도를 값으로 칠함(알림 없이 · 270). */ + paintMap?: (selection: DesignSelection | null) => void; } let releasing = false; @@ -65,7 +69,8 @@ function releaseAll(deps: CrossPickDeps): void { } /** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음. - * `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */ + * `release` = 강조 모두 끔(값은 부르는 쪽이 비움). 지도는 보일 때 모든 값을 칠함 — 보기 전환 때는 + * 종단 보기가 같은 값으로 칠해 둠 · 재계산 뒤는 횡단 보기 종단도 알림(`onPipesChanged`)이 다시 얹음. */ export function bindCrossPick( projectId: string | null, host: { visible: () => boolean; onDispose: (fn: () => void) => void }, @@ -73,7 +78,9 @@ export function bindCrossPick( ): { release: () => void } { host.onDispose( subscribeSelection(projectId, (selection, from) => { - if (!host.visible() || from === "b06") return; + if (!host.visible()) return; + deps.paintMap?.(selection); + if (from === "b06") return; if (selection.chainageM === null) releaseCrossHighlights(deps); else deps.reapply(); }), diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 29bca82ca..2b18e04d5 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -30,6 +30,7 @@ import { } from "../B05_Profile/B05_Profile_Api_Structures"; import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { mergeFacilityOptions } from "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge"; +import { fordWaitsOf, waitsFordHeight } from "../B05_Profile/B05_Profile_UI_Drainage_Ford_Fill"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { linkFields, @@ -49,6 +50,7 @@ import { } from "../A00_Common/design/design_selection"; import { showToast } from "@ui/ui_template_elements"; import { snapStructureStation } from "../common_util/common_util_structure_station"; +import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; import { adjustDockRoot, refreshAdjustSlots, @@ -60,15 +62,14 @@ import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec"; const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다."; -/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */ +/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 뒤에서 바로(298-1). */ const PIPE_ADD_NOTICE = - "계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; -const PIPE_REMOVE_NOTICE = - "계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; + "계곡 통과 시설을 넣었습니다 — 횡단도는 바로 보이고, 세부 배수유역은 뒤에서 다시 나눕니다."; +const PIPE_REMOVE_NOTICE = "계곡 통과 시설을 뺐습니다 — 세부 배수유역은 뒤에서 다시 나눕니다."; /** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */ const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다."; const PIPE_MOVE_NOTICE = - "기준 측점을 옮겼습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; + "기준 측점을 옮겼습니다 — 횡단도는 바로 보이고, 세부 배수유역은 뒤에서 다시 나눕니다."; export interface B06StructuresPanelDeps { projectId: string | null; @@ -129,6 +130,10 @@ export interface B06StructuresPanel { preferred: () => string | null; /** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */ selectStructure: (structureId: string) => void; + /** 측점 없는 관(물넘이포장) 하나를 폼에 올림 — 카드 안 고름 · 스크롤만 · 알약 · 고름 값에 알약 id(요구 178). */ + selectPipeAt: (chainageM: number) => void; + /** 배수유역도가 관 초안에 채운 빈 월류 높이(126-1)를 목록 · 폼에 받는다(298-1). */ + adoptFordHeights: () => void; /** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */ getStructures: () => StructureInstance[]; /** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */ @@ -220,11 +225,12 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc currentChainageM = null; // 폼 = 이 구조물 — 옛 관을 붙든 채 남지 않게(165-2) // 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다. // 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 그 카드로 스크롤만 · 폼 유지(177-4) - const pave = !coverable(structure); + // 측점 없는 구조물(제 측점을 안 심음)도 같은 길 — 근처 카드 측점선을 켜지 않음(요구 178) + const revealOnly = !coverable(structure) || !plantsStation(structure, markTypes); const id = structure.structure_id ?? null; preferredId = id; // 측점 고름(카드 하나 · 다시 세움)이 연 폼 = 누른 카드 그대로 — 구조물 기준 카드로 옮겨 가지 않음(189-1 · 231-1) - if (!openingAtStation) deps.focusChainage(focusOf(structure), pave); + if (!openingAtStation) deps.focusChainage(focusOf(structure), revealOnly); preferredId = null; // 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2) deps.markStructure?.(id, !openingAtStation); // 측점 고름이 연 것 = 카드 하나 · 범위 테두리 없음(231-1) @@ -255,6 +261,9 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }); pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); currentChainageM = at; + // 빈 월류 높이 = 다시 나눈 유량으로 채움(126-1) — 배수유역도 판이 이 표를 보고 채움(298-1) + if (waitsFordHeight(attributes.facility, attributes.options)) + fordWaitsOf(deps.projectId).add(at); persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); @@ -262,7 +271,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc showToast(PIPE_ADD_NOTICE, "success"); }, // 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다 - // (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다. + // (2026-09-12 일원화). 세부유역은 관 초안을 쓰면 배수유역도가 뒤에서 다시 나눈다(298-1). onPipeUpdate: (fromChainageM, toChainageM, attributes) => { // 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1) if (Math.abs(fromChainageM - toChainageM) > 0.005) movePipeTo(fromChainageM, toChainageM); @@ -493,6 +502,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); + deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2) showToast(PIPE_MOVE_NOTICE, "success"); } @@ -543,6 +553,23 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc section.selectPipeByChainage(hit.chainage_m); }, getStructures: () => structures, + adoptFordHeights: () => { + // 배수유역도가 초안에 채운 월류 높이(126-1)를 목록 · 폼에 받음 — 빈 칸만(298-1) + const draft = deps.projectId ? readPendingPipes(deps.projectId) : null; + const filled = pipeFacilities.filter((entry) => { + if (entry.options?.ford_height_m !== undefined) return false; + const height = draft?.find( + (pipe) => Math.abs(pipe.chainage_m - entry.chainage_m) < PIPE_MATCH_M, + )?.options?.ford_height_m; + if (height === undefined) return false; + entry.options = { ...entry.options, ford_height_m: height }; + return true; + }); + if (!filled.length) return; + section.setPipeFacilities(pipeFacilities); + const open = filled.find((entry) => entry.chainage_m === currentChainageM); + if (open) section.selectPipeByChainage(open.chainage_m); + }, releasePipe: () => { currentChainageM = null; section.selectPipeByChainage(null); @@ -583,5 +610,22 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } return { chainageM, structureId: hit.structureId }; }, + selectPipeAt: (chainageM) => { + const pipe = pipeFacilities.find( + (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, + ); + if (!pipe) return; + const id = `pipe-${pipe.chainage_m.toFixed(2)}`; + deps.focusChainage(pipe.chainage_m, true); // 카드 안 고름 · 스크롤만 — 옛 카드 · 측점선 풂(177-4 길) + currentChainageM = pipe.chainage_m; + refreshSpecDefaults(pipe.chainage_m); + section.selectPipeByChainage(pipe.chainage_m); + deps.markStructure?.(id); // 알약은 폼 뒤(165-2) + selectDesign( + deps.projectId, + { chainageM: pipe.chainage_m, structureId: id }, + { from: "b06" }, + ); + }, }; } diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index fc8ef4475..73181ca11 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -5,7 +5,7 @@ * ========================================================================== */ import { refreshAdjustSlots } from "./B06_Section_UI_Adjust_Dock"; -import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; import type { RevetPick } from "./B06_Section_UI_Cross_Culvert"; import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel"; @@ -21,15 +21,19 @@ import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist"; * 이 값으로 "카드만 고른 경우"를 가려낸다(2026-08-29 사용자: 측점 선택 때 구조물이 * 자동으로 골라지면 안 된다). 같은 턴에서만 참이다. */ let wallSelecting = false; +/** 이번 턴에 찍은 부재가 폼에 올린 시설 자리 — 안 골라진 카드 첫 누름에 카드 고름이 연동 관으로 다시 옮기지 않게(294-8). */ +let wallOwner: number | null = null; export function isWallSelecting(): boolean { return wallSelecting; } -function markWallSelecting(): void { +function markWallSelecting(owner: number | null = null): void { wallSelecting = true; + wallOwner = owner; queueMicrotask(() => { wallSelecting = false; + wallOwner = null; }); } @@ -44,12 +48,21 @@ export function wireStructureSelection( refreshCard?: (chainageM: number) => void, /** 폼에 올린 시설로 보기를 옮김 — 목록에서 고른 길(54-47). */ reveal?: (chainageM: number) => void, -): { syncInletStructure: () => void } { - const ownerChainageOf = (chainageM: number): number => { +): { syncInletStructure: () => void; formChainageOf: (target: CrossSection) => number } { + const ownerChainageOf = (chainageM: number, key?: RevetPick): number => { const sectionAt = detail()?.cross_sections.find( (entry) => Math.abs(entry.chainage_m - chainageM) < 0.01, ); - return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM; + // 자기 관 없는 카드의 「관」 = 물넘이 콘크리트 — 걸친 연동 관보다 앞(294-7 · 되살림 길도) + const fordM = sectionAt?.ford_pavement?.chainage_m; + if (key === "pipe" && fordM !== undefined && !sectionAt?.culvert) return fordM; + // 물넘이포장 범위 옆 카드 = 그 물넘이 자리(287-5 · 형상 누름이 그 시설을 엶) + return ( + (sectionAt && + (stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m ?? + sectionAt.ford_pavement?.chainage_m)) ?? + chainageM + ); }; // 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에 @@ -69,14 +82,15 @@ export function wireStructureSelection( }; const origRevet = stationControls.revetOffset.select; - stationControls.revetOffset.select = (chainageM, key) => { + stationControls.revetOffset.select = (chainageM, key, ownerM) => { // 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1) const nested = wallSelecting; - // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) - if (key) markWallSelecting(); - origRevet(chainageM, key); // 도수로 띠는 자기 측점 시설 — 이웃 관 연장 안이어도 그 관 폼을 안 엶(152-4) - const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM); + const owner = + ownerM ?? (key === "chute" ? chainageM : ownerChainageOf(chainageM, key ?? undefined)); + // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) + if (key) markWallSelecting(owner); + origRevet(chainageM, key); if (key) remember?.(owner, key); if (key) panel.showAtChainage(owner); else if (!nested) { @@ -153,6 +167,18 @@ export function wireStructureSelection( if (!pointing) pressAt(event.target as HTMLElement); }); panel.root.addEventListener("change", ({ target: el }) => pickAt(el as HTMLElement)); + // 시설을 폼에 여는 동안의 그림(종류 칸 → 값 채움 두 번)은 새 상자가 아님 — 마지막 상자(도수로)를 + // 고르면 누른 선 · 형상을 덮음(294-8 · 295-4) + let opening = false; + const origShow = panel.showAtChainage; + panel.showAtChainage = (chainageM) => { + opening = true; + try { + return origShow(chainageM); + } finally { + opening = false; + } + }; // 상자가 새로 서면(단 늘림 · [추가]) 그 상자를 고름 — 같은 시설을 다시 그린 때만 let painted: { chainageM: number | null; keys: Set } | null = null; panel.root.addEventListener("specpaint", () => { @@ -161,7 +187,7 @@ export function wireStructureSelection( const before = painted?.chainageM === chainageM ? painted.keys : null; painted = { chainageM, keys: new Set(boxes.map(fieldOf)) }; const born = before ? boxes.filter((box) => !before.has(fieldOf(box))).at(-1) : undefined; - if (!born || chainageM === null) return; + if (!born || chainageM === null || opening) return; revealAt(chainageM, null); pickBox(born); }); @@ -217,5 +243,11 @@ export function wireStructureSelection( // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. refreshAdjustSlots(); }; - return { syncInletStructure }; + /** 카드 고름이 폼에 올릴 자리 — 찍은 부재가 연 시설 먼저(걸친 연동 관 아님 · 294-8) · 그다음 소유 측점(79-6) · + * 카드만 고르면 그 측점. */ + const formChainageOf = (target: CrossSection): number => + (wallSelecting + ? (wallOwner ?? stationControls.structureSpan.ownerOf(target)?.chainage_m) + : undefined) ?? target.chainage_m; + return { syncInletStructure, formChainageOf }; } diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 88f3a9e64..11ecf14a2 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -69,6 +69,7 @@ import { import type { SectionViewController } from "./B06_Section_UI_Section_View_Types"; import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice"; import { highlightPavement, pavementAt } from "./B06_Section_UI_Cross_Design_Surface"; +import { highlightShapes, SHAPE_PICK_EVENT } from "./B06_Section_UI_Cross_Shape_Pick"; import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile"; import type { DesignPageShared } from "../A00_Common/design/design_shared"; @@ -243,6 +244,8 @@ export function createSectionView( let spanMarks: ReadonlyArray = []; let spanTypes: ReadonlyArray = []; let spanMarkId: string | null = null; + /** 고른 구조물(알약 · 목록 · 폼) — 물넘이포장 · 맹암거 형상 강조(287-5 · 288-2). */ + let pickedMarkId: string | null = null; const inSpan = (section: CrossSection, mark: StructureInstance | undefined): boolean => { const { start_m: start, end_m: end } = mark ?? {}; if (start == null || end == null || start === end) return false; @@ -261,6 +264,7 @@ export function createSectionView( const pave = span && isPavement(spanMarkId); card.classList.toggle("b06-cross-card--span", span); highlightPavement(card, pave ? mark : undefined); + highlightShapes(card, pickedMarkId); }; const syncSpanHighlights = (): void => { for (const section of currentDetail?.cross_sections ?? []) { @@ -436,6 +440,11 @@ export function createSectionView( // 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다. grid.style.gridTemplateColumns = `repeat(${columnCount}, ${cachedCardWidth}px)`; grid.dataset.columns = String(columnCount); // 4열부터 면적표 「계」만(173-3 · CSS) + // 맹암거 형상 누름 = 그 구조물 id 고름(288-2 · 노면공 포장 범위 표식과 따로 · 같은 id 고름 창구) + grid.addEventListener(SHAPE_PICK_EVENT, (event) => { + const id = (event as CustomEvent).detail; + if (id) pavementSelectListener?.(id); + }); cachedRowHeight.clear(); if (detail.cross_sections.length) { // 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로. @@ -579,7 +588,9 @@ export function createSectionView( elevationMid: plot.elevationMid, exaggeration: Math.max(currentExaggeration, 0.1), }; - return { svg, axes }; + // 칸 맞춤이 넓힌 가로 범위를 처음 배율로 되좁힘 = 카드 반폭(266-1) + const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1; + return { svg, axes, homeScale }; }, focusStation(stationId) { if (stationId === null) clearSelection(); @@ -604,6 +615,7 @@ export function createSectionView( selectMark: (structureId, span = true) => { // span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 포장층은 테두리가 아니라 늘 켬(165-2) spanMarkId = span || isPavement(structureId) ? structureId : null; + pickedMarkId = structureId; syncSpanHighlights(); profile.selectMark(structureId); }, diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts index 36f17d038..fab2266fb 100644 --- a/B06_Section/B06_Section_UI_Section_View_Menu.ts +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -33,6 +33,10 @@ export interface SectionStructureEdit { moveStructure: (structureId: string, toChainageM: number) => void; /** 구조물(관 아님) 하나를 폼에 올리고 그 카드로 간다 — 같은 측점 관보다 먼저(52-15). */ selectStructure: (structureId: string) => void; + /** 측점 없는 관(물넘이포장) 하나를 폼에 올림 — 카드 안 고름 · 그 자리로 스크롤만(요구 178). */ + selectPipe?: (chainageM: number) => void; + /** 배수 재계산이 관 종류 · 관경을 바꿈 — 카드(미리 뜬 새 관 그림 포함)를 다시 얹음(273-2). */ + pipesChanged?: () => void; } /** diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 3cac3c62d..ccf3c0b14 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -15,7 +15,16 @@ import { type DesignPageShared, } from "../A00_Common/design/design_shared"; import { readState } from "../A00_Common/b_page_state"; -import { coversChainage, PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { + coversChainage, + NO_SELECTION, + PIPE_MATCH_M, + readSelection, + selectDesign, +} from "../A00_Common/design/design_selection"; +import { paintMapPick } from "../A00_Common/design/design_pick_profile"; +import { ESCAPE_FROM } from "../A00_Common/design/design_pick_escape"; +import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; import type { CrossSection, @@ -85,21 +94,19 @@ function pipeStations( types: ReadonlyArray, intervalM: number, ): IrregularStation[] { - return marks - .filter((mark) => isPipeMark(mark) && mark.type_id !== "ford_pavement") - .map((mark) => { - const chainage = structureAnchorM(mark); - const station = Math.floor(chainage / intervalM); - return { - id: String(mark.structure_id), - station, - remainder: chainage - station * intervalM, - chainage_m: chainage, - structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관", - structureType: "배관", - origin: "pipe", - } as IrregularStation; - }); + return marks.filter(isPipeMark).map((mark) => { + const chainage = structureAnchorM(mark); + const station = Math.floor(chainage / intervalM); + return { + id: String(mark.structure_id), + station, + remainder: chainage - station * intervalM, + chainage_m: chainage, + structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관", + structureType: "배관", + origin: "pipe", + } as IrregularStation; + }); } /** @@ -139,6 +146,8 @@ export function createSectionProfile( let types: ReadonlyArray = []; let stations: IrregularStation[] = []; let intervalM = 20; + /** 마지막으로 받은 관 목록 지문(자리 · 종류 · 관경) — 바뀔 때만 카드를 다시 얹음. */ + let pipeSignature: string | null = null; /** 가까운 카드 — 관 측점 · 구조물 자리에서 카드를 고를 때. */ const nearestCard = (chainageM: number): CrossSection | null => { @@ -158,6 +167,15 @@ export function createSectionProfile( return stationId; }; + /** 배수유역도에서 고른 관 자리 → 고름 한 칸(null = 풂) — 횡단 보기 구독이 카드 · 폼 · 알약 · 표 열을 + * 그 값으로 세움(종단 보기 `pickFromMap` 과 같은 몫 · 270). */ + const pickFromMap = (chainageM: number | null): void => + selectDesign( + shared.projectId, + chainageM === null ? NO_SELECTION : { chainageM, structureId: null }, + { from: "map" }, + ); + const syncMarks = (): void => { if (!profile) return; profile.setStructureTypes([...types]); @@ -196,9 +214,33 @@ export function createSectionProfile( onStructureTypeAdd: (chainage, typeId) => host.edit()?.addStructureType(chainage, typeId), onIrregularSelect: (station) => host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null), + onBasinSelected: (chainageM) => pickFromMap(chainageM), + onPipeSelected: (chainageM) => pickFromMap(chainageM), + // 배수 재계산이 지운 지도 강조를 값대로 다시 얹음(종단 보기 restorePick 과 같은 몫 · 270) + onPipesChanged: (pipes) => { + if (shared.view() === "cross" && shared.profile?.side) + paintMapPick(shared.profile.side, readSelection(shared.projectId)); + // 관 종류 · 관경 · 채운 월류 높이가 바뀜(새 관 관경 · 빈 높이는 배수 재계산이 정함) — 카드 관 그림도 다시(273-2 · 298-1) + const signature = JSON.stringify( + pipes.map((p) => [ + p.chainage_m, + p.facility, + p.options?.pipe_diameter_mm ?? null, + p.options?.ford_height_m ?? null, + ]), + ); + const changed = pipeSignature !== null && signature !== pipeSignature; + pipeSignature = signature; + if (changed && shared.view() === "cross") host.edit()?.pipesChanged?.(); + }, // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. // 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15). onStructureSelect: (structureId) => { + // 알약 다시 누름 = 풀기 한 길(Esc 와 같은 값 — 카드 · 폼 · 알약 · 범위 테두리 모두 · 요구 178) + if (structureId === null) { + selectDesign(shared.projectId, NO_SELECTION, { from: ESCAPE_FROM }); + return; + } const hit = marks.find((entry) => entry.structure_id === structureId); if (!hit) return; const edit = host.edit(); @@ -206,6 +248,11 @@ export function createSectionProfile( edit.selectStructure(hit.structure_id); return; } + // 측점 없는 관(물넘이포장) = 카드 안 고름 · 그 자리로 스크롤만 · 그 관 폼 · 알약만(요구 178) + if (edit?.selectPipe && !plantsStation(hit, types)) { + edit.selectPipe(structureAnchorM(hit)); + return; + } // 목록에서 풀어도 카드는 남음 — 같은 카드면 「다시 보기」만 돼 관이 안 열렸다(53-2) host.selectCard(null); host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 939e33f2b..a67c0d508 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -39,12 +39,12 @@ export interface SectionViewController { /** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */ refreshCards: (chainages: ReadonlyArray) => void; /** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null) - * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */ + * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null) · 처음 배율(266-1 · 가로 = 카드 반폭). */ detailChart: ( stationId: string, widthPx: number, heightPx: number, - ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null; /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; diff --git a/B06_Section/B06_Section_UI_Style.css b/B06_Section/B06_Section_UI_Style.css index c4d3492ec..af048abe8 100644 --- a/B06_Section/B06_Section_UI_Style.css +++ b/B06_Section/B06_Section_UI_Style.css @@ -51,8 +51,6 @@ 문제 제거(2026-08-06 사용자 보고). */ border-radius: var(--radius-cards); background-color: var(--color-surface-raised); - color: var(--color-text-body); - font-size: var(--text-caption); } .b06-profile__group-legend { @@ -64,7 +62,7 @@ /* 라벨·입력·버튼을 **한 줄**로 세운다(횡단 보기 설정, 2026-08-23 사용자 지시). ui-field는 기본이 세로(라벨 위·입력 아래·그 아래 오류 슬롯)라 그대로 두면 버튼이 - 오류 슬롯 높이만큼 밀려 수평이 안 맞는다 — 이 줄에서만 가로로 눕히고 오류 슬롯을 접는다. */ + 오류 슬롯 높이만큼 밀려 수평이 안 맞는다 — 이 줄에서만 가로로 눕힘(오류 슬롯 접기 = 공용 양식 `.ui-panel-form`). */ .b06-profile__field-row { display: flex; align-items: center; @@ -91,10 +89,6 @@ min-width: 0; } -.b06-profile__field-row .ui-field__error { - display: none; -} - /* 창이 좁아 좌측 패널이 237px 까지 물러난 자리 — 라벨이 안 줄어 2px 이 새어 나왔다. 그 폭에서만 라벨을 윗줄로 접는다. 넓은 폭 배치는 손대지 않는다(2026-09-08 실측). ⚠ 폭 제한 없이 `flex-wrap` 을 주면 넓은 폭에서도 라벨이 윗줄로 올라간다 — 재 보고 되돌렸다. */ diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 0b21a02b5..434e10a85 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -254,7 +254,7 @@ stroke-width: 2.2; } -/* 물넘이포장(2026-08-28): 파인 노면 — 기존 계획고 점선 + 바닥 실선 + 진한 회색 빗금 포장 */ +/* 물넘이포장(287-2 · 287-4): 노면공 포장층(노견까지) + 월류 높이 점선 */ .b06-chart__ford-deck-plan { fill: none; stroke: var(--color-text-secondary); @@ -262,22 +262,21 @@ stroke-dasharray: 5 4; } -.b06-chart__ford-deck { - fill: none; - stroke: var(--color-text-body); - stroke-width: 1.8; - stroke-linejoin: round; -} - .b06-chart__ford-pavement { stroke: var(--color-text-body); stroke-width: 1; } -.b06-chart__ford-pavement-hatch { - stroke: color-mix(in srgb, var(--color-text-body) 70%, transparent); +/* 맹암거(288-1): 노면 아래 돌 무늬 형상 */ +.b06-chart__underdrain-body { + fill: color-mix(in srgb, #3aa0a8 16%, transparent); + stroke: #3aa0a8; stroke-width: 1.2; - fill: none; +} + +.b06-chart__underdrain-body.is-active { + fill: color-mix(in srgb, #3aa0a8 34%, transparent); + stroke-width: 2.2; } /* 배수관 세트(2026-08-19): 배관·기슭막이·돌붙임 — 분리 도형 3종 + 집수정 라벨 */ @@ -317,6 +316,17 @@ stroke-dasharray: 4 2; } +/* 월류 폭으로 옆 측점에 걸친 물넘이포장(294-6) — 연동 형상과 같은 꼴 · 고름 강조(is-active)가 먼저. */ +.b06-chart__ford-pavement.is-linked:not(.is-active) { + fill: color-mix(in srgb, #6f8696 30%, transparent); + stroke: #6f8696; + stroke-dasharray: 4 2; +} + +.b06-chart__ford-deck-plan.is-linked { + stroke: #6f8696; +} + .b06-chart__culvert-revet.is-active { fill: color-mix(in srgb, var(--color-danger) 45%, transparent); stroke: var(--color-danger); @@ -337,6 +347,13 @@ cursor: pointer; } +/* 물넘이포장 · 맹암거 형상 고름 판정면(287-5 · 288-2) — 보라 손잡이 아래 */ +.b06-chart__shape-hit { + fill: transparent; + stroke: none; + cursor: pointer; +} + /* BOX암거 끝 선택 강조 — 고른 절반을 옅게 칠하고 테두리를 두른다 (2026-08-30 사용자: 선택해도 하이라이트가 없었다). 색은 기슭막이·집수정 강조와 같은 계열이되, 면이 넓어 채도를 낮춘다. */ @@ -360,6 +377,11 @@ fill: var(--color-text-secondary); } +/* 관 정보 글(관매설 · 세월교 · 267-1) — 노폭 라벨(`.b06-chart__carriageway-label`)과 같은 글 크기 */ +.b06-chart__culvert-label.b06-chart__pipe-info { + font-size: 11px; +} + /* 유입 보호공 공간 확보 절토선 — 말단에서 표준단면 절토경사로 지반까지 */ .b06-chart__culvert-cut { stroke: var(--color-warning); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index 2f14f0637..27207c53b 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -1,6 +1,6 @@ /* ============================================================================= * B06_Section_UI_Style_Cross_Detail.css - * 횡단 카드 [상세] 단추 · 모달(138-4 · 150-2) — 공용 큰 창 · 그림(남은 높이) → 글 칸 → B07 수량표. + * 횡단 카드 [상세] 단추 · 모달(138-4 · 150-2) — 공용 큰 창 · 그림(남은 높이) → B07 수량표 → CAD 칸(265-1). * ========================================================================== */ /* 측구 줄 맨 오른쪽 — ⋯ 가 보이면 그 옆, 숨으면 혼자 오른쪽 끝 */ @@ -18,6 +18,20 @@ overflow: hidden; } +/* 표 위 CAD 칸(265-1) — 표 아래 · 머리 한 줄 접기(공용 `ui-collapsible` · 접히면 그림이 남은 높이) */ +.b06-detail__notebox { + flex: none; + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); +} + +.b06-detail__notehead { + font-size: var(--text-body-sm); + font-weight: 500; + color: var(--color-text); +} + .b06-detail__note { flex: none; width: 100%; @@ -28,6 +42,8 @@ .b06-detail__table { flex: none; + /* 고른 칸 바깥 줄(259-3) 자리 기준 */ + position: relative; } /* 그림 칸 = 테마 색(173-4 · 카드와 같은 바탕) · 그림은 칸 크기로 새로 그려 가득 */ @@ -81,7 +97,7 @@ color: var(--color-text); } -/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 · 줄 붉게 */ +/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 붉게 · 테두리는 묶음 바깥 한 줄(259-3) */ .b06-detail__qtable [data-key] { cursor: pointer; } @@ -92,12 +108,23 @@ .b06-detail__qtable .is-picked { background: color-mix(in srgb, var(--color-danger) 18%, transparent); - box-shadow: inset 0 0 0 1px var(--color-danger); color: var(--color-danger); } -.b06-detail__qtable tr:has(.is-picked) { - background: color-mix(in srgb, var(--color-danger) 5%, transparent); +.b06-detail__pick-outline { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.b06-detail__pick-outline path { + fill: none; + stroke: var(--color-danger); + stroke-width: 2; + stroke-linecap: square; } /* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */ @@ -169,17 +196,21 @@ } /* 선 조각 판정 선(209-1) — 투명 굵은 선 · 배율 무관 굵기 · 고른 조각 = 붉은 띠 */ -.b06-detail__piece { +.b06-detail__piece, +.b06-detail__piece-group { fill: none; stroke: transparent; - stroke-width: 10; + /* 누름 판정은 거리(LINE_PICK_PX 4) — 굵기는 걸침 표시만 */ + stroke-width: 8; stroke-linecap: round; vector-effect: non-scaling-stroke; pointer-events: stroke; cursor: pointer; } -.b06-detail__piece:hover { +/* 구조물 선 묶음(295-3) — 나뉜 조각 위 한 줄 · 걸치면 선 하나로 비침 */ +.b06-detail__piece:hover, +.b06-detail__piece-group:hover { stroke: color-mix(in srgb, var(--color-accent) 25%, transparent); } @@ -188,6 +219,22 @@ stroke-width: 6; } +/* 닫힌 영역 판정 면(259-2) — 투명 채움으로 누름 · 고른 영역 = 붉은 채움 */ +.b06-detail__face { + fill: transparent; + stroke: none; + pointer-events: fill; + cursor: pointer; +} + +.b06-detail__face:hover { + fill: color-mix(in srgb, var(--color-accent) 8%, transparent); +} + +.b06-detail__face.is-selected { + fill: color-mix(in srgb, var(--color-danger) 22%, transparent); +} + /* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */ .b06-detail__commit { width: auto; diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index 6d3b1488a..dbce9f48d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -17,6 +17,8 @@ import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box"; import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford"; import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; +import { appendUnderdrainOverlay } from "../B06_Section/B06_Section_UI_Cross_Underdrain"; +import { groundLine } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch"; import { appendCrossDesignOverlay, appendPavementOverlay, @@ -29,6 +31,8 @@ const SVG_NS = "http://www.w3.org/2000/svg"; /** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */ const STRUCTURE_LAYER_ID = "b08-structure"; const STRUCTURE_COLOR = "#f6d55c"; +/** 배수관 · 박스암거 색 — 구조물(노랑) · 설계선(보라) · 지반선(흰/검정)과 또렷이 다른 청록(268-2). */ +const PIPE_COLOR = "#22d3ee"; /** 설계선 도면층·색 — 서버 도면(`B07_DesignDetail_Engine_Cad`)이 쓰는 그 값이다. */ export const DESIGN_LAYER_ID = "b08-design"; const DESIGN_COLOR = "#b794f6"; @@ -49,6 +53,7 @@ interface Style { } const STRUCTURE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: STRUCTURE_COLOR }; +const PIPE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: PIPE_COLOR }; const DESIGN_STYLE: Style = { layerId: DESIGN_LAYER_ID, color: DESIGN_COLOR }; function baseEntity(style: Style, type: string, shapeData: unknown): Entity { @@ -482,8 +487,10 @@ function drawDesign( y: (elevation: number) => number, ): void { const { design, culvert, box, ford, own } = layouts; - const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y); + const ground = groundLine(section.samples ?? []); // 물넘이 도수로 띠 끝 · 길이 이음(294-4) + const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y, ground); if (!paved) appendPavementOverlay(svg, design, x, y, section.pavement); + appendUnderdrainOverlay(svg, section.underdrain, design, x, y); // 맹암거(288-1) appendCrossDesignOverlay( svg, design, @@ -494,9 +501,10 @@ function drawDesign( ); } -/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다. */ +/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다 — 박스암거 · 배수관은 pipeSvg 로 따로(268-2). */ function drawStructures( svg: SVGElement, + pipeSvg: SVGElement, section: CrossSection, layouts: Layouts, x: (offset: number) => number, @@ -504,12 +512,12 @@ function drawStructures( ): void { const { link, culvert, box, ford, own } = layouts; if (own) appendRevetmentOverlay(svg, own, x, y); - if (box) appendBoxOverlay(svg, box, x, y); + if (box) appendBoxOverlay(pipeSvg, box, x, y); if (ford) appendFordOverlay(svg, ford, x, y); if (culvert) { const linked = !section.culvert && !!link; appendCulvertOverlay( - svg, + pipeSvg, culvert, x, y, @@ -536,9 +544,11 @@ export function overlayEntities( const designSvg = document.createElementNS(SVG_NS, "svg"); drawDesign(designSvg, section, layouts, x, y); const structureSvg = document.createElementNS(SVG_NS, "svg"); - drawStructures(structureSvg, section, layouts, x, y); + const pipeSvg = document.createElementNS(SVG_NS, "svg"); + drawStructures(structureSvg, pipeSvg, section, layouts, x, y); return [ ...harvest(designSvg, DESIGN_STYLE, frame), ...harvest(structureSvg, STRUCTURE_STYLE, frame), + ...harvest(pipeSvg, PIPE_STYLE, frame), ]; } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 98a66b450..ab66e7bfc 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -524,10 +524,9 @@ def build_cross_drawing( # 표 모듈은 이 모듈의 직렬화 헬퍼를 쓰므로 순환을 피해 함수 안에서 들여온다. from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( - _cross_note_entities, _cross_table_entities, - cross_note_height, - cross_note_lines, + cross_note_block_entities, + cross_note_block_height, cross_table_height, cross_table_width, ) @@ -550,7 +549,7 @@ def build_cross_drawing( if quantity_table is not None: stack = ( cross_table_height(design, table_scale) - + cross_note_height(cross_note_lines(design), table_scale) + + cross_note_block_height(design, table_scale) + 8.0 ) oy = table_bottom + stack + own_half_height * CROSS_MM @@ -580,12 +579,11 @@ def build_cross_drawing( table_bottom = oy - half_height if quantity_table is not None: table_top = oy - half_height - 8.0 - # 표 위 글(150-3) — 줄 수만큼 표를 내림 - note_lines = cross_note_lines(design) + # 표 위 글(150-3) · CAD 칸(260-3) — 그 높이만큼 표를 내림 entities.extend( - _cross_note_entities(drawing_id, note_lines, table_top, center_x, table_scale) + cross_note_block_entities(drawing_id, design, table_top, center_x, table_scale) ) - table_top -= cross_note_height(note_lines, table_scale) + table_top -= cross_note_block_height(design, table_scale) entities.extend( _cross_table_entities( drawing_id, quantity_table, table_top, title_label, center_x, design, table_scale @@ -630,6 +628,12 @@ def build_cross_drawing( # 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울 # 서버 설계선을 이 안에서만 골라내는 데 쓴다. "frame": [frame_left, frame_bottom, frame_right, frame_top], + # 화면이 다시 그려 갈아 끼울 서버 선 — 설계선 + 측구 구간 구조물 선(268-1) + "design_ids": [ + e["id"] + for e in entities + if e["layerId"] in (DESIGN_LAYER_ID, STRUCTURE_LAYER_ID) + ], } ], "layers": [ diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py new file mode 100644 index 000000000..2d34ebbb1 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py @@ -0,0 +1,312 @@ +"""B07 횡단도 표 위 CAD 칸 — 미리 넣음(260-4 · 265-2 · 요구 162-21 · 164-5). + +⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `noteEntities` · `structureNoteLines` · +`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py` · +`test_265_note_cad.py`. +B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 그림을 놓음 · 사용자가 지운 칸 +(빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저 +(모달이 처음 열 때 옮기는 차례와 같음). +그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된 +구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」) + 그 구조물 좌측 패널 옵션 줄(166-1). +옵션 줄은 파이썬으로 다시 짜지 않음 — 화면과 같은 TS(`structureOptionLines`)를 Node +진입점(`B07_DesignDetail_Note_Options_Node.ts` · CLAUDE.md 5장 ②)으로 돌려 받음 · +실패는 비치명(조합 줄만). +좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. +""" + +import json +import logging +import math +import re +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_structure_types +from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file +from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points +from common_util.common_util_node_bundle import run_bundle_json + +logger = logging.getLogger(__name__) + +ROOT = Path(__file__).resolve().parents[1] +_OPTIONS_BUNDLE = ROOT / "config" / "b07_note_node" / "B07_DesignDetail_Note_Options_Node.js" +_OPTIONS_NPM = "build:b07-note" + +# TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭 +PIPE_MATCH_M = 0.51 +_LAYER_ID = "기본" +_FONT = 2.0 +_LINE = 3.0 +_COLOR = "#ff7f7f" +# 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리 +_PIPE_BODY_LINK = "grv6lny" +_LAYERS = ("system", "company", "personal") +_GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$") + +#: (관 누가거리, 세트, 옵션) — 원형 마크 재료 +Pipe = tuple[float, dict[str, Any], dict[str, Any]] +#: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items, type_id, options, option_lines}` +#: — TS `NoteThing`(option_lines = `optionLines`) +Thing = dict[str, Any] + + +def _js_round(value: float) -> int: + """JS `Math.round` — .5 는 위로.""" + return math.floor(value + 0.5) + + +def _number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def _whole_metres(value: Any) -> str: + """관 길이 = 사사오입 정수(265-3 · TS `pipeLengthWhole` · B08 「관매설」과 같은 값).""" + return f"{_js_round(value)}m" if _number(value) else "" + + +def station_no_label(chainage_m: float, interval_m: float) -> str: + """측점 이름(대문자 NO · 거리 정수) — TS `stationNoLabel` 짝.""" + safe = interval_m if interval_m > 0 else 1.0 + number = math.floor((chainage_m + 1e-6) / safe) + rest = chainage_m - number * safe + if rest >= safe - 0.05: + number += 1 + rest = 0.0 + return f"NO.{number}" if abs(rest) < 0.05 else f"NO.{number}+{_js_round(rest)}" + + +def _summary_item(value: Any) -> dict[str, Any] | None: + """TS `readSummaryItem` — 모양이 맞으면 그대로 · 틀리면 None.""" + if not isinstance(value, dict): + return None + owner, key = value.get("owner"), value.get("key") + ok = ( + value.get("layer") in _LAYERS + and (owner is None or isinstance(owner, str)) + and isinstance(key, str) + and key != "" + and isinstance(value.get("name"), str) + ) + return value if ok else None + + +def _group_items(value: Any) -> list[tuple[str, dict[str, Any]]]: + """TS `readGroupItems` — 그룹 id · 항목 모양이 맞는 것만(차례 그대로).""" + if not isinstance(value, dict): + return [] + return [ + (gid, item) + for gid, raw in value.items() + if _GROUP_ID.match(str(gid)) and (item := _summary_item(raw)) + ] + + +def things_at(things: list[Thing], chainage_m: float) -> list[Thing]: + """그 측점에 놓인 구조물 — TS `thingsAt`.""" + return [thing for thing in things if abs(thing["at"] - chainage_m) < PIPE_MATCH_M] + + +def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]: + """구조물 조합 줄 — TS `structureNoteLines` 짝. + + 본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌 · + 이어서 그 구조물 좌측 패널 옵션 줄(`option_lines` · 166-1). + """ + lines: list[str] = [] + for thing in things_at(things, chainage_m): + own = _summary_item(thing.get("summary_item")) + slots = ([("", own)] if own else []) + _group_items(thing.get("group_items")) + names = [ + item["name"] or item["key"] + for gid, item in slots + if not (thing["pipe"] and gid in ("", _PIPE_BODY_LINK)) + ] + if slots: + lines += list(dict.fromkeys(names)) + elif not thing["pipe"]: + lines.append(f"{thing['name']} 조합 미지정") + lines += [str(line) for line in thing.get("option_lines") or []] + return lines + + +def _shape(index: int, kind: str, shape_data: dict[str, Any]) -> dict[str, Any]: + return { + "id": f"culvert-note:{index}", + "type": kind, + "lineColor": _COLOR, + "lineWidth": 1, + "layerId": _LAYER_ID, + "shapeData": shape_data, + } + + +def _text(index: int, label: str, x: float, y: float, align: str = "left") -> dict[str, Any]: + return _shape( + index, + "Text", + { + "label": label, + "basePoint": {"x": x, "y": y}, + "options": { + "textDirection": {"x": 1, "y": 0}, + "textAlign": align, + "textColor": _COLOR, + "fontSize": _FONT, + "fontFamily": "sans-serif", + }, + }, + ) + + +def _pipe_mark( + chainage_m: float, culvert: dict[str, Any] | None, design: dict[str, Any] | None +) -> list[dict[str, Any]]: + """원형 마크 — TS `pipeMark`(주인 배수관만 · 상자형 · 숨은 관 · 이웃 = 없음).""" + if not isinstance(culvert, dict) or culvert.get("type") != "pipe": + return [] + if culvert.get("body") or culvert.get("hidden_pipe"): + return [] + owner = culvert.get("chainage_m") + if abs((owner if isinstance(owner, (int, float)) else chainage_m) - chainage_m) >= PIPE_MATCH_M: + return [] + cx, cy, radius = 6.5, -9.0, 6.5 + length = design.get("pipe_length_m") if isinstance(design, dict) else None + inside = [ + "배수관", + f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m", + f"L={_whole_metres(length)}", + ] + # 원 + 원 안 세 줄 — 아래 수직 지시선 없음(요구 169) + return [_shape(0, "Circle", {"center": {"x": cx, "y": cy}, "radius": radius})] + [ + _text(1 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside) + ] + + +def note_entities( + chainage_m: float, + culvert: dict[str, Any] | None, + design: dict[str, Any] | None, + station: str, + lines: list[str], +) -> list[dict[str, Any]]: + """미리 넣음 — TS `noteEntities` 짝(원형 마크 + 측점 · 조합 줄 · 마크 없으면 왼쪽 끝부터). + + `culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `lines` = `structure_note_lines`. + """ + mark = _pipe_mark(chainage_m, culvert, design) + if not mark and not lines: + return [] + x = 15 if mark else 0 + start = len(mark) + return mark + [ + _text(start + i, line, x, -_LINE * (i + 0.5)) for i, line in enumerate([station, *lines]) + ] + + +def _things(points: list[Any], structures: list[Any], names: dict[str, str]) -> list[Thing]: + """글 줄 재료 — 관 지점 → 구조물 배치 차례(TS `loadNoteThings` 와 같은 차례).""" + things: list[Thing] = [ + { + "name": names.get(point.facility, point.facility), + "pipe": point.facility == PIPE_FACILITY_PIPE, + "at": float(point.chainage_m), + "summary_item": point.summary_item, + "group_items": point.group_items, + "type_id": point.facility, + "options": dict(point.options or {}), + } + for point in points + ] + for item in structures: + at = item.chainage_m if item.chainage_m is not None else item.start_m + if not _number(at): + continue + things.append( + { + "name": names.get(item.type_id, item.type_id), + "pipe": False, + "at": float(at), + "summary_item": item.summary_item, + "group_items": item.group_items, + "type_id": item.type_id, + "options": dict(item.options or {}), + } + ) + return things + + +def with_option_lines(things: list[Thing]) -> list[Thing]: + """구조물마다 좌측 패널 옵션 줄(166-1) — 화면 TS 를 Node 로 한 번에 · 실패면 그대로.""" + if not things: + return things + try: + # 지연 import — M02 정의 저장소가 B07 엔진 import 차례에 끼지 않게 + from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible + + payload = { + "things": [ + {"type_id": t["type_id"], "options": t.get("options") or {}} for t in things + ], + "types": with_visible().model_dump(mode="json")["types"], + "definition": load_definition(), + } + output = run_bundle_json(_OPTIONS_BUNDLE, _OPTIONS_NPM, payload) + except Exception: + logger.warning("B07 CAD 칸 옵션 줄 계산 실패 — 조합 줄만 넣음", exc_info=True) + return things + rows = output.get("lines") if isinstance(output, dict) else None + if not isinstance(rows, list) or len(rows) != len(things): + return things + return [ + { + **thing, + "option_lines": [line for line in row if isinstance(line, str)] + if isinstance(row, list) + else [], + } + for thing, row in zip(things, rows) + ] + + +def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]: + """정본 재료 — (원형 마크 배수관 · 관경 안 고름 = 없음, 글 줄 재료 = 관 지점 + 구조물 배치).""" + try: + document = json.loads(pipe_points_file(project_root).read_text(encoding="utf-8")) + points = parse_pipe_points(document.get("points")) + except (OSError, json.JSONDecodeError): + points = [] + pipes: list[Pipe] = [] + for point in points: + options = dict(point.options or {}) + if point.facility != PIPE_FACILITY_PIPE or options.get("pipe_diameter_mm") in (None, ""): + continue + spec = {**_culvert_set(options), "chainage_m": float(point.chainage_m)} + pipes.append((float(point.chainage_m), spec, options)) + _revision, structures = load_structures(str(project_root)) + names = {kind.type_id: kind.name for kind in load_structure_types()} + return pipes, with_option_lines(_things(points, structures, names)) + + +def with_structure_note( + design: dict[str, Any] | None, + chainage_m: float, + sources: tuple[list[Pipe], list[Thing]], + station: str, +) -> dict[str, Any] | None: + """CAD 칸이 없고 옛 글도 없는 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로. + + 설계가 없는 측점(dict 아님)은 그대로 — 표 · 계획선 셈이 설계 없음으로 가는 길을 안 바꿈. + """ + if not isinstance(design, dict) or design.get("structure_note_cad") is not None: + return design + if str(design.get("structure_note") or "").strip(): + return design + pipes, things = sources + near = [pipe for pipe in pipes if abs(pipe[0] - chainage_m) < PIPE_MATCH_M] + spec = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))[1] if near else None + lines = structure_note_lines(things, chainage_m) + entities = note_entities(chainage_m, spec, design, station, lines) + if not entities: + return design + return {**design, "structure_note_cad": {"entities": entities}} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 1dc94c77b..1fa353f86 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -34,8 +34,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( polyline_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( - cross_note_height, - cross_note_lines, + cross_note_block_height, cross_table_height, cross_table_width, ) @@ -106,7 +105,7 @@ def section_block_size( top = half_height + _BORDER_PAD_MM below = half_height + _BORDER_PAD_MM if with_table: - note_h = cross_note_height(cross_note_lines(design_spec), table_scale) + note_h = cross_note_block_height(design_spec, table_scale) below += 8.0 + note_h + cross_table_height(design_spec, table_scale) return (width, top + below) @@ -241,7 +240,9 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> layer_id = f"{STATION_LAYER_PREFIX}{section['chainage']}" title = section.get("title") or f"{section['chainage']}" station_layers.append(_layer(layer_id, f"횡단 {title}")) - design_ids = [e["id"] for e in placed["entities"] if e["layerId"] == DESIGN_LAYER_ID] + # 측구 구간 구조물 선(b08-structure)도 갈아 끼울 대상 — 화면 구조물 덧그림이 같은 자리를 그림(268-1) + replaceable = (DESIGN_LAYER_ID, STRUCTURE_LAYER_ID) + design_ids = [e["id"] for e in placed["entities"] if e["layerId"] in replaceable] for entity in placed["entities"]: _move_to_layer(entity, layer_id) for placement in placed.get("cross_placements") or []: diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index 85a559e3e..fe102f202 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -401,6 +401,96 @@ def _cross_note_entities( ] +# 표 위 CAD 칸(260-3) — B06 [상세] 빈 CAD(`design.structure_note_cad`) 엔티티를 표 왼쪽 위에 맞춤. +# 칸이 있으면(엔티티가 비었어도) 옛 글 대신 이것 · 없으면 옛 글 줄 그대로. + + +def cross_note_cad(design: dict[str, Any] | None) -> list[dict[str, Any]] | None: + """설계의 표 위 CAD 엔티티 — 칸이 없으면 None(옛 글 줄로).""" + cad = design.get("structure_note_cad") if isinstance(design, dict) else None + if not isinstance(cad, dict): + return None + entities = cad.get("entities") + return [e for e in entities if isinstance(e, dict)] if isinstance(entities, list) else [] + + +def _text_width(label: str, font: float) -> float: + """글 폭 어림(mm) — 한글 · 한자 = 글자 크기 · 그 밖 = 0.6 배.""" + return sum(font if ord(char) >= 0x2E80 else 0.6 * font for char in label) + + +def note_cad_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None: + """CAD 칸 범위 (min_x, min_y, max_x, max_y) — 글은 기준점이 세로 가운데(CAD `textBaseline`)라 + 글 높이 · 폭을 맞춤 쪽으로 더함 · 그 밖은 `entities_bbox`.""" + from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox + + boxes = [] + for entity in entities: + shape = entity.get("shapeData") or {} + base = shape.get("basePoint") + if entity.get("type") == "Text" and isinstance(base, dict): + options = shape.get("options") or {} + font = float(options.get("fontSize") or 0.0) + width = _text_width(str(shape.get("label") or ""), font) + x, y = float(base["x"]), float(base["y"]) + left = {"center": x - width / 2.0, "right": x - width}.get(options.get("textAlign"), x) + boxes.append((left, y - font / 2.0, left + width, y + font / 2.0)) + elif (box := entities_bbox([entity])) is not None: + boxes.append(box) + if not boxes: + return None + return ( + min(b[0] for b in boxes), + min(b[1] for b in boxes), + max(b[2] for b in boxes), + max(b[3] for b in boxes), + ) + + +def _on_layer(entity: dict[str, Any], layer_id: str) -> dict[str, Any]: + entity["layerId"] = layer_id + for child in entity.get("children") or []: + _on_layer(child, layer_id) + return entity + + +def cross_note_block_height(design: dict[str, Any] | None, scale: float = 1.0) -> float: + """표 위 칸 높이(mm) — CAD 칸 = 범위 높이 × 배율 · 없으면 옛 글 줄 높이.""" + cad = cross_note_cad(design) + if cad is None: + return cross_note_height(cross_note_lines(design), scale) + box = note_cad_bbox(cad) + return (box[3] - box[1]) * scale if box else 0.0 + + +def cross_note_block_entities( + drawing_id: str, + design: dict[str, Any] | None, + top: float, + center_x: float = 0.0, + scale: float = 1.0, +) -> list[dict[str, Any]]: + """표 위 칸 엔티티 — CAD 칸 = 범위 왼쪽 위를 표 왼쪽 끝 · `top` 에 맞춰 옮김(× 배율 · 표 층 · + id `{도면}:notecad:{n}`) · 없으면 옛 글 줄(`_cross_note_entities`).""" + cad = cross_note_cad(design) + if cad is None: + return _cross_note_entities(drawing_id, cross_note_lines(design), top, center_x, scale) + box = note_cad_bbox(cad) + if box is None: + return [] + from B07_DesignDetail.B07_DesignDetail_Engine_Template import _transform_entity + + left = center_x - _CROSS_TABLE_WIDTH * scale / 2.0 + dx, dy = left - box[0] * scale, top - box[3] * scale + return [ + _on_layer( + _transform_entity(entity, f"{drawing_id}:notecad:{index}", scale, dx, dy), + CROSS_TABLE_LAYER_ID, + ) + for index, entity in enumerate(cad) + ] + + def cross_table_width(scale: float = 1.0) -> float: """수량 산출표 가로 폭(mm).""" return _CROSS_TABLE_WIDTH * scale diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py index d522ab3f9..3cc85c50f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py @@ -181,12 +181,21 @@ def ring_area(paths: list[list[Point]]) -> float | None: return area if area > 1e-9 else None +def rock_runs(design: dict[str, Any], samples: Any = None) -> list[list[Point]]: + """암 경계선 — 원지반선 + `rock_boundary_offset_m`(암 프리셋만 · 카드 그리기와 같은 규칙).""" + offset = _num(design.get("rock_boundary_offset_m")) + if design.get("geometry_preset") != "rock" or offset is None: + return [] + return [[(x, y + offset) for x, y in run] for run in ground_runs(samples)] + + def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]: - """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선).""" + """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선 · 암 경계선).""" return { "design": [_design_line(design)], "ground": ground_runs(samples), "spoil": [_polyline(design.get("spoil_fill_line"))], + "rock": rock_runs(design, samples), } diff --git a/B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts b/B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts new file mode 100644 index 000000000..edce66077 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts @@ -0,0 +1,45 @@ +/* ============================================================================= + * B07_DesignDetail_Note_Options_Node.ts + * B07 횡단도 표 위 CAD 칸 미리 넣음의 **좌측 패널 옵션 줄**을 서버가 내는 Node 진입점(166-1 · + * CLAUDE.md 5장 「계산 자리」 ②). + * + * 화면(B06 [상세] 모달 `loadNoteThings`)과 같은 함수(`structureOptionLines` · 정의 덧칸 `withDefinition`)를 + * 그대로 돌린다 — 파이썬 짝(`B07_DesignDetail_Engine_Cad_CulvertNote`)은 받은 줄을 구조물마다 조합 줄 뒤에 붙인다. + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { things: [{ type_id, options }], types: GET /projects/structure-types 의 types, + * definition: 구조물 정의(GET /m02/structure-definition 과 같은 꼴) } + * 출력 { lines: string[][] } — things 차례대로 + * 끝 코드: 0 성공 / 2 인자 오류 + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import type { StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon"; +import { + withDefinition, + type StructureDefinition, +} from "../B05_Profile/B05_Profile_UI_Structures_Define"; + +interface Input { + things?: Array<{ type_id: string; options?: Record }>; + types?: StructureType[]; + definition?: StructureDefinition | null; +} + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} +const input = JSON.parse(readFileSync(inputPath, "utf8")) as Input; +const types = new Map( + withDefinition(input.types ?? [], input.definition ?? null).map((type) => [type.type_id, type]), +); +const lines = (input.things ?? []).map((thing) => + structureOptionLines( + { type_id: thing.type_id, options: thing.options ?? {} }, + types.get(thing.type_id), + ), +); +writeFileSync(outputPath, JSON.stringify({ lines })); diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index a351e69c7..b6cb11762 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -25,6 +25,15 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( + Pipe, + Thing, + load_note_sources, + with_structure_note, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( + station_no_label as station_no_label_upper, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, @@ -383,17 +392,25 @@ def _cross_section_input( chainage: int | float, design: dict[str, Any] | None, overlay: tuple[float, float, float, float] | None = None, + pipes: tuple[list[Pipe], list[Thing]] | None = None, ) -> dict[str, Any] | None: - """한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다.""" + """한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다. + + `pipes` = 정본 재료(`load_note_sources` · 배수관 · 구조물) — CAD 칸 없는 측점에 + 미리 넣음(260-4 · 265-2).""" path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage) if not path.is_file(): return None source = _read_json(path) interval = infer_station_interval(longitudinal.get("stations") or []) + at = float(source.get("chainage_m", chainage)) return { "chainage": chainage, "source": source, - "design": design, + # 표 · 계획선은 저장 설계 그대로 · 그림만 미리 넣음 칸을 얹은 사본(260-4) + "design": with_structure_note( + design, at, pipes or ([], []), station_no_label_upper(at, interval) + ), "design_line": _cross_design_line(longitudinal_path, source, design), "quantity_table": _quantity_table(source, design), "title": station_no_label(float(source.get("chainage_m", chainage)), interval), @@ -416,6 +433,7 @@ def _read_cross_sheet( if sheet is None: raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.") longitudinal = _read_json(longitudinal_path) + pipes = load_note_sources(project_root) sections = [ section for section in ( @@ -425,6 +443,7 @@ def _read_cross_sheet( chainage, designs.get(chainage), overlays.get(chainage), + pipes, ) for chainage in sheet["chainages"] ) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 68426a2fa..d19228411 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -6,7 +6,7 @@ * DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다. * ========================================================================== */ -import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; @@ -79,7 +79,7 @@ export function buildDrawingSidePanel( const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; - button.className = "b07-drawing-button"; + button.className = "b07-drawing-button ui-panel-btn"; button.dataset.drawingId = drawing.id; button.dataset.confirmed = String(drawing.confirmed); const name = document.createElement("span"); @@ -114,7 +114,7 @@ export function buildDrawingSidePanel( if (!drawing) { const placeholder = document.createElement("button"); placeholder.type = "button"; - placeholder.className = "b07-drawing-button"; + placeholder.className = "b07-drawing-button ui-panel-btn"; placeholder.disabled = true; placeholder.title = "준비 중"; placeholder.dataset.pending = "true"; @@ -132,14 +132,10 @@ export function buildDrawingSidePanel( panel.append(button); continue; } - const section = document.createElement("section"); - // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시). - section.className = "b07-drawing-group ui-collapsible ui-sidebar-section"; + // 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식). + const section = createPanelCard(`${group.label} ${items.length}`); + section.classList.add("b07-drawing-group"); if (group.kind) section.dataset.kind = group.kind; - const sectionTitle = document.createElement("h3"); - sectionTitle.className = "ui-collapsible__title"; - sectionTitle.textContent = `${group.label} ${items.length}`; - section.append(sectionTitle); if (cross) { // 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만. for (const standard of drawings.filter((item) => item.kind === "cross_standard")) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 1b3145360..3d04ef741 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createPanelCard } from "@ui/ui_template_collapsible"; import { createSelectField, showToast } from "@ui/ui_template_elements"; import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { @@ -257,9 +258,10 @@ export function buildPlanSection( makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, onManage?: () => void, ): HTMLElement { - const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section"); + // 컨테이너 = 좌측 패널 공용 카드(269) + const section = createPanelCard(`평면도 ${items.length}`); + section.classList.add("b07-drawing-group", "b07-plan"); section.dataset.kind = "plan"; - section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`)); for (const { kindId, name, sheets } of groupPlanSheets(items)) { const block = el("div", "b07-plan-kind"); @@ -273,7 +275,7 @@ export function buildPlanSection( if (onManage) { // 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1). - const manage = el("button", "b07-plan-tools__button b07-plan-manage-open", "평면도 관리"); + const manage = el("button", "ui-panel-btn b07-plan-manage-open", "평면도 관리"); manage.type = "button"; manage.addEventListener("click", onManage); section.append(manage); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 71c501fb3..9e7c36ded 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -40,15 +40,6 @@ font-size: var(--text-body-sm); } -.b07-drawing-group { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - /* 테두리 색·두께는 공통 ui-sidebar-section이 준다 — 여기선 형태(라운드·안쪽 여백)만. */ - border-radius: var(--radius-lg); - padding: var(--spacing-8); -} - /* 횡단도는 2열 배치 — 단, **칸이 좁아지면 1열로 접는다**(2026-09-08). 좌측 패널이 320 고정이던 동안은 2열이 늘 128.8px 라 문제가 없었으나, 공용 오버레이가 좁은 폭에서 패널을 280·227·182 로 물리면서 칸이 108.8 → 82.4 → 60px @@ -62,13 +53,6 @@ gap: var(--spacing-4); } -.b07-drawing-group h3 { - margin: var(--spacing-8) 0 var(--spacing-4); - color: var(--color-text-muted); - font-size: var(--text-caption); - font-weight: 600; -} - /* 아직 내용이 없는 도면 — 눌리기는 하고 도각만 나온다(2026-09-01). 옅게 두되 못 누르는 것처럼 보이지 않게 커서는 그대로 둔다. */ .b07-drawing-button[data-pending="true"] { @@ -94,22 +78,13 @@ /* 격자 칸의 기본 최소폭은 글자 길이라, 이름이 길면 칸이 패널을 밀어내 단추가 오른쪽으로 튀어나갔다(2026-09-04 실측 14px). 0으로 풀면 이름이 `…`로 잘린다. */ min-width: 0; - min-height: 34px; - padding: var(--spacing-4) var(--spacing-8); - /* 경계가 뚜렷하도록 배경색과 유사하지만 조금 짙은 톤 + 은은한 테두리 */ - border: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent); - /* 확정 여부를 나타내는 좌측 색 띠 (미확정: 투명) */ + /* 크기 · 테두리 · 바탕 · 글자 = 공용 `.ui-panel-btn`(269) · 여기선 확정 여부 좌측 색 띠(미확정: 투명)만 */ border-left: 3px solid transparent; - border-radius: var(--radius-buttons); - background: color-mix(in srgb, var(--color-surface) 84%, #000); - color: var(--color-text); - cursor: pointer; text-align: center; } .b07-drawing-button__name { overflow: hidden; - font-size: var(--text-body-sm); white-space: nowrap; text-overflow: ellipsis; } @@ -227,7 +202,7 @@ /* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일: 배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기]. - 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비 · 오류 자리는 접음. */ + 단추 · 숫자 칸 크기 = 좌측 패널 공용 양식(`.ui-panel-form` · 269) · 한 줄 안 같은 너비. */ .b07-cross-layout-slot { grid-column: 1 / -1; } @@ -260,17 +235,8 @@ gap: var(--spacing-4); } -.b07-cross-layout .ui-btn, -.b07-cross-layout .ui-field .ui-input { - height: auto; - padding: var(--btn-bar-padding); - font-size: var(--btn-bar-font-size); - line-height: 1; -} - .b07-cross-layout .ui-field__label { margin: 0; - font-size: var(--btn-bar-font-size); white-space: nowrap; } @@ -279,10 +245,6 @@ min-width: 0; } -.b07-cross-layout .ui-field__error { - display: none; -} - /* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */ .b07-drawing-list__bypass { margin: var(--spacing-4) 0 0; @@ -315,10 +277,6 @@ font-size: var(--text-caption); } -.b07-plan-manage-open { - margin-top: var(--spacing-4); -} - /* [평면도 관리] 모달(130-1) — 왼쪽 종류 · 자리 · 장 목록 / 오른쪽 미리보기 · 공용 모달에 자기 class 로 큰 창 */ .ui-modal.b07-plan-manage { width: min(1320px, 96vw); diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index cedc8c175..029dc0a27 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -1,4 +1,3 @@ -import { Point } from '@flatten-js/core'; import { type DesignMeta, HtmlEvent } from '../App.types.ts'; import { ImageEntity } from '../entities/ImageEntity.ts'; import { TextEntity } from '../entities/TextEntity.ts'; @@ -11,11 +10,8 @@ import { getDesignMeta, getEntities, getFrameFields, - getLayers, getScreenCanvasDrawController, isDrawingDirty, - isDrawingReadOnly, - isViewOnly, resetUndoBaseline, setActiveLayerId, setDesignMeta, @@ -30,10 +26,10 @@ import { setBare, } from '../state.ts'; import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts'; -import { toast } from 'react-toastify'; import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; import { registerBoxResizeDrag } from './box-resize-drag.ts'; +import { closeTextEditor, handleTextDoubleClick } from './text-inplace-edit.ts'; import { handleTableMessage } from './aislo-table-bridge.ts'; import { registerGuideDrag, setGuide, setGuideArea, setGuideVisible } from './guide-area.ts'; @@ -114,63 +110,11 @@ export function requestDrawingExport(fileFormat: 'dxf' | 'dwg') { notifyParent(AISLO_DRAWING_EXPORT_MESSAGE, { fileFormat }); } -/** - * 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다 - * (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서 - * 화면과 저장값이 어긋났다. - */ -const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table'; - -/** - * 더블클릭한 위치의 텍스트(잠금 해제 레이어)를 찾아 내용을 즉시 편집한다. - * CAD 측점 테이블 값 셀 수정 수단 — 잠금 레이어와 수량 산출표는 제외. - */ +/** 글자 더블클릭 = 그 자리 입력 칸(`text-inplace-edit.ts` · 293-1). */ function registerTextDoubleClickEdit() { const canvas = getCanvas(); if (!canvas) return; - canvas.addEventListener('dblclick', (event: MouseEvent) => { - if (isViewOnly()) { - getScreenCanvasDrawController().zoomToFitScreen(); - return; - } - if (isDrawingReadOnly()) { - toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.'); - return; - } - const drawController = getScreenCanvasDrawController(); - const bounds = canvas.getBoundingClientRect(); - const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); - const worldPoint = drawController.targetToWorld(screenPoint); - const lockedLayerIds = new Set( - getLayers() - .filter((layer) => layer.isLocked) - .map((layer) => layer.id) - ); - let closest: TextEntity | null = null; - let closestDistance = Number.MAX_SAFE_INTEGER; - let closestOnQuantityTable = false; - for (const entity of getEntities()) { - if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue; - const distanceInfo = entity.distanceTo(worldPoint); - if (distanceInfo && distanceInfo[0] < closestDistance) { - closestDistance = distanceInfo[0]; - closest = entity; - closestOnQuantityTable = entity.layerId === QUANTITY_TABLE_LAYER_ID; - } - } - if (!closest) return; - const fontSize = closest.getTextOptions().fontSize; - const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2); - if (closestDistance > tolerance) return; - if (closestOnQuantityTable) { - toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.'); - return; - } - const nextLabel = window.prompt('값 수정', closest.getLabel()); - if (nextLabel === null || nextLabel === closest.getLabel()) return; - closest.setLabel(nextLabel); - setEntities([...getEntities()], true); // undo 스택 + DRAWING_CHANGED 통지 - }); + canvas.addEventListener('dblclick', (event: MouseEvent) => handleTextDoubleClick(event, canvas)); } /** @@ -224,6 +168,7 @@ export function registerAisloDrawingBridge() { } if (!isDrawingLoadMessage(event.data)) return; + closeTextEditor(false); // 옛 도면 글자 칸은 새 도면에 적용하지 않음 try { const drawing = await getEntitiesAndLayersFromJsonObject(event.data.drawing); setEntities(drawing.entities, false); diff --git a/B07_DesignDetail/openwebcad/src/integration/text-inplace-edit.ts b/B07_DesignDetail/openwebcad/src/integration/text-inplace-edit.ts new file mode 100644 index 000000000..8b2161276 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/text-inplace-edit.ts @@ -0,0 +1,247 @@ +/** + * 글자 더블클릭 = 그 자리 입력 칸(293-1) — 브라우저 창(prompt) 대신 글자 위에 겹친 칸에서 바로 고친다. + * 칸은 글자 크기 · 기울기 · 확대 배율을 매 화면 따라간다(휠 줌 · 가운데 단추 팬 중에도). + * Enter = 적용(여러 줄은 Ctrl+Enter · Enter 는 줄바꿈) · Esc = 취소 · 칸 밖 누름 = 적용 · 되돌리기 한 번. + */ +import { Point } from '@flatten-js/core'; +import { toast } from 'react-toastify'; +import type { Entity } from '../entities/Entity'; +import { TextEntity } from '../entities/TextEntity'; +import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts'; +import { bumpSceneVersion } from '../helpers/scene-version.ts'; +import { isImeKey } from '../inputController/ime-sink.ts'; +import { + getEntities, + getLayers, + getScreenCanvasDrawController, + isDrawingReadOnly, + isViewOnly, + setEntities, +} from '../state.ts'; +import { paintColor } from '../theme.ts'; +import { cloneKeepingLook } from '../tools/move-tool.helpers.ts'; + +/** + * 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다 + * (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서 + * 화면과 저장값이 어긋났다. + */ +const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table'; +/** 새로 늘어난 줄 간격 — 여러 줄 문자 명령(text-tools)과 같은 배수 */ +const LINE_GAP_RATIO = 1.35; +/** 너무 작게 줌아웃했을 때도 칠 수 있는 최소 글 크기(px) */ +const MIN_EDITOR_FONT_PX = 10; + +let closeOpenEditor: ((apply: boolean) => void) | null = null; + +const basePointOf = (entity: TextEntity): Point => entity.getFirstPoint() as Point; + +/** 여러 줄 문자(MTEXT) = 같은 묶음의 글자들이 같은 x 에 위아래로 놓인 것 · 위 줄부터. 아니면 그 글자 하나 */ +export function textLinesOf(target: TextEntity, entities: Entity[]): TextEntity[] { + if (!target.groupId) return [target]; + const group = entities.filter((entity) => entity.groupId === target.groupId); + const x = basePointOf(target).x; + const isMultiline = + group.length > 1 && + group.every( + (entity) => entity instanceof TextEntity && Math.abs(basePointOf(entity).x - x) < 1e-6 + ); + if (!isMultiline) return [target]; + return (group as TextEntity[]).sort((a, b) => basePointOf(b).y - basePointOf(a).y); +} + +/** 칸 글을 도면에 적용한 새 목록 — 바뀐 게 없거나 빈 글이면 null. 고친 글자는 복제본으로 갈아 끼워 되돌리기가 옛 글을 살린다 */ +export function applyTextEdit( + lines: TextEntity[], + value: string, + entities: Entity[] +): Entity[] | null { + const next = + lines.length > 1 + ? value + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0) + : [value]; + if (!next.join('').trim()) return null; + if (next.join('\n') === lines.map((line) => line.getLabel()).join('\n')) return null; + const last = lines[lines.length - 1]; + const gap = + lines.length > 1 + ? basePointOf(lines[0]).y - basePointOf(lines[1]).y + : last.getTextOptions().fontSize * LINE_GAP_RATIO; + const replaced = next.map((label, index) => { + const kept = index < lines.length; + const copy = cloneKeepingLook(kept ? lines[index] : last, kept) as TextEntity; + copy.setLabel(label); + if (!kept) { + copy.groupId = last.groupId; + copy.move(0, -gap * (index - lines.length + 1)); + } + return copy; + }); + const lineIndex = new Map(lines.map((line, index) => [line.id, index])); + const result: Entity[] = []; + for (const entity of entities) { + const index = lineIndex.get(entity.id); + if (index === undefined) result.push(entity); + else if (index < replaced.length) { + result.push(replaced[index]); + if (entity === last) result.push(...replaced.slice(lines.length)); + } + } + return result; +} + +/** 칸 안 키 — 적용 · 취소 · 그 밖(칸이 받음). 한글 조합 중 Enter 는 조합을 끝낼 뿐 */ +export function editorKeyAction( + evt: Pick, + multiline: boolean +): 'apply' | 'cancel' | null { + if (isImeKey(evt as KeyboardEvent)) return null; + if (evt.key === 'Escape') return 'cancel'; + if (evt.key === 'Enter' && (!multiline || evt.ctrlKey || evt.metaKey)) return 'apply'; + return null; +} + +/** 열린 칸이 있으면 닫는다(적용) — 도면을 새로 실을 때 등 */ +export function closeTextEditor(apply = true): void { + closeOpenEditor?.(apply); +} + +/** 글자 자리에 입력 칸을 띄운다 — 글 전체 골라진 채 바로 입력 */ +export function openTextEditor(lines: TextEntity[], canvas: HTMLCanvasElement): void { + closeTextEditor(); + const multiline = lines.length > 1; + const editor = document.createElement(multiline ? 'textarea' : 'input'); + editor.className = 'cad-text-inplace'; + editor.value = lines.map((line) => line.getLabel()).join('\n'); + editor.setAttribute('spellcheck', 'false'); + editor.setAttribute('autocomplete', 'off'); + const options = lines[0].getTextOptions(); + const angle = getAngleWithXAxis( + new Point(0, 0), + new Point(options.textDirection.x, -options.textDirection.y) + ); + const shiftX = { left: '0', center: '-50%', right: '-100%' }[options.textAlign] ?? '0'; + Object.assign(editor.style, { + position: 'fixed', + zIndex: '1000', + margin: '0', + padding: '0', + border: 'none', + outline: '1px dashed #4a90d9', + background: 'transparent', + color: paintColor(options.textColor), + textAlign: options.textAlign, + whiteSpace: 'pre', + overflow: 'hidden', + resize: 'none', + transformOrigin: '0 0', + }); + // 칸 아래 캔버스 글자를 감춘다 — 겹쳐 두 벌로 보이지 않게(닫을 때 되살림) + const previewLabels = lines.map((line) => line.previewLabel); + for (const line of lines) line.previewLabel = ''; + bumpSceneVersion(); + + let frame = 0; + const place = () => { + const controller = getScreenCanvasDrawController(); + const scale = controller.getScreenScale(); + const fontPx = Math.max(options.fontSize * scale, MIN_EDITOR_FONT_PX); + const linePx = multiline + ? Math.max((basePointOf(lines[0]).y - basePointOf(lines[1]).y) * scale, fontPx) + : fontPx * 1.2; + const anchor = controller.worldToTarget(basePointOf(lines[0])); + const bounds = canvas.getBoundingClientRect(); + const rows = editor.value.split('\n'); + const widest = Math.max(...rows.map((row) => controller.measureTextScreen(row, fontPx))); + Object.assign(editor.style, { + left: `${bounds.left + anchor.x}px`, + top: `${bounds.bottom - anchor.y}px`, + font: `${options.italic ? 'italic ' : ''}${options.bold ? 'bold ' : ''}${fontPx}px ${options.fontFamily}`, + lineHeight: `${linePx}px`, + width: `${widest + fontPx * 1.5}px`, + height: `${linePx * rows.length}px`, + transform: `rotate(${angle}rad) translate(${shiftX}, ${-linePx / 2}px)`, + }); + frame = requestAnimationFrame(place); + }; + + let closed = false; + const close = (apply: boolean) => { + if (closed) return; + closed = true; + closeOpenEditor = null; + cancelAnimationFrame(frame); + document.removeEventListener('pointerdown', onOutsidePointer, true); + lines.forEach((line, index) => (line.previewLabel = previewLabels[index])); + const value = editor.value; + editor.remove(); + const next = apply ? applyTextEdit(lines, value, getEntities()) : null; + if (next) + setEntities(next, true); // 되돌리기 한 번 + DRAWING_CHANGED 알림 + else bumpSceneVersion(); + }; + // 칸 밖 누름 = 적용 · 가운데 단추(팬)는 칸이 따라가므로 닫지 않음 + const onOutsidePointer = (evt: PointerEvent) => { + if (evt.target !== editor && evt.button !== 1) close(true); + }; + editor.addEventListener('keydown', (evt) => { + evt.stopPropagation(); // CAD 단축키 · 명령줄로 새지 않게 + const action = editorKeyAction(evt as KeyboardEvent, multiline); + if (!action) return; + evt.preventDefault(); + close(action === 'apply'); + }); + editor.addEventListener('blur', () => close(true)); + document.addEventListener('pointerdown', onOutsidePointer, true); + document.body.appendChild(editor); + closeOpenEditor = close; + place(); + editor.focus({ preventScroll: true }); + editor.select(); +} + +/** + * 더블클릭한 위치의 글자(잠금 해제 도면층)를 찾아 그 자리 입력 칸을 띄운다. + * 잠금 도면층 · 수량 산출표는 제외. + */ +export function handleTextDoubleClick(event: MouseEvent, canvas: HTMLCanvasElement): void { + if (isViewOnly()) { + getScreenCanvasDrawController().zoomToFitScreen(); + return; + } + if (isDrawingReadOnly()) { + toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.'); + return; + } + const drawController = getScreenCanvasDrawController(); + const bounds = canvas.getBoundingClientRect(); + const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); + const worldPoint = drawController.targetToWorld(screenPoint); + const lockedLayerIds = new Set( + getLayers() + .filter((layer) => layer.isLocked) + .map((layer) => layer.id) + ); + let closest: TextEntity | null = null; + let closestDistance = Number.MAX_SAFE_INTEGER; + for (const entity of getEntities()) { + if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue; + const distanceInfo = entity.distanceTo(worldPoint); + if (distanceInfo && distanceInfo[0] < closestDistance) { + closestDistance = distanceInfo[0]; + closest = entity; + } + } + if (!closest) return; + const fontSize = closest.getTextOptions().fontSize; + const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2); + if (closestDistance > tolerance) return; + if (closest.layerId === QUANTITY_TABLE_LAYER_ID) { + toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.'); + return; + } + openTextEditor(textLinesOf(closest, getEntities()), canvas); +} diff --git a/B08_Quantity/B08_Quantity_Haul.py b/B08_Quantity/B08_Quantity_Haul.py new file mode 100644 index 000000000..baf49493b --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul.py @@ -0,0 +1,165 @@ +"""B08 운반 행(PLAN 251-1 · 사용자 159번) — 총괄자재표 → 운반 묶음. + +거리이정표 행 · B09 운반공 줄의 한 원천. +묶음 = 총괄 줄 중 수량 있는 것 · 같은 묶음(이형철근 · 레미콘 · 깬돌 · 파형강관 · +커플링밴드)은 한 행 · 골재(모래 · 자갈 · 막자갈 · 혼합석) · 그 밖 줄은 따로 · +채집석 공제 뺌 · 장비 두 행(중기 · 기계) 늘. +옛 B09 운반 6줄 키(`haul:1~6` · 중기 · 기계 · 모래 · 자갈 · 시멘트 · 철근)는 그 묶음에 그대로 — +사용자 설계내역서 덮개가 고아가 되지 않게 · 그 여섯은 수량이 없어도 늘 있음. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Material_Calc import DEDUCT_KEY + +#: 장비 — (묶음 키, 이름, 단위, 옛 번호) +EQUIP: list[tuple[str, str, str, int]] = [ + ("equip_heavy", "중기", "대", 1), + ("equip_machine", "기계", "대", 2), +] +#: 옛 B09 운반 줄 번호 — 묶음 키 → n(`haul:n`) +LEGACY = { + "equip_heavy": 1, + "equip_machine": 2, + "sand": 3, + "gravel": 4, + "cement": 5, + "rebar": 6, +} +#: 총괄 줄 키 머리 → (묶음 키, 이름) — 여러 규격 = 한 행 +PREFIX = [ + ("rebar_", "rebar", "철근"), + ("remicon_", "remicon", "레미콘"), + ("stone_buy_", "stone", "깬돌"), + ("pipe_", "pipe", "파형강관"), + ("coupling_", "coupling", "커플링밴드"), +] + + +#: 총괄 고정 줄 이름 · 단위(줄에 이름이 없어도 같은 이름 · 옛 여섯 늘 행) +NAMES = { + "sand": ("모래", "㎥"), + "gravel": ("자갈", "㎥"), + "rubble": ("막자갈", "㎥"), + "mixed_aggregate": ("혼합석", "㎥"), + "cement": ("시멘트", "대"), + "rebar": ("철근", "ton"), + "wire_mesh": ("와이어메쉬", "㎡"), + "sod": ("떼", "㎡"), + "revet_block": ("호안블럭", "㎡"), + "flume_400c": ("수로관", "본"), + "steel_grating": ("스틸그레이팅", "EA"), +} + + +def _group(row: dict[str, Any]) -> tuple[str, str]: + key = str(row.get("key") or "") + for head, gkey, name in PREFIX: + if key.startswith(head): + return gkey, name + if key in NAMES: + return key, NAMES[key][0] + return key, str(row.get("group") or row.get("spec") or key) # 맞춤 없는 사본 자재 = 자재 이름 + + +def bill_key(gkey: str) -> str: + """B09 운반 줄 키 — 옛 여섯은 `haul:n` 그대로 · 새 묶음 = `haul:<묶음 키>`.""" + return f"haul:{LEGACY[gkey]}" if gkey in LEGACY else f"haul:{gkey}" + + +def haul_groups(materials: dict[str, Any] | None) -> list[dict[str, Any]]: + """총괄자재표(`material.rows`) → 운반 행 [{key, name, unit, qty}]. + + 장비 둘 + 묶음(차례 = 총괄 줄 차례).""" + rows = ((materials or {}).get("material") or {}).get("rows") or [] + out: dict[str, dict[str, Any]] = { + g: {"key": g, "name": n, "unit": u, "qty": None} for g, n, u, _ in EQUIP + } + for row in rows: + if row.get("key") == DEDUCT_KEY: # 채집석 공제(뺄 수량) + continue + gkey, name = _group(row) + amount = row.get("amount") + has = isinstance(amount, (int, float)) and amount > 0 + slot = out.get(gkey) + if slot is None: + if not has and gkey not in LEGACY: + continue # 수량 없는 새 묶음 = 행 없음 + slot = out[gkey] = { + "key": gkey, + "name": name, + "unit": row.get("unit") or "", + "qty": None, + } + if has and (slot["unit"] in ("", row.get("unit"))): + slot["unit"] = row.get("unit") or slot["unit"] + slot["qty"] = round((slot["qty"] or 0) + float(amount), 6) + for gkey in LEGACY: # 옛 여섯 = 늘 + if gkey not in out: + name, unit = NAMES[gkey] + out[gkey] = {"key": gkey, "name": name, "unit": unit, "qty": None} + return list(out.values()) + + +def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict[str, Any]: + """거리이정표 자료에 운반 행 상자 자동(251-2) — 묶음마다 거래처 상자가 없으면 넣음. + + 거래처 · 주소 빈칸 · 선 없음(linked false) · 항목 = 자재 이름 · + 저장은 [저장] 때(읽기만 합침).""" + have = {r.get("haul") for r in data.get("routes") or []} + routes = list(data.get("routes") or []) + for g in groups: + if g["key"] in have: + continue + routes.append( + { + "id": f"h-{g['key']}"[:40], + "name": "", + "address": "", + "to": "site", + "linked": False, + "dir": None, + "haul": g["key"], + "legs": [{"road": None, "km": None}], + "rows": [{"item": g["name"], "dest": "start", "note": ""}], + } + ) + return {**data, "routes": routes} + + +def route_bill_key(route: dict[str, Any], index: int = 0) -> str: + """거리이정표 행 → B09 운반공 줄 키(253-1) — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}` · + 한 상자 둘째 행부터 `.{n}`(261-4 자동 상자도 · 경로 id · 묶음 키에 「.」 없음 — 안 겹침).""" + tail = f".{index}" if index else "" + if route.get("haul"): + return bill_key(str(route["haul"])) + tail + return f"haul:r-{route.get('id')}" + tail + + +def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[str, Any]]: + """거리이정표 표 행 하나 = 운반공 줄 하나(253-1 · 261-4 단가연결 무관) — + [{key, name, ref, user, haul, L1, L2}] · name = 행 항목명 그대로(없으면 거래처 · 그것도 없으면 빈칸) · + L1 = 포장 계(2차 + 1차 + 포장) · L2 = 비포장(`compute_milestone` 결과) · + user = 사용자 상자 · haul = 자동 상자 묶음 키(첫 행은 총괄자재 묶음 줄 이름 · 거리).""" + routes = {r.get("id"): r for r in data.get("routes") or []} + out, seen = [], set() + for row in table: + route = routes.get(row.get("route")) or {} + key = route_bill_key(route, int(row.get("index") or 0)) + if key in seen: + continue + seen.add(key) + out.append( + { + "key": key, + "name": str(row.get("item") or "").strip() or str(route.get("name") or "").strip(), + "ref": route.get("ref"), + "user": not route.get("haul"), + "haul": route.get("haul"), + "L1": row.get("paved"), + "L2": row.get("unpaved"), + } + ) + return out diff --git a/B08_Quantity/B08_Quantity_Haul_Cycle.py b/B08_Quantity/B08_Quantity_Haul_Cycle.py new file mode 100644 index 000000000..02557fc77 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul_Cycle.py @@ -0,0 +1,102 @@ +"""B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력. + +⚠ 짝 = `B08_Quantity_Haul_Cycle.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_253_2_haul_cycle.py`. + 조작 중은 TS 짝(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 이 파일로 다시 채움(정본). + +식(거창 25 설계서 단가산출 1308~1347행 꼴 · 끝수 = 소수 둘째 자리 반올림) + q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) + t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + t2 + t3 + t4 + t5 + t6 + L1 = 거리이정표 그 행 포장 계(2차 + 1차 + 포장) · L2 = 비포장 · 속도 0 = 그 항 0. +로직 입력 채움(이름으로) — `Cm` · `사이클시간` = Cm · `t2` = t2 · `운반거리` = L1 + L2 · + `own` = 사용자가 덮은 칸(L1 · L2 · t1 · 로직 입력 이름) — 그 값이 이김. +기본값 = 거창 설계서(혼합골재 덤프 15톤) · 속도 · t 표 = master_data CC000054~058(화면이 고름). +""" + +from __future__ import annotations + +import math +from typing import Any + +DEFAULTS: dict[str, float] = { + "L1": 0.0, + "L2": 0.0, + "V1": 30.0, + "V2": 35.0, + "V3": 10.0, + "V4": 15.0, + "T": 15.0, + "rt": 1.7, + "f": 1.0, + "qs": 1.72, + "k": 1.0, + "Es": 0.6, + "Cms": 37.4, + "t3": 0.8, + "t4": 0.42, + "t5": 0.5, + "t6": 1.5, +} +#: 로직 입력 이름 → 셈 결과 칸 +FILL = {"Cm": "cm", "사이클시간": "cm", "t2": "t2", "운반거리": "L"} + + +def r2(value: float) -> float: + """소수 둘째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음).""" + return math.floor(value * 100 + 0.5) / 100 + + +def _div(a: float, b: float) -> float: + return a / b if b else 0.0 + + +def _num(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) if math.isfinite(value) else None + + +def cycle_time(inputs: dict[str, Any] | None) -> dict[str, float]: + """사이클 입력(빈 칸 = 기본값 · `t1` 있으면 그 값) → {q, n, t1, t2, cm, L}.""" + given = {k: _num(v) for k, v in (inputs or {}).items()} + v = {k: given[k] if given.get(k) is not None else d for k, d in DEFAULTS.items()} + q = r2(_div(v["T"], v["rt"]) * v["f"]) + n = r2(_div(q, v["qs"] * v["k"])) + t1 = given.get("t1") + t1 = r2(_div(v["Cms"] * n, 60 * v["Es"])) if t1 is None else t1 + l1, l2 = v["L1"], v["L2"] + t2 = r2((_div(l1, v["V1"]) + _div(l1, v["V2"]) + _div(l2, v["V3"]) + _div(l2, v["V4"])) * 60) + cm = r2(t1 + t2 + v["t3"] + v["t4"] + v["t5"] + v["t6"]) + return { + "q": q, + "n": n, + "t1": t1, + "t2": t2, + "cm": cm, + "L": math.floor((l1 + l2) * 1000 + 0.5) / 1000, + } + + +def cycle_inputs(ref: dict[str, Any] | None, dist: dict[str, Any] | None) -> dict[str, Any]: + """연결 `ref.cycle` + 거리이정표 거리(`dist{L1, L2}`) — 덮은 L1 · L2(`ref.own`)는 그대로.""" + cycle = dict((ref or {}).get("cycle") or {}) + own = set((ref or {}).get("own") or []) + for name in ("L1", "L2"): + if name not in own and _num((dist or {}).get(name)) is not None: + cycle[name] = dist[name] + if "t1" not in own: + cycle.pop("t1", None) + return cycle + + +def fill_given( + ref: dict[str, Any], names: list[str], dist: dict[str, Any] | None +) -> dict[str, Any]: + """로직 입력값 다시 채움 — `names` = 그 로직 입력 이름 · 덮은 칸(`ref.own`)은 그대로.""" + own = set(ref.get("own") or []) + got = cycle_time(cycle_inputs(ref, dist)) + given = dict(ref.get("given") or {}) + for name, slot in FILL.items(): + if name in names and name not in own: + given[name] = got[slot] + return given diff --git a/B08_Quantity/B08_Quantity_Haul_Cycle.ts b/B08_Quantity/B08_Quantity_Haul_Cycle.ts new file mode 100644 index 000000000..7d64079d4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul_Cycle.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B08_Quantity_Haul_Cycle.ts + * B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력. + * + * ⚠ 짝 = `B08_Quantity_Haul_Cycle.py`(같은 식) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_253_2_haul_cycle.py`. + * 조작 중은 이 파일(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 짝으로 다시 채움(정본). + * + * 식 = q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) · + * t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + … + t6 · 끝수 = 소수 둘째 자리 · + * 속도 0 = 그 항 0 · 로직 입력 채움 = Cm · 사이클시간 = Cm · t2 · 운반거리 = L1 + L2 · + * `own` = 사용자가 덮은 칸(그 값이 이김). + * ========================================================================== */ + +export const CYCLE_DEFAULTS: Record = { + L1: 0, + L2: 0, + V1: 30, + V2: 35, + V3: 10, + V4: 15, + T: 15, + rt: 1.7, + f: 1, + qs: 1.72, + k: 1, + Es: 0.6, + Cms: 37.4, + t3: 0.8, + t4: 0.42, + t5: 0.5, + t6: 1.5, +}; +/** 로직 입력 이름 → 셈 결과 칸 */ +export const CYCLE_FILL: Record = { + Cm: "cm", + 사이클시간: "cm", + t2: "t2", + 운반거리: "L", +}; + +export interface CycleResult { + q: number; + n: number; + t1: number; + t2: number; + cm: number; + L: number; +} +export interface CycleRef { + given?: Record | null; + cycle?: Record | null; + own?: string[] | null; +} +export type Dist = { L1?: number | null; L2?: number | null } | null | undefined; + +/** 소수 둘째 자리 반올림(0.5 올림 · 파이썬 짝 `r2`) */ +export const r2 = (value: number): number => Math.floor(value * 100 + 0.5) / 100; +const div = (a: number, b: number): number => (b ? a / b : 0); +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 사이클 입력(빈 칸 = 기본값 · t1 있으면 그 값) → {q, n, t1, t2, cm, L} */ +export function cycleTime(inputs: Record | null | undefined): CycleResult { + const v: Record = {}; + for (const [k, d] of Object.entries(CYCLE_DEFAULTS)) v[k] = num(inputs?.[k]) ?? d; + const q = r2(div(v.T, v.rt) * v.f); + const n = r2(div(q, v.qs * v.k)); + const t1 = num(inputs?.t1) ?? r2(div(v.Cms * n, 60 * v.Es)); + const t2 = r2((div(v.L1, v.V1) + div(v.L1, v.V2) + div(v.L2, v.V3) + div(v.L2, v.V4)) * 60); + const cm = r2(t1 + t2 + v.t3 + v.t4 + v.t5 + v.t6); + return { q, n, t1, t2, cm, L: Math.floor((v.L1 + v.L2) * 1000 + 0.5) / 1000 }; +} + +/** 연결 `ref.cycle` + 거리이정표 거리 — 덮은 L1 · L2(`ref.own`)는 그대로 */ +export function cycleInputs(ref: CycleRef | null | undefined, dist: Dist): Record { + const cycle: Record = { ...(ref?.cycle || {}) }; + const own = new Set(ref?.own || []); + for (const name of ["L1", "L2"] as const) + if (!own.has(name) && num(dist?.[name]) !== null) cycle[name] = dist?.[name]; + if (!own.has("t1")) delete cycle.t1; + return cycle; +} + +/** 로직 입력값 다시 채움 — names = 그 로직 입력 이름 · 덮은 칸은 그대로 */ +export function fillGiven(ref: CycleRef, names: string[], dist: Dist): Record { + const own = new Set(ref.own || []); + const got = cycleTime(cycleInputs(ref, dist)); + const given: Record = { ...(ref.given || {}) }; + for (const [name, slot] of Object.entries(CYCLE_FILL)) + if (names.includes(name) && !own.has(name)) given[name] = got[slot]; + return given; +} diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index f77d85f6a..2f2195984 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -4,15 +4,16 @@ 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. 조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈. -표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 +표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 · +`base_chain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면 시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · 포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · 끝수 = 소수 셋째 자리(반올림). -구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 -pick = 참(화면 「도로 구분 고르기」). -도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간 = 2차포장 · 1차포장 · 비포장 · -그 밖 = 포장 · 비포장 · -포장 = 2차 + 1차 + 포장. +구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7). +도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`road_of`) — 시점에 닿는 기준점 구간 = 1차포장 · +그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 +구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리). +옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrate_milestone` 로 새 꼴(`bases[]` · `to`). """ from __future__ import annotations @@ -22,6 +23,15 @@ from typing import Any ROADS = ("2차포장", "1차포장", "포장", "비포장") KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"} +# 구간 자리 → 포장 종류(152 · 254) — via = → 기준점 · base = 기준점 → 시점 · +# direct = 거래처 → 시점 · mid = 시점 → 중점 +PAVED = {"via": "2차포장", "base": "1차포장", "direct": "포장", "mid": "포장"} +SITE = "site" # 이음 대상 = 시점(그 밖 = 기준점 id) + + +def road_of(road: str | None, kind: str) -> str: + """그 구간 도로 구분 — 비포장만 사용자 값 · 그 밖(빈 값 · 옛 값) = 자리의 포장 종류.""" + return "비포장" if road == "비포장" else PAVED[kind] def r3(value: float) -> float: @@ -35,16 +45,10 @@ def leg_km(leg: dict[str, Any]) -> float | None: return leg.get("auto_km") if km is None else km -def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> bool: - """도로 구분마다 더함 — 안 고른 구간이 있으면 참.""" - pick = False +def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None, kind: str) -> None: + """도로 구분마다 더함 — 구간 자리로 포장 종류(152 · 안 고른 구간 없음).""" for leg in legs or []: - key = KEYS.get(leg.get("road")) - if key: - sums[key] += float(leg_km(leg) or 0) - else: - pick = True - return pick + sums[KEYS[road_of(leg.get("road"), kind)]] += float(leg_km(leg) or 0) def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: @@ -55,20 +59,66 @@ def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: return float(station or 0) / 1000 +def base_chain(data: dict[str, Any], to: str) -> list[dict[str, Any]]: + """기준점 사슬(254) — to 부터 이은 기준점을 따라 시점까지 · 방문 집합(돌기 끊음) · + 안 이은 · 없는 기준점에서 멈춤.""" + by_id = {b.get("id"): b for b in data.get("bases") or []} + out: list[dict[str, Any]] = [] + seen: set[str] = set() + while to != SITE and to not in seen: + seen.add(to) + base = by_id.get(to) + if base is None or base.get("linked", True) is False: + break + out.append(base) + to = base.get("to") or SITE + return out + + +def migrate_milestone(data: dict[str, Any]) -> dict[str, Any]: + """옛 꼴 → 새 꼴(254) — 옛 `base`(켜짐) = bases[0] id "b1" · + 경로 `via_base` = to "b1" | "site" · 새 꼴은 그대로(사본).""" + rest = {k: v for k, v in data.items() if k != "base"} + old = data.get("base") or {} + fresh = isinstance(rest.get("bases"), list) + on = not fresh and bool(old.get("enabled")) + if fresh: + bases = rest["bases"] + elif on: + keep = ("name", "address", "dir", "lon", "lat", "pos", "manual", "legs") + bases = [{**{k: old[k] for k in keep if k in old}, "id": "b1", "to": SITE, "linked": True}] + else: + bases = [] + routes = [] + for route in rest.get("routes") or []: + if not isinstance(route.get("to"), str): + via = on and route.get("via_base", True) is not False + route = {k: v for k, v in route.items() if k != "via_base"} + route["to"] = "b1" if via else SITE + routes.append(route) + return {**rest, "bases": bases, "routes": routes} + + +def _kind(owner: dict[str, Any], to_site: str, to_base: str) -> str: + return to_site if (owner.get("to") or SITE) == SITE else to_base + + def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list[dict[str, Any]]: """표 줄 — 경로 차례 · 경로 안 줄 차례.""" + data = migrate_milestone(data) site = data.get("site") or {} - base = data.get("base") or {} mid = {"road": site.get("mid_road") or "비포장", "km": mid_km(data, mid_default_m)} out: list[dict[str, Any]] = [] for route in data.get("routes") or []: for index, row in enumerate(route.get("rows") or []): sums = {"s2": 0.0, "s1": 0.0, "pv": 0.0, "un": 0.0} - pick = add_legs(sums, route.get("legs")) - if base.get("enabled") and route.get("via_base", True): - pick = add_legs(sums, base.get("legs")) or pick + # 안 이은 거래처 = 구간 km 없음(155) · 이음 = 거래처 구간 + 기준점 사슬(254) + if route.get("linked", True) is not False: + add_legs(sums, route.get("legs"), _kind(route, "direct", "via")) + for base in base_chain(data, route.get("to") or SITE): + add_legs(sums, base.get("legs"), _kind(base, "base", "via")) if row.get("dest") == "mid": - add_legs(sums, [mid]) + add_legs(sums, [mid], "mid") paved = sums["s2"] + sums["s1"] + sums["pv"] out.append( { @@ -85,7 +135,7 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list "unpaved": r3(sums["un"]), "total": r3(paved + sums["un"]), "note": row.get("note") or "", - "pick": pick, + "pick": False, } ) return out diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 50aa3ae78..ece7882e5 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -6,20 +6,115 @@ * 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. * 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈. * - * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 + * 표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 · + * `baseChain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면 * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · * 끝수 = 소수 셋째 자리(반올림) - * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 - * pick = 참(화면 「도로 구분 고르기」) - * 도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · - * 비포장 · 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장 · 포장 = 2차 + 1차 + 포장 + * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) + * 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`roadOf`) — 시점에 닿는 기준점 구간 = 1차포장 · + * 그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 + * 구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리) + * 옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrateMilestone` 로 새 꼴(`bases[]` · `to`) * ========================================================================== */ export const ROADS = ["2차포장", "1차포장", "포장", "비포장"] as const; export type Road = (typeof ROADS)[number]; -/** 구간 도로 구분 고르기 — 기준점 거침 = 2차 · 1차 · 비포장 · 아니면 포장 · 비포장(244-13) */ -export const roadsFor = (viaBase: boolean): Road[] => - viaBase ? ["2차포장", "1차포장", "비포장"] : ["포장", "비포장"]; +/** 구간 자리(152 · 254) — 거래처 · 기준점 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · + * 시점 → 중점(mid) */ +export type SegKind = "via" | "base" | "direct" | "mid"; +/** 포장 종류 = 선 자리로 정함(152) — → 기준점 = 2차 · 기준점 → 시점 = 1차 · 거래처 → 시점 = 포장 */ +export const PAVED: Record = { + via: "2차포장", + base: "1차포장", + direct: "포장", + mid: "포장", +}; +/** 그 구간 도로 구분 — 사용자는 비포장만 고름 · 그 밖(빈 값 · 옛 값) = 자리의 포장 종류 */ +export const roadOf = (road: Road | null | undefined, kind: SegKind): Road => + road === "비포장" ? "비포장" : PAVED[kind]; +/** 도로 구분을 선 자리 포장 종류로 바꾼 사본(152) — 다이어그램 선 글 · 그림용(저장 값은 그대로) */ +export function withRoads(data: MilestoneData): MilestoneData { + const fix = (legs: Leg[], kind: SegKind): Leg[] => + legs.map((leg) => ({ ...leg, road: roadOf(leg.road, kind) })); + return { + ...data, + site: { ...data.site, mid_road: roadOf(data.site.mid_road, "mid") }, + bases: data.bases.map((b) => ({ ...b, legs: fix(b.legs, baseKind(b)) })), + routes: data.routes.map((r) => ({ ...r, legs: fix(r.legs, routeKind(r)) })), + }; +} + +/** 이음 대상 = 시점(254 · 그 밖 = 기준점 id) */ +export const SITE_ID = "site"; +const baseKind = (b: { to: string }): SegKind => (b.to === SITE_ID ? "base" : "via"); +const routeKind = (r: { to: string }): SegKind => (r.to === SITE_ID ? "direct" : "via"); + +/** 선 자리 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */ +export function segKind(data: MilestoneData, from: string): SegKind { + if (from === "start") return "mid"; + const base = data.bases.find((b) => b.id === from); + if (base) return baseKind(base); + const route = data.routes.find((r) => r.id === from); + return route ? routeKind(route) : "direct"; +} + +/** 기준점 사슬(254) — to 부터 이은 기준점을 따라 시점까지 · 방문 집합(돌기 끊음) · 안 이은 · 없는 기준점에서 멈춤 */ +export function baseChain(data: MilestoneData, to: string): MilestoneBase[] { + const out: MilestoneBase[] = []; + const seen = new Set(); + let id = to; + while (id !== SITE_ID && !seen.has(id)) { + seen.add(id); + const base = data.bases.find((b) => b.id === id); + if (!base || base.linked === false) break; + out.push(base); + id = base.to; + } + return out; +} + +type OldBase = Partial & { enabled?: boolean }; +type OldData = Partial> & { + base?: OldBase | null; + routes?: Array & { via_base?: boolean }>; +}; + +/** 옛 꼴 → 새 꼴(254) — 옛 `base`(켜짐) = bases[0] id "b1" · 경로 `via_base` = to "b1" | "site" · + * 새 꼴은 그대로(사본) */ +export function migrateMilestone(data: OldData | MilestoneData): MilestoneData { + const { base: old, ...rest } = data as OldData; + const fresh = Array.isArray(rest.bases); + const on = !fresh && !!old?.enabled; + const bases: MilestoneBase[] = fresh + ? rest.bases! + : on + ? [ + { + id: "b1", + name: old!.name ?? "", + address: old!.address ?? "", + dir: old!.dir ?? null, + lon: old!.lon ?? null, + lat: old!.lat ?? null, + pos: old!.pos ?? null, + manual: !!old!.manual, + legs: old!.legs ?? [], + to: SITE_ID, + linked: true, + }, + ] + : []; + const routes = (rest.routes ?? []).map((r) => { + if (typeof r.to === "string") return r as MilestoneRoute; + const { via_base, ...keep } = r; + return { ...keep, to: on && via_base !== false ? "b1" : SITE_ID } as MilestoneRoute; + }); + return { + site: rest.site ?? { mid_station_m: null, mid_road: "비포장" }, + bases, + routes, + }; +} /** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; export type Dir = (typeof DIRS)[number]; @@ -45,28 +140,52 @@ export interface MilestoneRoute { /** 업체 이름 = 하위 탭 이름 */ name: string; address: string; - /** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */ - via_base: boolean; + /** 잇는 곳(254) — "site"(시점으로 바로) · 기준점 id */ + to: string; dir: Dir | null; lon?: number | null; lat?: number | null; - /** 업체 → 기준점(거침) · 시점 구간 */ + /** 다이어그램에 끌어 놓은 자리(153 · 왼 위 · 그림 좌표) — 없으면 방위 자동 자리 */ + pos?: [number, number] | null; + /** 선 이음(155) — false = 아직 안 이음(요소 추가 뒤 · 연결 점으로 이음 · 구간 km 안 셈) · 없음 = 이음(옛 자료) */ + linked?: boolean; + /** 수기 입력(155) — 카카오 길찾기 안 함 · km 손 입력만 */ + manual?: boolean; + /** 운반 묶음 키(251 · 총괄자재 · B09 운반 줄과 이음) — 없음 = 사용자 [요소 추가] 상자 */ + haul?: string | null; + /** 단가연결(251 · dg · sg · logic 꼴 · 정본) */ + ref?: Record | null; + /** 업체 → to(기준점 · 시점) 구간 */ legs: Leg[]; rows: MilestoneRow[]; } +/** 기준점(254 · 여럿) — 거래처가 거쳐 가는 자리 · to = 시점 또는 다른 기준점 */ +export interface MilestoneBase { + id: string; + name: string; + address: string; + dir: Dir | null; + lon?: number | null; + lat?: number | null; + pos?: [number, number] | null; + manual?: boolean; + /** 이 기준점 → to 구간 */ + legs: Leg[]; + /** 잇는 곳 — "site"(시점) · 다른 기준점 id */ + to: string; + /** false = 아직 선 안 이음(요소 추가 뒤) · 없음 = 이음 */ + linked?: boolean; +} + export interface MilestoneData { - site: { mid_station_m: number | null; mid_road: Road }; - base: { - enabled: boolean; - name: string; - address: string; - dir: Dir | null; - lon?: number | null; - lat?: number | null; - /** 기준점 → 시점 공통 구간 */ - legs: Leg[]; + site: { + mid_station_m: number | null; + mid_road: Road; + /** 길찾기용 시점 덮개(255-2 · 경로 탭 시점 끌기 · 노선은 그대로) — 없음 = 노선 첫 점 */ + start_pin?: { lon: number; lat: number } | null; }; + bases: MilestoneBase[]; routes: MilestoneRoute[]; } @@ -97,15 +216,9 @@ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 10 /** 구간 km — 사용자 값 우선 · 없으면 자동 값 */ export const legKm = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; -/** 도로 구분마다 더함 — 안 고른 구간이 있으면 참 */ -function addLegs(sums: Record, legs: Leg[] | undefined): boolean { - let pick = false; - for (const leg of legs ?? []) { - const key = leg.road ? KEYS[leg.road] : undefined; - if (key) sums[key] += Number(legKm(leg) ?? 0); - else pick = true; - } - return pick; +/** 도로 구분마다 더함 — 구간 자리로 포장 종류(152 · 안 고른 구간 없음) */ +function addLegs(sums: Record, legs: Leg[] | undefined, kind: SegKind): void { + for (const leg of legs ?? []) sums[KEYS[roadOf(leg.road, kind)]] += Number(legKm(leg) ?? 0); } /** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */ @@ -116,18 +229,21 @@ export function midKm(data: MilestoneData, midDefaultM: number | null): number { /** 표 줄 — 경로 차례 · 경로 안 줄 차례 */ export function computeMilestone( - data: MilestoneData, + raw: MilestoneData, midDefaultM: number | null, ): MilestoneTableRow[] { - const base = data.base; + const data = migrateMilestone(raw); const mid: Leg = { road: data.site?.mid_road ?? "비포장", km: midKm(data, midDefaultM) }; const out: MilestoneTableRow[] = []; for (const route of data.routes ?? []) { (route.rows ?? []).forEach((row, index) => { const sums = { s2: 0, s1: 0, pv: 0, un: 0 }; - let pick = addLegs(sums, route.legs); - if (base?.enabled && route.via_base !== false) pick = addLegs(sums, base.legs) || pick; - if (row.dest === "mid") addLegs(sums, [mid]); + if (route.linked !== false) { + // 안 이은 거래처 = 구간 km 없음(155) · 이음 = 거래처 구간 + 기준점 사슬(254) + addLegs(sums, route.legs, routeKind(route)); + for (const b of baseChain(data, route.to)) addLegs(sums, b.legs, baseKind(b)); + } + if (row.dest === "mid") addLegs(sums, [mid], "mid"); const paved = sums.s2 + sums.s1 + sums.pv; out.push({ route: route.id, @@ -143,7 +259,7 @@ export function computeMilestone( unpaved: r3(sums.un), total: r3(paved + sums.un), note: row.note ?? "", - pick, + pick: false, }); }); } diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py index ca6a9cff1..0ae895ca1 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Capture.py +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -11,6 +11,7 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · v 캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 · 키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가 드라이버 하위 프로세스를 못 만듦(249-1). +장소 그림(256-1) = 같은 길 · 작은 지도(480×320 · 표지 하나 · 주소 둘레 줌) → `place/<상자 id>.png`. """ from __future__ import annotations @@ -36,7 +37,8 @@ CHILD = __file__ # 자식으로 돌릴 스크립트 NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 -TAIL = " — 그림 올리기를 쓰세요." +TAIL = " — [그림 변경]으로 넣으세요." +PLACE_VIEW = {"w": 480, "h": 320, "level": 4} # 장소 그림(256-1) — 표지 가운데 · 고정 줌 _lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초) @@ -45,17 +47,22 @@ class CaptureError(Exception): """까닭 한 줄(키 · 예외 원문 없음).""" -def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str: - """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.""" +def page_html( + key: str, lines: list[Any], marks: list[dict[str, Any]], view: dict[str, int] | None = None +) -> str: + """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail. + view = 크기 · 줌 고정(장소 그림 · 첫 표지 가운데 · 경로 맞춤 안 함).""" + w, h = (view["w"], view["h"]) if view else (WIDTH, HEIGHT) + level = view["level"] if view else 7 data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c") sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false" return f""" -
    +
    """ @@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]: """자식 프로세스 몸통 — 그림 찍어 `/.png` 저장 → {saved, reason}.""" key = os.environ.get(KAKAO_JS, "").strip() try: - png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"]) + png = shoot(page_html(key, job["lines"], job["marks"], job.get("view")), job["origin"]) except CaptureError as error: return {"saved": False, "reason": str(error)} out_dir = Path(job["out"]) @@ -160,25 +169,36 @@ def spawn(job: dict[str, Any]) -> dict[str, Any]: def make_route_image( - out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]] + out_dir: Path, + route_id: str, + lines: list[Any], + marks: list[dict[str, Any]], + view: dict[str, int] | None = None, + what: str = "경로 그림", + tail: str = TAIL, ) -> dict[str, Any]: """그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림.""" key = os.environ.get(KAKAO_JS, "").strip() if not key: return { "saved": False, - "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}", + "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){tail}", } if not any(lines) and not marks: - return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"} + return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{tail}"} origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/") - job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks} + job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks, "view": view} with _lock: got = spawn({**job, "origin": origin}) if not got.get("saved"): - return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"} + return {"saved": False, "reason": f"{what} 자동 캡처 실패 — {got.get('reason')}{tail}"} return {"saved": True, "reason": None} +def make_place_image(out_dir: Path, place_id: str, mark: dict[str, Any]) -> dict[str, Any]: + """장소 그림(256-1) — 작은 지도 · 표지 하나 → `/<상자 id>.png`.""" + return make_route_image(out_dir, place_id, [], [mark], PLACE_VIEW, "장소 그림", "") + + if __name__ == "__main__": print(json.dumps(capture(json.loads(sys.stdin.buffer.read())))) diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 12f3c648b..a07c8f8c5 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -6,8 +6,12 @@ * 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면 * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · * 같은 방위 여럿 = 옆으로 나란히 - * 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5) - * 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 + * 기준점 자리 = 기준점 주소 좌표 → 잇는 곳(시점 · 다른 기준점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — + * 옛 8방 무시(244-5) · 여럿(254) = 잇는 곳이 먼저 놓이게 사슬 차례 + * 선 = 거래처 → to(기준점 · 시점) · 기준점 → to · 시점 → 중점 — 글 = 구간 줄마다 두 줄(1줄 도로 구분 · + * 2줄 km · 254 · `legText` 가 줄바꿈 글자로 나눔) + * 처음 자리 선 길이(254) = 그 구간 km 비례(`lineLen` · 로그 · 최소 · 최대) — 잇는 곳 테두리 → 상자 테두리 + * 상자 높이 = 내용(254 · 항목 · 이름 · 주소 줄바꿈 `wrap` 최대 3줄 · 좌표) · 기준점 상자도 주소 * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 @@ -18,7 +22,7 @@ * 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표 * ========================================================================== */ -import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; +import type { Dir, Leg, MilestoneBase, MilestoneData } from "./B08_Quantity_Milestone_Calc"; export interface DiagramBox { id: string; @@ -34,6 +38,8 @@ export interface DiagramBox { notes?: string[]; /** 호버 = 줄이지 않은 주소 */ tip?: string; + /** 장소 그림 자리(254-3 · 글 아래 · 그림 없으면 없음) */ + pic?: Rect; } export interface DiagramLine { @@ -77,11 +83,9 @@ const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"]; const SITE = { w: 380, h: 106 }; const NODE = { w: 124, h: 58 }; const BOX = { w: 148, h: 58 }; -const R_BASE = 390; -const R_ROUTE = 280; const PAD = 48; -/** 바탕 반폭 · 반높이 — 기준점 · 공사지 둘레 거래처(같은 거리)가 어느 방위로 와도 담김 */ -const REACH = R_BASE; +/** 바탕 반폭 · 반높이 — 공사지 둘레 상자(짧은 구간 · 선 길이 최소쯤)가 어느 방위로 와도 담김 */ +const REACH = 390; export const BASE_VIEW: [number, number, number, number] = [ -(REACH + BOX.w / 2 + PAD), -(REACH + BOX.h / 2 + PAD), @@ -93,9 +97,19 @@ export const BASE_VIEW: [number, number, number, number] = [ export const GAP = 12; const LINE_GAP = 4; const INSET = 8; -/** 글 크기(px) — 화면 CSS 와 같음(이름 13 · 항목 · 선 글 12 · 작은 글씨 10) */ -const FONT = { label: 13, items: 12, km: 12, note: 10 }; +/** 글 크기(px) — 화면 CSS 와 같음(이름 · 항목 13(254 · 거래처 글과 같음) · 선 글 12 · 작은 글씨 10) */ +const FONT = { label: 13, items: 13, km: 12, note: 10 }; const KM_LINE = 16; +/** 상자 안 글 줄 간격 · 위아래 여백(254 상자 높이 = 줄 × ROW + ROW_PAD · 최소 BOX.h) */ +export const ROW = 15; +const ROW_PAD = 13; +/** 장소 그림(254-3) — 상자 안쪽 폭 · 서버 그림 480 × 320 과 같은 3:2 */ +export const PIC = { w: 132, h: 88 }; +const NOTE_LINES = 3; + +/** 선 길이(254) — 구간 km 비례(로그) · 최소 · 최대(너무 멀어지지 않게) · 잇는 곳 테두리 → 상자 테두리 */ +export const lineLen = (km: number): number => + Math.min(320, Math.max(100, 100 + 90 * Math.log10(1 + Math.max(0, km)))); export interface Rect { x: number; @@ -123,6 +137,36 @@ export function clip(text: string, maxW: number, size: number): string { return `${out}…`; } +/** 여러 줄(254) — 폭 넘으면 낱말(빈칸) 사이에서 · 긴 낱말은 글자에서 줄바꿈 · 최대 줄 넘으면 끝 줄 줄임표 */ +export function wrap(text: string, maxW: number, size: number, maxLines = NOTE_LINES): string[] { + const out: string[] = []; + let line = ""; + for (const word of text.trim().split(/\s+/).filter(Boolean)) { + const next = line ? `${line} ${word}` : word; + if (textW(next, size) <= maxW) { + line = next; + continue; + } + if (line) out.push(line); + line = word; + while (textW(line, size) > maxW) { + let cut = ""; + for (const ch of line) { + if (textW(cut + ch, size) > maxW) break; + cut += ch; + } + out.push(cut); + line = line.slice(cut.length); + } + } + if (line) out.push(line); + if (out.length <= maxLines) return out; + return [...out.slice(0, maxLines - 1), clip(out.slice(maxLines - 1).join(" "), maxW, size)]; +} + +/** 상자 높이(254) = 글 줄(항목 · 이름 · 작은 글씨) · 최소 BOX.h */ +const boxH = (rows: number): number => Math.max(BOX.h, rows * ROW + ROW_PAD); + /** 상자 작은 글씨 — 주소(줄임) · 좌표(위도, 경도 소수 5자리) */ const notesOf = (w: number, address: string | null | undefined, lonlat?: LonLat): string[] => [ ...(address ? [clip(address, w - 2 * INSET, FONT.note)] : []), @@ -162,7 +206,7 @@ export const segHits = (x1: number, y1: number, x2: number, y2: number, r: Rect) segEnter(x1, y1, x2, y2, r) !== null; /** 상자 가운데 → 테두리까지 = 선 방향(dx, dy)의 몇 배 */ -const toEdge = (b: Rect, dx: number, dy: number): number => +const toEdge = (b: { w: number; h: number }, dx: number, dy: number): number => Math.min(dx ? b.w / 2 / Math.abs(dx) : Infinity, dy ? b.h / 2 / Math.abs(dy) : Infinity); /** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */ @@ -181,12 +225,19 @@ export function placeDeg( return own.dir ? DIR_DEG[own.dir] : null; } +/** 구간 km(Calc `legKm` 과 같음 · 형 import 만) */ +const kmOf = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; + +/** 선 글 — 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km · 줄바꿈 글자로 나눔) */ export const legText = (legs: Leg[]): string[] => legs.map((leg) => { - const km = leg.km ?? leg.auto_km ?? null; // Calc `legKm` 과 같음(형 import 만) - return `${leg.road ?? "도로 구분 고르기"} ${km == null ? "-" : String(km)}km`; + const km = kmOf(leg); + return `${leg.road ?? "도로 구분 고르기"}\n${km == null ? "-" : String(km)}km`; }); +/** 구간 km 합(선 길이 · 254) */ +const legsKm = (legs: Leg[]): number => legs.reduce((sum, leg) => sum + Number(kmOf(leg) ?? 0), 0); + /** 자동 값 호버 — 사용자 km 가 자동 값과 다를 때만 */ export const legTip = (leg: Leg): string => leg.auto_km != null && leg.km != null && leg.km !== leg.auto_km @@ -201,7 +252,7 @@ export function setAutoKm(legs: Leg[], km: number): Leg[] { } export interface KmSegment { - /** route = 거래처 구간 · base = 기준점 → 시점 공통 구간 */ + /** route = 거래처 구간 · base = 기준점 구간(→ 시점 · 다른 기준점) */ key: "route" | "base"; text: string; from: [number, number] | null; @@ -211,40 +262,64 @@ export interface KmSegment { const pointOf = (p: { lon?: number | null; lat?: number | null }): [number, number] | null => p.lon != null && p.lat != null ? [p.lon, p.lat] : null; -/** 구간 한 줄(244-13 연결선) — from = 거래처 id(거래처 → 기준점(거침) · 시점) · "base"(기준점 → 시점) · - * 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함) */ +/** 이음 대상 = 시점(Calc `SITE_ID` 와 같음 · 형 import 만) */ +const TO_SITE = "site"; +const baseOf = (data: MilestoneData, id: string): MilestoneBase | undefined => + data.bases.find((b) => b.id === id); +/** 구간 주인 — 기준점 · 거래처(from id) */ +export const ownerOf = (data: MilestoneData, from: string) => + baseOf(data, from) ?? data.routes.find((r) => r.id === from); + +/** 기준점 사슬(254 · Calc `baseChain` 과 같음) — to 부터 시점까지 · 돌기 · 안 이음 · 없음에서 멈춤 */ +export function chainOf(data: MilestoneData, to: string): MilestoneBase[] { + const out: MilestoneBase[] = []; + const seen = new Set(); + for (let id = to; id !== TO_SITE && !seen.has(id);) { + seen.add(id); + const base = baseOf(data, id); + if (!base || base.linked === false) break; + out.push(base); + id = base.to; + } + return out; +} + +/** 돌기(254) — 기준점 from 을 to 에 이으면 to 사슬이 from 으로 돌아옴(A → B → A · 이음 여부 무관) */ +export function makesLoop(data: MilestoneData, from: string, to: string): boolean { + const seen = new Set(); + for (let id = to; id !== TO_SITE && !seen.has(id); id = baseOf(data, id)?.to ?? TO_SITE) { + if (id === from) return true; + seen.add(id); + } + return false; +} + +/** 구간 한 줄(244-13 연결선) — from = 거래처 id · 기준점 id(→ to = 시점 · 기준점) · + * 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함 · 안 이음) */ export function segmentEnds(data: MilestoneData, from: string, start: LonLat): KmSegment | null { - const baseName = data.base.name || "기준점"; - if (from === "base") - return data.base.enabled - ? { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null } - : null; - const route = data.routes.find((r) => r.id === from); - if (!route) return null; - const viaBase = data.base.enabled && route.via_base; + const base = baseOf(data, from); + const owner = base ?? data.routes.find((r) => r.id === from); + if (!owner || owner.linked === false) return null; // 155 안 이은 상자 = 구간 없음 + const target = owner.to === TO_SITE ? undefined : baseOf(data, owner.to); + if (owner.to !== TO_SITE && !target) return null; return { - key: "route", - text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, - from: pointOf(route), - to: viaBase ? pointOf(data.base) : (start ?? null), + key: base ? "base" : "route", + text: `${owner.name || (base ? "기준점" : "거래처")} → ${target ? target.name || "기준점" : "시점"}`, + from: pointOf(owner), + to: target ? pointOf(target) : (start ?? null), }; } -/** 기준점 거쳐 시점 들어가는 구간(244-13 · 도로 구분 2차 · 1차 · 비포장) — 기준점 → 시점 · 거침 거래처 */ -export function chainVia(data: MilestoneData, from: string): boolean { - if (!data.base.enabled || from === "start") return false; - return from === "base" || !!data.routes.find((r) => r.id === from)?.via_base; -} - -/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점 +/** [길찾기] 구간 — 거래처 → to · 그 뒤 기준점 사슬 구간들(254) * (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */ export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] { const first = segmentEnds(data, routeId, start); - if (!first) return []; - return chainVia(data, routeId) ? [first, segmentEnds(data, "base", start)!] : [first]; + const route = data.routes.find((r) => r.id === routeId); + if (!first || !route) return []; + return [first, ...chainOf(data, route.to).map((b) => segmentEnds(data, b.id, start)!)]; } -/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */ +/** 카카오 지도 표지(244-9) — 거래처 · 기준점 사슬(좌표 있을 때) · 시점 */ export function mapMarks( data: MilestoneData, routeId: string, @@ -257,19 +332,15 @@ export function mapMarks( if (p) out.push({ name, lon: p[0], lat: p[1] }); }; add(route.name || "거래처", pointOf(route)); - if (data.base.enabled && route.via_base) add(data.base.name || "기준점", pointOf(data.base)); + if (route.linked !== false) + for (const b of chainOf(data, route.to)) add(b.name || "기준점", pointOf(b)); add("시점", start ?? null); return out; } -/** 자동 km 를 그 구간에(사용자 km 는 그대로) */ -export function putAutoKm( - data: MilestoneData, - routeId: string, - key: KmSegment["key"], - km: number, -): MilestoneData { - const legs = key === "base" ? data.base.legs : data.routes.find((r) => r.id === routeId)?.legs; +/** 자동 km 를 그 구간에(from = 거래처 · 기준점 id · 사용자 km 는 그대로) */ +export function putAutoKm(data: MilestoneData, from: string, km: number): MilestoneData { + const legs = ownerOf(data, from)?.legs; if (legs) setAutoKm(legs, km); return data; } @@ -289,6 +360,7 @@ function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): Dia const t1 = Math.min(toEdge(a, dx, dy) + LINE_GAP / len, 0.5); const t2 = Math.max((segEnter(ax, ay, bx, by, end) ?? 1) - LINE_GAP / len, t1); const labels = legText(legs); + const rows = labels.flatMap((l) => l.split("\n")); return { from: a.id, to: b.id, @@ -300,8 +372,8 @@ function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): Dia tips: legs.map(legTip), lx: (ax + bx) / 2, ly: (ay + by) / 2, - lw: labels.length ? Math.max(...labels.map((l) => textW(l, FONT.km))) + 8 : 0, - lh: labels.length * KM_LINE + 2, + lw: rows.length ? Math.max(...rows.map((l) => textW(l, FONT.km))) + 8 : 0, + lh: rows.length ? rows.length * KM_LINE + 2 : 0, }; } @@ -353,8 +425,10 @@ export function layoutMilestone( mid_address?: string | null; } | null, midKm: number, + pics: ReadonlySet = new Set(), // 254-3 장소 그림 있는 상자 id ): DiagramLayout { const boxes: DiagramBox[] = []; + const picH = (id: string): number => (pics.has(id) ? PIC.h + INSET : 0); const pending: Array<[string, string, Leg[]]> = []; const byId = new Map(); const center = (id: string): [number, number] => { @@ -362,11 +436,19 @@ export function layoutMilestone( return [b.x + b.w / 2, b.y + b.h / 2]; }; /** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */ + // 153 사용자가 끌어 놓은 자리(왼 위 · 그림 좌표) — 있으면 방위 자리 · 밀어 냄 대신 그 자리 + const posOf = (id: string): [number, number] | null | undefined => ownerOf(data, id)?.pos; const put = (box: DiagramBox, out?: [number, number]): void => { const inner = box.w - 2 * INSET; box.label = clip(box.label, inner, FONT.label); if (box.items) box.items = clip(box.items, inner, FONT.items); - for (let i = 0; out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) { + const pos = posOf(box.id); + if (pos) [box.x, box.y] = pos; + // 254 선 길이가 짧아져 앞서 놓인 선 위에 앉지 않게 — 놓인 상자 · 놓인 선(가운데 ↔ 가운데) 둘 다 피함 + const blocked = (): boolean => + boxes.some((b) => hits(box, b, GAP)) || + pending.some(([f, t]) => segHits(...center(f), ...center(t), box)); + for (let i = 0; !pos && out && i < 400 && blocked(); i++) { box.x += out[0] * 4; box.y += out[1] * 4; } @@ -406,31 +488,68 @@ export function layoutMilestone( line("start", "mid", [{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]); const startLL = site?.start_lonlat ?? null; - const useBase = data.base.enabled; - const baseLL: LonLat = - data.base.lon != null && data.base.lat != null ? [data.base.lon, data.base.lat] : null; - let baseDeg = 270; - if (useBase) { - baseDeg = placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270; - const [ux, uy] = unit(baseDeg); - const cx = ux * R_BASE; - const cy = uy * R_BASE; + const llOf = (p?: { lon?: number | null; lat?: number | null }): LonLat => + p && p.lon != null && p.lat != null ? [p.lon, p.lat] : null; + const used = new Set(); + const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; + /** 상자 id → 잇는 곳에서 본 방위(기준점 둘레 빈 방위 = 그 방위에서 돌림 · 되돌아가는 쪽 뺌) */ + const degOf = new Map(); + let auto = 0; + /** 방위 안 정한 상자 — 빈 8방 차례(기준점 둘레 = 잇는 곳 반대편부터 · 잇는 곳으로 향하는 쪽 뺌) */ + const free = (anchor: string): number => { + const turn = anchor === "site" ? 0 : (degOf.get(anchor) ?? 0); + const blocked = anchor === "site" ? -1 : turn + 180; + const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter( + (c) => keyOf(anchor, c) !== keyOf(anchor, blocked), + ); + return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; + }; + /** 잇는 곳 가운데 → 상자 가운데(254) = 잇는 곳 테두리 + 상자 테두리 + 선 길이(구간 km 비례) */ + const reachOf = (anchor: string, h: number, ux: number, uy: number, legs: Leg[]): number => + toEdge(byId.get(anchor)!, ux, uy) + toEdge({ w: BOX.w, h }, ux, uy) + lineLen(legsKm(legs)); + /** 잇는 곳 상자 — 놓인 기준점이면 그 id · 아니면 공사지 */ + const anchorOf = (to: string): string => (to !== "site" && byId.has(to) ? to : "site"); + + // 기준점(254 · 여럿) — 잇는 곳(시점 · 다른 기준점) 기준 방위(좌표 → 방위각 · 없으면 서부터 빈 방위) · + // 잇는 곳이 먼저 놓이게 사슬 차례 · 돌기 = 공사지 둘레 + const waiting = [...data.bases]; + while (waiting.length) { + let i = waiting.findIndex( + (b) => b.linked === false || b.to === "site" || byId.has(b.to) || !ownerOf(data, b.to), + ); + if (i < 0) i = 0; + const [b] = waiting.splice(i, 1); + const anchor = b.linked === false ? "site" : anchorOf(b.to); + const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL); + const deg = + placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ?? + (anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor)); + used.add(keyOf(anchor, deg)); + degOf.set(b.id, deg); + const [ax, ay] = center(anchor); + const [ux, uy] = unit(deg); + const notes = b.address ? wrap(b.address, BOX.w - 2 * INSET, FONT.note) : []; + const h = boxH(1 + notes.length) + picH(b.id); + const reach = reachOf(anchor, h, ux, uy, b.legs); put( { - id: "base", + id: b.id, kind: "base", - x: cx - BOX.w / 2, - y: cy - BOX.h / 2, + x: ax + ux * reach - BOX.w / 2, + y: ay + uy * reach - h / 2, w: BOX.w, - h: BOX.h, - label: data.base.name || "기준점", + h, + label: b.name || "기준점", + notes, + tip: b.address || undefined, }, [ux, uy], ); - line("base", "start", data.base.legs); + if (b.linked !== false && (b.to === "site" || byId.has(b.to))) + line(b.id, b.to === "site" ? "start" : b.to, b.legs); } - // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 + // 거래처 — 잇는 곳(기준점 · 공사지) 기준 방위 · 같은 방위끼리 옆으로 나란히 const groups = new Map< string, Array<{ @@ -441,31 +560,24 @@ export function layoutMilestone( anchor: string; deg: number; legs: Leg[]; + linked: boolean; + notes: string[]; + h: number; }> >(); - const used = new Set(); - const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; const items = data.routes.map((r) => { - const anchor = useBase && r.via_base ? "base" : "site"; - return { r, anchor, deg: placeDeg(r, anchor === "base" ? (baseLL ?? startLL) : startLL) }; + const anchor = anchorOf(r.to); + const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL); + return { r, anchor, deg: placeDeg(r, anchorLL) }; }); for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg)); - let auto = 0; - /** 방위 안 고른 거래처 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */ - const free = (anchor: string): number => { - const turn = anchor === "base" ? baseDeg : 0; - const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1; - const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter( - (c) => keyOf(anchor, c) !== keyOf(anchor, blocked), - ); - return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; - }; for (const { r, anchor, deg } of items) { const d = deg ?? free(anchor); used.add(keyOf(anchor, d)); const key = keyOf(anchor, d); if (!groups.has(key)) groups.set(key, []); const items = [...new Set(r.rows.map((row) => row.item.trim()).filter(Boolean))].join(" · "); + const notes = r.address ? wrap(r.address, BOX.w - 2 * INSET, FONT.note) : []; groups.get(key)!.push({ id: r.id, label: r.name || "거래처", @@ -474,16 +586,21 @@ export function layoutMilestone( anchor, deg: d, legs: r.legs, + // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음 + linked: r.linked !== false && (r.to === "site" || anchor === r.to), + notes, + h: boxH((items ? 1 : 0) + 1 + notes.length) + picH(r.id), }); } for (const group of groups.values()) { group.forEach((g, k) => { - const [ax, ay] = center(g.anchor === "base" ? "base" : "site"); + const [ax, ay] = center(g.anchor); const [ux, uy] = unit(g.deg); - // 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④) - const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP; + // 나란히 = 방위에 직각 · 상자 직각 폭(무리에서 가장 높은 상자) + 여유만큼(④) + const tall = Math.max(...group.map((x) => x.h)); + const spread = Math.abs(ux) * tall + Math.abs(uy) * BOX.w + GAP; const off = (k - (group.length - 1) / 2) * spread; - const reach = g.anchor === "site" ? R_BASE : R_ROUTE; // 공사지 둘레 = 기준점과 같은 거리(선 글 자리) + const reach = reachOf(g.anchor, g.h, ux, uy, g.legs); const cx = ax + ux * reach - uy * off; const cy = ay + uy * reach + ux * off; put( @@ -491,20 +608,23 @@ export function layoutMilestone( id: g.id, kind: "route", x: cx - BOX.w / 2, - y: cy - BOX.h / 2, + y: cy - g.h / 2, w: BOX.w, - h: BOX.h, + h: g.h, label: g.label, items: g.items, - notes: notesOf(BOX.w, g.address), + notes: g.notes, tip: g.address, }, [ux, uy], ); - line(g.id, g.anchor === "base" ? "base" : "start", g.legs); + if (g.linked) line(g.id, g.anchor === "site" ? "start" : g.anchor, g.legs); }); } + // 254-3 장소 그림 = 글 아래 · 안쪽 여백 안(그림 없으면 자리 없음) + for (const b of boxes) + if (pics.has(b.id)) b.pic = { x: b.x + INSET, y: b.y + b.h - INSET - PIC.h, ...PIC }; const lines = pending.map(([from, to, legs]) => edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!), ); @@ -523,18 +643,10 @@ export function layoutMilestone( }; } -/** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/link/by/car/이름,위도,경도/…` · 경유 ≤ 5) */ -export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: number }>): string { - const part = (p: { name: string; lon: number; lat: number }): string => - `${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`; - return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`; -} - -/** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ +/** 선 글 한 줄 → 그 구간 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */ export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null { if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }; - const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; - return legs?.[i] ?? null; + return ownerOf(data, from)?.legs[i] ?? null; } /** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */ @@ -550,8 +662,7 @@ export function setLeg( data.site.mid_station_m = patch.km == null ? null : Math.round(patch.km * 1000 * 1000) / 1000; return data; } - const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; - const leg = legs?.[i]; + const leg = ownerOf(data, from)?.legs[i]; if (leg) Object.assign(leg, patch); return data; } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 01f281924..793e45621 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -9,6 +9,10 @@ POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 · 표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` · 좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄 + POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 · + 이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름 + GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/.png` + · `auto/.png` · `.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음) GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램 지금 중점 측점(244-12 · 상자 작은 글씨) @@ -24,17 +28,29 @@ 그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자 그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님). 그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움. +장소 그림(256-1) = `place/<상자 id>.png` · [저장] 뒤 서버가 좌표가 바뀐(또는 그림 없는) 상자만 +다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 · +GET 답 `place_images` {상자 id: "place/.png"} · `place_times` {상자 id: 바뀐 시각 ms} +(캐시 깨기). +경로 그림(256-2) = [저장] 뒤 같은 길로 경로 탭과 같은 전체 경로(거래처 → 기준점 사슬 → 시점 · +구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · 표지 = `mapMarks` 차례) → `auto/<경로 id>.png` +· 지문(표지 · 수기) 바뀐 경로만 · 화면 [길찾기] 그림(거래처 구간만)이 들어오면 지문을 지워 다음 +[저장]에 전체로. 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). 표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). +기준점 = `bases[]`(254 · 여럿 · to = 시점 또는 다른 기준점) · 경로 `to` — 옛 `base` · `via_base` 는 +읽을 때 · 받을 때 새 꼴로 옮김(`migrate_milestone` · 저장 = 새 꼴). """ from __future__ import annotations import asyncio +import hashlib import json import math import os +import threading import urllib.error import urllib.parse import urllib.request @@ -42,16 +58,24 @@ from pathlib import Path from typing import Annotated, Any, Literal from uuid import UUID -from fastapi import APIRouter, File, HTTPException, Query, UploadFile +from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field, ValidationError, model_validator from B01_Dashboard.B01_Dashboard_Map import reverse_geocode from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 -from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image +from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes +from B08_Quantity.B08_Quantity_Milestone_Calc import ( + SITE, + base_chain, + compute_milestone, + migrate_milestone, + r3, +) +from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B08_Quantity.B08_Quantity_Router_Material import material_sheets from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import ( @@ -67,8 +91,11 @@ PATH = "/{project_id}/quantity/milestone" FOLDER = Path("B08_Quantity") / "milestone" DATA_FILE = "milestone.json" AUTO = "auto" # 자동 그림 하위 폴더(244-11) +PLACE = "place" # 장소 그림 하위 폴더(256-1) +PRINTS = ".prints.json" # 그림 지문 {"place/": 해시} — 바뀐 것만 다시 캡처 IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" +BOX_IDS = {SITE, "start", "mid"} # 다이어그램 공사지 상자 id — 기준점 · 경로 id 로 못 씀 Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13) Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] @@ -92,34 +119,59 @@ class Place(BaseModel): dir: Dir | None = None lon: float | None = Field(default=None, ge=-180, le=180) lat: float | None = Field(default=None, ge=-90, le=90) + # 153 다이어그램에 끌어 놓은 자리(왼 위 · 그림 좌표) — 없으면 방위 자동 자리 + pos: tuple[float, float] | None = None + manual: bool = False # 155 수기 입력 — 카카오 길찾기 안 함 legs: list[Leg] = Field(default_factory=list, max_length=20) class Route(Place): id: str = Field(pattern=ROUTE_ID) - via_base: bool = True + to: str = Field(default=SITE, pattern=ROUTE_ID) # 254 잇는 곳 = "site"(시점) · 기준점 id + # 251 운반 묶음 키(총괄자재 · B09 운반 줄과 이음) · 단가연결(dg · sg · logic 꼴 · 정본) + haul: str | None = Field(default=None, max_length=60, pattern=r"^[A-Za-z0-9_-]+$") + ref: dict[str, Any] | None = None + linked: bool = True # 155 False = 아직 선 안 이음(요소 추가 뒤) rows: list[Row] = Field(default_factory=list, max_length=50) class Base(Place): - enabled: bool = False + """기준점(254 · 여럿) — to = 시점 또는 다른 기준점 id(돌기는 화면이 막고 셈이 끊음).""" + + id: str = Field(pattern=ROUTE_ID) + to: str = Field(default=SITE, pattern=ROUTE_ID) + linked: bool = True # False = 아직 선 안 이음(요소 추가 뒤) + + +class LonLat(BaseModel): + lon: float = Field(ge=-180, le=180) + lat: float = Field(ge=-90, le=90) class Site(BaseModel): mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000) mid_road: Road = "비포장" + start_pin: LonLat | None = None # 255-2 길찾기용 시점 덮개(노선은 그대로) class Milestone(BaseModel): site: Site = Field(default_factory=Site) - base: Base = Field(default_factory=Base) + bases: list[Base] = Field(default_factory=list, max_length=20) routes: list[Route] = Field(default_factory=list, max_length=100) + @model_validator(mode="before") + @classmethod + def _migrate(cls, data: Any) -> Any: + """옛 꼴(단일 base · via_base) → 새 꼴(254).""" + return migrate_milestone(data) if isinstance(data, dict) else data + @model_validator(mode="after") def _unique_ids(self) -> Milestone: - ids = [route.id for route in self.routes] + ids = [b.id for b in self.bases] + [route.id for route in self.routes] if len(ids) != len(set(ids)): - raise ValueError("경로 id 가 겹칩니다.") + raise ValueError("경로 · 기준점 id 가 겹칩니다.") + if BOX_IDS & set(ids): + raise ValueError("site · start · mid 는 id 로 못 씁니다.") return self @@ -203,15 +255,29 @@ def images(root: str | Path, sub: str = "") -> dict[str, str]: } +def places(data: dict[str, Any]) -> list[tuple[str, str, float, float]]: + """좌표가 정해진 상자 → [(상자 id, 이름, 경도, 위도)] — 거래처 = 경로 id · 기준점 = bases id.""" + boxes = [ + (b["id"], b.get("name") or "", b.get("lon"), b.get("lat")) + for b in [*data.get("routes", []), *data.get("bases", [])] + ] + return [(i, n, lon, lat) for i, n, lon, lat in boxes if lon is not None and lat is not None] + + def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]: site = site_info(root, data["site"]["mid_station_m"]) auto = images(root, AUTO) + place = images(root, PLACE) return { "data": data, "site": site, "table": compute_milestone(data, site["mid_default_m"]), "images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선 "auto_images": auto, + "place_images": place, + "place_times": { + stem: int((folder(root) / name).stat().st_mtime * 1000) for stem, name in place.items() + }, } @@ -221,13 +287,99 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]: path.mkdir(parents=True, exist_ok=True) atomic_write_json(path / DATA_FILE, data) keep = {route["id"] for route in data["routes"]} - for sub in ("", AUTO): + for sub, ids in (("", keep), (AUTO, keep), (PLACE, {p[0] for p in places(data)})): for stem, name in images(root, sub).items(): - if stem not in keep: + if stem not in ids: (path / name).unlink(missing_ok=True) return payload(root, data) +def _print(*parts: Any) -> str: + """지문 — 좌표 · 이름 해시(좌표 원문은 안 남김).""" + return hashlib.sha1(json.dumps(parts, ensure_ascii=False).encode()).hexdigest()[:16] + + +def _read_prints(root: str | Path) -> dict[str, str]: + try: + return json.loads((folder(root) / PRINTS).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def _set_print(root: str | Path, key: str, value: str | None) -> None: + """지문 한 칸 적음 · None = 지움(다음 [저장]에 다시 캡처).""" + with _prints_lock: + prints = _read_prints(root) + if prints.get(key) == value: + return + if value is None: + prints.pop(key, None) + else: + prints[key] = value + atomic_write_json(folder(root) / PRINTS, prints) + + +_prints_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 지문 파일 읽고 쓰기 한 번에 하나 + + +def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: float) -> dict: + """장소 그림 한 장 캡처 → 지문 적음 → {saved, reason}.""" + got = make_place_image(folder(root) / PLACE, place_id, {"name": name, "lon": lon, "lat": lat}) + if got["saved"]: + _set_print(root, f"{PLACE}/{place_id}", _print(name, lon, lat)) + return got + + +def route_chain(data: dict[str, Any], route: dict[str, Any], start: list[float] | None) -> list: + """경로 그림 사슬 — [(이름, 경도, 위도, 수기)] · 화면 `mapMarks` 와 같은 차례 · + 좌표 없는 상자 뺌.""" + owners = [(route.get("name") or "거래처", route)] + if route.get("linked", True) is not False: + owners += [ + (b.get("name") or "기준점", b) for b in base_chain(data, route.get("to") or SITE) + ] + out = [(n, o.get("lon"), o.get("lat"), bool(o.get("manual"))) for n, o in owners] + if start: + out.append(("시점", start[0], start[1], False)) + return [c for c in out if c[1] is not None and c[2] is not None] + + +def route_image(root: str | Path, route_id: str, chain: list) -> dict[str, Any]: + """경로 그림 한 장 — 구간마다 카카오 길 선(수기 · 실패 = 곧은 선) → 캡처 → 지문 적음.""" + lines = [] + for (_, lon1, lat1, manual), (_, lon2, lat2, _) in zip(chain, chain[1:]): + path = [] + if not manual: + got = directions_km(LonLat(lon=lon1, lat=lat1), LonLat(lon=lon2, lat=lat2)) + path = got.get("path") or [] + lines.append(path or [[lat1, lon1], [lat2, lon2]]) + marks = [{"name": n, "lon": lon, "lat": lat} for n, lon, lat, _ in chain] + got = make_route_image(folder(root) / AUTO, route_id, lines, marks) + if got["saved"]: + _set_print(root, f"{AUTO}/{route_id}", _print(chain)) + return got + + +def refresh_images(root: str | Path) -> None: + """[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 · + 실패 = 다음 [저장]에 다시.""" + data = read_data(root) + prints = _read_prints(root) + have = images(root, PLACE) + for place_id, name, lon, lat in places(data): + if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have: + place_image(root, place_id, name, lon, lat) + start = site_info(root, None)["start_lonlat"] + have = images(root, AUTO) + for route in data["routes"]: + chain = route_chain(data, route, start) + key = f"{AUTO}/{route['id']}" + if route.get("linked", True) is False or len(chain) < 2: + continue # 안 이은 거래처 = 경로 그림 없음 + if prints.get(key) != _print(chain) or route["id"] not in have: + route_image(root, route["id"], chain) + + def _route_id(route: str) -> str: try: return Route(id=route).id @@ -235,16 +387,32 @@ def _route_id(route: str) -> str: raise HTTPException(status_code=400, detail="경로 id 가 바르지 않습니다.") from error +async def _haul(project_id: UUID) -> list[dict[str, Any]]: + """운반 묶음(251) — 자재 집계 못 읽으면 빈 목록(거리이정표는 그대로).""" + try: + return haul_groups(await material_sheets(project_id)) + except Exception: # noqa: BLE001 — 자재 집계 없음 · 실패 = 자동 상자 없음 + return [] + + @router.get(PATH) async def milestone_read(project_id: UUID) -> dict[str, Any]: root = await _project_root(project_id) - return await asyncio.to_thread(lambda: payload(root, read_data(root))) + groups = await _haul(project_id) + return await asyncio.to_thread(lambda: payload(root, with_haul_routes(read_data(root), groups))) @router.put(PATH) -async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: +async def milestone_save( + project_id: UUID, body: Milestone, tasks: BackgroundTasks +) -> dict[str, Any]: root = await _project_root(project_id) - return await asyncio.to_thread(save_data, root, body) + out = await asyncio.to_thread(save_data, root, body) + if not os.environ.get( + "PYTEST_CURRENT_TEST" + ): # 남의 시험 [저장] = 실제 카카오 · 브라우저 안 부름 + tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임 + return out @router.get(PATH + "/site-address") @@ -354,18 +522,41 @@ async def milestone_image_auto( lines = [[list(p) for p in line] for line in body.lines] marks = [m.model_dump() for m in body.marks] got = await asyncio.to_thread(make_route_image, folder(root) / AUTO, route_id, lines, marks) + if got["saved"]: # 거래처 구간만 그림 — 다음 [저장]에 전체 경로로 다시(256-2) + await asyncio.to_thread(_set_print, root, f"{AUTO}/{route_id}", None) return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]} +FILE_NAME = r"^(?:(?:auto|place)/)?[A-Za-z0-9_-]{1,40}\.(?:png|jpe?g|gif|webp|bmp)$" + + +@router.post(PATH + "/place-image") +async def milestone_place_image(project_id: UUID, body: Mark, id: str = Query(...)) -> dict: + """장소 그림(256-1) — 등록 · 수정 · 끌기 뒤 화면이 부름 → place/.png · 좌표는 저장 안 함.""" + place_id = _route_id(id) + root = await _project_root(project_id) + got = await asyncio.to_thread(place_image, root, place_id, body.name, body.lon, body.lat) + if not got["saved"]: + return {"name": None, "time": None, "reason": got["reason"]} + name = f"{PLACE}/{place_id}.png" + time_ms = int((folder(root) / name).stat().st_mtime * 1000) + return {"name": name, "time": time_ms, "reason": None} + + +@router.get(PATH + "/file") +async def milestone_file(project_id: UUID, name: str = Query(..., pattern=FILE_NAME)): + """그림 파일(256-1) — 하위 폴더 auto · place 만(패턴이 경로 넘기 막음).""" + root = await _project_root(project_id) + path = folder(root) / name + if not path.is_file(): + raise HTTPException(status_code=404, detail="그림이 없습니다.") + return FileResponse(path) + + KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" KAKAO_KEY = "KAKAO_REST_API_KEY" -class LonLat(BaseModel): - lon: float = Field(ge=-180, le=180) - lat: float = Field(ge=-90, le=90) - - class Directions(BaseModel): origin: LonLat destination: LonLat diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index ac64372d5..c29233cc3 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -287,7 +287,7 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro applyRock(); const tabs = document.createElement("div"); - tabs.className = "b08-quantity__tabs b08-earthwork__tabs"; + tabs.className = "b08-quantity__tabs ui-subtabs b08-earthwork__tabs"; tabs.setAttribute("role", "tablist"); const view = document.createElement("div"); let current: SheetId = "summary"; diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index 5056c993a..e33d2009b 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -110,7 +110,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise ) as unknown as HydroInputs; const tabs = document.createElement("div"); - tabs.className = "b08-quantity__tabs b08-hydro__tabs"; + tabs.className = "b08-quantity__tabs ui-subtabs b08-hydro__tabs"; tabs.setAttribute("role", "tablist"); const save = createButton({ label: "저장", diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 90ff5b010..09b24921d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -4,13 +4,17 @@ display: flex; flex-direction: column; gap: var(--spacing-8); - /* 틀 너비 = 표 너비(244-6) — 다이어그램은 너비를 보태지 않고 표에 맞춰 늘음 */ - width: fit-content; - max-width: 100%; + /* 149 — 페이지 폭 전체(다이어그램 · 표 같은 폭) */ + width: 100%; } -.b08-milestone > .b08-milestone__diagram { - contain: inline-size; +/* 149 표도 폭 전체 — 열 너비는 비율대로 늘음 */ +.b08-milestone > .ui-data-table { + width: 100%; +} + +.b08-milestone > .ui-data-table .ui-data-table__table { + width: 100%; } .b08-milestone__route { @@ -52,7 +56,19 @@ } /* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ +/* 153 · 155 · 157 · 159 [방위 배치] · [요소 추가] — 다이어그램 왼쪽 아래 · 단추 = 메인 탭 크기 */ +.b08-milestone__tools { + position: absolute; + bottom: 8px; + left: 8px; + display: flex; + gap: 6px; +} + .b08-milestone__diagram { + position: relative; + /* 149 높이 고정 · 그림은 틀 비율로 넓힌 범위에 맞춤(스크롤 없음 · 244-8) */ + height: clamp(420px, 58vh, 760px); overflow: hidden; border: 1px solid var(--color-border); border-radius: 6px; @@ -61,7 +77,6 @@ .b08-milestone__diagram svg { display: block; - margin: 0 auto; font-size: 13px; } @@ -76,23 +91,35 @@ stroke-width: 1.2; } -.b08-milestone__box text, -.b08-milestone__plus text { +.b08-milestone__box text { fill: var(--color-text); } +/* 153 상자 색 — 공사지(시점 · 중점) = 강조색 · 기준점 = 경고색 · 거래처 = 성공색 · 옅은 채움 + 테두리(테마 변수 · 밝음 · 어두움 같이) */ .b08-milestone__box.is-site rect { - fill: var(--color-surface-raised); + fill: color-mix(in srgb, var(--color-accent) 7%, var(--color-surface)); + stroke: var(--color-accent); stroke-dasharray: 4 3; } -.b08-milestone__box.is-base rect, .b08-milestone__box.is-start rect, .b08-milestone__box.is-mid rect { + fill: color-mix(in srgb, var(--color-accent) 16%, var(--color-surface)); stroke: var(--color-accent); } -.b08-milestone__box.is-route { +.b08-milestone__box.is-base rect { + fill: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + stroke: var(--color-warning); +} + +.b08-milestone__box.is-route rect { + fill: color-mix(in srgb, var(--color-success) 14%, var(--color-surface)); + stroke: var(--color-success); +} + +.b08-milestone__box.is-route, +.b08-milestone__box.is-base { cursor: pointer; } @@ -102,16 +129,6 @@ stroke-width: 2; } -.b08-milestone__plus { - cursor: pointer; -} - -.b08-milestone__plus circle { - fill: var(--color-surface); - stroke: var(--color-accent); - stroke-width: 1.5; -} - .b08-milestone__km { fill: var(--color-text); font-size: 12px; @@ -154,10 +171,10 @@ width: 80px; } -/* 거래처 상자 윗줄 = 항목(244-6) */ +/* 거래처 상자 윗줄 = 항목(244-6) · 글 크기 = 거래처 이름(254) */ .b08-milestone__items { fill: var(--color-text-secondary); - font-size: 12px; + font-size: 13px; } /* 상자 작은 글씨 = 주소 · 좌표(244-12) */ @@ -166,15 +183,15 @@ font-size: 10px; } -/* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */ -.b08-milestone .is-pick-road { - color: var(--color-danger); +/* 254-3 상자 안 장소 그림 — 누름 · 끌기는 상자 몫 */ +.b08-milestone__pic { + pointer-events: none; } -/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 */ +/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 · 높이 어디서나 한 값(150) */ .b08-milestone__map { width: 100%; - height: 360px; + height: 480px; border: 1px solid var(--color-border); } @@ -187,6 +204,19 @@ white-space: nowrap; } +/* 262-2 고정 시점 표지 — 둥근 붉은 알약(고르는 표지 = 기본 핀 + 흰 이름표와 구별) */ +.b08-milestone__map-start { + padding: 3px 10px; + border: 2px solid #fff; + border-radius: 999px; + background: #e60046; + color: #fff; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + pointer-events: none; +} + /* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */ .ui-modal.b08-milestone__modal { width: min(960px, 94vw); @@ -205,6 +235,18 @@ font-weight: 600; } +/* 255-3 칸 이름 = 내용 글 크기 이상 · 한 줄 · 소재지(주소 · 지도) 위 가로선 */ +.b08-milestone__modal .ui-field__label { + font-size: var(--text-body-sm); + white-space: nowrap; +} + +.b08-milestone__place-field { + margin-top: var(--spacing-8); + padding-top: var(--spacing-16); + border-top: 1px solid var(--color-border); +} + /* 연결선 누름 자리 — 투명한 굵은 선(244-13) */ .b08-milestone__line-hit { stroke: transparent; @@ -212,3 +254,64 @@ pointer-events: stroke; cursor: pointer; } + +/* 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254) · 끌어 기준점 · 공사지에 놓으면 연결 */ +.b08-milestone__connect { + fill: var(--color-success); + stroke: var(--color-surface); + stroke-width: 2; + cursor: crosshair; + opacity: 0.55; +} + +.b08-milestone__box:hover .b08-milestone__connect { + opacity: 1; +} + +.b08-milestone__wire { + stroke: var(--color-success); + stroke-width: 2; + stroke-dasharray: 6 4; + pointer-events: none; +} + +.b08-milestone__box.is-drop-target rect { + stroke-width: 3; + stroke-dasharray: none; +} + +/* 157 경로 탭 머리 줄 — 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기 */ +.b08-milestone__route-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-16); +} + +.b08-milestone__route-title { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8) var(--spacing-16); + font-size: var(--text-body-sm); +} + +.b08-milestone__route-addr { + color: var(--color-text-secondary); +} + +/* 단가연결 램프(251-4) — 항목 칸 앞 · 노랑 = 없음 · 초록 = 연결 */ +[data-b08-milestone="table"] td.b08-milestone__lamp::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + border-radius: 50%; + background: var(--color-warning); + vertical-align: middle; +} + +[data-b08-milestone="table"] td.b08-milestone__lamp.is-linked::before { + background: var(--color-success); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index df187f338..09a8f979d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -9,11 +9,18 @@ * 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도 * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) * 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State` + * 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] · + * [단가연결 풀기] — 창 = 연결 없음 → 단가연결 목록 → 옵션 값 창(276 · 옛 길) · 연결 있음 → + * 「운반 단가」 모달(253-3 · B09 운반공 줄과 같음 · 그 행 거리) · 경로 `ref` · B09 운반공 줄이 받음 * ========================================================================== */ import "./B08_Quantity_UI_Milestone.css"; import { createDataTable, type DataColumn } from "@ui/ui_template_data_table"; import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base"; +import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal"; +import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link"; import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; import { @@ -35,6 +42,18 @@ import { const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; const ROAD = ["도로 구분 (km)"]; const SUM = ["계 (km)"]; +/** 단가연결 램프 칸 class(`B08_Quantity_UI_Milestone.css`) */ +const LAMP = "b08-milestone__lamp"; + +/** 램프 호버 글 — 연결된 것 */ +export function refText(ref: Record | null | undefined): string { + if (!ref) return "단가연결 없음 — 줄 우클릭 [단가연결]"; + const what = + ref.tab === "logic" + ? `단가산출 ${String(ref.code ?? "")} ${String(ref.name ?? "")}` + : `일위대가 ${String(ref.name ?? ref.key ?? "")}`; + return `단가연결 — ${what.trim()}`; +} /** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ @@ -53,7 +72,7 @@ export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColu { key: "un", label: "비포장", group: ROAD, ...KM }, ]; return [ - { key: "item", label: "항목", width: 120 }, + { key: "item", label: "항목", width: 134 }, { key: "company", label: "거래처 이름", width: 120 }, { key: "dest", label: "목적지", width: 72 }, ...roads, @@ -70,7 +89,7 @@ export function addRoute(projectId: string, init: Partial = {}): id, name: `거래처 ${data.routes.length + 1}`, address: "", - via_base: true, + to: "site", dir: null, legs: [{ road: null, km: null }], // 도로 구분 = 사용자 고름(244-7) rows: [{ item: "", dest: "start", note: "" }], @@ -83,8 +102,9 @@ export function addRoute(projectId: string, init: Partial = {}): /** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */ function tableView(projectId: string): MilestoneView { const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); - let keys: Array<{ route: string; index: number }> = []; - let pickRows: boolean[] = []; + let keys: Array<{ route: string; index: number; L1: number; L2: number }> = []; + const routeAt = (i: number) => + liveMilestone(projectId).routes.find((r) => r.id === keys[i]?.route); const makeTable = (columns: DataColumn[]) => { const t = createDataTable({ columns, @@ -92,7 +112,7 @@ function tableView(projectId: string): MilestoneView { caption: "거리이정표", empty: "", editOnDouble: true, - cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null), + cellClass: (i, key) => (key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP), editable: (_i, key) => key === "item" || key === "company" || key === "note", onEdit: (i, key, text) => { const at = keys[i]; @@ -118,6 +138,38 @@ function tableView(projectId: string): MilestoneView { }); trackCells(t.root); t.root.dataset.b08Milestone = "table"; + attachHoverInfo( + t.root, + (cell) => refText(routeAt(Number(cell.closest("tr")?.dataset.i))?.ref), + `td.${LAMP}`, + ); + t.root.addEventListener("contextmenu", (event) => { + const tr = (event.target as Element).closest("tr[data-i]"); + const route = tr ? routeAt(Number(tr.dataset.i)) : undefined; + if (!route) return; + event.preventDefault(); + const link = (ref: Row | null, from?: Row): void => + editRoute(projectId, route.id, (_, r) => { + // 호버 글 이름(사전조합 · 구조물 조합 = 들여온 대가 이름) — B09 는 tab · key 만 읽음 + r.ref = ref && { ...ref, name: ref.name ?? from?.name ?? "" }; + }); + const at = keys[Number(tr?.dataset.i)]; + pickAtCell([ + [ + "단가연결", + () => + route.ref + ? openHaulModal({ + ref: route.ref as Row, + dist: at && { L1: at.L1, L2: at.L2 }, + name: route.rows[at?.index ?? 0]?.item || route.name || "운반", + apply: link, + }) + : void openPriceLink({ apply: link }), + ], + ...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []), + ]); + }); return t; }; let mode = "false:false"; @@ -130,29 +182,26 @@ function tableView(projectId: string): MilestoneView { const data = liveMilestone(projectId); const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null); // 기준점 생김 · 없앰 = 열 바뀜 → 표 새로 - const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`; + const next = `${data.bases.length > 0}:${rows.some((r) => r.pv > 0)}`; if (next !== mode) { mode = next; const fresh = makeTable( milestoneColumns( - data.base.enabled, + data.bases.length > 0, rows.some((r) => r.pv > 0), ), ); table.root.replaceWith(fresh.root); table = fresh; } - keys = rows.map(({ route, index }) => ({ route, index })); - pickRows = rows.map((r) => r.pick); + keys = rows.map(({ route, index, paved, unpaved }) => ({ + route, + index, + L1: paved, + L2: unpaved, + })); none.hidden = rows.length > 0; - // 도로 구분 안 고른 구간 = 합계 칸에 「도로 구분 고르기」(그 km 는 합에 안 듦 · 244-7) - table.setRows( - rows.map((r) => ({ - ...r, - dest: `${DEST_TEXT[r.dest]} ▾`, - total: r.pick ? "도로 구분 고르기" : r.total, - })), - ); + table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); diagram.refresh(); }; refresh(); @@ -164,7 +213,7 @@ function tableView(projectId: string): MilestoneView { export function renderMilestone(host: HTMLElement, projectId: string): void { const tabs = el("div", { - className: "b08-quantity__tabs b08-structure__tabs", + className: "b08-quantity__tabs ui-subtabs b08-structure__tabs", attrs: { role: "tablist" }, }); const view = el("div"); @@ -187,7 +236,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void { }; tabs.replaceChildren( tab("table", "거리이정표"), - ...routes.map((r, i) => tab(r.id, r.name.trim() || `거래처 ${i + 1}`)), + ...routes.map((r, i) => tab(r.id, r.name.trim() || r.rows[0]?.item || `거래처 ${i + 1}`)), ); }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 91cb5e2d6..ef0fa6f26 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -7,30 +7,44 @@ * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 = [+] 등록 모달 분류 「기준위치」(244-13 · [기준점 +] 없앰) - * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 고침 · 삭제) · 선 · 선 글 누름 = 연결선 모달 + * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 수정 · 삭제) · 선 · 선 글 누름 = 연결선 모달 * (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) - * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) + * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) · + * 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km) + * 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소) + * 장소 그림(254-3) = 서버 `place/<상자 id>.png`(256-1) 을 상자 글 아래 작게 · 그림 없으면 자리 없음 * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ -import { createSelectField, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; -import { midKm, type MilestoneRoute, type Road } from "./B08_Quantity_Milestone_Calc"; import { - chainVia, + midKm, + roadOf, + segKind, + withRoads, + type MilestoneRoute, + type Road, +} from "./B08_Quantity_Milestone_Calc"; +import { layoutMilestone, legAt, + makesLoop, + ownerOf, panContent, panStart, setLeg, type DiagramBox, } from "./B08_Quantity_Milestone_Layout"; import { openLegInfo } from "./B08_Quantity_UI_Milestone_LegModal"; -import { openPlaceModal, openSiteInfo } from "./B08_Quantity_UI_Milestone_Modal"; +import { fmtKm, openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milestone_Modal"; import { + findSegmentKm, liveMilestone, + placeImageBlobUrl, + placeImageKey, putMilestone, roadOptions, siteAddress, @@ -62,21 +76,23 @@ function editLegAt( const leg = legAt(data, from, i, midKm); if (!leg) return; document.querySelector(".b08-milestone__leg-edit")?.remove(); + const kind = segKind(data, from); const road = createSelectField({ label: "도로 구분", - options: roadOptions(chainVia(data, from), from !== "start", leg.road), // 시점 → 중점 = 늘 고름 + options: roadOptions(kind), // 포장 종류 = 선 자리(152) · 비포장만 고름 compact: true, }); - road.select.value = leg.road ?? ""; + road.select.value = roadOf(leg.road, kind); const km = el("input", { className: "ui-input", attrs: { type: "number", min: "0", step: "any" }, }); - km.value = leg.km == null ? "" : String(leg.km); + km.value = leg.km == null ? "" : fmtKm(leg.km); + const shown = km.value; // 안 고치면 저장 값 그대로 · 반올림 값으로 안 덮음 km.setAttribute("aria-label", "km"); if (leg.auto_km != null) { - km.placeholder = String(leg.auto_km); // 빈칸 = 자동 값 - km.title = `자동 값 ${leg.auto_km} km`; + km.placeholder = fmtKm(leg.auto_km); // 빈칸 = 자동 값 + km.title = `자동 값 ${fmtKm(leg.auto_km)} km`; } const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] }); box.dataset.b08MilestoneLegEdit = `${from}:${i}`; @@ -93,7 +109,7 @@ function editLegAt( const value = km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null; const patch = { road: (road.select.value || null) as Road | null, - ...(km.value.trim() && value === null ? {} : { km: value }), + ...(km.value === shown || (km.value.trim() && value === null) ? {} : { km: value }), }; putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch)); }; @@ -109,6 +125,27 @@ function editLegAt( km.select(); } +/** 처음 배율(149) — 상자 범위가 틀에 차도록 당김(1 ~ 1.8배 · 가운데 맞춤) · 좌표 = viewBox(바탕 이동 vx · vy 뺌) */ +function fitBoxes( + boxes: DiagramBox[], + vx: number, + vy: number, + vw: number, + vh: number, +): ZoomPanState | undefined { + if (!boxes.length) return undefined; + const pad = 36; + const x0 = Math.min(...boxes.map((b) => b.x)) - pad - vx; + const y0 = Math.min(...boxes.map((b) => b.y)) - pad - vy; + const x1 = Math.max(...boxes.map((b) => b.x + b.w)) + pad - vx; + const y1 = Math.max(...boxes.map((b) => b.y + b.h)) + pad - vy; + const scale = Math.min(1.8, Math.max(1, Math.min(vw / (x1 - x0), vh / (y1 - y0)))); + if (scale <= 1.001) return undefined; + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + return { scale, tx: vw / 2 - cx * scale, ty: vh / 2 - cy * scale }; +} + /** 다이어그램 — refresh() 로 캐시 값 다시 그림 */ export function createDiagram( projectId: string, @@ -118,32 +155,163 @@ export function createDiagram( host.dataset.b08Milestone = "diagram"; // 틀 위 휠 = 줌만(244-8) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 새지 않음 host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false }); - let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로 + let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로(사용자가 줌 · 팬 했을 때만) + let userZoomed = false; + // 사용자 줌 · 팬 = 그 배율 지킴 · 그 전엔 그릴 때마다 상자 범위에 맞춰 당김(149 · 글씨 읽힘 · 새 상자도 보임) + host.addEventListener("wheel", () => (userZoomed = true)); + host.addEventListener("pointermove", (e) => e.buttons && (userZoomed = true)); + host.addEventListener("dblclick", () => (userZoomed = false)); // 공용 더블클릭 = 원배율 const INFO: Record void> = { route: (id) => openPlaceModal(projectId, { add: false, id }), site: () => openSiteInfo(projectId), start: () => openSiteInfo(projectId), mid: () => openSiteInfo(projectId), - base: () => openPlaceModal(projectId, { add: false, id: "base" }), + base: (id) => openPlaceModal(projectId, { add: false, id }), }; - const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => { - const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 }); - const cx = box.x + box.w - 2; - const cy = box.y + 2; - g.append( - svg("circle", { cx, cy, r: 11 }), - svg("text", { x: cx, y: cy, "text-anchor": "middle", "dominant-baseline": "central" }, "+"), - svg("title", {}, "항목 추가"), - ); - g.dataset.b08MilestonePlus = viaBase ? "base" : "site"; - const go = (e: Event): void => { - e.stopPropagation(); - openPlaceModal(projectId, { add: true, viaBase }, addRoute); - }; - g.addEventListener("click", go); - g.addEventListener("keydown", (e) => e.key === "Enter" && go(e)); - return g; + /** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버) + * 기준점을 옮기면 거친 거래처도 따라감(자리 안 정한 것만 · 레이아웃이 기준점 둘레로 놓음) */ + const dragBox = (g: SVGGElement, box: DiagramBox, shift: SVGGElement): void => { + g.addEventListener("pointerdown", (down) => { + if (down.button !== 0) return; + down.stopPropagation(); // 빈 곳 팬 안 함 + const ctm = shift.getScreenCTM(); + const k = ctm ? ctm.a : 1; // 화면 px ÷ 그림 좌표 + let moved = false; + const move = (e: PointerEvent): void => { + const dx = (e.clientX - down.clientX) / k; + const dy = (e.clientY - down.clientY) / k; + if (!moved && Math.hypot(e.clientX - down.clientX, e.clientY - down.clientY) < 4) return; + moved = true; + g.setAttribute("transform", `translate(${dx} ${dy})`); + }; + const up = (e: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + if (!moved) return; + const swallow = (c: Event): void => c.stopPropagation(); // 끌고 난 누름 = 모달 아님 + g.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => g.removeEventListener("click", swallow, { capture: true }), 0); + const pos: [number, number] = [ + Math.round(box.x + (e.clientX - down.clientX) / k), + Math.round(box.y + (e.clientY - down.clientY) / k), + ]; + const fresh = liveMilestone(projectId); + const target = ownerOf(fresh, box.id); + if (!target) return; + target.pos = pos; + userZoomed = true; // 옮긴 뒤 배율 그대로 + putMilestone(projectId, fresh); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + }; + + /** 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254 · 테두리 안) · 끌어 기준점 상자 = 그 기준점으로 + * (to = 기준점 id) · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 자기 · 같은 연결 = 취소 · 기준점 돌기 + * (A → B → A) = 취소 + 알림 · 바뀌면 그 구간 새로(km 비움) + 길찾기 한 번(km · 거래처 = 경로 그림) */ + const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => { + const cx = box.x + 10; + const cy = box.y + 10; + const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 6 }); + dot.append(svg("title", {}, "끌어서 기준점 · 공사지에 놓으면 연결")); + dot.addEventListener("click", (e) => e.stopPropagation()); // 상자 모달 안 뜸 + dot.addEventListener("pointerdown", (down) => { + if (down.button !== 0) return; + down.stopPropagation(); // 상자 끌기 · 팬 안 함 + down.preventDefault(); + const toLocal = (e: PointerEvent): [number, number] => { + const m = shift.getScreenCTM(); + if (!m) return [cx, cy]; + const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse()); + return [p.x, p.y]; + }; + const wire = svg("line", { class: "b08-milestone__wire", x1: cx, y1: cy, x2: cx, y2: cy }); + shift.append(wire); + let over: Element | null = null; + /** 놓을 곳 — 기준점 id · "site"(공사지 · 시점 · 중점) · 없음 */ + const targetOf = (e: PointerEvent): string | null => { + const hit = document.elementFromPoint(e.clientX, e.clientY); + const g = hit?.closest("[data-b08-milestone-box]"); + const id = g?.dataset.b08MilestoneBox; + if (id === "site" || id === "start" || id === "mid") return "site"; + if (id === box.id) return null; // 자기 = 취소 + return id && liveMilestone(projectId).bases.some((b) => b.id === id) ? id : null; + }; + const move = (e: PointerEvent): void => { + const [x, y] = toLocal(e); + wire.setAttribute("x2", String(x)); + wire.setAttribute("y2", String(y)); + const t = targetOf(e); + const g = t ? shift.querySelector(`[data-b08-milestone-box="${t}"]`) : null; + if (g !== over) { + over?.classList.remove("is-drop-target"); + g?.classList.add("is-drop-target"); + over = g; + } + }; + const up = (e: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + wire.remove(); + over?.classList.remove("is-drop-target"); + const t = targetOf(e); + const fresh = liveMilestone(projectId); + const route = ownerOf(fresh, box.id); + if (!t || !route) return; + if (route.linked !== false && route.to === t) return; // 같은 연결 = 그대로 + if (box.kind === "base" && makesLoop(fresh, box.id, t)) { + showToast( + "연결이 돌아 제자리로 옵니다(기준점 A → B → A) — 연결하지 않았습니다.", + "warning", + ); + return; + } + route.to = t; + route.linked = true; // 155 요소 추가 뒤 첫 이음 + route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기 + putMilestone(projectId, fresh); + void findSegmentKm(projectId, route.id).then(toastKm); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + return dot; + }; + + // 153 · 159 [방위 배치] — 끌어 놓은 자리 모두 지우고 방위(소재지 좌표) 자동 자리로 · 늘 보임 + const arrange = createButton({ + label: "방위 배치", + variant: "ghost", + onClick: () => { + const fresh = liveMilestone(projectId); + fresh.bases.forEach((b) => (b.pos = null)); + fresh.routes.forEach((r) => (r.pos = null)); + userZoomed = false; + putMilestone(projectId, fresh); + }, + }); + arrange.dataset.b08MilestoneArrange = ""; + // 155 [요소 추가] — 기준점 · 거래처 등록(공사지 · 기준점 [+] 없앰) · 등록 뒤 연결 점으로 선 이음 + const addElem = createButton({ + label: "요소 추가", + variant: "ghost", + onClick: () => openPlaceModal(projectId, { add: true }, addRoute), + }); + addElem.dataset.b08MilestoneAdd = "open"; + const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] }); + + /** 장소 그림(254-3 · 256-1) blob 주소 — 상자마다 키(이름#시각)가 바뀔 때만 다시 받음 */ + const picUrls = new Map }>(); + const picUrl = (id: string, key: string): Promise => { + let got = picUrls.get(id); + if (!got || got.key !== key) { + got?.url.then(URL.revokeObjectURL, () => undefined); + got = { key, url: placeImageBlobUrl(projectId, id) }; + picUrls.set(id, got); + } + return got.url; }; const refresh = (): void => { @@ -153,7 +321,7 @@ export function createDiagram( // 시점 · 중점 주소 · 좌표(244-12) — 지금 중점 측점으로 서버 VWorld(뒤에서 · 오면 다시 그림) const addr = siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null); const layout = layoutMilestone( - data, + withRoads(data), // 선 글 도로 구분 = 선 자리 포장 종류(152) { start_lonlat: addr?.start_lonlat ?? site?.start_lonlat, mid_lonlat: addr?.mid_lonlat ?? site?.mid_lonlat, @@ -161,20 +329,26 @@ export function createDiagram( mid_address: addr?.mid_address, }, mk, + new Set([...data.bases, ...data.routes].map((o) => o.id).filter((id) => placeImageKey(id))), ); - const [vx, vy, vw, vh] = layout.view; + // 틀 = 페이지 폭 전체 · 높이 고정(CSS · 149) — 그림 범위를 틀 비율로 넓혀 가운데 둠 + // (화면 px ↔ 그림 좌표 비율이 가로세로 같아야 팬 · 줌이 어긋나지 않음) + const fw = Math.max(host.clientWidth, 320); + const fh = Math.max(host.clientHeight, 240); + const [lx, ly, lw, lh] = layout.view; + const scale = Math.max(lw / fw, lh / fh); + const vw = fw * scale; + const vh = fh * scale; + const vx = lx - (vw - lw) / 2; + const vy = ly - (vh - lh) / 2; // viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김 const root = svg("svg", { viewBox: `0 0 ${vw} ${vh}`, - width: vw, - height: vh, + width: fw, + height: fh, role: "img", "aria-label": "거리이정표 다이어그램", }); - // 틀 너비 = 아래 표 너비(244-6) · 높이는 비율대로(팬 · 줌 화면 px ↔ 그림 좌표가 어긋나지 않게) - // 높이 48vh 넘으면 그만큼 좁혀 틀 안에 다 들어감(244-8 · 스크롤 없음) - root.style.width = `min(100%, calc(48vh * ${vw / vh}))`; - root.style.height = "auto"; const layer = svg("g", {}); const shift = svg("g", { transform: `translate(${-vx} ${-vy})` }); layer.append(shift); @@ -204,9 +378,10 @@ export function createDiagram( ...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]), ]; const x = box.x + box.w / 2; + const th = box.pic ? box.pic.y - box.y : box.h; // 글 자리 = 그림 위 const text = svg("text", { x, - y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2, + y: box.kind === "site" ? box.y + 18 : box.y + th / 2 - ((rows.length - 1) * 15) / 2, "text-anchor": "middle", "dominant-baseline": "central", }); @@ -214,6 +389,24 @@ export function createDiagram( text.append(svg("tspan", { x, dy: i ? 15 : 0, ...(cls ? { class: cls } : {}) }, row)), ); g.append(svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), text); + // 254-3 장소 그림 — 글 아래 작게(그림 없으면 자리 없음 · 못 읽으면 지움) + const key = box.pic && placeImageKey(box.id); + if (box.pic && key) { + const { x: px, y: py, w: pw, h: ph } = box.pic; + const pic = svg("image", { + class: "b08-milestone__pic", + x: px, + y: py, + width: pw, + height: ph, + }); + pic.setAttribute("preserveAspectRatio", "xMidYMid slice"); + g.append(pic); + picUrl(box.id, key).then( + (url) => pic.setAttribute("href", url), + () => pic.remove(), + ); + } if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소 // 상자 누름 = 모달(탭 이동 아님) const info = (): void => INFO[box.kind](box.id); @@ -224,16 +417,17 @@ export function createDiagram( info(); }); g.addEventListener("keydown", (e) => e.key === "Enter" && info()); - if (box.kind === "site") g.append(plus(box, false)); - if (box.kind === "base") g.append(plus(box, true)); + if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift); + if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift)); shift.append(g); if (box.kind === "site") shift.append(lineLayer); } - // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄 + // 선 글은 상자 위에(가리지 않게 맨 끝) — 구간 줄마다 두 줄(254 · 도로 구분 / km) for (const ln of layout.lines) { // 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④) const x = ln.lx; - const y = ln.ly - ((ln.labels.length - 1) * 16) / 2; + const count = ln.labels.reduce((n, l) => n + l.split("\n").length, 0); + const y = ln.ly - ((count - 1) * 16) / 2; const text = svg("text", { class: "b08-milestone__km", x, @@ -241,34 +435,38 @@ export function createDiagram( "text-anchor": "middle", "dominant-baseline": "central", }); + let first = true; ln.labels.forEach((label, i) => { - const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); - span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; - if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); - // 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침 let wait = 0; - span.addEventListener("click", (e) => { - e.stopPropagation(); - clearTimeout(wait); - wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250); - }); - span.addEventListener("dblclick", (e) => { - e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 - clearTimeout(wait); - editLegAt(projectId, span, ln.from, i, mk); - }); - text.append(span); + for (const row of label.split("\n")) { + const span = svg("tspan", { x, dy: first ? 0 : 16 }, row); + first = false; + span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; + if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); + // 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침 + span.addEventListener("click", (e) => { + e.stopPropagation(); + clearTimeout(wait); + wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250); + }); + span.addEventListener("dblclick", (e) => { + e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 + clearTimeout(wait); + editLegAt(projectId, span, ln.from, i, mk); + }); + text.append(span); + } }); shift.append(text); } - host.replaceChildren(root); + host.replaceChildren(root, tools); attachSvgZoomPan({ svg: root, layer, widthPx: vw, heightPx: vh, pad: { left: 0, right: 0, top: 0, bottom: 0 }, - initial: zoomState, + initial: userZoomed ? zoomState : fitBoxes(layout.boxes, vx, vy, vw, vh), onChange: (state) => (zoomState = state), wheelZoom: true, content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임) @@ -276,5 +474,17 @@ export function createDiagram( }); }; refresh(); + // 틀 크기가 바뀌면(창 · 패널 접기 · 높이 = 58vh) 그 비율로 다시 그림 — 그림이 틀보다 크면 아래가 잘림(251) + let lastW = 0; + let lastH = 0; + new ResizeObserver(() => { + const w = host.clientWidth; + const h = host.clientHeight; + if (w && (Math.abs(w - lastW) > 1 || Math.abs(h - lastH) > 1)) { + lastW = w; + lastH = h; + refresh(); + } + }).observe(host); return { root: host, refresh }; } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index af216b5cb..a6c2af12f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -1,22 +1,20 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_KakaoMap.ts - * 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 · 표지(보기만). + * 거리이정표 카카오 지도 칸(PLAN 244-9 · 사용자 138번) — 등록 모달(소재지 고르기) · 연결선 모달(길 선 · 표지 끌기) + * 한 벌(`editMap`) · 경로 하위 탭 지도 칸은 없앰(156 · 탭 = 그림만). * * SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key * (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기 - * 길 선 = 마지막 [길찾기] 응답 경로 좌표(`routePaths` · 메모리만 · 저장 안 함) · 표지 = 거래처 · 기준점 · - * 시점(`mapMarks`) · 경로 전체 맞춤 + * 길 선 = 마지막 [길찾기] 응답 경로 좌표(메모리만 · 저장 안 함) · 처음 한 번만 맞춤(153 · 그 뒤 보기 그대로) * 경로 그림 = 서버 자동 캡처(244-11 · `…_State` autoRouteImage) — 브라우저 캡처 없음(접속 주소 무관) * (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 * ========================================================================== */ import { el } from "@ui/ui_template_elements"; -import { mapMarks } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; -import { liveMilestone, routePaths, siteInfo } from "./B08_Quantity_UI_Milestone_State"; export const NO_MAP = - "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요"; const SDK_FAIL = "KakaoSdkFail"; const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 @@ -30,6 +28,8 @@ interface KMap { setCenter(p: LatLng): void; setLevel(level: number): void; relayout(): void; + getBounds(): { contain(p: LatLng): boolean }; + panTo(p: LatLng): void; } interface KOverlay { setMap(map: KMap | null): void; @@ -74,7 +74,7 @@ export function loadKakaoMaps(projectId: string): Promise { fail( Object.assign( new Error( - `카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요`, + `카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요`, ), { name: SDK_FAIL }, ), @@ -91,87 +91,6 @@ export function loadKakaoMaps(projectId: string): Promise { return sdk; } -export interface RouteMapView { - root: HTMLElement; - /** 지금 값 · 마지막 [길찾기] 길 선으로 다시 그림 */ - draw: () => void; -} - -/** 경로 지도 칸 — 거래처 모달 · 경로 하위 탭 같은 한 벌 */ -export function routeMapView(projectId: string, routeId: string): RouteMapView { - const box = el("div", { className: "b08-milestone__map" }); - const hint = el("p", { className: "b08-options__hint" }); - hint.dataset.b08MilestoneMapHint = ""; - let map: KMap | null = null; - let drawn: KOverlay[] = []; - let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로) - - const draw = (): void => { - const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); - const lines = routePaths(routeId); - const sig = JSON.stringify([marks, lines]); - if (map && sig === shown) return; - loadKakaoMaps(projectId).then( - (maps) => - requestAnimationFrame(() => { - const points = [ - ...marks.map((m) => new maps.LatLng(m.lat, m.lon)), - ...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)), - ]; - if (!points.length) { - hint.textContent = "거래처 주소 · 노선 시점이 있어야 지도에 놓임"; - return; - } - box.hidden = false; - map ??= new maps.Map(box, { center: points[0], level: 7 }); - map.relayout(); - drawn.forEach((o) => o.setMap(null)); - drawn = [ - ...lines.map( - (line) => - new maps.Polyline({ - path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)), - strokeWeight: 6, - strokeColor: LINE_COLOR, - strokeOpacity: 0.9, - }), - ), - ...marks.flatMap((m) => { - const at = new maps.LatLng(m.lat, m.lon); - return [ - new maps.Marker({ position: at, title: m.name }), - new maps.CustomOverlay({ - position: at, - content: el("span", { className: "b08-milestone__map-label", text: m.name }), - yAnchor: 2.6, - }), - ]; - }), - ]; - drawn.forEach((o) => o.setMap(map)); - const bounds = new maps.LatLngBounds(); - points.forEach((p) => bounds.extend(p)); - map.setBounds(bounds); - shown = sig; - hint.textContent = lines.length - ? "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" - : "[길찾기]를 누르면 길 선 · 경로 그림이 생김"; - }), - (error: unknown) => { - box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄(사진 올리기는 그대로) - hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP; - }, - ); - }; - - const root = el("div", { - className: "b08-milestone__map-wrap", - children: [hint, box], - }); - draw(); - return { root, draw }; -} - /** 고르는 지도 표지(244-13) — key = 끌어 옮길 때 돌려받는 이름 */ export interface MapPin { key: string; @@ -179,6 +98,8 @@ export interface MapPin { lon: number; lat: number; movable: boolean; + /** 262-2 고정 시점 표지 — 끌기 · 맞춤 · 보기 이동에 안 쓰임 · 고르는 표지와 모양이 다름 */ + fixed?: boolean; } export interface EditMap { @@ -202,13 +123,15 @@ export function editMap( hint.dataset.b08MilestoneMapHint = ""; let map: KMap | null = null; let drawn: KOverlay[] = []; + let fitted = false; // 153 — 처음 한 번만 맞춤 · 그 뒤 고름 · 끌기는 보기(중심 · 줌) 그대로 const set = (pins: MapPin[], lines: Array>): void => { loadKakaoMaps(projectId).then( (maps) => requestAnimationFrame(() => { + const moving = pins.filter((p) => !p.fixed); const points = [ - ...pins.map((p) => new maps.LatLng(p.lat, p.lon)), + ...moving.map((p) => new maps.LatLng(p.lat, p.lon)), ...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)), ]; const c = opts.center?.(); @@ -236,6 +159,17 @@ export function editMap( ), ...pins.flatMap((p) => { const at = new maps.LatLng(p.lat, p.lon); + if (p.fixed) + return [ + new maps.CustomOverlay({ + position: at, + content: el("span", { + className: "b08-milestone__map-start", + text: p.name, + }), + yAnchor: 0.5, + }), + ]; const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable }); if (p.movable) maps.event.addListener(marker, "dragend", () => { @@ -253,13 +187,20 @@ export function editMap( }), ]; drawn.forEach((o) => o.setMap(map)); - if (points.length > 1) { + if (fitted) { + // 보기 밖 표지만 그쪽으로 옮김(줌 그대로) + const view = map.getBounds(); + const out = moving.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon))); + if (out) map.panTo(new maps.LatLng(out.lat, out.lon)); + } else if (points.length > 1) { const bounds = new maps.LatLngBounds(); points.forEach((p) => bounds.extend(p)); map.setBounds(bounds); + fitted = true; } else { map.setCenter(first); map.setLevel(points.length ? 4 : 9); + fitted = points.length > 0; } }), (error: unknown) => { diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index f8f7cd3e6..881dd86ca 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -3,20 +3,28 @@ * 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름. * * 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7) - * 도로 구분 = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · 비포장 · - * 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장(`roadOptions` · `chainVia`) + * 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함 — → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 · + * 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`) * 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음) * 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정) * 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음) * 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`) * ========================================================================== */ +import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { ROADS, type Leg, type Road } from "./B08_Quantity_Milestone_Calc"; -import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; +import { + ROADS, + roadOf, + segKind, + type Leg, + type Road, + type SegKind, +} from "./B08_Quantity_Milestone_Calc"; +import { ownerOf, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; -import { toastKm } from "./B08_Quantity_UI_Milestone_Modal"; +import { fmtKm, toastKm } from "./B08_Quantity_UI_Milestone_Modal"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { findSegmentKm, @@ -25,6 +33,7 @@ import { roadOptions, segmentPath, siteInfo, + startPoint, } from "./B08_Quantity_UI_Milestone_State"; const km = (text: string): number | null => { @@ -35,25 +44,31 @@ const km = (text: string): number | null => { /** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · 거리 km(빈칸 = 자동 값) · [×] · [+ 구간] */ export function legsEditor( initial: Leg[], - via: boolean, + kind: SegKind, ): { root: HTMLElement; value: () => Leg[] } { const list = el("div"); - const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = []; + const rows: Array<{ + road: HTMLSelectElement; + km: HTMLInputElement; + auto: number | null; + orig: number | null; // 안 고친 칸 = 원래 값 그대로(반올림 값으로 안 덮음) + shown: string; + }> = []; const add = (leg: Leg): void => { const road = createSelectField({ label: "도로 구분", - options: roadOptions(via, true, leg.road), + options: roadOptions(kind), // 포장 종류 = 선 자리(152) · 비포장만 고름 compact: true, }); const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); - road.select.value = leg.road ?? ""; - dist.input.value = leg.km == null ? "" : String(leg.km); + road.select.value = roadOf(leg.road, kind); + dist.input.value = leg.km == null ? "" : fmtKm(leg.km); const auto = leg.auto_km ?? null; if (auto != null) { - dist.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 - dist.input.title = `자동 값 ${auto} km`; + dist.input.placeholder = fmtKm(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 + dist.input.title = `자동 값 ${fmtKm(auto)} km`; } - const ref = { road: road.select, km: dist.input, auto }; + const ref = { road: road.select, km: dist.input, auto, orig: leg.km, shown: dist.input.value }; rows.push(ref); const drop = createButton({ label: "×", @@ -81,9 +96,9 @@ export function legsEditor( return { root: el("div", { children: [list, more] }), value: () => - rows.map(({ road, km: dist, auto }) => ({ + rows.map(({ road, km: dist, auto, orig, shown }) => ({ road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null, - km: km(dist.value), + km: dist.value === shown ? orig : km(dist.value), auto_km: auto, })), }; @@ -102,17 +117,18 @@ function openMidLeg(projectId: string): void { mount: (body, close, actions) => { const road = createSelectField({ label: "도로 구분", - options: roadOptions(false, false, data.site.mid_road), + options: roadOptions("mid"), compact: true, }); - road.select.value = data.site.mid_road; + road.select.value = roadOf(data.site.mid_road, "mid"); const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); dist.input.value = - data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000); - dist.input.placeholder = site?.mid_default_m == null ? "" : String(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2 + data.site.mid_station_m == null ? "" : fmtKm(data.site.mid_station_m / 1000); + dist.input.placeholder = site?.mid_default_m == null ? "" : fmtKm(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2 + const shown = dist.input.value; // 안 고치면 저장 값(m) 그대로 dist.input.dataset.b08MilestoneLegKm = "start"; const save = createButton({ - label: "고침", + label: "수정", variant: "filled", onClick: () => { const fresh = liveMilestone(projectId); @@ -120,7 +136,7 @@ function openMidLeg(projectId: string): void { projectId, setLeg(fresh, "start", 0, { road: road.select.value as Road, - km: km(dist.input.value), + ...(dist.input.value === shown ? {} : { km: km(dist.input.value) }), }), ); close(); @@ -136,10 +152,10 @@ function openMidLeg(projectId: string): void { }); } -/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "start"(시점 → 중점) */ +/** 연결선 누름 — from = 거래처 · 기준점 id(→ 기준점 · 시점 · 254) · "start"(시점 → 중점) */ export function openLegInfo(projectId: string, from: string): void { if (from === "start") return openMidLeg(projectId); - const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; + const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); // 255-2 덮개 const seg = segmentEnds(liveMilestone(projectId), from, start()); if (!seg) return; openModal({ @@ -147,39 +163,39 @@ export function openLegInfo(projectId: string, from: string): void { closeLabel: "닫기", dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { - const legsOf = (): Leg[] => { - const d = liveMilestone(projectId); - return (from === "base" ? d.base.legs : d.routes.find((r) => r.id === from)?.legs) ?? []; - }; - const via = chainVia(liveMilestone(projectId), from); - let legs = legsEditor(legsOf(), via); + const legsOf = (): Leg[] => ownerOf(liveMilestone(projectId), from)?.legs ?? []; + const isBase = liveMilestone(projectId).bases.some((b) => b.id === from); + const kind = segKind(liveMilestone(projectId), from); + let legs = legsEditor(legsOf(), kind); const status = hint(""); status.dataset.b08MilestoneLegStatus = ""; /** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */ const apply = (): void => { const fresh = liveMilestone(projectId); - const target = from === "base" ? fresh.base : fresh.routes.find((r) => r.id === from); + const target = ownerOf(fresh, from); if (!target) return; target.legs = legs.value(); putMilestone(projectId, fresh); }; const redrawLegs = (): void => { - const next = legsEditor(legsOf(), via); + const next = legsEditor(legsOf(), kind); legs.root.replaceWith(next.root); legs = next; }; + /** 표지 = 그 구간 두 끝(주인 · to = 기준점 또는 시점) */ const pins = (): MapPin[] => { const d = liveMilestone(projectId); - const route = d.routes.find((r) => r.id === from); + const owner = ownerOf(d, from); const out: MapPin[] = []; const add = (key: string, name: string, lon?: number | null, lat?: number | null): void => { if (lon != null && lat != null) out.push({ key, name, lon, lat, movable: key !== "start" }); }; - if (route) add(route.id, route.name || "거래처", route.lon, route.lat); - if (from === "base" || via) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); + if (!owner) return out; + add(owner.id, owner.name || (isBase ? "기준점" : "거래처"), owner.lon, owner.lat); + const target = d.bases.find((b) => b.id === owner.to); + if (target) add(target.id, target.name || "기준점", target.lon, target.lat); else add("start", "시점", start()?.[0], start()?.[1]); - if (from === "base") add("start", "시점", start()?.[0], start()?.[1]); return out; }; const drawMap = (): void => { @@ -205,7 +221,7 @@ export function openLegInfo(projectId: string, from: string): void { apply(); void reverseAddress(projectId, lon, lat).then((address) => { const fresh = liveMilestone(projectId); - const target = key === "base" ? fresh.base : fresh.routes.find((r) => r.id === key); + const target = ownerOf(fresh, key); if (!target) return; Object.assign(target, { lon, lat, address }); putMilestone(projectId, fresh); @@ -223,7 +239,7 @@ export function openLegInfo(projectId: string, from: string): void { }); find.dataset.b08MilestoneLegFind = ""; const save = createButton({ - label: "고침", + label: "수정", variant: "filled", onClick: () => { apply(); @@ -232,20 +248,38 @@ export function openLegInfo(projectId: string, from: string): void { }); save.dataset.b08MilestoneLegSave = ""; actions.append(find, save); - body.append( - legs.root, - hint( - from === "base" - ? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" - : "빈칸 = 길찾기 자동 값 · 넣은 값이 우선", - ), - status, - el("h4", { text: "경로 — 다음(카카오) 지도" }), - map.root, + // 155 [수기 입력] — 카카오 길찾기 연결 끊음(자동 값 지움 · 길찾기 · 지도 숨김 · km 손 입력) · 풀면 다시 길찾기 + const owner = (d = liveMilestone(projectId)) => ownerOf(d, from); + const manual = createCheckbox("수기 입력 — 카카오 길찾기 안 함(거리 손 입력)"); + manual.input.checked = !!owner()?.manual; + manual.input.dataset.b08MilestoneManual = ""; + const auto = hint( + isBase + ? "기준점 구간 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" + : "빈칸 = 길찾기 자동 값 · 넣은 값이 우선", ); + const mapHead = el("h4", { text: "경로 — 다음(카카오) 지도" }); + const showMode = (): void => { + const off = manual.input.checked; + find.hidden = auto.hidden = mapHead.hidden = map.root.hidden = off; + status.textContent = off ? "수기 입력 — 거리를 손으로 넣으세요" : status.textContent; + }; + manual.input.addEventListener("change", () => { + const fresh = liveMilestone(projectId); + const target = owner(fresh); + if (!target) return; + target.legs = legs.value().map((l) => (manual.input.checked ? { ...l, auto_km: null } : l)); + target.manual = manual.input.checked; + putMilestone(projectId, fresh); + redrawLegs(); + showMode(); + if (!manual.input.checked) void run(); // 다시 카카오 연결 = 길찾기 한 번 + }); + body.append(manual.root, legs.root, auto, status, mapHead, map.root); + showMode(); drawMap(); - // 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음) - if (!segmentPath(from).length && seg.from && seg.to) void run(false); + // 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음 · 수기 입력이면 안 함) + if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to) void run(false); }, }); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index f0454ab76..97c93a15f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -4,16 +4,18 @@ * * 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 · * 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · - * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제] - * 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정 - * 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리 + * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [수정] · [삭제] + * 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달 + * 연결(기준점 거침 · 시점으로 바로) = 다이어그램 연결 점 ● 끌기(154 · 155 · 등록 = 상자만 · 선 없음) + * 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰) + * 분류 기준위치 = 기준점(254 · 여럿 · 새 `bases` 원소 · 항목 · 목적지 없음 · 등록 = 상자만 · 연결 점으로 이음) · + * 방위 = 소재지 좌표로 자동 자리 · 기준점 [삭제] = 그 기준점에 이은 거래처 · 기준점은 안 이음으로 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 - * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 + * 시점(공사지) 상자 = 중점 측점 km(저장 m · 255-1) · 아래 카카오 지도(시점 · 중점 표지) * 조작 = 캐시 · [저장] 때 서버 * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; -import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton, createInputField, @@ -23,11 +25,19 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; -import type { Dest, MilestoneRoute, MilestoneRow, Road } from "./B08_Quantity_Milestone_Calc"; +import { + roadOf, + type Dest, + type MilestoneRoute, + type MilestoneRow, + type Road, +} from "./B08_Quantity_Milestone_Calc"; +import { setLeg } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; +import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { + capturePlace, findSegmentKm, liveMilestone, putMilestone, @@ -49,13 +59,14 @@ export const ITEMS: Record<"route" | "base", string[]> = { const line = (...children: HTMLElement[]): HTMLElement => el("div", { className: "b08-milestone__line-edit", children }); -const head = (text: string): HTMLElement => el("h4", { text }); - /** 길찾기 결과 한 줄 → 알림(까닭 줄 = 경고) */ export function toastKm(text: string): void { if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success"); } +/** 칸 · 글에 보이는 km — 3자리(1 m) 반올림 · 끝 0 지움(셈 값은 안 바꿈 · 261-1) */ +export const fmtKm = (km: number): string => String(Math.round(km * 1000) / 1000); + let listSeq = 0; /** 항목(손 입력 + 제안 목록 · 244-14) · 목적지 ▾ 한 줄 — 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜 */ @@ -69,7 +80,9 @@ function rowField(r: MilestoneRow, kind: "route" | "base") { const setKind = (k: "route" | "base"): void => { list.replaceChildren(...ITEMS[k].map((t) => el("option", { attrs: { value: t } }))); item.input.placeholder = `예 ${ITEMS[k][0]}`; - dest.root.style.visibility = k === "base" ? "hidden" : ""; // 기준점 → 시점 고정 + // 기준점 = 거쳐 가는 자리 — 항목 · 목적지 없음(149) + // 255-3 자리도 비움 — 기준점 이름이 분류 옆 첫 줄(두 줄로 안 꺾임) + item.root.style.display = dest.root.style.display = k === "base" ? "none" : ""; }; setKind(kind); dest.select.value = r.dest; @@ -77,23 +90,24 @@ function rowField(r: MilestoneRow, kind: "route" | "base") { return { item, dest, note: r.note, setKind }; } -export type PlaceTarget = { add: true; viaBase: boolean } | { add: false; id: string }; +export type PlaceTarget = { add: true } | { add: false; id: string }; -/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */ +/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id = 거래처 · 기준점 id */ export function openPlaceModal( projectId: string, target: PlaceTarget, addRoute?: (init: Partial) => string, ): void { const data = liveMilestone(projectId); - const editBase = !target.add && target.id === "base"; + const base = target.add ? undefined : data.bases.find((b) => b.id === target.id); + const editBase = !!base; const route = !target.add && !editBase ? data.routes.find((r) => r.id === target.id) : undefined; if (!target.add && !editBase && !route) return; const title = target.add - ? `항목 추가 — ${target.viaBase ? "기준점 거침" : "공사지로 바로"}` - : editBase - ? `기준점 — ${data.base.name || "기준점"}` - : `항목 정보 — ${route!.name || "거래처"}`; + ? "요소 추가 — 기준점 · 거래처" + : base + ? base.name || "기준점" + : route!.name || "거래처"; // 262-1 「—」 앞 글자 없음 openModal({ title, closeLabel: "닫기", @@ -110,51 +124,14 @@ export function openPlaceModal( }); sort.select.value = kind; sort.select.disabled = !target.add; - // 기준점 = 하나 — 이미 있으면 [+]에서 기준위치 못 고름(기준점 상자에서 고침) - const baseOption = sort.select.querySelector('option[value="base"]'); - if (target.add && data.base.enabled && baseOption) { - baseOption.disabled = true; - baseOption.textContent = "기준위치(있음 — 기준점 상자에서 고침)"; - } sort.select.dataset.b08MilestoneAdd = "sort"; const name = createInputField({ label: "거래처 이름", compact: true }); - name.input.value = editBase ? data.base.name : (route?.name ?? ""); + name.input.value = base ? base.name : (route?.name ?? ""); name.input.dataset.b08MilestoneAdd = "company"; const nameLabel = name.root.querySelector(".ui-field__label"); - const initRows: MilestoneRow[] = route?.rows.length - ? route.rows - : [{ item: "", dest: "start", note: "" }]; - const rows = initRows.map((r) => rowField(r, kind)); - const [first] = rows; - const more = el("div"); - const addRow = (field: ReturnType): void => { - const drop = createButton({ - label: "×", - size: "sm", - variant: "ghost", - onClick: () => { - rows.splice(rows.indexOf(field), 1); - row.remove(); - }, - }); - const row = line(field.item.root, field.dest.root, drop); - more.append(row); - }; - rows.slice(1).forEach(addRow); - const plusRow = createButton({ - label: "+ 줄", - size: "sm", - variant: "ghost", - onClick: () => { - const field = rowField({ item: "", dest: "start", note: "" }, kind); - rows.push(field); - addRow(field); - }, - }); - const via = createCheckbox("기준점 거침"); - via.input.checked = route?.via_base ?? true; - via.root.hidden = !route || !data.base.enabled; - const old = editBase ? data.base : route; + // 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠 + const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind); + const old = base ?? route; const place = placePicker( projectId, { address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null }, @@ -163,59 +140,78 @@ export function openPlaceModal( const setKind = (k: "route" | "base"): void => { kind = k; if (nameLabel) nameLabel.textContent = k === "base" ? "기준점 이름" : "거래처 이름"; - rows.forEach((r) => r.setKind(k)); - more.hidden = plusRow.hidden = k === "base"; + first.setKind(k); }; sort.select.addEventListener("change", () => setKind(sort.select.value as "route" | "base")); setKind(kind); - const rowValues = (): MilestoneRow[] => - rows.map((r) => ({ - item: r.item.input.value.trim(), - dest: r.dest.select.value as Dest, - note: r.note, - })); + const rowValues = (): MilestoneRow[] => [ + { + item: first.item.input.value.trim(), + dest: first.dest.select.value as Dest, + note: first.note, + }, + ...(route?.rows.slice(1) ?? []), + ]; const moved = (p: { lon: number | null; lat: number | null }): boolean => p.lon != null && (p.lon !== old?.lon || p.lat !== old?.lat); const ok = createButton({ - label: target.add ? "등록" : "고침", + label: target.add ? "등록" : "수정", variant: "filled", onClick: () => { const text = name.input.value.trim(); const where = place.value(); const fresh = liveMilestone(projectId); let segment: string | undefined; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번 - if (kind === "base") { - Object.assign(fresh.base, where, { enabled: true, name: text, dir: null }); - if (!fresh.base.legs.length) fresh.base.legs = [{ road: null, km: null }]; + let placed: string | undefined; // 256-1 등록 · 좌표 바뀜 = 장소 그림 캡처(뒤에서) + if (kind === "base" && base) { + const b = fresh.bases.find((x) => x.id === base.id); + if (!b) return close(); + Object.assign(b, where, { name: text, dir: null }); + putMilestone(projectId, fresh); + if (moved(where)) segment = placed = b.id; + } else if (kind === "base") { + // 254 새 기준점 = 상자만 · 선은 연결 점 ● 로 이음(이을 때 길찾기) + placed = `b${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`; + fresh.bases.push({ + id: placed, + name: text, + dir: null, + legs: [{ road: null, km: null }], + to: "site", + linked: false, + ...where, + }); putMilestone(projectId, fresh); - if (moved(where)) segment = "base"; } else if (route) { const r = fresh.routes.find((x) => x.id === route.id); if (!r) return close(); Object.assign(r, where, { name: text || r.name, - via_base: via.input.checked, rows: rowValues(), }); putMilestone(projectId, fresh); - if (moved(where) || r.via_base !== route.via_base) segment = r.id; + if (moved(where)) segment = placed = r.id; } else { if (!text) return name.setError("거래처 이름을 넣으세요."); - const same = fresh.routes.find((r) => r.name.trim() === text); - if (same) { - same.rows.push(...rowValues()); - if (where.lon != null) Object.assign(same, where); - putMilestone(projectId, fresh); - } - const viaBase = target.add && target.viaBase; - const id = - same?.id ?? - addRoute?.({ name: text, via_base: viaBase, dir: null, rows: rowValues(), ...where }); - if (id && where.lon != null) segment = id; + // 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기) + placed = addRoute?.({ + name: text, + to: "site", + linked: false, + dir: null, + rows: rowValues(), + ...where, + }); } close(); if (segment) void findSegmentKm(projectId, segment).then(toastKm); + if (placed && where.lon != null && where.lat != null) + void capturePlace(projectId, placed, { + name: text || (kind === "base" ? "기준점" : "거래처"), + lon: where.lon, + lat: where.lat, + }); }, }); ok.dataset.b08MilestoneInfo = "save"; @@ -223,12 +219,17 @@ export function openPlaceModal( label: "삭제", variant: "danger", onClick: async () => { - const label = editBase ? data.base.name || "기준점" : route?.name || "거래처"; + const label = base ? base.name || "기준점" : route?.name || "거래처"; if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제"))) return; const fresh = liveMilestone(projectId); - if (editBase) fresh.base = { enabled: false, name: "", address: "", dir: null, legs: [] }; - else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 + if (base) { + fresh.bases = fresh.bases.filter((b) => b.id !== base.id); + // 254 그 기준점에 이은 상자 = 안 이음(옛 구간 km = 다른 길 · 연결 점으로 다시 이음) + for (const o of [...fresh.bases, ...fresh.routes]) + if (o.to === base.id) + Object.assign(o, { to: "site", linked: false, legs: [{ road: null, km: null }] }); + } else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 putMilestone(projectId, fresh); close(); }, @@ -241,10 +242,6 @@ export function openPlaceModal( className: "b08-milestone__grid", children: [sort.root, first.item.root, name.root, first.dest.root], }), - more, - plusRow, - via.root, - head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"), place.root, ); }, @@ -259,72 +256,74 @@ export interface SiteAddress { mid_address: string | null; } -/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */ +/** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */ export function openSiteInfo(projectId: string): void { openModal({ - title: "공사지 — 시점 · 중점", + title: "시점 · 중점", closeLabel: "닫기", + dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { - const text = (label: string, value: string): HTMLElement => - el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); - // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) + // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) · 보이고 받는 것만 km(255-1) const data = liveMilestone(projectId); const site = siteInfo(); - const km = (m: number | null | undefined): string => - m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); const mid = createInputField({ - label: "중점 측점 (m)", + label: "중점 측점 (km)", type: "number", min: 0, compact: true, }); - mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; - mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); + mid.input.step = "0.001"; + mid.input.placeholder = site?.mid_default_m != null ? fmtKm(site.mid_default_m / 1000) : ""; + mid.input.value = + data.site.mid_station_m == null ? "" : fmtKm(data.site.mid_station_m / 1000); + const shown = mid.input.value; // 안 고치면 저장 값(m) 그대로 · 반올림 값으로 안 덮음 mid.input.dataset.b08Milestone = "mid_station_m"; const road = createSelectField({ label: "시점 → 중점 도로", - options: roadOptions(false, false, data.site.mid_road), + options: roadOptions("mid"), compact: true, }); - road.select.value = data.site.mid_road; + road.select.value = roadOf(data.site.mid_road, "mid"); const save = createButton({ - label: "고침", + label: "수정", variant: "filled", onClick: () => { + const text = mid.input.value; + const value = Number(text); + const km = text.trim() && Number.isFinite(value) && value >= 0 ? value : null; const fresh = liveMilestone(projectId); - const value = Number(mid.input.value); - fresh.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; - fresh.site.mid_road = road.select.value as Road; - putMilestone(projectId, fresh); + putMilestone( + projectId, + setLeg(fresh, "start", 0, { + road: road.select.value as Road, + ...(text === shown ? {} : { km }), + }), + ); close(); }, }); save.dataset.b08MilestoneSite = "save"; actions.append(save); - const map = buildAddressMap(); - const info = el("div", { children: [text("주소", "불러오는 중…")] }); - body.append( - line(mid.root, road.root), - text( - "시점 = 노선 첫 점(측점 0)", - `노선 ${km(site?.length_m)} km · 중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`, - ), - info, - map.root, - ); - request(`/projects/${projectId}/quantity/milestone/site-address`).then( - (s) => { - const ll = (p: [number, number] | null): string => - p ? ` (${p[1].toFixed(6)}, ${p[0].toFixed(6)})` : ""; - info.replaceChildren( - text("프로젝트 등록 주소(사업지역)", s.region || "없음"), - text("시점 브이월드 주소", (s.start_address ?? "못 찾음") + ll(s.start_lonlat)), - text("중점 브이월드 주소", (s.mid_address ?? "못 찾음") + ll(s.mid_lonlat)), - ); - if (s.start_lonlat) map.showPoint(s.start_lonlat[1], s.start_lonlat[0]); - else if (s.region) void map.show(s.region); - }, - (error: unknown) => showToast(`공사지 주소를 못 읽었습니다 — ${String(error)}`, "error"), + const map = editMap(projectId, { hint: "", onMove: () => undefined }); + const pins = (s: { + start_lonlat: [number, number] | null; + mid_lonlat: [number, number] | null; + }): MapPin[] => + ( + [ + ["start", "시점", s.start_lonlat], + ["mid", "중점", s.mid_lonlat], + ] as const + ).flatMap(([key, name, p]) => + p ? [{ key, name, lon: p[0], lat: p[1], movable: false }] : [], + ); + if (site) map.set(pins(site), []); + body.append(line(mid.root, road.root), map.root); + // 지금 중점 측점(캐시)의 중점 좌표 — 오면 표지 다시 + const query = data.site.mid_station_m == null ? "" : `?mid_m=${data.site.mid_station_m}`; + request(`/projects/${projectId}/quantity/milestone/site-address${query}`).then( + (s) => map.set(pins(s), []), + (error: unknown) => showToast(`공사지 좌표를 못 읽었습니다 — ${String(error)}`, "error"), ); }, }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts index aa65196e9..f4f2af5be 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts @@ -12,7 +12,7 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e import { searchAddress, type AddressCandidate } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { request } from "./B08_Quantity_UI_Hydro"; import { editMap } from "./B08_Quantity_UI_Milestone_KakaoMap"; -import { siteInfo } from "./B08_Quantity_UI_Milestone_State"; +import { liveMilestone, startPoint } from "./B08_Quantity_UI_Milestone_State"; export interface Place { address: string; @@ -52,27 +52,35 @@ export function placePicker(projectId: string, initial: Place, name: () => strin list.hidden = true; const where = el("p", { className: "b08-milestone__place" }); where.dataset.b08MilestonePlace = "value"; + // 262-2 시점 = 거리이정표가 쓰는 시점(끌어 옮긴 덮개 · 없으면 노선 시점) + const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); const map = editMap(projectId, { hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김", onClick: (lon, lat) => void moveTo(lon, lat), onMove: (_key, lon, lat) => void moveTo(lon, lat), - center: () => siteInfo()?.start_lonlat ?? null, + center: start, }); const draw = (): void => { where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`; + const s = start(); map.set( - place.lon != null && place.lat != null - ? [ - { - key: "place", - name: name() || "소재지", - lon: place.lon, - lat: place.lat, - movable: true, - }, - ] - : [], + [ + ...(s + ? [{ key: "start", name: "시점", lon: s[0], lat: s[1], movable: false, fixed: true }] + : []), + ...(place.lon != null && place.lat != null + ? [ + { + key: "place", + name: name() || "소재지", + lon: place.lon, + lat: place.lat, + movable: true, + }, + ] + : []), + ], [], ); }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index d61fee88b..f7ea25721 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -1,13 +1,12 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_Route.ts - * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). + * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4 · 157) — 머리 줄(거래처 이름 · 주소 + [그림 변경] · [초기화]) + + * 길찾기 지도(연결선 모달과 같은 카카오 지도 · 표지 끌어 소재지 옮김) · 사용자 그림을 올리면 그 그림 · [초기화] = 지도로 * - * 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자 - * 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버 - * 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기 - * [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · 경유 = 기준점 좌표(거침) · - * 도착 = 목적지 좌표) — 보기만 - * 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지(보기만) · JS 키 없음 = 안내 한 줄 + * [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버) + * 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157) + * 255-2 시점도 끌기 = 길찾기용 덮개(`site.start_pin`) · 표지 옮기면 그 자리에 닿는 구간 모두 다시 길찾기 · + * 열 때 선 없는 사슬 구간 모두 길찾기 · 실패 = 안내 한 줄 · 끌기는 그림 캡처 안 함 * ========================================================================== */ import { @@ -17,26 +16,31 @@ import { } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; -import { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout"; +import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout"; +import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { - autoImageNote, - canRevertImage, + capturePlace, deleteImage, + findSegmentKm, + hasUserImage, imageBlobUrl, liveMilestone, putMilestone, routeImageKey, - siteInfo, + segmentPath, + startPoint, + touchingSegments, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; -import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap"; let menu: MapContextMenu | null = null; let lastCell: Element | null = null; /** 표 칸 누름 자리 기억 — 「▾」 메뉴를 그 칸 아래에 엶 */ export function trackCells(root: HTMLElement): void { - root.addEventListener("click", (e) => (lastCell = (e.target as Element).closest("td")), true); + for (const type of ["click", "contextmenu"]) + root.addEventListener(type, (e) => (lastCell = (e.target as Element).closest("td")), true); } export function pickAtCell(items: MapContextMenuItem[]): void { @@ -70,131 +74,152 @@ export interface MilestoneView { refresh: () => void; } -/** 새 창 길찾기 지점 — 거래처 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */ -export function routePoints( - d: MilestoneData, - r: MilestoneRoute, -): Array<{ name: string; lon: number; lat: number }> | null { - const site = siteInfo(); - const dest = r.rows[0]?.dest ?? "start"; - const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat; - if (r.lon == null || r.lat == null || !to) return null; - const points = [{ name: r.name || "거래처", lon: r.lon, lat: r.lat }]; - if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null) - points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat }); - points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] }); - return points; -} - export function routeView(projectId: string, routeId: string): MilestoneView { - const title = el("h4"); - const HINT = - "경로 그림 = [길찾기] 때 서버가 지도에 길 선을 그려 자동으로 만듦 · [그림 올리기] = 내 그림 우선"; - const hint = el("p", { className: "b08-options__hint", text: HINT }); - hint.dataset.b08MilestoneRouteHint = ""; + // 157 머리 줄 = 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기 + const name = el("strong", { className: "b08-milestone__route-name" }); + const address = el("span", { className: "b08-milestone__route-addr" }); - // 그림 — 경로 하나 = 그림 하나 + // 몸 = 사용자 그림 있으면 그 그림 · 없으면 길찾기 지도(연결선 모달과 같은 지도 · 표지 끌어 소재지 옮김) const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } }); - const noImage = el("p", { className: "ui-general-block__empty", text: "길찾기 그림 없음" }); let shown: string | null | undefined; - const loadImage = (force = false): void => { - const name = routeImageKey(routeId); - if (!force && name === shown) return; - shown = name; - img.hidden = !name; - noImage.hidden = !!name; + const loadImage = (): void => { + const key = hasUserImage(routeId) ? routeImageKey(routeId) : null; + img.hidden = !key; + if (key === shown) return; + shown = key; if (img.src) URL.revokeObjectURL(img.src); img.removeAttribute("src"); - if (name) + if (key) imageBlobUrl(projectId, routeId).then( (url) => (img.src = url), (error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"), ); }; - const put = (picked: File): Promise => - uploadImage(projectId, routeId, picked).then( - () => loadImage(true), - (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), - ); const file = el("input", { attrs: { type: "file", accept: "image/*" } }); file.hidden = true; file.addEventListener("change", () => { const picked = file.files?.[0]; file.value = ""; - if (picked) void put(picked); + if (picked) + void uploadImage(projectId, routeId, picked).then( + () => refresh(), + (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), + ); }); - - const kakao = createButton({ - label: "다음 지도에서 길찾기", - size: "sm", - variant: "filled", - onClick: () => { - const d = liveMilestone(projectId); - const r = d.routes.find((x) => x.id === routeId); - const points = r ? routePoints(d, r) : null; - if (!points) - return showToast( - "거래처 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.", - "info", - ); - window.open(kakaoRouteUrl(points), "_blank", "noopener"); - }, - }); - kakao.dataset.b08MilestoneKakao = ""; - const upload = createButton({ - label: "그림 올리기", - size: "sm", + const change = createButton({ + label: "그림 변경", variant: "ghost", onClick: () => file.click(), }); - const revert = createButton({ - label: "자동 그림으로 되돌리기", - size: "sm", + change.dataset.b08MilestoneImageChange = ""; + // 초기화 = 사용자 그림만 지우고 길찾기 지도로 되돌림 + const reset = createButton({ + label: "초기화", variant: "ghost", onClick: () => - void deleteImage(projectId, routeId, true).catch((error: unknown) => - showToast(`자동 그림으로 되돌리지 못했습니다 — ${String(error)}`, "error"), - ), - }); - revert.dataset.b08MilestoneRevert = ""; - const removeImage = createButton({ - label: "그림 지우기", - size: "sm", - variant: "ghost", - onClick: () => - void deleteImage(projectId, routeId).catch((error: unknown) => - showToast(`그림을 지우지 못했습니다 — ${String(error)}`, "error"), + void deleteImage(projectId, routeId, true).then( + () => refresh(), + (error: unknown) => showToast(`초기화하지 못했습니다 — ${String(error)}`, "error"), ), }); + reset.dataset.b08MilestoneRevert = ""; + + const pins = (): MapPin[] => { + const d = liveMilestone(projectId); + const start = startPoint(d); + const r = d.routes.find((x) => x.id === routeId); + const out: MapPin[] = []; + const add = (key: string, label: string, lon?: number | null, lat?: number | null): void => { + if (lon != null && lat != null) out.push({ key, name: label, lon, lat, movable: true }); // 255-2 시점도 끌기(길찾기 덮개) + }; + if (!r) return out; + add(r.id, r.name || "거래처", r.lon, r.lat); + if (r.linked === false) return out; + for (const b of chainOf(d, r.to)) add(b.id, b.name || "기준점", b.lon, b.lat); // 254 기준점 사슬 + add("start", "시점", start?.[0], start?.[1]); + return out; + }; + const lines = (): Array> => { + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : []; + return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2); + }; + // 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」) + const status = el("p", { className: "b08-options__hint" }); + status.dataset.b08MilestoneRouteStatus = ""; + status.hidden = true; + /** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */ + const refind = async (ids: string[]): Promise => { + const texts = await Promise.all(ids.map((id) => findSegmentKm(projectId, id, false))); + const bad = texts.filter((t) => t.includes(" — ")); + status.textContent = bad.join(" · "); + status.hidden = !bad.length; + drawMap(); + }; + /** 표지 끌기 — 시점 = 덮개(site.start_pin · 노선 그대로) · 거래처 · 기준점 = 소재지 · 닿는 선 모두 다시 */ + const moveTo = async (key: string, lon: number, lat: number): Promise => { + const address = key === "start" ? "" : await reverseAddress(projectId, lon, lat); + const fresh = liveMilestone(projectId); + if (key === "start") fresh.site.start_pin = { lon, lat }; + else { + const target = ownerOf(fresh, key); + if (!target) return; + Object.assign(target, { lon, lat, address }); + const label = fresh.bases.some((b) => b.id === key) ? "기준점" : "거래처"; + void capturePlace(projectId, key, { name: target.name || label, lon, lat }); // 256-1 장소 그림 + } + putMilestone(projectId, fresh); + await refind(touchingSegments(fresh, key)); + }; + const map = editMap(projectId, { + hint: "", + onMove: (key, lon, lat) => void moveTo(key, lon, lat), + }); + const drawMap = (): void => map.set(pins(), lines()); + let asked = false; // 길 선이 없으면 열 때 한 번 길찾기(사슬 선 모두 · 그림 · 알림 없음) + const ensurePath = (): void => { + if (asked) return; + asked = true; + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : []; + const missing = [routeId, ...chain].filter((id) => !segmentPath(id).length); + if (missing.length) void refind(missing); + }; - const mapView = routeMapView(projectId, routeId); const root = el("section", { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, children: [ - title, el("div", { - className: "b08-milestone__bar", - children: [kakao, upload, revert, removeImage, file], + className: "b08-milestone__route-head", + children: [ + el("div", { className: "b08-milestone__route-title", children: [name, address] }), + el("div", { className: "b08-milestone__bar", children: [change, reset, file] }), + ], }), - hint, - mapView.root, - noImage, + status, img, + map.root, ], }); - const refresh = (): void => { + function refresh(): void { const d = liveMilestone(projectId); const r = d.routes.find((x) => x.id === routeId); if (!r) return; - const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"]; - title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; + name.textContent = `${r.name || "거래처"} → ${DEST_TEXT[r.rows[0]?.dest ?? "start"]}`; + address.textContent = r.address || ""; loadImage(); - revert.hidden = !canRevertImage(routeId); - hint.textContent = autoImageNote(routeId) ?? HINT; - mapView.draw(); - }; + const user = hasUserImage(routeId); + reset.hidden = !user; + map.root.hidden = user; + if (!user) { + drawMap(); + ensurePath(); + } + } refresh(); return { root, refresh }; } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 301afa94e..6d87b31cc 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -11,16 +11,25 @@ * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) * 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림 * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 - * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) · + * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) · * 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점) + * 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` · + * [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl` + * 기준점 여럿(254) = `bases[]` · 길 선 표 키 = 거래처 · 기준점 id · 옛 꼴 캐시 = 읽을 때 새 꼴(`migrateMilestone`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; -import { roadsFor, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; -import { mapMarks, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; +import { + PAVED, + SITE_ID, + migrateMilestone, + type MilestoneData, + type SegKind, +} from "./B08_Quantity_Milestone_Calc"; +import { mapMarks, ownerOf, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; export interface SiteInfo { @@ -35,11 +44,13 @@ interface Payload { site: SiteInfo; images: Record; auto_images: Record; + place_images: Record; + place_times: Record; } export const EMPTY: MilestoneData = { site: { mid_station_m: null, mid_road: "비포장" }, - base: { enabled: false, name: "", address: "", dir: null, legs: [] }, + bases: [], routes: [], }; @@ -61,6 +72,19 @@ export async function loadMilestone(projectId: string, force = false): Promise

    saved?.site ?? null; +/** 길찾기 시점 = 경로 탭에서 끌어 놓은 덮개(255-2 · site.start_pin) · 없으면 노선 첫 점 */ +export const startPoint = (data: MilestoneData): [number, number] | null => { + const pin = data.site.start_pin; + return pin ? [pin.lon, pin.lat] : (siteInfo()?.start_lonlat ?? null); +}; + +/** 그 자리(시점 "start" · 기준점 · 거래처 id)에 닿는 구간(255-2) — 나가는 구간(자기) + 들어오는 구간(to = 그 자리) */ +export function touchingSegments(data: MilestoneData, key: string): string[] { + const to = key === "start" ? SITE_ID : key; + const incoming = [...data.bases, ...data.routes].filter((o) => o.to === to && o.linked !== false); + return [...(key === "start" ? [] : [key]), ...incoming.map((o) => o.id)]; +} + /** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 · * 오면 다시 그림) · 아직 · 실패 = null(좌표만) */ const siteAddresses = new Map(); @@ -81,11 +105,10 @@ export function siteAddress(projectId: string, midM: number | null): SiteAddress } export const routeImages = (): Record => saved?.images ?? {}; -/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */ -export const canRevertImage = (routeId: string): boolean => { +/** 사용자가 올린 그림이 있음(157 · 자동 그림과 다른 이름) — 경로 탭 = 그 그림 · 없으면 길찾기 지도 */ +export const hasUserImage = (routeId: string): boolean => { const name = saved?.images[routeId]; - const auto = saved?.auto_images?.[routeId]; - return !!name && !!auto && name !== auto; + return !!name && name !== saved?.auto_images?.[routeId]; }; /** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */ @@ -95,16 +118,14 @@ export const routeImageKey = (routeId: string): string | null => { return name ? `${name}#${imageRev.get(routeId) ?? 0}` : null; }; -/** 경로 그림 자동 상태 한 줄(만드는 중 · 실패 까닭) — 없으면 null */ -const autoNotes = new Map(); -export const autoImageNote = (routeId: string): string | null => autoNotes.get(routeId) ?? null; - export const hasMilestoneDraft = (projectId: string): boolean => readState("b08-milestone", projectId) !== null; -/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */ +/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본 · 옛 꼴 캐시 = 새 꼴 · 254) */ export function liveMilestone(projectId: string): MilestoneData { - return clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY); + return migrateMilestone( + clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY), + ); } /** 고친 한 벌을 캐시에 · 화면에 알림 */ @@ -185,12 +206,11 @@ export async function autoRouteImage( lines: Array>, ): Promise { // 표지 = 거래처 쪽 구간 두 끝(mapMarks 차례 = 거래처 · 기준점(거침) · 시점 → 앞 둘) - const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null).slice( - 0, - 2, - ); - autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)"); - changes.dispatchEvent(new Event("change")); + const marks = mapMarks( + liveMilestone(projectId), + routeId, + startPoint(liveMilestone(projectId)), + ).slice(0, 2); let reason: string | null; try { const res = await request<{ name: string | null; reason: string | null }>( @@ -205,36 +225,60 @@ export async function autoRouteImage( imageRev.set(routeId, (imageRev.get(routeId) ?? 0) + 1); } } catch (error) { - reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — 그림 올리기를 쓰세요.`; + reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — [그림 변경]으로 넣으세요.`; } - if (reason) { - autoNotes.set(routeId, reason); - showToast(reason, "warning"); - } else autoNotes.delete(routeId); + if (reason) showToast(reason, "warning"); changes.dispatchEvent(new Event("change")); } +/** 장소 그림(256-1) 다시 읽을 표 — 이름 + 바뀐 시각 · 없으면 null */ +export const placeImageKey = (placeId: string): string | null => { + const name = saved?.place_images?.[placeId]; + return name ? `${name}#${saved?.place_times?.[placeId] ?? 0}` : null; +}; + +/** 장소 그림(256-1) — 상자 좌표 · 이름 → 서버 캡처 → 목록 고쳐 알림(뒤에서 · 실패 = 알림 한 줄) */ +export async function capturePlace( + projectId: string, + placeId: string, + mark: { name: string; lon: number; lat: number }, +): Promise { + let reason: string | null; + try { + const res = await request<{ name: string | null; time: number | null; reason: string | null }>( + `/projects/${projectId}/quantity/milestone/place-image?id=${encodeURIComponent(placeId)}`, + { method: "POST", body: JSON.stringify(mark) }, + ); + reason = res.reason; + if (res.name && saved) { + saved.place_images = { ...saved.place_images, [placeId]: res.name }; + saved.place_times = { ...saved.place_times, [placeId]: res.time ?? Date.now() }; + } + } catch (error) { + reason = `장소 그림 캡처 요청 실패(${String(error)})`; + } + if (reason) showToast(reason, "warning"); + changes.dispatchEvent(new Event("change")); +} + +/** 장소 그림 → 화면 주소(blob · 쿠키 포함) */ +export async function placeImageBlobUrl(projectId: string, placeId: string): Promise { + const name = saved?.place_images?.[placeId] ?? `place/${placeId}.png`; + const url = `${API_BASE_URL}/projects/${projectId}/quantity/milestone/file?name=${encodeURIComponent(name)}`; + return URL.createObjectURL(await (await call(url)).blob()); +} + /** 그림 → 화면 주소(blob · 쿠키 포함) */ export async function imageBlobUrl(projectId: string, routeId: string): Promise { return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); } -/** 도로 구분 ▾ — via = 기준점 거쳐 시점 들어가는 구간(2차 · 1차 · 비포장) · 아니면 포장 · 비포장(244-13) · - * blank = 「도로 구분 고르기」 빈 값(자동 판정 없음 · 244-7) · 목록 밖 옛 값도 보임 */ -export function roadOptions( - via: boolean, - blank: boolean, - current?: string | null, -): Array<{ value: string; text: string }> { - const roads: string[] = roadsFor(via); - if (current && !roads.includes(current)) roads.push(current); - return [ - ...(blank ? [{ value: "", text: "도로 구분 고르기" }] : []), - ...roads.map((r) => ({ value: r, text: r })), - ]; +/** 도로 구분 ▾(152) — 그 자리 포장 종류 · 비포장 둘(포장 종류는 선 자리로 정함 · `roadOf`) */ +export function roadOptions(kind: SegKind): Array<{ value: string; text: string }> { + return [PAVED[kind], "비포장"].map((r) => ({ value: r, text: r })); } -/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */ +/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 · 기준점 id) → [위도, 경도] 목록(메모리만 · 저장 안 함) */ const paths = new Map>(); export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? []; /** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */ @@ -249,7 +293,9 @@ export async function findSegmentKm( image = true, ): Promise { const data = liveMilestone(projectId); - const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null); + const owner = ownerOf(data, from); + if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함 + const seg = segmentEnds(data, from, startPoint(data)); if (!seg) return ""; if (!seg.from || !seg.to) { const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; @@ -271,9 +317,8 @@ export async function findSegmentKm( if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!); else paths.delete(from); const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 - const legs = from === "base" ? fresh.base.legs : fresh.routes.find((r) => r.id === from)?.legs; - if (legs?.[0]?.auto_km !== res.km) { - putAutoKm(fresh, from, seg.key, res.km); + if (ownerOf(fresh, from)?.legs[0]?.auto_km !== res.km) { + putAutoKm(fresh, from, res.km); putMilestone(projectId, fresh); } else changes.dispatchEvent(new Event("change")); if (image && seg.key === "route" && paths.has(from)) diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index 62d0a3147..9255a5cf5 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -21,7 +21,7 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; @@ -85,14 +85,11 @@ export function onOptionsChange(host: HTMLElement, fn: () => void): void { const pctText = (value: number): string => String(Math.round(value * 1e6) / 1e6); +/** 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식). */ function card(id: string, title: keyof typeof ui_locales, ...body: HTMLElement[]): HTMLElement { - const section = document.createElement("section"); - section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; + const section = createPanelCard(L(title)); section.dataset.b08Options = id; - const heading = document.createElement("h3"); - heading.className = "ui-collapsible__title"; - heading.textContent = L(title); - section.append(heading, ...body); + section.append(...body); return section; } diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index e07c12821..c443aea19 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -82,8 +82,9 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } +/* 칸과의 틈 = 공용 카드 틈(269) */ .b08-options__hint { - margin: var(--spacing-4) 0 0; + margin: 0; color: var(--color-text-secondary); font-size: var(--text-caption); } diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 385aebf8c..2a8ed9154 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -57,7 +57,7 @@ function pendingNote(): HTMLElement { /** 자재 탭 — 보조 탭 줄 + 몸통(보조 탭마다 새 몸통 · 늦은 응답이 다른 보조 탭을 덮지 않게). */ function renderMaterials(host: HTMLElement, projectId: string | null): void { const tabs = document.createElement("div"); - tabs.className = "b08-quantity__tabs b08-structure__tabs"; + tabs.className = "b08-quantity__tabs ui-subtabs b08-structure__tabs"; tabs.setAttribute("role", "tablist"); const view = document.createElement("div"); const select = (picked: SheetId): void => { @@ -92,7 +92,7 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): voi if (tab === "summary" && projectId) { // 보조 탭 줄 자리(170-4) — 보이지 않는 sm 단추 한 줄 · 자재 탭과 같은 높이라 탭 바꿀 때 안 튐 const blank = document.createElement("div"); - blank.className = "b08-quantity__tabs b08-quantity__tabs--blank"; + blank.className = "b08-quantity__tabs ui-subtabs b08-quantity__tabs--blank"; blank.setAttribute("aria-hidden", "true"); const ghost = createButton({ label: L(SHEETS[0].label), variant: "ghost", size: "sm" }); ghost.tabIndex = -1; diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index 29891ac95..9282a4e92 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -14,11 +14,13 @@ from __future__ import annotations from typing import Any +from B08_Quantity.B08_Quantity_Haul import bill_key, haul_groups from B08_Quantity.B08_Quantity_Material_Calc import _structure_row WHY_EARTH = "조합 밖 토공 산근 — 연결 없음" WHY_READY = "준비공 대가 — 연결 없음" WHY_HAUL = "운반 산근 — 연결 없음(운반거리 원천 없음)" +WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)" WHY_STRUCTURE = "대가 호표 — 연결 없음(일위대가 145-2)" #: 토공 항목 — (공종, 엑셀 명칭, 단위, 토공집계표 (구분, 종류, 규격 | None) | None) @@ -46,16 +48,6 @@ EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [ STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3"} SKIP_COLUMNS = ("pipe:cp|",) -#: 운반공 — (명칭, 단위, B08 총괄자재 줄 키(합) | None) -HAUL: list[tuple[str, str, tuple[str, ...] | None]] = [ - ("중기운반", "대", None), - ("기계운반", "대", None), - ("모래운반", "㎥", ("sand",)), - ("자갈운반", "㎥", ("gravel",)), - ("시멘트운반", "대", ("cement",)), - ("철근운반", "ton", ("rebar_d13", "rebar_d16", "rebar_d19", "rebar_d22", "rebar_d25")), -] - def _same(row: dict[str, Any], want: tuple[str, str, str | None]) -> bool: group, kind, spec = want @@ -113,28 +105,51 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]: return out -def haul_items(materials: dict[str, Any] | None) -> list[dict[str, Any]]: - rows = ((materials or {}).get("material") or {}).get("rows") or [] - amount = {row.get("key"): row.get("amount") for row in rows} +def haul_items( + materials: dict[str, Any] | None, + refs: dict[str, Any] | None = None, + routes: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) + + 거리이정표 행(253-1 · `B08_Quantity_Haul.haul_rows` · 행 하나 = 줄 하나 · 둘째 행부터 수량 빈칸) · + 이름 = 거리이정표 항목명 그대로(261-4 · 「운반」 안 붙임 · 비면 거래처 · 그것도 없으면 묶음 이름 · 「운반」) · + 연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김) · + `dist{L1, L2}` = 그 행 거리(253-3 「운반 단가」 모달 · 없으면 None).""" + rows = routes or [] + by_key = {r["key"]: r for r in rows} + extra: dict[str, list[dict[str, Any]]] = {} # 자동 상자 둘째 행부터 — 묶음 줄 바로 뒤 + for r in rows: + if r.get("haul") and r["key"] != bill_key(str(r["haul"])): + extra.setdefault(str(r["haul"]), []).append(r) + + def line(key: str, name: str, unit: str, qty: Any, note: str) -> dict[str, Any]: + ref = (refs or {}).get(key) + route = by_key.get(key) or {} + return { + "key": key, + "section": "3", + "name": name, + "spec": "", + "unit": unit, + "qty": qty, + "note": note, + "ref": ref, + "dist": {"L1": route["L1"], "L2": route["L2"]} if route else None, + "why": WHY_LOGIC if (ref or {}).get("tab") == "logic" else WHY_HAUL, + } + out = [] - for n, (name, unit, keys) in enumerate(HAUL, 1): - qty = None - if keys: - found = [amount[k] for k in keys if amount.get(k) is not None] - qty = round(sum(found), 6) if found else None + for g in haul_groups(materials): + key = bill_key(g["key"]) + name = (by_key.get(key) or {}).get("name") or g["name"] out.append( - { - "key": f"haul:{n}", - "section": "3", - "name": name, - "spec": "", - "unit": unit, - "qty": qty, - "note": "B08 총괄자재" if qty is not None else "", - "ref": None, - "why": WHY_HAUL, - } + line(key, name, g["unit"], g["qty"], "B08 총괄자재" if g["qty"] is not None else "") ) + for r in extra.get(g["key"], []): + out.append(line(r["key"], r.get("name") or g["name"], "", None, "B08 거리이정표")) + for r in rows: + if r.get("user"): + out.append(line(r["key"], r.get("name") or "운반", "", None, "B08 거리이정표")) return out @@ -143,7 +158,7 @@ def bill_items(b08: dict[str, Any]) -> dict[str, Any]: "items": [ *earth_items(b08.get("earthwork")), *structure_items(b08.get("summary")), - *haul_items(b08.get("materials")), + *haul_items(b08.get("materials"), b08.get("haul_refs"), b08.get("haul_routes")), ] } diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index b1c1a16c6..e900d37a8 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -10,19 +10,43 @@ 대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) · 단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} · [저장] 때 줄이 안 쓰는 사본은 지움(`prune`) · 층 읽기 권한 = 부르는 쪽(`M02_Layer_Access.grant`). + +운반 값 사본(263-2) — 운반공 줄 연결 + `{tab: dg, key, lib, given{줄 id: {입력: 값}}, own[줄 id|입력]}` · + 담은 로직 줄(id = `{참조 id}#{차례}` · 맨 위 줄 = `#{차례}`)의 거리 칸 + (운반거리 · Cm · 사이클시간 · t2) = + 거리이정표 거리로 셈(짝 `B08_Quantity_Haul_Cycle`) · 그 밖 = 손 값 → 값 묶음 `값` · + 폴더 = `{종류}@{층}@{주인}@{키}~{값 해시 8}`(값 없으면 해시 없음 · 옛 이름 그대로 읽힘) · + 같은 항목 같은 값 = 같은 사본 · + manifest `원값` = 덮기 전 값(다른 값 사본을 이 사본에서 다시 만듦) · + 단가 묶음 = 로직 · 자재 키로만 모음 → 값이 바뀌어도 그대로 · + 셈 때(`resolve_hauls`) 서버가 거리로 값을 다시 셈 → 다른 해시면 새 사본 · + 줄 연결 lib · key 바꿔 셈. """ from __future__ import annotations +import hashlib +import json import re import shutil from pathlib import Path from typing import Any +from B08_Quantity.B08_Quantity_Haul_Cycle import FILL, cycle_inputs, cycle_time from B09_Estimation.B09_Estimation_Store import folder as store_folder +from B09_Estimation.B09_Estimation_Store import ref_rows from common_util.common_util_json import atomic_write_json +from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Ref as refs -from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, build_snapshot, now_text, origin_of +from M02_MasterTemplete.M02_Item_Snapshot import ( + MANIFEST_NAME, + VALUES_FILE, + build_snapshot, + now_text, + origin_of, + seed_values, +) from M02_MasterTemplete.M02_Item_Source import ( LABOR_FILE, PRICE_FILE, @@ -34,17 +58,28 @@ from M02_MasterTemplete.M02_Item_Source import ( LIBRARY = "library" KINDS = ("combo", "precombo") -#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`) -NAME = re.compile(r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}$") +#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`) · 값 해시(263-2 · 없을 수 있음) +NAME = re.compile( + r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$" +) def library_dir(project_root: str | Path) -> Path: return store_folder(project_root) / LIBRARY -def folder_name(kind: str, layer: str, owner: str | None, key: str) -> str: - """고른 서고 것 → 사본 폴더 이름 — 규칙 밖이면 ValueError.""" +def tag(values: dict[str, Any]) -> str: + """값 묶음 해시 8 — 같은 값 = 같은 표지.""" + text = json.dumps(values, sort_keys=True, ensure_ascii=False) + return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8] + + +def folder_name( + kind: str, layer: str, owner: str | None, key: str, values: dict[str, Any] | None = None +) -> str: + """고른 서고 것 → 사본 폴더 이름(값 있으면 `~해시`) — 규칙 밖이면 ValueError.""" name = f"{kind}@{layer}@{(owner or '-').replace('/', '-')}@{key}" + name += f"~{tag(values)}" if values else "" if not NAME.match(name): raise ValueError(f"서고 항목 모양이 틀림 「{name}」") return name @@ -64,6 +99,130 @@ def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]: } +def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, dict]: + """서고 원본 → (사본 파일들, 원본 표지).""" + if kind not in KINDS: + raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나") + folder_name(kind, layer, owner, key) # 모양 먼저(원본 읽기 전) + if kind == "combo": + item = {"layer": layer, "owner": owner, "key": key} + item["name"] = item_name(item) + return build_snapshot(item), origin_of(item) + files = _precombo_files(layer, owner, key) + return files, files.pop("_원본") + + +# ── 운반 값 사본(263-2) ──────────────────────────────────────────────── +def _logic(code: str) -> dict[str, Any]: + try: + return dict(m01.loaded()[1].logic(code) or {}) + except Exception: # noqa: BLE001 — 없는 로직 = 입력 없음 + return {} + + +def _line_at(labor: Any, lid: str) -> dict[str, Any] | None: + rid, _, at = lid.rpartition("#") + doc = (labor or {}).get("줄") if not rid else ((labor or {}).get("참조사본") or {}).get(rid) + lines = doc.get("담은로직") if isinstance(doc, dict) else None + if not at.isdigit() or not isinstance(lines, list) or int(at) >= len(lines): + return None + line = lines[int(at)] + return line if isinstance(line, dict) and line.get("로직") else None + + +def lines_of(files: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + """사본 안 로직 줄 `(줄 id, 줄)` — 맨 위 줄 → 굳힌 참조 문서 줄.""" + labor = files.get(LABOR_FILE) or {} + docs = [("", labor.get("줄")), *((labor.get("참조사본") or {}).items())] + return [ + (f"{rid}#{i}", one) + for rid, doc in docs + for i, one in enumerate((doc or {}).get("담은로직") or []) + if isinstance(one, dict) and one.get("로직") + ] + + +def haul_values( + lines: list[tuple[str, dict[str, Any]]], given: Any, own: Any, dist: Any +) -> dict[str, dict[str, Any]]: + """값 묶음 `{줄 id: {입력: 값}}` — 거리 칸 = 거리로 셈(`own` 이면 손 값) · 그 밖 = 손 값.""" + given = given if isinstance(given, dict) else {} + own = set(own if isinstance(own, list) else []) + got = cycle_time(cycle_inputs(None, dist)) if isinstance(dist, dict) else None + out: dict[str, dict[str, Any]] = {} + for lid, line in lines: + hand = given.get(lid) if isinstance(given.get(lid), dict) else {} + vals = {} + for name in (str(i.get("이름")) for i in _logic(str(line["로직"])).get("입력") or []): + if got and name in FILL and f"{lid}|{name}" not in own: + vals[name] = got[FILL[name]] + elif isinstance(hand.get(name), (str, int, float)): + vals[name] = hand[name] + if vals: + out[lid] = vals + return out + + +def _apply(files: dict[str, Any], kind: str, values: dict[str, Any]) -> dict[str, Any]: + """값 덮기 → 덮기 전 값(`원값` · 없던 칸 = None).""" + labor = files.get(LABOR_FILE) + old: dict[str, Any] = {} + for lid, vals in values.items(): + if (line := _line_at(labor, lid)) is None: + continue + given = dict(line.get("입력값") or {}) + old[lid] = {k: given.get(k) for k in vals} + line["입력값"] = {**given, **vals} + if kind == "combo" and old: + files[VALUES_FILE] = seed_values(labor) + return old + + +def _restore(files: dict[str, Any], kind: str, old: Any) -> None: + """`원값` 되돌림 — 덮기 전 사본 내용.""" + labor = files.get(LABOR_FILE) + for lid, vals in (old if isinstance(old, dict) else {}).items(): + if (line := _line_at(labor, lid)) is None: + continue + given = dict(line.get("입력값") or {}) + for k, v in vals.items(): + if v is None: + given.pop(k, None) + else: + given[k] = v + line["입력값"] = given + if kind == "combo" and old: + files[VALUES_FILE] = seed_values(labor) + + +def _write(project_root: str | Path, name: str, files: dict, manifest: dict, values: dict) -> None: + target = library_dir(project_root) / name + old = _apply(files, str(manifest.get("종류")), values) + if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리 + shutil.rmtree(target) + target.mkdir(parents=True) + for file, content in files.items(): + if isinstance(content, bytes): + (target / file).write_bytes(content) + else: + atomic_write_json(target / file, content) + extra = {"값": values, "원값": old} if values else {} + atomic_write_json(target / MANIFEST_NAME, {**manifest, **extra}) # 마지막 — 있으면 다 쓴 폴더 + + +def _read_copy(folder: Path) -> tuple[dict[str, Any], dict[str, Any]] | None: + """다 쓴 사본 → (원값 되돌린 파일들, manifest(값 · 원값 뺌)) — 없으면 None.""" + manifest = read_json(folder / MANIFEST_NAME) + if not NAME.match(folder.name) or not isinstance(manifest, dict): + return None + files: dict[str, Any] = {} + for path in sorted(folder.iterdir()): + if path.is_file() and path.name != MANIFEST_NAME: + files[path.name] = read_json(path) if path.suffix == ".json" else path.read_bytes() + _restore(files, str(manifest.get("종류")), manifest.get("원값")) + return files, {k: v for k, v in manifest.items() if k not in ("값", "원값")} + + def import_copy( project_root: str | Path, kind: str, @@ -71,33 +230,105 @@ def import_copy( owner: str | None, key: str, who: dict[str, Any] | None = None, + haul: dict[str, Any] | None = None, ) -> dict[str, Any]: """서고 것 하나를 프로젝트 사본으로 — `{폴더, 만듦}` · 이미 있으면 그대로(만듦 거짓) · + haul = 운반 값(263-2 · `{given, own, dist}`) · 원본이 없으면 LookupError · 모양이 틀리면 ValueError.""" - if kind not in KINDS: - raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나") - name = folder_name(kind, layer, owner, key) - target = library_dir(project_root) / name - if isinstance(read_json(target / MANIFEST_NAME), dict): + haul = haul or {} + lib = haul.get("lib") # 있는 사본에서(굳힌 내용 · 같은 항목만) + if isinstance(lib, str) and lib.split("~")[0] == folder_name(kind, layer, owner, key): + if name := resolve(project_root, {**haul, "lib": lib}, haul.get("dist")): + return {"폴더": name, "만듦": False} + files, origin = _build(kind, layer, owner, key) + values = haul_values(lines_of(files), haul.get("given"), haul.get("own"), haul.get("dist")) + name = folder_name(kind, layer, owner, key, values) + if isinstance(read_json(library_dir(project_root) / name / MANIFEST_NAME), dict): return {"폴더": name, "만듦": False} - if kind == "combo": - item = {"layer": layer, "owner": owner, "key": key} - item["name"] = item_name(item) - files, origin = build_snapshot(item), origin_of(item) - else: - files = _precombo_files(layer, owner, key) - origin = files.pop("_원본") - if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리 - shutil.rmtree(target) - target.mkdir(parents=True) - for file, content in files.items(): - atomic_write_json(target / file, content) - stamp = now_text() - manifest = {"종류": kind, "원본": origin, "가져온날": stamp, "가져온이": who} - atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더 + manifest = {"종류": kind, "원본": origin, "가져온날": now_text(), "가져온이": who} + _write(project_root, name, files, manifest, values) return {"폴더": name, "만듦": True} +def library_inputs( + project_root: str | Path, kind: str, layer: str, owner: str | None, key: str, lib: Any = None +) -> list[dict[str, Any]]: + """담은 로직 입력 · 값(263-2 · 읽기만) — 있는 사본(`lib`)이면 그 덮기 전 값 · + 없으면 서고 원본.""" + got = None + if isinstance(lib, str) and NAME.match(lib): + got = _read_copy(library_dir(project_root) / lib) + files = got[0] if got else _build(kind, layer, owner, key)[0] + out = [] + for lid, line in lines_of(files): + logic = _logic(str(line["로직"])) + out.append( + { + "id": lid, + "로직": line["로직"], + "이름": logic.get("이름") or line["로직"], + "입력": logic.get("입력") or [], + "값": line.get("입력값") or {}, + } + ) + return out + + +def resolve(project_root: str | Path, ref: dict[str, Any], dist: Any) -> str | None: + """운반 연결 사본 → 지금 거리 · 손 값의 사본 폴더(없으면 그 사본에서 새로 만듦) · + 그 사본이 지워졌으면(prune 뒤 옛 연결) 같은 바탕 사본 → 서고 원본에서 · 못 하면 None.""" + lib = str(ref.get("lib") or "") + if not NAME.match(lib): + return None + base = lib.split("~")[0] + got = _read_copy(library_dir(project_root) / lib) + if got is None: # 같은 바탕 사본 + same = [f for f, _m in copies(project_root) if f.name.split("~")[0] == base] + got = _read_copy(same[0]) if same else None + if got is None: + kind, layer, owner, key = base.split("@") + owner = None if owner == "-" else owner.replace("-", "/") + try: + got = _build(kind, layer, owner, key) + except (LookupError, ValueError): + return None + got = (got[0], {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None}) + files, manifest = got + values = haul_values(lines_of(files), ref.get("given"), ref.get("own"), dist) + name = base + (f"~{tag(values)}" if values else "") + target = library_dir(project_root) / name / MANIFEST_NAME + if not isinstance(read_json(target), dict): + _write(project_root, name, files, manifest, values) + return name + + +def resolve_hauls(project_root: str | Path, user: dict[str, Any]) -> dict[str, Any]: + """셈 사용자 값 — 운반공 줄 서고 사본 연결(덮개 줄 · 거리이정표 연결)을 지금 거리의 사본으로.""" + dist = user.get("haul_dist") or {} + + def fix(ref: Any, key: Any) -> Any: + if key not in dist or not (isinstance(ref, dict) and ref.get("tab") == "dg"): + return ref + name = resolve(project_root, ref, dist[key]) if ref.get("lib") else None + return {**ref, "lib": name} if name and name != ref["lib"] else ref + + bill = user.get("bill") if isinstance(user.get("bill"), dict) else {} + rows = [ + {**r, "ref": fix(r["ref"], r.get("key"))} if isinstance(r, dict) and "ref" in r else r + for r in bill.get("rows") or [] + ] + hauls = {k: fix(ref, k) for k, ref in (user.get("haul_refs") or {}).items()} + return {**user, "bill": {**bill, "rows": rows}, "haul_refs": hauls} + + +def fix_keys(user: dict[str, Any], libs: dict[str, str]) -> None: + """사본 폴더 → 대가 블록 id — 운반 연결 key 를 바뀐 사본 블록으로(제자리).""" + for row in ref_rows(user): + ref = row.get("ref") + if isinstance(ref, dict) and ref.get("tab") == "dg" and ref.get("lib") in libs: + ref["key"] = libs[ref["lib"]] + + def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]: """다 쓴 사본 `(폴더, manifest)` — 폴더 이름 차례.""" base = library_dir(project_root) @@ -110,15 +341,11 @@ def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]: def used_folders(user: dict[str, Any]) -> set[str]: - """설계내역서 덮개 줄이 잇는 서고 사본 폴더.""" - rows = ((user or {}).get("bill") or {}).get("rows") or [] + """설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더.""" return { str(ref["lib"]) - for row in rows - if isinstance(row, dict) - and isinstance(ref := row.get("ref"), dict) - and ref.get("tab") == "dg" - and ref.get("lib") + for row in ref_rows(user) + if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib") } diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 30b69f9c4..6c7379b47 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -416,7 +416,7 @@ def compute_bill( if price_of is not None and item.get("owners"): parts = split_by_block(item, price_of) else: - parts = [(item, ref_price(item.get("ref"), books, hopyo))] + parts = [(item, ref_price(item.get("ref"), books, hopyo, item["key"]))] for part, prices in parts: items.append(_item_row(*_covered(part, prices, cover.get(part["key"]), books, hopyo))) for item in _material_items(inputs, base["compare"]): @@ -490,11 +490,10 @@ def compute_bill( gaps.extend(row.pop("_gaps", [])) row.pop("_home", None) row["no"] = "" - if row["kind"] == "title": - empty = f" 빈 줄 {row['blank']}" if row["blank"] else "" - row["note"] = ("(제외금액)" if row["key"] == "sec:G" else "") + empty + if row["kind"] == "title": # 257-2 비고 「빈 줄 n」 없음(blank 수는 줄 값 그대로) + row["note"] = "(제외금액)" if row["key"] == "sec:G" else "" elif row["kind"] == "group": - row["note"] = f"빈 줄 {row['blank']}" if row["blank"] else "" + row["note"] = "" if row["kind"] != "item" and row["cost"] == "direct" and row["level"] <= 2: up = row["parent"] # 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음) seq[up] = seq.get(up, 0) + 1 diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 3a5702b8e..50ad3e3fd 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -386,7 +386,7 @@ export function computeBill( const parts: Array<[Row, Row | null]> = priceOf !== null && item.owners && Object.keys(item.owners).length ? splitByBlock(item, priceOf) - : [[item, refPrice(item.ref ?? null, book, hopyo)]]; + : [[item, refPrice(item.ref ?? null, book, hopyo, item.key)]]; for (const [part, prices] of parts) items.push(itemRow(...covered(part, prices, cover[part.key], book, hopyo))); } @@ -458,10 +458,9 @@ export function computeBill( delete row._gaps; delete row._home; row.no = ""; - if (row.kind === "title") - row.note = - (row.key === "sec:G" ? "(제외금액)" : "") + (row.blank ? ` 빈 줄 ${row.blank}` : ""); - else if (row.kind === "group") row.note = row.blank ? `빈 줄 ${row.blank}` : ""; + // 257-2 비고 「빈 줄 n」 없음(blank 수는 줄 값 그대로) + if (row.kind === "title") row.note = row.key === "sec:G" ? "(제외금액)" : ""; + else if (row.kind === "group") row.note = ""; if (row.kind !== "item" && row.cost === "direct" && row.level <= 2) { // 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음) const up = row.parent; diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index 868e8e7e3..f1776fdaf 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -66,9 +66,9 @@ def used_keys(folder: Path) -> list[str]: return seen -def linked_resource_keys(project_root: str | Path) -> list[str]: +def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) -> list[str]: """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) · - 겹침 없음.""" + 겹침 없음 · `keep` = 화면 캐시 줄이 잇는 서고 사본만(296 · 지운 줄의 사본 뺌).""" from B09_Estimation.B09_Estimation_Bill_Library import copies base = structures_dir(project_root) @@ -78,7 +78,8 @@ def linked_resource_keys(project_root: str | Path) -> list[str]: if "일위대가" in linked.get("부분", []) ] out: list[str] = [] - for folder in [*folders, *(f for f, _ in copies(project_root))]: + libs = [f for f, _ in copies(project_root) if keep is None or f.name in keep] + for folder in [*folders, *libs]: for key in used_keys(folder): if key not in out: out.append(key) diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index e8dd8d916..d34bc262f 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -182,14 +182,17 @@ def _convert(master, key: str) -> dict[str, Any]: return material_input(row) -def refresh_book(keys: list[str]) -> dict[str, Any]: - """[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘.""" +def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[str, Any]: + """[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘 · + M01 원본이 지워진 키(296) = 옛 책 줄 그대로(지우지도 바꾸지도 않음).""" master = _master() want = demand(master, keys) rows = { key: _convert(master, key) for key in [*want["labor"], *want["machines"], *want["materials"]] } + old = (book or {}).get("rows") or {} + rows.update({key: old[key] for key in want["missing"] if key in old}) return { "made": datetime.now().isoformat(timespec="seconds"), "rows": rows, @@ -202,7 +205,8 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None: """203-1 [업데이트] 배지 — 지금 M01 로 새로 굳힌 책(`refresh_book`)과 견줌 · 다른 것 한 줄(같으면 None).""" if book is None: return "단가 아직 안 굳힘" - fresh = json.loads(json.dumps(refresh_book(keys), ensure_ascii=False)) # 저장 꼴(JSON)로 견줌 + # 저장 꼴(JSON)로 견줌 · M01 원본이 지워진 줄 = 옛 줄 그대로(낡음 까닭에서 빠짐 · 296) + fresh = json.loads(json.dumps(refresh_book(keys, book), ensure_ascii=False)) rows = book.get("rows") or {} new = sum(key not in rows for key in fresh["rows"]) changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items()) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 62ccb65e8..10e53f93e 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -4,12 +4,21 @@ 입력(굳힌 단가 · 수요 · B08 수량) + 사용자 값 → 표 전부 PUT /api/projects/{id}/estimation {user} [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 - POST /api/projects/{id}/estimation/prices - [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + POST /api/projects/{id}/estimation/prices {user?} + [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · + 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 + POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} + 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · + 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2) POST /api/projects/{id}/estimation/library {kind, layer, owner, key} 설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로 (있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} · - 층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`) + 층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → + [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`) · + 운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`) + POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?} + 「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음) +운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`). 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` · @@ -25,11 +34,15 @@ from typing import Any from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field +from B08_Quantity.B08_Quantity_Haul import haul_rows +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets +from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone +from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Sangeun as sangeun @@ -48,6 +61,11 @@ from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete.M02_Item_Snapshot import importer +try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이) + from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft +except ImportError: # pragma: no cover + schedule_draft = None + router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -103,29 +121,63 @@ def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> t return sheets, compute_summary(sheets, hopyo_lines(sheets)) -async def _demand(root: Path) -> list[str]: - """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2).""" - keys = await asyncio.to_thread(linked_resource_keys, root) - user = await asyncio.to_thread(store.read_user, root) +def haul_routes(root: Path) -> list[dict[str, Any]]: + """거리이정표 행 → 운반공 줄(253-1 · `haul_rows` · 거리 L1 · L2 포함) — 못 읽으면 빈 목록.""" + try: + data = read_milestone(root) + mid = site_info(root, data["site"]["mid_station_m"])["mid_default_m"] + return haul_rows(data, compute_milestone(data, mid)) + except Exception: # noqa: BLE001 — 거리이정표 없음 = 운반 연결 없음 + return [] + + +def haul_user(rows: list[dict[str, Any]]) -> dict[str, Any]: + """셈 덧값 — `haul_refs{줄 키: ref}`(251-5) · `haul_dist{줄 키: {L1, L2}}`(253-2 거리 채움).""" + return { + "haul_refs": {r["key"]: r["ref"] for r in rows if r.get("ref")}, + "haul_dist": {r["key"]: {"L1": r["L1"], "L2": r["L2"]} for r in rows}, + } + + +def calc_user(root: Path, draft: dict[str, Any] | None = None) -> dict[str, Any]: + """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값(draft = 화면 캐시 · 296) + 거리이정표 운반 연결 · + 거리(화면엔 안 보냄) · 운반 사본 연결 = 지금 거리의 사본(263-2).""" + user = {**(store.read_user(root) if draft is None else draft), **haul_user(haul_routes(root))} + return library.resolve_hauls(root, user) + + +async def _demand(root: Path, draft: dict[str, Any] | None = None) -> list[str]: + """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2) + + 거리이정표 운반 연결(251-5) · draft = 화면 캐시(296 · 서고 사본 = 그 줄이 잇는 것만).""" + user = await asyncio.to_thread(calc_user, root, draft) # 운반 값 사본 먼저(263-2) + keep = None if draft is None else library.used_folders(user) + keys = await asyncio.to_thread(linked_resource_keys, root, keep) hand = await asyncio.to_thread(sangeun.hand_resource_keys, user) return [*keys, *(k for k in hand if k not in keys)] -async def payload(project_id: UUID, root: Path) -> dict[str, Any]: +async def payload( + project_id: UUID, root: Path, draft: dict[str, Any] | None = None +) -> dict[str, Any]: notes: list[str] = [] b08 = await _b08(project_id, notes) - keys = await _demand(root) + keys = await _demand(root, draft) if not keys: notes.append( "연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김" ) book = await asyncio.to_thread(store.read_book, root) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) - inputs["bill"] = bill_items(b08) + hauls = await asyncio.to_thread(haul_routes, root) + user = await asyncio.to_thread(store.read_user, root) + calc = await asyncio.to_thread(library.resolve_hauls, root, {**user, **haul_user(hauls)}) + inputs["bill"] = bill_items({**b08, **calc, "haul_routes": hauls}) inputs["hopyo"] = hopyo_prices(root) inputs["daega"] = await asyncio.to_thread(daega_inputs, root) - user = await asyncio.to_thread(store.read_user, root) - sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, user) + library.fix_keys(calc, inputs["daega"].get("libs") or {}) # 263-2 바뀐 사본 = 그 블록 + sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc) + if schedule_draft: + schedule_draft.attach(inputs, sheets, user) return { "inputs": inputs, "user": user, @@ -151,7 +203,12 @@ async def estimation(project_id: UUID) -> dict[str, Any]: async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: root = await _project_root(project_id) user = await asyncio.to_thread(store.write_user, root, body.get("user")) - await asyncio.to_thread(library.prune, root, user) # 204-2 줄이 안 잇는 서고 사본 + calc = await asyncio.to_thread(calc_user, root) # 263-2 운반 값 사본 = 지금 거리 + if (rows := calc["bill"].get("rows")) != ((user.get("bill") or {}).get("rows") or []): + # 바뀐 사본 연결 = 저장 값에도(응답 user → 화면 캐시 · 다음 [저장]이 옛 lib 안 씀) + bill = {**user["bill"], "rows": rows} + await asyncio.to_thread(store.write_user, root, {**user, "bill": bill}) + await asyncio.to_thread(library.prune, root, calc) # 204-2 줄이 안 잇는 서고 사본 keys = await _demand(root) book = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.extend_book, book, keys) @@ -160,12 +217,71 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> @router.post("/{project_id}/estimation/prices") -async def refresh_prices(project_id: UUID) -> dict[str, Any]: +async def refresh_prices( + project_id: UUID, body: dict[str, Any] | None = Body(None) +) -> dict[str, Any]: root = await _project_root(project_id) - keys = await _demand(root) - book = await asyncio.to_thread(prices.refresh_book, keys) + raw = (body or {}).get("user") + draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함) + keys = await _demand(root, draft) + old = await asyncio.to_thread(store.read_book, root) + book = await asyncio.to_thread(prices.refresh_book, keys, old) await asyncio.to_thread(store.write_book, root, book) - return await payload(project_id, root) + return await payload(project_id, root, draft) + + +class PreviewBody(BaseModel): + code: str = Field(min_length=1, max_length=40) + given: dict[str, Any] = Field(default_factory=dict) + rate: float | None = None + picks: dict[str, Any] | None = None + + +def logic_preview(root: Path, body: PreviewBody) -> dict[str, Any]: + """로직 한 산근 미리보기 — 단가 = 굳힌 책(없는 줄 = 지금 M01) · 사용자 대비표 고름.""" + src = {"code": body.code, "given": sangeun._clean_given(body.given), "rate": body.rate, + "picks": body.picks} # fmt: skip + hands = [("preview", "미리보기", src)] + keys = sangeun.hand_resource_keys({"haul_refs": {"preview": {"tab": "logic", **src}}}) + user = store.read_user(root) + base = compute_base(prices.build_inputs(store.read_book(root), keys), user) + solver = sangeun.Solver(base) + block = solver.add(sangeun.hand_whole(hands), src, None) + text = [ + *block["head"], + *(t for ln in block["lines"] for t in ln.get("text") or []), + block["foot"], + ] + return { + "name": block["name"], + "lines": [t for t in text if t], + "unit": block["unit"], + "price": block["sum"], + "parts": {p: block[p] for p in sangeun.PARTS}, + "why": block["why"], + # 277-2 칸 나눔 — 맨 위 + 하위 산근 블록(차례) · 셈 그대로 · 화면이 머리 · 줄 · 계를 칸으로 + "blocks": [_block_shown(b) for b in sorted(solver.blocks.values(), key=lambda b: b["no"])], + } + + +SHOWN_LINE = ("name", "spec", "qty", "unit", "kind", *sangeun.PARTS, "why", "text") + + +def _block_shown(block: dict[str, Any]) -> dict[str, Any]: + """미리보기 칸 하나 — Solver 블록에서 화면이 쓰는 것만(머리 · 호표 줄 · 계 · 출처).""" + return { + **{k: block.get(k) for k in ("no", "logic", "name", "unit", "source", "head", "foot")}, + "price": block.get("sum"), + "parts": {p: block.get(p) for p in sangeun.PARTS}, + "why": block.get("why"), + "lines": [{k: ln.get(k) for k in SHOWN_LINE} for ln in block.get("lines") or []], + } + + +@router.post("/{project_id}/estimation/logic-preview") +async def preview_logic(project_id: UUID, body: PreviewBody) -> dict[str, Any]: + root = await _project_root(project_id) + return await asyncio.to_thread(logic_preview, root, body) class LibraryBody(BaseModel): @@ -173,6 +289,11 @@ class LibraryBody(BaseModel): layer: str owner: str | None = None key: str + # 263-2 운반공 줄 — 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리 · 있는 사본 + given: dict[str, Any] | None = None + own: list[str] | None = None + dist: dict[str, Any] | None = None + lib: str | None = None @router.post("/{project_id}/estimation/library") @@ -183,7 +304,14 @@ async def import_library( root = await _project_root(project_id) try: made = await asyncio.to_thread( - library.import_copy, root, body.kind, got.layer, got.owner, body.key, importer(session) + library.import_copy, + root, + body.kind, + got.layer, + got.owner, + body.key, + importer(session), + {"given": body.given, "own": body.own, "dist": body.dist, "lib": body.lib}, ) except LookupError as error: raise HTTPException(status_code=404, detail=str(error)) from error @@ -194,5 +322,22 @@ async def import_library( return {**made, "key": key, "payload": out} +@router.post("/{project_id}/estimation/library-inputs") +async def library_inputs( + project_id: UUID, body: LibraryBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + got = access.grant(session, body.layer, body.owner) + root = await _project_root(project_id) + try: + lines = await asyncio.to_thread( + library.library_inputs, root, body.kind, got.layer, got.owner, body.key, body.lib + ) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + return {"lines": lines} + + # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index cf25e1acf..297f9de63 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -37,7 +37,9 @@ from decimal import Decimal from pathlib import Path from typing import Any +from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total +from B09_Estimation.B09_Estimation_Store import ref_rows from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Item_Prices as item_prices @@ -474,13 +476,27 @@ class Solver: LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses") +def _input_names(code: str) -> list[str]: + try: + return [str(i.get("이름")) for i in m01.loaded()[1].logic(code).get("입력") or []] + except mf.FormulaError: + return [] + + def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, Any]]]: - """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)].""" + """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)] · + 운반공 줄(253-2 · `haul_dist{줄 키: {L1, L2}}`) = 거리이정표 거리로 로직 입력 다시 채움(정본).""" out = [] - for row in ((user or {}).get("bill") or {}).get("rows") or []: + dist = (user or {}).get("haul_dist") or {} + for row in ref_rows(user): # 251-5 거리이정표 운반 연결 포함 ref = row.get("ref") or {} if ref.get("tab") != "logic" or not ref.get("code"): continue + if row["key"] in dist: + ref = { + **ref, + "given": fill_given(ref, _input_names(str(ref["code"])), dist[row["key"]]), + } src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")), "rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip where = "수기 줄" if row["key"].startswith("hand:") else "내역 줄" # 184 자동 줄 연결 덮개 diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 0f1c5862a..cca16ec2c 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -30,6 +30,11 @@ from urllib.parse import unquote from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS +try: # 252 공기 초안(draft 폴더 · 지우면 공기 값 없이) + from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule +except ImportError: # pragma: no cover + clean_schedule = None + FOLDER = ("B09_Estimation", "v1") BOOK = "price_book.json" USER = "user_values.json" @@ -94,6 +99,21 @@ def _text(value: Any) -> str: return str(value)[:200] if isinstance(value, (str, int, float)) else "" +def ref_rows(user: dict[str, Any] | None) -> list[dict[str, Any]]: + """연결 있는 줄 모음 — 설계내역서 덮개 줄 + 거리이정표 운반 연결 + (251-5 · `haul_refs{줄 키: ref}` · 서버 셈 때만 붙임) · + 같은 줄 키에 사용자 연결 덮개가 있으면 그것만.""" + rows = [r for r in ((user or {}).get("bill") or {}).get("rows") or [] if isinstance(r, dict)] + covered = {r.get("key") for r in rows if "ref" in r} + haul = (user or {}).get("haul_refs") or {} + extra = [ + {"key": key, "name": "", "ref": ref} + for key, ref in haul.items() + if key not in covered and isinstance(ref, dict) + ] + return [*rows, *extra] + + def _clean_ref(ref: Any) -> dict[str, Any] | None: """손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름) · dg `lib` = 서고 사본 폴더(204-2 · [저장] 때 안 쓰는 사본 지움).""" @@ -102,13 +122,29 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None: tab = ref.get("tab") if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]: lib = ref.get("lib") if tab == "dg" else None - return {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})} + out = {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})} + given, own = ref.get("given"), ref.get("own") # 263-2 운반 값 사본 손 값 · 덮은 칸 + if isinstance(lib, str) and isinstance(given, dict): + out["given"] = { + str(lid): {str(k): v for k, v in vals.items() if isinstance(v, (str, int, float))} + for lid, vals in given.items() + if isinstance(vals, dict) + } + if isinstance(lib, str) and isinstance(own, list): + out["own"] = [str(x)[:200] for x in own if isinstance(x, str)][:200] + return out if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]: given = ref.get("given") if isinstance(ref.get("given"), dict) else {} picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None - return {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")), - "given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))}, - "rate": _num(ref.get("rate")), "picks": picks} # fmt: skip + out = {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")), + "given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))}, + "rate": _num(ref.get("rate")), "picks": picks} # fmt: skip + cycle = ref.get("cycle") # 253-2 운반 사이클 입력 · 덮은 칸 + if isinstance(cycle, dict): + out["cycle"] = {str(k): _num(v) for k, v in cycle.items() if _num(v) is not None} + own = ref.get("own") if isinstance(ref.get("own"), list) else [] + out["own"] = [str(x)[:40] for x in own if isinstance(x, str)][:40] + return out return None @@ -218,7 +254,10 @@ def clean_user(raw: Any) -> dict[str, Any]: if isinstance(rates, dict): estimate["rates"] = {str(k): _num(v) for k, v in rates.items()} bill = clean_bill(raw.get("bill")) - return {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})} + out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})} + if clean_schedule and (schedule := clean_schedule(raw.get("schedule"))): + out["schedule"] = schedule + return out def read_user(project_root: str | Path) -> dict[str, Any]: diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts new file mode 100644 index 000000000..a932921ab --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts @@ -0,0 +1,448 @@ +/* ============================================================================= + * B09_Estimation_UI_Haul_Modal.ts + * 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결. + * 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) · + * 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음 · + * 사전조합 · 구조물 조합 = 담은 로직 이름별 입력 `fetchLibInputs` · 263-2) · + * 오른 = 계산 칸 차례(277 · `B09_Estimation_UI_Haul_Steps`) — ① 거리(거리 칸 받을 때) · + * ② 사이클 8-2-8(Cm · 사이클시간 · t2 받을 때만) · ③ 단가산출 블록(서버 `POST …/estimation/logic-preview` + * `blocks` · 하위 산근 이어 붙임 · 조합 = 담은 로직 줄마다 · 값 바꾸면 다시 · 최신 응답만 · 셈 중 = 옛 칸 흐리게). + * 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「자동계산」 켜짐) · 손으로 고치면 덮음(`own` · 꺼짐) · + * 단추 누름 · 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값). + * 조합 [적용] = 거리 · 손 값 사본(서버 · 같은 값 = 같은 사본) · 연결 {tab dg, key, lib, given, own}. + * [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본) · 크기 = 큰 창보다 작게(276). + * 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결 — 연결 있는 줄만(276 · + * 연결 없음 = 단가연결 목록 → 옵션 값 창 옛 길). + * ========================================================================== */ + +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { + CYCLE_FILL, + cycleInputs, + fillGiven, + type Dist, +} from "../B08_Quantity/B08_Quantity_Haul_Cycle"; +import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { + fetchLibInputs, + importLib, + openPriceLink, + parseLib, + type LibLine, + type LibPick, + type Picked, + type PriceLinkOpts, +} from "./B09_Estimation_UI_Tabs_Bill_Link"; +import { + cycleBox, + distBox, + logicBox, + noteBox, + type StepBlock, +} from "./B09_Estimation_UI_Haul_Steps"; + +export interface HaulModalOpts extends PriceLinkOpts { + /** 지금 연결(없으면 null) */ + ref: Row | null; + /** 그 행 거리 — L1 포장 계 · L2 비포장(km) */ + dist: Dist; + /** 줄 이름(제목) */ + name: string; +} + +/** 옛 사이클 손 칸(253-3) — 새 모달은 받지 않음 */ +const OLD_OWN = new Set(["L1", "L2", "t1"]); +const AUTO = "자동계산"; +/** ② 사이클 칸 = 이 입력을 받는 로직만(운반거리만 받는 로직은 자기 식으로 셈) */ +const CYCLE_ONLY = new Set(["Cm", "사이클시간", "t2"]); + +interface Preview { + lines: string[]; + unit: string; + price: number | null; + why: string | null; + blocks: StepBlock[]; +} + +async function preview(code: string, given: Row, rate: unknown, picks: unknown): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) throw new Error("프로젝트 없음"); + const res = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/logic-preview`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, given, rate: rate ?? null, picks: picks ?? null }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return (await res.json()) as Preview; +} + +const label = (text: string, unit: string): string => (unit ? `${text} (${unit})` : text); +const KIND_TEXT: Record = { + logic: "단가산출", + precombo: "일위대가 사전조합", + combo: "구조물 조합", + dg: "일위대가 호표", + sg: "단가산출 호표", +}; + +/** 「운반 단가」 모달 */ +export function openHaulModal(o: HaulModalOpts): void { + const logicRef = o.ref?.tab === "logic" ? o.ref : null; + const old = (logicRef?.cycle as Row) || {}; + const libRef = o.ref?.tab === "dg" && o.ref.lib ? parseLib(String(o.ref.lib)) : null; + const st = { + /** 고른 것 — 단가산출 아닌 것(조합 · 호표) */ + other: (libRef + ? { ...libRef, name: String(o.ref?.name ?? ""), unit: "" } + : o.ref && !logicRef + ? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } } + : null) as Picked | null, + /** 조합 담은 로직 줄(263-2) · 손 값 {줄 id: {입력: 값}} · 덮은 칸 `줄 id|입력` */ + lines: [] as LibLine[], + libGiven: structuredClone((libRef && (o.ref?.given as Row)) || {}) as Record, + libOwn: new Set((libRef && (o.ref?.own as string[])) || []), + code: String(logicRef?.code ?? ""), + name: String(logicRef?.name ?? ""), + given: { ...((logicRef?.given as Row) || {}) } as Row, + own: new Set(((logicRef?.own as string[]) || []).filter((n) => !OLD_OWN.has(n))), + rate: logicRef?.rate ?? null, + picks: logicRef?.picks ?? null, + inputs: [] as ItemInput[], + unit: "", + }; + const oldCycle = Object.keys(old).length > 0; + let seq = 0; + let timer = 0; + openModal({ + title: `운반 단가 — ${o.name}`, + closeLabel: "닫기", + wide: true, + dialogClass: "b09-haul-modal", + mount: (body, close, head) => { + // 크기(276) — 공용 큰 창(96vw × 94vh)보다 작게 · 가운데 · 몸 안 두 칸이 따로 스크롤 + body.parentElement?.setAttribute( + "style", + "--modal-max:min(680px,86vh);width:min(1100px,94vw);height:var(--modal-max)", + ); + const title = el("strong"); + const linkBox = el("div", { + attrs: { style: "display:flex;gap:8px;align-items:center;margin-bottom:8px" }, + }); + const left = el("div", { attrs: { style: "overflow:auto;min-width:0" } }); + const steps = el("div", { className: "b09-haulsteps" }); + /** 마지막 단가산출 칸들 — 셈 중엔 흐리게 둠(고른 것 바뀌면 비움) */ + let last: HTMLElement[] = []; + const right = el("div", { attrs: { style: "overflow:auto;min-width:0" }, children: [steps] }); + // 사이클 = 8-2-8 기본값 + 거리(옛 손 값 안 씀 · [적용]하면 지움) + const ref = (): Row => ({ own: [...st.own], cycle: {}, given: st.given }); + const names = (): string[] => st.inputs.map((i) => i.이름); + const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row; + + const isLib = (p: Picked | null): p is LibPick & { name: string; unit: string } => + p?.kind === "combo" || p?.kind === "precombo"; + /** 거리 칸 값(거리 없으면 undefined · 서버 짝 `Bill_Library.haul_values`) */ + const fromDist = (name: string): unknown => + o.dist ? fillGiven({ given: {}, own: [], cycle: {} }, [name], o.dist)[name] : undefined; + + /** 조합 줄 거리 칸 — 따르면 거리 값(아니면 undefined) */ + const libAuto = (lid: string, name: string, id = `${lid}|${name}`): unknown => + name in CYCLE_FILL && !st.libOwn.has(id) ? fromDist(name) : undefined; + /** 조합 줄 미리보기 값 = 원값 + 손 값 + 거리 값(서버 짝 `haul_values`) */ + const libFilled = (line: LibLine): Row => { + const out: Row = { ...line.값, ...(st.libGiven[line.id] ?? {}) }; + for (const { 이름 } of line.입력) { + const value = libAuto(line.id, 이름); + if (value !== undefined) out[이름] = value; + } + return out; + }; + + const redraw = (): void => { + const lib = isLib(st.other); + const want = !o.dist + ? [] + : lib + ? st.lines.flatMap((l) => l.입력.map((i) => i.이름)) + : st.code + ? names() + : []; + const input = cycleInputs(ref(), o.dist); + const head2 = [ + ...(want.some((n) => n in CYCLE_FILL) ? [distBox(o.dist, input)] : []), + ...(want.some((n) => CYCLE_ONLY.has(n)) ? [cycleBox(input)] : []), + ]; + const show = (...boxes: HTMLElement[]): void => steps.replaceChildren(...head2, ...boxes); + window.clearTimeout(timer); + const my = ++seq; + const jobs = lib + ? st.lines.map((l) => ({ code: l.로직, given: libFilled(l), rate: null, picks: null })) + : st.code + ? [{ code: st.code, given: filled(), rate: st.rate, picks: st.picks }] + : []; + if (!jobs.length) { + const what = lib + ? `${KIND_TEXT[st.other!.kind]} — 담은 로직 없음` + : st.other + ? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결` + : "없음"; + return show(noteBox(`연결 — ${what}`)); + } + const tail = lib ? [noteBox("조합 단가 = [적용] · [저장] 뒤 서버 셈")] : []; + last.forEach((b) => b.classList.add("is-stale")); + show(...(last.length ? last : [noteBox("단가산출 — 셈 중…")]), ...tail); + timer = window.setTimeout(() => { + Promise.all(jobs.map((j) => preview(j.code, j.given, j.rate, j.picks))).then( + (got) => { + if (my !== seq) return; // 최신 응답만 + if (!lib) st.unit = got[0].unit; + last = got.flatMap((g) => + g.blocks.map((b, i) => logicBox(b, i ? "하위 산근" : "단가산출")), + ); + show(...last, ...tail); + }, + (error: unknown) => { + if (my !== seq) return; + last = []; + show(noteBox(`못 셈 — ${String(error)}`, true), ...tail); + }, + ); + }, 250); + }; + + const row = (text: string, cells: HTMLElement[]): HTMLElement => + el("div", { + className: "m02-inputval__row", + children: [ + el("span", { className: "m02-inputval__name", text }), + el("div", { className: "m02-inputval__cell", children: cells }), + ], + }); + const shows: Array<() => void> = []; + + /** 입력 칸 하나 — auto = 거리에서 채울 값(아니면 undefined) · set(null = 비움) · + * unown = 「자동계산」 누름(덮음 풀기 · 거리 칸 + 거리 있을 때만 단추) */ + const valueRow = ( + input: ItemInput, + auto: () => unknown, + hand: () => unknown, + set: (value: unknown) => void, + unown?: () => void, + ): HTMLElement => { + const now = auto(); + const field = createValueControl( + input, + ((now === undefined ? hand() : now) as string | number | null) ?? null, + (next, bad) => { + if (bad) return; + const empty = next === null || next === ""; + const num = typeof next === "string" && Number.isFinite(Number(next)); + set(empty ? null : num ? Number(next) : next); + update(); + }, + ); + field.control.setAttribute("data-labor-input", input.이름); + const button = + unown && o.dist + ? createButton({ + label: AUTO, + variant: "pill", + size: "sm", + onClick: () => { + unown(); + update(); + }, + }) + : null; + shows.push(() => { + const value = auto(); + if (button) { + button.classList.toggle("is-active", value !== undefined); + button.setAttribute("aria-pressed", String(value !== undefined)); + } + if (value !== undefined && document.activeElement !== field.control) + field.control.value = String(value ?? ""); + }); + const cells = button ? [field.control, button, field.error] : [field.control, field.error]; + return row(label(input.이름, input.단위 || ""), cells); + }; + const logicRows = (): HTMLElement[] => + st.inputs.map((input) => { + const name = input.이름; + const fill = name in CYCLE_FILL; + return valueRow( + input, + () => (fill && !st.own.has(name) ? filled()[name] : undefined), + () => st.given[name], + (value) => { + if (value === null) delete st.given[name]; + else st.given[name] = value; + if (fill && value !== null) st.own.add(name); + else st.own.delete(name); + }, + fill + ? () => { + st.own.delete(name); + delete st.given[name]; + } + : undefined, + ); + }); + /** 조합 = 담은 로직 이름별 입력(263-2) */ + const libRows = (): HTMLElement[] => + st.lines.flatMap((line) => [ + el("h5", { text: `${line.이름} (${line.로직})`, attrs: { style: "margin:8px 0 2px" } }), + ...line.입력.map((input) => { + const name = input.이름; + const id = `${line.id}|${name}`; + const fill = name in CYCLE_FILL; + return valueRow( + input, + () => libAuto(line.id, name), + () => st.libGiven[line.id]?.[name] ?? line.값[name], + (value) => { + const mine = (st.libGiven[line.id] ??= {}); + if (value === null) delete mine[name]; + else mine[name] = value; + if (fill && value !== null) st.libOwn.add(id); + else st.libOwn.delete(id); + }, + fill + ? () => { + st.libOwn.delete(id); + delete st.libGiven[line.id]?.[name]; + } + : undefined, + ); + }), + ]); + + /** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */ + const update = (): void => { + shows.forEach((show) => show()); + redraw(); + }; + const muted = (text: string): HTMLElement => el("p", { className: "m02-labin__muted", text }); + /** 고른 것 바뀜 — 왼쪽 칸을 새로 */ + const refresh = (): void => { + shows.splice(0); + last = []; + const lines = st.code + ? st.inputs.length + ? logicRows() + : [muted("입력 없음")] + : isLib(st.other) + ? st.lines.length + ? libRows() + : [muted("담은 로직 입력 없음")] + : [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")]; + left.replaceChildren( + el("h4", { text: "옵션 값", attrs: { style: "margin:12px 0 4px" } }), + ...lines, + ...(oldCycle && st.code + ? [muted("옛 사이클 손 값 — 셈은 8-2-8 기본값 · [적용]하면 지움")] + : []), + ); + const p = st.other; + title.textContent = st.code + ? `단가산출 ${st.code} ${st.name}` + : p + ? `${KIND_TEXT[p.kind] ?? p.kind} ${String("from" in p ? (p.from.name ?? "") : "name" in p ? p.name : "")}` + : "연결 — 없음"; + change.querySelector(".ui-btn__label")!.textContent = st.code || p ? "변경" : "고르기"; + update(); + }; + + const useLogic = (code: string, name: string): void => { + fetchLogicInputs([code]).then( + (got) => { + st.other = null; + st.code = code; + st.name = name; + st.inputs = got.로직[0]?.입력 ?? []; + const keep = new Set(st.inputs.map((i) => i.이름)); + for (const k of Object.keys(st.given)) if (!keep.has(k)) delete st.given[k]; + refresh(); + }, + (error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"), + ); + }; + /** 조합 담은 로직 입력 읽고 왼쪽 새로 */ + const useLib = (p: LibPick): void => { + fetchLibInputs(p).then( + (lines) => { + st.lines = lines; + refresh(); + }, + (error: unknown) => showToast(`조합 입력을 못 읽음 — ${String(error)}`, "error"), + ); + }; + const usePicked = (p: Picked): void => { + if (p.kind === "logic") return useLogic(p.code, p.name); + st.other = p; + st.code = ""; + st.inputs = []; + st.lines = []; + st.libGiven = {}; + st.libOwn.clear(); + if (isLib(p)) useLib(p); + refresh(); + }; + const change = createButton({ + label: "변경", + variant: "ghost", + size: "sm", + onClick: () => void openPriceLink({ ...o, onPick: usePicked }), + }); + linkBox.append(title, change); + const ok = createButton({ + label: "적용", + onClick: () => { + const p = st.other; + if (!st.code && !p) return showToast("연결할 것을 먼저 고르세요", "error"); + if (isLib(p)) { + const haul = { given: st.libGiven, own: [...st.libOwn], dist: o.dist }; + const { kind, layer, owner, key, lib } = p; + void importLib(o, { kind, layer, owner, key, lib, haul }); + } else if (p && "ref" in p) o.apply(p.ref, p.from); + else { + const next: Row = { + tab: "logic", + code: st.code, + name: st.name, + given: filled(), + rate: st.rate, + picks: st.picks, + own: [...st.own], + }; + o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") }); + showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success"); + } + close(); + }, + }); + head.append(ok); + body.append( + linkBox, + // 278-1 왼쪽(옵션 값) 폭 = 예전 약 0.8 배(칸 · 단추는 줄 안에서 접힘) + el("div", { + attrs: { + style: + "display:grid;grid-template-columns:minmax(260px,0.7fr) minmax(340px,1.4fr);gap:16px;flex:1;min-height:0", + }, + children: [left, right], + }), + ); + if (st.code) useLogic(st.code, st.name); + else { + if (libRef) useLib(libRef); + refresh(); + } + }, + }); +} diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Steps.css b/B09_Estimation/B09_Estimation_UI_Haul_Steps.css new file mode 100644 index 000000000..cf8826d2c --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Haul_Steps.css @@ -0,0 +1,89 @@ +/* 「운반 단가」 창 오른쪽 계산 칸(PLAN 277) — 칸마다 제목 줄 · 몸 · 바닥 결과(굵게) · 테마 변수만 */ +.b09-haulsteps { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} +.b09-haulstep { + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} +.b09-haulstep.is-stale { + opacity: 0.5; +} +.b09-haulstep__head { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: baseline; + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} +.b09-haulstep__title { + color: var(--color-text); + font-weight: 600; +} +.b09-haulstep__sub, +.b09-haulstep__formula, +.b09-haulstep__group { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} +.b09-haulstep__sub { + margin-left: auto; +} +.b09-haulstep__body { + padding: var(--spacing-4) var(--spacing-12); +} +.b09-haulstep__group { + margin: var(--spacing-8) 0 var(--spacing-4); +} +.b09-haulstep__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0 var(--spacing-12); + padding: 2px 0; +} +.b09-haulstep__value { + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--color-text); +} +.b09-haulstep__formula { + grid-column: 1 / -1; + white-space: pre-line; + word-break: break-all; +} +.b09-haulstep__foot { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-12); + justify-content: space-between; + align-items: baseline; + padding: var(--spacing-8) var(--spacing-12); + border-top: 1px solid var(--color-border); + font-weight: 700; + color: var(--color-text); +} +.b09-haulstep__foot .b09-haulstep__value { + margin-left: auto; +} +.b09-haulstep__foot .b09-haulstep__formula { + flex-basis: 100%; + text-align: right; + font-weight: 400; +} +.b09-haulstep__bad { + color: var(--color-danger); +} +/* 278-2 B09 단가산출근거 박스보기 머리 줄 — 같은 줄 꼴 · 값 굵게 */ +.b09-sgsteps { + padding: 2px var(--spacing-4) var(--spacing-4); + border-bottom: 1px solid var(--color-border); +} +.b09-sgsteps .b09-haulstep__value { + font-weight: 700; +} diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts b/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts new file mode 100644 index 000000000..559d539af --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts @@ -0,0 +1,231 @@ +/* ============================================================================= + * B09_Estimation_UI_Haul_Steps.ts + * 「운반 단가」 창 오른쪽 계산 칸(PLAN 277) — 칸 차례 · 칸마다 제목 줄 · 몸 · 바닥 결과(굵게). + * ① 거리(바닥 운반거리) ② 사이클시간 건설품셈 8-2-8(TS 짝 `B08_Quantity_Haul_Cycle` · 바닥 Cm) + * ③ 단가산출 = 서버 `logic-preview` 의 `blocks` 하나(277-2) — 머리 글 → 입력값 · 중간값(이름 · 값 크게, + * 식 작게 흐리게) · 호표 줄(수량 · 금액) · 바닥 단가 + 노무 · 재료 · 경비(빈칸 = 까닭 빨강). + * 셈은 서버 그대로 — 여기는 그리기만 · 테마 변수(`B09_Estimation_UI_Haul_Steps.css`). + * 머리 줄 나눔 · 그리기(`headSteps`) = B09 단가산출근거 박스보기도 같이 씀(278-2 · `Tabs_Groups`). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { CYCLE_DEFAULTS, cycleTime, type Dist } from "../B08_Quantity/B08_Quantity_Haul_Cycle"; +import "./B09_Estimation_UI_Haul_Steps.css"; + +/** 호표 줄 하나(서버 `SHOWN_LINE`) */ +export interface StepLine { + name: string; + spec: string; + qty: number | null; + unit: string; + kind: string; + labor: number | null; + material: number | null; + expense: number | null; + why: string | null; + text?: string[] | null; +} + +/** 산근 블록 하나(서버 `_block_shown`) */ +export interface StepBlock { + no: number; + logic: string; + name: string; + unit: string; + source: string; + head: string[]; + foot: string; + price: number | null; + parts: Record; + why: string | null; + lines: StepLine[]; +} + +interface StepRow { + name: string; + value: string; + formula?: string; + bad?: boolean; + /** 머리 글 몇째 줄(`splitHead`) */ + at?: number; +} + +const PART_TEXT: Record = { labor: "노무", material: "재료", expense: "경비" }; + +/** 값 — 천 단위 쉼표 · 빈 값 「—」 */ +export const stepNum = (v: number | null | undefined, digits = 4): string => + v == null || !Number.isFinite(v) + ? "—" + : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); + +const rowEl = (r: StepRow, ats?: number[]): HTMLElement => + el("div", { + className: "b09-haulstep__row", + ...(ats && r.at !== undefined ? { attrs: { "data-at": String(ats[r.at]) } } : {}), + children: [ + el("span", { text: r.name }), + el("span", { + className: `b09-haulstep__value${r.bad ? " b09-haulstep__bad" : ""}`, + text: r.value, + }), + ...(r.formula ? [el("span", { className: "b09-haulstep__formula", text: r.formula })] : []), + ], + }); + +const group = (label: string, rows: StepRow[], ats?: number[]): HTMLElement[] => + rows.length + ? [ + el("div", { className: "b09-haulstep__group", text: label }), + ...rows.map((r) => rowEl(r, ats)), + ] + : []; + +/** 칸 하나 — 제목 줄(제목 · 곁글) · 몸 · 바닥(굵게) */ +function box(title: string, sub: string, body: HTMLElement[], foot: HTMLElement[]): HTMLElement { + return el("section", { + className: "b09-haulstep", + children: [ + el("div", { + className: "b09-haulstep__head", + children: [ + el("span", { className: "b09-haulstep__title", text: title }), + ...(sub ? [el("span", { className: "b09-haulstep__sub", text: sub })] : []), + ], + }), + ...(body.length ? [el("div", { className: "b09-haulstep__body", children: body })] : []), + ...(foot.length ? [el("div", { className: "b09-haulstep__foot", children: foot })] : []), + ], + }); +} + +const stepFoot = (name: string, value: string, bad = false): HTMLElement[] => [ + el("span", { text: name }), + el("span", { className: `b09-haulstep__value${bad ? " b09-haulstep__bad" : ""}`, text: value }), +]; + +/** 알림 칸(제목 줄만) — 셈 중 · 못 셈 · 조합 안내 · 연결 없음 */ +export function noteBox(text: string, bad = false): HTMLElement { + const got = box(text, "", [], []); + if (bad) got.querySelector(".b09-haulstep__title")?.classList.add("b09-haulstep__bad"); + return got; +} + +/** ① 거리 — 거리이정표 포장 · 비포장 → 운반거리 */ +export function distBox(dist: Dist, input: Record): HTMLElement { + const rows = [ + { name: "포장 L1", value: `${stepNum(dist?.L1)} km` }, + { name: "비포장 L2", value: `${stepNum(dist?.L2)} km` }, + ]; + return box( + "거리", + "거리이정표", + rows.map((r) => rowEl(r)), + stepFoot("운반거리", `${stepNum(cycleTime(input).L)} km`), + ); +} + +/** ② 사이클시간 — 건설품셈 8-2-8 덤프트럭(기본값 + 거리) */ +export function cycleBox(input: Record): HTMLElement { + const v: Record = { ...CYCLE_DEFAULTS }; + for (const k of Object.keys(v)) if (typeof input[k] === "number") v[k] = input[k] as number; + const r = cycleTime(input); + const rows: StepRow[] = [ + { name: "q", value: `${stepNum(r.q)} ㎥`, formula: `T / rt × f = ${v.T} / ${v.rt} × ${v.f}` }, + { + name: "n", + value: `${stepNum(r.n)} 회`, + formula: `q / (qs × k) = ${r.q} / (${v.qs} × ${v.k})`, + }, + { + name: "t1", + value: `${stepNum(r.t1)} 분`, + formula: `Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es})`, + }, + { + name: "t2", + value: `${stepNum(r.t2)} 분`, + formula: `(L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 = (${v.L1}/${v.V1} + ${v.L1}/${v.V2} + ${v.L2}/${v.V3} + ${v.L2}/${v.V4}) × 60`, + }, + { + name: "Cm", + value: `${stepNum(r.cm)} 분`, + formula: `t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6}`, + }, + ]; + return box( + "사이클시간", + "건설품셈 8-2-8 덤프트럭", + rows.map((r) => rowEl(r)), + stepFoot("Cm", `${stepNum(r.cm)} 분`), + ); +} + +type HeadOf = Pick; + +/** 머리 글 → 입력값(「이름 : 뜻 = 값」) · 중간값(「이름 = 식 = 값」 · 「└」 = 앞 줄 식에 붙임) */ +export function splitHead(b: HeadOf): { inputs: StepRow[]; mids: StepRow[] } { + const inputs: StepRow[] = []; + const mids: StepRow[] = []; + let last: StepRow | null = null; + for (const [at, line] of b.head.entries()) { + if (line.startsWith(`${b.name} / `) || (b.source && line === `${b.source} 적용`)) continue; + if (line.startsWith("└") && last) { + last.formula = [last.formula, line].filter(Boolean).join("\n"); + continue; + } + const colon = line.indexOf(" : "); + const eq = line.indexOf(" = "); + const end = line.lastIndexOf(" = "); + if (colon >= 0 && (eq < 0 || colon < eq)) { + const value = end > colon ? line.slice(end + 3) : line.slice(colon + 3); + const note = end > colon ? line.slice(colon + 3, end) : ""; + inputs.push((last = { name: line.slice(0, colon), value, formula: note, at })); + } else if (eq >= 0) { + const formula = end > eq ? line.slice(eq + 3, end) : ""; + mids.push((last = { name: line.slice(0, eq), value: line.slice(end + 3), formula, at })); + } else mids.push((last = { name: line, value: "", at })); + } + return { inputs, mids }; +} + +/** 머리 글 → 「입력값」 · 「중간값」 묶음(③ 칸 몸 · 278-2 B09 단가산출근거 박스보기가 같이 씀) · + * ats = 머리 줄마다 부르는 쪽 줄 번호(주면 줄에 `data-at` — 누름 · 고름) */ +export function headSteps(b: HeadOf, ats?: number[]): HTMLElement[] { + const { inputs, mids } = splitHead(b); + return [...group("입력값", inputs, ats), ...group("중간값", mids, ats)]; +} + +/** ③ 단가산출 칸 — 블록 하나(맨 위 = 「단가산출」 · 하위 = 「하위 산근」) */ +export function logicBox(b: StepBlock, label = "단가산출"): HTMLElement { + const unit = b.unit ? `원/${b.unit.replace(/^원\//, "")}` : "원"; + const lines: StepRow[] = b.lines.map((ln) => { + const parts = [ln.labor, ln.material, ln.expense]; + const money = parts.some((p) => p == null) ? null : parts.reduce((a, p) => a! + p!, 0); + return { + name: ln.spec ? `${ln.name} ${ln.spec}` : ln.name, + value: ln.why + ? `빈칸 — ${ln.why}` + : `${stepNum(ln.qty)} ${ln.unit} · ${stepNum(money, 1)} 원`, + formula: (ln.text || []).join("\n"), + bad: !!ln.why, + }; + }); + const foot = + b.price == null + ? stepFoot("단가", `빈칸 — ${b.why ?? ""}`, true) + : [ + ...stepFoot("단가", `${stepNum(b.price, 1)} ${unit}`), + el("span", { + className: "b09-haulstep__formula", + text: Object.entries(b.parts) + .map(([p, v]) => `${PART_TEXT[p] ?? p} ${stepNum(v, 1)}`) + .join(" · "), + }), + ]; + return box( + `${label} ${b.logic} ${b.name}`, + [unit, b.source].filter(Boolean).join(" · "), + [...headSteps(b), ...group("호표", lines)], + foot, + ); +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 56f923c40..81137921b 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -30,9 +30,9 @@ visibility: hidden; } -/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */ +/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈(257-1 머리 글 안 줄바꿈 글자도 살림) */ [data-b09-table] thead th { - white-space: normal; + white-space: pre-line; overflow-wrap: anywhere; } @@ -130,12 +130,33 @@ --pick-r: inset -2px 0 0 var(--color-danger); } -/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) */ +/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) · 257-3 초록 테두리 + * (바탕 색 아님 — 입력 칸 테두리(outline)와 달리 안쪽 상자 그림자) */ .b09-estimation .ui-data-table__table tbody td.is-picked { - background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas)); + box-shadow: inset 0 0 0 2px var(--color-success); font-weight: var(--font-weight-bold); } +/* 257-1 설계내역서 단가연결 칸 = 램프(B08 구조물집계표 램프 꼴 · 글 숨김 · 풍선 = 글) — 노랑 = 없음 · 초록 = 연결 */ +.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp { + font-size: 0; + text-align: center; +} + +.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-warning); + vertical-align: middle; +} + +.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp.is-linked::before { + background: var(--color-success); +} + /* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */ .b09-estimation .ui-data-table__table tbody td.is-edited { background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); @@ -158,9 +179,9 @@ background: var(--color-warning); } -/* 122 데이터 점검 [업데이트] — 제목 아래 틈 = 카드 왼쪽 여유와 같게 */ +/* 122 데이터 점검 [업데이트] — 제목 아래 틈 = 카드 왼쪽 여유와 같게(공용 카드 틈 8 + 4) */ .b09-options [data-b09-refresh] { - margin-top: calc(var(--spacing-8) + var(--spacing-4)); + margin-top: var(--spacing-4); } /* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */ diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 78682d64f..1b331e139 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -19,7 +19,7 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { cellColumn, createDataTable, type DataTable } from "@ui/ui_template_data_table"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; @@ -34,6 +34,7 @@ import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { + DRAFT, rowIndex, TABS, type Link, @@ -61,7 +62,16 @@ import { } from "./B09_Estimation_UI_Tabs_Summary"; import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; -import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; +import { + isGroupTab, + lineHint, + pickClass, + renderGroupPanel, + unpickOnRelease, +} from "./B09_Estimation_UI_Tabs_Groups"; + +// 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음) +import.meta.glob("./draft/*_UI.ts", { eager: true }); function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -136,7 +146,7 @@ export function computeSheets(payload: Row, user: Row): Row { books, ); const { gaps: daegaGaps, ...daega } = labor; - return { + const out: Row = { ...base, ...kept, ...daega, @@ -144,6 +154,8 @@ export function computeSheets(payload: Row, user: Row): Row { // 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 요율(사용자 ?? 자동 166-1) · 손입력 total: computeTotal(base.bill?.sums, user.estimate, payload.rate_table), }; + for (const extend of DRAFT.extend) extend(payload, user, out); + return out; } export function renderB09Estimation(root: HTMLElement): void { @@ -186,7 +198,7 @@ export function renderB09Estimation(root: HTMLElement): void { bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1) if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); - const build = BUILDERS[tab]; + const build = BUILDERS[tab] ?? DRAFT.builders[tab]; if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); const ctx: TabContext = { sheets, payload, user, setUser }; const spec = build(ctx); @@ -234,6 +246,17 @@ export function renderB09Estimation(root: HTMLElement): void { const box = cell.getBoundingClientRect(); openMenuAt(box.left, box.bottom, items); }); + // 257-3 칸 우클릭 메뉴 — 누른 칸 자리에 B09 공용 메뉴(줄 메뉴가 없는 표) + if (spec.cellMenu) + made.root.addEventListener("contextmenu", (event) => { + const cell = (event.target as Element).closest("tr[data-i] td"); + const column = cell ? spec.columns[cellColumn(cell)] : undefined; + const items = + cell && column && spec.cellMenu?.(Number(cell.parentElement?.dataset.i), column.key); + if (!items?.length) return; + event.preventDefault(); + openMenuAt(event.clientX, event.clientY, items); + }); // 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 공용 호버 정보(185-3) if (spec.tip) attachHoverInfo( @@ -268,13 +291,17 @@ export function renderB09Estimation(root: HTMLElement): void { const bill = (spec as BillTable).menu ? (spec as BillTable) : null; const at = focus === undefined ? -1 : rowIndex(spec.rows, focus); if (bill) jumpTo(bill.rows, at); + // 257-2 설계내역서 밖 탭도 이동해 온 줄 = 같은 빨강 상자(그 줄 하나 · 줄 누름 · Esc 로 풂) + const range: [number, number] | null = !bill && at >= 0 ? [at, at] : null; + const rangeClass = range ? (i: number) => pickClass(range, i) : undefined; const table = makeTable( - bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined, + bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : rangeClass, ); + if (range) unpickOnRelease(table.root); const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null; if (level) bars.setTools(level.tools); panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : [])); - if (at >= 0) requestAnimationFrame(() => table.focusRow(at, !bill)); // 설계내역서 = 스크롤만(표시 = 빨강) + if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; const apply = (next: Row, fromServer: boolean): void => { @@ -285,7 +312,14 @@ export function renderB09Estimation(root: HTMLElement): void { show(current); }; - const run = async (path: string, method: string, done: string, init?: Row): Promise => { + // keep = 화면 캐시 그대로(296 [업데이트] — 지운 줄 · 고친 칸을 안 되살림) + const run = async ( + path: string, + method: string, + done: string, + init?: Row, + keep = false, + ): Promise => { if (!projectId) return; showLoadingOverlay(); try { @@ -293,8 +327,8 @@ export function renderB09Estimation(root: HTMLElement): void { method, ...(init ? { body: JSON.stringify(init) } : {}), }); - clearState("b09-estimation", projectId); - apply(next, true); + if (!keep) clearState("b09-estimation", projectId); + apply(next, !keep); showToast(done, "success"); } catch (error) { showToast(`하지 못했습니다 — ${String(error)}`, "error"); @@ -313,7 +347,13 @@ export function renderB09Estimation(root: HTMLElement): void { label: "업데이트", variant: "ghost", onClick: () => - void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."), + void run( + "/estimation/prices", + "POST", + "단가를 지금 M01 값으로 새로 굳혔습니다.", + { user }, + true, + ), }); refresh.dataset.b09Refresh = ""; const check = createCheckPanel(refresh, (link) => show(link.tab, link.key)); @@ -332,18 +372,15 @@ export function renderB09Estimation(root: HTMLElement): void { }), }); reset.dataset.b09BillReset = ""; - const section = document.createElement("section"); - section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; - const heading = document.createElement("h3"); - heading.className = "ui-collapsible__title"; - heading.textContent = "데이터 점검"; - section.append(heading, refresh, check.list); + // 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식) + const section = createPanelCard("데이터 점검"); + section.append(refresh, check.list); const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; actions.append(save, reset); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; - left.append(section, actions); + left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions); attachCollapsible(left); const layout = createWorkflowLayout({ diff --git a/B09_Estimation/B09_Estimation_UI_TabBar.ts b/B09_Estimation/B09_Estimation_UI_TabBar.ts index 3ce0e2a2f..183fc5f76 100644 --- a/B09_Estimation/B09_Estimation_UI_TabBar.ts +++ b/B09_Estimation/B09_Estimation_UI_TabBar.ts @@ -37,9 +37,9 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars { top.className = "b09-estimation__tabs"; top.setAttribute("role", "tablist"); const sub = document.createElement("div"); - sub.className = "b09-estimation__tabs"; + sub.className = "b09-estimation__tabs ui-subtabs"; const tools = document.createElement("div"); - tools.className = "b09-estimation__tools"; + tools.className = "b09-estimation__tools ui-subtabs__tools"; const lastSeen = new Map(); const draw = (tab: TabId): void => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1b5a903b7..97d937b6e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -59,8 +59,7 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ ["공사원가계산서", ["cost"]], - ["총괄설계내역서", ["total"]], - ["설계내역서", ["bill"]], + ["설계내역", ["total", "bill"]], ["일위대가", ["dg_list", "dg"]], ["단가산출", ["sg_list", "sg"]], ["환율및기초자료", ["basis"]], @@ -112,6 +111,8 @@ export interface TabTable { cellClass?: (index: number, key: string) => string | null | undefined; /** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */ choices?: (index: number, key: string) => MapContextMenuItem[] | null; + /** 칸 우클릭 메뉴(257-3 · 줄 메뉴가 없는 표 · 빈 것 = 안 엶) */ + cellMenu?: (index: number, key: string) => MapContextMenuItem[] | null; /** 레벨(201-1 · 공용 표 level · 보기 전용) — 그 열 들여쓰기 */ level?: DataTableOptions["level"]; /** 같은 값 세로 병합할 열(201-1 · 공용 표 mergeSame) */ @@ -120,6 +121,15 @@ export interface TabTable { export type TabBuild = (ctx: TabContext) => TabTable | string; +/** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) — + * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) · + * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) */ +export const DRAFT: { + builders: Record; + extend: Array<(payload: Row, user: Row, sheets: Row) => void>; + panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>; +} = { builders: {}, extend: [], panels: [] }; + /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ key, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 4eb8eb286..02ed55245 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -6,7 +6,8 @@ * 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`) * * 자재단가대비표 적용 단가 칸 누름 = 고르기 메뉴(183-3 · 값 있는 출처 · 견적 · 「최저(자동)」 → - * `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) · + * `pick` · 옛 수기 `applied` 지움 · 257-3 우클릭 「적용 단가 바꾸기」 = 같은 목록) · + * 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처 · 초록 테두리) · * 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력 * · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) * 견적서 글자 = 견적 단가 수기 줄만(185-5 · 나머지 빈칸 · 빈칸 누름 = 안 엶) @@ -177,6 +178,14 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { else delete materials[key]; setUser({ ...user, materials }); }; + const chooseApplied = (r: Row): MapContextMenuItem[] => + appliedChoices(r, (s) => { + const cell = mine(r.key); + delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂 + if (s) cell.pick = s; + else delete cell.pick; + put(r.key, cell); + }); return { columns: [ qty("no", "번호", 0), @@ -215,18 +224,15 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { else cell.quote = v; put(String(row.key), cell); }, - // 183-3 — 고른 출처(없으면 최저) 단가 칸 강조 · 적용 단가 칸 누름 = 고르기 메뉴 + // 183-3 — 고른 출처(없으면 최저) 단가 칸 강조(257-3 초록 테두리) · 적용 단가 칸 누름 = 고르기 메뉴 cellClass: (i, key) => (key === `p:${rows[i]?.applied_from}` ? "is-picked" : null), - choices: (i, key) => { + choices: (i, key) => (key === "applied" && rows[i] ? chooseApplied(rows[i]) : null), + // 257-3 우클릭 「적용 단가 바꾸기」 → 출처 목록(적용 단가 칸 · 고른 출처 단가 칸 · 최저가여도 바꿈) + cellMenu: (i, key) => { const r = rows[i]; - if (key !== "applied" || !r) return null; - return appliedChoices(r, (s) => { - const cell = mine(r.key); - delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂 - if (s) cell.pick = s; - else delete cell.pick; - put(r.key, cell); - }); + if (!r || (key !== "applied" && key !== `p:${r.applied_from}`)) return null; + const children = chooseApplied(r).filter((x): x is [string, () => void] => Array.isArray(x)); + return [{ label: "적용 단가 바꾸기", children }]; }, onCell: (i, key) => { const row = out[i]; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index fc8e13020..1c37d2653 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -8,7 +8,7 @@ * 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값 * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) * 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level` - * 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴 + * 단가연결 칸(램프) = 항목 줄(자재 · 조달수수료 빼고) · 램프 누름 = 우클릭 [연결] 과 같은 메뉴 * 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) · * 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력 * ========================================================================== */ @@ -34,10 +34,12 @@ export const buildBill: TabBuild = (ctx): BillTable => { const bill: Row = sheets.bill || { rows: [], totals: null }; // 손질 · 메뉴 = 셈 줄 그대로(raw) · 표 = 번호 붙인 이름(no = 레벨 1 · 2 묶음 차례) const raw: DataRow[] = bill.rows; + // 257-1 연결 칸 = 램프(글은 CSS 가 숨김 · 풍선 = 글) — 연결 = 호표 줄(ref) 또는 손 줄 비고(대가N) + const linkText = (r: Row): string => (!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결"); const rows: DataRow[] = raw.map((r: Row) => ({ ...r, name: r.no ? `${r.no} ${r.name}` : r.name, - connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "연결"} ▾` : "", + connect: linkable(r) ? `${linkText(r)} ▾` : "", })); const direct: Row = bill.totals?.direct || {}; const pair = (key: string, label: string) => [ @@ -57,14 +59,14 @@ export const buildBill: TabBuild = (ctx): BillTable => { ...pair("labor", "노무비"), ...pair("material", "재료비"), ...pair("expense", "경비"), - text("connect", "연결", 96), + { ...text("connect", "단가\n연결", 44), title: "단가연결" }, text("note", "비고", 200), ], rows, total: { name: "순공사비 계(관급 제외)", ...direct }, frozen: 1, hint: - "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" + + "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" + (direct.blank ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` : ""), @@ -88,9 +90,21 @@ export const buildBill: TabBuild = (ctx): BillTable => { if (r.key !== "fee") return editCell(ctx, raw, i, key, v); setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } }); }, - cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null), + cellClass: (i, key) => { + const r = rows[i]; + const lamp = + key === "connect" && r && linkable(raw[i]) + ? `b09-bill__lamp${linkText(raw[i]) === "단가연결" ? "" : " is-linked"}` + : ""; + return [lamp, edited(r, key) ? "is-edited" : ""].filter(Boolean).join(" ") || null; + }, tip: (i, key) => { const r = rows[i]; + if (r && key === "connect" && linkable(raw[i])) { + const auto = (r.auto_ref as Row | null)?.key ?? "빈칸"; + const back = edited(r, key) ? ` — 자동 값 ${auto} — 우클릭 [자동값으로 되돌리기]` : ""; + return linkText(raw[i]) + back; + } if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)"; if (!r || !edited(r, key)) return null; const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`]; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index f036c2206..99243bdb4 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -225,7 +225,13 @@ function linkFor(ctx: TabContext, rows: DataRow[], i: number) { const row = rows[i]; const e = entryOf(freeze(ctx.user, rows), String(row.key)); const ref: Row | null = e && "ref" in e ? e.ref : (row.ref ?? null); - return linkItems(ctx, ref, applyRef(ctx, rows, i)); + const key = String(row.key); + // 253-3 운반공 줄 = 「운반 단가」 모달(거리 = 서버가 준 항목 `dist`) + const item = key.startsWith("haul:") + ? ((ctx.payload.inputs?.bill?.items as Row[]) || []).find((it) => it.key === key) + : undefined; + const haul = item && { dist: item.dist ?? null, name: String(row.name ?? item.name ?? "") }; + return linkItems(ctx, ref, applyRef(ctx, rows, i), haul); } /** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */ @@ -252,8 +258,8 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent)); }); if (linkable(row)) { - const [picks, more] = linkFor(ctx, rows, i); - parts[1].push({ label: "연결", children: picks }, ...more); + const [picks, more] = linkFor(ctx, rows, i); // 251-6 단가연결 = 모달 하나(옛 하위 메뉴 다섯) + parts[1].push(...picks, ...more); } // 옮김 · 레벨 — 같은 부모 형제와 자손째 if (!fixed(key)) { @@ -355,7 +361,8 @@ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void const box = lastCell?.getBoundingClientRect(); if (!shared || !box || !linkable(rows[i])) return; const [picks, more] = linkFor(ctx, rows, i); - shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks); + if (!more.length) return picks[0][1](); // 251-6 아직 연결 없음 = 단가연결 모달 바로 + shared.open(box.left, box.bottom, [...picks, "-", ...more]); } /** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 9152d1120..bdc49755d 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -4,6 +4,8 @@ * 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새 * 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · * 연결 풀기. + * 251 단가연결 = 한 모달(`openPriceLink` · 종류 고르기 + 검색) — B09 설계내역서 · B08 거리이정표 같이 씀 · + * 단가산출 = 옵션 값 창(`askInputs` · 160) * * 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름) * 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 · @@ -12,59 +14,28 @@ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; -import { createFindList } from "@ui/ui_template_find_list"; -import { openItemPicker } from "@ui/ui_template_item_picker"; -import { openModal } from "@ui/ui_template_modal"; +import { loadRows } from "@ui/ui_template_item_picker"; +import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data"; +import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; +import type { Dist } from "../B08_Quantity/B08_Quantity_Haul_Cycle"; import type { Row } from "./B09_Estimation_Calc_Base"; +import { openHaulModal } from "./B09_Estimation_UI_Haul_Modal"; import type { TabContext } from "./B09_Estimation_UI_Tabs"; /** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */ export type ApplyRef = (ref: Row | null, from?: Row) => void; export type Leaf = [string, () => void]; - -/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */ -function pickHopyo(ctx: TabContext, apply: ApplyRef, tab: "dg" | "sg"): void { - const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || []; - if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error"); - const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`); - openModal({ - title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기", - closeLabel: "닫기", - mount: (body, close) => { - const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" }); - const find = (): void => { - const q = finder.search.value.trim().toLowerCase(); - const found = listing.filter( - (r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q), - ); - finder.hint.textContent = `${found.length} / ${listing.length}`; - finder.setRows( - found.map((r) => ({ - key: String(r.key), - tag: label(r), - name: String(r.name || ""), - sub: String(r.spec || ""), - unit: String(r.unit || ""), - onPick: () => { - close(); - apply({ tab, key: r.key }, r); - }, - })), - ); - }; - finder.search.addEventListener("input", find); - find(); - body.append(finder.root); - requestAnimationFrame(() => finder.search.focus()); - }, - }); -} +const LAYER_TEXT: Record = { + system: "마스터", + company: "회사", + personal: "개인", +}; /** M01 로직 → 입력값 창 → 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */ -function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void { +export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void { fetchLogicInputs([code]).then( (got) => { const logic = got.로직[0] ?? null; @@ -91,33 +62,78 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void ); } +/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) · + * lib = 있는 사본 폴더 · haul = 운반 값(263-2 · 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리) */ +export type LibPick = { + kind: "combo" | "precombo"; + layer: string; + owner: string | null; + key: string; + lib?: string; + haul?: { given: Row; own: string[]; dist: Dist }; +}; +/** 담은 로직 줄 입력 · 값(263-2 · `POST …/estimation/library-inputs`) */ +export interface LibLine { + id: string; + 로직: string; + 이름: string; + 입력: ItemInput[]; + 값: Row; +} + +/** 사본 폴더 이름 → 서고 항목(`종류@층@주인@키~해시` · 짝 `Bill_Library.folder_name`) */ +export function parseLib(lib: string): LibPick | null { + const [kind, layer, owner, key] = lib.split("~")[0].split("@"); + if ((kind !== "combo" && kind !== "precombo") || !key) return null; + return { kind, layer, owner: owner === "-" ? null : owner.replace("-", "/"), key, lib }; +} + +async function postLib(path: string, body: Row): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) throw new Error("프로젝트 없음"); + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/${path}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const got = (await response.json().catch(() => null)) as Row | null; + if (!response.ok || !got) { + const detail = got?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return got; +} + +/** 서고 항목 담은 로직 입력 · 값(있는 사본이면 그 덮기 전 값) */ +export async function fetchLibInputs(lib: LibPick): Promise { + const { kind, layer, owner, key } = lib; + const got = await postLib("library-inputs", { kind, layer, owner, key, lib: lib.lib ?? null }); + return (got.lines as LibLine[]) || []; +} +export type Picked = + | { kind: "logic"; code: string; name: string } + | (LibPick & { name: string; unit: string }) + | { kind: "dg" | "sg"; ref: Row; from: Row }; + /** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만 * 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */ -async function importLib( - ctx: TabContext, - apply: ApplyRef, - pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string }, +export async function importLib( + o: { apply: ApplyRef; onPayload?: (payload: Row) => void }, + lib: LibPick, ): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - if (!projectId) return; + const apply = o.apply; + const { kind, layer, owner, key } = lib; + const pick = { kind, layer, owner, key, ...(lib.lib && { lib: lib.lib }), ...lib.haul }; showToast("가져오는 중…", "info"); try { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(pick), - }); - const got = (await response.json().catch(() => null)) as Row | null; - if (!response.ok || !got) { - const detail = got?.detail; - throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); - } + const got = await postLib("library", pick); if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦"); const { user: _user, ...rest } = got.payload as Row; - Object.assign(ctx.payload, rest); + o.onPayload?.(rest); const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); - apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); + const hand = lib.haul ? { given: lib.haul.given, own: lib.haul.own } : {}; + apply({ tab: "dg", key: got.key, lib: got.폴더, ...hand }, from); showToast( got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "가져온 호표로 연결함", "success", @@ -127,39 +143,96 @@ async function importLib( } } -/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */ -function pickCombo(ctx: TabContext, apply: ApplyRef): void { - void openItemPicker({ current: null, canCopy: false }).then((got) => { - if (got.action !== "apply") return; - const { layer, owner, key } = got.item; - void importLib(ctx, apply, { kind: "combo", layer, owner, key }); - }); +/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 · + * haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 연결 있음 = 「운반 단가」 모달([변경] = 같은 목록 · 263-1) · + * 연결 없음 = 옛 길(목록 → 옵션 값 창 · 276) */ +export function linkItems( + ctx: TabContext, + ref: Row | null, + apply: ApplyRef, + haul?: { dist: Dist; name: string }, +): [Leaf[], Leaf[]] { + const more: Leaf[] = []; + const link: PriceLinkOpts = { + apply, + hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] }, + onPayload: (p) => Object.assign(ctx.payload, p), + }; + const priceLink = (): void => void openPriceLink(link); + const haulModal = (): void => + openHaulModal({ ...link, ref, dist: haul?.dist, name: haul?.name || "" }); + if (ref?.tab === "logic" && !haul) + more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]); + if (ref) more.push(["단가연결 풀기", () => apply(null)]); + return [[["단가연결", haul && ref ? haulModal : priceLink]], more]; } -/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */ -function pickPrecombo(ctx: TabContext, apply: ApplyRef): void { - openLogicPicker(() => undefined, { +/** 단가연결 모달(251 · 159 · 160) — 한 목록에 단가산출 로직 · 일위대가 사전조합 · 구조물 조합(마스터 · 회사 · + * 개인 층 단추) · 호표(B09 · 있을 때) · 종류 고르기 + 검색 + 구분 · 상세구분 거름 · + * 단가산출 = 옵션 값 창(`askInputs` · 160) · 사전조합 · 구조물 조합 = 프로젝트 사본 들여옴 → `{tab: dg, key, lib}` */ +export interface PriceLinkOpts { + apply: ApplyRef; + /** B09 호표 목록(일위대가 · 단가산출) — 없으면 그 종류 없음(B08) */ + hopyo?: { dg: Row[]; sg: Row[] }; + /** 서고 사본 들여온 뒤 받은 원가 자료(B09 = 화면 자료에 끼움) */ + onPayload?: (payload: Row) => void; + /** 고른 것 받기(263-1 「운반 단가」 모달) — 없으면 단가산출 = 옵션 값 창 · 조합 = 사본 들여옴 · 호표 = 연결 */ + onPick?: (picked: Picked) => void; +} + +export async function openPriceLink(o: PriceLinkOpts): Promise { + const pick = + o.onPick ?? + ((p: Picked): void => { + if (p.kind === "logic") askInputs(o.apply, p.code, p.name); + else if ("ref" in p) o.apply(p.ref, p.from); + else void importLib(o, p); + }); + const hop = (tab: "dg" | "sg") => + ((tab === "dg" ? o.hopyo?.dg : o.hopyo?.sg) ?? []).map((r) => ({ + kind: tab, + key: String(r.key), + tag: tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`, + name: String(r.name || ""), + sub: String(r.spec || ""), + unit: String(r.unit || ""), + onPick: () => pick({ kind: tab, ref: { tab, key: r.key }, from: r }), + })); + // 구조물 조합 = 마스터 · 회사 · 개인 한 목록(B08 [선택] 나무와 같은 원천) + const combos = (await loadRows().catch(() => [])).map((r) => ({ + kind: "combo", + key: `${r.layer}:${r.owner ?? ""}:${r.key}`, + tag: "구조물 조합", + name: itemName(r), + sub: `${LAYER_TEXT[r.layer ?? "system"]} · ${r.key}`, + unit: String(r.단위 || ""), + onPick: () => + pick({ + kind: "combo", + layer: r.layer ?? "system", + owner: r.owner ?? null, + key: r.key, + name: itemName(r), + unit: String(r.단위 || ""), + }), + })); + openLogicPicker((logic) => pick({ kind: "logic", code: logic.키, name: logic.이름 }), { layer: "personal", owner: null, - libOnly: true, - title: "일위대가 사전조합 고르기", - pick: (ref) => - void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }), + title: "단가연결", + extra: [...combos, ...hop("dg"), ...hop("sg")], + kindFilter: [ + { value: "logic", text: "단가산출" }, + { value: "precombo", text: "일위대가 사전조합" }, + { value: "combo", text: "구조물 조합" }, + ...(o.hopyo + ? [ + { value: "dg", text: "일위대가 호표" }, + { value: "sg", text: "단가산출 호표" }, + ] + : []), + ], + pick: (ref, name) => + pick({ kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키, name, unit: "" }), }); } - -/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */ -export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] { - const more: Leaf[] = []; - if (ref?.tab === "logic") - more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]); - if (ref) more.push(["연결 풀기", () => apply(null)]); - const picks: Leaf[] = [ - ["일위대가 호표", () => pickHopyo(ctx, apply, "dg")], - ["단가산출 호표", () => pickHopyo(ctx, apply, "sg")], - ["구조물 조합", () => pickCombo(ctx, apply)], - ["일위대가 사전조합", () => pickPrecombo(ctx, apply)], - ["단가산출", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름))], - ]; - return [picks, more]; -} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 8618460f7..641818e31 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -11,6 +11,8 @@ * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 * 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 * 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1) + * 278-2 단가산출근거 박스 = 머리 줄(`|머리n`)을 운반 단가 창 ③ 칸 꼴로(`headSteps` 한 벌 · 몸 위 칸) · + * 출처 = 그 칸 맨 위 · 호표 줄 · 총계 = 그대로 · 묶음 자료(`tabGroups`) · 표는 안 바뀜 * ========================================================================== */ import type { DataRow, DataTable } from "@ui/ui_template_data_table"; @@ -26,6 +28,7 @@ import { type GroupParts, } from "@ui/ui_template_group_cards"; import { readState, writeState } from "../A00_Common/b_page_state"; +import { headSteps } from "./B09_Estimation_UI_Haul_Steps"; import { rowIndex, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs"; export type GroupView = "cards" | "table"; @@ -152,6 +155,33 @@ export function tabGroups(tab: TabId, spec: TabTable): GroupCard[] { return cards; } +const HEAD_LINE = /\|머리\d+$/; + +/** 278-2 단가산출근거 박스 — 머리 줄을 몸 줄에서 빼 입력값 · 중간값 칸(`top`)으로 · 줄 번호 그대로 */ +export function sgHeadCards(cards: GroupCard[], rows: DataRow[]): GroupCard[] { + return cards.map((c) => { + const heads = c.lines.filter((l) => HEAD_LINE.test(str(rows[l.at]?.key))); + if (!heads.length) return c; + const source = str(rows[c.at]?.source); + const top = document.createElement("div"); + top.className = "b09-sgsteps"; + if (source) { + const cite = document.createElement("div"); + cite.className = "b09-haulstep__group"; + cite.textContent = source; + top.append(cite); + } + const head = heads.map((l) => str(rows[l.at].text)); + top.append( + ...headSteps( + { name: c.name, source, head }, + heads.map((l) => l.at), + ), + ); + return { ...c, top, lines: c.lines.filter((l) => !heads.includes(l)) }; + }); +} + /** 고른 호표의 표 줄 구간 [첫 줄, 끝 줄](키 `|` 앞머리가 같은 줄) · 없으면 null */ export function pickRange(rows: DataRow[], focus: string | undefined): [number, number] | null { if (focus === undefined) return null; @@ -203,6 +233,17 @@ function listenKeys(): void { }); } +/** 257-2 이동해 온 줄 빨강(묶음 아닌 표 · 설계내역서 빼고) — 줄 누름 · 방향키 · Esc 로 풂(다시 그리지 않음) */ +export function unpickOnRelease(root: HTMLElement): void { + const clear = (): void => { + for (const tr of root.querySelectorAll("tr.is-pick")) + tr.classList.remove("is-pick", "is-pick-top", "is-pick-end"); + }; + root.addEventListener("click", clear); + live = { root, unpick: clear }; + listenKeys(); +} + /** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ const chosen = new Map(); @@ -234,7 +275,10 @@ export function renderGroupPanel(o: { if (view === "cards") { const first = o.spec.columns[0]?.key ?? ""; const cards = createGroupCards({ - groups: tabGroups(o.tab, o.spec), + groups: + o.tab === "sg" + ? sgHeadCards(tabGroups(o.tab, o.spec), o.spec.rows) + : tabGroups(o.tab, o.spec), columns: readGroupCols(o.tab), onColumns: (n) => rememberGroup("b09-group-cols", o.tab, n), onPick: (i) => o.goto(o.spec.link?.(i, first) ?? null), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index fc51ab5d0..8eb60ecdf 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -94,6 +94,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { note_raw: b.spec_raw, name: b.name, unit: b.unit, + source: b.source ?? "", // 278-2 박스보기 머리 줄 나눔(출처 줄) }, null, true, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index c95485580..e41a21e1b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -223,8 +223,9 @@ export const buildTotal: TabBuild = (ctx): TabTable => { `순자재대 ${won(supply.net ?? null)} + 조달수수료 ${won(supply.fee ?? null)} → 천원 올림`, { hand: hand("supply") }, ), - row("grand", "바.", "총공사비", sub.grand ?? null), ]; + // 257-2 총공사비 = 표 아래 고정 줄(템플릿 total · 몸 줄 아님 · 줄 이동 누름 없음) + const grand = row("grand", "바.", "총공사비", sub.grand ?? null); const safetyRow = rows.find((r) => r.key === "safety"); if (safetyRow && safety.a !== null && safety.a !== undefined) safetyRow.note = `A(관급 ÷ 1.1 포함) ${won(safety.a)} · B(× 1.2) ${won(safety.b)} → 작은 쪽`; @@ -245,6 +246,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { text("candidate", "요율 출처", 260), ], rows, + total: grand, frozen: 2, rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), @@ -269,7 +271,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { /** 원가계산서 줄 [키(cost · 총괄 줄), 구분, 비목, 세목] */ const COST_ROWS: Array<[string, string, string, string, string?]> = [ - ["direct_material", "순공사원가", "재료비", "직접재료비", "material"], + ["direct_material", "순공사원가", "재료비", "직접재료비", "direct"], ["indirect_material", "순공사원가", "재료비", "간접재료비"], ["scrap", "순공사원가", "재료비", "작업설 · 부산물 등(△)"], ["material", "순공사원가", "재료비", "소계", "direct"], @@ -288,8 +290,7 @@ const COST_ROWS: Array<[string, string, string, string, string?]> = [ ["vat", "부가가치세", "", "", "vat"], ["contract", "도급공사비", "", "", "contract"], ["supply", "관급자재대", "", "", "supply"], - ["grand", "총공사비", "", "", "grand"], -]; +]; // 총공사비(grand) = 표 아래 고정 줄(257-2 · buildCost total) export const buildCost: TabBuild = (ctx): TabTable => { const t: Row = ctx.sheets.total || {}; @@ -314,6 +315,14 @@ export const buildCost: TabBuild = (ctx): TabTable => { level: !part ? 1 : name === "소계" ? 2 : 3, })); return { + // 257-2 총공사비 = 표 아래 고정 줄(템플릿 total · 줄 이동 누름 없음) + total: { + key: "grand", + group: "총공사비", + amount: cost.grand ?? null, + share: share.grand ?? null, + note: formula.grand ?? "", + }, columns: [ text("group", "구분", 96), text("part", "비목", 72), diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Calc.py b/B09_Estimation/draft/B09_Draft_Schedule_Calc.py new file mode 100644 index 000000000..7dd55204f --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Calc.py @@ -0,0 +1,314 @@ +"""B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). + +짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` · `test_264_2_b09_schedule_options.py`). +근거 = 실무 설계서 공기 탭 식(거창 25 · 울진 24 · 울진소광 25) — 값 베끼기가 아니라 식만: + + 공기산출 설계내역서 항목 줄마다 작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) + 일작업량 = 장비 — 일 작업시간 × 대수 ÷ 단위당 주 장비 시간 + (= 일 작업시간 × Q · Q = 1 ÷ 단위당 시간) + 인력 — 투입 인원 ÷ 단위당 인(장비 없는 줄) + 입력 — 사용자 값(연결 없는 줄 · 덮어쓰기) + 단위당 자원 = 서버 역전개(`…_Source.unit_resources` · 호표 구성 1 단위) + 반영률 = 다른 공종과 겹침(100 = 혼자 · 50 = 반 겹침 · 0 = 다 겹침) · 운반공 기본 0 + 소계 = ROUND(Σ 작업일수 + 기타, 0) · 총작업일수 = ROUND(준비 + 소계 + 정리, 0) + 공기집계 착공일부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) · + 법정공휴일 B = 일요일 + 공휴일(대체 · 선거일 포함 · 사용자 덮개) · + 중복 C = A × B ÷ 달력 · + 비작업일수 = ROUNDDOWN(A + B − C, 0) · 작업가능일수 = 달력 − 비작업 + 준비 → 작업 → 정리 차례로 작업가능일수를 채움 · 다 쓴 달 = 그 달 비작업 전부 · + 마지막 달(덜 쓴 달) = ROUNDUP(비작업 × 쓴 날 ÷ 작업가능일, 0) + 공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공일 + 공사기간 − 1 + 옵션(264 · user.schedule · 기본값 = 위 식 그대로) — 착공일(1일 아니면 첫 달 = 그날부터 · + A = 날수 비례) · 기후 자료(원천 climates 고르기 · 달마다 덮개가 이김) · + 쉬는 날(일 · 토일) · 공휴일 빼기(대체 · 선거일 · 노동절 · 제헌절 — 이름으로 가름) · + 투입 기본(장비 대 · 인력 인) · 반영률 기본(운반공 · 그 밖) · + 주 장비 기준(가장 긴 장비 · 장비 시간 합) · 중복 C 끝수(그대로 · 소수 1자리 반올림) · + 마지막 달 비작업(쓴 비율 올림 · 전부) +""" + +from __future__ import annotations + +import datetime +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import num, round_half, rounddown, roundup + +#: 사용자 값 기본(264 패널 · 표 칸 같은 값) +DEFAULTS = { + "hours": 8, + "units": 1, + "crew": 10, + "prep_docs": 2, + "prep_setup": 2, + "tidy": 4, + "etc": 0, + "rate_haul": 0, + "rate_base": 100, +} +#: 공휴일 빼기 갈래 — 이름에 든 글 +HOLIDAY_KINDS = { + "substitute": ("대체",), + "election": ("선거",), + "labor": ("노동절", "근로자"), + "constitution": ("제헌절",), +} +#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 운반공(= 다른 공종과 겹침) +SKIP_TOP = ("4", "G") +OVERLAP_TOP = ("3",) +MAX_MONTHS = 120 + + +def _top(section: Any) -> str: + return str(section or "").split(".")[0] + + +def _cfg(user: dict[str, Any], key: str) -> float: + value = num(user.get(key)) + return DEFAULTS[key] if value is None or value < 0 else value + + +def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, base: dict) -> dict: + hours = base["hours"] + qty = num(item.get("qty")) or 0.0 + rate = num(cfg.get("rate")) + if rate is None: + rate = base["rate_haul"] if _top(item.get("section")) in OVERLAP_TOP else base["rate_base"] + daily_in = num(cfg.get("daily")) + machine = (unit or {}).get("machine") + total = (unit or {}).get("machine_sum") + if machine and base["sum"] and total and (num(total.get("hr")) or 0) > 0: + many = f" 외 {total['n'] - 1}종" if (total.get("n") or 1) > 1 else "" + machine = {**machine, "name": f"{machine.get('name') or ''}{many}", "hr": total["hr"]} + labor = num((unit or {}).get("labor")) + out = { + "kind": "item", + "key": item["key"], + "name": item.get("name") or "", + "spec": item.get("spec") or "", + "unit": item.get("unit") or "", + "lv": item.get("level") or 1, + "qty": qty, + "rate": rate, + "basis": "", + "who": "", + "per": None, + "put": None, + "q": None, + "daily": None, + "daily_in": daily_in, + "days": None, + } + if daily_in is not None and daily_in > 0: + out.update(basis="입력", daily=daily_in) + need = qty * rate / 100 / daily_in + elif machine and (num(machine.get("hr")) or 0) > 0: + hr = num(machine["hr"]) + units = num(cfg.get("units")) or base["units"] + out.update(basis="장비", who=machine.get("name") or "", per=hr, put=units) + out.update(q=1 / hr, daily=hours * units / hr) + need = qty * rate / 100 * hr / (hours * units) + elif labor is not None and labor > 0: + crew = num(cfg.get("crew")) or base["crew"] + out.update(basis="인력", who="인력", per=labor, put=crew, daily=crew / labor) + need = qty * rate / 100 * labor / crew + else: + out["basis"] = "연결 없음" + out["days"] = 0.0 if rate == 0 else None # 다 겹침 = 일작업량 없어도 0 + return out + out["days"] = roundup(need, 1) + return out + + +def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) -> dict: + """공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리.""" + hours = _cfg(user, "hours") or DEFAULTS["hours"] + base = { + "hours": hours, + "units": _cfg(user, "units") or DEFAULTS["units"], + "crew": _cfg(user, "crew") or DEFAULTS["crew"], + "rate_haul": min(_cfg(user, "rate_haul"), 100), + "rate_base": min(_cfg(user, "rate_base"), 100), + "sum": user.get("machine_basis") == "sum", + } + units = src.get("units") or {} + cfgs = user.get("rows") or {} + rows: list[dict[str, Any]] = [] + pending: list[list[Any]] = [] # [줄, 넣음] + for row in bill_rows: + level = row.get("level") or 1 + if row.get("kind") != "item": + pending = [p for p in pending if (p[0].get("level") or 1) < level] + pending.append([row, False]) + continue + key = str(row.get("key") or "") + qty = num(row.get("qty")) + if ( + _top(row.get("section")) in SKIP_TOP + or key.startswith("mat:") + or key == "fee" + or qty is None + or qty <= 0 + ): + continue + for p in pending: + if not p[1]: + p[1] = True + head = p[0] + rows.append( + { + "kind": "head", + "key": head["key"], + "name": head.get("name") or "", + "lv": head.get("level") or 1, + } # fmt: skip + ) + rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, base)) + docs = _cfg(user, "prep_docs") + setup = _cfg(user, "prep_setup") + etc = _cfg(user, "etc") + tidy = _cfg(user, "tidy") + days = sum(r["days"] or 0 for r in rows if r["kind"] == "item") + subtotal = round_half(days + etc, 0) + missing = sum(1 for r in rows if r["kind"] == "item" and r["days"] is None) + return { + "hours": hours, + "rows": rows, + "prep_docs": docs, + "prep_setup": setup, + "prep": docs + setup, + "etc": etc, + "subtotal": subtotal, + "tidy": tidy, + "total": round_half(docs + setup + subtotal + tidy, 0), + "missing": missing, + } + + +def _month_days(year: int, month: int) -> int: + nxt = datetime.date(year + month // 12, month % 12 + 1, 1) + return (nxt - datetime.date(year, month, 1)).days + + +def _counted(name: str, off: list[str]) -> bool: + """공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음.""" + for part in name.split(";"): + kinds = [k for k, words in HOLIDAY_KINDS.items() if any(w in part for w in words)] + if not any(k in off for k in kinds): + return True + return False + + +def _holidays( + src: dict[str, Any], year: int, month: int, first: int, user: dict[str, Any] +) -> tuple[int, list[str]]: + """그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」).""" + head = f"{year:04d}-{month:02d}-" + off = user.get("holiday_off") or [] + named = { + d: n + for d, n in (src.get("holidays") or {}).get(str(year)) or [] + if d.startswith(head) and int(d[-2:]) >= first and _counted(n, off) + } + rest = (5, 6) if user.get("rest") == "satsun" else (6,) + count = 0 + for day in range(first, _month_days(year, month) + 1): + iso = f"{head}{day:02d}" + if datetime.date(year, month, day).weekday() in rest or iso in named: + count += 1 + return count, [f"{int(d[-2:])}일 {n}" for d, n in sorted(named.items())] + + +def climate_base(src: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: + """고른 기후 자료(원천 climates) · 없으면 원천 기본값.""" + return (src.get("climates") or {}).get(user.get("climate_set")) or src.get("climate") or {} + + +def _climate(src: dict[str, Any], user: dict[str, Any], kind: str, month: int) -> float: + mine = ((user.get("climate") or {}).get(kind) or [None] * 12)[month - 1] + if num(mine) is not None: + return num(mine) + return num((climate_base(src, user).get(kind) or [0] * 12)[month - 1]) or 0.0 + + +def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -> dict[str, Any]: + """공기집계 — parts = [준비, 작업, 정리] 일수 · 착공일부터 채움.""" + start = {**(src.get("start") or {}), **(user.get("start") or {})} + year, month = int(start.get("year") or 2000), int(start.get("month") or 1) + first = min(max(int(start.get("day") or 1), 1), _month_days(year, month)) + begin = datetime.date(year, month, first) + left = [max(p, 0) for p in parts] + months: list[dict[str, Any]] = [] + total = 0 + while sum(left) > 0 or not months: + if len(months) >= MAX_MONTHS: + break + full = _month_days(year, month) + days = full - first + 1 + rain = _climate(src, user, "rain", month) + heat = _climate(src, user, "heat", month) + a = roundup(rain + heat if first == 1 else (rain + heat) * days / full, 1) + auto, names = _holidays(src, year, month, first, user) + mine = num((user.get("holiday") or {}).get(f"{year:04d}-{month:02d}")) + b = mine if mine is not None else auto + c = a * b / days + if user.get("c_round") == "round1": + c = round_half(c, 1) + off = rounddown(a + b - c, 0) + avail = days - off + cap = max(avail, 0) + use = [0.0, 0.0, 0.0] + room = cap + for i in range(3): + take = min(room, left[i]) + use[i] = take + left[i] -= take + room -= take + used = sum(use) + if cap <= 0: + off_used = days + elif used >= cap or user.get("last_off") == "all": + off_used = off + else: + off_used = roundup(off * used / cap, 0) + span = used + off_used + total += span + months.append( + { + "year": year, + "month": month, + "days": days, + "rain": rain, + "heat": heat, + "a": a, + "b": b, + "b_auto": auto, + "names": names, + "c": c, + "off": off, + "avail": avail, + "prep": use[0], + "work": use[1], + "tidy": use[2], + "used": used, + "off_used": off_used, + "span": span, + } # fmt: skip + ) + year, month = (year + 1, 1) if month == 12 else (year, month + 1) + first = 1 + end = begin + datetime.timedelta(days=max(total, 1) - 1) + return { + "start": {"year": begin.year, "month": begin.month, "day": begin.day}, + "months": months, + "days": total, + "months_n": round_half(total / 30, 1), + "end": end.isoformat(), + } + + +def compute_schedule(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) -> dict: + """공기 한 벌 — {work: 공기산출, calendar: 공기집계}.""" + user = user or {} + work = work_table(src or {}, bill_rows or [], user) + parts = [work["prep"], work["subtotal"], work["tidy"]] + return {"work": work, "calendar": month_table(src or {}, user, parts)} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts b/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts new file mode 100644 index 000000000..501a98915 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts @@ -0,0 +1,300 @@ +/* ============================================================================= + * B09_Draft_Schedule_Calc.ts + * B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). + * 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` · + * `test_264_2_b09_schedule_options.py`) — 식 설명은 그쪽 머리. + * 공기산출 = 설계내역서 항목 줄마다 ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비 · 인력 · 입력 + * 공기집계 = 착공일부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움 + * 옵션(264) = 착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준 · + * 중복 C 끝수 · 마지막 달 비작업(기본값 = 위 식 그대로) + * ========================================================================== */ + +import { num, roundHalf, rounddown, roundup, type Row } from "../B09_Estimation_Calc_Base"; + +/** 사용자 값 기본(264 패널 · 표 칸 같은 값) */ +export const DEFAULTS: Record = { + hours: 8, + units: 1, + crew: 10, + prep_docs: 2, + prep_setup: 2, + tidy: 4, + etc: 0, + rate_haul: 0, + rate_base: 100, +}; +/** 공휴일 빼기 갈래 — 이름에 든 글 */ +export const HOLIDAY_KINDS: Record = { + substitute: ["대체"], + election: ["선거"], + labor: ["노동절", "근로자"], + constitution: ["제헌절"], +}; +const SKIP_TOP = ["4", "G"]; +const OVERLAP_TOP = ["3"]; +const MAX_MONTHS = 120; + +const top = (section: unknown): string => String(section ?? "").split(".")[0]; + +function cfg(user: Row, key: string): number { + const value = num(user[key]); + return value === null || value < 0 ? DEFAULTS[key] : value; +} + +function workRow(item: Row, unit: Row | undefined, c: Row, base: Row): Row { + const hours: number = base.hours; + const qty = num(item.qty) ?? 0; + let rate = num(c.rate); + if (rate === null) + rate = OVERLAP_TOP.includes(top(item.section)) ? base.rate_haul : base.rate_base; + const dailyIn = num(c.daily); + let machine = unit?.machine as Row | null | undefined; + const total = unit?.machine_sum as Row | null | undefined; + if (machine && base.sum && total && (num(total.hr) ?? 0) > 0) { + const many = (total.n || 1) > 1 ? ` 외 ${total.n - 1}종` : ""; + machine = { ...machine, name: `${machine.name || ""}${many}`, hr: total.hr }; + } + const labor = num(unit?.labor); + const out: Row = { + kind: "item", + key: item.key, + name: item.name || "", + spec: item.spec || "", + unit: item.unit || "", + lv: item.level || 1, + qty, + rate, + basis: "", + who: "", + per: null, + put: null, + q: null, + daily: null, + daily_in: dailyIn, + days: null, + }; + let need: number; + if (dailyIn !== null && dailyIn > 0) { + Object.assign(out, { basis: "입력", daily: dailyIn }); + need = (qty * rate) / 100 / dailyIn; + } else if (machine && (num(machine.hr) ?? 0) > 0) { + const hr = num(machine.hr) as number; + const units = num(c.units) || base.units; + Object.assign(out, { basis: "장비", who: machine.name || "", per: hr, put: units }); + Object.assign(out, { q: 1 / hr, daily: (hours * units) / hr }); + need = (((qty * rate) / 100) * hr) / (hours * units); + } else if (labor !== null && labor > 0) { + const crew = num(c.crew) || base.crew; + Object.assign(out, { basis: "인력", who: "인력", per: labor, put: crew, daily: crew / labor }); + need = (((qty * rate) / 100) * labor) / crew; + } else { + out.basis = "연결 없음"; + out.days = rate === 0 ? 0 : null; // 다 겹침 = 일작업량 없어도 0 + return out; + } + out.days = roundup(need, 1); + return out; +} + +/** 공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리 */ +export function workTable(src: Row, billRows: Row[], user: Row): Row { + const hours = cfg(user, "hours") || DEFAULTS.hours; + const base: Row = { + hours, + units: cfg(user, "units") || DEFAULTS.units, + crew: cfg(user, "crew") || DEFAULTS.crew, + rate_haul: Math.min(cfg(user, "rate_haul"), 100), + rate_base: Math.min(cfg(user, "rate_base"), 100), + sum: user.machine_basis === "sum", + }; + const units: Row = src.units || {}; + const cfgs: Row = user.rows || {}; + const rows: Row[] = []; + let pending: Array<[Row, boolean]> = []; + for (const row of billRows) { + const level = row.level || 1; + if (row.kind !== "item") { + pending = pending.filter(([p]) => (p.level || 1) < level); + pending.push([row, false]); + continue; + } + const key = String(row.key ?? ""); + const qty = num(row.qty); + if ( + SKIP_TOP.includes(top(row.section)) || + key.startsWith("mat:") || + key === "fee" || + qty === null || + qty <= 0 + ) + continue; + for (const p of pending) { + if (p[1]) continue; + p[1] = true; + rows.push({ kind: "head", key: p[0].key, name: p[0].name || "", lv: p[0].level || 1 }); + } + rows.push(workRow(row, units[key], cfgs[key] || {}, base)); + } + const docs = cfg(user, "prep_docs"); + const setup = cfg(user, "prep_setup"); + const etc = cfg(user, "etc"); + const tidy = cfg(user, "tidy"); + const items = rows.filter((r) => r.kind === "item"); + const days = items.reduce((s: number, r) => s + (r.days || 0), 0); + const subtotal = roundHalf(days + etc, 0); + return { + hours, + rows, + prep_docs: docs, + prep_setup: setup, + prep: docs + setup, + etc, + subtotal, + tidy, + total: roundHalf(docs + setup + subtotal + tidy, 0), + missing: items.filter((r) => r.days === null).length, + }; +} + +const monthDays = (year: number, month: number): number => + new Date(Date.UTC(year, month, 0)).getUTCDate(); + +const pad = (n: number): string => String(n).padStart(2, "0"); + +/** 공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음 */ +function counted(name: string, off: string[]): boolean { + for (const part of name.split(";")) { + const kinds = Object.keys(HOLIDAY_KINDS).filter((k) => + HOLIDAY_KINDS[k].some((w) => part.includes(w)), + ); + if (!kinds.some((k) => off.includes(k))) return true; + } + return false; +} + +/** 그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름 */ +function holidays( + src: Row, + year: number, + month: number, + first: number, + user: Row, +): [number, string[]] { + const head = `${String(year).padStart(4, "0")}-${pad(month)}-`; + const off: string[] = user.holiday_off || []; + const named = new Map( + ((src.holidays || {})[String(year)] || []).filter( + ([d, n]: [string, string]) => + d.startsWith(head) && Number(d.slice(-2)) >= first && counted(n, off), + ), + ); + const rest = user.rest === "satsun" ? [6, 0] : [0]; + let count = 0; + for (let day = first; day <= monthDays(year, month); day += 1) { + const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + if (rest.includes(weekday) || named.has(`${head}${pad(day)}`)) count += 1; + } + const names = [...named.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([d, n]) => `${Number(d.slice(-2))}일 ${n}`); + return [count, names]; +} + +/** 고른 기후 자료(원천 climates) · 없으면 원천 기본값 */ +export const climateBase = (src: Row, user: Row): Row => + (src.climates || {})[user.climate_set] || src.climate || {}; + +function climate(src: Row, user: Row, kind: string, month: number): number { + const mine = num(((user.climate || {})[kind] || [])[month - 1]); + if (mine !== null) return mine; + return num((climateBase(src, user)[kind] || [])[month - 1]) ?? 0; +} + +/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공일부터 채움 */ +export function monthTable(src: Row, user: Row, parts: number[]): Row { + const start: Row = { ...(src.start || {}), ...(user.start || {}) }; + let year = Math.trunc(Number(start.year) || 2000); + let month = Math.trunc(Number(start.month) || 1); + let first = Math.min(Math.max(Math.trunc(Number(start.day) || 1), 1), monthDays(year, month)); + const begin = Date.UTC(year, month - 1, first); + const left = parts.map((p) => Math.max(p, 0)); + const months: Row[] = []; + let total = 0; + while (left.reduce((s, v) => s + v, 0) > 0 || !months.length) { + if (months.length >= MAX_MONTHS) break; + const full = monthDays(year, month); + const days = full - first + 1; + const rain = climate(src, user, "rain", month); + const heat = climate(src, user, "heat", month); + const a = roundup(first === 1 ? rain + heat : ((rain + heat) * days) / full, 1); + const [auto, names] = holidays(src, year, month, first, user); + const mine = num((user.holiday || {})[`${String(year).padStart(4, "0")}-${pad(month)}`]); + const b = mine !== null ? mine : auto; + let c = (a * b) / days; + if (user.c_round === "round1") c = roundHalf(c, 1); + const off = rounddown(a + b - c, 0); + const avail = days - off; + const cap = Math.max(avail, 0); + const use = [0, 0, 0]; + let room = cap; + for (let i = 0; i < 3; i += 1) { + const take = Math.min(room, left[i]); + use[i] = take; + left[i] -= take; + room -= take; + } + const used = use[0] + use[1] + use[2]; + const offUsed = + cap <= 0 + ? days + : used >= cap || user.last_off === "all" + ? off + : roundup((off * used) / cap, 0); + const span = used + offUsed; + total += span; + months.push({ + year, + month, + days, + rain, + heat, + a, + b, + b_auto: auto, + names, + c, + off, + avail, + prep: use[0], + work: use[1], + tidy: use[2], + used, + off_used: offUsed, + span, + }); + if (month === 12) { + year += 1; + month = 1; + } else month += 1; + first = 1; + } + const from = new Date(begin); + const end = new Date(begin + (Math.max(total, 1) - 1) * 864e5); + return { + start: { year: from.getUTCFullYear(), month: from.getUTCMonth() + 1, day: from.getUTCDate() }, + months, + days: total, + months_n: roundHalf(total / 30, 1), + end: end.toISOString().slice(0, 10), + }; +} + +/** 공기 한 벌 — {work: 공기산출, calendar: 공기집계} */ +export function computeSchedule(src: Row, billRows: Row[], user: Row): Row { + const u = user || {}; + const work = workTable(src || {}, billRows || [], u); + return { + work, + calendar: monthTable(src || {}, u, [work.prep, work.subtotal, work.tidy]), + }; +} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Holidays.json b/B09_Estimation/draft/B09_Draft_Schedule_Holidays.json new file mode 100644 index 000000000..b349e0842 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Holidays.json @@ -0,0 +1,19 @@ +{ + "2024": [["2024-01-01", "신정"], ["2024-02-09", "설날 전날"], ["2024-02-10", "설날"], ["2024-02-11", "설날 다음날"], ["2024-02-12", "설날 대체 휴일"], ["2024-03-01", "삼일절"], ["2024-04-10", "국회의원 선거일"], ["2024-05-05", "어린이날"], ["2024-05-06", "어린이날 대체 휴일"], ["2024-05-15", "부처님오신날"], ["2024-06-06", "현충일"], ["2024-08-15", "광복절"], ["2024-09-16", "추석 전날"], ["2024-09-17", "추석"], ["2024-09-18", "추석 다음날"], ["2024-10-01", "국군의 날"], ["2024-10-03", "개천절"], ["2024-10-09", "한글날"], ["2024-12-25", "기독탄신일"]], + "2025": [["2025-01-01", "신정"], ["2025-01-27", "임시공휴일"], ["2025-01-28", "설날 전날"], ["2025-01-29", "설날"], ["2025-01-30", "설날 다음날"], ["2025-03-01", "삼일절"], ["2025-03-03", "삼일절 대체 휴일"], ["2025-05-05", "부처님오신날; 어린이날"], ["2025-05-06", "부처님오신날 대체 휴일; 어린이날 대체 휴일"], ["2025-06-03", "대통령 선거일"], ["2025-06-06", "현충일"], ["2025-08-15", "광복절"], ["2025-10-03", "개천절"], ["2025-10-05", "추석 전날"], ["2025-10-06", "추석"], ["2025-10-07", "추석 다음날"], ["2025-10-08", "추석 대체 휴일"], ["2025-10-09", "한글날"], ["2025-12-25", "기독탄신일"]], + "2026": [["2026-01-01", "신정"], ["2026-02-16", "설날 전날"], ["2026-02-17", "설날"], ["2026-02-18", "설날 다음날"], ["2026-03-01", "삼일절"], ["2026-03-02", "삼일절 대체 휴일"], ["2026-05-01", "노동절"], ["2026-05-05", "어린이날"], ["2026-05-24", "부처님오신날"], ["2026-05-25", "부처님오신날 대체 휴일"], ["2026-06-03", "지방선거일"], ["2026-06-06", "현충일"], ["2026-07-17", "제헌절"], ["2026-08-15", "광복절"], ["2026-08-17", "광복절 대체 휴일"], ["2026-09-24", "추석 전날"], ["2026-09-25", "추석"], ["2026-09-26", "추석 다음날"], ["2026-10-03", "개천절"], ["2026-10-05", "개천절 대체 휴일"], ["2026-10-09", "한글날"], ["2026-12-25", "기독탄신일"]], + "2027": [["2027-01-01", "신정"], ["2027-02-06", "설날 전날"], ["2027-02-07", "설날"], ["2027-02-08", "설날 다음날"], ["2027-02-09", "설날 대체 휴일"], ["2027-03-01", "삼일절"], ["2027-05-01", "노동절"], ["2027-05-03", "노동절 대체 휴일"], ["2027-05-05", "어린이날"], ["2027-05-13", "부처님오신날"], ["2027-06-06", "현충일"], ["2027-07-17", "제헌절"], ["2027-07-19", "제헌절 대체 휴일"], ["2027-08-15", "광복절"], ["2027-08-16", "광복절 대체 휴일"], ["2027-09-14", "추석 전날"], ["2027-09-15", "추석"], ["2027-09-16", "추석 다음날"], ["2027-10-03", "개천절"], ["2027-10-04", "개천절 대체 휴일"], ["2027-10-09", "한글날"], ["2027-10-11", "한글날 대체 휴일"], ["2027-12-25", "기독탄신일"], ["2027-12-27", "기독탄신일 대체 휴일"]], + "2028": [["2028-01-01", "신정"], ["2028-01-26", "설날 전날"], ["2028-01-27", "설날"], ["2028-01-28", "설날 다음날"], ["2028-03-01", "삼일절"], ["2028-05-01", "노동절"], ["2028-05-02", "부처님오신날"], ["2028-05-05", "어린이날"], ["2028-06-06", "현충일"], ["2028-07-17", "제헌절"], ["2028-08-15", "광복절"], ["2028-10-02", "추석 전날"], ["2028-10-03", "개천절; 추석"], ["2028-10-04", "추석 다음날"], ["2028-10-05", "추석 대체 휴일"], ["2028-10-09", "한글날"], ["2028-12-25", "기독탄신일"]], + "2029": [["2029-01-01", "신정"], ["2029-02-12", "설날 전날"], ["2029-02-13", "설날"], ["2029-02-14", "설날 다음날"], ["2029-03-01", "삼일절"], ["2029-05-01", "노동절"], ["2029-05-05", "어린이날"], ["2029-05-07", "어린이날 대체 휴일"], ["2029-05-20", "부처님오신날"], ["2029-05-21", "부처님오신날 대체 휴일"], ["2029-06-06", "현충일"], ["2029-07-17", "제헌절"], ["2029-08-15", "광복절"], ["2029-09-21", "추석 전날"], ["2029-09-22", "추석"], ["2029-09-23", "추석 다음날"], ["2029-09-24", "추석 대체 휴일"], ["2029-10-03", "개천절"], ["2029-10-09", "한글날"], ["2029-12-25", "기독탄신일"]], + "2030": [["2030-01-01", "신정"], ["2030-02-02", "설날 전날"], ["2030-02-03", "설날"], ["2030-02-04", "설날 다음날"], ["2030-02-05", "설날 대체 휴일"], ["2030-03-01", "삼일절"], ["2030-05-01", "노동절"], ["2030-05-05", "어린이날"], ["2030-05-06", "어린이날 대체 휴일"], ["2030-05-09", "부처님오신날"], ["2030-06-06", "현충일"], ["2030-07-17", "제헌절"], ["2030-08-15", "광복절"], ["2030-09-11", "추석 전날"], ["2030-09-12", "추석"], ["2030-09-13", "추석 다음날"], ["2030-10-03", "개천절"], ["2030-10-09", "한글날"], ["2030-12-25", "기독탄신일"]], + "2031": [["2031-01-01", "신정"], ["2031-01-22", "설날 전날"], ["2031-01-23", "설날"], ["2031-01-24", "설날 다음날"], ["2031-03-01", "삼일절"], ["2031-03-03", "삼일절 대체 휴일"], ["2031-05-01", "노동절"], ["2031-05-05", "어린이날"], ["2031-05-28", "부처님오신날"], ["2031-06-06", "현충일"], ["2031-07-17", "제헌절"], ["2031-08-15", "광복절"], ["2031-09-30", "추석 전날"], ["2031-10-01", "추석"], ["2031-10-02", "추석 다음날"], ["2031-10-03", "개천절"], ["2031-10-09", "한글날"], ["2031-12-25", "기독탄신일"]], + "2032": [["2032-01-01", "신정"], ["2032-02-10", "설날 전날"], ["2032-02-11", "설날"], ["2032-02-12", "설날 다음날"], ["2032-03-01", "삼일절"], ["2032-05-01", "노동절"], ["2032-05-03", "노동절 대체 휴일"], ["2032-05-05", "어린이날"], ["2032-05-16", "부처님오신날"], ["2032-05-17", "부처님오신날 대체 휴일"], ["2032-06-06", "현충일"], ["2032-07-17", "제헌절"], ["2032-07-19", "제헌절 대체 휴일"], ["2032-08-15", "광복절"], ["2032-08-16", "광복절 대체 휴일"], ["2032-09-18", "추석 전날"], ["2032-09-19", "추석"], ["2032-09-20", "추석 다음날"], ["2032-09-21", "추석 대체 휴일"], ["2032-10-03", "개천절"], ["2032-10-04", "개천절 대체 휴일"], ["2032-10-09", "한글날"], ["2032-10-11", "한글날 대체 휴일"], ["2032-12-25", "기독탄신일"], ["2032-12-27", "기독탄신일 대체 휴일"]], + "2033": [["2033-01-01", "신정"], ["2033-01-30", "설날 전날"], ["2033-01-31", "설날"], ["2033-02-01", "설날 다음날"], ["2033-02-02", "설날 대체 휴일"], ["2033-03-01", "삼일절"], ["2033-05-01", "노동절"], ["2033-05-02", "노동절 대체 휴일"], ["2033-05-05", "어린이날"], ["2033-05-06", "부처님오신날"], ["2033-06-06", "현충일"], ["2033-07-17", "제헌절"], ["2033-07-18", "제헌절 대체 휴일"], ["2033-08-15", "광복절"], ["2033-09-07", "추석 전날"], ["2033-09-08", "추석"], ["2033-09-09", "추석 다음날"], ["2033-10-03", "개천절"], ["2033-10-09", "한글날"], ["2033-10-10", "한글날 대체 휴일"], ["2033-12-25", "기독탄신일"], ["2033-12-26", "기독탄신일 대체 휴일"]], + "2034": [["2034-01-01", "신정"], ["2034-02-18", "설날 전날"], ["2034-02-19", "설날"], ["2034-02-20", "설날 다음날"], ["2034-02-21", "설날 대체 휴일"], ["2034-03-01", "삼일절"], ["2034-05-01", "노동절"], ["2034-05-05", "어린이날"], ["2034-05-25", "부처님오신날"], ["2034-06-06", "현충일"], ["2034-07-17", "제헌절"], ["2034-08-15", "광복절"], ["2034-09-26", "추석 전날"], ["2034-09-27", "추석"], ["2034-09-28", "추석 다음날"], ["2034-10-03", "개천절"], ["2034-10-09", "한글날"], ["2034-12-25", "기독탄신일"]], + "2035": [["2035-01-01", "신정"], ["2035-02-07", "설날 전날"], ["2035-02-08", "설날"], ["2035-02-09", "설날 다음날"], ["2035-03-01", "삼일절"], ["2035-05-01", "노동절"], ["2035-05-05", "어린이날"], ["2035-05-07", "어린이날 대체 휴일"], ["2035-05-15", "부처님오신날"], ["2035-06-06", "현충일"], ["2035-07-17", "제헌절"], ["2035-08-15", "광복절"], ["2035-09-15", "추석 전날"], ["2035-09-16", "추석"], ["2035-09-17", "추석 다음날"], ["2035-09-18", "추석 대체 휴일"], ["2035-10-03", "개천절"], ["2035-10-09", "한글날"], ["2035-12-25", "기독탄신일"]], + "2036": [["2036-01-01", "신정"], ["2036-01-27", "설날 전날"], ["2036-01-28", "설날"], ["2036-01-29", "설날 다음날"], ["2036-01-30", "설날 대체 휴일"], ["2036-03-01", "삼일절"], ["2036-03-03", "삼일절 대체 휴일"], ["2036-05-01", "노동절"], ["2036-05-03", "부처님오신날"], ["2036-05-05", "어린이날"], ["2036-05-06", "부처님오신날 대체 휴일"], ["2036-06-06", "현충일"], ["2036-07-17", "제헌절"], ["2036-08-15", "광복절"], ["2036-10-03", "개천절; 추석 전날"], ["2036-10-04", "추석"], ["2036-10-05", "추석 다음날"], ["2036-10-06", "추석 대체 휴일"], ["2036-10-07", "추석 대체 휴일"], ["2036-10-09", "한글날"], ["2036-12-25", "기독탄신일"]], + "2037": [["2037-01-01", "신정"], ["2037-02-14", "설날 전날"], ["2037-02-15", "설날"], ["2037-02-16", "설날 다음날"], ["2037-02-17", "설날 대체 휴일"], ["2037-03-01", "삼일절"], ["2037-03-02", "삼일절 대체 휴일"], ["2037-05-01", "노동절"], ["2037-05-05", "어린이날"], ["2037-05-22", "부처님오신날"], ["2037-06-06", "현충일"], ["2037-07-17", "제헌절"], ["2037-08-15", "광복절"], ["2037-08-17", "광복절 대체 휴일"], ["2037-09-23", "추석 전날"], ["2037-09-24", "추석"], ["2037-09-25", "추석 다음날"], ["2037-10-03", "개천절"], ["2037-10-05", "개천절 대체 휴일"], ["2037-10-09", "한글날"], ["2037-12-25", "기독탄신일"]], + "2038": [["2038-01-01", "신정"], ["2038-02-03", "설날 전날"], ["2038-02-04", "설날"], ["2038-02-05", "설날 다음날"], ["2038-03-01", "삼일절"], ["2038-05-01", "노동절"], ["2038-05-03", "노동절 대체 휴일"], ["2038-05-05", "어린이날"], ["2038-05-11", "부처님오신날"], ["2038-06-06", "현충일"], ["2038-07-17", "제헌절"], ["2038-07-19", "제헌절 대체 휴일"], ["2038-08-15", "광복절"], ["2038-08-16", "광복절 대체 휴일"], ["2038-09-12", "추석 전날"], ["2038-09-13", "추석"], ["2038-09-14", "추석 다음날"], ["2038-09-15", "추석 대체 휴일"], ["2038-10-03", "개천절"], ["2038-10-04", "개천절 대체 휴일"], ["2038-10-09", "한글날"], ["2038-10-11", "한글날 대체 휴일"], ["2038-12-25", "기독탄신일"], ["2038-12-27", "기독탄신일 대체 휴일"]], + "2039": [["2039-01-01", "신정"], ["2039-01-23", "설날 전날"], ["2039-01-24", "설날"], ["2039-01-25", "설날 다음날"], ["2039-01-26", "설날 대체 휴일"], ["2039-03-01", "삼일절"], ["2039-04-30", "부처님오신날"], ["2039-05-01", "노동절"], ["2039-05-02", "부처님오신날 대체 휴일"], ["2039-05-03", "노동절 대체 휴일"], ["2039-05-05", "어린이날"], ["2039-06-06", "현충일"], ["2039-07-17", "제헌절"], ["2039-07-18", "제헌절 대체 휴일"], ["2039-08-15", "광복절"], ["2039-10-01", "추석 전날"], ["2039-10-02", "추석"], ["2039-10-03", "개천절; 추석 다음날"], ["2039-10-04", "추석 대체 휴일"], ["2039-10-05", "추석 대체 휴일"], ["2039-10-09", "한글날"], ["2039-10-10", "한글날 대체 휴일"], ["2039-12-25", "기독탄신일"], ["2039-12-26", "기독탄신일 대체 휴일"]], + "2040": [["2040-01-01", "신정"], ["2040-02-11", "설날 전날"], ["2040-02-12", "설날"], ["2040-02-13", "설날 다음날"], ["2040-02-14", "설날 대체 휴일"], ["2040-03-01", "삼일절"], ["2040-05-01", "노동절"], ["2040-05-05", "어린이날"], ["2040-05-07", "어린이날 대체 휴일"], ["2040-05-18", "부처님오신날"], ["2040-06-06", "현충일"], ["2040-07-17", "제헌절"], ["2040-08-15", "광복절"], ["2040-09-20", "추석 전날"], ["2040-09-21", "추석"], ["2040-09-22", "추석 다음날"], ["2040-10-03", "개천절"], ["2040-10-09", "한글날"], ["2040-12-25", "기독탄신일"]] +} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts new file mode 100644 index 000000000..9bd64b379 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts @@ -0,0 +1,196 @@ +/* ============================================================================= + * B09_Draft_Schedule_Panel.ts + * B09 왼쪽 패널 「공기 옵션」 카드(PLAN 264-1 · 사용자 164-2 · 초안 — draft 폴더째 지우면 사라짐). + * 등록 = `B09_Draft_Schedule_UI` 가 DRAFT.panels(카드) · DRAFT.extend(값 맞춤)에 넣음 — + * 본 코드 훅 = `…_UI_Shell` 이 「데이터 점검」 아래 · [저장] 묶음 위에 붙이는 한 줄 + * 값 = user.schedule(캐시 → [저장] 한 번에) · 공기산출 · 공기집계 표 칸과 같은 값(어디서 고쳐도 한 벌) + * 꼴 = 좌측 패널 공용 카드(269 · createPanelCard · 공용 칸 · 체크박스) — draft 덧칠 CSS 없음 + * ========================================================================== */ + +import { createPanelCard } from "@ui/ui_template_collapsible"; +import { createInputField, createSelectField } from "@ui/ui_template_elements"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { num, type Row } from "../B09_Estimation_Calc_Base"; +import { parseNumber } from "../B09_Estimation_UI_Tabs"; +import { DEFAULTS } from "./B09_Draft_Schedule_Calc"; + +type Sync = (mine: Row, src: Row) => void; + +const HOLIDAY_LABELS: Array<[string, string]> = [ + ["substitute", "대체공휴일"], + ["election", "선거일"], + ["labor", "근로자의 날(노동절)"], + ["constitution", "제헌절"], +]; +const CHOICES: Record> = { + rest: [ + ["sun", "일요일만"], + ["satsun", "토 · 일"], + ], + machine_basis: [ + ["max", "단위당 시간이 가장 긴 장비"], + ["sum", "장비 시간 합(차례로 일하는 장비)"], + ], + c_round: [ + ["none", "그대로"], + ["round1", "소수 1자리 반올림"], + ], + last_off: [ + ["ratio", "쓴 비율만큼 올림"], + ["all", "그 달 비작업 전부"], + ], +}; + +/** 지금 화면의 카드 맞춤 — 셈마다 DRAFT.extend 가 부름(카드를 다시 만들면 새 카드로) */ +let syncNow: Sync | null = null; + +export function syncSchedulePanel(payload: Row, user: Row): void { + syncNow?.(user.schedule || {}, payload.inputs?.schedule || {}); +} + +/** 두 칸 줄 — 공용 줄 class 가 없어 자리에서(draft 폴더째 지우면 사라짐) · + * 바깥 div 는 꾸밈 없음 = 카드 접기(`display: none`)가 먹게 */ +function pair(...items: HTMLElement[]): HTMLDivElement { + const row = document.createElement("div"); + row.style.cssText = + "display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--spacing-8)"; + row.append(...items); + const outer = document.createElement("div"); + outer.append(row); + return outer; +} + +export function createSchedulePanel(user: () => Row, setUser: (next: Row) => void): HTMLElement { + const syncs: Sync[] = []; + const change = (fn: (s: Row) => void): void => { + const next: Row = structuredClone(user().schedule || {}); + fn(next); + setUser({ ...user(), schedule: next }); + }; + + /** 수 칸 — 빈칸 · 기본값 = 지움(기본) · 범위 밖 = 되돌림 */ + const number = (key: string, label: string, min: number, max: number): HTMLElement => { + const { root, input } = createInputField({ label, type: "number", compact: true, min, max }); + input.step = "any"; + input.dataset.b09Schedule = key; + let last: Row = {}; + syncs.push((mine) => { + last = mine; + input.value = String(num(mine[key]) ?? DEFAULTS[key]); + }); + input.addEventListener("change", () => { + const v = parseNumber(input.value); + if (v === undefined || (v !== null && (v < min || v > max))) { + input.value = String(num(last[key]) ?? DEFAULTS[key]); + return; + } + change((s) => { + if (v === null || v === DEFAULTS[key]) delete s[key]; + else s[key] = v; + }); + }); + return root; + }; + + /** 고르기 칸 — 첫째 = 기본(고르면 지움) · options 없으면 원천에서(기후 자료) */ + const choose = (key: string, label: string, options?: Array<[string, string]>): HTMLElement => { + const { root, select, setOptions } = createSelectField({ label, options: [], compact: true }); + select.dataset.b09Schedule = key; + let values: string[] = []; + syncs.push((mine, src) => { + const list = + options ?? + Object.entries((src.climates || {}) as Row).map(([id, c]): [string, string] => [ + id, + c.name || id, + ]); + if (list.map(([v]) => v).join() !== values.join()) { + values = list.map(([v]) => v); + setOptions(list.map(([value, text]) => ({ value, text }))); + } + select.value = values.includes(mine[key]) ? mine[key] : (values[0] ?? ""); + }); + select.addEventListener("change", () => + change((s) => { + if (select.value === values[0]) delete s[key]; + else s[key] = select.value; + }), + ); + return root; + }; + + const startField = createInputField({ label: "착공일", type: "date", compact: true }); + const start = startField.input; + start.dataset.b09Schedule = "start"; + syncs.push((mine, src) => { + const at: Row = { ...(src.start || {}), ...(mine.start || {}) }; + const pad = (n: unknown): string => String(Number(n) || 1).padStart(2, "0"); + start.value = at.year ? `${at.year}-${pad(at.month)}-${pad(at.day)}` : ""; + }); + start.addEventListener("change", () => { + const hit = /^(\d{4})-(\d{2})-(\d{2})$/.exec(start.value); + change((s) => { + if (!hit) return void delete s.start; + const day = Number(hit[3]); + s.start = { year: Number(hit[1]), month: Number(hit[2]), ...(day > 1 ? { day } : {}) }; + }); + }); + + const checks = HOLIDAY_LABELS.map(([kind, label]) => { + const { root, input } = createCheckbox(label); + input.dataset.b09Holiday = kind; + syncs.push((mine) => { + input.checked = !(mine.holiday_off || []).includes(kind); + }); + input.addEventListener("change", () => + change((s) => { + const off = HOLIDAY_LABELS.map(([k]) => k).filter((k) => + k === kind ? !input.checked : (s.holiday_off || []).includes(k), + ); + if (off.length) s.holiday_off = off; + else delete s.holiday_off; + }), + ); + return root; + }); + const holidayBox = document.createElement("div"); + holidayBox.className = "ui-field"; + const holidayCaption = document.createElement("span"); + holidayCaption.className = "ui-field__label"; + holidayCaption.textContent = "공휴일 넣기(그 밖 법정공휴일은 늘 넣음)"; + holidayBox.append(holidayCaption, ...checks); + + const section = createPanelCard("공기 옵션"); + section.dataset.b09SchedulePanel = ""; + section.style.display = "none"; // 원천(서버 공기 자료)이 오면 보임 · hidden 은 공용 카드 flex 가 덮음 + section.append( + startField.root, + choose("climate_set", "기후 자료(달마다 덮개는 공기집계 표)"), + choose("rest", "쉬는 날", CHOICES.rest), + holidayBox, + number("hours", "일 작업시간(시간)", 0.5, 24), + pair( + number("units", "장비 기본 대수", 0.001, 1e4), + number("crew", "인력 기본 인원", 0.001, 1e4), + ), + pair( + number("prep_docs", "서류 · 착수 준비(일)", 0, 1000), + number("prep_setup", "공종별 준비(일)", 0, 1000), + ), + pair(number("tidy", "정리(일)", 0, 1000), number("etc", "기타 · 현장여건(일)", 0, 1000)), + pair( + number("rate_haul", "반영률 운반공(%)", 0, 100), + number("rate_base", "반영률 그 밖(%)", 0, 100), + ), + choose("machine_basis", "주 장비 기준", CHOICES.machine_basis), + choose("c_round", "중복일수 C 끝수", CHOICES.c_round), + choose("last_off", "마지막 달 비작업일", CHOICES.last_off), + ); + + syncNow = (mine, src) => { + section.style.display = src.climates ? "" : "none"; + for (const sync of syncs) sync(mine, src); + }; + syncNow(user().schedule || {}, {}); + return section; +} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Source.py b/B09_Estimation/draft/B09_Draft_Schedule_Source.py new file mode 100644 index 000000000..ad206aee7 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Source.py @@ -0,0 +1,200 @@ +"""B09 공기 초안 서버 원천(PLAN 252-2 · 사용자 161번 — draft 폴더째 지우면 사라짐). + +본 코드 훅 = `B09_Estimation_Router.payload` · `B09_Estimation_Store.clean_user` 가 이 모듈을 + 가져오지 못하면 건너뜀(공기 값 · 탭 없이 그대로). + inputs["schedule"] = 원천(줄마다 단위당 자원 · 공휴일 표 · 기후 기본값 · 착공 기본값) + sheets["schedule"] = 서버 정본 셈(`…_Calc.compute_schedule` · 화면은 TS 짝으로 즉시) +단위당 자원 = 설계내역서 항목 줄의 호표 1 단위 역전개(`B09_Estimation_Summary.expand`) — + 주 장비 = 단위당 시간이 가장 긴 장비 · 인력 = 단위당 인 합(직종 무관) +공휴일 = `B09_Draft_Schedule_Holidays.json`(2024~2040 · 법정 · 대체 · 확정 선거일 — 공개 `holidays` + 목록 0.106 에서 뽑음 · 2027 이후 선거일은 날짜가 안 정해져 뺌 · 달마다 사용자 덮개) +기후 기본값 = 참고 설계서(25 거창) 월별 강수일(일강수량 5mm 이상) · 혹서일(33℃ 이상) — + 고르기(264) = 참고 설계서 셋(거창 25 · 울진 24 · 울진 소광 25 「공사기간산정표」) · 0에서 직접 · + 달마다 사용자 덮개가 이김 +사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수 · 고르기 칸은 목록 안) +""" + +from __future__ import annotations + +import calendar +import datetime +import json +from pathlib import Path +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import num +from B09_Estimation.B09_Estimation_Summary import expand, hopyo_lines +from B09_Estimation.draft.B09_Draft_Schedule_Calc import HOLIDAY_KINDS, compute_schedule + +HOLIDAYS = Path(__file__).with_name("B09_Draft_Schedule_Holidays.json") +CLIMATE = { + "label": "경남 거창(참고 설계서 · 강수 = 일강수량 5mm 이상 · 혹서 = 33℃ 이상)", + "rain": [1.2, 1.4, 3.6, 4.5, 3.6, 4.4, 7.7, 6.9, 5.5, 3.1, 2.2, 1.2], + "heat": [0, 0, 0, 0, 0.5, 1.2, 5.9, 8.4, 0, 0, 0, 0], +} +#: 기후 자료 고르기(264) — 첫 줄 = 기본 · rain_note · heat_note = 공기집계 비고 +CLIMATES = { + "geochang25": { + **CLIMATE, + "name": "거창 25 참고", + "rain_note": "일강수량 5mm 이상", + "heat_note": "33℃ 이상", + }, + "uljin24": { + "name": "울진 24 참고", + "label": "경북 울진(참고 설계서 24 · 강수 = 일강수량 5mm 이상 · 혹서 없음)", + "rain": [0.6, 1.3, 2.9, 4.3, 3.2, 3.9, 7.4, 8.3, 4, 2.8, 2.6, 1.4], + "heat": [0] * 12, + "rain_note": "일강수량 5mm 이상", + "heat_note": "혹서 없음(참고 설계서)", + }, + "sogwang25": { + "name": "울진 소광 25 참고", + "label": "경북 울진 소광(참고 설계서 25 · 강수 = 일강수량 10mm 이상 · 혹서 없음)", + "rain": [2.1, 2, 3.3, 4, 2.1, 3.7, 6.3, 7.9, 5, 4.2, 3.6, 1.6], + "heat": [0] * 12, + "rain_note": "일강수량 10mm 이상", + "heat_note": "혹서 없음(참고 설계서)", + }, + "zero": { + "name": "0에서 직접", + "label": "직접 입력(달마다 강수 · 혹서 칸)", + "rain": [0] * 12, + "heat": [0] * 12, + "rain_note": "직접 입력", + "heat_note": "직접 입력", + }, +} +START_MONTH = 3 +NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy", "rate_haul", "rate_base") +POSITIVE = ("units", "crew") +#: 고르기 칸 — 기본값이 첫째 +CHOICES = { + "climate_set": tuple(CLIMATES), + "rest": ("sun", "satsun"), + "machine_basis": ("max", "sum"), + "c_round": ("none", "round1"), + "last_off": ("ratio", "all"), +} + + +def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]: + """설계내역서 항목 줄 → {줄 키: {machine: {key, name, hr} | None, labor: 인 | None}}(1 단위).""" + lines_of = hopyo_lines(sheets) + machines = {row["key"]: row for row in sheets.get("machine") or []} + out: dict[str, dict[str, Any]] = {} + for row in (sheets.get("bill") or {}).get("rows") or []: + key = str((row.get("ref") or {}).get("key") or "") + if row.get("kind") != "item" or key not in lines_of: + continue + used: dict[str, dict[str, float]] = {} + expand(lines_of, key, 1.0, used) + machine = None + hours = used.get("machine") or {} + if hours: + mkey, hr = max(hours.items(), key=lambda kv: kv[1]) + if hr > 0: + src = machines.get(mkey) or {} + name = " ".join(t for t in (src.get("name"), src.get("spec")) if t) or mkey + machine = {"key": mkey, "name": name, "hr": hr} + labor = sum((used.get("labor") or {}).values()) + # 264 주 장비 기준 「장비 시간 합」 = 차례로 일하는 장비 · n = 장비 수 + spent = [hr for hr in hours.values() if hr > 0] + out[row["key"]] = { + "machine": machine, + "labor": labor if labor > 0 else None, + "machine_sum": {"hr": sum(spent), "n": len(spent)} if machine else None, + } + return out + + +def holiday_table() -> dict[str, list[list[str]]]: + try: + return json.loads(HOLIDAYS.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def schedule_inputs(sheets: dict[str, Any], today: datetime.date | None = None) -> dict[str, Any]: + today = today or datetime.date.today() + return { + "units": unit_resources(sheets), + "holidays": holiday_table(), + "climate": CLIMATE, + "climates": CLIMATES, + "start": {"year": today.year + 1, "month": START_MONTH}, + } + + +def attach(inputs: dict[str, Any], sheets: dict[str, Any], user: dict[str, Any]) -> None: + """payload 훅 — 원천 · 서버 정본 셈을 끼움.""" + inputs["schedule"] = schedule_inputs(sheets) + bill = (sheets.get("bill") or {}).get("rows") or [] + sheets["schedule"] = compute_schedule(inputs["schedule"], bill, user.get("schedule") or {}) + + +def _positive(value: Any) -> float | None: + v = num(value) + return v if v is not None and v > 0 else None + + +def _twelve(values: Any) -> list[float | None] | None: + if not isinstance(values, list) or len(values) != 12: + return None + return [v if (v := num(x)) is not None and v >= 0 else None for x in values] + + +def clean_schedule(raw: Any) -> dict[str, Any]: + """user["schedule"] 다듬기 — 모르는 칸 버림 · 수 칸은 0 이상 수 · 빈 것 = 칸 없음.""" + if not isinstance(raw, dict): + return {} + out: dict[str, Any] = {} + for field in NUMBERS: + v = num(raw.get(field)) + if v is not None and 0 <= v <= (100 if field.startswith("rate") else 1000): + out[field] = v + for field in POSITIVE: + if (v := _positive(raw.get(field))) is not None and v <= 1e4: + out[field] = v + for field, allowed in CHOICES.items(): + if raw.get(field) in allowed: + out[field] = raw[field] + off = raw.get("holiday_off") if isinstance(raw.get("holiday_off"), list) else [] + kinds = [k for k in HOLIDAY_KINDS if k in off] + if kinds: + out["holiday_off"] = kinds + start = raw.get("start") if isinstance(raw.get("start"), dict) else {} + year, month = num(start.get("year")), num(start.get("month")) + if year and month and 2000 <= year <= 2100 and 1 <= month <= 12: + out["start"] = {"year": int(year), "month": int(month)} + day = num(start.get("day")) + if day and 1 < day <= calendar.monthrange(int(year), int(month))[1]: + out["start"]["day"] = int(day) + climate = raw.get("climate") if isinstance(raw.get("climate"), dict) else {} + kept = {k: v for k in ("rain", "heat") if (v := _twelve(climate.get(k)))} + if kept: + out["climate"] = kept + holiday = raw.get("holiday") if isinstance(raw.get("holiday"), dict) else {} + marks = { + str(k): v + for k, x in holiday.items() + if len(str(k)) == 7 and (v := num(x)) is not None and 0 <= v <= 31 + } + if marks: + out["holiday"] = marks + rows = {} + for key, cell in (raw.get("rows") or {}).items() if isinstance(raw.get("rows"), dict) else []: + if not isinstance(cell, dict) or not str(key) or len(str(key)) > 200: + continue + one = {} + rate = num(cell.get("rate")) + if rate is not None and 0 <= rate <= 100: + one["rate"] = rate + for field in ("units", "crew", "daily"): + if (v := _positive(cell.get(field))) is not None: + one[field] = v + if one: + rows[str(key)] = one + if rows: + out["rows"] = rows + return out diff --git a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts new file mode 100644 index 000000000..45f700bdf --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts @@ -0,0 +1,343 @@ +/* ============================================================================= + * B09_Draft_Schedule_UI.ts + * B09 위 탭 「공기」(PLAN 252-3 · 사용자 161번 · 초안) — 아래 탭 공기집계 · 공기산출. + * 등록 = 이 파일을 `…_UI_Shell` 이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 TABS · TAB_GROUPS · + * DRAFT(탭 짜기 · 셈 곁)에 넣음 — draft 폴더째 지우면 탭 · 셈이 함께 사라짐 + * 셈 = `B09_Draft_Schedule_Calc`(TS 짝 · 조작 즉시) · 원천 = 서버 `inputs.schedule` · 값 = `user.schedule` + * 왼쪽 「공기 옵션」 카드 = `B09_Draft_Schedule_Panel`(264-1 · 같은 값 · 비고는 고른 옵션을 따름) + * 고칠 칸(더블클릭) — 공기산출: 준비 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 기타 · 정리 / + * 공기집계: 착공 연월 · 강수 · 혹서(달마다 · 해 무관) · 법정공휴일(그 해 그 달 덮개 · 빈칸 = 자동) + * ========================================================================== */ + +import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; +import { num, type Row } from "../B09_Estimation_Calc_Base"; +import { + DRAFT, + parseNumber, + qty, + TAB_GROUPS, + TABS, + text, + type TabContext, + type TabId, + type TabTable, +} from "../B09_Estimation_UI_Tabs"; +import { climateBase, computeSchedule } from "./B09_Draft_Schedule_Calc"; +import { createSchedulePanel, syncSchedulePanel } from "./B09_Draft_Schedule_Panel"; + +const SUM = "schedule_sum" as TabId; +const CALC = "schedule_calc" as TabId; + +/** 사용자 공기 값 고침 → 캐시 · 바로 다시 셈 */ +function change(ctx: TabContext, fn: (s: Row) => void): void { + const next: Row = structuredClone(ctx.user.schedule || {}); + fn(next); + ctx.setUser({ ...ctx.user, schedule: next }); +} + +/** 칸 값 — 빈칸 = 지움(기본값) · 수 아님 = 무시 */ +function setNum(box: Row, key: string, value: string, min = 0, max = 1000): void { + const v = parseNumber(value); + if (v === undefined) return; + if (v === null || v < min || v > max) delete box[key]; + else box[key] = v; +} + +const pct = (n: number): string => String(Math.round(n * 10) / 10); + +/* ── 공기산출 ─────────────────────────────────────────────────────────── */ + +const CALC_COLUMNS: DataColumn[] = [ + text("name", "구분 · 명칭", 220), + text("spec", "규격", 120), + qty("qty", "시공량", 2), + text("unit", "단위", 56), + text("basis", "기준", 64), + text("who", "주 자원", 150), + qty("per", "단위당 시간 · 인", 4), + qty("put", "투입 대 · 인", 1), + qty("q", "시간당 작업량 Q", 2), + qty("daily", "일작업량", 2), + qty("rate", "반영률 %", 1), + qty("days", "작업일수", 1), + text("note", "비고", 220), +]; +const FIXED: Record = { + prep_docs: "days", + prep_setup: "days", + hours: "put", + etc: "days", + tidy: "days", +}; + +function itemNote(r: Row): string { + if (r.rate === 0) return "다른 공종과 겹침(반영 0 %)"; + if (r.basis === "연결 없음") return "설계내역서 [단가연결] 하면 자동 · 또는 일작업량 입력"; + if (r.basis === "장비") return `Q = 1 ÷ ${r.per} 시간`; + if (r.basis === "인력") return `${r.put}인 ÷ 단위당 ${r.per}인`; + return "일작업량 = 입력"; +} + +export function buildScheduleCalc(ctx: TabContext): TabTable | string { + const sch: Row | undefined = ctx.sheets.schedule; + if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; + const w: Row = sch.work; + const rows: DataRow[] = [ + { key: "h:prep", name: "1. 준비기간", lv: 1, head: true }, + { key: "prep_docs", name: "각종 서류 및 착수 준비", lv: 2, days: w.prep_docs }, + { key: "prep_setup", name: "공종별 준비기간", lv: 2, days: w.prep_setup, note: "자재운반 등" }, + { key: "h:work", name: "2. 공종별 작업일수", lv: 1, head: true }, + { + key: "hours", + name: "일 작업시간", + lv: 2, + put: w.hours, + unit: "시간", + note: "장비 일작업량 = 이 시간 × Q × 대수", + }, + ...(w.rows as Row[]).map((r) => + r.kind === "head" + ? { key: r.key, name: r.name, lv: r.lv + 1, head: true } + : { + ...r, + lv: r.lv + 1, + qty: r.qty, + per: r.per ?? "", + put: r.put ?? "", + q: r.q ?? "", + daily: r.daily ?? "", + days: r.days ?? "", + note: itemNote(r), + item: true, + }, + ), + { key: "etc", name: "기타(현장여건 감안)", lv: 2, days: w.etc }, + { + key: "subtotal", + name: "작업일수 소계", + lv: 2, + days: w.subtotal, + note: "ROUND(Σ 작업일수 + 기타, 0)", + head: true, + }, + { key: "tidy", name: "3. 정리기간", lv: 1, days: w.tidy, note: "1개월 범위 안", head: true }, + ]; + const missing = w.missing ? ` · 연결 없음 ${w.missing}줄` : ""; + return { + columns: CALC_COLUMNS, + rows, + total: { name: "총작업일수", days: w.total, note: "준비 + 소계 + 정리 → 공기집계로" }, + frozen: 1, + level: { key: "name", of: (i) => Number(rows[i].lv) || 1 }, + rowClass: (i) => (rows[i].head ? "is-title" : null), + // 사용자 덮개 칸(기본값과 다름) = 설계내역서 고친 칸 꼴 + cellClass: (i, key) => { + const k = String(rows[i].key); + const mine: Row = ctx.user.schedule || {}; + if (FIXED[k]) return FIXED[k] === key && num(mine[k]) !== null ? "is-edited" : null; + const own: Row = mine.rows?.[k] || {}; + const field = key === "put" ? (rows[i].basis === "장비" ? "units" : "crew") : key; + return rows[i].item && ["rate", "daily", "put"].includes(key) && num(own[field]) !== null + ? "is-edited" + : null; + }, + hint: `작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비(일 작업시간 × Q × 대수) · 인력(인원 ÷ 단위당 인) · 입력${missing}`, + editable: (i, key) => { + const r = rows[i]; + if (FIXED[String(r.key)]) return FIXED[String(r.key)] === key; + if (!r.item) return false; + if (key === "put") return r.basis === "장비" || r.basis === "인력"; + return key === "daily" || key === "rate"; + }, + onEdit: (i, key, value) => { + const r = rows[i]; + const k = String(r.key); + change(ctx, (s) => { + if (FIXED[k]) + return setNum(s, k, value, k === "hours" ? 0.5 : 0, k === "hours" ? 24 : 1000); + s.rows ??= {}; + const box: Row = (s.rows[k] ??= {}); + if (key === "rate") setNum(box, "rate", value, 0, 100); + else if (key === "daily") setNum(box, "daily", value, 1e-9, 1e9); + else if (key === "put") + setNum(box, r.basis === "장비" ? "units" : "crew", value, 1e-9, 1e4); + if (!Object.keys(box).length) delete s.rows[k]; + }); + }, + }; +} + +/* ── 공기집계 ─────────────────────────────────────────────────────────── */ + +const LINES: Array<[string, string, string, string]> = [ + // [키(달 값 칸), 구분, 세부, 비고] + ["days", "달력일수", "", ""], + ["rain", "기후여건 A", "강수일", "일강수량 5mm 이상(달마다 · 해 무관)"], + ["heat", "기후여건 A", "혹서일", "33℃ 이상"], + ["a", "기후여건 A", "소계", "ROUNDUP(강수 + 혹서, 1)"], + ["b", "법정공휴일 B", "", "일요일 + 공휴일 · 대체 · 선거일(풍선 = 이름 · 고치면 그 달 덮개)"], + ["c", "중복일수 C", "", "A × B ÷ 달력일수"], + ["off", "비작업일수", "", "ROUNDDOWN(A + B − C, 0)"], + ["avail", "작업가능일수", "", "달력일수 − 비작업일수"], + ["prep", "공사기간", "준비기간", "작업가능일을 준비 → 작업 → 정리 차례로 채움"], + ["work", "공사기간", "작업일수", ""], + ["tidy", "공사기간", "정리기간", ""], + ["used", "공사기간", "작업일수 계", ""], + ["off_used", "공사기간", "비작업일수", "다 쓴 달 = 전부 · 마지막 달 = 쓴 비율 올림"], + ["span", "공사기간", "공사일수", "작업일수 계 + 비작업일수"], +]; + +/** 264 고른 옵션을 따르는 비고 — 기본과 같으면 LINES 글 그대로(rain 은 늘 기후 자료 이름) */ +function optionNotes(src: Row, mine: Row, midMonth: boolean): Record { + const base = climateBase(src, mine); + const out: Record = {}; + if (base.label) + out.rain = `${base.rain_note ?? "일강수량 5mm 이상"}(달마다 · 해 무관) · 기본값 = ${base.label}`; + if (base.heat_note) out.heat = base.heat_note; + if (midMonth) { + out.days = "첫 달 = 착공일부터"; + out.a = "ROUNDUP(강수 + 혹서, 1) · 첫 달 = 날수 비례"; + } + const off: string[] = mine.holiday_off || []; + if (mine.rest === "satsun" || off.length) { + const rest = mine.rest === "satsun" ? "토 · 일" : "일요일"; + const cut = HOLIDAY_NAMES.filter(([k]) => off.includes(k)).map(([, n]) => n); + out.b = `${rest} + 공휴일${cut.length ? `(뺌 = ${cut.join(" · ")})` : ""} · 풍선 = 이름 · 고치면 그 달 덮개`; + } + if (mine.c_round === "round1") out.c = "A × B ÷ 달력일수 · 소수 1자리 반올림"; + if (mine.last_off === "all") out.off_used = "다 쓴 달 · 마지막 달 = 그 달 비작업 전부"; + return out; +} + +const HOLIDAY_NAMES: Array<[string, string]> = [ + ["substitute", "대체"], + ["election", "선거일"], + ["labor", "노동절"], + ["constitution", "제헌절"], +]; + +export function buildScheduleSum(ctx: TabContext): TabTable | string { + const sch: Row | undefined = ctx.sheets.schedule; + if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; + const cal: Row = sch.calendar; + const months: Row[] = cal.months; + const src: Row = ctx.payload.inputs?.schedule || {}; + const mine: Row = ctx.user.schedule || {}; + const day = cal.start.day > 1 ? `.${String(cal.start.day).padStart(2, "0")}` : ""; + const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}${day}`; + const notes = optionNotes(src, mine, Boolean(day)); + const columns: DataColumn[] = [ + text("label", "구분", 96), + text("sub", "세부", 96), + ...months.map((m, i) => ({ + ...qty(`m${i}`, `${m.month}월`, 2), + group: [`${m.year}년`], + width: 58, + })), + qty("sum", "계", 2), + text("note", "비고", 340), + ]; + const rows: DataRow[] = [ + { + key: "start", + label: "착공", + sub: start, + note: `준공 예정 ${cal.end} · 공사기간 ${cal.days}일(≒ ${pct(cal.months_n)}개월)`, + }, + ...LINES.map(([key, label, sub, note]) => { + const row: DataRow = { key, label, sub, note: notes[key] ?? note }; + let sum = 0; + months.forEach((m, i) => { + row[`m${i}`] = m[key]; + sum += Number(m[key]) || 0; + }); + row.sum = sum; + return row; + }), + ]; + const monthOf = (key: string): number => (key.startsWith("m") ? Number(key.slice(1)) : -1); + const ym = (m: Row): string => `${m.year}-${String(m.month).padStart(2, "0")}`; + return { + columns, + rows, + frozen: 2, + mergeSame: ["label"], + rowClass: (i) => (rows[i].key === "span" || rows[i].key === "start" ? "is-title" : null), + hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 「연.월」 또는 「연.월.일」(왼쪽 「공기 옵션」과 같은 값) · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", + tip: (i, key) => { + const m = months[monthOf(key)]; + if (!m || rows[i].key !== "b") return null; + const names = (m.names as string[]).join(" · "); + const own = num(mine.holiday?.[ym(m)]); + return [ + names || "공휴일 없음(일요일만)", + own !== null ? `자동 ${m.b_auto} → 덮개 ${own}` : "", + ] + .filter(Boolean) + .join(" · "); + }, + cellClass: (i, key) => { + const m = months[monthOf(key)]; + if (!m) return null; + const k = String(rows[i].key); + const own = + k === "b" + ? num(mine.holiday?.[ym(m)]) + : k === "rain" || k === "heat" + ? num(mine.climate?.[k]?.[m.month - 1]) + : null; + return own !== null ? "is-edited" : null; // 사용자 덮개(기본값과 다름) + }, + editable: (i, key) => { + const k = String(rows[i].key); + if (k === "start") return key === "sub"; + return (k === "rain" || k === "heat" || k === "b") && monthOf(key) >= 0; + }, + onEdit: (i, key, value) => { + const k = String(rows[i].key); + change(ctx, (s) => { + if (k === "start") { + const hit = /(\d{4})\D+(\d{1,2})(?:\D+(\d{1,2}))?/.exec(value); + if (!value.trim()) delete s.start; + else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) { + const [year, month, day] = [Number(hit[1]), Number(hit[2]), Number(hit[3] ?? 1)]; + const last = new Date(Date.UTC(year, month, 0)).getUTCDate(); + s.start = { year, month, ...(day > 1 && day <= last ? { day } : {}) }; + } + return; + } + const m = months[monthOf(key)]; + if (k === "b") { + s.holiday ??= {}; + setNum(s.holiday, ym(m), value, 0, 31); + if (!Object.keys(s.holiday).length) delete s.holiday; + return; + } + s.climate ??= {}; + const list: Array = s.climate[k] ?? Array(12).fill(null); + const v = parseNumber(value); + if (v === undefined) return; + list[m.month - 1] = v === null || v < 0 || v > 31 ? null : v; + if (list.some((x) => x !== null)) s.climate[k] = list; + else delete s.climate[k]; + if (!Object.keys(s.climate).length) delete s.climate; + }); + }, + }; +} + +/* ── 등록 ─────────────────────────────────────────────────────────────── */ + +if (!TABS.some(([id]) => id === SUM)) { + TABS.push([SUM, "공기집계"], [CALC, "공기산출"]); + TAB_GROUPS.push(["공기", [SUM, CALC]]); + DRAFT.builders[SUM] = buildScheduleSum; + DRAFT.builders[CALC] = buildScheduleCalc; + DRAFT.extend.push((payload, user, sheets) => { + const src = payload.inputs?.schedule; + if (src) sheets.schedule = computeSchedule(src, sheets.bill?.rows || [], user.schedule || {}); + }); + // 264-1 왼쪽 「공기 옵션」 카드 — 셈마다 지금 값으로 맞춤 + DRAFT.panels.push(createSchedulePanel); + DRAFT.extend.push(syncSchedulePanel); +} diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts index b83422733..e99da649e 100644 --- a/M02_MasterTemplete/M02_Labor_Values.ts +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -51,7 +51,7 @@ export function createValueControl( choices.length ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) : el("input", { - className: "ui-input m02-inputval__control", + className: "ui-input ui-input--sm m02-inputval__control", // 고르기 칸과 같은 높이(277-4) attrs: { type: range ? "number" : "text", ...(range?.min !== undefined ? { min: String(range.min) } : {}), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 310825c63..043ece45b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -3,6 +3,8 @@ * [로직 더하기] 창 — 한 목록에 구분(단가산출 · 단가승격 · 일위조합) 칸 · 치는 대로 걸러짐(찾기 단추 없음) · * 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} · * 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류) + * 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) + + * 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; @@ -38,6 +40,18 @@ export interface LibraryPick { /** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */ libOnly?: boolean; title?: string; + /** 목록 끝 덧붙일 줄(251 · 구조물 조합 · B09 호표) — kind = 종류 고르기 값 */ + extra?: Array<{ + kind: string; + key: string; + tag: string; + name: string; + sub: string; + unit: string; + onPick: () => void; + }>; + /** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */ + kindFilter?: Array<{ value: string; text: string }>; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -72,12 +86,21 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra let scopes: Scope[] = []; let scope: Scope | null = null; let libRows: { kind: "Kind_Combo"; row: LibraryRow }[] = []; + // 251 종류 고르기 — 값 "" = 전체 · logic · precombo · 덧붙인 줄 kind + const kindSel = lib?.kindFilter + ? createSelectField({ + options: [{ value: "", text: L("M01_All") }, ...lib.kindFilter], + compact: true, + label: "종류", + }) + : null; + const kindOk = (k: string): boolean => !kindSel?.select.value || kindSel.select.value === k; let loading = false; let seq = 0; /* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */ const finder = createFindList({ placeholder: tc("Pick_Search"), - filters: [sub.root, detail.root], + filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root], extra: [layerBar], }); @@ -85,18 +108,26 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra const q = finder.search.value.trim().toLowerCase(); const found = all.filter( (x) => + kindOk("logic") && (!sub.select.value || x.구분 === sub.select.value) && (!detail.select.value || x.상세구분 === detail.select.value) && matchLogic(finder.search.value, x).ok, // 이름 · 키 · 별칭 …(공용 · 208-1) ); const libFound = libRows.filter( (x) => + kindOk("precombo") && (!sub.select.value || x.row.구분 === sub.select.value) && (!detail.select.value || x.row.상세구분 === detail.select.value) && (!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)), ); - const total = all.length + libRows.length; - const shown = found.length + libFound.length; + const extra = (lib?.extra ?? []).filter( + (x) => + kindOk(x.kind) && + !sub.select.value && + (!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)), + ); + const total = all.length + libRows.length + (lib?.extra?.length ?? 0); + const shown = found.length + libFound.length + extra.length; finder.hint.textContent = loading ? tc("Loading") : shown @@ -132,6 +163,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra }, }; }), + ...extra.map((x) => ({ + ...x, + onPick: () => { + close(); + x.onPick(); + }, + })), ]); }; const loadLib = async (): Promise => { @@ -192,6 +230,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra find(); }); detail.select.addEventListener("change", find); + kindSel?.select.addEventListener("change", find); finder.search.addEventListener("input", find); layerBar.style.display = "none"; diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index b9b99b1d8..2c6b95a76 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -98,6 +98,10 @@ export interface CrossDesignOptions { ditchChoice?: boolean | null; /** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */ surfaceDropM?: number; + /** 물넘이포장 범위 · 맹암거 측점 — 측구 없음 · 노면 = `tiltPct`(비면 표준 횡단경사)로 지대 낮은 쪽 한 경사 + * (286-2 · 287-4 · 288-1). 짝: 파이썬 `compute_cross_design(tilted=…, tilt_pct=…)`. */ + tilted?: boolean; + tiltPct?: number | null; /** 이 측점의 평면 곡선반경(m) — 곡선부 확폭을 정하는 입력. 직선이면 null. */ planRadiusM?: number | null; /** 곡선 **바깥쪽**("left"/"right") — 확폭이 붙는 쪽(2026-09-06 사용자 확정). */ @@ -187,6 +191,11 @@ export interface CrossDesignResult { /** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */ berm?: { width_m: number; interval_m: number; slope_deg: number; depth_m?: number }; surface_drop_m?: number; + /** 물넘이포장 · 맹암거 자리(286-2 · 287-4) — 측구 없음 · 지대 낮은 쪽 한 경사. */ + tilted?: boolean; + tilt_pct?: number; + /** 물넘이 낮은 쪽 늘인 노면 끝(294-3) — 그 쪽 절토 사면 대신 노면이 원지반까지. */ + tilt_reach?: { side: "left" | "right"; offset_m: number; elevation_m: number }; pavement_thickness_m?: number; rock_boundary_offset_m?: number; } @@ -312,7 +321,9 @@ export function computeCrossDesign( } const pavedGroup = resolveGroup("paved", options.standard); // 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. - const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct; + const tiltPct = options.tilted && (options.tiltPct ?? 0) > 0 ? options.tiltPct! : null; + // 구조물 자리 경사(287-4 · 288-1 · 파이썬 짝) + const crossSlopePct = tiltPct ?? (paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct); const resolvedDitchSide = resolveDitchSide(sectionMode, options.ditchSide); const valid: Array<[number, number]> = samples @@ -367,6 +378,8 @@ export function computeCrossDesign( berm: options.berm ?? null, shoulderExtendLeftM: options.shoulderExtendLeftM ?? 0, shoulderExtendRightM: options.shoulderExtendRightM ?? 0, + tilted: Boolean(options.tilted), + groundSpan: [valid[0][0], valid[valid.length - 1][0]], }); // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). @@ -581,6 +594,19 @@ export function computeCrossDesign( design_line: designLine, cut_slope_segments: geometry.cutSlopeSegments(), }; + if (options.tilted) result.tilted = true; + // 낮은 쪽 늘인 노면 끝(294-3 · 파이썬 짝) + for (const side of ["left", "right"] as const) { + const reach = geometry.reach[side]; + if (!options.tilted || reach === undefined) continue; + const signed = side === "left" ? reach : -reach; + result.tilt_reach = { + side, + offset_m: round4(signed), + elevation_m: round4(geometry.roadZ(signed)), + }; + } + if (options.tilted && options.tiltPct != null) result.tilt_pct = round4(options.tiltPct); if (drop > 0) result.surface_drop_m = round4(drop); if (options.berm) { // 소단 제원을 설계에 되싣는다 — 짝 파이썬과 같은 까닭이고, **옹벽 의무 판정 diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index b67b2d67e..75358fc4b 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -145,6 +145,10 @@ export class SectionGeometry { /** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6). */ shoulderExtendLeftM?: number; shoulderExtendRightM?: number; + /** 물넘이포장 · 맹암거 자리 — 측구 없음 · 노면 = 지대 낮은 쪽 한 경사(286-2 · 287-4 · 파이썬 짝 `tilted`). */ + tilted?: boolean; + /** 원지반 샘플 범위(오프셋 최소 · 최대) — 낮은 쪽 연장 한계(294-3 · 파이썬 짝 `ground_span`). */ + groundSpan?: [number, number] | null; }) { const { group } = params; const halfRoad = group.road_width_m / 2; @@ -169,6 +173,12 @@ export class SectionGeometry { // 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). const slope = params.crossSlopePct / 100; this.slopePerOffset = params.ditchSide === "left" ? -slope : slope; + // 물넘이포장 · 맹암거 자리(287-4 · 288-1) — 노면 = 한 경사로 지대 낮은 쪽(노면 끝 원지반이 낮은 쪽) + // 으로 기움 · 측구 없음(아래 · 파이썬 짝) + if (params.tilted && params.groundAt) { + const lowLeft = params.groundAt(this.leftExtent) < params.groundAt(-this.rightExtent); + this.slopePerOffset = lowLeft ? -slope : slope; + } // 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토. const groundAt = params.groundAt; @@ -213,6 +223,8 @@ export class SectionGeometry { this.hasDitch = choice === null || bothFill ? autoDitch : choice; /** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */ this.ditchChoice = bothFill ? null : choice; + // 물넘이포장 · 맹암거 자리(286-2 · 288-1) — 측구가 안 섬 · 선택은 그대로(파이썬 짝) + if (params.tilted) this.hasDitch = false; // 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** // 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`. @@ -251,6 +263,36 @@ export class SectionGeometry { ]; } } + + // 물넘이 낮은 쪽 연장(294-3) — 노면이 내려가는 쪽 노면 끝 원지반이 노면보다 높으면(절토) 노면을 + // 같은 기울기로 늘여 원지반과 처음 만나는 자리까지(그 쪽 절토 사면 없음) · 반폭 안에서 못 만나면 + // 옛 길(절토 사면). ⚠ 파이썬 짝 `_extend_low_side`. + if (params.tilted && params.groundAt && Math.abs(this.slopePerOffset) > 1e-12) + this.extendLowSide(params.groundAt, params.groundSpan ?? null); + } + + /** 낮은 쪽 늘인 노면 끝(오프셋 절대값) — 쪽마다(294-3). */ + reach: { left?: number; right?: number } = {}; + + private extendLowSide(groundAt: (offsetM: number) => number, span: [number, number] | null) { + const side = this.slopePerOffset < 0 ? "left" : "right"; + const sign = side === "left" ? 1 : -1; + const extent = side === "left" ? this.leftExtent : this.rightExtent; + const limit = span ? (side === "left" ? span[1] : -span[0]) : extent + 50; + const gap = (dist: number) => groundAt(sign * dist) - this.roadZ(sign * dist); // > 0 = 절토 + if (gap(extent) <= 1e-3) return; + const step = 0.05; + let dist = extent; + while (dist < limit - 1e-9) { + const next = Math.min(dist + step, limit); + const g0 = gap(dist); + const g1 = gap(next); + if (g1 <= 0) { + this.reach[side] = dist + ((next - dist) * g0) / (g0 - g1); + return; + } + dist = next; + } } /** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */ @@ -412,6 +454,8 @@ export class SectionGeometry { } const role = side === "left" ? this.leftRole : this.rightRole; const dist = Math.abs(offsetM); + const reach = this.reach[side]; + if (reach !== undefined) return dist <= reach + 1e-9 ? this.roadZ(offsetM) : groundM; // 294-3 if (role === "cut") { const cross = this.cutCrossDist(side); if (cross !== null && dist >= cross) return groundM; @@ -502,6 +546,9 @@ export class SectionGeometry { } } } + // 늘인 노면 끝(294-3) + if (this.reach.left !== undefined) points.push(this.reach.left); + if (this.reach.right !== undefined) points.push(-this.reach.right); for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side); diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index e1a92706a..ba1c749a7 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -199,11 +199,12 @@ function sideSpec( } /** 도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 · 두께 - * (cm → m · 빈 값 45 cm) · 구조를 안 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */ + * (cm → m · 빈 값 45 cm) · 구조를 안 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림 · 길이 + * (`{head}_{n}_length_m` · 286-3) = 사면 따라 그릴 길이(그림 전용 · 비면 원지반까지). 파이썬 짝 `_chute`. */ export function chute( values: Record, head = "chute_add", -): { structure: string; thickness_m: number } | null { +): { structure: string; thickness_m: number; length_m?: number } | null { const pattern = new RegExp(`^${head}_(\\d+)_structure$`); const picked = Object.entries(values) .flatMap(([key, value]) => { @@ -215,7 +216,12 @@ export function chute( if (!picked.length) return null; const [n, structure] = picked[0]; const cm = num(values[`${head}_${n}_thickness`], null); - return { structure, thickness_m: round((cm && cm > 0 ? cm : CHUTE_THICKNESS_CM) / 100, 3) }; + const found = { + structure, + thickness_m: round((cm && cm > 0 ? cm : CHUTE_THICKNESS_CM) / 100, 3), + }; + const length = num(values[`${head}_${n}_length_m`], null); + return length && length > 0 ? { ...found, length_m: round(length, 3) } : found; } /** 도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2). */ @@ -499,7 +505,7 @@ function fordPavementSet( { type: "ford_pavement", span_m: span, - // 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). + // 월류 높이 — 노면 위 점선(287-4). 없으면 안 그린다(수치를 지어내지 않는다). depth_m: depth && depth > 0 ? depth : null, slope_pct: used !== null ? round(Math.tan((used * Math.PI) / 180) * 100, 4) : legacy, defaulted: defaulted(values, [ @@ -574,6 +580,12 @@ export function buildCulvertSets( return sets; } +/** 차례 비교(파이썬 튜플 `<` 와 같음) — 처음 다른 자리에서 작은 쪽이 먼저. */ +function rankBefore(a: readonly number[], b: readonly number[]): boolean { + const at = a.findIndex((value, index) => value !== b[index]); + return at >= 0 && a[at] < b[at]; +} + /** 측점 자료에 세트를 얹는다(파이썬 `attach_culvert_sets`). 얹은 개수를 돌려준다. */ export function attachCulvertSets( sections: Array>, @@ -601,25 +613,35 @@ export function attachCulvertSets( for (const section of sections) { const chainage = num(section.chainage_m, null); if (chainage === null) continue; + // ⚠ 키가 다른 시설은 같은 측점에 함께 얹는다(299-1 · 짝: 파이썬 `attach_culvert_sets`) — 첫 시설 + // 하나에서 멈추면 물넘이포장 연동 범위 안 측점의 **자기 관**이 빠졌다. 같은 키끼리는 소유 측점 + // 것 → 흙막이 아닌 자기 시설 → 가까운 것 → 관 지점 차례. + const best = new Map(); + let order = 0; for (const [pipeChainage, spec] of sets) { + order += 1; // 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다. let reach = CHAINAGE_TOLERANCE_M; if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2; - if (Math.abs(chainage - pipeChainage) <= reach || owner.get(pipeChainage) === chainage) { - // ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬 - // `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면 - // 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**. - section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = { - ...spec, - chainage_m: pipeChainage, - }; - // 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음. - if (spec.guard) - section.culvert = { ...(spec.guard as CulvertSetSpec), chainage_m: pipeChainage }; - attached += 1; - break; + const owned = owner.get(pipeChainage) === chainage; + if (!(Math.abs(chainage - pipeChainage) <= reach || owned)) continue; + const entries: Array<[string, CulvertSetSpec, number]> = [ + [SECTION_KEYS[String(spec.type)] ?? "culvert", spec, 0], + ]; + // 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`) · 자기 관과 부딪히면 관 먼저. + if (spec.guard) entries.push(["culvert", spec.guard as CulvertSetSpec, 1]); + for (const [key, value, isGuard] of entries) { + const rank = [owned ? 0 : 1, isGuard, Math.abs(chainage - pipeChainage), order]; + const held = best.get(key)?.rank; + // ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의 절반까지 + // 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 + // 측점에서 센다**. + if (!held || rankBefore(rank, held)) + best.set(key, { rank, value: { ...value, chainage_m: pipeChainage } }); } } + for (const [key, { value }] of best) section[key] = value; + if (best.size) attached += 1; } return attached; } diff --git a/common_util/common_util_mass_haul.css b/common_util/common_util_mass_haul.css index 74b2e1276..ce334dbb9 100644 --- a/common_util/common_util_mass_haul.css +++ b/common_util/common_util_mass_haul.css @@ -386,19 +386,25 @@ display: inline-flex; align-items: center; gap: var(--spacing-4, 4px); - padding: 2px var(--spacing-8, 8px); - border: 1px solid var(--color-border); + /* 큰 알약(289) — 글자 · 여백 한 단 위 */ + padding: var(--spacing-4, 4px) var(--spacing-12, 12px); + /* 붉은 계통(289-2) — 테두리 · 글자 · 옅은 바탕 = 위험색 섞음(밝음 · 어두움 같이) */ + border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent); border-radius: var(--radius-pills, 999px); - background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); - color: var(--color-text-body); - font-size: var(--text-caption); - line-height: 1.3; + background: color-mix( + in srgb, + var(--color-danger) 6%, + color-mix(in srgb, var(--color-surface-raised) 88%, transparent) + ); + color: color-mix(in srgb, var(--color-danger) 70%, var(--color-text-body)); + font-size: var(--pill-font-size-lg); + line-height: var(--leading-body-sm); white-space: nowrap; pointer-events: auto; } .mass-haul-badge em { - color: var(--color-text-secondary); + color: color-mix(in srgb, var(--color-danger) 55%, var(--color-text-secondary)); font-style: normal; } @@ -406,6 +412,12 @@ font-weight: var(--font-weight-bold, 700); } +/* 음수 = 더 진하게 — 테두리 = 위험색 그대로 · 바탕 짙게 · 값 = 위험색 더 많이(대비 4.5 : 1 지킴) */ +.mass-haul-badge.is-over { + border-color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface-raised)); +} + .mass-haul-badge.is-over strong { - color: var(--color-danger, #f43f5e); + color: color-mix(in srgb, var(--color-danger) 85%, var(--color-text-body)); } diff --git a/common_util/common_util_node_bundle.py b/common_util/common_util_node_bundle.py index 7a5a6247e..1a5e6e022 100644 --- a/common_util/common_util_node_bundle.py +++ b/common_util/common_util_node_bundle.py @@ -23,8 +23,9 @@ ROOT = Path(__file__).resolve().parents[1] SOURCE_DIRS = ("B05_Profile", "B06_Section", "B08_Quantity", "common_util") # 번들에 박히는 자료(JSON) — 벽 전면 기울기 표준경사 표·기슭막이 치수 표(2026-09-14 B5). 표만 바뀌어도 다시 빌드. SOURCE_DATA_DIRS = ("resources/data_masonry",) -# 폴더째 훑지 않는 자리 — B07 은 CAD 앱(openwebcad · node_modules)이 함께 살아 맨 위 TS 만 본다. -SOURCE_GLOBS = ("B07_DesignDetail/*.ts",) +# 폴더째 훑지 않는 자리 — B07 은 CAD 앱(openwebcad · node_modules)이 함께 살아 맨 위 TS 만 본다 · +# 구조물 명세(A00 design)는 B07 CAD 칸 옵션 줄 번들이 끌어 씀(166-1). +SOURCE_GLOBS = ("B07_DesignDetail/*.ts", "A00_Common/design/*.ts") # 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다. BUILD_TIMEOUT_S = 300 RUN_TIMEOUT_S = 600 diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index 79eb2cdd3..63d2b8622 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -1,8 +1,12 @@ -"""구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안이면 그 측점에 붙음(234-1). +"""구조물 · 관 기준 측점 = 정수 m(반올림 · .5 는 올림) · 노선 끝 안쪽(요구 167 · 272). -TS 짝 = `common_util/common_util_structure_station.ts`(화면 입력 · 끌기 · 방향키 · 정수 맞춤 단추). +측점 값(측점 + 거리 · 누가거리)에 소수를 안 씀 — 일반 측점(격자)과 같은 m 면 서버 측점 +만들기가 한 측점으로 합침(구조물 측점이 위 · `B05_Profile_Engine_Sections_Core`). +234-1 의 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수 · 사용자 167). +TS 짝 = `common_util/common_util_structure_station.ts` +(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김). 거울 시험 = `resources/tester/test_234_1_structure_station.py`. -일반 측점 = 격자(간격) · 시종점 — 서버 자동 배치 · 노선 재투영이 쓴다. +일반 측점 = 격자(간격) · 시종점 — 끝(노선 길이)을 넘지 않게 자르는 데만 씀. """ from __future__ import annotations @@ -17,8 +21,6 @@ from config.config_system_design import SECTION_STATION_INTERVAL_M _Candidate = TypeVar("_Candidate") -# 일반 측점에 붙는 폭(m) — 이 안이면 그 측점 값(끝 측점처럼 정수가 아니어도). -GENERAL_SNAP_M = 1.0 _EPS = 1e-6 @@ -32,27 +34,14 @@ def general_stations(total_m: float, interval_m: float) -> list[float]: return values -def _near_general(value: float, stations: Sequence[float]) -> float | None: - best: float | None = None - for station in stations: - gap = abs(station - value) - if gap < GENERAL_SNAP_M - _EPS and (best is None or gap < abs(best - value)): - best = float(station) - return best - - def snap_structure_station(chainage_m: float, stations: Sequence[float]) -> float: - """기준 측점 맞춤 — 일반 측점 1 m 안이면 그 측점 · 아니면 정수 m(.5 는 올림) · 정수가 일반 - 측점 1 m 안이면 그 측점. 두 번 맞춰도 같다.""" + """기준 측점 = 정수 m(.5 는 올림) · 0 ~ 노선 끝(소수 끝은 그 아래 정수) 안 · 멱등.""" value = float(chainage_m) if not math.isfinite(value): return value - direct = _near_general(value, stations) - if direct is not None: - return direct + end = max(stations) if stations else math.inf whole = float(math.floor(value + 0.5)) - near = _near_general(whole, stations) - return whole if near is None else near + return max(0.0, min(whole, float(math.floor(end + _EPS)))) def route_general_stations(total_m: float) -> list[float]: @@ -71,7 +60,7 @@ def snap_candidates( snapped: list[_Candidate] = [] taken: set[float] = set() for candidate in candidates: - at = min(snap_structure_station(candidate.chainage_m, stations), total_m) + at = snap_structure_station(candidate.chainage_m, stations) if at in taken: continue taken.add(at) diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 4c5cc21d2..ac0cb7d52 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -1,16 +1,26 @@ /* ============================================================================= * common_util/common_util_structure_station.ts - * 구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안이면 그 측점에 붙음(234-1). + * 구조물 · 관 기준 측점 = 정수 m(반올림 · .5 는 올림) · 노선 끝 안쪽(요구 167 · 272). * + * 측점 값(측점 + 거리 · 누가거리)에 소수를 안 씀 — 일반 측점(격자)과 같은 m 면 서버 측점 만들기가 + * 한 측점으로 합침(구조물 측점이 위). 234-1 의 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수 · 사용자 167). * 파이썬 짝 = `common_util/common_util_structure_station.py`(서버 자동 배치 · 노선 재투영). * 거울 시험 = `resources/tester/test_234_1_structure_station.py`. - * 일반 측점 = 격자 · 시종점(구조물 측점 kind "irregular" 은 뺌) — 상세를 읽을 때 한 번 둔다. + * 일반 측점 = 격자 · 시종점(구조물 측점 kind "irregular" 은 뺌) — 끝(노선 길이)을 넘지 않게 자르는 데만 · + * 상세를 읽을 때 한 번 둔다. + * + * 측점 한 벌(272) — 측점을 심는 관 · 구조물(`plantedChainages`) = 서버 `resolve_extra_stations` + * (B05_Profile_Engine_Sections.py) 짝 · 같은 m 면 측점 하나 · 구조물이 위(`oneStationList`) = 서버 + * `_merge_irregular_into_longitudinal`(B05_Profile_Router_Confirm.py) 짝. 거울 시험 = + * `resources/tester/test_272_1_station_list.py`. 종단 그래프 · 3D · 계획선 측점 줄(표 · ▲▼)이 + * 이 목록 하나를 씀. * ========================================================================== */ -/** 일반 측점에 붙는 폭(m) — 이 안이면 그 측점 값(끝 측점처럼 정수가 아니어도). */ -export const GENERAL_SNAP_M = 1; const EPS = 1e-6; +/** 같은 측점으로 볼 거리(m) — 서버 측점 만들기의 격자 붙임 폭(`Engine_Sections_Core`)과 같음. */ +export const SAME_STATION_M = 0.005; + let general: number[] = []; /** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). */ @@ -24,46 +34,170 @@ export function setGeneralStations( export const generalStations = (): readonly number[] => general; -function nearGeneral(value: number, stations: readonly number[]): number | null { - let best: number | null = null; - for (const station of stations) { - const gap = Math.abs(station - value); - if (gap < GENERAL_SNAP_M - EPS && (best === null || gap < Math.abs(best - value))) - best = station; - } - return best; -} - -/** 기준 측점 맞춤 — 일반 측점 1 m 안이면 그 측점 · 아니면 정수 m(.5 는 올림) · 정수가 일반 측점 - * 1 m 안이면 그 측점(끝 119.8 옆 119 → 119.8). 두 번 맞춰도 같다. */ +/** 기준 측점 = 정수 m(.5 는 올림) · 0 ~ 노선 끝(소수 끝은 그 아래 정수) 안. 두 번 맞춰도 같다. */ export function snapStructureStation( chainageM: number, stations: readonly number[] = general, ): number { if (!Number.isFinite(chainageM)) return chainageM; - const direct = nearGeneral(chainageM, stations); - if (direct !== null) return direct; + const end = stations.length ? Math.max(...stations) : Infinity; const whole = Math.floor(chainageM + 0.5); - return nearGeneral(whole, stations) ?? whole; + return Math.max(0, Math.min(whole, Math.floor(end + EPS))); } -/** 방향키 한 걸음(1 m) — 붙기가 제자리로 되돌리면 한 걸음 더(끝 119.8 → 118). 노선 밖이면 제자리. */ +/** 방향키 한 걸음(1 m) — 맞춘 값이 제자리면 한 걸음 더 · 노선 밖이면 제자리. */ export function stepStructureStation( fromM: number, direction: 1 | -1, stations: readonly number[] = general, ): number { - const end = stations.length ? Math.max(...stations) : Infinity; for (let steps = 1; steps <= 3; steps += 1) { const next = snapStructureStation(fromM + direction * steps, stations); - if (next < 0 || next > end + EPS) return fromM; if ((next - fromM) * direction > EPS) return next; } return fromM; } -/** 이미 맞춘 값인가(정수 맞춤 단추가 옮길 것을 가름). */ +/** 이미 정수 m 인가(옛 소수 자료를 옮길 것을 가름). */ export const isSnappedStation = ( chainageM: number, stations: readonly number[] = general, ): boolean => Math.abs(snapStructureStation(chainageM, stations) - chainageM) < EPS; + +/* ── 측점을 심는 자리(272 · 서버 `resolve_extra_stations` · `is_station_planting_type` 짝) ── */ + +export interface PlantingPipe { + chainage_m: number; + facility?: string | null; + start_m?: number | null; + end_m?: number | null; +} + +export interface PlantingStructure { + type_id: string; + chainage_m?: number | null; + start_m?: number | null; + end_m?: number | null; +} + +export interface PlantingType { + type_id: string; + group: string; + placement: string; + managed_by?: string | null; +} + +/** 측점 하나 — 구간형(독립 기슭막이 · C · D군)은 시작 · 기준 · 끝 셋, 그 밖은 기준 하나. */ +export interface StationMark { + chainage_m: number; + role: "start" | "anchor" | "end"; +} + +function marksOf( + start: number | null | undefined, + anchor: number | null | undefined, + end: number | null | undefined, + interval: boolean, +): StationMark[] { + const marks: Array<[StationMark["role"], number | null | undefined]> = interval + ? [ + ["start", start], + ["anchor", anchor], + ["end", end], + ] + : [["anchor", anchor]]; + return marks.flatMap(([role, at]) => + typeof at === "number" && Number.isFinite(at) ? [{ chainage_m: at, role }] : [], + ); +} + +/** 관 시설 하나가 심는 측점 — 독립 기슭막이 = 시작 · 기준 · 끝 · 그 밖(물넘이포장 포함 · 286-1) = 기준. */ +export function pipeStationMarks(pipe: PlantingPipe): StationMark[] { + const facility = pipe.facility ?? "pipe"; + return marksOf(pipe.start_m, pipe.chainage_m, pipe.end_m, facility === "revetment"); +} + +/** 구조물 하나가 심는 측점 — A군 = 기준 · C · D군 구간형 = 시작 · 기준 · 끝 · 다른 정본 소관 · 그 밖 = 없음. */ +export function structureStationMarks( + structure: PlantingStructure, + type: PlantingType | undefined, +): StationMark[] { + if (!type || type.managed_by) return []; + const interval = type.placement === "interval"; + if (type.group !== "A" && !(interval && (type.group === "C" || type.group === "D"))) return []; + const anchor = structure.chainage_m ?? structure.start_m; + return marksOf(structure.start_m, anchor, structure.end_m, interval); +} + +/** 측점을 심는 자리 전부(오름차순 · 겹침 그대로) — 서버 `resolve_extra_stations` 의 누가거리. */ +export function plantedChainages( + pipes: ReadonlyArray, + structures: ReadonlyArray, + types: ReadonlyArray, +): number[] { + const byId = new Map(types.map((type) => [type.type_id, type])); + return [ + ...pipes.flatMap(pipeStationMarks), + ...structures.flatMap((structure) => + structureStationMarks(structure, byId.get(structure.type_id)), + ), + ] + .map((mark) => mark.chainage_m) + .sort((a, b) => a - b); +} + +/* ── 측점 한 벌(272 · 서버 `_merge_irregular_into_longitudinal` 짝) ── */ + +export interface ListedStation { + chainage_m: number; + kind?: string; + /** 구조물 측점이 얹힌 격자 자리 — 구조물이 떠나면 일반 측점으로 돌아옴(서버 표시). */ + grid?: boolean; + structure?: string; +} + +/** + * 측점 한 벌 = 서버 측점 중 일반 측점(격자 · 시종점 · 구조물이 떠난 격자 자리는 글 떼고 일반으로) + + * 지금 심긴 구조물 측점(`planted`). 같은 m 면 구조물 측점 하나(일반은 숨김) · 서버의 구조물 측점은 + * 지금 목록이 갈음(옮긴 관의 옛 자리가 안 남음). + */ +export function oneStationList( + server: ReadonlyArray, + planted: ReadonlyArray

    , +): Array { + const general = server + .filter((station) => station.kind !== "irregular" || station.grid) + .filter((station) => + planted.every((mark) => Math.abs(mark.chainage_m - station.chainage_m) >= SAME_STATION_M), + ) + .map((station) => { + if (!station.grid && station.structure === undefined) return station; + const { grid: _grid, structure: _structure, ...rest } = station; + return { ...rest, kind: station.kind === "irregular" ? "regular" : station.kind } as S; + }); + return [...general, ...planted].sort((a, b) => a.chainage_m - b.chainage_m); +} + +/** + * 사라진 구조물 측점(`gone`)의 계획선 꼭짓점을 새 측점(`born`)으로 옮기거나 지운다 — 서버 + * `_follow_moved_stations`(B05_Profile_Router_Confirm.py) 짝: 0.006m 안 반올림 차이 · 옛 소수 측점의 + * 정수 m 맞춤 자리(272)면 높이 그대로 옮기고, 나머지는 지움(측점 없는 꼭짓점이 계획선을 꺾지 않게). + */ +export function followMovedNodes( + nodes: ReadonlyArray, + gone: ReadonlyArray, + born: ReadonlyArray, +): N[] { + const target = (old: number): number | null => { + const near = born.find((value) => Math.abs(value - old) < 0.006); + if (near !== undefined) return near; + const snapped = Math.floor(old + 0.5); + return born.some((value) => Math.abs(value - snapped) < EPS) ? snapped : null; + }; + return nodes.flatMap((node) => { + const old = gone.find((value) => Math.abs(value - node.chainage_m) < 0.006); + if (old === undefined) return [node]; + const to = target(old); + return to === null ? [] : [{ ...node, chainage_m: to }]; + }); +} diff --git a/common_util/common_util_structure_underdrain.ts b/common_util/common_util_structure_underdrain.ts new file mode 100644 index 000000000..ca2a91d44 --- /dev/null +++ b/common_util/common_util_structure_underdrain.ts @@ -0,0 +1,66 @@ +/* ============================================================================= + * common_util/common_util_structure_underdrain.ts + * 맹암거(cross_underdrain · A군 점형) 제원을 횡단 측점에 얹는다(288-1). + * + * 기준 측점(0.02 m 안) 횡단 dict 에 `underdrain` = 높이 · 경사 — 횡단도가 돌 무늬 형상을 그리고, + * 설계 계산은 그 측점을 측구 없이 지대 낮은 쪽 한 경사로 기울임(`tilted`). + * 파이썬 짝 = `B06_Section/B06_Section_Engine_Tilt.py`(`load_underdrains` · `attach_underdrains`). + * ========================================================================== */ + +export const UNDERDRAIN_TYPE_ID = "cross_underdrain"; +const EDGE_TOLERANCE_M = 0.02; + +/** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 없으면 표준 횡단경사). */ +export interface UnderdrainSpec { + structure_id: string | null; + chainage_m: number; + height_m: number | null; + slope_pct: number | null; +} + +export interface UnderdrainInput { + structure_id?: string | null; + type_id: string; + chainage_m?: number | null; + start_m?: number | null; + options?: Record | null; +} + +const positive = (value: unknown): number | null => { + const parsed = value === "" || value === null || value === undefined ? NaN : Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +}; + +/** 구조물 목록의 맹암거 제원(파이썬 `load_underdrains` 짝). */ +export function underdrainSpecsFrom(structures: readonly UnderdrainInput[]): UnderdrainSpec[] { + return structures.flatMap((structure) => { + const at = structure.chainage_m ?? structure.start_m; + if (structure.type_id !== UNDERDRAIN_TYPE_ID || typeof at !== "number") return []; + const options = structure.options ?? {}; + return [ + { + structure_id: structure.structure_id ?? null, + chainage_m: at, + height_m: positive(options.culvert_height_m), + slope_pct: positive(options.culvert_slope_pct), + }, + ]; + }); +} + +/** 기준 측점 횡단 dict 에 `underdrain` 을 얹는다(옛 값은 걷음) — 얹은 개수(파이썬 `attach_underdrains` 짝). */ +export function attachUnderdrainSpecs( + sections: Array>, + specs: readonly UnderdrainSpec[], +): number { + let attached = 0; + for (const section of sections) { + delete section.underdrain; + const chainage = Number(section.chainage_m); + const hit = specs.find((spec) => Math.abs(spec.chainage_m - chainage) <= EDGE_TOLERANCE_M); + if (!hit) continue; + section.underdrain = { ...hit }; + attached += 1; + } + return attached; +} diff --git a/config/google_token.json b/config/google_token.json index 73d739372..6bfef3732 100644 --- a/config/google_token.json +++ b/config/google_token.json @@ -1 +1 @@ -{"token": "ya29.a0AdMD6EhuJehKjsVhSLOpAEGES3oFxVfoH561Xmota4ZrCMiZHRH_YikXuFepOlH0oxU2TxFifQY0a6V4TODKKv_zM5_nN3doOzIZ4BHokpes8hRB2zcic8M9_fjG_B0S1YgjNgB1UDM6v-HfoGsT_RJBF2AHXIBMCytswZBxHIrCPvc5VzBT51RVW3aClahyCe2TLUIaCgYKAfQSARMSFQHGX2MijKpjVLthV_s9Le7_B9DCyw0206", "refresh_token": "1//0eg8jt_r9Dr7jCgYIARAAGA4SNwF-L9IrAQ_ulD6kqQ_3M9SKvEuCvFjSu2LN8_q6K_AMPossxCKzXp7ycUzTTpHcdH_3vZJclSY", "token_uri": "https://oauth2.googleapis.com/token", "client_id": "222243854357-eg5aquofa0epa3ri78k5ngpfclroc7rg.apps.googleusercontent.com", "client_secret": "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", "scopes": ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/script.projects"], "universe_domain": "googleapis.com", "account": "", "expiry": "2026-09-05T23:33:00Z"} \ No newline at end of file +{"token": "ya29.a0AX07Cms_Mr_cIxMqI7d3tZp5yvLwc557onouF07rOxMcSChG2hhLwughRmStGGgViEQK7Wf81_mW-u6W6y7_SPMnXy2BPI0KjSpBd8W9c6rELzIgW7w-kUBfOqDMaa-Gc0htcHUTISoPtNbLDbUMFNrmKXlVqkp80DM-YKlQc7pSnM2XMHqjzOzQXC5NsJAaQ0gd4HIaCgYKAawSARMSFQHGX2MijTO-loPa-7a97WycOEat1A0206", "refresh_token": "1//0eA6qtRCSTkERCgYIARAAGA4SNwF-L9Ir5SgGy2MpsHmXX1uNa8hJc_-i_zkyDdRh2-TuplZyeayJKwIegMpeWLjvwtl-I-9RTgA", "token_uri": "https://oauth2.googleapis.com/token", "client_id": "222243854357-eg5aquofa0epa3ri78k5ngpfclroc7rg.apps.googleusercontent.com", "client_secret": "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", "scopes": ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/script.projects"], "universe_domain": "googleapis.com", "account": "", "expiry": "2026-10-12T08:55:12Z"} \ No newline at end of file diff --git a/docs/raw/plans/2026-10-11_plan_250-252.md b/docs/raw/plans/2026-10-11_plan_250-252.md new file mode 100644 index 000000000..5d346ddea --- /dev/null +++ b/docs/raw/plans/2026-10-11_plan_250-252.md @@ -0,0 +1,26 @@ +# 2026-10-11 이관 — 250 · 251 · 252 + +## 250. B08 거리이정표 — 수정 글 · 상자 하나 = 항목 하나 · 폭 전체 · 줌 · 지도 높이 · 도로 구분 = 선 자리 (브레인 직접) +정함 = 사용자 149 · 150 · 152번 · 「이번부터는 너가 직접 작업」 +- [x] 250-1 「고침」 → 「수정」 · 등록 모달 [+ 줄] 없앰(상자 하나 = 항목 하나 · 같은 이름 합침 없음) · 기준위치 = 항목 · 목적지 없음 · 페이지 폭 전체(다이어그램 · 표) · 다이어그램 틀 높이 고정 + 틀 비율 그림 범위 + 처음 배율 = 상자 범위 맞춤(사용자 줌 뒤엔 그 배율) · 카카오 지도 높이 어디서나 480px · 도로 구분 = 선 자리(거래처 → 기준점 2차 · 기준점 → 시점 1차 · 그 밖 포장 · 비포장만 고름 · 파이썬 · TS 짝) — 검증완료(test_250_1 4 + 244_* 63 · 249_1 통과 · typecheck · 화면 = 글자 높이 15.8 → 49(배율 1.59) · 표 폭 = 틀 폭) +- [x] 250-2 (153번) 카카오 지도 = 처음 한 번만 맞춤 · 소재지 · 기준점 고르기 · 끌기 뒤 보기(중심 · 줌) 그대로(보기 밖 표지만 그쪽으로 옮김) · 상자 색(공사지 = 강조색 · 기준점 = 경고색 · 거래처 = 성공색 · 테마 변수 옅은 채움 + 테두리) · 거래처 · 기준점 상자 끌어 옮김(자리 = 데이터 pos · [저장] 때 서버 · 기준점 옮기면 거친 거래처 따라감) + [자동 정렬](끌어 놓은 자리 지움) — 검증완료(test_250_1 6 · 244_* 통과 · typecheck · 화면 = 끌기 -45,40 이동 · 끌고 난 누름 모달 안 뜸 · 밝음 · 어두움 색 확인) +- [x] 250-3 (151 · 154번) 선 연결 = 연결 점 ● 끌기 — 거래처 상자 오른쪽 가운데 ● 를 끌어 기준점 상자 = 기준점 거침 · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 km 비움 + 길찾기 한 번(km · 경로 그림) · 등록 모달 「기준점 거침」 체크 없앰 — 검증완료(test_250_1 7 · 244_* 통과 · typecheck · 화면 = 공사지에 놓음 → 포장 · 카카오 「도착 주변 도로 없음」 안내 / 기준점에 다시 놓음 → 2차포장 10.5 km 자동) +- [x] 250-4 (155번) 공사지 · 기준점 [+] 없앰 → 다이어그램 왼쪽 위 [요소 추가]([자동 정렬] 오른쪽) · 등록 = 상자만(새 거래처 = 선 없음 · linked false · 연결 점 ● 로 이음) · 연결선 모달 [수기 입력](카카오 길찾기 연결 끊음 · 자동 값 지움 · 길찾기 · 지도 숨김 · 풀면 다시 길찾기 · manual) · 안 이은 거래처 = 구간 km 안 셈(파이썬 · TS 짝) · 서버 모델 linked · manual — 검증완료(test_250_1 8 · 244_* 통과 · typecheck · 화면 = 요소 추가 → 선 없는 상자 → ● 를 기준점에 놓아 선 생김 → 연결선 모달 수기 입력 켬 = 길찾기 · 지도 숨김) +- [x] 250-5 (156번) 거래처 경로 하위 탭 = 그림만 — [다음 지도에서 길찾기] 새 창 · 탭 안 카카오 지도 칸 없앰(길찾기 · 지도 = 연결선 모달 · 그림 = 서버 자동 캡처 · 올리기 · 되돌리기 · 지우기는 그대로) · 쓰이지 않게 된 routeMapView · kakaoRouteUrl · routePoints 지움 — 검증완료(244_* · 250_1 74 통과 · typecheck · 옛 경로 지도 시험 = 같은 SDK 싣기 · 안내 길(editMap)로 바꿈) +- [x] 250-6 (157번) [자동 정렬] · [요소 추가] = 다이어그램 왼쪽 아래 · 메인 탭 크기 단추 · 거래처 경로 탭 = 머리 줄(거래처 이름 → 목적지 · 주소 왼 · [그림 변경] · [초기화] 오른 · 글자 = 메인 탭 크기) + 길찾기 지도(연결선 모달과 같은 카카오 지도 · 표지 끌어 소재지 옮김) · 사용자 그림 올리면 그 그림 · [초기화] = 사용자 그림 지우고 지도로 · [그림 지우기] · 안내 줄 없앰 — 검증완료(test_250_1 10 · 244_* · 249_1 통과 · typecheck · 화면 = 단추 높이 32 = 메인 탭 32 · 경로 탭 지도 · 글자 14px) + +## 251. 거리이정표 운반 행 · 단가연결 (브레인 직접 · 사용자 159 · 160번 · 조사 AI) +근거 = `docs/raw/요구_1010_2/조사_AI_단가연결.md` 2장 정함 +- [x] 251-1 운반 묶음 함수(B08_Quantity_Haul · 총괄자재 → 묶음 행 · 장비 두 행 · 옛 키 haul:1~6 유지) + B09 운반 줄이 이 함수로 — 검증완료(test_251_1 2 · 145_3 · 182 · 184_2 · 239_1 통과) +- [x] 251-2 거리이정표 서버 읽기 = 운반 행마다 상자 자동(with_haul_routes · 거래처 · 주소 빈칸 · 선 없음 · haul 키 · 저장은 [저장] 때) · 서버 모델 haul · ref — 검증완료(test_251_1 4 · 244_1 · 244_7 통과 · 실제 GET = 운반 여섯 상자 붙음) +- [x] 251-3 단가연결 모달(openPriceLink · openLogicPicker 바탕 · 종류 고르기 · 구조물 조합 = 항목 목록 · 호표 줄 · 단가산출 옵션 창 askInputs) — 검증완료(182 · 204 · 246 · 184_3 · 166 · 208_1 · m02 통과) +- [x] 251-4 거리이정표 램프(항목 칸 · 호버 = 무엇) · 표 우클릭 [단가연결] · [단가연결 풀기] · route.ref 저장 · 다이어그램 틀 높이 바뀜 = 다시 그림(아래 잘림) — 검증완료(test_251_1 7 · 244 · 250 통과 · ORCA = 모래 줄 우클릭 → 단가산출 고름 → 옵션 창 → 초록 램프 → [저장] → B09 모래운반 단가 2,002 · 데이터 되돌림) · 상자 모달 [단가연결] = 안 넣음(우클릭으로 충분) +- [x] 251-5 B09 운반 줄 ref = 거리이정표 ref(haul_refs · 설계내역서 덮개가 이김) · hand_sources · used_folders · hand_resource_keys = ref_rows 한 곳 · 자동 줄 단가 = 줄 키(Py · TS 짝) — 검증완료(test_251_1 6 · B09 시험 98 통과 · 145_5 166-5 안내 글 = 236-1 로 이미 깨짐 · 실제 GET 200) · 운반거리 km 자동 채움 = 미룸(로직마다 입력 이름 다름) +- [x] 251-6 B09 설계내역서 우클릭 · 「단가연결 ▾」 = 단가연결 모달 · 이름 「단가연결」 — 검증완료(시험 · ORCA 메뉴 = 항목 추가 · 단가연결 · 로직 입력값 고치기 · 단가연결 풀기) + +## 252. B09 공기 초안 — 메인 탭 「공기」 · 하위 「공기집계」 · 「공기산출」 (브레인 직접 · 사용자 161번) +근거 = 실무 설계서 셋의 공기 탭 식(거창 25 · 울진 24 · 울진소광 25) · 산림자원법 시행규칙 별표2 파.(2)(타) 「공사기간 산정기준근거」 · 건설기술진흥법 시행령 66조의2 · 정함 = 브레인(사용자 위임) — 아티팩트로 알림 +코드 = `B09_Estimation/draft/` 한 폴더(지우면 탭 · 저장 칸 · 서버 셈이 함께 사라짐 · 본 코드 훅은 없으면 건너뜀) +- [x] 252-1 셈 짝(Py · TS) — 공기산출 = 설계내역서 항목 줄마다 시공량 ÷ 일작업량(장비 = 일 작업시간 × 시간당 작업량 Q × 대수 · Q = 1 ÷ 단위당 주 장비 시간 / 인력 = 투입 인원 ÷ 단위당 인 / 입력 = 사용자 값) × 반영률 → ROUNDUP 1 · 소계 ROUND 0 + 준비(서류 · 공종별) + 기타 + 정리 · 공기집계 = 착공 연월부터 달마다 달력 − (기후 A + 공휴일 B − A×B/달력) ROUNDDOWN → 작업가능일 · 준비 → 작업 → 정리 차례로 채움 · 마지막 달 비작업 = 쓴 비율 ROUNDUP · 공사기간 = 작업 + 비작업 · 준공 예정일 — 검증완료(test_252_1 8 · 거창 공기산출 87 · 총 95일 · 작업가능일 23 · 23 · 21 · 21 · 16 재현 · TS 거울 같음) · 반영률 0 = 일작업량 없어도 0일 +- [x] 252-2 서버 원천 — 줄마다 단위당 자원(역전개 `expand` 1 단위 · 주 장비 이름 · 시간 · 인 합) · 공휴일 표(일요일 + 법정 · 대체 · 선거일 2024~2040) · 기후 기본값(거창) · 사용자 값 schedule 다듬기 · 서버 정본 셈 — 검증완료(test_252_1 · 실제 PUT = 화면과 같은 223일 · 준공 2027-11-09 · 값 되돌림 · draft 없는 꼴 흉내 = 훅 건너뜀) +- [x] 252-3 화면 — 위 탭 「공기」 · 아래 탭 공기집계 · 공기산출 · 고칠 칸(착공 연월 · 강수 · 혹서 · 공휴일 덮개 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 준비 · 기타 · 정리) · 공휴일 칸 풍선 = 그 달 공휴일 이름 · 사용자 덮개 칸 = 고친 칸 색 — 검증완료(typecheck · B09 시험 196 통과(145_5 = 옛 깨짐) · ORCA = 일작업량 · 반영률 · 기타 · 착공 · 공휴일 · 강수 고침 → 즉시 다시 셈 · 캐시 비움) diff --git a/docs/raw/plans/2026-10-11_plan_253-260.md b/docs/raw/plans/2026-10-11_plan_253-260.md new file mode 100644 index 000000000..23ee83b6b --- /dev/null +++ b/docs/raw/plans/2026-10-11_plan_253-260.md @@ -0,0 +1,57 @@ +# 2026-10-11 이관 — 253 ~ 260 (요구 162 묶음) + +## 253. 거리이정표 → 설계내역서 운반공 · 운반 단가 모달 (sub2 · 요구 162-1) +정함(브레인) = `docs/raw/요구_1010_2/조사_AJ_162묶음.md` 1장 · 사용자 확정 = 행 하나 = 운반공 줄 하나 · 연결 · 미연결 같은 모달 +- [x] 253-1 운반공 줄 = 거리이정표 행 1:1(자동 상자 옛 키 · 사용자 상자 `haul:r-{id}`) · 서버 B09 가 경로 전부 읽음 — 시험 · 검증완료(test_253_1 3건 · 사용자 상자 둘째 행 `haul:r-{id}.1` · 중점 행 L2 2.8) +- [x] 253-2 사이클시간 짝(Py · TS · 건설품셈 8-2-8 · 거창 단가산출 1308~1347 재현) + 로직 입력 이름 채움(Cm · 사이클시간 · t2 · 운반거리) · 서버 B09 셈 때 거리로 다시 채움 — 시험 · 검증완료(test_253_2 5건 · 거창 칸 읽어 t1 5.33 · t2 101.23 · Cm 109.78 · TS 거울 4벌 같음 · L1 10 이면 Cm 45.69) +- [x] 253-3 미리보기 길 `POST …/estimation/logic-preview`(render_sangeun_text) + 「운반 단가」 모달(단가산출 · [변경] · 옵션 값 · 계산 절차) — 거리이정표 우클릭 · 설계내역서 운반공 줄에서 엶 — 시험 · typecheck · 검증완료(test_253_3 4건 · GF000134 Cm 109.78 → 16,290원/㎥ · typecheck 새 빨강 없음) + +## 254. 거리이정표 여러 기준점 · 다이어그램 (sub8 · 요구 162-4 · 5 · 6 · 8 · 10) +정함(브레인) = 조사 AJ 2장 · 사용자 확정 = 기준점 2개 이상 · 기준점에도 선 연결 +- [x] 254-1 데이터 `bases[]` · 경로 `to` · 옛 `base` 옮김 · 돌기 막음 · 도로 구분(시점에 닿는 기준점 구간 1차 · 그 밖 2차) · Calc 짝 · 서버 모델 — 시험(먼저 커밋 · 보고) + - 검증완료 — test_254_1 5(옛 꼴 옮김 Py · TS · 서버 읽기 · PUT 새 꼴 · 기준점 둘 사슬 거래처 28.3 · 0.6 · 0.3 = 29.2 거울 · 안 이음 멈춤 · 돌기 A↔B 끊음 · makesLoop) + 244_* · 250_1 · 251_1 포함 84 통과 · typecheck · ruff +- [x] 254-2 다이어그램 — 기준점 상자 연결 점 · 기준점 주소 · 연결 점 왼쪽 위 안쪽 · 항목 글 = 거래처 글 · 주소 줄바꿈 + 상자 높이 · 선 글 두 줄(도로 / km) · 선 길이 거리 비례(로그 · 최소 · 최대) — 시험 + - 검증완료 — test_254_2 4(선 길이 = 100 + 90·log10(1 + km) · 최소 100 · 최대 320 · 테두리 사이 실측 · 주소 2줄 = 높이 73 · 3줄 넘으면 줄임표 · 선 글 두 줄 높이 34 · 연결 점 (10, 10) r 6 · 돌기 취소) + 244_* · 250_1 · 251_1 · 254_1 포함 88 통과 · typecheck · prettier +- [x] 254-3 상자 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」 · 장소 그림(`place_images`) 상자 안 배치(256-1 꼴) — 시험 · typecheck + - 검증완료 — test_254_3 2(그림 132 × 88 · 글 아래 안쪽 여백 8 · 상자 높이 + 96 · 그림 없는 상자 높이 그대로 · 제목 「거래처 —」 · 키 바뀔 때만 다시 받음) + 244_* · 250_1 · 251_1 · 254_* 포함 90 통과 · typecheck · prettier + +## 255. 거리이정표 시점 모달 · 거래처/기준점 모달 · 경로 탭 (sub1 · 요구 162-2 · 3 · 4) +정함(브레인) = 조사 AJ 2장 +- [x] 255-1 시점 모달 — 중점 측점 km · 설명 줄 지움 · 지도 = 공용 카카오 지도(시점 · 중점 표지) — 시험 · 검증완료(test_255_1 · 1.2345 km → 1234.5 m · 빈칸 = null · 244-4 · 244-6 기대값 고침 12 통과) +- [x] 255-2 (254-1 합친 뒤) 경로 탭 — 시점도 끌기(`site.start_pin` · 길찾기용) · 기준점 옮기면 그 기준점에 닿는 구간 모두 다시 · 다시 열면 모든 구간 선 · 실패 안내 — 시험 · 검증완료(test_255_2 · 덮개 없음 → 127,35 · 덮개 → 128.25,36.5 · 기준점 b1 = b1 · b2 · r2 다시(안 이은 r4 빠짐) · 거리이정표 시험 86 통과) +- [x] 255-3 (254-1 합친 뒤) 거래처 · 기준점 모달 — 제목 ≥ 내용 글 · 「소재지 — 주소 찾기 · 지도에서 고르기(…)」 지움 · 기준점 이름 한 줄 · 주소 · 지도 위 가로선 — typecheck · 검증완료(typecheck 0 · test_255_3 · 칸 이름 12 → 14px(입력 글과 같음) · 장소 그림 capturePlace = 등록 · 좌표 바뀜 · 경로 탭 끌기 · 거리이정표 시험 94 통과) + +## 256. 거리이정표 그림 영구 저장 (sub4 · 요구 162-6 · 7) +정함(브레인) = 조사 AJ 2장 · 사용자 확정 = 그림은 뒤에 다른 곳에서 씀 · 잘 보관 · 경로 그림은 화면 기능 변경 아님 +- [x] 256-1 장소 그림 — 거래처 · 기준점 좌표가 정해지면 서버가 작은 지도 캡처 → `milestone/place/.png` · GET 에 `place_images` · 지운 상자 그림은 [저장] 때 지움 — 시험 + - 검증완료(test_256_1 6건 · 실제 캡처 검증_sub4 place 2장 480×320 · 약 0.13MB) +- [x] 256-2 경로 그림 — 경로 탭과 같은 전체 경로(기준점 사슬 포함 · 254-1 뒤) → `milestone/auto/<경로 id>.png` · 좌표 · 구간이 바뀐 경로는 [저장] 때 다시 캡처 — 시험 + - 검증완료(test_256_2 4건 · 실제 캡처 auto/r1.png 1200×800 · 0.97MB · 도로 구간 길 선 · 길찾기 실패 구간 곧은 선 · 다시 [저장] 0.02초) + +## 257. B09 탭 · 표 (sub3 · 요구 162-11 · 12 · 13 · 15 · 16 · 17 · 18) +정함(브레인) = 조사 AJ 3장 +- [x] 257-1 위 탭 「설계내역」(총괄설계내역서 · 설계내역서) · 단가연결 칸 = 램프(누름 그대로 · 풍선) · 머리 「단가↵연결」 · 폭 줄임 — 시험 + - 검증완료(test_257_1 둘 · test_181_1 · test_229_1 · B09 UI 시험 167 통과 · 위 탭 8 · 램프 칸 class 셋 · 머리 폭 44) +- [x] 257-2 이동해 온 줄 = 모든 탭 빨강 상자 · 비고 「빈 줄 N」 지움(Py · TS 짝) · 총공사비 = 아래 고정 줄(총괄 · 원가계산서) — 시험 + - 검증완료(test_257_2 셋 · test_185_1 · test_145_3 · 145_4 · 182 · 242_1 · 200_2 · B09 시험 248 통과 · 145_5 1건은 경비 집계표 안내 글 = 이 절 밖 · typecheck 새 오류 0) +- [x] 257-3 자재단가대비표 적용 단가 = 초록 테두리 · 우클릭 「적용 단가 바꾸기」 — 시험 · typecheck + - 검증완료(test_257_3 둘 · test_183_3 · B09 시험 248 통과 · typecheck B09 오류 0 — B08 Milestone_Modal 1건은 이 절 밖) + +## 258. 표 양식 · 구조물 조합 색 (sub5 · 요구 162-9 · 14 · 22) +정함(브레인) = 조사 AJ 4장 · 사용자 확정 = 입력 칸 테두리 옅게 되살림 +- [x] 258-1 템플릿 표 폭 = CSS 변수 · 거리이정표 표 100% — 시험 · 검증완료(test_258_1 · test_ui_data_table 통과 · 인라인 --dt-w · css width var · 고정 열 표 폭 값 그대로) +- [x] 258-2 입력 칸 테두리(템플릿 · outline · 옅게) · 시험 셋 기대값 고침 — 시험 · 검증완료(accent 28% outline · test_243_1 · 246_1 · 241_1 통과) +- [x] 258-3 B05/B06 구조물 조합 미지정 = 회색(구조물리스트 램프 색) — typecheck · 검증완료(text-muted 테두리 · 12% 바탕 · test_177_1 기대값 고침 통과) + +## 259. B06 횡단 상세 — 영역 · 길이 · 표 강조 (sub7 · 요구 162-19 · 20) +정함(브레인) = 조사 AJ 5장 +- [x] 259-1 공용 팬 `moved` 고침 · 암 경계선 나눔 · 고름(서버 · 스키마 다섯 곳) — 시험 · 검증완료(test_259 5건 · 팬 뒤 누름 통과 · 암 경계선 셋으로 나뉨 · 가운데 5.33m 서버 거울 · 209·164·179·150·244 47건) +- [x] 259-2 닫힌 영역 안 누름 고름 · 채움 · 면적 · 둘레 / 선 길이 · 기울기 · 표 칸 누름 = 그래프 고름 + 같은 정보 — 시험 · 검증완료(test_259 11건 · 면 둘 6.00㎡ · 4.67㎡ 찾음 · 열린 영역 0 · 붙은 둘 = 10.67㎡ 서버 거울 · 표 칸 → 저장 조각 · 띠 짝) +- [x] 259-3 표 강조 바깥 한 줄 · 겹선 없음 · 다른 묶음 안 물듦 — typecheck · 검증완료(ㄴ 모양 바깥 줄 10토막 · 칸 사이 겹선 없음 · tr:has 물들임 없앰 · typecheck 내 파일 0건) + +## 260. B06 상세 → B07 횡단배수 CAD 칸 (sub6 · 요구 162-21) +정함(브레인) = 조사 AJ 5장 · 사용자 확정 = 빈 CAD(바탕만) · 구조물 없으면 빈값 · 횡단배수만 미리 넣음 · B07 표 위 +- [x] 260-1 새 칸 `structure_note_cad`(스키마 · 남김 · 새로 셈 · 저장 patch) · 옛 글 옮김 — 시험 · 검증완료(test_260_note_cad 칸 4건 · test_138 7건 통과 · typecheck 내 파일 0) +- [x] 260-2 상세 모달 글 상자 → 빈 CAD 도면(cad_host · 높이 고정 · 닫으면 destroy) · 횡단배수 미리 넣음 스크립트(주인 측점 · 비었을 때만) — 시험 · 검증완료(test_260_note_cad 미리 넣음 10건 — 그림 7줄 일치 · 빈 구조물 · 세월교 · 숨은 관 · 이웃 = 빈값) +- [x] 260-3 B07 = CAD 칸 엔티티를 표 위(좌상 맞춤 · 범위 크기) · 없으면 옛 글 줄 — 시험 · 검증완료(test_260_note_cad B07 5건 — 범위 왼쪽 = 표 왼쪽 · 위 = 옛 표 윗변 · 표 15 mm 내림 = 범위 높이 · 배율 2 = 30 mm · test_150 · 174 · 180 통과) +- [x] 260-4 안 연 측점 = B07 이 파이썬 짝 스크립트로 넣음(TS 와 거울) · 지운 것([]) 유지 — 시험 · 검증완료(test_260_note_cad 거울 9쌍 TS = 파이썬 · 칸 없음만 넣음 · [] · 옛 글 · 이웃 그대로 · B07 표 내림 = 범위 높이 · B07 시험 108 통과) diff --git a/docs/raw/plans/2026-10-11_plan_261.md b/docs/raw/plans/2026-10-11_plan_261.md new file mode 100644 index 000000000..f382de145 --- /dev/null +++ b/docs/raw/plans/2026-10-11_plan_261.md @@ -0,0 +1,10 @@ +# 2026-10-11 이관 — 261 (화면 검증 흠 고침) + +## 261. 화면 검증 흠 고침 (브레인 화면 검증 · 사용자 163번) +정함(브레인) = 브레인 화면 검증(10-11) 흠 다섯 · 사용자 확정 = 1 소수점 제어 · 3 · 4 제안대로 · 5 이정표 항목명 그대로 개별 줄 · 2 보류 +- [x] 261-1 (sub1) 거리이정표 km · m 칸 끝수 — 시점 창 중점 측점 km 3자리(끝 0 지움 · 안 고치면 원래 값) · 구간 · 다이어그램 km 칸도 점검 — 시험 · 검증완료(test_261_1 3건 · 관련 시험 26건 통과 · typecheck 통과 · 576.595 m → 「0.577」 · 0.3 → 300 m · 안 고침 = 576.595 유지) +- [x] 261-2 (sub3) 공사원가계산서 직접재료비 이동 대상 = 총괄 「가. 순공사비계」(direct) · 모든 원가 줄 이동 대상이 총괄 줄에 있는지 시험 — 검증완료(test_261_2 2 · test_185_1 5 통과 · 원가 줄 이동 대상 전부 총괄 줄에 있음 · f0f35c574) +- [x] 261-3 (sub7) B06 상세 영역 — 보통 배율에서 영역 안 누름 = 영역(보이는 선 가까이만 선) · 설계선이 지반 · 암 띠를 나눠 깎기 토사 · 암석 영역 따로(표 값 근처) — 시험 — 검증완료(test_261_3 8 · 233_2 · 259 · 209_1 통과 · typecheck · 6 px 띠 가운데 = 영역 · 선 1 px = 선 · 4+15 깎기 암석 18.24㎡ · 토사 6.00㎡ · sub7 일반 깎기 3곳 영역 = 그림 칠 면적) +- [x] 261-4 (sub2) 운반공 줄 이름 = 거리이정표 항목명 그대로(「운반」 안 붙임) · 행마다 개별 줄 · 단가연결 무관 · 키 그대로 — 시험 + - 검증완료(test_261_4 외 5파일 26 통과 · 검증_sub2 실제 GET 운반공 10줄 「강모래 · 강모래(haul:3.1) · 전석운반 ×2 · 석산」 · 운반운반 0 · typecheck 통과) +- 보류(사용자) = 저장 경로 그림이 끌어 옮긴 시점(start_pin)을 안 씀 diff --git a/docs/raw/plans/2026-10-11_plan_262-270.md b/docs/raw/plans/2026-10-11_plan_262-270.md new file mode 100644 index 000000000..c65e9c3c8 --- /dev/null +++ b/docs/raw/plans/2026-10-11_plan_262-270.md @@ -0,0 +1,41 @@ +# 2026-10-11 이관 — 262~270 (요구 164 · 165) + +## 262. 거리이정표 창 제목 · 지도 시점 표지 (요구 164-1) +- [x] 262-1 (sub1) 상자 창 제목 앞 「거래처 — · 기준점 — · 공사지 —」 지움 — 거래처 = 그 이름 · 기준점 = 그 이름 · 시점 창 = 「시점 · 중점」(요소 추가 · 구간 창 그대로) — 검증완료(test_262_1 · test_254_3 통과 · typecheck 통과 · 화면 검증 = 사용자) +- [x] 262-2 (sub1) 거래처 · 기준점 창 소재지 지도에 시점 표지(고정 · 움직이지 않음) — 시점이 정해져 있을 때 · 소재지 없으면 시점 중심으로 엶 — 검증완료(test_262_1 통과 · typecheck 통과 · 화면 검증 = 사용자) + +## 263. 운반 단가 모달 = 고른 항목 입력 + 거리 자동 (요구 164-1) +정함(브레인) = 사용자 말 「수기 전체 입력 아님 · 고른 단가산출 · 구조물 조합 · 사전조합의 입력에 이정표 거리만 자동 · 8-2-8 은 이미 있음」 → 사이클 손 칸 없앰 +- [x] 263-1 (sub2) 왼쪽 = 고른 항목 입력만(사이클시간 입력 · 8-2-8 표 고르기 칸 지움) · 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 이정표에서 「거리에서」 · 8-2-8 사이클 = 기본값으로 셈(계산 절차에만 보임) · [변경] = 한 목록 고르기(단가산출 · 사전조합 · 구조물 조합 · 호표) · [다른 종류] 없앰 — 검증완료(test_263_1 5 · typecheck · 실제 GET 검증_sub2 GF000284 운반거리 12 km 79,349 원 → 32 km 202,624 원) +- [x] 263-2 (sub2) 사전조합 · 구조물 조합 = 담은 로직 입력 보임 + 거리 칸 채움 → 행별 사본(거리마다 호표) · [저장] 때 서버가 거리로 다시 채움 — 검증완료(test_263_2 7 · 같은 값 사본 하나 · 다른 거리 둘 · 거리 바뀐 뒤 새 사본 · 옛 사본 prune · 옛 이름 읽힘) + +## 264. B09 공기 옵션 컨테이너 (요구 164-2 · draft 안) +정함(브레인) = 공기 초안 아티팩트 「내가 정한 것」 12 가지 → 사용자 옵션 · 기본값 = 지금 값 · 기후 고르기 = 참고 설계서 셋(거창 25 · 울진 24 · 울진 소광 25) +- [x] 264-1 (sub8) B09 왼쪽 패널 「공기 옵션」 컨테이너(draft 등록 · draft 지우면 사라짐) — 착공일 · 기후 자료 · 쉬는 날 · 공휴일 넣기 · 일 작업시간 · 투입 기본 · 준비 · 정리 · 기타 · 반영률 기본 · 주 장비 기준 · 중복일수 끝수 · 마지막 달 비작업 + - 검증완료(typecheck 0 · test_264_2 훅 시험 — DRAFT.panels 기본 빈 목록 = draft 없으면 카드 없음 · 화면 확인은 사용자) +- [x] 264-2 (sub8) 셈 짝(Py · TS) · 다듬기 · 거울 시험에 새 옵션 + - 검증완료(test_252_1 8 · test_264_2 16 통과 — 기본값 거창 95 일 · 136 일 그대로 · 옵션 11 가지 각각 값 바뀜 · 거울 TS = Py) + +## 265. B06 상세 CAD 칸 — 접기 · 차례 · 구조물 글 · 관 길이 정수 (요구 164-3 · 5 · 6) +- [x] 265-1 (sub6) 차례 = 그림 → 표 → CAD 칸 · CAD 칸 접기/펴기 · 열 때 기본 = 구조물 측점 폄 · 일반 측점 접힘 — 검증완료(test_265 차례 · 기본 접힘 판정 구조물 측점 5건 · 일반 측점 0건 · typecheck) +- [x] 265-2 (sub6) 미리 넣는 글 = 그 측점 구조물 전부 — 구조물마다 지정된 구조물 조합의 일위대가 이름 + 옵션 값 · 사용자 예시 글 베낀 꼴 버림 · 배관 = 원형 마크 · 짝(B07 Py) 같이 — 검증완료(test_265 · test_260 거울 TS = Py · 구조물 다섯 줄 · 미지정 · 배관 본체 줄 없음 · B07 빌드) +- [x] 265-3 (sub6) 원형 마크 관 길이 = 사사오입 정수(`pipeLengthWhole` · 225-1) — 검증완료(test_265 거울 8.5 → L=9m · 8.4 → L=8m · TS = Py) + +## 266. B06 상세 처음 배율 = 횡단 보기 반폭 (요구 164-4) +- [x] 266-1 (sub7) 상세 창 처음 가로 범위 = 좌측 「횡단 보기 설정」 반폭(그 측점 개별 표시 반폭이 있으면 그것 · 카드와 같게) + - 검증완료(test_266 · 164 · 173 등 84건 · typecheck · 반폭 15 → 처음 −15~+15 · 개별 10 → −10~+10 · ⤢ · 더블클릭 같은 범위) + +## 267. B06 횡단도 관매설 글 크기 (요구 164-7) +- [x] 267-1 (sub5) 노폭 위 회색 「관매설 …」(세월교 정보 글 포함) 글자 크기 = 「노폭 …」 글자 크기 · 색 그대로 · 줄 겹침 없음 — 검증완료(test_267_1 · 225_1 — 관매설 · 세월교 글 11px = 노폭 11px · 줄 간격 13px · 5fdfc64d1) + +## 268. B07 횡단도 노란 측구 · 관 색 (요구 165-1 · 2) +- [x] 268-1 (sub3) 측구 노란 덧그림 하나 더 — 실제 화면에서 확정 뒤 그 덧그림만 지움 — 검증완료(test_268 4 · 212 · 137 — 갈아 끼우는 측점의 서버 노란 측구 걷음 · 장 엔티티 노란 측구 0 · d496517ce) +- [x] 268-2 (sub3) 관 · 박스암거 색 = 다른 선과 또렷이 다른 색(횡단도) — 검증완료(관 · 박스암거 = 청록 #22d3ee · 선 13개 · B07_DesignDetail_Cross_Overlay.ts · d496517ce) + +## 269. 좌측 패널 컨테이너 공용 양식 (요구 165-3) +- [x] 269-1 (sub4) B07 좌측 패널 폭 · 단추 · 입력칸 = B05/B06 「구조물 배치」 공용 양식 — 검증완료(test_269 9 · createPanelCard · .ui-panel-form 칸 26px · B07 묶음 카드 공용) +- [x] 269-2 (sub4) B06 다른 컨테이너도 같은 양식 — 검증완료(B06 횡단 보기 · 표준 횡단면 칸 공용 양식) +- [x] 269-3 (sub4) B05 이상 좌측 패널 점검 · 맞춤(아래 단추 묶음 예외) — 검증완료(B05 · B08 · B09 카드 공용 · 점검 빠진 곳 0 · 아래 단추 묶음 그대로 · 9e3c87edd) + +## 270. B05/B06 하이라이트 연동 되살림 (요구 165-4 · 브레인 직접) +- [x] 270-1 (브레인) 배수유역도 · 표 열 하이라이트 잃은 까닭 확인(노면공 범위 값 충돌 · 어제 저녁 분석) → 고침 · 구조물 목록 · 횡단도 · 3D 측점 연동 그대로 · 브라우저 검증 — 검증완료(까닭 = 횡단 보기가 한 벌 배수유역도를 고름 한 칸에 안 이음 · B05 는 정상 · test_270_1 6 + 강조 안전망 85 통과 · ORCA B06: 관 · 세월교 고름 → 유역 · 관 마커 · 유역 누름 → 카드 · 표 열 · 목록 · 알약 · 다시 누름 = 모두 풂 · 노면공 범위 끼어듦 없음 · 재계산 뒤 유지 · e09c6863f) diff --git a/docs/raw/plans/2026-10-12_plan_271-276.md b/docs/raw/plans/2026-10-12_plan_271-276.md new file mode 100644 index 000000000..1f85b76c6 --- /dev/null +++ b/docs/raw/plans/2026-10-12_plan_271-276.md @@ -0,0 +1,38 @@ +# 2026-10-12 이관 — 271~274 · 276 (요구 166~169 · 사용자 10-11) + +## 271. 요구 166 — CAD 글 옵션 · 정수 맞춤 · 측점 겹침 (브레인 직접) +- [x] 271-1 표 위 CAD 칸 미리 넣음 = 구조물 조합 이름 + 좌측 패널 옵션 줄(B06 모달 · B07 같은 줄 · Node 번들) — 검증완료(test_166_1 5 · 260 · 265 기대값 · ORCA 4+15 상세 창 = 관 · 관 보호공(유입) · (유출) 옵션 줄 · B07 실자료 같은 줄 · 2399c533c) +- [x] 271-2 조사 AK 보고 · 사용자 답(167) = 측점 갖는 구조물 범위 그대로 · 측점 값 정수 m(소수 안 씀) · 단추 없음 · 옛 자료도 정수 · 겹침 근본 해결 → 272 + +## 272. 측점 한 벌 — 정수 m · 구조물 측점 위 · 겹침 근본 해결 (요구 167 · 브레인 직접 · 리팩터링 · 검증 대기) + +- [x] 272-1 측점 규칙 한 벌(TS ↔ Py 짝 · 거울 시험) — 정수 m(사사오입 · 일반 측점 1 m 붙기 뺌 · 끝은 노선 길이 안) · 측점 심는 구조물(지금 서버 규칙 `plantedChainages` ↔ `resolve_extra_stations`) · 같은 m 면 측점 하나(주인 = 구조물 · 격자 자리 `grid`) · 떠나면 일반 측점 되살아남(`oneStationList` ↔ `_merge_irregular_into_longitudinal`) + - 검증완료 — test_234_1 · test_272_1(심는 자리 · 한 벌 · 꼭짓점 거울) +- [x] 272-2 서버 — B05 [저장] · B06 저장 · [측점 만들기]가 모두 「목록 전체 다시 맞춤」(빈 목록도 · 옛 구조물 측점 · 떠난 격자 자리 글 · 횡단 파일 글) · 계획선 측점 줄 다시 셈 · 정본에 없는 측점 횡단 행 = 0.5 m 안 새 측점으로 옮김(설계 값 그대로) 나머지 지움 · 옛 소수 측점 꼭짓점 · 편집 = 정수 자리로 옮김 · 맞춤 실패는 저장을 안 막음 + - 검증완료 — test_272_2 · 192_1 · 213_1 · 238_1 · b06_patch_station · b06_save_fills_empty_design · 76 등 +- [x] 272-3 화면 — 종단 그래프 선 · 3D 띠 · 표 열 · ▲▼ 가 같은 목록(초안 반영 · `oneStationList`) · 측점 심는 구조물 = 관 · 독립 기슭막이 시작 · 끝 · A군 · C군 구간형 시작 · 기준 · 끝 · B06 카드 = 초안에 없는 옛 구조물 측점 숨김 · 고름 = 같은 m 구조물 측점 먼저 · 맞추기 폭(0.1 dropStationsNear · snapToMasterStations) 걷음 · C군 계산 구간 · 포장 나눔 경계 · 폼 칸 표기 = 정수 m + - 검증완료 — typecheck 0 · 표적 시험 548 통과(205-1 은 이 일 전부터 실패 · CSS 값 바뀜) +- [x] 272-4 옛 자료 — [정수 맞춤] 단추 없앰 · 종단 보기를 열 때 한 번 자동 정수(관 · 구조물 · 독립 기슭막이 시작 · 끝 · 편집 따라감 · 알림 한 줄 · [저장]하면 남음) · 서버 PUT 정수 강제는 안 넣음(화면이 정수만 보냄) + - 검증완료 — test_234_1(자동 맞춤 · 알림) +- [x] 272-5 검증 — 브라우저(검증_랩탑메인): 열자마자 관 11개 정수 · 최대 기울기 14.4 %(1777 % 튐 고침) · 선 70 · ▲ 70 · 표 열 70 겹침 없음 · 관을 격자 100 위로 = 69(하나) · 밖으로 = 70(격자 되살아남) · 새로고침 같음 · [저장] = 서버 측점 · 횡단 파일 글 · DB 행 70 = 측점 70(남는 행 없음 · 행 옮김) · B06 카드 70 · 카드 고름 → 종단 선 + - 검증완료 + +## 273. 요구 168 — 종단 고름 자리로 이동 · B06 횡단도 바로 반영 (브레인 직접 · 검증 대기) + +- [x] 273-1 종단 그래프 — 다른 영역(횡단 카드 · 3D · 배수유역도 · 좌측 목록)에서 고르면 칠한 결과(고른 측점선 · 없으면 알약)가 화면 밖일 때 가운데로 옮김 · 같은 고름 다시 칠하면 안 밂 · 그래프 안 누름은 안 움직임(옛 코드 없음 — 새로 씀 · `createSelectionReveal`) + - 검증완료 — test_273_1 · 브라우저(좌측 목록 48+1 → 선 가운데 369/739 · 그래프 안 누름 안 움직임 · B06 카드 52+17 → 가운데) +- [x] 273-2 B06 횡단도 바로 반영 — 새로 놓은 · 옮긴 관 · 구조물(초안)의 횡단 카드를 [저장] 전에 보임(사용자: 편의 · 보안은 알아서 · 느리지 않게) + - 서버: 읽기 전용 미리 뜨기 `POST …/section/preview-stations`(로그인 · 회사 · 프로젝트 권한 그대로 · 한 번에 60곳 · 파일 · DB 안 씀) — 저장과 같은 길(`sample_structure_sections` · `compute_default_designs(crosses=)`) + - 화면: 초안이 심는 자리 중 횡단 없는 곳만 묻고 한 번 뜬 자리는 기억(`B06_Section_Section_Drafts`) · 카드 이름 · 관 그림 얹음 · 늦은 옛 응답 무시 · 배수 재계산이 관경을 정하면 카드 다시 얹음 · B06 관 옮김도 카드 다시 + - 검증완료 — test_273_2 · 관련 시험 443 · 브라우저(B06 관 추가 → 카드 + 관 그림 0.6초 · 옮김 → 새 카드 0.4초 · 옛 카드 사라짐 · 새로고침 그대로 · [저장] 뒤 면적 0.82/0.25/0.00/1.07/6.55 같음 · 뺀 뒤 [저장] 원래대로 70/70) + +## 274. 요구 169 — 표 위 CAD 배수관 원형 마크 아래 수직선 없앰 (브레인 직접 · 검증 대기) + +- [x] 274-1 B06 상세 모달 표 위 CAD · B07 미리 넣음(짝) — 배수관 원형 마크 = 원 + 원 안 세 줄 · 아래 지시선 걷음(`pipeMark` ↔ `_pipe_mark`) + - 검증완료 — test_260 · 265 · 166_1 · 164 · 브라우저(검증_랩탑메인 4+15 상세 — 원만) + +## 276. B08 거리이정표 · B09 운반공 줄 단가연결 — 연결 없음 = 옛 길 · 있음 = 작은 운반 단가 모달 (사용자 10-11 · 검증 대기) + +- [x] 276-1 연결 없음 = 단가연결 목록 → 옵션 값 창(옛 길 · 263-1 이전 · `openPriceLink` → `askInputs`) · 연결 있음 = 「운반 단가」 모달 — B08 우클릭 [단가연결] · B09 운반공 줄(`haul && ref`) · 그 밖 B09 줄 그대로 +- [x] 276-2 「운반 단가」 모달 크기 = 96vw × 94vh → min(1100px, 94vw) × min(680px, 86vh) · 가운데 · 두 칸 따로 스크롤(공용 큰 창 규칙 그대로) + - 검증완료 — typecheck 0 · test_276_1 · 263_1 · 253_3 · 263_2 · 251_1 · 182 · m02_labor_layer · 모달 공용 2 = 54 통과 · ORCA(검증_랩탑메인 · 저장 안 함 · 끝나고 연결 풀고 프로젝트 되돌림): B08 혼합골재 · B09 전석운반 연결 없음 → 조회 모달 → GF000284 옵션 창 → [연결] → 다시 열면 운반 단가 모달 1100×680(전 1411×1011) · 운반거리 거리에서 25.677 · 23.877 diff --git a/docs/raw/요구_1010_2/그림_162-21_횡단배수_글.png b/docs/raw/요구_1010_2/그림_162-21_횡단배수_글.png new file mode 100644 index 000000000..cece5b8c1 Binary files /dev/null and b/docs/raw/요구_1010_2/그림_162-21_횡단배수_글.png differ diff --git a/docs/raw/요구_1010_2/그림_171_운반단가창.png b/docs/raw/요구_1010_2/그림_171_운반단가창.png new file mode 100644 index 000000000..97445a98e Binary files /dev/null and b/docs/raw/요구_1010_2/그림_171_운반단가창.png differ diff --git a/docs/raw/요구_1010_2/그림_183_물넘이포장.png b/docs/raw/요구_1010_2/그림_183_물넘이포장.png new file mode 100644 index 000000000..28afd4fd8 Binary files /dev/null and b/docs/raw/요구_1010_2/그림_183_물넘이포장.png differ diff --git a/docs/raw/요구_1010_2/그림_188_물넘이연장.png b/docs/raw/요구_1010_2/그림_188_물넘이연장.png new file mode 100644 index 000000000..cf506fd37 Binary files /dev/null and b/docs/raw/요구_1010_2/그림_188_물넘이연장.png differ diff --git a/docs/raw/요구_1010_2/조사_AI_단가연결.md b/docs/raw/요구_1010_2/조사_AI_단가연결.md new file mode 100644 index 000000000..17a596819 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AI_단가연결.md @@ -0,0 +1,33 @@ +# 조사 AI — 거리이정표 운반 행 · 단가연결 (사용자 159 · 160번) + +브레인 직접 조사 · 2026-10-11 + +## 1. 지금 모습 + +- B08 총괄자재표 = 고정 줄 `TOTAL_ROWS`(`B08_Quantity_Material_Calc.py:104-126`) · 칸 key · group · spec · unit · qty · amount(할증 · 끝수 반영) + - 사본(구조물조합 → 일위대가) 자재는 글자 맞춤으로 고정 줄에 붙음 · 안 맞으면 unmatched 줄 +- B09 설계내역서 운반공(`SECTIONS` "3") = 고정 6줄 `HAUL`(`B09_Estimation_Bill_Items.py:50-57`) — 중기운반 · 기계운반 · 모래 · 자갈 · 시멘트 · 철근 운반 · 수량 = 총괄 amount · ref 없음 · 「운반거리 원천 없음」 +- 거리이정표 값은 B09 어디서도 안 읽음 +- 연결 고르기 = `B09_Estimation_UI_Tabs_Bill_Link.ts` 메뉴 다섯(일위대가 호표 · 단가산출 호표 · 구조물 조합 · 일위대가 사전조합 · 단가산출) + - 단가산출 = `openLogicPicker` → `askInputs`(옵션 값 입력 창 `openLaborInput`) — 160번 「옵션 추가 모달」은 이미 있는 길 + - `openLogicPicker(done, lib)` = 로직 + 서고 사전조합을 한 목록(검색 · 구분 · 상세구분 · 층 단추)으로 이미 보임 — 새 모달의 바탕 + - 구조물 조합 · 사전조합 = `importLib` → 서버 사본(`POST …/estimation/library`) → `{tab: dg, key, lib}` +- 로직 연결 단가는 `Sangeun.hand_sources(user)`가 user.bill 덮개 줄에서만 읽음 · 사본 정리 `Bill_Library.used_folders(user)` · 단가 수요 `hand_resource_keys(user)`도 같음 +- 운반 로직 중 km 입력 = `GF000284` 덤프운반 「운반거리」 하나 · 그 밖은 사이클시간 · 포장/비포장 입력 없음 + +## 2. 정함(브레인 · 사용자 「알아서」) + +1. **운반 행 = 총괄자재 묶음** — 총괄 줄 중 수량 있는 것 · 같은 묶음(이형철근 D13~D25 · 레미콘 셋 · 깬돌 둘)은 한 행 · 골재는 모래 · 자갈 · 막자갈 · 혼합석 따로 · 장비 두 행(중기 · 기계) 늘 · 옛 키 `haul:1~6` 은 그 묶음에 그대로(사용자 덮개 고아 없음) · 새 묶음 = `haul:<묶음 키>` +2. **거리이정표 자동 상자** — 서버 읽기 때 운반 행마다 거래처 상자가 없으면 넣음(거래처 · 주소 빈칸 · 선 없음 · 항목 = 자재 이름 · 저장은 [저장] 때) · 사용자 [요소 추가] 상자도 그대로 +3. **단가연결 저장 자리 = 거리이정표 행(정본)** — `route.ref`(dg · sg · logic 꼴 그대로) · B09 운반 줄 ref = 그 값(B09 에서 고친 덮개가 있으면 덮개가 이김) + - 로직 입력 「운반거리」가 있으면 B09 셈 때 거리이정표 합계 km 로 채움 + - B09 `hand_sources` · `used_folders` · `hand_resource_keys` 가 자동 줄 ref 도 읽게(서버만 · 화면 셈은 서버가 준 산근 그대로) +4. **단가연결 모달** — `openLogicPicker` 바탕 한 목록: 종류 고르기(전체 · 단가산출 · 일위대가 사전조합 · 구조물 조합 · 일위대가 호표 · 단가산출 호표) + 검색 + 구분 · 상세구분 + 층(마스터 · 회사 · 개인) · 단가산출 = 옵션 값 창(160번) · 사전조합 · 구조물조합 = 사본 들여옴 + - B09 설계내역서 우클릭 · 「연결 ▾」 칸 · 거리이정표 표 · 상자 = 같은 모달 · 이름 「연결」 → 「단가연결」 +5. **램프** — 거리이정표 표 항목 앞 · 상자 · 초록 = 단가 연결됨 · 노랑 = 안 됨(구조물 집계표 램프 꼴) + +## 3. 위험 + +- 운반 행 키 바뀜 → 옛 키 그대로 둬 막음 +- 사본 정리(prune)가 거리이정표 ref 사본을 지우지 않게 used_folders 에 자동 줄 ref 포함 +- 거울 짝 Calc_Bill(py · ts) 은 안 고침(줄 짜기 · ref 는 서버 inputs 로 감) diff --git a/docs/raw/요구_1010_2/조사_AJ_162묶음.md b/docs/raw/요구_1010_2/조사_AJ_162묶음.md new file mode 100644 index 000000000..985fd868b --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AJ_162묶음.md @@ -0,0 +1,91 @@ +# 조사 AJ — 요구 162 묶음(23) 지금 상태 · 정함(브레인) · 담당 + +근거 = 조사 에이전트 넷(10-11) · 줄 번호는 조사 시점 기준. + +## 1. 거리이정표 → 설계내역서 운반공 · 운반 단가 모달 (253 · sub2) + +지금 +- B09 운반 줄 = 총괄자재 묶음(`B08_Quantity_Haul.haul_groups`) · 거리이정표 [요소 추가] 상자는 내역 줄이 없음. +- 운반 로직은 거리를 직접 안 받음 — `GF000134~136`(골재운반 덤프) = `Cm`(분) 손 입력 · `GF000137`(트레일러) = `t2` · `GF000283`(자재 운반) = `사이클시간` · `GF000284`(산림 덤프운반) = `운반거리`(km 하나 · 임내 속도 5 · 6) · 불도저 = 거리 없음. +- 실무 설계서(거창 25 `단가산출` 1308~1347행 · 혼합골재 운반 덤프 15톤) = L1 포장 25.1 · L2 비포장 0.8 · V1 30 · V2 35 · V3 10 · V4 15 · t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4)×60 = 101.23 · Cm = 5.33 + 101.23 + 0.8 + 0.42 + 0.5 + 1.5 = 109.78. 경쟁사(STmate)도 같은 식. +- 근거 원문 = 건설품셈 8-2-8 덤프트럭(`resources/knowledge/original/원가계산/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계/8-02-08_덤프트럭.md` · t2 = 운반거리/적재속도 + 운반거리/공차속도 · 도로 상태별 속도 표 · t3~t6) · 표는 master_data `계수_건설품셈_08장_건설기계.json` CC000054~058(로직키 없음 · 읽기만). +- 계산 절차 글 = 서버만(`B09_Estimation_Sangeun_Text.render_sangeun_text`) · 브라우저는 로직을 못 셈 · 옵션 창(`openLaborInput`)은 입력만. + +정함(브레인) +- 거리이정표 행 하나 = 운반공 줄 하나. 자동 상자 = 옛 키 그대로(`haul:1~6` · `haul:{묶음}` · 수량 = 총괄자재) · 사용자 상자 = `haul:r-{경로 id}` · 이름 = 항목 + 「운반」 · 수량 빈칸(설계내역서에서 입력). +- L1(포장) = 그 행 포장 계(2차 + 1차 + 포장) · L2(비포장) = 그 행 비포장 — `compute_milestone` 결과. +- 사이클시간 = 건설품셈 8-2-8: cm = t1 + t2 + t3 + t4 + t5 + t6 · t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · 기본값 = 거창 설계서 값 · 속도 · t 값은 CC000054~058 고르기로 바꿈. +- 로직 입력 채움(이름으로) — `Cm` · `사이클시간` = cm · `t2` = t2 · `운반거리` = L1 + L2 · 채운 칸 = 「거리에서」 표시 · 손으로 덮으면 그 값. +- 셈 자리 = 짝 ①(사이클시간 파이썬 · TS) · 서버가 B09 셈 때 거리이정표 거리로 다시 채워 정본(거리 바뀌면 단가도 따라감). +- 모달 하나 「운반 단가」 — 위 = 단가산출 이름 · [변경](단가연결 고르기) · 왼 = 옵션 값(로직 입력 + 사이클 입력) · 오른 = 계산 절차(사이클 식 줄 + 로직 산출 줄 · 새 길 `POST /api/projects/{id}/estimation/logic-preview` = `render_sangeun_text` 재사용) · 연결 없는 줄도 같은 모달(단가산출 빈칸 → [고르기]). +- 여는 곳 = 거리이정표 표 우클릭 [단가연결] · 설계내역서 운반공 줄 단가연결. 그 밖 줄 = 지금 단가연결 창. +- 원본 로직(master_data)은 고치지 않음 — 앱 코드가 셈해 입력값을 채움. + +## 2. 거리이정표 화면 (254 · sub8 / 255 · sub1 / 256 · sub4) + +지금 +- 기준점 = 단일 객체 `base`(+ 경로 `via_base`) — TS · PY · 시험 13곳이 고정 id `"base"` 를 씀. +- 다이어그램 — 항목 12px · 거래처 13px · 주소 한 줄 말줄임 · 상자 높이 고정(124×58 · 148×58) · 기준점 상자 = 이름만 · 연결 점 = 거래처 오른쪽 가운데 · 선 글 = 한 줄 「도로 km」 · 방위 배치 = 고정 반지름(R_BASE 390 · R_ROUTE 280 · 거리 무관). +- 상자 누름 모달 제목 = 「항목 정보 — 이름」(표 머리 「항목」). +- 시점 모달 = 중점 측점 m · 설명 줄 · 지도 = B01 타일 지도(`buildAddressMap`) · 시점만. +- 거래처 · 기준점 모달 = 제목 h3 · 「소재지 — 주소 찾기 · 지도에서 고르기(…)」 h4 · 주소 · 지도 사이 구분 없음 · 기준점 이름 칸 줄바꿈. +- 경로 탭 — 거래처 · 기준점만 끌기(시점 고정) · 기준점 옮기면 기준점→시점 구간만 다시 · 다시 열면 기준점→시점 선 없음 · 실패 안내 없음. +- 그림 — `auto/<경로 id>.png`(거래처 구간만 · 길찾기 때) · 화면이 안 씀 · 장소 그림 없음. + +정함(브레인) +- 여러 기준점 — `bases[]`(id · 이름 · 주소 · 좌표 · pos · legs · `to` = 시점 또는 다른 기준점 id) · 경로 `to`(시점 또는 기준점 id) · 옛 `base` · `via_base` 는 읽을 때 옮김(지우지 않음 · 저장 때 새 꼴). 돌기(A → B → A) 막음. +- 도로 구분 — 시점에 닿는 기준점 구간 = 1차포장 · 그 밖 기준점 구간(기준점 → 기준점 · 거래처 → 기준점) = 2차포장 · 거래처 → 시점 = 포장 · 비포장은 사용자 고름(지금과 같음). +- 선 길이 = 거리 비례(로그 · 최소 · 최대 둘레 안) — 너무 멀어지지 않게. +- 연결 점 = 상자 왼쪽 위 안쪽 · 기준점 상자에도. +- 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 없음). +- 시점 끌기 = 길찾기용 시점 자리 덮개(`site.start_pin` · 노선은 그대로). +- 장소 그림 = `milestone/place/<상자 id>.png`(작은 지도 · 표지) · 경로 그림 = `milestone/auto/<경로 id>.png`(경로 탭과 같은 전체 경로 · 기준점 사슬 포함) · 상자 · 경로 지우면 [저장] 때 지움 · 화면 GET 에 `place_images` 로 알림. + +## 3. B09 (257 · sub3) + +지금 +- 위 탭 = 총괄설계내역서 · 설계내역서 따로. +- 단가연결 칸 = 글(「연결됨 ▾」 등) · 머리 줄바꿈 안 됨(템플릿이 글로 넣고 nowrap · B09 css normal). +- 빨강 이동 상자 = 설계내역서 · 묶음 탭만 · 나머지 탭 이동 = 보통 강조(14% 바탕). +- 자재단가대비표 적용 단가 칸 = 22% 바탕 · 왼쪽 누름 메뉴만(우클릭 없음). +- 비고 「빈 줄 N」 = 제목 · 묶음 줄(Calc_Bill py · ts 짝). +- 총공사비 = 몸 줄(따라 스크롤) · 템플릿 `total` = 아래 고정 줄(tfoot sticky). + +정함(브레인) +- 위 탭 「설계내역」 = 아래 총괄설계내역서 · 설계내역서. +- 단가연결 칸 = 램프(초록 연결 · 노랑 없음 · B08 구조물집계표 꼴) · 누름 동작 그대로 · 풍선 = 무엇에 연결 · 머리 「단가↵연결」 · 폭 줄임. +- 이동해 온 줄 = 모든 탭에서 같은 빨강 상자. +- 적용 단가 = 초록 테두리(입력 칸 테두리와 다른 색) · 우클릭 「적용 단가 바꾸기」(출처 목록). +- 「빈 줄 N」 지움(「(제외금액)」 은 둠). +- 총괄설계내역서 · 공사원가계산서 총공사비 = 템플릿 아래 고정 줄(`total`) · 그 줄 이동 누름은 빠짐. + +## 4. 표 양식 · B05/B06 패널 (258 · sub5) + +지금 +- 거리이정표 표가 폭을 다 안 씀 — 템플릿이 표에 인라인 `width: Σpx` 를 박아 B08 css `width:100%` 를 이김(템플릿과 충돌). +- 입력 칸 테두리 = 243-1(`td.is-can-edit` outline)이 246-1(사용자 128)에서 빠짐 · 시험 셋이 빠진 꼴을 지킴. +- 구조물 조합 미지정 = 노랑(`--color-warning`) · 구조물리스트 램프 = 회색(`--color-text-muted`). + +정함(브레인) +- 템플릿 표 폭 = CSS 변수(`--dt-w`) — 페이지 css 가 덮을 수 있게 · 거리이정표 표 = 100%. +- 입력 칸 테두리 되살림 — 템플릿 전체 · 옅게(예전 60% → 25~30%) · outline(빨강 이동 상자는 box-shadow). +- 미지정 = 회색(구조물리스트 램프와 같은 색). + +## 5. B06 횡단 상세 (259 · sub7 / 260 · sub6) + +지금 +- 상세 모달 = `B06_Section_UI_Cross_Detail*.ts` · 선은 교차점에서 나눔(`splitAtCrossings`) · 암 경계선은 안 들어감(그리기만). +- 고름 — 선 여럿 고르면 길이 합 · 닫힘이면 면적 · 영역 안 누름 고르기 없음 · 채움 강조 없음 · 표 칸 누름 = 길이 · 면적 안 보임 · 저장된 선을 그래프에서 다시 안 고름. +- ⚠ 공용 `A00_Common/b_svg_zoom_pan.ts` — 가운데 버튼 팬 뒤 `moved` 가 안 풀려 왼쪽 누름을 모두 삼킴 → 팬 한 번 뒤 고르기 · 길이 표시가 멈춤(「길이 정보 안 나옴」의 까닭). +- 표 강조 = 칸마다 빨강 inset 테두리 → 붙은 칸에 빨강 · 회색 · 빨강 겹선 · `tr:has(.is-picked)` 가 다른 묶음까지 물듦. +- 그래프 아래 = 글 상자(`structure_note` · 2000자) → B07 이 표 위에 글 줄로 놓음(`_cross_note_entities`). +- CAD 는 iframe(`ui_template/cad_host`) · 모달 안 빈 도면 이미 씀(M02 · B07 도면 관리). +- 횡단배수 자료 = `pipe_points.json`(관경 · 유입 · 유출 형식 · 찰/메 · 높이 · 길이 · 도수로/돌붙임 추가 = 하나 · 쪽 없음) · 관 길이 L = `design.pipe_length_m`(주인 측점). + +정함(브레인) +- 암 경계선도 나눔 · 고름(서버 `pick_lines` · 스키마 포함 다섯 곳). +- 닫힌 영역 = 영역 안 누름으로 고름(열린 영역 제외) · 채움 강조 · 면적 · 둘레 보임 · 선 = 길이 · 기울기 · 수평 · 높이 · 표 칸 누름 = 그 칸 선 · 영역을 그래프에서 고르고 같은 정보. +- 공용 팬 `moved` 고침(왼쪽 누름 때 풂) — 거리이정표 다이어그램도 같은 줄을 씀. +- 표 강조 = 고른 칸 묶음 바깥 한 줄 · 겹선 없음 · 다른 묶음 안 물듦. +- 글 상자 → 빈 CAD 도면(바탕만) · 새 칸 `structure_note_cad`(엔티티) · 옛 글이 있으면 처음 열 때 글로 옮김 · B07 = CAD 칸이 있으면 표 위에 그 엔티티(좌상 맞춤 · 크기 = 범위) · 없으면 옛 글 줄. +- 미리 넣음 = 횡단배수 주인 측점만 · 비었을 때만(사용자 그림 덮지 않음) · 「NO.측점」 · 원 안 「배수관 / Φ관경m/m / L=길이m」 · 관보호공(유입 · 유출) 형식(찰|메, H=높이) L=길이m · 도수로/돌붙임 추가 = 「관유출부보강(돌붙임,찰) 면적m2」(쪽 칸이 없어 유출부로 · 사용자가 고침). diff --git a/docs/raw/요구_1010_2/조사_AK_정수맞춤_측점겹침.md b/docs/raw/요구_1010_2/조사_AK_정수맞춤_측점겹침.md new file mode 100644 index 000000000..aedb6fdb9 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AK_정수맞춤_측점겹침.md @@ -0,0 +1,102 @@ +# 조사 AK — 정수 맞춤 · 구조물 측점과 일반 측점 겹침 · 강조 (요구 166-3 · 166-5) + +기준 = main `2399c533c` · 코드 읽기 둘(입력 길 · 측점 합침) + 브라우저 재현(검증_랩탑메인 · 저장 안 함 · 되돌림 끝). + +## 1. 한눈 결론 + +- 사용자 방향(전처리 관 지점 = 반올림 정수 · 뒤 입력도 정수)은 **새 입력에는 대부분 이미 들어가 있음** — 234-1(`994b91636`, 10-10 19:05)이 폼 · 끌기 · 우클릭 · 방향키 · 배수유역도 · B06 옮김 · 서버 자동 배치 · 노선 재투영을 한 규칙(`snapStructureStation` · 일반 측점 1 m 안이면 그 측점 · 아니면 정수 m)으로 묶음. +- **옛 소수 자료는 그대로** — 서버 확정 목록 재계산이 0.01(`common_util_drainage_detail.py:487`)이라, [정수 맞춤]을 누르기 전까지 소수(95.38 · 484.213 …)로 남음. 단추는 세션 초안만 바꾸고 [저장]해야 남음. +- **「횡단배수 근처 2개씩」= 옛 소수 측점이 안 지워진 것** — 관을 옮기면(정수 맞춤 포함) 화면은 새 측점을 더하지만 종단 측점 목록(서버 것)에는 옛 소수 측점이 남아 ▲▼ 단추 · 표 열 · B06 카드가 두 벌. [저장] 뒤에도 서버 증분 병합이 옛 측점을 못 지우는 경우가 있음(새 자리가 일반 측점이면 병합을 건너뜀). 실제 자료 13 프로젝트에 일반 + 소수 같은 정수 m 쌍 22건(예 440 · 440.241). +- **구조물 측점이 일반 측점을 덮는 규칙이 화면마다 다름** — 서버는 일반 측점 이름(id · 종류 · 파일)을 남기고 구조물은 글(라벨)만 얹음 · 종단 그래프 · 3D 는 구조물을 남기고 일반을 지움(0.1 m) · 표 · ▲▼ 는 둘 다 남김 · B06 카드는 따로 지우지 않음. 판정 폭도 0.005 · 0.006 · 0.01 · 0.02 · 0.05 · 0.1 · 0.51 · 1.0 m 로 갈림. +- **정수 맞춤 뒤 더 잦아질 강조 흠** — 관이 일반 측점에 딱 맞으면(예 960.657 → 960) 고름이 카드 id(일반 측점)로 풀리는데 그래프 · 3D 는 그 id 를 지워 둬서 선 · 띠가 안 켜짐(표 열만 켜짐 · 다시 누르면 풀림). 브라우저 재현 = 목록 48+0 누름 → 표 48+0 · 유역 10 · 관 마커는 켜짐, 그래프 선 0. + +## 2. [정수 맞춤] 단추 + +| 항목 | 내용 | 자리 | +| --- | --- | --- | +| 자리 | B05 · B06 좌측 구조물 목록 머리(같은 패널) | `B05_Profile_UI_Structures_Panel.ts:545-551` → `_Structures_Snap.ts:113-127` | +| 대상 | 관 + 구조물 — 구조물은 기준 · 시작 · 끝 중 하나라도 소수면 · 관은 기준만(0.51 m 안에 다른 관이 있으면 건너뜀) | `_Structures_Snap.ts:48-53, 64-88` | +| 규칙 | 일반 측점(시점 · 20 m 격자 · 종점) 1 m 안이면 그 측점 · 아니면 정수 m · 종점에 붙으면 소수 그대로 | `common_util_structure_station.ts:39-48` · `.py:44-55` | +| 실행 | 확인 창 「소수 기준 측점 N건 … [저장]을 눌러야 남습니다」 → 구조물 한 번에 · 관은 하나씩(관마다 세부유역 다시 셈 POST — 11개면 11번) | `_Structures_Snap.ts:129-160` | +| 저장 | 세션 초안만 · [저장] 때 정본 | `B05_Profile_Api_Pipes_Draft.ts:31-36` | +| 계획고 편집 키 | 관 · A군만 따라감 · C · D군 구간 측점은 안 따라감(저장 때 서버가 버리고 알림) | `_Page_Structures.ts:107-121` · `Engine_Grade_Alignment.py:217` | +| 안 하는 것 | 관 시작 · 끝(독립 기슭막이 구간) · 종점에 붙은 소수 · 건너뛴 관 | `_Structures_Snap.ts:91-98` | + +## 3. 입력 길별 정수 처리(지금) + +정수(맞춤) — 폼 [추가] · [수정](친 칸) · 알약 끌기 · 측점선 끌기 · 우클릭 추가(종단 · 3D · 배수유역도) · 방향키 ◀▶ · 배수유역도 끌기 · 추가 · B06 관 옮김 · 추가 · 서버 자동 배치(`snap_candidates`) · 노선 재투영. + +정수가 안 나오는 길: + +| # | 길 | 지금 | 자리 | +| --- | --- | --- | --- | +| 1 | 서버 확정 목록 재계산 | 0.01 — 옛 소수 관이 계속 소수로 저장 | `common_util_drainage_detail.py:487` | +| 2 | 안 고친 폼 칸 · 0.005 이하 [수정] | 옛 값 그대로(단추 몫) | `_Structures_Fields.ts:184-187` · `_Drainage_Panel.ts:620` | +| 3 | C군 계산형 구간 — 기준이 옛 소수 · 길이 1 m 미만 | 시작 · 끝 날값 | `_Structures_Panel_Commit.ts:313-316` | +| 4 | 콘크리트 포장 나눔 경계(세월교 ± 월류폭 / 2) | 홀수 폭이면 .5 | `_Structures_Pavement.ts:24-33, 63-69` | +| 5 | 종점에 붙음 | 종점 소수 그대로 | `common_util_structure_station.ts:46-47` | +| 6 | 관 시설 시작 · 끝(독립 기슭막이) | 어디서도 안 맞춤 · 그런데 측점으로 심김 | `_Structures_Snap.ts:93-94` · `B05_Profile_Engine_Sections.py:285-286` | +| 7 | 옛 측점 이관 · [초기화] 스냅샷 | 날값 | `B05_Profile_Structures_Migration.py:126-151` · `common_util_initial_snapshot.py:260-` | +| 8 | 같은 정수로 두 관이 모임 | 끌기 · 방향키 · 폼 · B06 옮김이 안 막음 → 서버 중복 제거로 하나 · 그 속성 사라짐 | `common_util_drainage_detail.py:487` | +| 9 | 「일반 측점」 목록이 곳마다 다름 | 서버 20 m 고정 · 화면 횡단 상세 · B05 읽기 재투영 [0] · B04 1 m 인덱스 | `common_util_structure_station.py:58-60` · `B05_Profile_Engine_Sections.py:187-192` | + +## 4. 「2개씩」 재현(브라우저 · 검증_랩탑메인 · 저장 안 함) + +- 처음: 관 11 개 모두 소수(95.38 · 195.3 · 292.22 · 387.68 · 484.213 · 581.902 · 676.591 · 769.28 · 864.968 · 960.657 · 1057.346 — 「spacing」 자동 배치) · 그래프 선은 관마다 하나. +- [정수 맞춤] 뒤(초안): 관 = 95 · 195 · 292 · 388 · 484 · 582 · 677 · 769 · 865 · 960 · 1057(정수). + - 종단 ▲▼(계획고) 측점 = 95 · **95.4** · 195 · **195.3** · 292 · **292.2** · **387.7** · 388 · 484 · **484.2** · **581.9** · **676.6** · 677 · 769 · **769.3** · 865 · **960.7** · 1057 · **1057.3** — 굵은 것이 옛 자리(지워지지 않음). + - 582 는 새 자리가 빠지고 옛 581.9 만(0.1 m 안 중복 빼기가 옛 것을 남김). + - 표에도 옛 자리 열이 남아 4+15 · 5+0 칸 글자가 겹침. + - B06 카드 = 옛 「4+15 95.4」 · 「48+1 960.7」 카드가 그대로 · B06 목록 4+15 누름 = 고름 95.38(옛 자리). +- 까닭: 그래프 · ▲▼ · 표의 측점 목록 = 서버 종단 측점(옛 소수 irregular 포함) · 화면은 초안 관으로 새 측점만 더함 · 서버가 다시 만들기 전까지 둘 다. +- [저장] 뒤에도 남는 경우(코드): 증분 병합(B05 확정 `B05_Profile_Router_Confirm.py:346-386` · B06 측점 저장 `B06_Section_Router_Stations.py:145-228`)은 일반 측점을 안 건드리고(`Confirm.py:293`) irregular 만 돌려줌(`Engine_Sections.py:592-598`) → 새 자리가 일반 측점이면 0 건이라 병합을 건너뜀(`Confirm.py:377-378` · `Stations.py:192`) → **옛 소수 측점 · 카드가 남고, 일반 측점의 옛 구조물 글도 남음**. 전체 재생성(`run_section_generation`)만 깨끗해짐. + +## 5. 구조물 측점 ↔ 일반 측점 규칙(지금) + +| 자리 | 같은 자리 판정 | 남기는 것 | +| --- | --- | --- | +| 서버 측점 만들기 | 0.005 m | 일반 측점 id · 종류 · 파일 이름 + 구조물 글만 얹음(`Engine_Sections_Core.py:343-357, 412-422, 469-471`) | +| 종단 그래프 선 · 3D 띠 | 0.1 m | 구조물 측점 · 일반 지움 · 관이 떠나면 곧장 되살림(`B05_Profile_UI_Profile_Render.ts:231-240` · `_Page_Stations.ts:50-53`) | +| 표 열 | 0.1 m | 둘 다 · 일반 열 값만 비움(`_Profile_Table.ts:595-604`) | +| ▲▼ 단추 | 0.1 m | 둘 다(0.1 m 안만 하나) · 23 px 밀어냄(`_Profile_Edit.ts:358-386`) | +| B06 카드 | 없음 | 카드마다 하나 · 소수 카드가 파일 이름 차례로 앞에 섬(`B06_Section_Router.py:273`) · 일반 카드도 관 벽을 같이 그려 벽 고름이 두 카드에 켜짐 | +| 계획선 변화점 | — | 두 측점 모두 꼭짓점 → 아주 짧은 구간 기울기(실측 899.91 → 900 −78.83%) | + +겹침으로 보이는 경우(코드 · 실자료): +1. 0.005 < 거리 ≤ 0.1 m — 그래프 · 3D 하나 · 표 빈 열 둘 · ▲▼ 둘 · 카드 둘 · 짧은 구배 칸. +2. 0.1 < 거리 < 0.51 m(440 · 440.241) — 선 둘(글 「22+0」 같음) · 3D 띠 둘 겹침 · 표 열 둘 · ▲▼ 둘 · 카드 둘. +3. 0.51 ≤ 거리 ≲ 2 m — 모두 둘(4 px 간격). +4. B06 일반 카드 440 고름 → 종단 선 · 좌측 폼은 관(440.241). +5. B05 일반 선 440 고름 → 관 폼 · 지도 관 마커 · 관 알약도 켜짐. +6. 같은 고름 값을 B05 · B06 이 다른 선으로 켬(보기 · 복원 차례 따라 바뀜). +7. 구조물 측점이 일반 측점에 딱 맞음(합쳐짐) → 고름이 일반 측점 id → 그래프 · 3D 가 지운 id 라 선 · 띠 · 핀 꺼짐 · 표 열만(재현 48+0). +8. 관을 옮긴 뒤 다시 만들기 전 — 4장. +9. C · D 구간형 · 기슭막이 시작 · 끝 — 서버 카드 · 표 열 · ▲▼ 는 있는데 화면 측점 목록(A군 관만)에 없어 그래프 선 · 3D 띠 없음. + +## 6. 3번 물음 — 「자기 측점이 없는 구조물」 + +- 측점 = 횡단 카드 한 장 · 종단 표 한 열 · 그래프 선 한 줄 · 3D 띠 하나. 20 m 일반 측점 + 구조물 측점. +- 지금 구조물 측점을 만드는 것: 관(배관 · 세월교 · BOX — A군)은 화면 · 서버 모두 · C · D 구간형(옹벽 등 시작 · 기준 · 끝)은 서버만(카드 · 표 열은 있고 그래프 선 · 3D 띠는 없음) · 점형(위험표시 등 E군)은 어디서도 안 만듦(`B05_Profile_Structures_Schema.py:143-157` · `_Page_Structures.ts:148-169` · `B06_Section_UI_Section_View_Profile.ts:90-110`). +- 그래서 위험표시(6+7.86)를 고르면 B05 표에 켤 열이 없음 · B06 은 가장 가까운 카드(6+0)로 감(`B06_Section_UI_Page_Structure_Pick.ts:102-104`). +- 5번과 같은 뿌리 — 「어떤 구조물이 자기 측점을 갖는가」를 한 규칙으로 정해야 겹침 · 강조가 한 벌이 됨. + +## 7. 고칠 방향(제안 · 구현 전 사용자 확정) + +1. 측점 한 벌 규칙 — 서버가 만든 종단 측점(kind) 하나를 그래프 · 3D · 표 · ▲▼ · B06 카드 · 고름이 그대로 씀 · 화면 쪽 따로 지우기(0.1 m) · 맞추기 폭을 없애고 판정 폭 한 값. +2. 구조물 측점이 위 — 같은 자리(정수 맞춤 뒤 = 딱 같은 m)면 측점 하나 · 그 측점의 주인 = 구조물(종류 · 이름 · 고름 id) · 일반 측점은 「비활성」 표시만 남김 · 구조물이 떠나면 일반 측점 되살아남. +3. 옮김 · 저장 때 옛 측점 지움 — 증분 병합이 옛 소수 측점 · 옛 구조물 글을 지우고(새 자리가 일반 측점이어도) · 초안 단계 화면도 옛 측점을 숨김. +4. 정수 빈틈 메움 — 서버 확정 목록 재계산 · 관 시작 · 끝 · C군 계산형 · 포장 나눔 · 이관도 같은 맞춤 · 같은 정수에 두 관 = 막고 알림 · 「일반 측점 목록」 한 벌(서버 · 화면 같은 것). +5. 옛 자료 정리 — [정수 맞춤] 단추는 두고, 저장 때 서버가 측점을 전체 다시 만들어 옛 소수 측점 · 카드를 지움(실자료 22 쌍). +6. 강조 — 고름이 같은 자리 구조물 측점 id 로 풀리게(카드 id 앞섬 고침) · 3D · 그래프 · 표 · 카드가 같은 id. + +⬜ 사용자 확정 +- 자기 측점을 갖는 구조물 범위 — (가) 관(A군)만 · (나) 관 + 기준 측점이 있는 구조물(옹벽 · 돌쌓기 등 기준 측점 하나) · (다) 지금 서버처럼 구간형은 시작 · 기준 · 끝 모두. +- 종점(EP · 소수) 근처 구조물 — 종점에 붙여 소수 허용인지, 정수로 둘지. +- 옛 자료 — 저장 때 자동 정수 맞춤까지 할지, 단추로만 할지. + +## 8. 사용자 답(167) 뒤 — 리팩터링 필요 판단 + +- 겹침은 구조물 측점을 하나씩 더할 때마다 화면 · 서버에 「같은 자리 처리」를 따로 덧대 온 결과 — 측점 목록을 만드는 곳이 넷(서버 전체 생성 · 서버 증분 병합 둘 · 화면 bridge)이고 지우기 · 맞추기 폭이 여덟 갈래. +- 덧대기로는 다음 구조물 · 다음 화면에서 또 샘 → 측점 목록 규칙 한 벌(정수 m · 같은 m = 하나 · 주인 = 구조물)을 서버 · 화면이 같이 쓰게 바꿈(PLAN 272). +- 측점 갖는 구조물 범위 = 지금 서버 규칙 그대로(관 · 독립 기슭막이 시작 · 기준 · 끝 · 측점 심는 종류) — 화면도 같은 범위로 맞춤(지금 화면은 관 · A군만이라 구간형 선이 빠짐). + diff --git a/docs/raw/요구_1010_2/조사_AL_운반단가창.md b/docs/raw/요구_1010_2/조사_AL_운반단가창.md new file mode 100644 index 000000000..ae232ccd4 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AL_운반단가창.md @@ -0,0 +1,47 @@ +# 조사 AL — 「운반 단가」 창 오른쪽 계산 · 운반거리 칸 (요구 170-2 · 171) + +그림 = `그림_171_운반단가창.png`(운반 단가 — 전석운반 · 단가산출 GF000284 · 포장 23.3 + 비포장 0.577 km). +창 = `B09_Estimation/B09_Estimation_UI_Haul_Modal.ts` 한 벌 — B08 거리이정표 우클릭 [단가연결] · B09 설계내역서 운반공 줄이 같이 씀(276). + +## 1. 한눈 결론 + +- 오른쪽 계산 = 글 상자(`pre`) 하나에 「거리 · 8-2-8 사이클 · 단가산출 글」을 줄바꿈으로 이어 붙임 — 제목 · 칸 · 결과 구분 없음. +- 서버는 줄마다 갈래(입력 · 중간값 · 수량 · 금액 · 계)를 알고 셈 — 미리보기 길(`logic-preview`)이 글로 합쳐 보낼 뿐 → 갈래를 붙여 보내면 화면이 컨테이너로 나눔 · 계산은 안 바뀜. +- 그림의 「사이클시간 — 건설품셈 8-2-8」 덩어리는 GF000284 셈에 안 쓰임 — 이 로직 입력은 「운반거리」 하나 · 자기 식(산림품셈 10-12-1 Cmt)으로 다시 셈. 화면이 「운반거리」 칸만 보고 8-2-8 을 띄움(`CYCLE_FILL` 에 운반거리 포함) → 사이클이 두 번 보여 헷갈림. +- 「여러 단가산출」 두 경우 — (가) 조합(사전조합 · 구조물 조합) 연결: 담은 로직이 여럿인데 오른쪽 = 안내 두 줄뿐(셈 안 보임) (나) 로직이 다른 로직을 부름: 서버는 하위 산근까지 셈(`Solver.blocks`) · 미리보기는 맨 위 하나만 보냄. 지금 운반 로직 GF000134 · GF000284 는 하위 없음. +- 운반거리 칸 높이 — 고르기 칸 = 작은 크기(30px · `ui-select--sm`) · 숫자 칸 = 보통 크기(42px · `ui-input`) · 칸 만드는 공용 함수 `createValueControl`(M02_Labor_Values.ts) 한 곳의 차이 → 같은 함수를 쓰는 화면 셋(운반 단가 창 · M02 일위대가 입력 창 · 프로젝트 값 카드) 모두 같은 어긋남. +- 「거리에서」 — 지금 = 글 표시 · 손으로 고치면 사라짐(`own` 에 넣음) · 칸을 비우면 거리 값으로 돌아감. 단추로 바꿔도 저장 꼴(`own` = 사용자가 덮은 칸)은 그대로 — 화면만 고침 · 서버 짝 고침 없음. + +## 2. 1번 — 컨테이너 꼴(제안) + +차례대로 쌓음 · 컨테이너마다 제목 줄 · 결과 = 바닥 한 줄(굵게). + +``` +① 거리 — 거리이정표 포장 23.3 · 비포장 0.577 바닥: 운반거리 23.877 km +② 사이클시간 — 건설품셈 8-2-8 q · n · t1 · t2 · Cm 표 바닥: Cm 100.86 분 (로직이 Cm · 사이클시간 · t2 를 받을 때만) +③ 단가산출 GF000284 덤프운반 / ㎥ — 산림품셈 10-12-1 + 입력값 재료 · 운반거리 · 지역 · 보정작업 + 중간값 이름 · 값 크게 / 식 작게 흐리게(K · E0 · L · 감마 · Q1 · q · n · Cmt · Q) + 수량 굴착기 1/Q1 · 덤프트럭 1/Q + 금액 표 장비 × 노무비 · 재료비 · 경비 + 바닥: 단가 원/㎥ (노무 · 재료 · 경비) +④ 하위 단가산출 · 조합의 다음 로직 — ③ 과 같은 꼴로 이어서 +``` + +- 서버 = 미리보기 응답에 덩어리 목록(제목 · 출처 · 줄 갈래 · 계) 더함 · 글 줄(`lines`)은 그대로 — B09 단가산출근거 탭 글과 같은 원천(`render_sangeun_text`). +- 조합 = 담은 로직마다 미리보기 → 덩어리 차례 · 조합 전체 단가는 지금처럼 [적용] · [저장] 뒤 서버 셈(조합 합계 미리보기는 따로 큰 일). + +## 3. 2번 — 운반거리 칸(제안) + +- 높이 = 공용 칸 만들기에서 숫자 칸도 작은 크기(`ui-input--sm`) → 세 화면 같이 맞음. +- 「자동계산」 단추 = 거리 값을 받는 칸(운반거리 · Cm · 사이클시간 · t2) 옆 · 켜짐(강조) = 거리에서 가져온 값 · 손으로 고치면 꺼짐 · 다시 누르면 거리 값 넣고 켜짐 · 거리 없는 줄 = 단추 없음 · 공용 단추 꼴(`is-active` · `aria-pressed`). + +## 4. 참고 — 로직 방법(사용자 판단 거리) + +- GF000284 는 운반거리 전체(포장 + 비포장)를 이 로직 표의 덤프트럭 속도(적재 5 · 공차 6 km/h) 하나로 셈 — 포장 · 비포장을 나눠 쓰지 않음. 바꿀지는 단가산출 로직 일(사용자와 함께). +- 옵션 없음 · 임의 아님(요구 172 답) — 원문 산림품셈 10-12-1 주② 표가 속도 한 쌍만 정함(V1 적재 5 · V2 공차 6 km/hr · t2 = (L/5 + L/6)×60 · 적하 「불량」 · 대기 「진입불편」) · 로직 표 QF000337 = 원문 그대로 · 포장 · 비포장 속도 나눔 = 건설품셈 8-2-8(거리이정표 사이클) — 그 값을 받는 로직 = 골재운반 GF000134~136(Cm) · 덤프트럭 자재운반 10-9-1(사이클시간). + +## 5. 사용자 확정 + +- 단가산출 로직 · 셈 안 건드림 · 화면(프론트) 먼저(요구 172). +- 물음 셋 = 추천대로(브레인 정함 · 사용자 바꿀 수 있음) — 조합 · 하위 산근 같은 꼴 · 8-2-8 칸 = 로직이 Cm · 사이클시간 · t2 를 받을 때만 · 칸 높이 = 공용 함수(세 화면 같이) → PLAN 277. diff --git a/docs/raw/요구_1010_2/조사_AM_알약고름.md b/docs/raw/요구_1010_2/조사_AM_알약고름.md new file mode 100644 index 000000000..011c0b9a3 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AM_알약고름.md @@ -0,0 +1,35 @@ +# 조사 AM — 종단도 아래 알약 고름 · 풂 (요구 178 · 고치지 않음 · 브레인 직접 코드 확인) + +알약 = 종단도 아래 「구조물」 줄 단추(`B05_Profile_UI_Structures_Marks.ts:205` — 누름 = 골라져 있으면 `onSelect(null)` 풂 · 아니면 `onSelect(id)`). +누름 뒤 = `B05_Profile_UI_Profile_Render.ts:311` — 종단도 안 값(알약 · 측점선)을 먼저 바꾸고 → 보기 처리(`onStructureSelect`)로 넘김(B05 = `design_view_profile.ts:176` · B06 = `B06_Section_UI_Section_View_Profile.ts:233`). + +## 1. 한눈 결론 + +| 보기 | 측점 없는 구조물 고름 | 풂(알약 다시 누름) | +|---|---|---| +| B05 종단 | 일반 구조물 = 알약만(맞음) · 측점선 없는 관 시설(물넘이포장 등) = 같은 m 일반 측점선도 켜짐 | 길 끝까지 이어짐(고름 값 비움 · 폼 닫음) — 안 풀리는 화면이면 그 경우 재현 필요 | +| B06 횡단 | 가까운 횡단 카드를 골라 그 카드 측점선이 켜지고 알약과 같이 남음(옛 규칙 177-3 · 53-2) | **끊김** — 처리 첫 줄이 풂 신호를 버림 | + +## 2. B06 풂 끊김 (확실) + +- `B06_Section_UI_Section_View_Profile.ts:233~235` — `marks.find(structure_id === structureId)` → 풂(null)은 못 찾음 → `if (!hit) return`. +- 그래서 고름 한 칸(`structure-pick`) · 고른 횡단 카드 · 왼쪽 폼 · 알약 기억(`markedId`)이 그대로 남음 → 종단도는 그 자리에서 잠깐 꺼졌다가 카드를 다시 보일 때(`showCard` · `selectMark`) 다시 켜짐. +- B06 고름 칠하기(`B06_Section_UI_Page_Pick_Sync.ts`)는 `from "b06"` 을 되받지 않음 → 값이 안 비면 풂 길(`releaseCrossHighlights`)이 안 돎. + +## 3. B06 측점 없는 구조물 = 근처 측점선 켜짐 (규칙대로 도는 것) + +- 일반 구조물 알약 → `edit.selectStructure` → 폼 고름(`B06_Section_UI_Page_Structures_Panel.ts:215`) → `focusChainage(focusOf(구조물))`(227) = 그 자리 · 덮는 범위에서 가장 가까운 횡단 카드 고름 → 그 카드 측점선 켬. +- 이어 `markStructure(id, true)`(230) → `selectMark` → `setSelectedStructure(id, keepStation = true)`(`design_profile.ts:615`) = 「측점선 없는 구조물이면 카드 측점선을 지킴」(177-3) → 알약 + 근처 카드 측점선이 같이 남음. +- 관 알약(B06) → `selectCard(nearestCard(기준점))` = 가까운 카드 → 같은 꼴. + +## 4. B05 측점선 없는 관 시설 = 같은 m 일반 측점선 켜짐 + +- 관 알약(`pipe-*`)은 고름 값에 구조물 id 없이 자리(m)만 적음(`design_view_profile.ts:176~185` · `rememberPick(…, null)`). +- 종단도 칠하기(`design_pick_profile.ts:52` `profilePickOf`) 차례 = ① 구조물 id ② 같은 m 구조물 측점 ③ **같은 m 일반 측점 카드** ④ 관 알약 → 측점선 없는 관(물넘이포장 등)이 일반 측점과 같은 m 면 ③ 에서 일반 측점선을 켬(구조물 · 관 기준 측점 = 정수 m · 20 m 격자와 자주 겹침) · 알약은 0.51 m 짝짓기로 같이 남음. +- 일반 구조물(id 있음)은 ① 에서 끝 — 측점선 안 켬 · 단 기준점 0.51 m 안에 다른 구조물 측점선이 있으면 그 선을 켬(`stationIdAtStructure`). + +## 5. 고칠 방향 (제안만 · 사용자 판단) + +- B06 풂 = B05 와 같게 — 알약 다시 누름이면 고름 한 칸 비움 → 풂 한 길(`release`)이 카드 · 폼 · 알약 · 범위 테두리를 끔. +- 측점 없는 구조물 = 알약만 켬(측점선 안 켬) — B06 은 카드를 보여 주되 측점선 강조는 안 함 → 177-3(카드 선 지킴) 규칙을 바꾸는 일. +- 측점선 없는 관 시설 = 고름 값에 알약 id 도 적음(또는 ③ 일반 카드보다 ④ 관 알약을 먼저) → 일반 측점선 안 켬. diff --git a/package.json b/package.json index 53baeccd0..06f974a03 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,13 @@ "type": "module", "scripts": { "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", - "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-cad", + "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-note && npm run build:b07-cad", "build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node", "build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node", "build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node", "build:spreadsheet": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_spreadsheet_node.ts --outDir ../config/spreadsheet_node", "build:b07-extent": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts --outDir ../config/b07_extent_node", + "build:b07-note": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts --outDir ../config/b07_note_node", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", diff --git a/resources/tester/cad/text-inplace-edit.test.ts b/resources/tester/cad/text-inplace-edit.test.ts new file mode 100644 index 000000000..8b870cd5e --- /dev/null +++ b/resources/tester/cad/text-inplace-edit.test.ts @@ -0,0 +1,216 @@ +/** + * 글자 더블클릭 = 그 자리 입력 칸(PLAN 293-1) — prompt 대신 글자 위 입력 칸 · Enter 적용 · Esc 취소 · + * 칸 밖 누름 적용 · 되돌리기 한 번 · 여러 줄(MTEXT)은 Ctrl+Enter 적용 · 키가 CAD 로 새지 않음. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-inplace-edit + */ +import { Point } from "@flatten-js/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../B07_DesignDetail/openwebcad/src/theme.ts", () => ({ + paintColor: (color: string) => color, +})); +vi.mock("../../../B07_DesignDetail/openwebcad/src/state.ts", async (importOriginal) => ({ + ...(await importOriginal>()), + getScreenCanvasDrawController: () => ({ + getScreenScale: () => 2, + worldToTarget: (point: Point) => point, + // 시험 쪽 Point 로 — 앱과 시험이 flatten 을 따로 실어 instanceof 가 갈림 + targetToWorld: (point: Point) => new Point(point.x, point.y), + measureTextScreen: () => 40, + zoomToFitScreen: () => {}, + }), +})); + +import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts"; +import { + closeTextEditor, + editorKeyAction, + handleTextDoubleClick, +} from "../../../B07_DesignDetail/openwebcad/src/integration/text-inplace-edit"; +import { + getEntities, + resetUndoBaseline, + setEntities, + undo, +} from "../../../B07_DesignDetail/openwebcad/src/state"; + +type Listener = (evt: Record) => void; +class FakeElement { + public value = ""; + public className = ""; + public style: Record = {}; + public selected = false; + public removed = false; + public listeners: Record = {}; + constructor(public tagName: string) {} + setAttribute() {} + addEventListener(type: string, listener: Listener) { + (this.listeners[type] ??= []).push(listener); + } + fire(type: string, evt: Record = {}) { + for (const listener of this.listeners[type] ?? []) listener(evt); + } + focus() {} + select() { + this.selected = true; + } + remove() { + this.removed = true; + this.fire("blur"); + } +} + +let created: FakeElement[] = []; +let documentListeners: Record = {}; +const prompt = vi.fn(); +const canvas = { + getBoundingClientRect: () => ({ left: 10, top: 20, bottom: 520, right: 810 }), +} as unknown as HTMLCanvasElement; + +beforeEach(() => { + created = []; + documentListeners = {}; + prompt.mockReset(); + vi.stubGlobal("window", { prompt, dispatchEvent: () => true }); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal("document", { + createElement: (tag: string) => { + const element = new FakeElement(tag); + created.push(element); + return element; + }, + body: { appendChild: () => {} }, + addEventListener: (type: string, listener: Listener) => + (documentListeners[type] ??= []).push(listener), + removeEventListener: (type: string, listener: Listener) => + (documentListeners[type] = (documentListeners[type] ?? []).filter((l) => l !== listener)), + }); +}); +afterEach(() => { + closeTextEditor(false); + vi.unstubAllGlobals(); +}); + +/** 화면 좌표 = 월드 좌표(가짜 제어기) · 캔버스 아래 520 기준으로 y 를 뒤집음 */ +const dblclickAt = (x: number, y: number) => + handleTextDoubleClick({ clientX: 10 + x, clientY: 520 - y } as MouseEvent, canvas); +const key = (editor: FakeElement, k: string, extra: Record = {}) => { + const evt = { + key: k, + ctrlKey: false, + metaKey: false, + isComposing: false, + keyCode: 0, + stopped: false, + prevented: false, + stopPropagation() { + evt.stopped = true; + }, + preventDefault() { + evt.prevented = true; + }, + ...extra, + }; + editor.fire("keydown", evt); + return evt; +}; +const labels = () => getEntities().map((entity) => (entity as TextEntity).getLabel()); +const load = (...entities: TextEntity[]) => { + setEntities(entities, false); + resetUndoBaseline(); +}; + +describe("글자 더블클릭 = 그 자리 입력 칸", () => { + it("더블클릭 → 글자 자리에 입력 칸 · 글 전체 골라짐 · prompt 안 부름 · 캔버스 글자 감춤", () => { + const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }); + load(text); + dblclickAt(101, 200); + expect(prompt).not.toHaveBeenCalled(); + expect(created).toHaveLength(1); + const [editor] = created; + expect(editor.tagName).toBe("input"); + expect(editor.value).toBe("배수관"); + expect(editor.selected).toBe(true); + expect(editor.style.left).toBe("110px"); // 캔버스 왼쪽 10 + 글자 x 100 + expect(editor.style.top).toBe("320px"); // 캔버스 아래 520 - 글자 y 200 + expect(editor.style.font).toContain("10px"); // 글 크기 5 × 배율 2 + expect(text.previewLabel).toBe(""); + }); + + it("Enter = 적용 · 되돌리기 한 번에 옛 글 · 키가 CAD 로 새지 않음", () => { + load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 })); + dblclickAt(100, 200); + const [editor] = created; + const typed = key(editor, "a"); + expect(typed.stopped).toBe(true); + expect(typed.prevented).toBe(false); + editor.value = "횡단배수관"; + const enter = key(editor, "Enter"); + expect(enter.stopped).toBe(true); + expect(editor.removed).toBe(true); + expect(labels()).toEqual(["횡단배수관"]); + expect((getEntities()[0] as TextEntity).previewLabel).toBeNull(); + undo(); + expect(labels()).toEqual(["배수관"]); + }); + + it("Esc = 취소 · 글 그대로 · 감춘 글자 되살림", () => { + const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }); + load(text); + dblclickAt(100, 200); + const [editor] = created; + editor.value = "바꿈"; + key(editor, "Escape"); + expect(editor.removed).toBe(true); + expect(labels()).toEqual(["배수관"]); + expect(getEntities()[0]).toBe(text); + expect(text.previewLabel).toBeNull(); + }); + + it("칸 밖 누름 = 적용 · 가운데 단추(팬)는 닫지 않음", () => { + load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 })); + dblclickAt(100, 200); + const [editor] = created; + editor.value = "옹벽"; + for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 1 }); + expect(editor.removed).toBe(false); + for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 0 }); + expect(editor.removed).toBe(true); + expect(labels()).toEqual(["옹벽"]); + }); + + it("한글 조합 중 Enter 는 적용하지 않음", () => { + expect(editorKeyAction({ key: "Enter", isComposing: true } as KeyboardEvent, false)).toBeNull(); + expect(editorKeyAction({ key: "Enter", keyCode: 229 } as KeyboardEvent, false)).toBeNull(); + }); + + it("여러 줄(MTEXT) = 여러 줄 칸 · Enter 줄바꿈 · Ctrl+Enter 적용 · 줄 늘면 아래로 · 되돌리기 한 번", () => { + const first = new TextEntity("0", "첫 줄", new Point(100, 200), { fontSize: 5 }); + const second = new TextEntity("0", "둘째 줄", new Point(100, 193), { fontSize: 5 }); + first.groupId = second.groupId = "m1"; + load(second, first); + dblclickAt(100, 193); + const [editor] = created; + expect(editor.tagName).toBe("textarea"); + expect(editor.value).toBe("첫 줄\n둘째 줄"); + expect(key(editor, "Enter").prevented).toBe(false); + expect(editor.removed).toBe(false); + editor.value = "첫 줄\n둘째\n셋째"; + key(editor, "Enter", { ctrlKey: true }); + expect(editor.removed).toBe(true); + const lines = getEntities() as TextEntity[]; + expect(lines.map((line) => line.getLabel())).toEqual(["둘째", "셋째", "첫 줄"]); + expect(lines.every((line) => line.groupId === "m1")).toBe(true); + expect(lines[1].getFirstPoint()?.y).toBeCloseTo(186); + undo(); + expect(labels()).toEqual(["둘째 줄", "첫 줄"]); + }); + + it("수량표 글자는 칸을 띄우지 않음", () => { + load(new TextEntity("b08-cross-table", "12.5", new Point(100, 200), { fontSize: 5 })); + dblclickAt(100, 200); + expect(created).toHaveLength(0); + expect(prompt).not.toHaveBeenCalled(); + }); +}); diff --git a/resources/tester/fixtures/b06_261_3_cross_415.json b/resources/tester/fixtures/b06_261_3_cross_415.json new file mode 100644 index 000000000..3e3c74f6e --- /dev/null +++ b/resources/tester/fixtures/b06_261_3_cross_415.json @@ -0,0 +1 @@ +[{"tag":"polyline","cls":"b06-chart__cross-profile","dl":false,"pts":[[949.954642190761,98.34482758620575],[943.9057761359919,103.41302728071172],[937.856910081223,108.574113362354],[931.8080440264539,113.79092159809379],[925.7591779716848,116.3634075493297],[919.7103119169159,116.29158331379654],[913.6614458621468,119.40724533901843],[907.6125798073778,128.6081150062668],[901.5637137526088,136.825681007652],[895.5148476978397,140.7823168804701],[889.4659816430707,144.57612937682438],[883.4171155883017,145.92762751944147],[877.3682495335327,147.27912566205856],[871.3193834787636,149.41340756082334],[865.2705174239946,151.6286595805964],[859.2216513692256,160.15366524808218],[853.1727853144566,170.78189793600617],[847.1239192596876,176.7764088804081],[841.0750532049185,179.99066742232273],[835.0261871501496,182.80751546443977],[828.9773210953805,185.17395284238637],[822.9284550406114,188.64809486547938],[816.8795889858425,194.55915565378862],[810.8307229310734,201.03251903054885],[804.7818568763043,209.94251082695544],[798.7329908215354,218.7469378129739],[792.6841247667663,224.27866211325696],[786.6352587119974,229.81038641354002],[780.5863926572283,240.940372540707],[774.5375266024593,252.87010344444045],[768.4886605476903,262.9864810868107],[762.4397944929212,272.4983955443274],[756.3909284381522,278.89896686698546],[750.3420623833832,283.1707123680587],[744.2931963286142,286.96572253989297],[738.2443302738451,290.14777691893215],[732.1954642190761,293.9478439427878],[726.1465981643071,299.107502492042],[720.097732109538,304.0510471548911],[714.0488660547691,307.82759618752465],[708,311.6041452201596],[701.951133945231,315.9430573299059],[695.902267890462,320.2819694396523],[689.8534018356929,323.72677446237947],[683.804535780924,327.04386372722547],[677.7556697261549,330.0219261474343],[671.7068036713858,332.86733693506136],[665.6579376166169,335.10343334726014],[659.6090715618478,336.86563739726404],[653.5602055070788,338.91952986616036],[647.5113394523098,341.34845203045313],[641.4624733975407,343.8562151149041],[635.4136073427718,346.56547802537034],[629.3647412880027,349.78795092738864],[623.3158752332337,356.6029058679937],[617.2670091784647,363.4178608085988],[611.2181431236957,368.15345755421663],[605.1692770689266,372.88905429983583],[599.1204110141576,374.600193822605],[593.0715449593886,375.75124464189906],[587.0226789046195,376.6134500093462],[580.9738128498506,377.3443707879392],[574.9249467950815,381.8309962067428],[568.8760807403125,389.2387158252522],[562.8272146855435,396.575312876689],[556.7783486307744,403.8079178229121],[550.7294825760055,409.86913566216543],[544.6806165212364,412.4161316918462],[538.6317504664673,415.7644452032679],[532.5828844116984,424.7219689891325],[526.5340183569293,433.67948067726644],[520.4851523021603,436.17899310841756],[514.4362862473913,438.47018259264297],[508.38742019262224,445.02930679232855],[502.33855413785324,452.5733436562465],[496.28968808308423,456.2258788370261],[490.24082202831516,458.10954411740977],[484.19195597354616,460.61677490164794],[478.14308991877715,463.67421054222643],[472.0942238640081,468.8769491155226],[466.0453578092391,477.65517241379195]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[732.1954642190761,325.66273823867266],[726.1465981643071,325.57200524785054]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[726.1465981643071,325.57200524785054],[720.097732109538,325.48127225702837]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[720.097732109538,325.48127225702837],[714.0488660547691,325.3905392662076]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[714.0488660547691,325.3905392662076],[708,325.2998062753855]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[708,325.2998062753855],[701.951133945231,325.20907328456474]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[701.951133945231,325.20907328456474],[695.902267890462,325.11834029374256]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[695.902267890462,325.11834029374256],[689.8534018356929,325.0276073029218]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[689.8534018356929,325.0276073029218],[683.804535780924,324.9368743120997]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[683.804535780924,324.9368743120997],[674.1263500932936,333.0020290517912]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[674.1263500932936,333.0020290517912],[665.7789149377124,342.6802147394211]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[665.7789149377124,342.6802147394211],[641.2227165723032,343.9671505633862]]},{"tag":"polyline","cls":"b06-chart__rock-boundary","dl":false,"pts":[[949.954642190761,104.39369364097477],[943.9057761359919,109.46189333548074],[937.856910081223,114.62297941712302],[931.8080440264539,119.83978765286282],[925.7591779716848,122.41227360409871],[919.7103119169159,122.34044936856556],[913.6614458621468,125.45611139378745],[907.6125798073778,134.65698106103582],[901.5637137526088,142.87454706242102],[895.5148476978397,146.8311829352391],[889.4659816430707,150.62499543159342],[883.4171155883017,151.9764935742105],[877.3682495335327,153.3279917168276],[871.3193834787636,155.46227361559238],[865.2705174239946,157.67752563536544],[859.2216513692256,166.20253130285118],[853.1727853144566,176.83076399077518],[847.1239192596876,182.82527493517713],[841.0750532049185,186.03953347709177],[835.0261871501496,188.8563815192088],[828.9773210953805,191.22281889715538],[822.9284550406114,194.6969609202484],[816.8795889858425,200.60802170855763],[810.8307229310734,207.0813850853179],[804.7818568763043,215.99137688172448],[798.7329908215354,224.79580386774293],[792.6841247667663,230.327528168026],[786.6352587119974,235.85925246830902],[780.5863926572283,246.98923859547602],[774.5375266024593,258.9189694992094],[768.4886605476903,269.03534714157973],[762.4397944929212,278.5472615990965],[756.3909284381522,284.94783292175447],[750.3420623833832,289.21957842282774],[744.2931963286142,293.014588594662],[738.2443302738451,296.19664297370116],[732.1954642190761,299.9967099975568],[726.1465981643071,305.15636854681105],[720.097732109538,310.0999132096601],[714.0488660547691,313.87646224229366],[708,317.6530112749286],[701.951133945231,321.991923384675],[695.902267890462,326.3308354944213],[689.8534018356929,329.7756405171485],[683.804535780924,333.0927297819945],[677.7556697261549,336.07079220220334],[671.7068036713858,338.91620298983037],[665.6579376166169,341.1522994020292],[659.6090715618478,342.91450345203305],[653.5602055070788,344.96839592092937],[647.5113394523098,347.39731808522214],[641.4624733975407,349.90508116967317],[635.4136073427718,352.61434408013935],[629.3647412880027,355.83681698215764],[623.3158752332337,362.65177192276275],[617.2670091784647,369.4667268633678],[611.2181431236957,374.20232360898564],[605.1692770689266,378.93792035460484],[599.1204110141576,380.649059877374],[593.0715449593886,381.8001106966681],[587.0226789046195,382.6623160641152],[580.9738128498506,383.39323684270823],[574.9249467950815,387.8798622615118],[568.8760807403125,395.2875818800212],[562.8272146855435,402.624178931458],[556.7783486307744,409.8567838776811],[550.7294825760055,415.91800171693444],[544.6806165212364,418.46499774661527],[538.6317504664673,421.81331125803695],[532.5828844116984,430.7708350439015],[526.5340183569293,439.72834673203545],[520.4851523021603,442.22785916318657],[514.4362862473913,444.51904864741203],[508.38742019262224,451.07817284709756],[502.33855413785324,458.6222097110155],[496.28968808308423,462.27474489179514],[490.24082202831516,464.1584101721788],[484.19195597354616,466.66564095641695],[478.14308991877715,469.7230765969955],[472.0942238640081,474.9258151702916],[466.0453578092391,483.704038468561]]},{"tag":"polygon","cls":"b06-chart__culvert-basin","dl":false,"pts":[[736.913579741796,343.80933640297974],[732.1954642190761,325.66273823867266],[734.6150106409838,325.66273823867266],[739.3331261637036,343.80933640297974]]},{"tag":"polygon","cls":"b06-chart__culvert-basin","dl":false,"pts":[[738.7040440940075,341.3897899810716],[753.221322625453,341.3897899810716],[752.4954586988808,343.80933640297974],[739.3331261637036,343.80933640297974]]},{"tag":"polyline","cls":"b06-chart__design-cross","dl":false,"pts":[[753.221322625453,341.3897899810716],[801.4280127212174,220.8730647416608],[843.7112234665693,178.58985399634633]]},{"tag":"polyline","cls":"b06-chart__design-cross","dl":false,"pts":[[665.7789149377124,342.6802147394211],[641.2227165723032,343.9671505633862]]},{"tag":"polygon","cls":"b06-chart__culvert-revet is-selectable","dl":false,"pts":[[676.8483398179395,348.72908079419017],[676.8483398179395,333.0020290517912],[668.6823706440014,333.0020290517912],[663.9642551212817,348.72908079419017]]}] diff --git a/resources/tester/helper_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs index 612ce5081..b3a1e2334 100644 --- a/resources/tester/helper_130_b07_plan_manage.cjs +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -140,7 +140,6 @@ const STUBS = { return cad; }, }, - "@ui/ui_template_collapsible": { attachCollapsible: () => {} }, "@ui/ui_template_locale": { currentLanguageIndex: 0, ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }), @@ -233,6 +232,9 @@ function loadTs(file) { cache.set(full, box.exports); const localRequire = (request) => { if (STUBS[request]) return STUBS[request]; + // 공용 카드(269) = 진짜 모듈(가짜 그릇 위에서 돎) + if (request === "@ui/ui_template_collapsible") + return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts")); if (request === "./B07_DesignDetail_Api_Fetch") return API; if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); throw new Error(`stub 없음: ${request}`); diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs index c80f3c94c..147bce9d6 100644 --- a/resources/tester/helper_153_b07_cross_layout.cjs +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -78,7 +78,9 @@ function loadTs(file) { ...loadTs(path.join(UI, "ui_template_elements.ts")), showToast: (message, kind) => toasts.push([kind, message]), }; - if (request === "@ui/ui_template_collapsible") return { attachCollapsible: () => {} }; + // 공용 카드(269) = 진짜 모듈 + if (request === "@ui/ui_template_collapsible") + return loadTs(path.join(UI, "ui_template_collapsible.ts")); if (request === "@ui/ui_template_locale") return { currentLanguageIndex: 0, diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs index a56ff0baa..f58025313 100644 --- a/resources/tester/helper_164_detail_zoom.cjs +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -37,6 +37,8 @@ const STUBS = { "./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} }, // 선 조각 고르기(209-1)는 줌과 무관 — 따로 시험(`test_209_1_detail_select.py`) "./B06_Section_UI_Cross_Detail_Select": { attachDetailSelect: () => ({}), PIECE_HIT_CLASS: "" }, + // 표 위 CAD 칸(260)도 줌과 무관 — 따로 시험(`test_260_note_cad.py`) + "./B06_Section_UI_Cross_Detail_Cad": { mountNoteCad: () => {}, stationNoLabel: () => "" }, }; const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); function loadTs(full) { @@ -90,6 +92,8 @@ function el(tag) { }; return walk(self); }, + // 첫 보기 꼭짓점(295-2) — 이 그림엔 꺾은선 · 다각형 없음 + querySelectorAll: () => [], addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), fire: (type, event) => { const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }; @@ -107,7 +111,7 @@ globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: e const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts")); -// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150) +// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 319,142 = 칸 가운데 · 295-2 첫 보기 = 1배) const AXES = { widthPx: 600, heightPx: 300, @@ -124,7 +128,7 @@ function chart() { const clipGroup = svgElement("g", { "clip-path": "url(#c)" }); const plot = svgElement("g", { class: "b06-chart__plot-layer" }); const center = svgElement("g", { class: "b06-chart__center" }); - center.append(svgElement("line", { x1: 300, y1: 132, x2: 300, y2: 168 })); + center.append(svgElement("line", { x1: 319, y1: 124, x2: 319, y2: 160 })); plot.append(center); clipGroup.append(plot); svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" })); diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index f9fc900b3..9da73a054 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -137,6 +137,10 @@ const panelModule = loadTs( writePendingPipes() {}, }, "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge": { mergeFacilityOptions: (x) => x }, + "../B05_Profile/B05_Profile_UI_Drainage_Ford_Fill": { + fordWaitsOf: () => new Set(), + waitsFordHeight: () => false, + }, "../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({ pipe_points: PIPES, basins: [] }), }, @@ -159,6 +163,24 @@ const panelModule = loadTs( setAdjustSideSlots() {}, }, "./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} }, + // 요구 178 — 알약 측점 짝(진짜 `plantsStation` · 272 측점 심기 한 벌) + "../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), + { + "@ui/ui_template_context_menu": { createMapContextMenu() {} }, + "./B05_Profile_UI_IrregularStations": { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: () => false, + }, + "./B05_Profile_Api_Structures": { + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + }, + "../A00_Common/design/design_selection": selection, + "../common_util/common_util_structure_station": loadTs( + path.join(ROOT, "common_util", "common_util_structure_station.ts"), + ), + }, + ), }, ); diff --git a/resources/tester/helper_166_1_note_options.cjs b/resources/tester/helper_166_1_note_options.cjs new file mode 100644 index 000000000..725dba4cc --- /dev/null +++ b/resources/tester/helper_166_1_note_options.cjs @@ -0,0 +1,45 @@ +/* 166-1 CAD 칸 미리 넣음 옵션 줄 — 진짜 `structureOptionLines`(명세 · 정의 덧칸)를 Node 로 돌림. + * 표준입력 { things: [{type_id, options}], types, definition } → 표준출력 { lines: string[][] } + * (B07 번들 `B07_DesignDetail_Note_Options_Node` 와 같은 입력 · 같은 함수 — 짝 = test_166_1_note_options.py). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { structureOptionLines } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Balloon.ts"), +); +const { withDefinition } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Define.ts"), +); +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const types = new Map( + withDefinition(input.types ?? [], input.definition ?? null).map((type) => [type.type_id, type]), +); +const lines = (input.things ?? []).map((thing) => + structureOptionLines( + { type_id: thing.type_id, options: thing.options ?? {} }, + types.get(thing.type_id), + ), +); +process.stdout.write(JSON.stringify({ lines })); diff --git a/resources/tester/helper_177_3_station_line_keep.cjs b/resources/tester/helper_177_3_station_line_keep.cjs index 70673c23d..d43711fdc 100644 --- a/resources/tester/helper_177_3_station_line_keep.cjs +++ b/resources/tester/helper_177_3_station_line_keep.cjs @@ -22,6 +22,7 @@ let selectedStructureId = null; const structureIdAtStation = (stationId) => (stationId === "irr-80" ? "pipe-80.17" : null); const stationIdAtStructure = (structureId) => (structureId === "pipe-80.17" ? "irr-80" : null); const draw = () => {}; +const revealPick = () => {}; // 화면 밖이면 가운데로(273-1) — 이 시험 몫 아님 module.exports = { line: () => selectedStationId, ${method("setSelectedStation")} diff --git a/resources/tester/helper_224_1_picker_top_table.cjs b/resources/tester/helper_224_1_picker_top_table.cjs index a7b1e700a..724e246b3 100644 --- a/resources/tester/helper_224_1_picker_top_table.cjs +++ b/resources/tester/helper_224_1_picker_top_table.cjs @@ -24,7 +24,9 @@ const node = (props = {}) => ({ const STUBS = { "./ui_template_elements": { el: (_tag, props) => node(props) }, - "./ui_template_locale": { t: (key) => (key === "M02_PickerTableFailed" ? "표 실패 {value}" : "읽는 중") }, + "./ui_template_locale": { + t: (key) => (key === "M02_PickerTableFailed" ? "표 실패 {value}" : "읽는 중"), + }, "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch": { readTemplate: async (...args) => { log.push(["readTemplate", ...args]); @@ -77,6 +79,10 @@ function loadTs(file) { const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); const row = (key, 열, layer, owner) => ({ key, 열, layer, owner }); const names = () => log.map((entry) => entry[0]); +// 280-2 — 표는 층(+ 주인) · 열을 받음 +const at = (r) => (r ? { layer: r.layer, owner: r.owner } : null); +const show = (table, r) => table.show(at(r), r ? r.열 : ""); +const picked = []; const creates = () => log.filter((entry) => entry[0] === "createSheet").map((entry) => entry[1]); (async () => { @@ -87,13 +93,13 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent const sameLayer = row("SS2", "rv2", "personal", "1/3"); const master = row("SS9", "rv9", "system", null); - const table = createComboTable(); + const table = createComboTable((colId) => picked.push(colId)); out.startHidden = table.root.hidden; - table.show(null); + show(table, null); out.nullHidden = table.root.hidden; out.nullCalls = names(); - table.show(mine); + show(table, mine); out.shownHidden = table.root.hidden; await flush(); out.firstReads = log.filter((e) => e[0] !== "createSheet"); @@ -111,12 +117,12 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent }; log.length = 0; - table.show(sameLayer); // 같은 층 다른 조합 — 표 그대로 · 열만 + show(table, sameLayer); // 같은 층 다른 조합 — 표 그대로 · 열만 await flush(); out.sameLayer = { calls: names(), selected: first.selected, destroyed: first.destroyed }; log.length = 0; - table.show(master); // 다른 층 — 그 층 표로 바뀜 + show(table, master); // 다른 층 — 그 층 표로 바뀜 await flush(); out.otherLayer = { reads: log.filter((e) => e[0] === "readTemplate"), @@ -127,21 +133,21 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent }; log.length = 0; - table.show(null); + show(table, null); out.hideAgain = table.root.hidden; // 읽는 사이에 다른 조합을 고름 — 마지막 것만 그려짐 log.length = 0; - table.show(mine); - table.show(master); + show(table, mine); + show(table, master); await flush(); out.race = { creates: creates().length, docTag: creates()[0]?.doc.tag }; // 번호 읽기 실패 — 표는 그려짐 log.length = 0; state.numbersFail = true; - const lone = createComboTable(); - lone.show(mine); + const lone = createComboTable((colId) => picked.push(colId)); + show(lone, mine); await flush(); out.numbersFail = { creates: creates().length, seen: creates()[0]?.opts.seenNumbers }; state.numbersFail = false; @@ -149,8 +155,8 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent // 표 읽기 실패 — 까닭 글 · 표 없음 log.length = 0; state.tableFail = "그 층은 아직 만든 것이 없음"; - const broken = createComboTable(); - broken.show(mine); + const broken = createComboTable((colId) => picked.push(colId)); + show(broken, mine); await flush(); out.tableFail = { creates: creates().length, text: broken.root.children[0].text }; @@ -160,6 +166,24 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent ownerDiffers: data.tableKey(mine) !== data.tableKey(row("SS1", "rv1", "personal", "1/4")), mine: data.tableKey(mine), }; + // 280-2 — 표에서 고른 열은 부른 쪽에 넘김 · 열 없는 고름(colId null)은 안 넘김 + state.tableFail = null; + const fresh = createComboTable((colId) => picked.push(colId)); + log.length = 0; + show(fresh, mine); + await flush(); + const sheetOpts = creates()[0].opts; + sheetOpts.onSelect({ colId: "rv2" }); + sheetOpts.onSelect({ colId: null }); + out.onColumn = picked.slice(-1); + // 층만 바꿈(고른 열 없음) — 그 층 표를 그리고 열은 안 고름 + log.length = 0; + fresh.show({ layer: "system", owner: null }, ""); + await flush(); + out.layerOnly = { + reads: log.filter((e) => e[0] === "readTemplate"), + selected: creates()[0].selected, + }; console.log(JSON.stringify(out)); })().catch((error) => { console.error(error); diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 0f8153e01..749a362d8 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -42,7 +42,7 @@ const structurePick = loadTs( const PID = "p231"; const TYPES = [ { type_id: "pavement_concrete", group: "G" }, - { type_id: "retaining_wall", group: "C" }, + { type_id: "retaining_wall", group: "C", placement: "interval" }, // 실제 등록부 = C 구간형(측점 심음) { type_id: "hazard_sign", group: "E" }, { type_id: "pipe", group: "A", managed_by: "pipe_points" }, ]; @@ -156,6 +156,10 @@ const panelModule = loadTs( writePendingPipes() {}, }, "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge": { mergeFacilityOptions: (x) => x }, + "../B05_Profile/B05_Profile_UI_Drainage_Ford_Fill": { + fordWaitsOf: () => new Set(), + waitsFordHeight: () => false, + }, "../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({ pipe_points: PIPES, basins: [] }), }, @@ -178,6 +182,24 @@ const panelModule = loadTs( setAdjustSideSlots() {}, }, "./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} }, + // 요구 178 — 알약 측점 짝(진짜 `plantsStation` · 272 측점 심기 한 벌) + "../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), + { + "@ui/ui_template_context_menu": { createMapContextMenu() {} }, + "./B05_Profile_UI_IrregularStations": { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: () => false, + }, + "./B05_Profile_Api_Structures": { + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + }, + "../A00_Common/design/design_selection": selection, + "../common_util/common_util_structure_station": loadTs( + path.join(ROOT, "common_util", "common_util_structure_station.ts"), + ), + }, + ), }, ); diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs index 0158756a5..f4c8623ca 100644 --- a/resources/tester/helper_233_2_view_pick.cjs +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -275,6 +275,17 @@ const layerChildren = []; const layer = { querySelectorAll: () => [...layerChildren], append: (hit) => layerChildren.push(hit), + // 261-3 누름 = 거리 판정 — 화면 좌표 = 그림 px(배율 1) + getScreenCTM: () => ({ a: 1, b: 0, inverse: () => null }), + ownerSVGElement: { contains: () => true }, +}; +globalThis.DOMPoint = class { + constructor(x, y) { + [this.x, this.y] = [x, y]; + } + matrixTransform() { + return this; + } }; const drawGround = () => { layerChildren.length = 0; @@ -302,7 +313,17 @@ const select = DTS.attachDetailSelect({ drawGround(); select.redraw(); const hit = layerChildren.find((el) => el.classes.has(DTS.PIECE_HIT_CLASS)); -click({ target: hit, shiftKey: false, ctrlKey: false, metaKey: false, stopPropagation() {} }); +const [clientX, clientY] = hit.getAttribute("points").split(" ")[0].split(",").map(Number); +click({ + target: hit, + clientX, + clientY, + shiftKey: false, + ctrlKey: false, + metaKey: false, + stopPropagation() {}, + stopImmediatePropagation() {}, +}); out.detailPicked = select.source("length")?.pieces.length ?? 0; axes = { ...axes, pixelsPerMeter: 23 }; // 창 크기 바뀜 → 다시 그림 drawGround(); diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs index e6717ba84..87fff283b 100644 --- a/resources/tester/helper_234_1_structure_station.cjs +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -1,4 +1,4 @@ -/* 234-1 구조물 기준 측점 = 정수 m · 일반 측점 붙기 · [정수 맞춤]. +/* 234-1 · 272 구조물 기준 측점 = 정수 m · 옛 소수 자동 맞춤(단추 없음 · 167). * 진짜 `common_util_structure_station` · `_Structures_Snap` · `_Structures_Fields` · `_Drainage_Pipes` · * `followedStation` 을 Node 로 돌림. 입력은 argv[2] JSON(파이썬과 같은 값) · 표준출력 JSON 한 벌. */ @@ -93,7 +93,7 @@ out.typedOne = anchor.read(20, true); anchor.write(80.17, 20); out.untouched = anchor.read(20, true); -// ③ 구조물 옮김 · [정수 맞춤] 셈 · 단추 +// ③ 구조물 옮김 · 옛 소수 자동 맞춤 셈 · 실행 const toasts = []; let confirmText = null; const snap = loadTs("B05_Profile/B05_Profile_UI_Structures_Snap.ts", { @@ -134,24 +134,25 @@ out.plan = { const moves = []; let emits = 0; let closed = 0; -snap - .runSnap({ +Promise.resolve( + snap.snapOldStations({ structures, pipes: () => pipes, emit: () => (emits += 1), movePipe: (from, to, attributes) => moves.push({ from, to, attributes }), closeForm: () => (closed += 1), - }) + }), +) .then(async (count) => { out.run = { count, emits, closed, moves, structures, confirmText, toasts: [...toasts] }; - // 두 번째 누름 = 옮길 것 없음(관은 위 moves 로 옮겨졌다고 보고 목록을 맞춤) + // 두 번째 = 옮길 것 없음(관은 위 moves 로 옮겨졌다고 보고 목록을 맞춤) moves.forEach(({ from, to }) => { const hit = pipes.find((pipe) => pipe.chainage_m === from); if (hit) hit.chainage_m = to; }); toasts.length = 0; const again = { structures, pipes: () => pipes, emit() {}, movePipe() {}, closeForm() {} }; - out.second = { count: await snap.runSnap(again), toasts: [...toasts] }; + out.second = { count: snap.snapOldStations(again), toasts: [...toasts] }; // ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘) const alignment = loadTs("B05_Profile/B05_Profile_UI_Profile_Alignment.ts"); diff --git a/resources/tester/helper_259_detail_region.cjs b/resources/tester/helper_259_detail_region.cjs new file mode 100644 index 000000000..a17c883c6 --- /dev/null +++ b/resources/tester/helper_259_detail_region.cjs @@ -0,0 +1,222 @@ +/* PLAN 259 단위검증 헬퍼 — [상세] 공용 팬 누름 풂 · 암 경계선 나눔 · 닫힌 영역 · 표 칸 → 그래프 고름. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes, rock} · + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_259_detail_region.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const Z = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); +const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts")); + +const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; + +/* ① 공용 팬 — 가운데 단추 팬(click 없음) 뒤 왼쪽 누름 click 이 안 삼켜짐 */ +function panCase(panStart) { + const listeners = {}; + const svg = { + addEventListener: (name, fn) => (listeners[name] ??= []).push(fn), + setAttribute: () => {}, + classList: { add: () => {}, remove: () => {} }, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 600, height: 300 }), + }; + const layer = { setAttribute: () => {} }; + Z.attachSvgZoomPan({ + svg, + layer, + widthPx: 600, + heightPx: 300, + pad: { left: 10, right: 10, top: 10, bottom: 10 }, + content: { x: -600, y: -300, width: 1800, height: 900 }, + panStart, + }); + const fire = (name, init = {}) => { + let stopped = false; + const event = { + button: 0, + clientX: 0, + clientY: 0, + pointerId: 1, + preventDefault: () => {}, + stopPropagation: () => (stopped = true), + ...init, + }; + (listeners[name] ?? []).forEach((fn) => fn(event)); + return stopped; + }; + const drag = (button) => { + fire("pointerdown", { button, clientX: 100, clientY: 100 }); + fire("pointermove", { button, clientX: 140, clientY: 120 }); + fire("pointerup", { button, clientX: 140, clientY: 120 }); + }; + const click = (x = 50) => { + fire("pointerdown", { clientX: x, clientY: 50 }); + fire("pointerup", { clientX: x, clientY: 50 }); + return fire("click", { clientX: x, clientY: 50 }); + }; + return { drag, click, fire }; +} +{ + const middle = panCase(); + middle.drag(1); // 가운데 단추 팬 — 브라우저는 click 을 안 보냄(auxclick) + out.panMiddle = { firstClickStopped: middle.click(), secondClickStopped: middle.click() }; + // 거리이정표 꼴 — 빈 곳 왼쪽 끌기 팬(끌기로 끝난 click 은 막고 · 다음 누름은 통과) + const left = panCase((event) => event.button === 1 || event.button === 0); + left.drag(0); + // 끌기 끝 click(왼쪽은 브라우저가 보냄) — 막혀야 함 + out.panLeft = { afterDragStopped: left.fire("click"), nextClickStopped: left.click() }; +} + +/* ② 암 경계선 — 그림 요소 → 조각(설계선과 만나는 점에서 나눔) */ +const px = ([offset, elevation]) => + [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ].join(","); +const el = (classes, points, attrs = {}) => ({ + tagName: "polyline", + classList: { contains: (name) => classes.includes(name) }, + hasAttribute: (name) => name in attrs, + getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)), +}); +const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]); +const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]); +const rockPts = G.rockRuns(design, samples)[0]; +const elements = [ + el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }), + el(["b06-chart__cross-profile"], groundPts), + el(["b06-chart__rock-boundary"], rockPts), +]; +const pieces = S.collectPieces(elements, axes); +out.pieces = pieces; +const rockPieces = pieces.filter((p) => p.line === "rock"); +out.rockLabel = S.selectionLabel(design, [rockPieces[1]]); +out.rockSource = S.selectionSource("length", [rockPieces[1]]); +out.rockLocal = T.localCellValue(out.rockSource, design, samples); +out.rockLength = G.pathLength(rockPieces[1].points); + +/* ③ 닫힌 영역(259-2) — 면 찾기 · 열린 영역 제외 · 영역 고름 값 · 붙은 두 영역 = 맞닿은 변 뺌 */ +const faces = G.findFaces(pieces); +out.faces = faces.map((face) => ({ + area: face.area, + lines: face.edges.map((i) => pieces[i].line).sort(), +})); +const one = faces.map((face) => + S.regionLabel( + [face], + face.edges.map((i) => pieces[i]), + ), +); +out.faceLabels = one; +const union = S.regionEdges(faces); +const unionPieces = union.map((i) => pieces[i]); +out.union = { + lines: unionPieces.map((p) => p.line).sort(), + label: S.regionLabel(faces, unionPieces), + selectLabel: S.selectionLabel(design, unionPieces), + source: S.selectionSource("area", unionPieces), +}; +out.union.local = T.localCellValue(out.union.source, design, samples); +// 열린 원지반만 = 면 없음 +out.openFaces = G.findFaces(pieces.filter((p) => p.line === "ground")).length; +// 정사각형 하나 + 매달린 꼬리 — 꼬리 지우고 면 하나(넓이 1) +out.square = G.findFaces([ + { + line: "ground", + points: [ + [0, 0], + [1, 0], + ], + }, + { + line: "design", + points: [ + [1, 0], + [1, 1], + [0, 1], + ], + }, + { + line: "rock", + points: [ + [0, 1], + [0, 0], + ], + }, + { + line: "spoil", + points: [ + [1, 1], + [3, 3], + ], + }, +]).map((face) => ({ area: face.area, edges: face.edges.sort() })); + +/* ④ 표 칸 → 그래프(259-2) — 저장된 조각 짝 · 칸 그래프 짝 · 띠 값 글 */ +const stored = G.pieceSource(rockPieces[1]); +out.same = { + forward: S.samePiece(rockPieces[1], stored), + reverse: S.samePiece(rockPieces[1], { ...stored, start: stored.end, end: stored.start }), + otherLine: S.samePiece(rockPieces[1], { ...stored, line: "ground" }), + otherPiece: S.samePiece(rockPieces[0], stored), +}; +const table = { + cells: { + fence: out.rockSource, + cut_soil: { src: "area", key: "cut_rock" }, + "r:r1:0": { src: "text", text: "글" }, + }, + rows: [], + columns: [], +}; +out.cellGraph = ["fence", "cut_soil", "r:r1:0", "embankment"].map((key) => T.cellGraph(table, key)); +out.bandLabel = [ + T.bandLabel("cut_soil", { cut_soil_area_m2: 1.234 }), + T.bandLabel("cut_soil", {}), + T.bandLabel("ground", { cut_soil_area_m2: 1 }), +]; + +/* ⑤ 표 강조 바깥 한 줄(259-3) — 합친 묶음 이름 칸(두 줄) + 둘째 줄 이름 · 값 = ㄴ 모양 */ +const P = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Pick.ts")); +out.outline = P.unionOutline([ + [0, 0, 10, 20], + [10, 10, 20, 20], + [20, 10, 30, 20], +]); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_260_note_cad.cjs b/resources/tester/helper_260_note_cad.cjs new file mode 100644 index 000000000..9f6f8727b --- /dev/null +++ b/resources/tester/helper_260_note_cad.cjs @@ -0,0 +1,148 @@ +/* PLAN 260 단위검증 헬퍼 — [상세] 표 위 CAD 칸 · 옛 글 옮김 · 횡단배수 미리 넣음. + * `B06_Section_UI_Cross_Detail_Cad.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다(CAD · 세션 · 서버 = 흉내). + * 결과는 표준출력에 JSON 한 벌 · 인자 `mirror` = 표준입력 입력으로 거울 결과(260-4). 확장자 `.cjs` 까닭은 `helper_108_drag_box_hit.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +// 인자 `load` = 표준입력 {pipes, structures, types} 를 세션 · 서버 흉내로 `loadNoteThings`(265-2) +const DATA = { pipes: [], structures: [], types: [] }; +const STUBS = { + "@ui/cad_host/cad_host": { createCadHost: () => ({}) }, + "../A00_Common/b_page_state": { readState: () => null, writeState: () => undefined }, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: DATA.pipes }), + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({ structures: DATA.structures }), + fetchStructureTypes: async () => DATA.types, + readPendingStructures: () => null, + }, + // 좌측 패널 옵션 줄(166-1)은 test_166_1 이 진짜 명세로 봄 — 여기는 조합 줄 · 마크만 + "../B05_Profile/B05_Profile_UI_Structures_Balloon": { structureOptionLines: () => [] }, +}; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + throw new Error(`흉내 없는 모듈: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +// 관 길이 정수(265-3) = 실제 `pipeLengthWhole` · 조합 참조 읽기(265-2) = 실제(둘 다 import 없음) +STUBS["./B06_Section_UI_Cross_Culvert_Const"] = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Const.ts"), +); +STUBS["../common_util/common_util_summary_item"] = loadTs( + path.join(ROOT, "common_util", "common_util_summary_item.ts"), +); +const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts")); + +const wall = (role, form, height, length) => ({ + role, + structure: "기슭막이", + revet_form: form, + revet_height_m: height, + revet_length_m: length, +}); +const culvert = (extra = {}) => ({ + type: "pipe", + chainage_m: 1433, + pipe_kind: null, + diameter_m: 1.2, + min_cover_m: 0.5, + inlet: wall("inlet", "돌쌓기(찰)", 2, 10), + outlet: wall("outlet", "돌쌓기(메)", 2, 10), + ...extra, +}); +const section = (spec, design = { pipe_length_m: 22 }, chainage = 1433) => ({ + chainage_m: chainage, + station_id: "s", + samples: [], + culvert: spec, + design, +}); + +/** 엔티티 → 비교하기 쉬운 꼴(종류 · 글 · 자리 · 색). */ +const brief = (entities) => + entities.map((entity) => ({ + type: entity.type, + label: entity.shapeData.label ?? null, + align: entity.shapeData.options?.textAlign ?? null, + x: entity.shapeData.basePoint?.x ?? entity.shapeData.center?.x ?? null, + y: entity.shapeData.basePoint?.y ?? entity.shapeData.center?.y ?? null, + color: entity.lineColor, + layer: entity.layerId, + })); +const run = (sec, lines = []) => brief(cad.noteEntities(sec, "NO.71+13", lines)); +const LINES = ["기슭막이 찰쌓기 2.0m", "돌붙임 찰붙임"]; + +const out = { + full: run(section(culvert()), LINES), + noLength: run(section(culvert(), {})), + basin: run( + section( + culvert({ + inlet: { + role: "inlet", + structure: "집수정", + basin_form: "L", + basin_height_m: 1.7, + basin_length_m: 2, + }, + }), + ), + [], + ), + chuteFallback: run(section(culvert({ chute: { structure: "도수로", thickness_m: 0.45 } })), []), + noSides: run( + section( + culvert({ + inlet: { role: "inlet", structure: "" }, + outlet: { role: "outlet", structure: "" }, + }), + ), + [], + ), + none: run(section(undefined)), + ford: run(section(culvert({ body: "ford" }))), + hidden: run(section(culvert({ hidden_pipe: true }))), + neighbour: run(section(culvert(), { pipe_length_m: 22 }, 1440)), + // 마크 없이 조합 줄만(구조물 측점 · 265-2) = 측점부터 왼쪽 끝 + linesOnly: run(section(undefined), LINES), + legacy: brief(cad.legacyNoteEntities("\nL형 측구 \n\n집수정 보강\n")), + legacyEmpty: cad.legacyNoteEntities(" ").length, + station: [ + cad.stationNoLabel(1433, 20), + cad.stationNoLabel(1420, 20), + cad.stationNoLabel(1439.97, 20), + ], +}; +// 거울(260-4 · 265-2) — 시험이 표준입력으로 준 같은 입력 [{section, station, things, chainage, interval}] +// → TS 조합 줄 · 엔티티 · 측점 이름(파이썬 짝 `_Engine_Cad_CulvertNote` 와 대조) +if (process.argv[2] === "mirror") { + const cases = JSON.parse(fs.readFileSync(0, "utf8")); + const mirrored = cases.map((item) => { + const lines = cad.structureNoteLines(item.things ?? [], item.section.chainage_m); + return { + lines, + entities: cad.noteEntities(item.section, item.station, lines), + station: cad.stationNoLabel(item.chainage, item.interval), + at: cad.thingsAt(item.things ?? [], item.section.chainage_m).length, + }; + }); + process.stdout.write(JSON.stringify(mirrored)); +} else if (process.argv[2] === "load") { + Object.assign(DATA, JSON.parse(fs.readFileSync(0, "utf8"))); + cad.loadNoteThings("p").then((things) => process.stdout.write(JSON.stringify(things))); +} else process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_261_3_detail_pick.cjs b/resources/tester/helper_261_3_detail_pick.cjs new file mode 100644 index 000000000..5e913780e --- /dev/null +++ b/resources/tester/helper_261_3_detail_pick.cjs @@ -0,0 +1,85 @@ +/* PLAN 261-3 단위검증 헬퍼 — [상세] 누름 판정(거리 · 얇은 띠) · 구조물 둘레로 닫히는 깎기 영역. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON + * {cases: [{name, axes, elements: [{tag, cls, dl, pts(그림 px)}], clicks: [[x, y, scale]]}]} · + * 결과 = 표준출력 JSON {이름: {pieces, faces, clicks}}. 짝 = `test_261_3_detail_pick.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); + +const element = (e) => ({ + tagName: e.tag, + classList: { contains: (name) => e.cls.split(/\s+/).includes(name) }, + hasAttribute: (name) => name === "data-design-line" && e.dl, + getAttribute: (name) => { + if (name === "points") return e.pts.map((p) => p.join(",")).join(" "); + const m = /^([xy])([12])$/.exec(name); + return m ? String(e.pts[+m[2] - 1][m[1] === "x" ? 0 : 1]) : null; + }, +}); + +const { cases } = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; +for (const { name, axes, elements, clicks } of cases) { + const pieces = S.collectPieces(elements.map(element), axes); + const faces = G.findFaces(pieces); + const toPx = ([offset, elevation]) => [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ]; + const paths = pieces.map((piece) => piece.points.map(toPx)); + const rings = faces.map((face) => face.ring.map(toPx)); + const areas = faces.map((face) => face.area); + out[name] = { + pieces: pieces.map((piece) => ({ line: piece.line, points: piece.points })), + faces: faces.map((face) => { + const edges = face.edges.map((i) => pieces[i]); + const source = S.selectionSource("area", edges); + return { + area: face.area, + edges: face.edges, + lines: [...new Set(edges.map((piece) => piece.line))].sort(), + ring: face.ring, + source, + }; + }), + clicks: (clicks ?? []).map(([x, y, scale]) => + S.pickAt([x, y], scale, paths, rings, areas, pieces), + ), + px: { paths, rings }, + }; +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_266_detail_home.cjs b/resources/tester/helper_266_detail_home.cjs new file mode 100644 index 000000000..a5dfdbdbf --- /dev/null +++ b/resources/tester/helper_266_detail_home.cjs @@ -0,0 +1,110 @@ +/* PLAN 266-1 단위검증 헬퍼 — [상세] 처음 가로 범위 = 카드 반폭. + * 실제 TS(`_Cross_View_Metrics` 칸 맞춤 · `b_svg_zoom_pan` homeScale)를 Node 로 돌림. + * 처음 배율 = 뷰 `detailChart` 와 같은 식(base.ppm / fitted.ppm · 짝 시험이 원본 줄을 확인). + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_266_detail_home.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +const CROSS_HEIGHT = Number(/export const CROSS_HEIGHT = (\d+)/.exec(common)[1]); +const STUBS = { + "./B06_Section_UI_Section_Common": { + CROSS_PAD, + CROSS_HEIGHT, + validElevation: (s) => s.valid !== false && Number.isFinite(s.elevation_m), + }, +}; +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => STUBS[request] ?? {}; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts")); +const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); + +function fakeSvg(width, height) { + const listeners = {}; + const attrs = {}; + return { + attrs, + setAttribute: (name, value) => (attrs[name] = String(value)), + getAttribute: (name) => attrs[name] ?? null, + classList: { add() {}, remove() {} }, + addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), + fire: (type, event) => + (listeners[type] ?? []).forEach((fn) => + fn({ preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }), + ), + setPointerCapture() {}, + releasePointerCapture() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width, height }), + }; +} + +// 가파른 비탈 — ±30 m 샘플 · 1 m 마다 · 1 m 에 0.6 m 오름(칸 맞춤이 가로를 넓히게) +const section = { + samples: Array.from({ length: 61 }, (_, i) => ({ + offset_m: i - 30, + elevation_m: 100 + 0.6 * (i - 30), + })), +}; + +/** 반폭 · 칸 크기로 상세 창을 연 셈 → 처음 · 줌 · 팬 · 원복 뒤 보이는 가로 범위(m). */ +function open(halfWidth, W, H) { + const base = metrics.crossPlotBase(section, 1, W, halfWidth, 100); + const fitted = metrics.fitPlotBase(base, H); + const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1; + const svg = fakeSvg(W, H); + const layer = fakeSvg(W, H); + let state = null; + const handle = attachSvgZoomPan({ + svg, + layer, + widthPx: W, + heightPx: H, + pad: CROSS_PAD, + maxScale: 16, + content: { x: -W, y: -H, width: 3 * W, height: 3 * H }, + wheelZoom: true, + homeScale, + onChange: (next) => (state = next), + }); + // 보이는 플롯 가로 끝(svg x) → 도형 층 x → 측점 거리(m) + const range = () => { + const s = state ?? { scale: 1, tx: 0, ty: 0 }; + const toM = (x) => + fitted.minOffset + ((x - s.tx) / s.scale - CROSS_PAD.left) / fitted.pixelsPerMeter; + return [toM(CROSS_PAD.left), toM(W - CROSS_PAD.right)].map((v) => Math.round(v * 1000) / 1000); + }; + const out = { homeScale, widened: fitted !== base, first: range() }; + svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: 100 }); + out.wheelScale = state.scale / homeScale; + svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 }); + svg.fire("pointermove", { clientX: 160, clientY: 100 }); + svg.fire("pointerup", {}); + out.panned = range()[0] !== out.first[0]; + handle.reset(); // [⤢] + out.reset = range(); + for (let i = 0; i < 40; i++) svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: -100 }); + out.minScale = state.scale; // 줄이면 칸 맞춤 전체(1배)까지 + svg.fire("dblclick", {}); + out.dblclick = range(); + return out; +} + +process.stdout.write( + JSON.stringify({ + global15: open(15, 1200, 300), + station10: open(10, 1200, 300), + tall15: open(15, 1200, 900), + }), +); diff --git a/resources/tester/helper_270_1_cross_map.cjs b/resources/tester/helper_270_1_cross_map.cjs new file mode 100644 index 000000000..9252a7b2a --- /dev/null +++ b/resources/tester/helper_270_1_cross_map.cjs @@ -0,0 +1,118 @@ +/* 270-1 횡단 보기 배수유역도 = 고름 한 칸에서 파생 — 진짜 TS 를 Node 로 돌림. + * `bindCrossPick`(B06_Section_UI_Page_Pick_Sync) + 진짜 `paintMapPick`(design_pick_profile) + 지도 흉내. + * 표준출력 JSON 한 벌 · 짝 = test_270_1_cross_map.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const NM = path.join(ROOT, "config", "node_modules"); +const ts = require(path.join(NM, "typescript")); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +globalThis.window = { addEventListener() {}, removeEventListener() {} }; +globalThis.document = { addEventListener() {}, removeEventListener() {} }; + +const store = new Map(); +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); +const IRR = { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", +}; +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": IRR, +}); +const PP = loadTs("A00_Common/design/design_pick_profile.ts", { + "./design_selection": DSel, + "./design_pick_escape": ESC, + "../../B05_Profile/B05_Profile_UI_Selection": SY, + "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, +}); +const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", { + "../A00_Common/design/design_selection": DSel, + "../A00_Common/design/design_pick_escape": ESC, +}); + +const PID = "p270"; +/* 지도 흉내 — 관 셋(95.38 · 195.3 · 292.22) · 고른 관 = 찾는 폭 안 관(배수유역도 `selectPipeAtChainage` 꼴) */ +const PIPES = [95.38, 195.3, 292.22]; +const map = { + calls: [], + pipe: null, + station: null, + selectPipeAtChainage(at, notify = true) { + this.calls.push(["pipe", at, notify]); + this.pipe = + at === null ? null : (PIPES.find((c) => Math.abs(c - at) < DSel.PIPE_MATCH_M) ?? null); + }, + markStation(at) { + this.calls.push(["mark", at]); + this.station = at; + }, +}; +let visible = true; +let reapplied = 0; +const disposers = []; +const host = { visible: () => visible, onDispose: (fn) => disposers.push(fn) }; +const deps = { + sections: () => [], + controls: { + revetOffset: { highlightFor: () => false, selectedFor: () => false, select() {} }, + ford: { selectedFor: () => false, select() {} }, + box: { selectedFor: () => false, select() {} }, + }, + releaseForm() {}, + view: { focusStation() {}, refreshCards() {}, selectMark() {} }, + reapply: () => reapplied++, + paintMap: (selection) => PP.paintMapPick(map, selection), +}; +const pick = SYNC.bindCrossPick(PID, host, deps); +const snap = () => ({ pipe: map.pipe, station: map.station, reapplied, calls: map.calls.length }); +const out = {}; + +// ① 횡단 보기 안 누름(from b06) — 지도는 칠하고 넘김 적용은 안 함 +DSel.selectDesign(PID, { chainageM: 95.38, structureId: null }, { from: "b06" }); +out.b06Pipe = { ...snap(), notify: map.calls[0]?.[2] }; +// ② 일반 측점 카드(관 없음) — 옛 관 강조가 풀리고 마름모만 +DSel.selectDesign(PID, { chainageM: 80, structureId: null }, { from: "b06" }); +out.b06Card = snap(); +// ③ 지도에서 고름(from map) — 지도 칠함 + 넘김 적용(카드 · 폼 · 알약 · 표 열) +DSel.selectDesign(PID, { chainageM: 292.22, structureId: null }, { from: "map" }); +out.fromMap = snap(); +// ④ 노면공(범위 · 기준 30) — 관 없음 · 넘김 적용 +DSel.selectDesign(PID, { chainageM: 30, structureId: "pave" }, { from: "b05" }); +out.pave = snap(); +// ⑤ 풀기(from b06) — 지도 풂 +DSel.selectDesign(PID, DSel.NO_SELECTION, { from: "b06" }); +out.release = snap(); +// ⑥ 숨은 보기 — 안 칠함 · 안 적용 +visible = false; +const before = map.calls.length; +DSel.selectDesign(PID, { chainageM: 195.3, structureId: null }, { from: "map" }); +out.hidden = { calls: map.calls.length - before, reapplied }; +// ⑦ 다시 보임 뒤 다른 곳(종단 보기)이 바꾼 값 = 칠함 + 넘김 적용 +visible = true; +DSel.selectDesign(PID, { chainageM: 95.38, structureId: null }, { from: "b05" }); +out.show = snap(); +pick.release(); // 풀기 길은 그대로(값은 부르는 쪽이 비움) +out.releaseKeepsMap = map.pipe; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_272_1_station_list.cjs b/resources/tester/helper_272_1_station_list.cjs new file mode 100644 index 000000000..d446f7156 --- /dev/null +++ b/resources/tester/helper_272_1_station_list.cjs @@ -0,0 +1,29 @@ +/* 272-1 측점 한 벌 — 진짜 `common_util_structure_station.ts` 의 `plantedChainages` · `oneStationList` 를 + * Node 로 돌림. 입력 argv[2] JSON(파이썬과 같은 값) · 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const station = loadTs("common_util/common_util_structure_station.ts"); +const out = { + planted: station.plantedChainages(input.pipes, input.structures, input.types), + list: station.oneStationList(input.server, input.fresh), + nodes: station.followMovedNodes(input.nodes, input.gone, input.born), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_273_1_profile_reveal.cjs b/resources/tester/helper_273_1_profile_reveal.cjs new file mode 100644 index 000000000..5e084fc25 --- /dev/null +++ b/resources/tester/helper_273_1_profile_reveal.cjs @@ -0,0 +1,60 @@ +/* 273-1 종단 고름 → 화면 밖이면 가운데로 — 진짜 `createSelectionReveal`(design_profile_viewport.ts)을 + * 가짜 스크롤러로 돌림. 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const js = ts.transpileModule( + fs.readFileSync(path.join(ROOT, "A00_Common/design/design_profile_viewport.ts"), "utf8"), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }, +).outputText; +const box = { exports: {} }; +const stubs = { + "../../B05_Profile/B05_Profile_UI_Profile_Wheel": { attachWheelHorizontalScroll: () => {} }, + "@util/common_util_chart_ywindow": { needsFullRedraw: () => false }, + "@util/common_util_view_window": { viewOf: () => globalThis }, +}; +new Function("exports", "module", "require", js)(box.exports, box, (name) => { + if (!stubs[name]) throw new Error(`stub 없음: ${name}`); + return stubs[name]; +}); + +/** 폭 700 스크롤러 — 고른 선은 내용 좌표 `lineAt`(없으면 null). */ +function scroller(lineAt) { + const body = { + scrollLeft: 0, + clientWidth: 700, + line: lineAt, + getBoundingClientRect: () => ({ left: 0, width: 700 }), + querySelector(selector) { + if (this.line === null || !selector.includes("--selected")) return null; + const x = this.line - this.scrollLeft; + return { getBoundingClientRect: () => ({ left: x - 1, width: 2 }) }; + }, + }; + return body; +} + +const out = {}; +const far = scroller(3000); +const reveal = box.exports.createSelectionReveal(far); +reveal("a"); +out.far = far.scrollLeft; // 3000 - 350 +far.scrollLeft = 100; // 사용자가 옮겨 봄 +reveal("a"); // 같은 고름 다시 칠함 +out.same = far.scrollLeft; + +const near = scroller(400); +box.exports.createSelectionReveal(near)("b"); +out.near = near.scrollLeft; // 보이는 자리 — 안 밂 + +const late = scroller(null); +const revealLate = box.exports.createSelectionReveal(late); +revealLate("c"); // 아직 안 그려짐 +late.line = 2000; +revealLate("c"); // 그린 뒤 같은 고름 — 이제 밂 +out.late = late.scrollLeft; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs new file mode 100644 index 000000000..6f7528072 --- /dev/null +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -0,0 +1,113 @@ +/* 273-2 초안 측점 — 진짜 `B06_Section_Section_Drafts.ts` + 진짜 측점 규칙(`common_util_structure_station`)을 + * 가짜 서버 · 초안으로 돌림. 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (name) => { + if (!stubs[name]) throw new Error(`stub 없음: ${name} (${file})`); + return stubs[name]; + }); + return box.exports; +} + +const station = loadTs("common_util/common_util_structure_station.ts"); +let draftPipes = null; +const asked = []; +let slow = false; +const types = [ + { + type_id: "pipe", + group: "A", + placement: "point", + managed_by: "pipe_points", + name: "배관", + options: [], + }, +]; +const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: [{ chainage_m: 95.38, facility: "pipe" }] }), + }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => types, + readPendingStructures: () => null, + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => draftPipes }, + "@util/common_util_culvert_sets": { + registryDefaults: () => ({ pipe: { pipe_kind: "파형강관", pipe_diameter_mm: 800 } }), + buildCulvertSets: (points) => new Map(points.map((p) => [p.chainage_m, { kind: "pipe" }])), + attachCulvertSets: (sections, sets) => { + for (const [at] of sets) { + const hit = sections.find((s) => Math.abs(s.chainage_m - at) < 0.005); + if (hit) hit.culvert = true; + } + }, + }, + "../common_util/common_util_structure_station": station, + "./B06_Section_Api_Fetch": { + previewStructureSections: async (_projectId, chainages) => { + asked.push(chainages); + if (slow) await new Promise((resolve) => setTimeout(resolve, 50)); + return chainages.map((c) => ({ + chainage_m: c, + kind: "irregular", + station_id: `s${c}`, + samples: [], + })); + }, + }, +}); + +/** 서버 상세 — 격자 0 · 20 · 40 · 100 + 옛 관 측점 95.38. */ +const detailOf = () => ({ + cross_sections: [0, 20, 40, 95.38, 100].map((c) => ({ + chainage_m: c, + kind: c === 95.38 ? "irregular" : c === 0 ? "bp" : "regular", + station_id: `s${c}`, + samples: [], + })), +}); +const chainages = (detail) => detail.cross_sections.map((s) => s.chainage_m); + +(async () => { + const out = {}; + const detail = detailOf(); + draftPipes = [{ chainage_m: 95, facility: "pipe", source: "user" }]; // 95.38 → 95 + await drafts.withDraftStations(detail, "P", 1); + const preview = detail.cross_sections.find((s) => s.chainage_m === 95); + out.moved = { + chainages: chainages(detail), + asked: asked.splice(0), + preview: { preview: preview.preview, structure: preview.structure, culvert: preview.culvert }, + }; + await drafts.withDraftStations(detail, "P", 1); + out.again = { chainages: chainages(detail), asked: asked.splice(0) }; + draftPipes = null; + await drafts.withDraftStations(detail, "P", 1); + out.cleared = { chainages: chainages(detail) }; + + const race = detailOf(); + slow = true; + draftPipes = [{ chainage_m: 96, facility: "pipe", source: "user" }]; + const first = drafts.withDraftStations(race, "P", 1); // 늦게 끝남 + await new Promise((resolve) => setTimeout(resolve, 5)); + slow = false; + draftPipes = [{ chainage_m: 120, facility: "pipe", source: "user" }]; + await drafts.withDraftStations(race, "P", 1); + await first; + out.race = { chainages: chainages(race) }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/helper_280_1_combo_pick.cjs b/resources/tester/helper_280_1_combo_pick.cjs new file mode 100644 index 000000000..af16257b4 --- /dev/null +++ b/resources/tester/helper_280_1_combo_pick.cjs @@ -0,0 +1,264 @@ +/* 구조물 조합 선택 모달(PLAN 280-2) 시험 헬퍼 — `ui_template_item_picker.ts` 를 트랜스파일해 + * 화면 부품(요소 · 모달 · 나무 · 카드 · 위 표)을 대역으로 갈아 끼우고 돌림 · 자료 쪽(`_data`)은 진짜. + * 위 표 고름 → 나무(펼쳐 고름) · 오른쪽 카드 · 층 단추 → 위 표 = 그 층 — 결과는 표준출력에 JSON 한 벌. + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const UI = path.join(ROOT, "ui_template"); + +// ── 화면 그릇 흉내 — 클래스 · 자식 · 이벤트 · 숨김만 ───────────────────── +class FakeNode { + constructor(props = {}) { + this._classes = new Set( + String(props.className ?? "") + .split(/\s+/) + .filter(Boolean), + ); + this.textContent = props.text ?? ""; + this.children = [...(props.children ?? [])]; + this.hidden = !!(props.attrs && "hidden" in props.attrs); + this.disabled = false; + this.dataset = {}; + this.attrs = {}; + this.listeners = {}; + this.value = ""; + this.style = { setProperty() {} }; + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + remove: (...names) => names.forEach((n) => self._classes.delete(n)), + toggle: (name, force) => { + const on = force === undefined ? !self._classes.has(name) : !!force; + on ? self._classes.add(name) : self._classes.delete(name); + return on; + }, + contains: (name) => self._classes.has(name), + }; + } + append(...kids) { + this.children.push(...kids); + } + replaceChildren(...kids) { + this.children = kids; + } + setAttribute(name, value) { + this.attrs[name] = value; + } + getAttribute(name) { + return this.attrs[name]; + } + addEventListener(type, handler) { + (this.listeners[type] ||= []).push(handler); + } + click() { + (this.listeners.click || []).forEach((h) => h({ target: this })); + } + scrollIntoView() { + scrolled.push(this.dataset.id ?? ""); + } + all() { + return this.children.flatMap((c) => (c instanceof FakeNode ? [c, ...c.all()] : [])); + } + querySelector(selector) { + const name = selector.replace(/^\./, ""); + return this.all().find((n) => n.classList.contains(name)) ?? null; + } +} +const scrolled = []; + +const tableShows = []; +const detailShows = []; +let onColumn = null; +let modal = null; + +const button = ({ label, variant, onClick }) => { + const node = new FakeNode({ className: `ui-btn ui-btn--${variant ?? "filled"}`, text: label }); + if (onClick) node.addEventListener("click", onClick); + return node; +}; + +const STUBS = { + "./ui_template_item_picker.css": {}, + "@config/config_frontend": { API_BASE_URL: "/api" }, + "./ui_template_elements": { + el: (_tag, props) => new FakeNode(props), + createButton: button, + createInputField: () => { + const input = new FakeNode({ className: "ui-input" }); + return { root: new FakeNode({ children: [input] }), input }; + }, + showToast: () => {}, + }, + "./ui_template_locale": { t: (key) => key }, + "./ui_template_modal": { + openModal: (opts) => { + const body = new FakeNode({ className: "ui-modal__body" }); + modal = { title: opts.title, body }; + opts.mount(body); + return { close: () => {} }; + }, + }, + // 나무 — 펼친(`open`) 갈래만 아래 줄을 그림 = 보이는 줄만 생김 + "./ui_template_tree": { + renderTree: function render(nodes, make, depth = 0) { + return nodes.flatMap((node) => { + if (!node.children?.length) return [make(node, "", depth, () => node.run?.())]; + const row = make(node, node.open ? "▾" : "▸", depth, () => {}); + return [row, ...(node.open ? render(node.children, make, depth + 1) : [])]; + }); + }, + createTreeRow: (node, _caret, _depth, onClick) => { + const row = new FakeNode({ className: "ui-tree__row", text: node.label }); + row.dataset.id = node.id; + row.addEventListener("click", onClick); + return row; + }, + openTreeMenu: () => {}, + }, + "./ui_template_item_picker_ask": { askFields: async () => null }, + "./ui_template_item_picker_project": { projectItems: () => ({}) }, + "./ui_template_item_picker_detail": { + createItemDetail: () => ({ + root: new FakeNode({ className: "detail" }), + show: (row) => detailShows.push(row ? row.key : null), + markEdited: () => {}, + reload: () => {}, + }), + }, + "./ui_template_item_picker_edit": { createEditView: () => ({}) }, + "./ui_template_item_picker_table": { + createComboTable: (cb) => { + onColumn = cb; + return { + root: new FakeNode({ className: "table" }), + show: (at, column) => + tableShows.push(at ? `${at.layer}|${at.owner ?? ""}|${column}` : null), + destroy: () => {}, + }; + }, + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request in STUBS) return STUBS[request]; + if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const item = (key, 열, 경로) => ({ key, 열, 경로 }); +const GROUPS = [ + { + layer: "system", + owner: null, + items: [ + item("SS1", "rvS1", ["돌기슭막이", "메쌓기", "1.2m"]), + item("SS2", "rvS2", ["돌기슭막이", "찰쌓기", "1.5m"]), + ], + }, + { layer: "company", owner: "c1", items: [item("SC1", "rvC1", ["배수관", "흄관"])] }, + { + layer: "personal", + owner: "u1", + items: [item("SP1", "rvP1", ["옹벽", "기초"]), item("SP2", "rvP2", ["옹벽", "벽체", "1m"])], + }, +]; +globalThis.fetch = async () => ({ ok: true, json: async () => GROUPS }); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); + +(async () => { + const { openItemPicker } = loadTs(path.join(UI, "ui_template_item_picker.ts")); + const out = {}; + void openItemPicker({ current: null, canCopy: false }); + await flush(); + const [, cols] = modal.body.children; + const [search, layerBar, treeHost] = cols.children[0].children; + const searchInput = search.children[0]; + const active = () => treeHost.all().find((n) => n.classList.contains("is-active"))?.dataset.id; + const leaves = () => + treeHost + .all() + .filter((n) => n.dataset.id && n.dataset.id.split("|").length === 3) + .map((n) => n.dataset.id); + const layerButton = (key) => layerBar.children.find((b) => b.textContent === key); + const pressed = () => + layerBar.children + .filter((b) => b.getAttribute("aria-pressed") === "true") + .map((b) => b.textContent); + + out.title = modal.title; + out.start = { table: tableShows.at(-1), layer: pressed(), detail: detailShows.at(-1) }; + + // ① 위 표에서 열 고름(개인 층) → 나무 고름 · 펼쳐 보임 · 굴림 · 오른쪽 카드 · 표 열 + scrolled.length = 0; + onColumn("rvP2"); + out.tablePick = { + active: active(), + visible: leaves().includes("personal|u1|SP2"), + scrolled: scrolled.includes("personal|u1|SP2"), + detail: detailShows.at(-1), + table: tableShows.at(-1), + }; + // 같은 열 다시 · 표에 없는 열 = 아무것도 안 바뀜(표 → 나무 → 표 되돌이 없음) + const before = { detail: detailShows.length, table: tableShows.length }; + onColumn("rvP2"); + onColumn("rvNone"); + out.sameAgain = { + detail: detailShows.length - before.detail, + table: tableShows.length - before.table, + }; + + // ② 층 단추 → 위 표 = 그 층 표(고른 항목은 다른 층이라 열 없음) · 나무 = 그 층 + layerButton("M02_PickerSystem").click(); + out.layerSystem = { table: tableShows.at(-1), layer: pressed(), detail: detailShows.at(-1) }; + layerButton("M02_PickerCompany").click(); + out.layerCompany = { table: tableShows.at(-1), layer: pressed() }; + // 고른 항목의 층으로 돌아오면 그 열이 다시 골라짐 + layerButton("M02_PickerPersonal").click(); + out.layerBack = tableShows.at(-1); + + // ③ 다른 층 표에서 고름 — 접힌 폴더가 펼쳐지고 그 항목 · 카드 · 표 열 + layerButton("M02_PickerSystem").click(); + out.systemLeavesBefore = leaves(); + onColumn("rvS2"); + out.systemPick = { + active: active(), + visible: leaves().includes("system||SS2"), + detail: detailShows.at(-1), + table: tableShows.at(-1), + }; + + // ④ 나무 고름 → 위 표 열(지금 길 그대로) + onColumn("rvS1"); + const leaf = treeHost.all().find((n) => n.dataset.id === "system||SS2"); + leaf.click(); + out.treePick = { active: active(), detail: detailShows.at(-1), table: tableShows.at(-1) }; + + // ⑤ 찾기에 가려진 항목을 표에서 고르면 찾기를 풂 + searchInput.value = "찰쌓기"; + (searchInput.listeners.input || []).forEach((h) => h()); + onColumn("rvS1"); + out.searchCleared = { value: searchInput.value, active: active() }; + + console.log(JSON.stringify(out)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/helper_281_1_drainage_zoom_range.cjs b/resources/tester/helper_281_1_drainage_zoom_range.cjs new file mode 100644 index 000000000..8d066aab7 --- /dev/null +++ b/resources/tester/helper_281_1_drainage_zoom_range.cjs @@ -0,0 +1,130 @@ +/* 281-1 배수유역 지도 줌아웃 한계 헬퍼 — `B05_Profile_UI_Drainage_ZoomRange.ts` 를 + * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 낸다. + * `.cjs` 까닭은 `helper_b05_curve_radius_floor.cjs` 와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +/** TS 한 장을 CommonJS 로 옮겨 실행한다. `@ui/` 는 색 함수만 쓰므로 빈 값으로 갈음한다. */ +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@ui/")) return { themeColor: () => "" }; + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const zoom = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_ZoomRange.ts")); +const render = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapRender.ts")); +const { computeZoomOutLimit, clampWheelScale, ZOOM_OUT_ROUTE_FACTOR, ZOOM_OUT_FALLBACK_SCALE } = + zoom; + +// 도엽 4000 × 3000 m — 경위도는 1 도 = 1000 m 로 단순하게 둔다. +const META = { + x_min: 0, + x_max: 4000, + y_min: 0, + y_max: 3000, + width_meters: 4000, + height_meters: 3000, + lon_min: 127, + lon_max: 131, + lat_min: 36, + lat_max: 39, +}; +const W = 800; +const H = 600; // 도엽과 같은 비율 — 배율 1 = 화면 폭 4000 m +const toLonLat = (x, y) => [127 + x / 1000, 36 + y / 1000]; +/** 배율 s 에서 화면 폭에 드는 실폭(m). */ +const viewWidthM = (s) => 4000 / s; + +// 노선 1000 × 500 m (가운데 2000, 1500) — 2 배 = 2000 × 1000 m. +const ROUTE = [ + { x: 1500, y: 1250 }, + { x: 2500, y: 1750 }, +]; +const routeOnly = computeZoomOutLimit(META, ROUTE, [], W, H); + +// 유역이 노선 2 배 범위 밖(서쪽 400 m 까지)으로 나감 → 폭 2600 m. +const basinOut = computeZoomOutLimit( + META, + ROUTE, + [{ polygon_lonlat: [toLonLat(400, 1500), toLonLat(1500, 1400), toLonLat(1500, 1600)] }], + W, + H, +); +// 유역이 2 배 범위 안 → 한계 그대로. +const basinIn = computeZoomOutLimit( + META, + ROUTE, + [{ polygon_lonlat: [toLonLat(1800, 1400), toLonLat(2200, 1400), toLonLat(2000, 1600)] }], + W, + H, +); + +// 노선이 도엽만큼 길면 2 배가 도엽보다 큼 → 도엽 전체(배율 1)에서 멈춤. +const wide = computeZoomOutLimit( + META, + [ + { x: 100, y: 100 }, + { x: 3900, y: 2900 }, + ], + [], + W, + H, +); + +// 짧은 노선(100 m) — 2 배(200 m)가 처음 보기(노선 + 200 m 여유 양쪽)보다 좁음 → 처음 보기에 맞춤. +const SHORT = [ + { x: 1950, y: 1500 }, + { x: 2050, y: 1500 }, +]; +const short = computeZoomOutLimit(META, SHORT, [], W, H); +const shortInitial = render.computeRouteView( + META, + { x_min: 1950, x_max: 2050, y_min: 1500, y_max: 1500 }, + W, + H, +).scale; + +// 휠 — 밀기(×0.87)를 계속하면 한계에서 멈춤 · 당기기(×1.15)는 상한에서 멈춤. +let scale = 3; +for (let i = 0; i < 40; i += 1) scale = clampWheelScale(scale * 0.87, scale, routeOnly, 200); +const wheelOutStop = scale; +for (let i = 0; i < 80; i += 1) scale = clampWheelScale(scale * 1.15, scale, routeOnly, 200); +const wheelInStop = scale; +// 이미 한계 밖(더 멀리)에 있으면 밀어도 더 안 빠지고 튀지도 않음. +const outside = clampWheelScale(1.2 * 0.87, 1.2, routeOnly, 200); + +process.stdout.write( + JSON.stringify({ + factor: ZOOM_OUT_ROUTE_FACTOR, + fallbackConst: ZOOM_OUT_FALLBACK_SCALE, + routeOnly, + routeOnlyViewM: viewWidthM(routeOnly), + basinOutViewM: viewWidthM(basinOut), + basinIn, + noRoute: computeZoomOutLimit(META, [], [], W, H), + noMeta: computeZoomOutLimit(null, ROUTE, [], W, H), + wide, + short, + shortInitial, + wheelOutStop, + wheelInStop, + outside, + }), +); diff --git a/resources/tester/helper_282_1_profile_toolbar.cjs b/resources/tester/helper_282_1_profile_toolbar.cjs new file mode 100644 index 000000000..e24598ca9 --- /dev/null +++ b/resources/tester/helper_282_1_profile_toolbar.cjs @@ -0,0 +1,185 @@ +/* 282 종단 도구줄 — 진짜 TS(도구 묶음 render · withToolLead · attachProfilePopout · 배수유역 판)를 + * 가짜 DOM 위에서 돌려 JSON 으로 냄. + * · [새 창] = 도구 묶음 맨 앞([초기화] 바로 왼쪽) · 다시 그려도 그 자리 + * · 새 창 열림 · 닫힘 = 글 바뀜 · 제자리 돌림 + * · 옛 값 drainage-visible=false 여도 판(손잡이) 보임 */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8"); +const load = (code, scope) => { + const js = ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const exports = {}; + const names = Object.keys(scope); + new Function("exports", ...names, js)(exports, ...names.map((n) => scope[n])); + return exports; +}; + +// ---- 가짜 DOM ---- +function el(tag) { + const node = { + tagName: tag.toUpperCase(), + className: "", + textContent: "", + title: "", + hidden: false, + disabled: false, + attrs: {}, + listeners: {}, + children: [], + parent: null, + dataset: {}, + style: {}, + classList: { + list: () => node.className.split(/\s+/).filter(Boolean), + contains: (c) => node.classList.list().includes(c), + add: (c) => + !node.classList.contains(c) && (node.className = [...node.classList.list(), c].join(" ")), + remove: (c) => + (node.className = node.classList + .list() + .filter((x) => x !== c) + .join(" ")), + toggle: (c, on) => + (on ?? !node.classList.contains(c)) ? node.classList.add(c) : node.classList.remove(c), + }, + detach() { + if (node.parent) node.parent.children = node.parent.children.filter((c) => c !== node); + node.parent = null; + }, + append(...kids) { + kids.forEach((k) => (k.detach(), (k.parent = node), node.children.push(k))); + }, + prepend(...kids) { + kids.reverse().forEach((k) => (k.detach(), (k.parent = node), node.children.unshift(k))); + }, + replaceChildren(...kids) { + node.children.slice().forEach((c) => c.detach()); + node.append(...kids); + }, + setAttribute: (k, v) => (node.attrs[k] = v), + addEventListener: (type, fn) => (node.listeners[type] ??= []).push(fn), + click: () => (node.listeners.click ?? []).forEach((fn) => fn({ stopPropagation() {} })), + }; + return node; +} +const fakeDoc = () => { + const doc = { title: "" }; + doc.createElement = el; + doc.head = el("head"); + doc.body = el("body"); + doc.documentElement = Object.assign(el("html"), { attributes: [] }); + doc.querySelectorAll = () => []; + doc.importNode = (n) => n; + return doc; +}; +const document = fakeDoc(); +const frames = []; +const window = { + listeners: {}, + addEventListener: (t, fn) => (window.listeners[t] ??= []).push(fn), + removeEventListener: () => {}, + open: () => popup, +}; +const popupListeners = {}; +const popup = { + closed: false, + document: fakeDoc(), + close: () => (popup.closed = true), + addEventListener: (t, fn) => (popupListeners[t] ??= []).push(fn), + ResizeObserver: class { + observe() {} + }, + requestAnimationFrame: () => 0, + cancelAnimationFrame: () => {}, +}; + +// ---- 진짜 도구 묶음 ---- +const { createProfileTools } = load(read("B05_Profile", "B05_Profile_UI_Profile_Tools.ts"), { + require: () => ({ sameRun: () => false }), + document, +}); +const tools = createProfileTools({ + onStraighten() {}, + onShift() {}, + onTilt() {}, + onResetToSaved() {}, + canResetToSaved: () => true, + onUndo() {}, + onRedo() {}, + canUndo: () => false, + canRedo: () => false, + hasStraightRuns: () => false, + onChanged() {}, + onModeChange() {}, + freeHint: () => "", + mountOverlay() {}, +}); + +// ---- 진짜 withToolLead(design_profile.ts) ---- +const profileSrc = read("A00_Common", "design", "design_profile.ts"); +const leadAt = profileSrc.indexOf("const withToolLead"); +const leadSrc = profileSrc.slice(leadAt, profileSrc.indexOf("};", leadAt) + 2); +const button = el("button"); +const { withToolLead } = load(`${leadSrc}\nexports.withToolLead = withToolLead;`, { + ctx: { toolLead: button }, +}); + +// ---- 진짜 attachProfilePopout ---- +const { attachProfilePopout } = load(read("A00_Common", "design", "design_profile_popout.ts"), { + require: () => ({ showToast() {} }), + document, + window, + getComputedStyle: () => ({ backgroundColor: "" }), + requestAnimationFrame: (fn) => frames.push(fn), +}); +const slot = el("div"); +const root = el("div"); +slot.append(root); +const balanceBar = el("div"); +root.append(balanceBar); +const profile = { root, redraw() {}, dispose() {} }; +attachProfilePopout(profile, slot, button); + +const render = () => { + const wrap = withToolLead(tools.render()); + balanceBar.replaceChildren(wrap); + return wrap.children.map((c) => c.textContent); +}; +const out = { first: render(), buttonClass: button.className }; +out.again = render(); +button.click(); +out.opened = { text: button.textContent, inPopup: popup.document.body.children[0] === root }; +out.openedRender = render(); +button.click(); +out.closed = { text: button.textContent, inSlot: root.parent === slot, popupClosed: popup.closed }; +out.closedRender = render(); + +// ---- 옛 값 drainage-visible=false — 판 · 손잡이 보임 ---- +const store = { "drainage-visible": "false", "drainage-collapsed": "false" }; +const sideSrc = read("B05_Profile", "B05_Profile_UI_Drainage_Side.ts"); +const panelRoot = Object.assign(el("aside"), { className: "b05-drainage" }); +const handle = el("button"); +panelRoot.append(handle); +const fakePanel = { root: panelRoot, setView() {}, dispose() {} }; +const side = load(sideSrc, { + require: (id) => (id.endsWith(".css") ? {} : { createDrainagePanel: () => fakePanel }), +}); +const sideToolSrc = read("B05_Profile", "B05_Profile_UI_Profile_SideTool.ts"); +const { drainageSide } = load(sideToolSrc, { + require: (id) => + id.includes("b_page_state") + ? { readStateRaw: (k) => store[k] ?? null, writeStateRaw: (k, v) => (store[k] = v) } + : side, +}); +const board = drainageSide({ projectId: "p", view: "profile" }); +out.side = { + rootHidden: board.root.hidden, + handleHidden: handle.hidden, + hasSetVisible: "setVisible" in board, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_284_1_pill_pick.cjs b/resources/tester/helper_284_1_pill_pick.cjs new file mode 100644 index 000000000..1b7dd660f --- /dev/null +++ b/resources/tester/helper_284_1_pill_pick.cjs @@ -0,0 +1,107 @@ +/* 284 알약 고름 헬퍼 — 진짜 `B05_Profile_UI_Profile_Structures.ts`(plantsStation · stationIdAtStructure · + * structureIdKeeping)를 Node 로 돌려 표준출력 JSON 한 벌로(요구 178 · 179-1). + * 등록부 꼴 = 구조물정의.json 의 group · placement · managed_by 그대로. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), { + "../b_page_state": { readState: () => null, writeState() {} }, +}); +const PREFIX = "irregular:"; +const rule = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), { + "@ui/ui_template_context_menu": { createMapContextMenu() {} }, + "./B05_Profile_UI_IrregularStations": { + irregularStationId: (id) => `${PREFIX}${id}`, + isPipeStation: (station) => station.origin === "pipe", + }, + "./B05_Profile_Api_Structures": { + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + }, + "../A00_Common/design/design_selection": selection, + "../common_util/common_util_structure_station": loadTs( + path.join(ROOT, "common_util", "common_util_structure_station.ts"), + ), +}); + +const TYPES = [ + { type_id: "cross_underdrain", group: "A", placement: "point", managed_by: null }, + { type_id: "retaining_wall", group: "C", placement: "interval", managed_by: null }, + { type_id: "ditch_side", group: "B", placement: "interval", managed_by: null }, + { type_id: "hazard_sign", group: "E", placement: "point", managed_by: null }, + { type_id: "bed_sill", group: "D", placement: "point", managed_by: null }, +]; +const S = (id, type, at, extra = {}) => ({ + structure_id: id, + type_id: type, + chainage_m: at, + ...extra, +}); +const STRUCTURES = [ + S("drain", "cross_underdrain", 40), + S("wall", "retaining_wall", 100, { start_m: 90, end_m: 110 }), + S("ditch", "ditch_side", 80, { start_m: 60, end_m: 100 }), + S("sign", "hazard_sign", 80), // 관 측점(80)과 같은 m — 측점 없는 구조물 + S("sill", "bed_sill", 140), + S("unknown", "legacy_x", 160), + S("pipe-80.00", "pipe", 80), + S("pipe-120.00", "ford_pavement", 120), +]; +// 종단도 구조물 측점(관 투영 + 심은 구조물) — 물넘이포장도 측점 심음(286-1) +const STATIONS = [ + { id: "drain", chainage_m: 40, origin: "structure" }, + { id: "pipe-80.00", chainage_m: 80, origin: "pipe" }, + { id: "wall", chainage_m: 100, origin: "structure" }, + { id: "pipe-120.00", chainage_m: 120, origin: "pipe" }, +]; +const byId = (id) => STRUCTURES.find((s) => s.structure_id === id); + +const plants = Object.fromEntries( + STRUCTURES.map((s) => [s.structure_id, rule.plantsStation(s, TYPES)]), +); +const line = Object.fromEntries( + STRUCTURES.map((s) => [ + s.structure_id, + rule.stationIdAtStructure(s.structure_id, STATIONS, STRUCTURES, TYPES), + ]), +); +// 종류 없이 부르면(옛 부름) 구조물은 옛 길 — 같은 자리 측점선 +const lineNoTypes = rule.stationIdAtStructure("sign", STATIONS, STRUCTURES); +// 관 측점선(80)을 다시 누름 — 고른 것이 측점 없는 표지면 관 알약으로(표지는 선 짝 아님) +const keepSign = rule.structureIdKeeping( + `${PREFIX}pipe-80.00`, + "sign", + STATIONS, + STRUCTURES, + null, + TYPES, +); +// 고른 것이 관이면 그대로 +const keepPipe = rule.structureIdKeeping( + `${PREFIX}pipe-80.00`, + "pipe-80.00", + STATIONS, + STRUCTURES, + null, + TYPES, +); + +process.stdout.write( + JSON.stringify({ plants, line, lineNoTypes, keepSign, keepPipe, sample: byId("sign") }), +); diff --git a/resources/tester/helper_286_1_ford_pavement.cjs b/resources/tester/helper_286_1_ford_pavement.cjs new file mode 100644 index 000000000..0d0b018cc --- /dev/null +++ b/resources/tester/helper_286_1_ford_pavement.cjs @@ -0,0 +1,65 @@ +/* 286 물넘이포장 종횡단 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로. + * 입력 = argv[2] JSON 파일 { pipes, structures, types }. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const station = loadTs(path.join(ROOT, "common_util", "common_util_structure_station.ts")); + +const ford = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts"), { + "./B06_Section_UI_Cross_Wall_Hatch": loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts"), + { "./B06_Section_UI_Cross_Culvert_Const": {} }, + ), + "./B06_Section_UI_Cross_Design_Surface": { appendPavementOverlay() {} }, +}); +// 286-3 도수로 · 돌붙임 길이 — 유출(좌 · 우가 높음) 성토사면 1:1.5(5 m 내려감 · 사면 길이 √(7.5²+5²) ≈ 9.01) +const sets = loadTs(path.join(ROOT, "common_util", "common_util_culvert_sets.ts")); +const slopeDesign = { + design_elevation_m: 100, + cross_slope_pct: 2, + section_mode: "left_cut", + road_edges: { + left: { offset_m: 2.5, elevation_m: 100 }, + right: { offset_m: -2.5, elevation_m: 100 }, + }, + design_line: [ + { offset_m: 10, elevation_m: 95 }, + { offset_m: 2.5, elevation_m: 100 }, + { offset_m: -2.5, elevation_m: 100 }, + { offset_m: -6, elevation_m: 104 }, + ], +}; +const chuteOf = (extra) => + sets.chute( + { ford_chute_1_structure: "돌붙임", ford_chute_1_thickness: 30, ...extra }, + "ford_chute", + ); + +const out = { + chuteNoLength: chuteOf({}), + chuteLength: chuteOf({ ford_chute_1_length_m: 3 }), + lineNoLength: ford.fordChuteLine(slopeDesign, chuteOf({})), + lineLength: ford.fordChuteLine(slopeDesign, chuteOf({ ford_chute_1_length_m: 3 })), + lineTooLong: ford.fordChuteLine(slopeDesign, chuteOf({ ford_chute_1_length_m: 50 })), + fordMarks: station.pipeStationMarks({ chainage_m: 230, facility: "ford_pavement" }), + planted: station.plantedChainages(input.pipes, input.structures, input.types), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_287_ford_pavement.cjs b/resources/tester/helper_287_ford_pavement.cjs new file mode 100644 index 000000000..9b0b2fb48 --- /dev/null +++ b/resources/tester/helper_287_ford_pavement.cjs @@ -0,0 +1,245 @@ +/* 287 물넘이포장 후속 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로. + * 상대 import · `@config/` 는 따라 옮김. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const load = (file, stubs = {}) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (stubs[m]) return stubs[m]; + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; +const D = (f) => path.join(ROOT, "A00_Common", "design", f); +const calc = load(D("design_structure_ford_calc.ts")); + +/** 폼 흉내 — 상태 · setValue 만. */ +const formOf = (slope, chute) => { + const state = { slope: [{ ...slope }], chute: [{ ...chute }] }; + return { state: () => state, setValue: (c, i, v) => (state[c][0][i] = v) }; +}; +const change = (form, c, i) => { + calc.syncChuteArea(form, { id: c }, { id: i }); + return form.state().chute[0].area; +}; + +// 287-2 · 287-4 그림 — DOM 흉내(속성 · 자식만) +class Node { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.dataset = {}; + this.style = {}; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + hasAttribute(k) { + return k in this.attrs; + } + append(...kids) { + this.children.push(...kids); + } + insertBefore(kid, ref) { + const at = ref ? this.children.indexOf(ref) : -1; + if (at < 0) this.children.push(kid); + else this.children.splice(at, 0, kid); + } + get classList() { + const names = () => (this.attrs.class ?? "").split(" ").filter(Boolean); + return { + contains: (c) => names().includes(c), + toggle: (c, on) => { + const rest = names().filter((n) => n !== c); + this.attrs.class = (on ? [...rest, c] : rest).join(" "); + }, + }; + } + addEventListener(type, fn) { + (this.listeners ??= {})[type] = fn; + } + querySelectorAll(selector) { + const attr = /^\[([\w-]+)\]$/.exec(selector)?.[1]; + const all = this.children.flatMap((k) => [k, ...k.querySelectorAll(selector)]); + return all.filter((k) => attr && k.hasAttribute(attr)); + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; +const pave = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts")); +// 서버가 지대 낮은 쪽(우)으로 5 % 기울인 노면 · 노견 끝 ±3 · 차도 끝 ±2.5 +const tilted = { + section_mode: "left_cut", + tilted: true, + cross_slope_pct: 5, + design_elevation_m: 100, + pavement_thickness_m: 0.2, + paved: true, + road_edges: { + left: { offset_m: 3, elevation_m: 100.15 }, + right: { offset_m: -3, elevation_m: 99.85 }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: 100.125 }, + right: { offset_m: -2.5, elevation_m: 99.875 }, + }, + design_line: [ + { offset_m: 6, elevation_m: 103 }, + { offset_m: 3, elevation_m: 100.15 }, + { offset_m: -3, elevation_m: 99.85 }, + { offset_m: -7, elevation_m: 97.2 }, + ], +}; +const svg = new Node("g"); +const drew = pave.appendFordPavementOverlay( + svg, + { span_m: 8, depth_m: 0.4, slope_pct: 5 }, + tilted, + (o) => o, + (e) => e, +); +const ofClass = (c) => svg.children.filter((k) => (k.attrs.class ?? "").split(" ").includes(c)); + +// 287-5 형상 고름 — 판정면 차례 · 누름 = 고름 · 끌기 = 고름 아님 · 노면공 포장층 안 섞임 · 강조 +const shapePick = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts")); +const layer = new Node("g"); +const fordShape = new Node("polygon"); +fordShape.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement"); +fordShape.setAttribute("data-structure-id", "pipe-230.00"); +fordShape.setAttribute("points", "0,0 1,0 1,1"); +const paveShape = new Node("polygon"); // 노면공 포장층 — 범위 표식이 있어 형상 고름 밖 +paveShape.setAttribute("data-structure-id", "pave-1"); +paveShape.setAttribute("data-pave-type", "pavement_concrete"); +const structureHit = new Node("polygon"); +structureHit.setAttribute("class", "b06-chart__culvert-revet-hit"); +layer.append(fordShape, paveShape, structureHit); +const picked = []; +const setShape = shapePick.attachShapePick(layer, (id) => picked.push(id)); +// 보라 손잡이 판정은 뒤에 붙음 — 설계선 고름과 같은 자리(구조물 판정면 앞)에 꽂음 +const purple = new Node("polyline"); +purple.setAttribute("class", "b06-chart__design-hit"); +layer.insertBefore( + purple, + layer.children.find((k) => k.classList.contains("b06-chart__culvert-revet-hit")), +); +const shapeHit = layer.children.find((k) => k.classList.contains("b06-chart__shape-hit")); +const press = (dx) => { + const ev = { + clientX: 10, + clientY: 10, + stopped: false, + stopPropagation() { + this.stopped = true; + }, + }; + shapeHit.listeners.pointerdown(ev); + const up = { + ...ev, + clientX: 10 + dx, + stopPropagation() { + up.stopped = true; + }, + }; + shapeHit.listeners.click(up); + return up.stopped; +}; +const clickStops = press(0); +const dragStops = press(12); +setShape(true); +const activeAfterSet = fordShape.classList.contains("is-active"); +shapePick.highlightShapes(layer, "other"); +const activeAfterOther = fordShape.classList.contains("is-active"); +shapePick.highlightShapes(layer, "pipe-230.00"); +const activeAfterSame = fordShape.classList.contains("is-active"); +// 물넘이 포장층 표식 = 관 지점 알약 id +const tagged = new Node("g"); +pave.appendFordPavementOverlay( + tagged, + { chainage_m: 230, span_m: 8, depth_m: 0.4, slope_pct: 5 }, + tilted, + (o) => o, + (e) => e, +); + +// 287-3 화면 길 — 폼 onChange 훅 그대로(세월교 · 물넘이 한 벌 overflowSync) +globalThis.document.createElement = (tag) => new Node(tag); +const overflowRun = (flowValue) => { + const state = { slope: [{ width: "10", height: "0.05" }] }; + const form = { + state: () => state, + setValue: (c, i, v) => (state[c][0][i] = v), + refresh: () => undefined, + }; + let emitted = 0; + const sync = calc.overflowSync( + () => form, + "slope", + () => flowValue, + () => (emitted += 1), + ); + const edit = (item, value) => { + state.slope[0][item] = value; // 폼이 값을 적고 훅을 부름(design_structure_form onEdit) + sync.onChange({ id: "slope" }, { id: item }); + return state.slope[0].height; + }; + return { + afterWidth8: edit("width", "8"), + afterClear: edit("height", ""), + afterLow: edit("height", "0.01"), + afterHigh: edit("height", "0.9"), + hint: sync.fields({ id: "slope" }).map((el) => el.textContent), + otherBoxHint: sync.fields({ id: "chute" }).length, + emitted, + }; +}; + +const out = { + overflowFlow: overflowRun(14.5776), + overflowNoFlow: overflowRun(null), + shapeOrder: layer.children.map((k) => + (k.attrs.class ?? k.attrs["data-structure-id"]).split(" ").pop(), + ), + picked, + clickStops, + dragStops, + activeAfterSet, + activeAfterOther, + activeAfterSame, + pavedShapeIsPicked: picked.includes("pave-1"), + fordTag: tagged.children + .filter((k) => (k.attrs.class ?? "").includes("ford-pavement")) + .map((k) => k.attrs["data-structure-id"]), + drew, + pavePoints: ofClass("b06-chart__ford-pavement").map((k) => k.attrs.points), + planPoints: ofClass("b06-chart__ford-deck-plan").map((k) => k.attrs.points), + inflowLeft: pave.inflowLeft(tilted), + // 287-3 월류 폭 → 높이 = 세월교와 같은 풀이(유량 1.2 ㎥/s · 폭 10 m) · 유량 없으면 null(손 입력) + heightByWidth: calc.fordHeightM(1.2, 10), + heightNoFlow: calc.fordHeightM(null, 10), + paveSlopeItems: load(D("design_structure_ford_pavement.ts")) + .FORD_PAVEMENT_SPEC.containers.find((c) => c.id === "slope") + .items.map((i) => i.id), + // 287-1 길이 넣음 = 월류 폭 × 길이 · 비움 = 기본값 · 폭 바꿈 = 길이 있을 때만 다시 셈 · 폭 비면 기본 폭 + areaByLength: change(formOf({ width: "6" }, { length_m: "4", area: "30" }), "chute", "length_m"), + areaCleared: change(formOf({ width: "6" }, { length_m: "", area: "24" }), "chute", "length_m"), + areaByWidth: change(formOf({ width: "8" }, { length_m: "2.5", area: "15" }), "slope", "width"), + areaWidthNoLength: change(formOf({ width: "8" }, { length_m: "", area: "17" }), "slope", "width"), + areaNoWidth: change(formOf({ width: "" }, { length_m: "3", area: "30" }), "chute", "length_m"), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_288_underdrain.cjs b/resources/tester/helper_288_underdrain.cjs new file mode 100644 index 000000000..04282f3c8 --- /dev/null +++ b/resources/tester/helper_288_underdrain.cjs @@ -0,0 +1,104 @@ +/* 288 맹암거 횡단 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로. + * 입력 = argv[2] JSON 파일 { structures, sections }. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +class Node { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + append(...kids) { + this.children.push(...kids); + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const util = load(path.join(ROOT, "common_util", "common_util_structure_underdrain.ts")); +const draw = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Underdrain.ts")); + +const specs = util.underdrainSpecsFrom(input.structures); +const sections = input.sections.map((s) => ({ ...s })); +const attached = util.attachUnderdrainSpecs(sections, specs); + +// 지대 낮은 쪽(우)으로 5 % 기울인 노면 · 좌 = 절토(오름) · 우 = 성토(내림) · 암거 높이 1 m +const design = { + road_edges: { + left: { offset_m: 3, elevation_m: 100.15 }, + right: { offset_m: -3, elevation_m: 99.85 }, + }, + design_line: [ + { offset_m: 6, elevation_m: 102.9 }, + { offset_m: 3, elevation_m: 100.15 }, + { offset_m: -3, elevation_m: 99.85 }, + { offset_m: -7, elevation_m: 97.2 }, + ], +}; +const svg = new Node("g"); +const group = draw.appendUnderdrainOverlay( + svg, + { structure_id: "ud-1", chainage_m: 40, height_m: 1, slope_pct: 5 }, + design, + (o) => o, + (e) => e, +); +const body = group?.children.find((k) => k.attrs.class === "b06-chart__underdrain-body"); + +// 288-2 형상 누름 창구 — 물넘이(pipe-…) = 시설 고름 · 맹암거 = id 알림(목록이 고름) +globalThis.CustomEvent = class { + constructor(type, init) { + this.type = type; + this.detail = init?.detail; + this.bubbles = init?.bubbles; + } +}; +const shapePick = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts")); +const routed = []; +const from = { dispatchEvent: (event) => routed.push([event.type, event.detail, event.bubbles]) }; +const route = shapePick.shapePickRoute(from, () => routed.push(["pipe"])); +route("pipe-230.00"); +route("ud-1"); + +process.stdout.write( + JSON.stringify({ + specs, + attached, + sections: sections.map((s) => s.underdrain ?? null), + outline: draw.underdrainOutline(design, 1), + noHeight: draw.underdrainOutline(design, null), + drew: !!group, + bodyId: body?.attrs["data-structure-id"] ?? null, + routed, + eventName: shapePick.SHAPE_PICK_EVENT, + stones: group?.children.filter((k) => k.tag === "g").flatMap((g) => g.children).length ?? 0, + }), +); diff --git a/resources/tester/helper_290_1_project_purge.cjs b/resources/tester/helper_290_1_project_purge.cjs new file mode 100644 index 000000000..ac4d147cb --- /dev/null +++ b/resources/tester/helper_290_1_project_purge.cjs @@ -0,0 +1,90 @@ +/* 290-1 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움 — 진짜 TS(b_page_state · b_project_switch)를 + * 가짜 저장소 위에서 돌려 JSON 으로 냄(공용 storage · 브라우저 안 씀). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const load = (rel, requireStub) => { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const exports = {}; + new Function("exports", "require", "window", js)(exports, requireStub, global.window); + return exports; +}; + +function fakeStorage() { + const map = new Map(); + return { + get length() { + return map.size; + }, + key: (i) => [...map.keys()][i] ?? null, + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + dump: () => Object.fromEntries(map), + }; +} +global.window = { sessionStorage: fakeStorage(), localStorage: fakeStorage() }; + +const pageState = load("A00_Common/b_page_state.ts", () => ({})); +const asked = []; +const purged = []; +let answer = true; +const sw = load("A00_Common/b_project_switch.ts", (id) => { + if (id.includes("b_page_state")) return pageState; + if (id.includes("b_asset_cache")) return { purgeOtherProjects: async (p) => purged.push(p) }; + return { + showConfirmDialog: async (msg, ok, cancel) => (asked.push([msg, ok, cancel]), answer), + }; +}); + +const A = "aaaa1111"; +const B = "bbbb2222"; +const C = "cccc3333"; +const SEED = { + [`aislo:draft:structures:${A}`]: '[{"id":1}]', + [`aislo:draft:crossw:${A}:12`]: '{"10":1}', + [`b06:culvertopt:${A}:12`]: '{"x":1}', // 옛 키(초안) + [`aislo:result:latest:${A}`]: '{"r":1}', + [`aislo:draft:structures:${B}`]: '[{"id":2}]', + [`aislo:result:section-detail:${B}:7`]: '{"d":1}', + [`aislo:result:latest:${C}`]: '{"r":3}', + [`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음 + [`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸 + "aislo.otp-pending": "x", // 프로젝트와 무관 +}; +const seed = (extra = {}) => { + global.window.sessionStorage = fakeStorage(); + Object.entries({ ...SEED, ...extra }).forEach(([k, v]) => + global.window.sessionStorage.setItem(k, v), + ); + asked.length = 0; + purged.length = 0; +}; +const keys = () => Object.keys(global.window.sessionStorage.dump()).sort(); +const nameOf = async (id) => `이름-${id}`; +global.window.localStorage.setItem(`aislo:pref:routeedit-view:${A}`, "{}"); + +(async () => { + const out = {}; + seed(); + answer = true; + await sw.enterProject(B, nameOf); + out.drop = { keys: keys(), asked: [...asked], purged: [...purged] }; + + seed(); + answer = false; + await sw.enterProject(B, nameOf); + out.keep = { keys: keys(), asked: asked.length }; + + seed({ "aislo:entered-project": B }); + await sw.enterProject(B, nameOf); + out.same = { keys: keys(), asked: asked.length, purged: purged.length }; + + out.pref = global.window.localStorage.getItem(`aislo:pref:routeedit-view:${A}`); + out.plan = sw.planProjectPurge(SEED, B).draftProjects; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_294_1_chute_remove_pick.cjs b/resources/tester/helper_294_1_chute_remove_pick.cjs new file mode 100644 index 000000000..e18dc83b0 --- /dev/null +++ b/resources/tester/helper_294_1_chute_remove_pick.cjs @@ -0,0 +1,271 @@ +/* PLAN 294-1 · 294-2 시험 헬퍼 — `B06_Section_UI_Adjust_Dock.ts` 를 트랜스파일해 작은 가짜 DOM 위에서 돌림. + * 294-1 — 강조 켜고 끈 뒤에도 이름표 [삭제] 단추 · 원래 글 · 괄호 붙임도 글 칸만. + * 294-2 — 물넘이포장(배관 명세 숨음)에서 side "pipe" = 「경사각도」 상자 강조. + * 결과 = 표준출력 JSON 한 벌. ⚠ 확장자 `.cjs` 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +// ── 작은 가짜 DOM — 태그 · 클래스 · 속성 · 자식 · 단순 선택자(쉼표 · `>` 하나) ────────── +const TEXT = 3; +class Text { + constructor(data) { + this.nodeType = TEXT; + this.textContent = data; + this.parentNode = null; + } +} +class Comment { + constructor() { + this.nodeType = 8; + this.parentNode = null; + } +} +const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); +const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); +class El { + constructor(tag) { + this.nodeType = 1; + this.tagName = tag.toUpperCase(); + this.attrs = {}; + this.childNodes = []; + this.parentNode = null; + this.hidden = false; + const self = this; + this.dataset = new Proxy( + {}, + { + get: (_, key) => self.attrs[`data-${kebab(String(key))}`], + set: (_, key, value) => ((self.attrs[`data-${kebab(String(key))}`] = String(value)), true), + deleteProperty: (_, key) => delete self.attrs[`data-${kebab(String(key))}`], + }, + ); + this.classList = { + set: new Set(), + add: (...n) => n.forEach((c) => self.classList.set.add(c)), + remove: (...n) => n.forEach((c) => self.classList.set.delete(c)), + contains: (c) => self.classList.set.has(c), + toggle: (c, on) => + (on ?? !self.classList.set.has(c)) ? self.classList.add(c) : self.classList.remove(c), + }; + } + set className(v) { + this.classList.set = new Set(String(v).split(/\s+/).filter(Boolean)); + } + get children() { + return this.childNodes.filter((n) => n instanceof El); + } + get firstChild() { + return this.childNodes[0] ?? null; + } + get firstElementChild() { + return this.children[0] ?? null; + } + get parentElement() { + return this.parentNode instanceof El ? this.parentNode : null; + } + get textContent() { + return this.childNodes.map((n) => (n.nodeType === 8 ? "" : n.textContent)).join(""); + } + set textContent(v) { + this.childNodes.forEach((n) => (n.parentNode = null)); + this.childNodes = []; + this.append(String(v)); + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + hasAttribute(k) { + return k === "hidden" ? this.hidden : k in this.attrs; + } + append(...kids) { + for (const k of kids) { + const node = typeof k === "string" ? new Text(k) : k; + node.parentNode?.childNodes.splice(node.parentNode.childNodes.indexOf(node), 1); + node.parentNode = this; + this.childNodes.push(node); + } + } + after(node) { + const list = this.parentNode.childNodes; + node.parentNode?.childNodes.splice(node.parentNode.childNodes.indexOf(node), 1); + node.parentNode = this.parentNode; + list.splice(list.indexOf(this) + 1, 0, node); + } + insertBefore(node, ref) { + node.parentNode?.childNodes.splice(node.parentNode.childNodes.indexOf(node), 1); + node.parentNode = this; + const at = ref ? this.childNodes.indexOf(ref) : -1; + if (at < 0) this.childNodes.push(node); + else this.childNodes.splice(at, 0, node); + return node; + } + replaceChild(node, old) { + const at = this.childNodes.indexOf(old); + node.parentNode?.childNodes.splice(node.parentNode.childNodes.indexOf(node), 1); + node.parentNode = this; + this.childNodes[at] = node; + old.parentNode = null; + } + replaceChildren(...kids) { + this.childNodes.forEach((n) => (n.parentNode = null)); + this.childNodes = []; + this.append(...kids); + } + scrollIntoView() {} + all() { + return this.children.flatMap((c) => [c, ...c.all()]); + } + matches(selector) { + return selector.split(",").some((one) => { + const parts = one.trim().split(/\s*>\s*/); + if (parts.length === 2) + return ( + matchOne(this, parts[1]) && !!this.parentElement && matchOne(this.parentElement, parts[0]) + ); + return matchOne(this, parts[0]); + }); + } + closest(selector) { + for (let at = this; at instanceof El; at = at.parentElement) + if (at.matches(selector)) return at; + return null; + } + querySelectorAll(selector) { + return this.all().filter((n) => n.matches(selector)); + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } +} +function matchOne(el, compound) { + const tag = compound.match(/^[a-z]+/i)?.[0]; + if (tag && el.tagName !== tag.toUpperCase()) return false; + for (const [, c] of compound.matchAll(/\.([\w-]+)/g)) if (!el.classList.contains(c)) return false; + for (const [, k, v] of compound.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) { + if (k === "hidden") { + if (!el.hidden) return false; + } else if (v === undefined ? !(k in el.attrs) : el.attrs[k] !== v) return false; + } + return true; +} +const body = new El("body"); +globalThis.Node = { TEXT_NODE: TEXT }; +globalThis.document = { + createElement: (tag) => new El(tag), + createTextNode: (data) => new Text(data), + createComment: () => new Comment(), + querySelectorAll: (s) => body.querySelectorAll(s), + querySelector: (s) => body.querySelector(s), +}; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, require); + return box.exports; +} +const dock = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Adjust_Dock.ts")); + +// ── 폼 흉내 — 명세 상자 = fieldset[data-spec-container] > legend(글 + [삭제]) ────────── +const el = (tag, attrs = {}, ...kids) => { + const node = new El(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") node.className = v; + else if (k === "hidden") node.hidden = v; + else node.setAttribute(k, v); + } + node.append(...kids); + return node; +}; +const removeButton = () => el("button", { "data-spec-remove": "chute" }, "삭제"); +const box = (id, title, withRemove, ...kids) => { + const legend = el("legend", {}, title); + if (withRemove) legend.append(" ", removeButton()); + return el("fieldset", { "data-spec-container": id }, legend, ...kids); +}; +const slots = { + inlet: el("div"), + outlet: el("div"), + extra: el("div"), + revetInlet: el("div"), + revetOutlet: el("div"), + wingInlet: el("div"), + wingOutlet: el("div"), +}; +// 배관 명세(숨음 · 물넘이포장 고른 때) — 「관」 · 유입 · 유출 칸 +const pipeSpec = el( + "div", + { class: "b05-structure__spec", hidden: true }, + box("pipe", "관", false), + box("inlet", "유입구", false, slots.inlet), + box("outlet", "유출구", false, slots.outlet), + box("revet_in", "기슭막이 유입", false, slots.revetInlet), + box("revet_out", "기슭막이 유출", false, slots.revetOutlet), + box("extra", "추가", false, slots.extra), +); +// 보이는 명세 — 날개벽 유출 칸(괄호 붙임 + [삭제] 단추 상자) · 물넘이포장 경사각도 · 도수로/돌붙임 +const wingOut = box("wing_out", "유출 날개벽", true, slots.wingOutlet); +const ford = el( + "div", + { class: "b05-structure__spec" }, + box("wing_in", "유입 날개벽", false, slots.wingInlet), + wingOut, + box("slope", "경사각도", false), + box("chute", "도수로/돌붙임", true), +); +body.append(pipeSpec, ford); +dock.setAdjustSideSlots(slots); + +const legendOf = (sel) => { + const fs_ = body.querySelector(sel); + const legend = fs_.querySelector("legend"); + return { + active: fs_.classList.contains("is-active"), + text: legend.childNodes.find((n) => n.nodeType === TEXT)?.textContent, + buttons: legend.children.filter((c) => c.tagName === "BUTTON").map((c) => c.textContent), + base: legend.dataset.baseLabel ?? null, + }; +}; +const CHUTE = 'fieldset[data-spec-container="chute"]'; +const SLOPE = 'fieldset[data-spec-container="slope"]'; +const WING = 'fieldset[data-spec-container="wing_out"]'; +const PIPE = 'fieldset[data-spec-container="pipe"]'; + +const panel = el("div"); +const out = {}; +// 294-1 ① 도수로 띠 고름 → 상자 강조 · 단추 그대로 +panel.dataset.side = "chute"; +dock.adoptAdjustPanel(panel); +out.chuteOn = legendOf(CHUTE); +// ② 날개벽 유출 고름 — 괄호 붙임(글 칸만) · 단추 그대로 · 도수로 강조 풂 +panel.dataset.side = "outlet"; +panel.dataset.panelTitle = "기슭막이"; +dock.refreshAdjustSlots(); +out.wingOn = legendOf(WING); +out.chuteOff = legendOf(CHUTE); +// 294-2 ③ 물넘이포장 콘크리트(side "pipe" · 관 상자 숨음) = 경사각도 상자 +panel.dataset.side = "pipe"; +dock.refreshAdjustSlots(); +out.slopeOn = legendOf(SLOPE); +out.pipeOn = legendOf(PIPE).active; +out.wingOff = legendOf(WING); +// ④ 풂 — 모두 원래 글 · 단추 +panel.dataset.side = ""; +dock.refreshAdjustSlots(); +out.released = [CHUTE, WING, SLOPE].map((sel) => legendOf(sel)); +// ⑤ 배관을 고른 때(배관 명세 보임 · 물넘이 숨음)는 그대로 「관」 상자 +pipeSpec.hidden = false; +ford.hidden = true; +panel.dataset.side = "pipe"; +dock.refreshAdjustSlots(); +out.pipeVisible = { pipe: legendOf(PIPE).active, slope: legendOf(SLOPE).active }; +console.log(JSON.stringify(out)); diff --git a/resources/tester/helper_294_3_ford_extend_chute.cjs b/resources/tester/helper_294_3_ford_extend_chute.cjs new file mode 100644 index 000000000..0a3dc1bf9 --- /dev/null +++ b/resources/tester/helper_294_3_ford_extend_chute.cjs @@ -0,0 +1,94 @@ +/* 294 물넘이 낮은 쪽 연장 · 도수로 끝단 · 띠 ↔ 사면 고름 헬퍼 — 진짜 TS 를 Node 로 돌려 JSON 한 벌로. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +class Node { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.dataset = {}; + this.style = {}; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + append(...kids) { + this.children.push(...kids); + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const pave = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts")); +const design = input.design; // 서버가 낸 양측 절토 · 낮은 쪽 연장 설계 +const svg = new Node("g"); +pave.appendFordPavementOverlay( + svg, + { span_m: 8, depth_m: 0.3, slope_pct: 5 }, + design, + (o) => o, + (e) => e, +); +const paveLayer = svg.children.find((k) => (k.attrs.class ?? "").includes("ford-pavement")); + +// 294-4 도수로 · 돌붙임 끝단 · 길이 +const hatch = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts")); +const P = (offset, elevation) => ({ offset, elevation }); +const slope = [{ points: [P(0, 0), P(3, -4)], kind: "fill" }]; // 사면 5 m +const flat = [P(3, -4), P(5, -4), P(8, -4)]; // 사면 끝 바깥 원지반 +const pts = (lines) => + lines.map((l) => l.points.map((p) => [+p.offset.toFixed(4), +p.elevation.toFixed(4)])); +const dipGround = hatch.groundLine(input.dipSamples); +const fillGround = hatch.groundLine(input.fillSamples); +const calc = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts")); +const formOf = (state) => ({ state: () => state, setValue: (c, i, v) => (state[c][0][i] = v) }); +const fordState = { ford: [{ width: "8" }], chute_add: [{ length_m: "2.5", area: "30" }] }; +calc.syncChuteArea(formOf(fordState), { id: "chute_add" }, { id: "length_m" }, "chute_add", "ford"); +const guard = load(path.join(ROOT, "A00_Common", "design", "design_structure_guard.ts")); +const fordPave = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_pavement.ts")); + +const out = { + edges: pave.fordEdges(design), + pavePoints: paveLayer?.attrs.points ?? null, + runNone: pts(hatch.chuteRun(slope)), + runShort: pts(hatch.chuteRun(slope, 2)), + runLong: pts(hatch.chuteRun(slope, 7, flat)), + runPointOnly: pts(hatch.chuteRun([{ points: [P(3, -4)] }])), + runPointLength: pts(hatch.chuteRun([{ points: [P(3, -4)] }], 2, flat)), + dipLine: pave.outletFillLine(design, dipGround), + dipChute: pave.fordChuteLine(design, {}, dipGround), + dipChuteLength: pave.fordChuteLine(design, { length_m: 1.5 }, dipGround), + fillLine: pave.outletFillLine(input.fillDesign, fillGround), + fillLineOld: pave.outletFillLine(input.fillDesign), + fillChuteLength: pave.fordChuteLine(input.fillDesign, { length_m: 1 }, fillGround), + fordArea: fordState.chute_add[0].area, + chuteItems: guard.CHUTE_ITEMS.map((i) => i.id), + fordPaveChuteSame: + fordPave.FORD_PAVEMENT_SPEC.containers.find((c) => c.id === "chute").items === + guard.CHUTE_ITEMS, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_294_6_ford_linked_mark.cjs b/resources/tester/helper_294_6_ford_linked_mark.cjs new file mode 100644 index 000000000..98dc452df --- /dev/null +++ b/resources/tester/helper_294_6_ford_linked_mark.cjs @@ -0,0 +1,142 @@ +/* 294-6 물넘이 연결 표기 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로. + * 옆 카드(물넘이 측점 ≠ 카드 측점) · 주인 카드에 물넘이포장을 그리고 연결 꼴 · 호버 글 · 고름 표식을 잼. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +// DOM 흉내 — 속성 · 자식 · classList · 글만 +class Node { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.dataset = {}; + this.style = {}; + this.textContent = ""; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + hasAttribute(k) { + return k in this.attrs; + } + append(...kids) { + this.children.push(...kids); + } + get classList() { + const names = () => (this.attrs.class ?? "").split(" ").filter(Boolean); + const set = (list) => (this.attrs.class = list.join(" ")); + return { + contains: (c) => names().includes(c), + add: (c) => names().includes(c) || set([...names(), c]), + toggle: (c, on) => + set(on ? [...names().filter((n) => n !== c), c] : names().filter((n) => n !== c)), + }; + } + querySelectorAll(selector) { + const attr = /^\[([\w-]+)\]$/.exec(selector)?.[1]; + const all = this.children.flatMap((k) => [k, ...k.querySelectorAll(selector)]); + return all.filter((k) => attr && k.hasAttribute(attr)); + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; + +const B06 = (f) => path.join(ROOT, "B06_Section", f); +const pave = load(B06("B06_Section_UI_Cross_Ford_Pavement.ts")); +const linked = load(B06("B06_Section_UI_Cross_Ford_Linked.ts")); +const shapePick = load(B06("B06_Section_UI_Cross_Shape_Pick.ts")); + +const design = { + section_mode: "left_cut", + tilted: true, + cross_slope_pct: 5, + design_elevation_m: 100, + pavement_thickness_m: 0.2, + paved: true, + road_edges: { + left: { offset_m: 3, elevation_m: 100.15 }, + right: { offset_m: -3, elevation_m: 99.85 }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: 100.125 }, + right: { offset_m: -2.5, elevation_m: 99.875 }, + }, + design_line: [ + { offset_m: 6, elevation_m: 103 }, + { offset_m: 3, elevation_m: 100.15 }, + { offset_m: -3, elevation_m: 99.85 }, + { offset_m: -7, elevation_m: 97.2 }, + ], +}; +// 검증 프로젝트 꼴 — 4+10(물넘이 · 폭 10) → 4+15 카드에도 포장 · 측점 간격 20 +const spec = { chainage_m: 90, span_m: 10, depth_m: 0.4, slope_pct: 5 }; + +const draw = (chainage) => { + const svg = new Node("g"); + const before = new Node("polyline"); // 앞서 그린 것(세월교 점선 등) — 연결 꼴 밖 + before.setAttribute("class", "b06-chart__ford-deck-plan"); + svg.append(before); + const from = svg.children.length; + pave.appendFordPavementOverlay( + svg, + spec, + design, + (o) => o, + (e) => e, + ); + const marked = linked.markLinkedFordPavement( + svg, + from, + { ford_pavement: spec, chainage_m: chainage }, + 20, + ); + const shapes = svg.children + .slice(from) + .filter((k) => + ["b06-chart__ford-pavement", "b06-chart__ford-deck-plan"].some((c) => + k.classList.contains(c), + ), + ); + shapePick.highlightShapes(svg, "pipe-90.00"); + return { + marked, + classes: shapes.map((k) => k.attrs.class), + titles: shapes.map((k) => + k.children.filter((c) => c.tag === "title").map((c) => c.textContent), + ), + pick: shapes + .filter((k) => k.hasAttribute("data-structure-id")) + .map((k) => k.attrs["data-structure-id"]), + beforeClass: before.attrs.class, + }; +}; + +process.stdout.write( + JSON.stringify({ + neighbor: draw(95), + owner: draw(90), + noSpec: linked.markLinkedFordPavement(new Node("g"), 0, { chainage_m: 95 }, 20), + }), +); diff --git a/resources/tester/helper_294_7_ford_slab_pick.cjs b/resources/tester/helper_294_7_ford_slab_pick.cjs new file mode 100644 index 000000000..b09b33ccb --- /dev/null +++ b/resources/tester/helper_294_7_ford_slab_pick.cjs @@ -0,0 +1,304 @@ +/* PLAN 294-7 · 295-4 단위검증 헬퍼 — 물넘이 콘크리트 누름(얇은 판정면 · 자기 물넘이 고름) · + * 보라 선 첫 누름이 남은 부재 고름을 안 되살림. 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. + * 짝 = `test_294_7_ford_slab_pick.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); + +class FakeStorage { + getItem(key) { + return Object.prototype.hasOwnProperty.call(this, key) ? this[key] : null; + } + setItem(key, value) { + this[key] = String(value); + } + removeItem(key) { + delete this[key]; + } +} +globalThis.window = { sessionStorage: new FakeStorage(), localStorage: new FakeStorage() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; +globalThis.requestAnimationFrame = (fn) => fn(); + +const STUBS = { + "./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} }, + "./B06_Section_UI_Cross_Design_Pick": { setBermPickHost: () => {} }, + "./B06_Section_UI_Page_Persist": { BERM_TYPE_ID: "berm" }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const SP = loadTs(path.join(B06, "B06_Section_UI_Cross_Shape_Pick.ts")); +const PO = loadTs(path.join(B06, "B06_Section_UI_Cross_Pick_Order.ts")); +const SEL = loadTs(path.join(B06, "B06_Section_UI_Page_Structures_Selection.ts")); +const DS = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts")); +const out = {}; + +/* ── ① 얇은 콘크리트 판정면 — 기본 배율 4 px · 노면선 y=100 ─────────────── */ +const slab = "100,100 200,96 200,100 100,104"; // 기운 노면 · 두께 4 +const thick = SP.thickenDown(slab); +const pts = (s) => s.split(" ").map((p) => p.split(",").map(Number)); +const inside = (p, ring) => { + let inn = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [ax, ay] = ring[i]; + const [bx, by] = ring[j]; + if (ay > p[1] !== by > p[1] && p[0] < ((bx - ax) * (p[1] - ay)) / (by - ay) + ax) inn = !inn; + } + return inn; +}; +const gap = (p, [a, b]) => { + const d = [b[0] - a[0], b[1] - a[1]]; + const t = Math.max( + 0, + Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / (d[0] ** 2 + d[1] ** 2)), + ); + return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]); +}; +// 카드 층 흉내 — 보라 노면선 판정(굵기 10 = 위아래 5) · 형상 판정면 · 중심 십자 없음 +const node = (name, classes, points) => ({ + name, + classList: { contains: (c) => classes.includes(c) }, + getAttribute: (a) => (a === "points" ? points : null), +}); +function stacked(shapePoints) { + const kids = []; + const layer = { + children: kids, + append: (...nodes) => + nodes.forEach((n) => { + const at = kids.indexOf(n); + if (at >= 0) kids.splice(at, 1); + kids.push(n); + }), + addEventListener: () => {}, + }; + layer.append( + node("road", ["b06-chart__design-hit"], "100,100 200,96"), + node("slab", ["b06-chart__shape-hit"], shapePoints), + ); + PO.stackPickTargets(layer); + return kids; +} +const topAt = (p, kids) => + [...kids] + .reverse() + .find((n) => + n.classList.contains("b06-chart__design-hit") + ? gap(p, pts(n.getAttribute("points"))) <= 5 + : inside(p, pts(n.getAttribute("points"))), + )?.name ?? null; +const now = stacked(thick); +const old = stacked(slab); +out.slab = { + thick, + bottomAt150: (pts(thick)[2][1] + pts(thick)[3][1]) / 2, // 아랫변 가운데 + justBelow: topAt([150, 106], now), // 노면선 6 px 아래 + justBelowOld: topAt([150, 106], old), + onLine: topAt([150, 99], now), // 노면선 위 = 보라 선(끌기 · 185) + insideOld: topAt([150, 101], old), // 옛 판정면 안쪽도 보라 띠에 덮임 + order: now.map((n) => n.name), + tallUnchanged: SP.thickenDown("0,0 30,0 30,20 0,20") === "0,0 30,0 30,20 0,20", +}; + +/* ── ② 형상 누름 길 — 물넘이 id = 그 누가거리 · 맹암거 = 알림 ─────────────── */ +const routed = []; +const from = { dispatchEvent: (event) => routed.push(["event", event.detail]) }; +globalThis.CustomEvent = class { + constructor(type, init) { + this.type = type; + this.detail = init?.detail; + } +}; +const route = SP.shapePickRoute(from, (at) => routed.push(["pipe", at])); +route("pipe-90.00"); +route("ud-1"); +out.routed = routed; + +/* ── ③ 폼에 올릴 시설(주인 측점) — 연동 관 95 m 가 걸친 물넘이 90 m 카드 ──── */ +const sections = [ + { chainage_m: 80, station_id: "s80", culvert: { c: 80 }, ford_pavement: { chainage_m: 90 } }, + { chainage_m: 90, station_id: "s90", ford_pavement: { chainage_m: 90 } }, + { chainage_m: 95, station_id: "s95", culvert: { c: 95 } }, + { chainage_m: 110, station_id: "s110", culvert: { c: 110 } }, +]; +const log = []; +const stationControls = { + revetOffset: { select: (at, key) => log.push(["select", at, key]), highlightFor: () => null }, + ford: { select: () => {} }, + box: { select: () => {} }, + structureSpan: { + // 연동 관 95 m 의 연장(유출 흙막이 10 m)이 90 m 카드까지 옴 + ownerOf: (s) => + s.culvert ? s : s.chainage_m >= 85 && s.chainage_m <= 105 ? sections[2] : null, + }, + inletStructure: { set: () => {}, valueFor: () => "auto" }, +}; +const panel = { + root: { addEventListener: () => {}, querySelectorAll: () => [], contains: () => false }, + facility: { onInletStructureChange: () => {}, setInletStructure: () => {} }, + currentChainage: () => null, + showAtChainage: (at) => log.push(["form", at]), + releasePipe: () => log.push(["release"]), + getStructures: () => [], + selectStructure: () => {}, +}; +SEL.wireStructureSelection( + stationControls, + () => ({ cross_sections: sections }), + panel, + (at, key) => log.push(["remember", at, key]), +); +const run = (...args) => { + log.length = 0; + stationControls.revetOffset.select(...args); + return log.filter(([kind]) => kind !== "select"); +}; +out.owner = { + slabOwnCard: run(90, "pipe"), // 형상 누름 · 되살림 = 그 물넘이 90(옛 = 연동 관 95) + slabHint: run(90, "pipe", 90), + slabLinkedCard: run(80, "pipe", 90), // 자기 관 있는 이웃 카드의 연동 콘크리트 = 물넘이 + pipeBody: run(80, "pipe"), // 관 몸통 = 자기 관 그대로 + chute: run(90, "chute"), +}; + +/* ── ④ 보라 선 첫 누름(295-4) — 남은 부재키(도수로 띠)를 카드 고름이 지키지 않게 ── */ +const P = "p1"; +const seed = () => { + DS.selectDesign(P, { chainageM: null, structureId: null }); + DS.selectDesign(P, { chainageM: 110, structureId: null }, { partKey: "chute" }); +}; +seed(); +DS.selectDesign(P, { chainageM: 110, structureId: null }, { from: "b06" }); // 옛 길 = 카드 고름만 +const oldKey = DS.readSelection(P)?.partKey ?? null; +seed(); +DS.selectDesign(P, { chainageM: 110, structureId: null }, { partKey: "", from: "b06" }); // 선 첫 누름 = 풂 먼저 +DS.selectDesign(P, { chainageM: 110, structureId: null }, { from: "b06" }); // 그다음 카드 고름 +out.firstLine = { oldKey, newKey: DS.readSelection(P)?.partKey ?? null }; + +/* ── ⑤ 안 골라진 카드 첫 누름(294-8 · 295-4) — 페이지 흐름: 부재 고름 → 카드 고름 리스너 → 폼 엶 ── + * 폼은 열 때 두 번 그림(종류 칸 = 기본값 → 시설 값) — 둘째 그림에 도수로 상자가 섬. */ +const tick = () => new Promise((done) => setTimeout(done, 0)); +const flowSections = [ + { chainage_m: 90, station_id: "s90", ford_pavement: { chainage_m: 90, chute: {} } }, + { chainage_m: 95, station_id: "s95", culvert: { c: 95, chute: {} } }, + { chainage_m: 110, station_id: "s110", ford_pavement: { chainage_m: 110, chute: {} } }, +]; +const flowLog = []; +const box = (id) => { + const el = { + dataset: { specContainer: id, specIndex: "0" }, + closest: (sel) => (sel.startsWith("fieldset") ? el : null), + }; + return el; +}; +let shown = null; +let boxes = []; +const handlers = {}; +let flowPicked = null; +const flowControls = { + revetOffset: { + select: (at, key) => { + flowLog.push(["select", at, key]); + flowPicked = key ? { at, key } : null; + }, + highlightFor: (s) => (flowPicked?.at === s.chainage_m ? flowPicked.key : null), + }, + ford: { select: () => {} }, + box: { select: () => {} }, + structureSpan: { + // 연동 관 95 m 의 유출 흙막이가 4+10(90 m) 카드까지 옴(299) + ownerOf: (s) => (s.culvert ? s : s.chainage_m === 90 ? flowSections[1] : null), + }, + inletStructure: { set: () => {}, valueFor: () => "auto" }, +}; +const paint = (ids) => { + boxes = ids.map(box); + handlers.specpaint?.forEach((fn) => fn()); +}; +const flowPanel = { + root: { + addEventListener: (type, fn) => (handlers[type] ??= []).push(fn), + querySelectorAll: () => boxes, + contains: () => false, + }, + facility: { onInletStructureChange: () => {}, setInletStructure: () => {} }, + currentChainage: () => shown, + showAtChainage: (at) => { + flowLog.push(["form", at]); + shown = at; + if (at === null) return { chainageM: null, structureId: null }; + paint(at === 95 ? ["pipe"] : ["slope"]); // 종류 칸 = 기본값 + paint(at === 95 ? ["pipe", "chute_add"] : ["slope", "chute"]); // 시설 값 = 도수로 상자 섬 + return { chainageM: at, structureId: null }; + }, + releasePipe: () => flowLog.push(["release"]), + getStructures: () => [], + selectStructure: () => {}, +}; +const flowWired = SEL.wireStructureSelection( + flowControls, + () => ({ cross_sections: flowSections }), + flowPanel, + (at, key) => flowLog.push(["remember", at, key]), +); +// 카드 고름 리스너(`design_view_cross` setStationSelectListener) — 폼 자리는 배선이 돌려준 함수 그대로 +const cardSelected = (target) => flowPanel.showAtChainage(flowWired.formChainageOf(target)); +const esc = () => { + flowPicked = null; + shown = null; + boxes = []; +}; +const flow = async (before, press) => { + esc(); + before?.(); + await tick(); + flowLog.length = 0; + press(); + const got = flowLog.map((entry) => [...entry]); + await tick(); + return got; +}; +// 형상 누름 = toggleRevet("pipe", 90) — 안 골라진 카드면 부재 고름 → onSelect +const slabPress = () => { + flowControls.revetOffset.select(90, "pipe", 90); + cardSelected(flowSections[0]); +}; +// 보라 선 첫 누름 = pickLine — 남은 부재 풂 → onSelect +const linePress = () => { + flowControls.revetOffset.select(110, null); + cardSelected(flowSections[2]); +}; + +(async () => { + // 다시 누름 = 풂 — 고름 표시(마이크로태스크)가 풀린 뒤 + await Promise.resolve(); + out.owner.release = run(90, null, 90); + out.first = { + slabAfterEsc: await flow(null, slabPress), + slabAfterOther: await flow(() => flowPanel.showAtChainage(95), slabPress), + lineAfterEsc: await flow(null, linePress), + lineAfterOther: await flow(() => flowPanel.showAtChainage(95), linePress), + }; + // 폼 안 [추가] · 단 늘림 = 그 상자 고름 그대로(54-30) + flowPanel.showAtChainage(95); + flowLog.length = 0; + paint(["pipe", "chute_add", "soil_add"]); + out.first.addBox = flowLog.map((entry) => [...entry]); + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_295_pick_order_detail.cjs b/resources/tester/helper_295_pick_order_detail.cjs new file mode 100644 index 000000000..f380f8e9f --- /dev/null +++ b/resources/tester/helper_295_pick_order_detail.cjs @@ -0,0 +1,273 @@ +/* PLAN 295 단위검증 헬퍼 — 횡단 카드 고르기 차례 · [상세] 모달 고르기 차례 · 첫 보기 · 구조물 선 안 나눔. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_295_pick_order_detail.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const P = loadTs(path.join(B06, "B06_Section_UI_Cross_Pick_Order.ts")); +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); +const H = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Home.ts")); +const out = {}; + +/* ── 카드: 흉내 층 ───────────────────────────────────────────────────── */ +function node(name, classes, points) { + return { + name, + parent: null, + tagName: points && classes.includes("b06-chart__design-hit") ? "polyline" : "polygon", + classList: { contains: (c) => classes.includes(c) }, + getAttribute: (a) => (a === "points" ? points : null), + }; +} +function layer(kids) { + const listeners = []; + const self = { + children: [], + append: (...nodes) => + nodes.forEach((n) => { + const at = self.children.indexOf(n); + if (at >= 0) self.children.splice(at, 1); + self.children.push(n); + }), + addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture }), + fire: (type, event) => { + let stopped = false; + const full = { ...event, stopPropagation: () => (stopped = true) }; + listeners.filter((l) => l.type === type && l.capture).forEach((l) => l.fn(full)); + return stopped; + }, + }; + self.append(...kids); + return self; +} +const box = (x0, y0, x1, y1) => `${x0},${y0} ${x1},${y0} ${x1},${y1} ${x0},${y1}`; +const HIT = "b06-chart__culvert-revet-hit"; +// 꽂힌 옛 차례 — 면적 띠(맨 아래) · 보라 선(구조물 판정면 앞) · 형상 · 도수로 띠 · 벽 · 관 +const kids = [ + node("band", ["b06-chart__area"], box(0, 0, 600, 300)), + node("slope", ["b06-chart__design-hit"], "400,100 500,200"), + node("top", ["b06-chart__design-hit"], "300,100 400,100"), + node("shape", ["b06-chart__shape-hit"], box(300, 100, 310, 110)), // 100 + node("chute", [HIT, "b06-chart__chute-hit"], box(390, 90, 510, 210)), // 14400 — 성토 사면을 덮음 + node("wall", [HIT], box(200, 100, 220, 300)), // 4000 + node("pipe", [HIT], box(250, 250, 255, 255)), // 25 +]; +const L = layer(kids); +P.stackPickTargets(L); +out.cardOrder = L.children.map((n) => n.name); +// 누른 자리에서 맨 위 판정(뒤에서부터) — 다각형 안 · 선은 5 px 안 +const inside = (p, pts) => { + let inn = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const [ax, ay] = pts[i]; + const [bx, by] = pts[j]; + if (ay > p[1] !== by > p[1] && p[0] < ((bx - ax) * (p[1] - ay)) / (by - ay) + ax) inn = !inn; + } + return inn; +}; +const gap = (p, [a, b]) => { + const d = [b[0] - a[0], b[1] - a[1]]; + const t = Math.max( + 0, + Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / (d[0] ** 2 + d[1] ** 2)), + ); + return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]); +}; +const ptsOf = (n) => + n + .getAttribute("points") + .split(" ") + .map((q) => q.split(",").map(Number)); +const topAt = (p, order) => + [...order] + .reverse() + .find((n) => + n.classList.contains("b06-chart__design-hit") ? gap(p, ptsOf(n)) <= 5 : inside(p, ptsOf(n)), + )?.name; +out.slopeClick = topAt([450, 150], L.children); // 띠 안 성토 사면 위 +out.slopeClickOld = topAt([450, 150], kids); // 옛 차례 +out.chuteClick = topAt([480, 120], L.children); // 띠 안 · 선 밖 +out.pipeOverWall = topAt([252, 252], [...L.children]); +out.area = P.pointsArea(box(0, 0, 10, 20)); +// 끌기 = 고름 아님 +L.fire("pointerdown", { clientX: 10, clientY: 10 }); +out.dragStopped = L.fire("click", { clientX: 20, clientY: 10 }); +L.fire("pointerdown", { clientX: 10, clientY: 10 }); +out.clickStopped = L.fire("click", { clientX: 11, clientY: 11 }); + +/* ── 모달: 고르기 차례 ───────────────────────────────────────────────── */ +const sq = (x0, y0, x1, y1) => [ + [x0, y0], + [x1, y0], + [x1, y1], + [x0, y1], +]; +const big = sq(0, 0, 100, 100); +const small = sq(40, 40, 60, 60); +const edges = (ring) => ring.map((p, i) => [p, ring[(i + 1) % ring.length]]); +const paths = [...edges(big), ...edges(small)]; +const pieces = paths.map((points) => ({ line: "design", points })); +const rings = [big, small]; +const areas = [10000, 400]; +const pick = (p, band = null) => S.pickAt(p, 1, paths, rings, areas, pieces, band); +out.modal = { + line: pick([50, 41]), // 작은 면 둘레 1 px + small: pick([50, 50]), + big: pick([20, 20]), + bandSmaller: pick([50, 50], 100), + bandBigger: pick([50, 50], 5000), + bandBigFace: pick([20, 20], 5000), + bandOnly: S.pickAt([500, 500], 1, paths, rings, areas, pieces, 50), + lineOverBand: pick([50, 41], 1), +}; + +/* ── 모달: 첫 보기 ───────────────────────────────────────────────────── */ +const plot = { x: 50, y: 10, width: 500, height: 280 }; +const center = [300, 150]; +const drawn = [ + [100, 120], + [500, 140], + [300, 360], // 아래로 210 — 큰 쪽 + [200, 100], + [5000, 9000], // 가로 창 밖 = 셈 안 함 +]; +const home = H.detailHomeView(drawn, center, plot, 1, 16); +const map = ([x, y]) => [home.tx + home.scale * x, home.ty + home.scale * y]; +out.home = { + scale: home.scale, + center: map(center), + plotCenter: [plot.x + plot.width / 2, plot.y + plot.height / 2], + fits: drawn.slice(0, 4).every(([x, y]) => { + const [sx, sy] = map([x, y]); + return ( + sx >= plot.x - 1e-6 && + sx <= plot.x + plot.width + 1e-6 && + sy >= plot.y - 1e-6 && + sy <= plot.y + plot.height + 1e-6 + ); + }), + flat: H.detailHomeView([[300, 150]], center, plot, 2, 16).scale, +}; + +/* ── 모달: 구조물 선 안 나눔 ─────────────────────────────────────────── */ +// 상자(구조물 네 변 · 변마다 묶음) + 가로지르는 원지반선 +const boxLines = edges(sq(0, 0, 10, 10)).map((points, group) => ({ + line: "structure", + points, + group, +})); +const split = G.splitAtCrossings([ + ...boxLines, + { + line: "ground", + points: [ + [-5, 5], + [15, 5], + ], + }, +]); +const left = split.findIndex( + (p) => p.line === "structure" && p.points.every(([x]) => Math.abs(x) < 1e-9), +); +const members = S.groupMembers(split, left); +const chosen = S.toggleMembers([], members, false); +out.structure = { + pieces: split.length, + groundPieces: split.filter((p) => p.line === "ground").length, + members: members.length, + chosen: chosen.length, + again: S.toggleMembers(chosen, members, false).length, + additiveOff: S.toggleMembers([...chosen, 99], members, true), + label: S.selectionLabel( + undefined, + chosen.map((i) => split[i]), + ), + faces: G.findFaces(split) + .map((f) => Math.round(f.area * 100) / 100) + .sort(), + // 옛 저장 조각(구조물 선 아래 반쪽) — 그대로 다시 고름 + oldPick: split.filter((p) => S.samePiece(p, { line: "structure", start: [0, 0], end: [0, 5] })) + .length, + wholeValue: G.piecesValue( + "length", + chosen.map((i) => G.pieceSource(split[i])), + {}, + ), +}; +// collectPieces — 구조물 선(설계선 표식 없음)에 묶음 · 설계선은 없음 +const axes = { + widthPx: 600, + heightPx: 300, + pixelsPerMeter: 10, + maxOffset: 20, + displayMax: 120, + elevationMid: 110, + exaggeration: 1, +}; +const px = ([offset, elevation]) => + [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ].join(","); +const el = (classes, points, attrs = {}) => ({ + tagName: "polyline", + classList: { contains: (name) => classes.includes(name) }, + hasAttribute: (name) => name in attrs, + getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)), +}); +const collected = S.collectPieces( + [ + el( + ["b06-chart__design-cross"], + [ + [-10, 110], + [10, 110], + ], + { "data-design-line": "" }, + ), + el( + ["b06-chart__design-cross"], + [ + [0, 105], + [0, 115], + ], + ), + ], + axes, +); +out.collected = collected.map((p) => ({ line: p.line, group: p.group ?? null })); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_298_add_reanalyze.cjs b/resources/tester/helper_298_add_reanalyze.cjs new file mode 100644 index 000000000..8edfbfa25 --- /dev/null +++ b/resources/tester/helper_298_add_reanalyze.cjs @@ -0,0 +1,88 @@ +/* PLAN 298-1 헬퍼 — B06 [추가]가 적은 「높이 기다림」을 배수유역도 판이 같은 표(`fordWaitsOf`)로 보고 + * 다시 나눈 유량으로 빈 월류 높이를 채움(126-1) · 프로젝트마다 따로 · 실패 = 토스트 한 줄(`analyzeFailed`). + * TS 로더는 `helper_126_ford_fill.cjs` 와 같다 · 결과 = 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const toasts = []; +const STUBS = { + "@ui/ui_template_elements": { showToast: (message, kind) => toasts.push([message, kind]) }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request === "@config/config_frontend") + return loadTs(path.join(ROOT, "config", "config_frontend.ts")); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} }); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const B05 = path.join(ROOT, "B05_Profile"); +const fill = loadTs(path.join(B05, "B05_Profile_UI_Drainage_Ford_Fill.ts")); +const { fordHeightM } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts"), +); + +const flow = 8.0; +const points = () => [ + { chainage_m: 185, facility: "ford_bridge", options: { ford_width_m: 10 } }, + { chainage_m: 300, facility: "ford_pavement", options: {} }, +]; +const basins = [ + { chainage_m: 185, design_flow_m3s: flow }, + { chainage_m: 300, design_flow_m3s: flow }, +]; + +// ① B06 [추가] — 그 프로젝트 표에 측점을 적음(판과 같은 표) +const b06Wants = fill.waitsFordHeight("ford_bridge", { ford_width_m: 10 }); +if (b06Wants) fill.fordWaitsOf("p1").add(185); +const same = fill.fordWaitsOf("p1") === fill.fordWaitsOf("p1"); +// ② 판의 다시 나눔 응답 — 같은 표로 채움 · 기다리지 않던 300 은 빈칸 그대로 +const p1 = points(); +const filled = fill.fillFordHeights(p1, basins, fill.fordWaitsOf("p1")); +// ③ 다른 프로젝트 같은 측점은 안 채움 +const p2 = points(); +const otherFilled = fill.fillFordHeights(p2, basins, fill.fordWaitsOf("p2")); + +let failed = null; +try { + const parts = loadTs(path.join(B05, "B05_Profile_UI_Drainage_Parts.ts")); + failed = { + status: parts.analyzeFailed(new Error("502 Bad Gateway"), "세부유역 산정에 실패했습니다."), + plain: parts.analyzeFailed("x", "세부유역 산정에 실패했습니다."), + toasts: [...toasts], + }; +} catch (error) { + failed = { error: String(error) }; +} + +process.stdout.write( + JSON.stringify({ + b06Wants, + same, + filled, + heights: p1.map((p) => p.options.ford_height_m ?? null), + left: [...fill.fordWaitsOf("p1")], + otherFilled, + otherHeights: p2.map((p) => p.options.ford_height_m ?? null), + need: fordHeightM(flow, 10), + failed, + }), +); diff --git a/resources/tester/helper_b05_station_roundtrip.cjs b/resources/tester/helper_b05_station_roundtrip.cjs index a967df824..fee14a6c5 100644 --- a/resources/tester/helper_b05_station_roundtrip.cjs +++ b/resources/tester/helper_b05_station_roundtrip.cjs @@ -66,13 +66,13 @@ const INTERVAL = 20; const out = {}; const fields = stationFields("기준", () => INTERVAL); -// ① 실어 둔 값은 칸을 안 고치면 그대로(표시는 6 + 3.4) +// ① 실어 둔 값은 칸을 안 고치면 그대로(표시는 6 + 3 · 칸도 정수 m 167) fields.write(123.37, INTERVAL); out.shown = [fields.station.value, fields.remainder.value]; out.untouched = fields.read(INTERVAL, true); fields.write(123.3721, INTERVAL); out.untouchedRaw = fields.read(INTERVAL, true); -// 사용자가 놓은 119.8 — 다시 열면 5 + 19.8 로 보이고 그대로 읽힘 +// 옛 소수 119.8 — 다시 열면 5 + 20 으로 보이고 그대로 읽힘 fields.write(119.8, INTERVAL); out.r119 = [fields.station.value, fields.remainder.value, fields.read(INTERVAL, true)]; fields.write(123.3721, INTERVAL); @@ -82,7 +82,7 @@ fields.remainder.fire("change"); out.edited = fields.read(INTERVAL, true); // 같은 글로 되돌려 치면 — 여전히 실어 둔 값(칸 글이 쓴 그대로) fields.write(123.37, INTERVAL); -fields.remainder.value = "3.4"; +fields.remainder.value = "3"; out.retypedSame = fields.read(INTERVAL, true); // 넘침 정규화(1 + 30 → 2 + 10)는 친 값으로 fields.station.value = "1"; diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 1d65a9cc3..f295049b1 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -215,14 +215,15 @@ const row = buildSummaryRow(); const clicks = []; row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") }); const [labelNode, line] = row.root.children; -const [nameNode, pickBtn, clearBtn] = line.children; +// 280-1 — [선택] 단추 없음 · 이름 칸이 고르기 +const [nameNode, clearBtn] = line.children; out.rowEmpty = { name: nameNode.value, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, get: row.get(), label: labelNode.textContent, - pick: pickBtn.textContent, + buttons: line.children.filter((c) => c.tagName === "button").map((c) => c.textContent), clear: clearBtn.textContent, }; row.set(A); @@ -234,8 +235,15 @@ out.rowSet = { key: row.get().key, nameShowsKey: nameNode.value.includes("SS000007"), }; -pickBtn.click(); +nameNode.click(); clearBtn.click(); +// 이름 칸 키 — Enter · Space = 고르기 · 다른 키는 그대로 +const keys = []; +for (const key of ["Enter", " ", "a"]) + (nameNode.listeners.keydown || []).forEach((h) => + h({ key, preventDefault: () => keys.push(`prevent:${key}`) }), + ); +out.keyClicks = { clicks: clicks.slice(2), prevented: keys }; out.clicks = clicks; row.set(null); out.rowCleared = { name: nameNode.value, clearDisabled: clearBtn.disabled }; diff --git a/resources/tester/test_116_right_click_add.py b/resources/tester/test_116_right_click_add.py index c925d89b1..6bb6acca7 100644 --- a/resources/tester/test_116_right_click_add.py +++ b/resources/tester/test_116_right_click_add.py @@ -89,7 +89,7 @@ def test_addAt_은_새로_놓기_패널_폄_후_판정(): body.index('root.classList.remove("is-collapsed")'), body.index("addsAtOnce(type, optionInputs)"), body.index("finishAdd("), - body.index("commitTempPipe()"), # 폼만 갈래 — A군 임시 배치 그대로 + body.index("commitTempPipe(true)"), # 폼만 갈래 — A군 임시 배치 그대로(우클릭만 · 291-1) ] assert order == sorted(order) assert "DEFAULT_INTERVAL_LENGTH_M" in body # 구간형 기본 15 m 그대로 diff --git a/resources/tester/test_118_pipe_pick.py b/resources/tester/test_118_pipe_pick.py index 42c96a478..8a5254b20 100644 --- a/resources/tester/test_118_pipe_pick.py +++ b/resources/tester/test_118_pipe_pick.py @@ -112,5 +112,5 @@ def test_벽_본체_동시_고름_금지_그대로() -> None: in station ) view = _src("B06_Section/B06_Section_UI_Cross_View.ts") - assert "const toggleRevet = (key: RevetPick): void => {" in view + assert "const toggleRevet = (key: RevetPick, ownerM?: number): void => {" in view assert "if (activeRevet !== null) toggleRevet(activeRevet);" in view diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index 3e87c8bfd..6db3d80ed 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -156,7 +156,7 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): ) assert 'card("summary", "B08_Quantity_Tab_StructureSummary", one.root),' in panel assert 'card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock),' in panel - assert "ui-collapsible ui-sidebar-section ui-sidebar-card" in panel + assert "createPanelCard(L(title))" in panel # 269 좌측 패널 공용 카드 assert ( 'actions.className = "ui-sidebar-actions"' in panel and "attachCollapsible(panel)" in panel ) diff --git a/resources/tester/test_126_ford_right_click.py b/resources/tester/test_126_ford_right_click.py index 6a5fb03a7..cdfff91e9 100644 --- a/resources/tester/test_126_ford_right_click.py +++ b/resources/tester/test_126_ford_right_click.py @@ -95,7 +95,7 @@ def test_배수유역_패널_배선(): # 채움은 보관소가 응답을 받기 전에 · 채웠으면 세션 초안에 다시 담음 apply = src[src.index("function apply(") : src.index("async function run(")] order = [ - apply.index("fillFordHeights(response.pipe_points, response.basins, fordWaits)"), + apply.index("fillFordHeights(response.pipe_points, response.basins, fordWaits())"), apply.index("facilityStore.replaceFromResponse(response.pipe_points)"), apply.index("if (fordFilled) rememberPipes()"), apply.index("callbacks.onPipesChanged?.("), @@ -103,5 +103,7 @@ def test_배수유역_패널_배선(): assert order == sorted(order) add = src[src.index(" addPipe(chainageM, attributes) {") : src.index(" removePipe(")] assert "waitsFordHeight(attributes?.facility, attributes?.options)" in add - assert add.index("fordWaits.add(chainageM)") < add.index("pipeEditor.addAtChainage(chainageM)") - assert "if (fordWaits.delete(fromChainageM)) fordWaits.add(toChainageM);" in add + assert add.index("fordWaits().add(chainageM)") < add.index( + "pipeEditor.addAtChainage(chainageM)" + ) + assert "if (fordWaits().delete(fromChainageM)) fordWaits().add(toChainageM);" in add diff --git a/resources/tester/test_137_design_pick.py b/resources/tester/test_137_design_pick.py index af7412b9a..fb46749b6 100644 --- a/resources/tester/test_137_design_pick.py +++ b/resources/tester/test_137_design_pick.py @@ -97,7 +97,7 @@ def test_카드_배선_하나만() -> None: """137-3 — 선을 고르면 벽 · 본체 · 면적을 풀고 카드도 고름 · 다른 고름은 선을 풂.""" view = _src("B06_Section/B06_Section_UI_Cross_View.ts") assert "const pickLine = (index: number): void => {" in view - assert "if (!isSelected) onSelect(section.station_id);" in view + assert "if (activeRevet === null) revetOffset?.select(section.chainage_m, null);" in view assert view.count("dropLine();") >= 4 # 본체 · 벽 · 카드 풂 · 면적 assert "setLineActive(pickedLineAt(section.station_id));" in view # 다시 그려도 되살림 assert "zoomBar.prepend(linePick.label);" in view # 치수 = [+ − ⤢] 왼쪽 diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py index 87a11b144..1113830dd 100644 --- a/resources/tester/test_145_3_b09_bill.py +++ b/resources/tester/test_145_3_b09_bill.py @@ -124,7 +124,7 @@ def test_제목_합_빈_줄과_원가계산서_이음(): assert keys[:3] == ["sec:1", "sec:1.1", "sec:1.2"] and keys[-1] == "sec:G" assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0 assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수 - assert rows["sec:1"]["blank"] == 1 and "빈 줄 1" in rows["sec:1"]["note"] + assert rows["sec:1"]["blank"] == 1 and "빈 줄" not in rows["sec:1"]["note"] # 257-2 비고 빈 줄 없음 # 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None assert sheets["bill"]["sums"] == { "material": None, @@ -190,8 +190,8 @@ def test_항목_짜기_B08_수량(): "key": "pipe:revet|돌쌓기(찰)|2.0", } haul = {v["name"]: v["qty"] for k, v in items.items() if k.startswith("haul:")} - assert haul == {"중기운반": None, "기계운반": None, "모래운반": 16, "자갈운반": None, - "시멘트운반": None, "철근운반": 0.75} # fmt: skip + assert haul == {"중기": None, "기계": None, "모래": 16, "자갈": None, + "시멘트": None, "철근": 0.75} # fmt: skip 261-4 묶음 이름 그대로 def test_거울_TS_가_같은_값(): diff --git a/resources/tester/test_145_4_b09_total_calc.py b/resources/tester/test_145_4_b09_total_calc.py index ad95a5763..42b6e9ac2 100644 --- a/resources/tester/test_145_4_b09_total_calc.py +++ b/resources/tester/test_145_4_b09_total_calc.py @@ -191,7 +191,7 @@ t = buildTotal(ctx()); t.onEdit(at(t.rows, "care"), "rate", ""); out.careGone = "care" in user.estimate.rates; t = buildTotal(ctx()); -out.grandBlank = t.rows[at(t.rows, "grand")].amount; +out.grandBlank = t.total.amount; // 257-2 총공사비 = 표 아래 고정 줄 t.onEdit(at(t.rows, "safety"), "hand", "1,000"); out.base = user.estimate.safety_base; const c = buildCost(ctx()); diff --git a/resources/tester/test_150_detail_pick.py b/resources/tester/test_150_detail_pick.py index f9df29850..4019e8a86 100644 --- a/resources/tester/test_150_detail_pick.py +++ b/resources/tester/test_150_detail_pick.py @@ -80,8 +80,9 @@ def test_상세_표_칸_키_누름_배선(): ) as f: src = f.read() assert "element.dataset.key = key" in src - assert "highlightDetail(svg, state.section.design, picked)" in src - assert 'closest("[data-key]")' in src + # 259-2 — 저장 띠 출처면 그 띠 · 저장 조각이면 강조 없음(그래프 고름) · 값 칸(data-cell)도 누름 + assert "highlightDetail(svg, state.section.design, band)" in src + assert 'closest("[data-key], [data-cell]")' in src def test_상세_큰_창_공용_막_그림_글_표_순서(): @@ -92,7 +93,10 @@ def test_상세_큰_창_공용_막_그림_글_표_순서(): with open(os.path.join(base, "B06_Section_UI_Style_Cross_Detail.css"), encoding="utf-8") as f: css = f.read().replace(chr(13), "") assert "wide: true" in src and "backdropClass" not in src - assert "body.append(cad, note, tableBox)" in src - assert "pointer-events: none" not in css and "b06-detail__backdrop" not in css + # 265-1 — 그림 → 표 → CAD 칸(글 칸 자리) + assert "body.append(cad, tableBox, noteBox)" in src + # 막 없음 — 259-3 표 강조 바깥 줄(누름 통과)만 pointer-events: none + backdrop = css.split(".b06-detail__pick-outline {")[0] + assert "pointer-events: none" not in backdrop and "b06-detail__backdrop" not in css assert ".ui-modal.b06-detail > .ui-modal__body {\n overflow: hidden;" in css assert "flex: 1;\n min-height: 0;" in css and "height: 100%;" in css # 173-4 칸 가득 diff --git a/resources/tester/test_152_3_chute_cut.py b/resources/tester/test_152_3_chute_cut.py index 01421f9d3..e3870b7c6 100644 --- a/resources/tester/test_152_3_chute_cut.py +++ b/resources/tester/test_152_3_chute_cut.py @@ -55,9 +55,8 @@ def test_거름_한_벌_배선() -> None: culvert = _src("B06_Section/B06_Section_UI_Cross_Culvert.ts") assert "layout.outletFill.segments.filter(isChuteSegment)," in culvert corridor = _src("B05_Profile/B05_Profile_UI_Corridor_Structures.ts") - assert ( - "chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment)" in corridor - ) + flat = " ".join(corridor.split()) + assert "chuteBands( layout.outletFill.segments, section.culvert?.chute, isChuteSegment," in flat hatch = _src("B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts") assert '"3도 절토선"' in hatch # 박스 · 세월교 본체 끝은 성토만(3도 규칙 안 넓힘) diff --git a/resources/tester/test_152_4_chute_pick.py b/resources/tester/test_152_4_chute_pick.py index 36bf47d36..a2deaddaa 100644 --- a/resources/tester/test_152_4_chute_pick.py +++ b/resources/tester/test_152_4_chute_pick.py @@ -61,6 +61,9 @@ def test_좌측_상자_강조_배선() -> None: ) selection = _src("B06_Section/B06_Section_UI_Page_Structures_Selection.ts") assert 'if (id === "chute_add" || id === "chute") return "chute";' in selection - assert 'const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM);' in selection + assert ( + 'ownerM ?? (key === "chute" ? chainageM : ownerChainageOf(chainageM, key ?? undefined))' + in selection + ) css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css") assert ".b06-chart__culvert-pitching.is-active {" in css diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index e85518359..58565d78a 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -54,8 +54,8 @@ def test_축_고정_도형_층만_움직임_눈금_다시(out): assert out["axesTransform"] is None and out["clipTransform"] is None assert out["ticksChanged"] is True and out["ticksAfter"] > 0 assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로 - # 확대 — 십자는 중심(300,150)에서 배율 되돌림 - assert out["centerTransform"].startswith("translate(300 150) scale(0.869") + # 확대 — 십자는 중심(319,142 · 칸 가운데)에서 배율 되돌림 + assert out["centerTransform"].startswith("translate(319 142) scale(0.869") assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만 assert out["noLayer"] is None # 도형 층 없음 = 줌 없음 @@ -84,13 +84,16 @@ def test_모달_배선_단추_칸_비율(): css = _read("B06_Section_UI_Style_Cross_Detail.css") assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src - assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src + flat = " ".join(src.split()) + assert ( + "attachDetailZoom( clone, axes, zoom, (next) => (zoom = next), drawn.homeScale, )" in flat + ) assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3) assert 'clone.querySelector("g.b06-chart__plot-layer")' in src assert "createCrossAxes(axisLayer, axes)" in src view = _read("B06_Section_UI_Section_View.ts") assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view - assert "return { svg, axes };" in view + assert "return { svg, axes, homeScale };" in view assert "position: relative;" in css # 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득 assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css @@ -105,10 +108,12 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸(): ".b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);", ".b06-detail__cad .b06-chart__spoil-fill.is-active {\n stroke: var(--color-danger);", ".b06-detail__qtable .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%", - ".b06-detail__qtable tr:has(.is-picked) {", ".b06-detail__qtable [data-key][data-empty] {\n cursor: default;", ): assert selector in css, selector + # 259-3 — 줄 물들임 · 칸마다 테두리 없앰(묶음 바깥 한 줄) + assert "tr:has(.is-picked)" not in css + assert "inset 0 0 0 1px var(--color-danger)" not in css assert "royal-amethyst" not in css and "--color-warning);" not in css.split("/* 그림 강조")[1] assert ( ".b06-detail__qtable [data-key]:hover {\n background: color-mix(in srgb, var(--color-danger) 8%" @@ -118,11 +123,13 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸(): def test_빈칸_누름_없음_이름_칸_첫_키(): src = _read("B06_Section_UI_Cross_Detail.ts") - assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src - assert 'if (!key || el.hasAttribute("data-empty")) return;' in src - # 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키 - assert "nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)" in src - assert "nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)" in src + assert "!present.has(el.dataset.key) && !tablePick.graph(el.dataset.key)" in src + assert '(!el.dataset.key || el.hasAttribute("data-empty"))) return;' in src + # 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키 · + # 259-3 덮는 줄 키 모두(data-keys) + flat = " ".join(src.split()) + assert "nameCell( groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0," in flat + assert "nameCell( groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1," in flat def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만(): diff --git a/resources/tester/test_165_pavement_flow.py b/resources/tester/test_165_pavement_flow.py index 1afa758f1..f13aa4ab6 100644 --- a/resources/tester/test_165_pavement_flow.py +++ b/resources/tester/test_165_pavement_flow.py @@ -110,7 +110,9 @@ def test_6_겹친_노면공(out): def test_배선(): panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") - assert "deps.focusChainage(focusOf(structure), pave);" in panel + assert ( + "deps.focusChainage(focusOf(structure), revealOnly);" in panel + ) # 노면공 · 측점 없는 구조물(요구 178) assert "openId" not in panel # 범위 안 빈 카드 = 연 노면공 지킴(165-1) 없앰 · 189-1 # 관 알약은 폼 뒤(폼이 연 구조물을 닫으며 알약을 끔) at = panel.index("section.selectPipeByChainage(hit.pipeChainageM);") diff --git a/resources/tester/test_166_1_note_options.py b/resources/tester/test_166_1_note_options.py new file mode 100644 index 000000000..94536b3ed --- /dev/null +++ b/resources/tester/test_166_1_note_options.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""166-1 횡단 [상세] CAD 칸 · B07 표 위 미리 넣음 = 구조물 조합 이름 + 좌측 패널 옵션 줄. + +사용자 말: 「일단은 좌측패널 옵션과 구조물조합이름을 다 넣어주면 내가 추가로 선정하겠음」. +옵션 줄 = 좌측 패널 폼과 같은 컨테이너 · 이름 · 보이는 칸(`structureOptionLines`) — +관 보호공 구조 갈래에 맞지 않는 옛 값(집수정인데 흙막이 칸 값)은 안 씀 · +필수 상자 빈 칸 = 폼이 채워 보이는 기본값. +B07 은 같은 TS 를 Node 번들(`B07_DesignDetail_Note_Options_Node`)로 돌려 받음. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Structures_Schema import load_definition # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402 + structure_note_lines, + with_option_lines, +) +from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + +PIPE = { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_structure": "L", + "inlet_basin_height_m": 1.3, + # 구조를 집수정으로 바꾸기 전 흙막이 값 — 폼이 안 보이는 옛 값 + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.3, +} +WALL = {"wall_form": "돌쌓기(찰)", "length_m": 12, "height_m": 2.5, "before_m": 6, "after_m": 6} +THINGS = [ + {"type_id": "pipe", "options": PIPE}, + {"type_id": "retaining_wall", "options": WALL}, + {"type_id": "hazard_sign", "options": {}}, +] + + +@pytest.fixture(scope="module") +def ts_lines() -> list[list[str]]: + payload = { + "things": THINGS, + "types": with_visible().model_dump(mode="json")["types"], + "definition": load_definition(), + } + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_166_1_note_options.cjs")], + input=json.dumps(payload, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout)["lines"] + + +def test_배관_옵션_줄은_폼_컨테이너_그대로(ts_lines): + pipe = ts_lines[0] + assert pipe[0] == "관: 관경 800" + # 집수정 갈래 칸만 · 빈 동물이동형 = 폼 기본 「유」 · 흙막이 옛 값은 안 씀 + assert pipe[1] == "관 보호공(유입): 구조 집수정 · 형태 L형 · 높이 1.3m · 동물이동형 유" + assert "10m" not in pipe[1] + assert pipe[2].startswith("관 보호공(유출): 구조 흙막이 · 형태 돌 쌓기(찰) · 길이 10m") + assert "기준측점 전 5m · 기준측점 후 5m" in pipe[2] + + +def test_정의_그룹_종류는_그룹마다_한_줄_옵션_없으면_줄_없음(ts_lines): + assert ts_lines[1] == [ + "옹벽: 형태 돌쌓기(찰) · 길이 12m · 높이 2.5m · 기준측점 전 6m · 기준측점 후 6m" + ] + assert ts_lines[2] == [] + + +def test_B07_은_같은_TS_를_Node_번들로_받아_같은_줄(ts_lines): + things = [{**thing, "name": thing["type_id"], "pipe": False, "at": 0.0} for thing in THINGS] + got = with_option_lines(things) + assert [thing["option_lines"] for thing in got] == ts_lines + + +def test_조합_줄_뒤에_옵션_줄_미지정도_옵션_줄(): + thing = { + "name": "옹벽", + "pipe": False, + "at": 40.0, + "summary_item": None, + "group_items": None, + "option_lines": ["옹벽: 높이 2.5m"], + } + assert structure_note_lines([thing], 40.0) == ["옹벽 조합 미지정", "옹벽: 높이 2.5m"] + + +def test_화면은_구조물마다_옵션_줄을_붙임(): + cad = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Detail_Cad.ts").read_text("utf-8") + assert "structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id))" in cad + assert "...(thing.optionLines ?? [])" in cad diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index 8941dc397..436f12882 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -227,10 +227,10 @@ console.log(JSON.stringify(out)); out = json.loads(_node(src + body)) # 246-1 더함 = 「항목 추가」 하나 · 지움 = 「줄 삭제」 · 2단 제목 ← = 1단으로 assert out["title"] == ["항목 추가", "-", "내어쓰기 ←", "-", "줄 삭제"] - assert out["auto"] == ["항목 추가", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←", + assert out["auto"] == ["항목 추가", "-", "단가연결", "-", "아래로 ↓", "내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 삭제"] # fmt: skip # 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음) - assert out["hand"] == ["항목 추가", "-", "연결", "연결 풀기", "-", "위로 ↑", + assert out["hand"] == ["항목 추가", "-", "단가연결", "단가연결 풀기", "-", "위로 ↑", "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip # 항목 추가(항목 줄) = 바로 아래 같은 부모 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) below = out["below"] diff --git a/resources/tester/test_170_b08_modal_head.py b/resources/tester/test_170_b08_modal_head.py index df73e7398..ddde235dc 100644 --- a/resources/tester/test_170_b08_modal_head.py +++ b/resources/tester/test_170_b08_modal_head.py @@ -14,7 +14,7 @@ PAGE_CSS = (ROOT / "B08_Quantity/B08_Quantity_UI_Page.css").read_text(encoding=" def test_170_4_집계표_보조_탭_줄_자리(): branch = PAGE.split('if (tab === "summary" && projectId) {', 1)[1].split("\n }", 1)[0] # 자재 탭 보조 탭 줄과 같은 클래스 · sm 단추 하나(보이지 않음 · 초점 안 감) - assert '"b08-quantity__tabs b08-quantity__tabs--blank"' in branch + assert '"b08-quantity__tabs ui-subtabs b08-quantity__tabs--blank"' in branch # 279 공용 양식 assert 'size: "sm"' in branch and "ghost.tabIndex = -1" in branch assert 'setAttribute("aria-hidden", "true")' in branch # 집계표는 줄 아래 몸통에 그림(replaceChildren 이 빈 줄을 지우지 않게) diff --git a/resources/tester/test_173_areas_detail.py b/resources/tester/test_173_areas_detail.py index 0ac4e246e..a772fc015 100644 --- a/resources/tester/test_173_areas_detail.py +++ b/resources/tester/test_173_areas_detail.py @@ -100,7 +100,10 @@ def test_상세_칸_크기로_새로_그림_배선(): assert "cardZoomStates.delete(stationId);" in view types = _read("B06_Section_UI_Section_View_Types.ts") # 179-3 — svg + 축 옵션 - assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types + assert ( + " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;" + in types + ) assert ( " detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number," in types diff --git a/resources/tester/test_177_1_summary_unassigned_bg.py b/resources/tester/test_177_1_summary_unassigned_bg.py index bafa3cf4f..7af55cb66 100644 --- a/resources/tester/test_177_1_summary_unassigned_bg.py +++ b/resources/tester/test_177_1_summary_unassigned_bg.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""PLAN 177-1 — 구조물 조합 미연결 라벨 = 연한 노랑(경고) 배경 + 테두리(소스 시험).""" +"""PLAN 177-1 → 258-3 — 구조물 조합 미연결 라벨 = 옅은 회색(구조물 리스트 램프 색) 배경 + 테두리(소스 시험).""" import os import re @@ -15,10 +15,10 @@ def _rule(sel: str) -> str: return m.group(1) -def test_미연결_배경_테두리_경고색_글자색_없음(): +def test_미연결_배경_테두리_회색_글자색_없음(): body = _rule(".b05-structure__summary-name:not(.is-assigned)") - assert re.search(r"border-color:\s*var\(--color-warning\)", body) - assert re.search(r"background:\s*color-mix\([^;]*--color-warning[^;]*--color-surface", body) + assert re.search(r"border-color:\s*var\(--color-text-muted\)", body) + assert re.search(r"background:\s*color-mix\([^;]*--color-text-muted[^;]*--color-surface", body) assert not re.search(r"(^|[;\s])color:", body) diff --git a/resources/tester/test_177_4_pavement_span_box.py b/resources/tester/test_177_4_pavement_span_box.py index 324224a05..fe4782090 100644 --- a/resources/tester/test_177_4_pavement_span_box.py +++ b/resources/tester/test_177_4_pavement_span_box.py @@ -70,6 +70,9 @@ def test_스크롤만_길은_리스너를_안_거침(): cross = _read("A00_Common", "design", "design_view_cross.ts") assert "if (revealOnly) sectionView.revealStation(nearest.station_id);" in cross panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") - assert "const pave = !coverable(structure);" in panel + # 노면공 + 측점 없는 구조물(제 측점을 안 심음)도 스크롤만(요구 178) + assert ( + "const revealOnly = !coverable(structure) || !plantsStation(structure, markTypes);" in panel + ) # 목록 고름은 노면공뿐 아니라 늘 고름 기록(231-2) assert 'if (!openingAtStation) selectDesign(deps.projectId, at, { from: "b06" });' in panel diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py index 12f27f1d5..09b83bd94 100644 --- a/resources/tester/test_181_1_b09_tab_groups.py +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -1,4 +1,4 @@ -"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. +"""181-1 B09 탭 두 층 — 위 탭 8(203-1 연결 점검 뺌 · 257-1 설계내역 한 묶음) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. - 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 - 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴) @@ -19,8 +19,7 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts" # 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례 EXPECTED = [ ["공사원가계산서", ["cost"]], - ["총괄설계내역서", ["total"]], - ["설계내역서", ["bill"]], + ["설계내역", ["total", "bill"]], ["일위대가", ["dg_list", "dg"]], ["단가산출", ["sg_list", "sg"]], ["환율및기초자료", ["basis"]], @@ -42,7 +41,7 @@ def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩(): # 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리 firsts = list(dict.fromkeys(out["of"])) assert firsts == [g[0] for g in EXPECTED] - assert len(firsts) == 9 + assert len(firsts) == 8 def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭(): @@ -60,26 +59,26 @@ const view = () => ({ }); bars.draw('dg'); const a = view(); -bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 +bars.top.children[2].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 bars.sub.children[0].click(); // 일위대가목록표 bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐 const b = view(); bars.draw('sum_material'); bars.draw('dg'); -bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) -bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭 +bars.top.children[6].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) +bars.top.children[5].click(); // 중기 = 본 적 없음 → 첫 아래 탭 bars.draw('cost'); const c = view(); -bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭 +bars.top.children[1].click(); // 설계내역 = 첫 아래 탭(총괄) bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음 console.log(JSON.stringify({ a, b, c, picks })); """, ) a, b, c = out["a"], out["b"], out["c"] - assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [ - "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출" + assert len(a["top"]) == 8 and [t[0] for t in a["top"]][:5] == [ + "공사원가계산서", "설계내역", "일위대가", "단가산출", "환율및기초자료" ] # fmt: skip - assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5 + assert [t[2] for t in a["top"]] == ["false"] * 2 + ["true"] + ["false"] * 5 assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false") assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]] assert not a["blank"] and a["role"] == "tablist" @@ -88,7 +87,7 @@ console.log(JSON.stringify({ a, b, c, picks })); ["자재단가대비표", "true", "compare"], ["재료비수량금액집계표", "false", "sum_material"], ] # fmt: skip - assert b["top"][7][2] == "true" and b["top"][3][2] == "false" + assert b["top"][6][2] == "true" and b["top"][2][2] == "false" # 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개 assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1 assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index 1ae488159..6b9129080 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -121,7 +121,7 @@ def test_묶음_금액_자손_합_공종_합_그대로(): rows = _rows(sheets) g2, g1 = rows["hand:g2"], rows["hand:g1"] assert (g2["sum"], g2["labor"], g2["material"], g2["expense"]) == (2460, 2000, 400, 60) - assert (g2["blank"], g2["note"]) == (1, "빈 줄 1") + assert (g2["blank"], g2["note"]) == (1, "") # 257-2 비고 빈 줄 없음 assert (g1["sum"], g1["labor"], g1["blank"]) == (12300 + 2460, 12000, 1) # a + b(+ c 빈 줄) assert rows["hand:p"]["sum"] == 0 and rows["hand:p"]["note"] == "" # 공종 합 · 원가계산서 sums · 계 = 묶음 없을 때와 같음(묶음 줄 안 셈) @@ -266,6 +266,13 @@ globalThis.openModal = (o) => { picked.title = o.title; o.mount({ append() {} }, () => (picked.closed = true)); }; +// 251-6 단가연결 = 한 모달(openLogicPicker 바탕) — 호표 줄 = lib.extra +globalThis.loadRows = async () => []; +globalThis.openLogicPicker = (done, lib) => { + picked.title = lib.title; + picked.kinds = lib.kindFilter.map((k) => k.text); + picked.rows = lib.extra; +}; let user = __USER__; const ctx = { sheets: { bill: __BILL__, daega: __DAEGA__ }, payload: {}, get user() { return user; }, setUser: (u) => (user = u) }; @@ -288,11 +295,11 @@ const out = { table.onCell(at("hand:a"), "connect"); table.onCell(at("hand:g1"), "connect"); table.onCell(at("earth:4"), "connect"); -table.onCell(at("hand:c"), "connect"); +table.onCell(at("hand:c"), "connect"); // 연결 없음 = 단가연결 창 바로(메뉴 없음) +await new Promise((r) => setTimeout(r, 0)); out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]); -opened[2].items[0][1](); // hand:c → 일위대가 호표 고르기 창 -picked.rows[0].onPick(); -out.picked = [picked.title, picked.closed, picked.rows.map((r) => r.tag)]; +picked.rows.find((r) => r.tag === "대가3").onPick(); +out.picked = [picked.title, picked.kinds, picked.rows.map((r) => r.tag)]; out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref; console.log(JSON.stringify(out)); """ @@ -318,17 +325,12 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): ] # fmt: skip 201-1 레벨 = 공용 표 level assert out["lv"] == ["name", 1, 3, 4, 3] assert out["edit"] == [True, False] # 묶음 줄 = 이름만 - picks = [ - "일위대가 호표", - "단가산출 호표", - "구조물 조합", - "일위대가 사전조합", - "단가산출", - ] # 204-2 · 246-1 이름 셋 - # 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 - unlink = [10, 40, [*picks, "-", "연결 풀기"]] - assert out["menus"] == [unlink, unlink, [10, 40, picks]] - assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용 + # 251-6 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄 = [단가연결 · 단가연결 풀기] · + # 연결 없는 줄 = 단가연결 창 바로(종류 = 단가산출 · 사전조합 · 구조물 조합 · 호표 둘) + unlink = [10, 40, ["단가연결", "-", "단가연결 풀기"]] + assert out["menus"] == [unlink, unlink] + kinds = ["단가산출", "일위대가 사전조합", "구조물 조합", "일위대가 호표", "단가산출 호표"] + assert out["picked"] == ["단가연결", kinds, ["대가3"]] assert out["ref"] == DG diff --git a/resources/tester/test_183_3_b09_applied_pick.py b/resources/tester/test_183_3_b09_applied_pick.py index dcc5b1c91..3e4f90e27 100644 --- a/resources/tester/test_183_3_b09_applied_pick.py +++ b/resources/tester/test_183_3_b09_applied_pick.py @@ -101,7 +101,7 @@ def test_배선_수기_적용_칸_출처_돌림_없앰(): assert 'editable: (_i, key) => key === "p:견적",' in compare assert "nextPick" not in base and 'key === "applied_from"' not in compare assert 'key === `p:${rows[i]?.applied_from}` ? "is-picked"' in compare - assert 'if (key !== "applied" || !r) return null;' in compare + assert 'key === "applied" && rows[i] ? chooseApplied(rows[i]) : null' in compare # 257-3 메뉴 둘 공용 assert "delete cell.applied" in compare shell = _src("B09_Estimation/B09_Estimation_UI_Shell.ts") assert ( @@ -110,4 +110,4 @@ def test_배선_수기_적용_칸_출처_돌림_없앰(): edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") assert "export function openMenuAt(" in edit and "const ctxMenu = sharedMenu();" in edit css = _src("B09_Estimation/B09_Estimation_UI_Page.css") - assert "td.is-picked" in css and "var(--color-accent)" in css + assert "td.is-picked" in css and "var(--color-success)" in css # 257-3 초록 테두리 diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index e7e72e92b..b3daf1fba 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -123,15 +123,15 @@ def test_메뉴_모든_줄(): # 맨 위 제목 = 항목 추가(첫 하위) · 아래로(자식 있는 맨 위 = 못 지움 · 맨 위 = 내어쓰기 없음) assert out["sec:1"] == [*add, "-", "아래로 ↓"] # 자동 항목 = 더함 · 연결 · 옮김 · 삭제(고친 칸 없음 = 자동값으로 없음) - assert out["earth:4"] == [*add, "-", "연결", "연결 풀기", "-", + assert out["earth:4"] == [*add, "-", "단가연결", "단가연결 풀기", "-", "아래로 ↓", "내어쓰기 ←", "-", "줄 삭제"] # fmt: skip # 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음) - assert out["earth:5"] == [*add, "-", "연결", "-", "위로 ↑", "아래로 ↓", + assert out["earth:5"] == [*add, "-", "단가연결", "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 삭제"] # fmt: skip assert out["hand:g"] == [*add, "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-", "줄 삭제"] # fmt: skip - assert out["hand:a"] == [*add, "-", "연결", "연결 풀기", "-", "내어쓰기 ←", + assert out["hand:a"] == [*add, "-", "단가연결", "단가연결 풀기", "-", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip # 조달수수료 · 관급 자재 = 연결 · 옮김 · 삭제 없음 · 관급자재대 제목 = 옮김 · 삭제 없음 · # 자재 줄 = 1단으로 못 냄(별도 자재 mat:M1 도) @@ -174,7 +174,7 @@ def test_삭제_옮김_새_공종_자동값으로(): ("mat:M2", "내어쓰기 ←"), ("earth:5", "자동값으로 되돌리기"), ("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "항목 추가"), ("earth:4", "항목 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"), - ("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "항목 추가")] # fmt: skip + ("earth:4", "단가연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "항목 추가")] # fmt: skip out = _menu(user, runs=runs)["runs"] keys = lambda b: [k for k, _ in _order(b)] # noqa: E731 # 덮개에 없는 보이는 줄(hand:b · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘 @@ -215,7 +215,7 @@ def test_삭제_옮김_새_공종_자동값으로(): got = {e["key"]: e for e in out["hand:g|항목으로 바꾸기"]["rows"]} assert "kind" not in got["hand:g"] and got["hand:a"]["parent"] == "sec:1.2" # 자동 줄 연결 풀기 = ref 덮개 null - assert {e["key"]: e for e in out["earth:4|연결 풀기"]["rows"]}["earth:4"]["ref"] is None + assert {e["key"]: e for e in out["earth:4|단가연결 풀기"]["rows"]}["earth:4"]["ref"] is None # 조달수수료 항목 추가 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) got = out["fee|항목 추가"]["rows"] assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2") @@ -270,7 +270,7 @@ def test_표_칸_강조_번호_연결_칸(): ) out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body)) assert out["names"] == ["1 토공사", "1.1 절토", "earth:4"] # 번호 = 셈 no - assert out["connect"] == ["연결됨 ▾", "연결 ▾", "대가3 ▾", "", "", ""] + assert out["connect"] == ["연결됨 ▾", "단가연결 ▾", "대가3 ▾", "", "", ""] assert out["cls"] == ["is-edited", None, None] assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]" assert out["edit"] == [True, False, True, True, False] diff --git a/resources/tester/test_185_1_b09_total_level.py b/resources/tester/test_185_1_b09_total_level.py index 896400301..5ba5c8e07 100644 --- a/resources/tester/test_185_1_b09_total_level.py +++ b/resources/tester/test_185_1_b09_total_level.py @@ -21,7 +21,8 @@ BODY = f""" const user = {{ estimate: {{}} }}; const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }}; const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.level.key, t.level.of(i), t.cellClass ?? null]); -console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }})); +const T = buildTotal(ctx), C = buildCost(ctx); +console.log(JSON.stringify({{ total: pack(T), cost: pack(C), fixed: {{ total: T.total, cost: C.total }} }})); """ @@ -41,8 +42,9 @@ def _run() -> dict: def test_총괄_레벨(): rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]} - top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"] + top = ["direct", "net_cost", "da", "supply_price", "contract"] assert all(rows[k] == (1, "is-title") for k in top) + assert "grand" not in rows # 257-2 총공사비 = 표 아래 고정 줄(total) for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat", "supply"]: # fmt: skip assert rows[k] == (2, None), k @@ -51,8 +53,9 @@ def test_총괄_레벨(): def test_원가계산서_레벨(): rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]} - for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]: + for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply"]: assert rows[k] == (1, "is-title"), k + assert "grand" not in rows # 257-2 총공사비 = 표 아래 고정 줄(total) for k in ["material", "labor", "expense_sum"]: assert rows[k] == (2, None), k for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]: @@ -74,3 +77,10 @@ def test_CSS_옛_레벨_규칙_없음_공용_표로(): r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* var\(--dt-indent\)\);", common, ) + + +def test_총공사비_아래_고정_줄_총괄_원가계산서(): + fixed = _run()["fixed"] + assert fixed["total"]["key"] == "grand" and fixed["total"]["name"] == "총공사비" + assert fixed["cost"]["key"] == "grand" and fixed["cost"]["group"] == "총공사비" + assert fixed["cost"]["amount"] == fixed["total"]["amount"] # 원가계산서 = 총괄 거울 diff --git a/resources/tester/test_187_1_b05_head_button_size.py b/resources/tester/test_187_1_b05_head_button_size.py index dbfc07721..53f0f67fa 100644 --- a/resources/tester/test_187_1_b05_head_button_size.py +++ b/resources/tester/test_187_1_b05_head_button_size.py @@ -1,6 +1,6 @@ -"""187-1 B05 종단 그래프 머리 줄 — [초기화]~▼ 단추 크기 = 오른쪽 [배수유역도] · [새 창] 크기. +"""187-1 B05 종단 그래프 머리 줄 — [초기화]~▼ 단추 크기 = [새 창] 크기([배수유역도] 단추는 282-1 로 지움). -- 기준값 = 오른쪽 두 단추의 계산 크기(`.ui-btn` 바탕 + 줄 단추 토큰) — 높이 16 · 글자 12 · 좌우 여백 8 +- 기준값 = [새 창] 단추의 계산 크기(`.ui-btn` 바탕 + 줄 단추 토큰) — 높이 16 · 글자 12 · 좌우 여백 8 - 왼쪽 단추(`b05-route-profile__tool`) = 같은 토큰 · 고정 높이 · 최소 너비 없음 """ @@ -29,13 +29,9 @@ def _decl(block: str, prop: str) -> str: THEME = _read("ui_template/ui_template_theme.css") BASE = _read("ui_template/ui_template_elements_styles.ts") LEFT = _block(_read("B05_Profile/B05_Profile_UI_Style.css"), ".b05-route-profile__tool") -TOGGLE = _block( - _read("B05_Profile/B05_Profile_UI_Style_Table.css"), - ".b05-route-profile__trailing > .b05-drainage__toggle", -) POPOUT = _block( _read("A00_Common/design/design_page.css"), - ".b05-route-profile__trailing > .b05-route-profile__popout", + ".b05-route-profile__tools > .b05-route-profile__popout", ) @@ -62,14 +58,14 @@ def _size(block: str) -> dict: def test_기준값_오른쪽_단추_16_12_8(): - for block in (TOGGLE, POPOUT): + for block in (POPOUT,): assert _size(block) == {"padV": 1, "padH": 8, "font": 12, "height": 16} # 높이 계산의 바탕 = .ui-btn 줄 높이 1 · 테두리 1px assert re.search(r"\.ui-btn \{[^}]*line-height: 1;[^}]*border: 1px solid", BASE) def test_왼쪽_단추_높이_글자_여백이_오른쪽_기준값과_같음(): - assert _size(LEFT) == _size(TOGGLE) == _size(POPOUT) + assert _size(LEFT) == _size(POPOUT) assert _decl(LEFT, "line-height") == "1" assert re.search(r"border:\s*1px solid", LEFT) @@ -80,8 +76,8 @@ def test_왼쪽_단추에_옛_고정_크기_없음(): assert "9px" not in LEFT -def test_세_단추_모두_같은_토큰(): - for block in (LEFT, TOGGLE, POPOUT): +def test_두_단추_모두_같은_토큰(): + for block in (LEFT, POPOUT): assert _decl(block, "padding") == "var(--btn-bar-padding)" assert _decl(block, "font-size") == "var(--btn-bar-font-size)" assert _decl(LEFT, "font-family") == "var(--font-body)" # .ui-btn 바탕과 같은 글꼴 diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py index de42f1eea..c2e564442 100644 --- a/resources/tester/test_201_1_table_level_merge.py +++ b/resources/tester/test_201_1_table_level_merge.py @@ -134,4 +134,4 @@ def test_B08_B09_적용(): assert 'const CHILD = "ㄴ "' in labor and '" 합 계"' not in labor # 셸 칸 → 열 = cellColumn(병합 표에서 cellIndex 가 밀림) shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert ".cellIndex" not in shell and shell.count("cellColumn(") == 2 + assert ".cellIndex" not in shell and shell.count("cellColumn(") >= 2 diff --git a/resources/tester/test_203_1_b09_data_check.py b/resources/tester/test_203_1_b09_data_check.py index 2beecbcb5..804896861 100644 --- a/resources/tester/test_203_1_b09_data_check.py +++ b/resources/tester/test_203_1_b09_data_check.py @@ -21,11 +21,11 @@ SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") def test_묶음_이름_단추_글_설명_없음(): - assert 'heading.textContent = "데이터 점검";' in SHELL and "원가 단가" not in SHELL + assert 'createPanelCard("데이터 점검")' in SHELL and "원가 단가" not in SHELL # 269 공용 카드 assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL # 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검 assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL - assert "section.append(heading, refresh, check.list);" in SHELL + assert "section.append(refresh, check.list);" in SHELL # 제목 = 공용 카드(269) assert "check.draw(payload, sheets);" in SHELL diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index 4da59ffb2..71cd98c8c 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -188,10 +188,11 @@ globalThis.showToast = (m, k) => toasts.push([k, m]); globalThis.API_BASE_URL = "/api"; globalThis.CURRENT_PROJECT_ID_KEY = "pid"; globalThis.localStorage = { getItem: () => "P1" }; -globalThis.openLogicPicker = (done, lib) => { sent.push(["pre", lib.layer, lib.libOnly, lib.title]); - lib.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); }; -globalThis.openItemPicker = async () => ({ action: "apply", - item: { layer: "system", owner: null, key: "SS000011", name: "관매설" } }); +let picker = null; // 251-6 단가연결 = 한 창(openLogicPicker · 사전조합 + 덧붙인 줄 구조물 조합) +globalThis.openLogicPicker = (done, lib) => { picker = lib; + sent.push(["pre", lib.layer, lib.kindFilter.map((k) => k.value), lib.title]); }; +globalThis.loadRows = async () => [{ key: "SS000011", layer: "system", owner: null, 단위: "m" }]; +globalThis.itemName = () => "관매설"; const answer = { 폴더: "precombo@company@7@SC-000002", 만듦: true, key: "DG:new", payload: { user: { bill: "서버 것" }, inputs: { daega: { libs: {} } }, sheets: { daega: [{ key: "DG:new", name: "회사 조합", spec: "", unit: "㎡" }] } } }; @@ -202,9 +203,12 @@ const applied = []; const apply = (ref, from) => applied.push([ref, from && from.name]); const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply); const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) }; -picks.find((x) => x[0] === "일위대가 사전조합")[1](); +picks[0][1](); await new Promise((r) => setTimeout(r, 0)); -picks.find((x) => x[0] === "구조물 조합")[1](); +out.combo = picker.extra.filter((x) => x.kind === "combo").map((x) => [x.tag, x.name, x.sub]); +picker.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); +await new Promise((r) => setTimeout(r, 0)); +picker.extra.find((x) => x.kind === "combo").onPick(); await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); Object.assign(out, { sent, applied, payload: ctx.payload, toasts }); @@ -223,16 +227,12 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): ) # fmt: skip assert result.returncode == 0, result.stderr out = json.loads(result.stdout) - assert out["picks"] == [ - "일위대가 호표", - "단가산출 호표", - "구조물 조합", - "일위대가 사전조합", - "단가산출", - ] # 246-1 이름 셋(서고 · 층 · M01 글 없음) - assert out["more"] == ["연결 풀기"] + assert out["picks"] == ["단가연결"] # 251-6 고르기 다섯 = 단가연결 창 하나 + assert out["more"] == ["단가연결 풀기"] pre, (url, body) = out["sent"][0], out["sent"][1] - assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋 + kinds = ["logic", "precombo", "combo", "dg", "sg"] + assert pre == ["pre", "personal", kinds, "단가연결"] # 층 셋 단추 · 종류 고르기 + assert out["combo"] == [["구조물 조합", "관매설", "마스터 · SS000011"]] assert url == "/api/projects/P1/estimation/library" assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"} assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None, diff --git a/resources/tester/test_209_1_detail_select.py b/resources/tester/test_209_1_detail_select.py index b695354c9..9ef743826 100644 --- a/resources/tester/test_209_1_detail_select.py +++ b/resources/tester/test_209_1_detail_select.py @@ -117,11 +117,11 @@ def test_원지반_포함_닫힌_영역_넓이(out): def test_정보창_값(out): label = out["label"] length = 2 * hypot(4 / 3, 2) + 4 + 2 * CROSS_X - assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡" + assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡ · 둘레 {length:.2f}m" assert label["open"].endswith("면적 닫히지 않음") assert label["single"] == "길이 4.00m · 경사비율 0.0%\n수평 4.00m · 높이 0.00m" # 카드 꼴 assert label["ground"] == ( - f"원지반선 길이 {2 * CROSS_X:.2f}m\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m" + f"원지반선 길이 {2 * CROSS_X:.2f}m · 경사비율 0.0%\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m" ) diff --git a/resources/tester/test_212_3_b07_cross_layout_buttons.py b/resources/tester/test_212_3_b07_cross_layout_buttons.py index 3b51c0f18..6cc3f02fd 100644 --- a/resources/tester/test_212_3_b07_cross_layout_buttons.py +++ b/resources/tester/test_212_3_b07_cross_layout_buttons.py @@ -4,7 +4,7 @@ 못박는 것 - 줄 = 「배치 열 · 행」 · 「[이 장] [모든 장]」 · 「표 배율 [배율 적용]」 · [안내선 숨기기](부르는 쪽). - - 단추 · 숫자 칸 · 이름 = 줄 위 작은 단추 토큰(187 `--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비. + - 단추 · 숫자 칸 · 이름 = 좌측 패널 공용 양식(269 `.ui-panel-form`) · 한 줄 안 같은 너비. - 옛 한 줄 감기(flex-wrap) · 숫자 칸 48px 고정 없음. - [안내선 숨기기] 뒤 남는 검은 선 = 인쇄 칸선(도각 층 도면 개체 · 회청 `#8ea0b5` · 밝은 바탕에서 짙게 그려짐) — 안내선(겹판)과 다른 것이라 지우지 않음 · 단추는 겹판만 끔. @@ -40,16 +40,13 @@ def _decl(block: str, prop: str) -> str: def test_단추_숫자_칸_한_규격_토큰(): - shared = _block(".b07-cross-layout .ui-btn,\n.b07-cross-layout .ui-field .ui-input") - assert _decl(shared, "padding") == "var(--btn-bar-padding)" - assert _decl(shared, "font-size") == "var(--btn-bar-font-size)" - assert _decl(shared, "line-height") == "1" - assert _decl(shared, "height") == "auto" # 공용 작은 칸 30px 를 풀어 단추 높이에 맞춤 + # 269 — 크기는 좌측 패널 공용 양식(`.ui-panel-form`)이 줌 · B07 덧칠(줄 위 작은 단추 토큰) 없음 + assert ".b07-cross-layout .ui-btn" not in CSS + assert ".b07-cross-layout .ui-field .ui-input {" in CSS # 폭만 + assert "height" not in _block(".b07-cross-layout .ui-field .ui-input") label = _block(".b07-cross-layout .ui-field__label") - assert _decl(label, "font-size") == "var(--btn-bar-font-size)" - # 토큰은 theme 한 곳(187) - assert "--btn-bar-padding: 1px var(--spacing-8);" in THEME - assert "--btn-bar-font-size: var(--text-caption);" in THEME + assert "font-size" not in label + assert "createPanelCard(" in PANELS # 배치 칸이 사는 횡단도 묶음 = 공용 카드 def test_한_줄에_한_일_같은_너비(): diff --git a/resources/tester/test_224_1_picker_top_table.py b/resources/tester/test_224_1_picker_top_table.py index e01ed490e..e9258b32c 100644 --- a/resources/tester/test_224_1_picker_top_table.py +++ b/resources/tester/test_224_1_picker_top_table.py @@ -115,8 +115,8 @@ def test_모달과_M02_가_같은_표_부품_줄_글_도우미_숨김_열_규칙 def test_모달_배선_고르면_보이고_편집_화면에서는_숨김(): ts = _text(UI / "ui_template_item_picker.ts") - assert "createComboTable()" in ts - assert "comboTable.show(chosen);" in ts # 고른 항목이 바뀔 때마다 + assert "createComboTable((colId) => pickColumn(colId))" in ts # 280-2 표 고름 → 나무 + assert "paintTable();" in ts # 고른 항목 · 층이 바뀔 때마다(280-2) assert "body.append(tableWrap, cols, editHost," in ts # 맨 위 assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때 assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때 diff --git a/resources/tester/test_229_1_b09_level_buttons_subbar.py b/resources/tester/test_229_1_b09_level_buttons_subbar.py index 2d40334c7..50a605e4a 100644 --- a/resources/tester/test_229_1_b09_level_buttons_subbar.py +++ b/resources/tester/test_229_1_b09_level_buttons_subbar.py @@ -75,7 +75,7 @@ console.log(JSON.stringify({ assert out["a"]["levels"] == ["들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"] assert out["a"]["guideIn"] is True # 안내 글 = 단추 줄이 아니라 따로(표 아래로) assert out["guideTag"] == "P" and "ui-general-block__empty" in out["guideCls"] - assert out["subBlank"] # 하위 탭 없는 묶음 = 빈 보조 줄(숨은 단추 + 도구) + assert not out["subBlank"] # 257-1 설계내역 = 아래 탭 둘(총괄 · 설계내역서) · 빈 보조 줄 아님 assert out["disabledBefore"] == [True] * 4 # 고른 줄 없음 = 흐림 assert out["disabledAfter"] == [True, False, False, True] # 줄 누름 = 메뉴 항목대로 assert out["b"] == [] # 다른 탭(일위대가표) = 단추 없음 diff --git a/resources/tester/test_231_1_pave_reload.py b/resources/tester/test_231_1_pave_reload.py index 898c92503..2267491a9 100644 --- a/resources/tester/test_231_1_pave_reload.py +++ b/resources/tester/test_231_1_pave_reload.py @@ -60,7 +60,7 @@ def test_같은_카드_목록_고름_뒤_오감도_노면공_안_켬(out) -> Non assert after["form"] == "sign" # 목록 고름도 기록 — 오감 뒤 그 폼(231-2) assert after["paveLayer"] is None assert after["span"] != "pave" - assert after["card"] == 120 + assert after["card"] is None # 측점 없는 표지 = 카드 안 고름 · 스크롤만(요구 178) def test_목록_노면공은_오감해도_범위_강조_그대로(out) -> None: @@ -99,7 +99,7 @@ def test_B05_3D_고름은_노면공_id_를_안_적음(out) -> None: def test_배선() -> None: panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") assert "section.selectAt({ chainageM: picked, structureId }, coverable);" in panel - assert "if (!openingAtStation) deps.focusChainage(focusOf(structure), pave);" in panel + assert "if (!openingAtStation) deps.focusChainage(focusOf(structure), revealOnly);" in panel assert "deps.markStructure?.(id, !openingAtStation);" in panel cross = _read("A00_Common", "design", "design_view_cross.ts") assert ( diff --git a/resources/tester/test_231_2_list_pick_record.py b/resources/tester/test_231_2_list_pick_record.py index a1a41b153..4e8dbe756 100644 --- a/resources/tester/test_231_2_list_pick_record.py +++ b/resources/tester/test_231_2_list_pick_record.py @@ -51,7 +51,8 @@ def test_카드_120_뒤_목록_관_오감_새로고침_관_폼(out) -> None: def test_같은_카드_목록_표지는_기록되고_오감_새로고침_뒤_그대로(out) -> None: - sign = {"form": "sign", "paveLayer": None, "span": "sign", "card": 120, "sid": "sign"} + # 측점 없는 표지 = 카드 안 고름 · 스크롤만 · 폼 · 범위 테두리 · 기록은 그대로(요구 178) + sign = {"form": "sign", "paveLayer": None, "span": "sign", "card": None, "sid": "sign"} assert out["listSign"] == sign assert out["listSignTrip"] == sign assert out["listSignF5"] == sign diff --git a/resources/tester/test_234_1_structure_station.py b/resources/tester/test_234_1_structure_station.py index 885ec58af..4bbf71e9a 100644 --- a/resources/tester/test_234_1_structure_station.py +++ b/resources/tester/test_234_1_structure_station.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- -"""234-1 구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안 붙기 · [정수 맞춤] 단추. +"""234-1 · 272-1 구조물 · 관 기준 측점 = 정수 m(반올림) · 노선 끝 안쪽. + +요구 167 — 측점 값에 소수를 안 씀 · 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수). 9-29 「저장값 소수 · 표기만 정수」(20장)를 「저장값도 정수 m」로 바꿈 (사용자 확정 10-10 · 조사 AF 7절 A+B+C). @@ -33,11 +35,11 @@ from common_util.common_util_structure_station import ( # noqa: E402 TOTAL = 279.8 STATIONS = general_stations(TOTAL, 20.0) -# (입력, 기대) — 소수 → 정수 · 일반 측점 1 m 안 붙기 · 끝 측점(소수) 붙기 +# (입력, 기대) — 소수 → 정수(반올림) · 끝(소수 279.8)은 넘지 않음 CASES = [ (80.17, 80.0), # 관 80.17 = 카드 80 한 자리 - (80.6, 80.0), # 1 m 안 → 일반 측점 - (80.99, 80.0), + (80.6, 81.0), # 그냥 반올림(일반 측점 붙기 없음 · 167) + (80.99, 81.0), (81.0, 81.0), # 꼭 1 m 는 밖 — 81 에 둘 수 있음(방향키가 갇히지 않음) (50.5, 51.0), # .5 는 올림(파이썬 · TS 같은 꼴) (50.4, 50.0), @@ -45,15 +47,16 @@ CASES = [ (263.206, 263.0), (127.86, 128.0), (0.3, 0.0), - (279.3, 279.8), # 끝 측점 1 m 안 → 끝 측점(정수가 아닌 일반 측점) - (278.6, 279.8), # 반올림 279 가 끝 측점 1 m 안 + (279.3, 279.0), # 끝 279.8 안쪽 정수 + (279.7, 279.0), # 반올림 280 은 끝 밖 → 279 + (278.6, 279.0), (277.4, 277.0), ] STEPS = [ ((80.0, 1), 81.0), ((80.17, 1), 81.0), - ((80.17, -1), 80.0), # 79.17 은 일반 측점 80 의 1 m 안 - ((279.8, -1), 278.0), # 279 는 끝 측점에 붙어 제자리 → 한 걸음 더 + ((80.17, -1), 79.0), + ((279.8, -1), 279.0), # 옛 끝 소수 → 안쪽 정수 ((279.8, 1), 279.8), # 노선 밖은 제자리 ((0.0, -1), 0.0), ] @@ -132,9 +135,9 @@ def test_general_registry_skips_irregular(ts) -> None: def test_form_input_integer(ts) -> None: assert ts["remainderStep"] == "1" assert ts["typed"] == 80 # 입력 4+0.17 = 80.17 → 80 - assert ts["typedNear"] == 80 # 4+0.6 → 일반 측점 80 에 붙음 + assert ts["typedNear"] == 81 # 4+0.6 → 반올림 81(붙기 없음 · 167) assert ts["typedOne"] == 81 - assert ts["untouched"] == 80.17 # 안 고친 옛 값은 그대로(단추 몫) + assert ts["untouched"] == 80.17 # 안 고친 옛 값은 그대로(열 때 자동 맞춤 몫 · 272-4) def test_moved_structure_range_ends_integer(ts) -> None: @@ -145,7 +148,8 @@ def test_moved_structure_range_ends_integer(ts) -> None: assert (drag["chainage_m"], drag["start_m"], drag["end_m"]) == (133, 133, 145) -def test_snap_button_plan_and_run(ts) -> None: +def test_old_station_auto_snap_plan_and_run(ts) -> None: + """272-4 — 단추 없이 종단 보기를 열 때 한 번 · 확인 창 없음 · 알림 한 줄.""" plan = ts["plan"] assert [entry["index"] for entry in plan["structures"]] == [0, 1, 3] assert plan["pipes"] == [[80.17, 80], [263.31, 263]] @@ -158,12 +162,12 @@ def test_snap_button_plan_and_run(ts) -> None: assert [(m["from"], m["to"]) for m in run["moves"]] == [(80.17, 80), (263.31, 263)] assert run["moves"][0]["attributes"]["options"] == {"pipe_kind": "흄관"} assert run["moves"][0]["attributes"]["facility"] == "pipe" - assert "5건" in run["confirmText"] - assert run["toasts"][-1]["message"].startswith("정수 맞춤 5건") + assert run["confirmText"] is None # 확인 창 없음 + assert run["toasts"][-1]["message"].startswith("옛 소수 측점 5건을 정수 m 로 옮겼습니다") assert "1건은 그대로" in run["toasts"][-1]["message"] - # 두 번째 누름 — 옮길 것 없음(겹친 관만 알림) + # 두 번째 — 옮길 것 없음(겹친 관만 알림) assert ts["second"]["count"] == 0 - assert "1건은 맞출 자리에 다른 관" in ts["second"]["toasts"][0]["message"] + assert "맞출 자리에 다른 관이 있는 관 1건" in ts["second"]["toasts"][0]["message"] def test_plan_edit_follows_batch_move(ts) -> None: @@ -172,7 +176,7 @@ def test_plan_edit_follows_batch_move(ts) -> None: def test_drainage_map_pipe_integer(ts) -> None: - assert ts["editor"] == [141, 200] # 옮김 141.37 → 141 · 추가 200.6 → 일반 측점 200 + assert ts["editor"] == [141, 201] # 옮김 141.37 → 141 · 추가 200.6 → 201 def _vertices() -> list[RouteVertex]: @@ -215,7 +219,8 @@ def test_wiring_source() -> None: assert "movedStructure(structures[index], toChainageM)" in read( "B05_Profile/B05_Profile_UI_Structures_Panel.ts" ) - assert "snapDock(list" in read("B05_Profile/B05_Profile_UI_Structures_Panel.ts") + assert "snapOldStations({" in read("B05_Profile/B05_Profile_UI_Structures_Panel.ts") + assert "panel.structures.snapOld()" in read("A00_Common/design/design_view_profile.ts") assert "stepStructureStation(fromM" in read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") b06 = read("B06_Section/B06_Section_UI_Page_Structures_Panel.ts") assert "const to = snapStructureStation(toChainageM)" in b06 diff --git a/resources/tester/test_238_1_save_keeps_structure_stations.py b/resources/tester/test_238_1_save_keeps_structure_stations.py index d2ae972af..23b2baa33 100644 --- a/resources/tester/test_238_1_save_keeps_structure_stations.py +++ b/resources/tester/test_238_1_save_keeps_structure_stations.py @@ -93,6 +93,7 @@ def _patch(monkeypatch, root: Path) -> None: "get_longitudinal_section", lambda *_a: _value({"longitudinal_file_path": LONG}), ) + monkeypatch.setattr(confirm, "sync_cross_section_rows", lambda *_a: _value(0)) def _plant_like_b06(root: Path) -> None: diff --git a/resources/tester/test_240_1_b09_bill_folder_font_drag.py b/resources/tester/test_240_1_b09_bill_folder_font_drag.py index eda6e364f..ec2b4564f 100644 --- a/resources/tester/test_240_1_b09_bill_folder_font_drag.py +++ b/resources/tester/test_240_1_b09_bill_folder_font_drag.py @@ -48,7 +48,12 @@ def test_끌기_미리_보기_어디_안_몇_레벨_폴더_테두리_끄는_줄_ def test_데이터_점검_업데이트_위_틈_카드_왼쪽_여유와_같음(): css = _read(B09 / "B09_Estimation_UI_Page.css") m = re.search(r"\.b09-options \[data-b09-refresh\] \{\s*margin-top: ([^;]+);", css) - assert m and m.group(1) == "calc(var(--spacing-8) + var(--spacing-4))" + # 269 — 공용 카드 틈(8) + 4 = 카드 왼쪽 여유(12) + assert m and m.group(1) == "var(--spacing-4)" + overlay = _read(ROOT / "ui_template" / "ui_template_overlay.css") + assert re.search( + r":where\(\.ui-sidebar-card\.ui-panel-form\) \{[^}]*gap: var\(--spacing-8\);", overlay + ) card = _read(ROOT / "ui_template" / "ui_template_overlay.css") assert re.search( r"\.ui-sidebar-card \{\s*padding: calc\(var\(--spacing-8\) \+ var\(--spacing-4\)\);", card diff --git a/resources/tester/test_242_1_b09_bill_jump_range.py b/resources/tester/test_242_1_b09_bill_jump_range.py index cf65f27a4..5f9b71b27 100644 --- a/resources/tester/test_242_1_b09_bill_jump_range.py +++ b/resources/tester/test_242_1_b09_bill_jump_range.py @@ -122,7 +122,7 @@ def test_Shell_배선_공용_표_스크롤만(): shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert "if (bill) jumpTo(bill.rows, at);" in shell assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell - assert "table.focusRow(at, !bill)" in shell + assert "table.focusRow(at, false)" in shell # 257-2 모든 탭 빨강 = 스크롤만 common = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") assert "focusRow: (index: number, mark?: boolean) => void;" in common assert "state.focus = mark ? index : null;" in common diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 5629a735d..a261089b1 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -1,7 +1,7 @@ -"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 246-1 에서 지움. +"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 258-2 에서 옅게 되살림. - 공용 표 editOnDouble = 한 번 누름 = 글 상자 안 열림 · 더블클릭 · 칸 누른 뒤 Enter · F2 = 그 칸 글 상자 -- 고칠 수 있는 칸(td) 표시 `is-can-edit` 없음(246-1 · 241 앞 모양) +- 고칠 수 있는 칸(td) = 글일 때도 `is-can-edit`(옅은 outline · 258-2) · 못 고치는 칸 · 옛 방식 표 = 없음 - B09 = 표 만드는 길 한 곳(Shell) · B08 = 고칠 칸 있는 표(자재 집계표 셋) 모두 editOnDouble - 칸 누름으로 여는 메뉴 · 고르기 · 이동(onCellClick · choices · link)은 그대로 """ @@ -76,7 +76,7 @@ setTimeout(() => { def test_공용_표_더블클릭_Enter_F2(): out = json.loads(_node(TABLE + HARNESS + SCENE_JS)) - assert out["can"] == [] # 246-1 고칠 칸 td 표시 없음 + assert out["can"] == ["3"] # 고칠 수 있는 칸만 · 글일 때도 표시(258-2) assert out["start"] == [] # 처음 = 글 상자 없음 assert out["click"] == [[], ["click 1:qty"]] # 한 번 누름 = 안 열림 · 누름 알림은 그대로 assert out["f2"] == ["1:qty"] # F2 = 누른 칸 입력 @@ -90,10 +90,18 @@ def test_공용_표_더블클릭_Enter_F2(): assert out["old"] == [["1:qty"], []] # editOnDouble 없는 표 = 예전대로(테두리 class 없음) -def test_고칠_칸_테두리_CSS_없음(): +def test_고칠_칸_테두리_CSS_옅게_테마_변수만(): css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") - ts = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") - assert "is-can-edit" not in css and "is-can-edit" not in ts # 246-1 되돌림 + m = re.search(r"\.ui-data-table__table td\.is-can-edit \{([^}]*)\}", css) + assert m + body = m.group(1) + assert ( + "outline: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-canvas));" + in body + ) + assert "outline-offset: -1px;" in body # 칸 크기 불변 + assert "box-shadow" not in body # 빨강 이동 상자(box-shadow)와 안 겹침 + assert "#" not in body # 하드코딩 색 없음 def _sources(*folders: str) -> list[Path]: diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py index b1d8968e3..c4bbfbc45 100644 --- a/resources/tester/test_244_10_b08_milestone_tailscale.py +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -38,7 +38,8 @@ const routePaths = () => [[[35.68, 127.9], [35.64, 127.83]]]; FAKE_KAKAO = """ class Fake { setMap() {} relayout() {} setBounds() {} extend() {} } (globalThis as any).kakao = { maps: { load: (f: () => void) => f(), LatLng: Fake, - LatLngBounds: Fake, Map: Fake, Polyline: Fake, Marker: Fake, CustomOverlay: Fake } }; + LatLngBounds: Fake, Map: Fake, Polyline: Fake, Marker: Fake, CustomOverlay: Fake, + event: { addListener: () => {} } } }; """ @@ -48,7 +49,9 @@ def _view(setup): + setup + IMPORTS.sub("", _read(KMAP)) + """ -const view = routeMapView("p1", "r1"); +// 156 경로 탭 지도 칸 없앰 — 같은 SDK 싣기 · 안내 길 = 연결선 모달 지도 칸(editMap) +const view = editMap("p1", { onMove: () => {}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" }); +view.set([{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }], routePaths()); await new Promise((r) => setTimeout(r, 30)); const [hint, box] = view.root.children; console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hidden, @@ -56,9 +59,10 @@ console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hi """ ) src = IMPORTS.sub("", _read(LAYOUT)) + script + # 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", src], + "--input-type=module-typescript", "-"], input=src, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr @@ -86,6 +90,6 @@ def test_SDK_401_도메인_등록_안내(): got = _view(NO_MEDIA + sdk_401) # window.kakao 없음 → script 넣음 → 401 = onerror assert len(got["injected"]) == 1 and "sdk.js?appkey=fake-js-key" in got["injected"][0] assert got["hint"] == ( - f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요" ) assert got["boxHidden"] diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py index 25a591302..4efdac4de 100644 --- a/resources/tester/test_244_11_b08_milestone_auto_capture.py +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -201,7 +201,7 @@ def test_사용자_그림_우선_되돌리기(tmp_path, monkeypatch): def test_실패_까닭_한_줄(tmp_path, monkeypatch): - tail = " — 그림 올리기를 쓰세요." + tail = " — [그림 변경]으로 넣으세요." cases = { "no_browser": "경로 그림 자동 캡처 실패 — 서버에서 브라우저(Edge · Chrome)를 못 띄움", "timeout": "경로 그림 자동 캡처 실패 — 지도 그림이 20초 안에 안 끝남", @@ -269,14 +269,11 @@ def test_화면_브라우저_캡처_없음_길찾기_뒤_자동(): assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state assert "user_only=true" in state and "showToast(reason," in state route = read("B08_Quantity_UI_Milestone_Route.ts") - assert ( - 'label: "자동 그림으로 되돌리기"' in route - and "deleteImage(projectId, routeId, true)" in route - ) - assert "revert.hidden = !canRevertImage(routeId);" in route - assert "hint.textContent = autoImageNote(routeId) ?? HINT;" in route + # 157 [초기화] = 사용자 그림만 지우고 길찾기 지도로 · 쓸데없는 안내 줄 없음 + assert 'label: "초기화"' in route and "deleteImage(projectId, routeId, true)" in route + assert "reset.hidden = !user;" in route and "HINT" not in route kmap = read("B08_Quantity_UI_Milestone_KakaoMap.ts") - assert "등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" in kmap # SDK 도메인 안내 한 줄은 남김 + assert "등록 필요 — 지도 대신 [그림 변경]으로 넣으세요" in kmap # SDK 도메인 안내 한 줄은 남김 big = ("Milestone_Capture.py", "Router_Milestone.py", "UI_Milestone_State.ts") for name in (f"B08_Quantity_{n}" for n in big): assert len(read(name).splitlines()) <= 700 diff --git a/resources/tester/test_244_12_b08_milestone_base_view.py b/resources/tester/test_244_12_b08_milestone_base_view.py index 2c83b6a93..a5365f1c2 100644 --- a/resources/tester/test_244_12_b08_milestone_base_view.py +++ b/resources/tester/test_244_12_b08_milestone_base_view.py @@ -16,6 +16,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) LONG = "경상남도 거창군 북상면 병곡리 968-31" @@ -58,6 +59,7 @@ def _clipped(note, box_w): def _lay(data, site=None): + data = migrate_milestone(data) # 254 옛 꼴 → bases · to(옛 기준점 = b1) return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)") @@ -92,7 +94,7 @@ def test_시점_중점_선_상자_주소_좌표(): assert boxes["mid"]["notes"] == ["35.71234, 127.90000"] # 주소 없음 = 좌표만 assert _lay(_data([]))["boxes"][1]["notes"] == [] # 좌표도 없음 = 빈 줄 line = next(ln for ln in lay["lines"] if ln["from"] == "start") - assert line["to"] == "mid" and line["labels"] == ["비포장 1km"] + assert line["to"] == "mid" and line["labels"] == ["비포장\n1km"] # 254 두 줄 s, m = boxes["start"], boxes["mid"] assert m["x"] - (s["x"] + s["w"]) >= 100 # 선 글 자리 for b in (s, m): # 상자 = 공사지 안 @@ -106,7 +108,9 @@ def test_거래처_상자_항목_이름_주소(): _route("k", "E", via=False, address="거창읍")])) # fmt: skip boxes = {b["id"]: b for b in lay["boxes"]} assert (boxes["g"]["items"], boxes["g"]["label"]) == ("장비", "g") - _clipped(boxes["g"]["notes"][0], boxes["g"]["w"]) + # 254 긴 주소 = 줄바꿈(낱말 사이) + 상자 세로 늘림(줄 × 15 + 13 · 최소 58) + assert boxes["g"]["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"] + assert boxes["g"]["h"] == 4 * 15 + 13 and boxes["k"]["h"] == 58 assert boxes["g"]["tip"] == LONG and boxes["k"]["notes"] == ["거창읍"] assert _node('clip("가나다", 100, 10)') == "가나다" assert _node(f'clip("{LONG}", 108, 10)') == "경상남도 거창군 북상…" # 한글 10 · 빈칸 6 · … 6 diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index e409a1606..e462cbf04 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -21,7 +21,10 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) B08 = "B08_Quantity/B08_Quantity_" @@ -90,27 +93,29 @@ def test_포장_도로_구분_거울(): def test_도로_구분_갈래_연결선_구간_끝(): - d = json.dumps(WITH_BASE) - off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}}) + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + d = json.dumps(migrate_milestone(WITH_BASE)) + off = {**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}} + off = json.dumps(migrate_milestone(off)) got = _node( [CALC, LAYOUT], f"""const D = {d}; const O = {off}; const S = [127.83, 35.64]; -console.log(JSON.stringify([roadsFor(true), roadsFor(false), - ["g", "d", "base", "start"].map((f) => chainVia(D, f)), ["g", "base"].map((f) => chainVia(O, f)), - segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "base", S), - segmentEnds(D, "start", S), segmentEnds(O, "base", S), segmentEnds(O, "g", S), +console.log(JSON.stringify([["g", "d", "b1", "start"].map((f) => PAVED[segKind(D, f)]), + ["g", "b1"].map((f) => segKind(O, f)), + segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "b1", S), + segmentEnds(D, "start", S), segmentEnds(O, "b1", S), segmentEnds(O, "g", S), kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""", ) - assert got[0] == ["2차포장", "1차포장", "비포장"] and got[1] == ["포장", "비포장"] - assert got[2] == [True, False, True, False] and got[3] == [False, False] - assert got[4] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], + # 152 포장 종류 = 선 자리 — 기준점 거침 거래처 2차 · 안 거침 포장 · 기준점 → 시점 1차 · 시점 → 중점 포장 + assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "direct"] + assert got[2] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], "to": [127.85, 35.66]} # fmt: skip - assert got[5]["text"] == "d → 시점" and got[5]["to"] == [127.83, 35.64] - assert got[6] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66], + assert got[3]["text"] == "d → 시점" and got[3]["to"] == [127.83, 35.64] + assert got[4] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66], "to": [127.83, 35.64]} # fmt: skip - assert got[7] is None and got[8] is None # 시점 → 중점 = 손 입력 · 기준점 없음 - assert got[9]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로 - assert got[10] == ["route", "base"] and got[11] == ["route"] + assert got[5] is None and got[6] is None # 시점 → 중점 = 손 입력 · 기준점 없음 + assert got[7]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로 + assert got[8] == ["route", "base"] and got[9] == ["route"] def test_표_열_기준점_따라(): @@ -235,13 +240,13 @@ await loadMilestone("p1"); const a = await findSegmentKm("p1", "g"); await new Promise((r) => setTimeout(r, 10)); const afterRoute = CALLS.length; -const b = await findSegmentKm("p1", "base"); +const b = await findSegmentKm("p1", "b1"); // 254 옛 꼴 캐시 → 기준점 id b1 const writes = WRITES.n; -await findSegmentKm("p1", "base"); // 같은 km = 캐시 안 씀 +await findSegmentKm("p1", "b1"); // 같은 km = 캐시 안 씀 const live = liveMilestone("p1"); console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, again: WRITES.n, - route: live.routes[0].legs, base: live.base.legs, path: segmentPath("g"), - routeLines: routePaths("g"), baseLines: routePaths("base"), + route: live.routes[0].legs, base: live.bases[0].legs, path: segmentPath("g"), + routeLines: routePaths("g"), baseLines: routePaths("b1"), start: await findSegmentKm("p1", "start") }}));""" got = _node([CALC, LAYOUT, STATE], script, STATE_STUBS) assert got["a"] == "g → 병곡리 10.5 km" and got["b"] == "병곡리 → 시점 10.5 km" @@ -288,7 +293,7 @@ def test_화면_등록_모달_연결선_모달(): assert "editMap(projectId" in place and "onClick: (lon, lat) => void moveTo(lon, lat)" in place assert "/quantity/milestone/reverse?lon=" in place # ② 상자 누름 = 같은 등록 모달 · [기준점 +] 없음 - assert 'base: () => openPlaceModal(projectId, { add: false, id: "base" }),' in diagram + assert "base: (id) => openPlaceModal(projectId, { add: false, id })," in diagram assert 'label: "기준점 +"' not in diagram and "addBase" not in diagram # ③ 연결선 누름 = 연결선 모달 · 시점 → 중점 = 손 입력만 assert "openLegInfo(projectId, ln.from);" in diagram and "b08-milestone__line-hit" in diagram @@ -297,7 +302,7 @@ def test_화면_등록_모달_연결선_모달(): assert "길찾기" not in mid.replace("길찾기 없음", "") and "editMap" not in mid assert 'if (from === "start") return openMidLeg(projectId);' in leg assert "void reverseAddress(projectId, lon, lat)" in leg and "void run();" in leg - assert "if (!segmentPath(from).length && seg.from && seg.to) void run(false);" in leg + assert "if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to)" in leg for rel in (B08 + "UI_Milestone_Modal.ts", B08 + "UI_Milestone_Place.ts", B08 + "UI_Milestone_LegModal.ts", KMAP, STATE, LAYOUT, CALC, B08 + "UI_Milestone.ts", B08 + "UI_Milestone_Diagram.ts", diff --git a/resources/tester/test_244_14_b08_milestone_item_input.py b/resources/tester/test_244_14_b08_milestone_item_input.py index f13a81e1a..b4fe3871b 100644 --- a/resources/tester/test_244_14_b08_milestone_item_input.py +++ b/resources/tester/test_244_14_b08_milestone_item_input.py @@ -78,6 +78,6 @@ def test_항목_손_입력_제안_분류_바꿔도_글_유지(): def test_저장_값_손_글_그대로(): modal = MODAL.read_text(encoding="utf-8") - assert "item: r.item.input.value.trim()," in modal # 목록 밖 글도 받음(앞뒤 빈칸만 뺌) + assert "item: first.item.input.value.trim()," in modal # 목록 밖 글도 받음(앞뒤 빈칸만 뺌) assert 'createSelectField({ label: "항목"' not in modal assert len(modal.splitlines()) <= 700 diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index ef1e7b574..d7013901c 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -64,13 +64,16 @@ def test_견본_표_합(): assert (a["s2"], a["s1"], a["un"]) == (23.8, 0.6, 0.8) assert (a["paved"], a["unpaved"], a["total"]) == (24.4, 0.8, 25.2) assert b["item"] == "장비(8톤)" and b["total"] == 25.2 and b["company"] == "거창군청" - # 기준점 안 거침 · 목적지 중점 = 시점 → 중점(기본 1500 ÷ 1000) 비포장 더함 · 빈 km = 0 - assert (c["s2"], c["s1"], c["un"], c["total"]) == (61.2, 2.05, 1.5, 64.75) + # 기준점 안 거침 = 거래처 → 시점 = 포장(152 · 옛 2차 · 1차 값도 자리로) · 목적지 중점 = 시점 → 중점 + # (기본 1500 ÷ 1000) 비포장 더함 · 빈 km = 0 + assert (c["s2"], c["s1"], c["pv"], c["un"], c["total"]) == (0, 0, 63.25, 1.5, 64.75) # 사용자 중점 측점 · 기준점 없음 = 공통 구간 안 셈 no_base = {**SAMPLE, "base": {**SAMPLE["base"], "enabled": False}, "site": {"mid_station_m": 420, "mid_road": "1차포장"}} # fmt: skip a2, _, c2 = compute_milestone(no_base, 1500.0) - assert a2["total"] == 23.8 and (c2["s1"], c2["un"]) == (2.47, 0) + # 기준점 없음 = 2차 · 1차 없이 포장(152) · 시점 → 중점 1차포장 값 = 포장 + assert a2["total"] == 23.8 and (a2["s2"], a2["pv"]) == (0, 23.8) + assert (c2["s1"], c2["pv"], c2["un"]) == (0, 63.67, 0) def test_거울_TS(): diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index a8dc4adb0..ac4b3f24a 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -14,10 +14,13 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 + IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) def _layout(data, site=None, mid_km=0.75): + data = migrate_milestone(data) # 254 옛 꼴(base · via_base) → bases · to(옛 기준점 = b1) src = (ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Layout.ts").read_text(encoding="utf-8") body = ( f"\nconst d = {json.dumps(data, ensure_ascii=False)};" @@ -63,7 +66,7 @@ def test_공사지_컨테이너_시점_중점_선(): b = next(x for x in lay["boxes"] if x["id"] == bid) assert site["x"] <= b["x"] and b["x"] + b["w"] <= site["x"] + site["w"] assert lay["lines"] == [ - {**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장 0.75km"]} + {**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장\n0.75km"]} # 254 두 줄 ] @@ -75,8 +78,9 @@ def test_8방_자리_기준점_없음_공사지_기준(): ex, ey = _center(lay, "e") nx, ny = _center(lay, "n") wx, wy = _center(lay, "sw") + # 254 선 길이 = km 비례(빈 km = 최소 100) — 공사지 테두리(가로 190 · 세로 53)에서 잼 assert ex > cx + 200 and abs(ey - cy) < 1 - assert ny < cy - 200 and abs(nx - cx) < 1 + assert ny < cy - 150 and abs(nx - cx) < 1 assert wx < cx and wy > cy line = next(ln for ln in lay["lines"] if ln["from"] == "e") assert line["to"] == "start" # 기준점 없으면 시점으로 @@ -95,17 +99,17 @@ def test_기준점_기준_방위_선_글(): ), # fmt: skip ) cx, cy = _center(lay, "site") - bx, by = _center(lay, "base") + bx, by = _center(lay, "b1") assert bx < cx - 200 and abs(by - cy) < 1 # 기준점 = 공사지 서쪽 gx, gy = _center(lay, "g") assert abs(gx - bx) < 1 and gy < by - 200 # 거창군청 = 기준점 북쪽 kx, _ = _center(lay, "k") assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽 labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]} - assert labels[("g", "base")] == ["2차포장 23.8km"] - assert labels[("base", "start")] == ["1차포장 0.6km", "비포장 0.8km"] + assert labels[("g", "b1")] == ["2차포장\n23.8km"] # 254 두 줄(도로 구분 / km) + assert labels[("b1", "start")] == ["1차포장\n0.6km", "비포장\n0.8km"] assert labels[("k", "start")] == [] - assert next(b for b in lay["boxes"] if b["id"] == "base")["label"] == "병곡리 968-31" + assert next(b for b in lay["boxes"] if b["id"] == "b1")["label"] == "병곡리 968-31" def test_경위도_있으면_방위각이_8방보다_앞섬(): @@ -127,7 +131,7 @@ def test_빈_방위_차례_같은_방위_나란히(): cx, cy = _center(lay, "c") dx, dy = _center(lay, "d") sx, sy = _center(lay, "site") - assert cy < sy - 200 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북) + assert cy < sy - 150 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북) assert (dx, dy) != (cx, cy) and dx < sx # 다음 빈 방위(북서) boxes = [b for b in lay["boxes"] if b["kind"] == "route"] for i, p in enumerate(boxes): # 업체 상자끼리 안 겹침 @@ -145,12 +149,13 @@ def test_화면_상자_누름_더하기_창(): "openTab" not in ui and "route: (id) => openPlaceModal(projectId, { add: false, id })," in ui ) - assert 'if (box.kind === "site") g.append(plus(box, false));' in ui - assert 'if (box.kind === "base") g.append(plus(box, true));' in ui - assert "openPlaceModal(projectId, { add: true, viaBase }, addRoute)" in ui + # 155 공사지 · 기준점 [+] 없앰 → 다이어그램 [요소 추가] + assert "plus(box" not in ui and 'label: "요소 추가"' in ui + assert "openPlaceModal(projectId, { add: true }, addRoute)" in ui modal = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts").read_text(encoding="utf-8") assert "openModal({" in modal - assert "same.rows.push(...rowValues())" in modal # 같은 업체 이름 = 그 경로에 표 줄 + # 149 상자 하나 = 항목 하나 — [+ 줄] · 같은 이름 합침 없음 + assert "same.rows.push" not in modal and '"+ 줄"' not in modal page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8") assert "createDiagram(projectId, (init) => addRoute(projectId, init))" in page assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 8d533d060..31cdf6dd5 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -18,6 +18,7 @@ if str(ROOT) not in sys.path: from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402 from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -58,34 +59,30 @@ def test_방위_좌표_자동(): # 기준점 좌표 없음 → 시점 좌표 기준 · 업체 = 시점 남쪽 · 기준점 안 거침 업체 = 동쪽 "routes": [_route("s", 127.8, 35.6), _route("e", 127.9, 35.7, via=False)], } - lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") - bx, by = _center(lay, "base") + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "b1") sx, sy = _center(lay, "s") - assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래 + assert abs(sx - bx) < 1 and sy > by + 150 # 남 = 아래(254 빈 km = 최소 선 길이) cx, cy = _center(lay, "site") ex, ey = _center(lay, "e") assert ex > cx + 200 and abs(ey - cy) < 1 # 기준점 좌표 있음 → 기준점 기준(업체가 기준점 북쪽) data["base"] = {**base, "lon": 127.7, "lat": 35.7} data["routes"] = [_route("n", 127.7, 35.8)] - lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") - bx, by = _center(lay, "base") + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "b1") nx, ny = _center(lay, "n") assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표) - assert abs(nx - bx) < 1 and ny < by - 200 + assert abs(nx - bx) < 1 and ny < by - 150 def test_다음_지도_길찾기_주소(): - pts = [{"name": "거창군청", "lon": 127.909, "lat": 35.686}, - {"name": "병곡리, 968/31", "lon": 127.75, "lat": 35.78}, - {"name": "임도 시점", "lon": 127.74, "lat": 35.79}] # fmt: skip - url = _node(f"kakaoRouteUrl({json.dumps(pts, ensure_ascii=False)})") - # 공식 꼴 /link/by/car/이름,위도,경도/… · 이름의 쉼표 · 빗금은 빈칸 - assert url == ( - "https://map.kakao.com/link/by/car/%EA%B1%B0%EC%B0%BD%EA%B5%B0%EC%B2%AD,35.686,127.909/" - "%EB%B3%91%EA%B3%A1%EB%A6%AC%20%20968%2031,35.78,127.75/" - "%EC%9E%84%EB%8F%84%20%EC%8B%9C%EC%A0%90,35.79,127.74" - ) + # 156 경로 탭 새 창 길찾기 없앰(길찾기 = 연결선 모달 · 카카오 길찾기 API) — 주소 만들기도 없음 + assert "kakaoRouteUrl" not in _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "window.open(" not in route and "다음 지도에서 길찾기" not in route def test_VWorld_좌표_주소(monkeypatch): @@ -153,29 +150,27 @@ def test_화면_모달_팬줌_그림탭(): place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts") assert 'from "../B01_Dashboard/B01_Dashboard_Api_Fetch"' in place assert "searchAddress(text)" in place - assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal # 공사지 모달 지도 - # ② 시점 모달 — 등록 주소 · VWorld 주소 · 지도 - assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal - assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal + # ② 시점 모달 — 지도 = 공용 카카오 지도(255-1 · 주소 줄 지움) + assert "/quantity/milestone/site-address" in modal and "editMap(projectId" in modal # ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달 - for text in ('"고침"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): + for text in ('"수정"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): assert text in add leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") - assert "legsEditor(legsOf(), via)" in leg + assert "legsEditor(legsOf(), kind)" in leg diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert "route: (id) => openPlaceModal(projectId, { add: false, id })," in diagram assert "start: () => openSiteInfo(projectId)," in diagram # ③ 팬 · 줌 = 공용 assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram - assert "wheelZoom: true" in diagram and "initial: zoomState" in diagram + # 149 처음 배율 = 상자 범위 맞춤(사용자 줌 · 팬 뒤엔 그 배율) + assert "wheelZoom: true" in diagram and "initial: userZoomed ? zoomState : fitBoxes(" in diagram # ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 그림 올리기 길(화면 캡처는 244-11 에서 서버 자동으로) route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert ( "createDataTable" not in route and "구간" not in route.split("export function routeView")[1] ) - assert "window.open(kakaoRouteUrl(points)" in route assert "getDisplayMedia" not in route and "uploadImage(projectId, routeId, picked)" in route - assert 'label: "그림 올리기"' in route + assert 'label: "그림 변경"' in route # 157 그림 올리기 → 그림 변경 page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert 'label: "+ 추가"' not in page and 'label: "삭제"' not in page for rel in ("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", @@ -194,21 +189,21 @@ def test_선_글_바로_고침_모달과_한_벌(): "legs": [{"road": "1차포장", "km": 0.6}]}, "routes": [{**_route("g"), "legs": [{"road": "2차포장", "km": None}]}], } # fmt: skip - d = json.dumps(data) + d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1) got = _node( f"[legAt({d}, 'g', 0, 0.75), legAt({d}, 'start', 0, 0.75), legAt({d}, 'x', 0, 0)," f" setLeg(setLeg(setLeg({d}, 'g', 0, {{road: '1차포장', km: 23.8}})," - f" 'base', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]" + f" 'b1', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]" ) assert got[0] == {"road": "2차포장", "km": None} assert got[1] == {"road": "비포장", "km": 0.75} and got[2] is None after = got[3] assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}] - assert after["base"]["legs"] == [{"road": "1차포장", "km": None}] + assert after["bases"][0]["legs"] == [{"road": "1차포장", "km": None}] assert after["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"} - # 표 셈 그대로 — 고친 값 = 모달 값과 같은 칸 + # 표 셈 — 포장 종류 = 선 자리(152 · 기준점 거침 거래처 = 2차포장 · 저장 값 1차포장 이어도) (row,) = compute_milestone(after, 1500.0) - assert (row["s2"], row["s1"], row["un"], row["total"]) == (0, 23.8, 0, 23.8) + assert (row["s2"], row["s1"], row["un"], row["total"]) == (23.8, 0, 0, 23.8) ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert 'span.addEventListener("dblclick"' in ui and "e.stopPropagation();" in ui assert "editLegAt(projectId, span, ln.from, i, mk)" in ui diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index 0b1b161e1..b4505f07d 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -5,9 +5,15 @@ import json import re import subprocess +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 + IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -17,6 +23,7 @@ def _read(rel): def _layout(base, site): data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, "base": base, "routes": []} + data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1) src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")) expr = f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)" result = subprocess.run( @@ -27,7 +34,7 @@ def _layout(base, site): assert result.returncode == 0, result.stderr lay = json.loads(result.stdout) center = {b["id"]: (b["x"] + b["w"] / 2, b["y"] + b["h"] / 2) for b in lay["boxes"]} - return center["site"], center["base"] + return center["site"], center["b1"] BASE = {"enabled": True, "name": "기준", "address": "", "legs": []} @@ -42,7 +49,7 @@ def test_좌표_없음_서쪽_옛_방위_무시(): def test_좌표_있음_공사지_기준_방위각(): (cx, cy), (bx, by) = _layout({**BASE, "dir": "W", "lon": 127.8, "lat": 35.8}, SITE) - assert abs(bx - cx) < 1 and by < cy - 200 # 북 = 위(옛 「서」 무시) + assert abs(bx - cx) < 1 and by < cy - 150 # 북 = 위(옛 「서」 무시 · 254 최소 선 길이) (cx, cy), (bx, by) = _layout({**BASE, "dir": None, "lon": 127.9, "lat": 35.7}, SITE) assert bx > cx + 200 and abs(by - cy) < 1 # 동 = 오른 # 시점 좌표 모름 = 서쪽 기본 @@ -58,8 +65,9 @@ def test_좌측_패널_방위_칸_없음_모달_안내(): base = modal[ modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") ] - assert "방위는 이 자리로 자동" in base # 244-13 — 기준점 = 등록 모달 분류 기준위치 - layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") assert ( - "placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270" in layout - ) + "placePicker(" in base and "방위는 이 자리로 자동" not in base + ) # 255-3 소재지 머리 줄 지움 + layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + assert "placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ??" in layout + assert '(anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor))' in layout diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 9b5248ef8..cef51aaa2 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -27,9 +27,10 @@ def _node(script): IMPORTS.sub("", _read(rel)) for rel in ("A00_Common/b_svg_zoom_pan.ts", "B08_Quantity/B08_Quantity_Milestone_Layout.ts") ) + # 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", src + "\n" + script], + "--input-type=module-typescript", "-"], input=src + "\n" + script, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr @@ -82,13 +83,13 @@ def test_실제_포인터_사건으로_팬(): def test_거래처_상자_항목_글(): data = { "site": {"mid_station_m": None, "mid_road": "비포장"}, - "base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "bases": [], "routes": [ - {"id": "g", "name": "거창군청", "address": "", "via_base": True, "dir": None, + {"id": "g", "name": "거창군청", "address": "", "to": "site", "dir": None, "legs": [], "rows": [{"item": "장비", "dest": "start", "note": ""}, {"item": " 장비 ", "dest": "mid", "note": ""}, {"item": "골재", "dest": "mid", "note": ""}]}, - {"id": "k", "name": "", "address": "", "via_base": True, "dir": None, + {"id": "k", "name": "", "address": "", "to": "site", "dir": None, "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]}, ], } # fmt: skip @@ -104,10 +105,10 @@ def test_거래처_상자_항목_글(): def test_표_폭_다이어그램_폭_좌측_카드_없음(): css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") block = css[css.index(".b08-milestone {") :] - assert "width: fit-content;" in block[: block.index("}")] # 틀 = 표 너비 - assert ".b08-milestone > .b08-milestone__diagram {\n contain: inline-size;" in css + assert "width: 100%;" in block[: block.index("}")] # 149 틀 = 페이지 폭 전체 + assert ".b08-milestone > .ui-data-table {\n width: 100%;" in css # 149 표도 폭 전체 ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert "root.style.width = `min(100%, calc(48vh * ${vw / vh}))`;" in ui # 다이어그램 = 틀 너비 + assert "const fw = Math.max(host.clientWidth, 320);" in ui # 149 다이어그램 = 틀 폭 · 틀 비율 page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert "children: [diagram.root, none, table.root]" in page # 좌측 패널 카드 없음 · 공사지 · 기준점은 모달 @@ -116,14 +117,14 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음(): assert "milestoneCardBody" not in _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") site = modal[modal.index("export function openSiteInfo") :] - assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site - assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site - # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음 + assert 'label: "중점 측점 (km)"' in site and 'label: "시점 → 중점 도로"' in site + assert 'setLeg(fresh, "start", 0, {' in site and "putMilestone(" in site + # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 254 여럿 · 삭제 = 그 기준점만 base = modal[ modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") ] - assert "enabled: true," in base and 'label: "삭제"' in base - assert "fresh.base = { enabled: false," in base + assert "fresh.bases.push({" in base and 'label: "삭제"' in base + assert "fresh.bases = fresh.bases.filter((b) => b.id !== base.id);" in base assert 'label: "기준점 +"' not in ui and "openBaseInfo" not in ui and "addBase" not in ui assert "content: panContent(vw, vh)" in ui and "panStart," in ui diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 0392f8230..9523bf302 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -24,7 +24,10 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) FAKE_KEY = "fake-kakao-key-0123456789" @@ -144,7 +147,8 @@ def test_키_값_응답_로그에_안_나옴(monkeypatch, caplog): def _data(base_enabled=True, base_ll=(127.85, 35.66)): - return { + """254 옛 꼴로 적고 새 꼴(bases · to · 옛 기준점 = b1)로 옮겨 씀.""" + return migrate_milestone({ "site": {"mid_station_m": None, "mid_road": "비포장"}, "base": {"enabled": base_enabled, "name": "병곡리 968-31", "address": "", "dir": None, "lon": base_ll[0], "lat": base_ll[1], "legs": []}, @@ -156,7 +160,7 @@ def _data(base_enabled=True, base_ll=(127.85, 35.66)): "lon": None, "lat": None, "legs": [{"road": None, "km": None}], "rows": [{"item": "철근", "dest": "start", "note": ""}]}, ], - } # fmt: skip + }) # fmt: skip LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" @@ -179,29 +183,30 @@ def test_구간_채움(): two = _node(LAYOUT, f"kmSegments({d}, 'r2', null)") assert two == [{"key": "route", "text": "김천역 → 시점", "from": None, "to": None}] # 자동 km 넣기 — 빈 구간 = 안 고른 줄 하나 · 사용자 km 는 그대로 - put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 'route', 10.5), 'r1', 'base', 1.4)") + put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 10.5), 'b1', 1.4)") assert put["routes"][0]["legs"] == [{"road": "2차포장", "km": 23.8, "auto_km": 10.5}] - assert put["base"]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}] + assert put["bases"][0]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}] # 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값 lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)") line = {ln["from"]: ln for ln in lay["lines"]} - assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] - assert line["base"]["labels"] == ["도로 구분 고르기 1.4km"] and line["base"]["tips"] == [""] - assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 + # 254 선 글 두 줄(도로 구분 / km) + assert line["r1"]["labels"] == ["2차포장\n23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] + assert line["b1"]["labels"] == ["도로 구분 고르기\n1.4km"] and line["b1"]["tips"] == [""] + assert line["start"]["labels"] == ["비포장\n0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 def test_사용자_값_우선_고르기_표시(): data = _data() data["routes"][0]["legs"] = [{"road": "2차포장", "km": None, "auto_km": 10.5}] - data["base"]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}] + data["bases"][0]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}] a, b = compute_milestone(data, 1500.0) # 거래처 = 자동 10.5 · 기준점 = 사용자 0.6(자동 1.4 무시) · 중점 1.5 비포장 assert (a["s2"], a["s1"], a["un"], a["total"], a["pick"]) == (10.5, 0.6, 1.5, 12.6, False) - # 도로 구분 안 고름 = 합에 안 듦 · pick - assert (b["total"], b["pick"]) == (0, True) - data["base"]["legs"][0]["road"] = None + # 152 도로 구분 안 고름 = 선 자리 포장 종류로 셈(pick 늘 거짓) — 빈 km = 0 + assert (b["total"], b["pick"]) == (0, False) + data["bases"][0]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리) a2, _ = compute_milestone(data, 1500.0) - assert (a2["s1"], a2["total"], a2["pick"]) == (0, 12.0, True) + assert (a2["s1"], a2["total"], a2["pick"]) == (0.6, 12.6, False) # TS 짝 거울 for d in (data, _data()): ts = _node("B08_Quantity/B08_Quantity_Milestone_Calc.ts", @@ -210,7 +215,7 @@ def test_사용자_값_우선_고르기_표시(): # 저장 꼴 — auto_km · road null 받음 · 빠진 road = 옛 기본 2차포장 saved = ms.Milestone.model_validate(data).model_dump() assert saved["routes"][0]["legs"][0]["auto_km"] == 10.5 - assert saved["base"]["legs"][0]["road"] is None + assert saved["bases"][0]["legs"][0]["road"] is None assert ms.Leg.model_validate({"km": 1}).road == "2차포장" @@ -223,7 +228,7 @@ def test_화면_길찾기_단추_키_화면에_없음(): leg = read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") assert 'label: "길찾기"' in leg and "findSegmentKm(projectId, segment)" in modal assert "/quantity/milestone/directions" in state and "segmentEnds(" in state - assert '"도로 구분 고르기"' in table and "r.pick" in table + assert '"도로 구분 고르기"' not in table and "r.pick" not in table # 152 자리로 정함 assert "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름 # 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음 for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): diff --git a/resources/tester/test_244_8_b08_milestone_no_scroll.py b/resources/tester/test_244_8_b08_milestone_no_scroll.py index cbf8855aa..17c980d2a 100644 --- a/resources/tester/test_244_8_b08_milestone_no_scroll.py +++ b/resources/tester/test_244_8_b08_milestone_no_scroll.py @@ -37,9 +37,11 @@ def test_틀_스크롤_없음_그림_맞춤(): assert "overflow: hidden;" in block and "overflow: auto" not in block assert "max-height" not in block # 높이는 그림이 맞춤 — 틀이 자르지 않음 ui = _read(DIAGRAM) - # 너비 = 틀 · 높이 48vh 넘으면 비율대로 좁힘(height auto 라 화면 px ↔ 그림 좌표 그대로) - assert "root.style.width = `min(100%, calc(48vh * ${vw / vh}))`;" in ui - assert 'root.style.height = "auto";' in ui + # 149 틀 = 폭 전체 · 높이 고정(CSS) · 그림 범위를 틀 비율로 넓힘(화면 px ↔ 그림 좌표 가로세로 같음) + assert "const scale = Math.max(lw / fw, lh / fh);" in ui + assert "height: clamp(420px, 58vh, 760px);" in _read( + "B08_Quantity/B08_Quantity_UI_Milestone.css" + ) def test_휠_진짜_사건_줌_스크롤_막음(): diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 1d25f62a8..df41ff5d7 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -19,6 +19,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" @@ -46,9 +47,10 @@ def _read(rel): def _node(rels, script): src = "".join(IMPORTS.sub("", _read(rel)) for rel in rels) + # 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", src + "\n" + script], + "--input-type=module-typescript", "-"], input=src + "\n" + script, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr @@ -118,14 +120,14 @@ def test_표지_셋(): "routes": [{"id": "r1", "name": "거창군청", "lon": 127.9, "lat": 35.68, "via_base": True}, {"id": "r2", "name": "", "lon": None, "lat": None, "via_base": False}], } # fmt: skip - d = json.dumps(data) + d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1) got = _node( [LAYOUT], f"""const D = {d}; console.log(JSON.stringify([ mapMarks(D, "r1", [127.83, 35.64]), mapMarks(D, "r2", [127.83, 35.64]), - mapMarks({{ ...D, base: {{ ...D.base, enabled: false }} }}, "r1", null), + mapMarks({{ ...D, bases: [] }}, "r1", null), ]));""", ) assert got[0] == [ @@ -159,7 +161,11 @@ const PATHS = {json.dumps(paths)}; const asked: string[] = []; const request = async (url: string) => (asked.push(url), {{ key: KEY }}); {extra} -const view = routeMapView("p1", "r1"); +// 156 경로 탭 지도 칸 없앰 — 같은 SDK 싣기 · 안내 길 = 연결선 모달 지도 칸(editMap) +const view = editMap("p1", {{ onMove: () => {{}}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" }}); +view.set([{{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }}, + {{ key: "base", name: "병곡리", lon: 127.85, lat: 35.66, movable: true }}, + {{ key: "start", name: "시점", lon: 127.83, lat: 35.64, movable: false }}], PATHS); await new Promise((r) => setTimeout(r, 30)); const [hint, box] = view.root.children; console.log(JSON.stringify({{ asked, injected, hint: hint.textContent, boxHidden: box.hidden, @@ -173,7 +179,7 @@ def test_키_없음_안내_한_줄(): assert got["asked"] == ["/projects/p1/quantity/milestone/map-key"] assert got["injected"] == [] # SDK 안 부름 assert got["hint"] == ( - "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요" ) assert got["boxHidden"] @@ -193,6 +199,7 @@ const on = (kind: string, o: any) => ({ setMap: (m: any) => m && LOG.overlays.pu Polyline: function (o: any) { return on("line", o); }, Marker: function (o: any) { return on("marker", o); }, CustomOverlay: function (o: any) { return on("label", o); }, + event: { addListener: () => {} }, } }; """ @@ -216,10 +223,11 @@ def test_화면_자리_캡처_키_서버만(): assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동 leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") # 244-13 = 연결선 모달 지도 - assert "map.root," in leg and "map.set(pins()" in leg + assert "mapHead, map.root" in leg and "map.set(pins()" in leg route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") - assert "routeMapView(projectId, routeId)" in route - assert "mapView.draw();" in route + # 156 경로 탭 = 그림만(새 창 길찾기 · 지도 칸 없앰 · 길찾기 · 지도 = 연결선 모달) + assert "routeMapView" not in route and "다음 지도에서 길찾기" not in route + assert "routeMapView" not in kmap state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "paths.set(from, res.path!);" in state # 메모리만(캐시 · 서버 안 감) for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만 diff --git a/resources/tester/test_246_1_b09_bill_menu_simple.py b/resources/tester/test_246_1_b09_bill_menu_simple.py index 7a76c6545..4df6d88a6 100644 --- a/resources/tester/test_246_1_b09_bill_menu_simple.py +++ b/resources/tester/test_246_1_b09_bill_menu_simple.py @@ -16,11 +16,12 @@ ROOT = Path(__file__).resolve().parents[2] OLD_ADDS = ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가", "새 공종 추가(1단)"] -def test_입력_칸_테두리_없음(): +def test_입력_칸_채움_색_td_테두리_옅게(): css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") body = css.split(".ui-data-table__input {")[1].split("}")[0] assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body - assert "box-shadow" not in body and "is-can-edit" not in css + assert "box-shadow" not in body + assert "td.is-can-edit {" in css # 258-2 — 고칠 칸 옅은 outline 되살림 def test_더함_하나_줄_삭제_연결_이름_셋(): @@ -31,11 +32,12 @@ def test_더함_하나_줄_삭제_연결_이름_셋(): assert not set(OLD_ADDS) & set(out[key]) and "줄 지우기" not in out[key] assert "줄 삭제" in out["earth:4"] and "줄 삭제" in out["hand:a"] link = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8") - picks = link.split("const picks: Leaf[] = [")[1].split("];")[0] + # 251-6 고르기 다섯 = 단가연결 모달 하나 · 종류 이름 = 단가산출 · 사전조합 · 구조물 조합 · 호표 둘 + kinds = link.split("kindFilter: [")[1].split("],\n pick")[0] for name in ["일위대가 호표", "단가산출 호표", "구조물 조합", "일위대가 사전조합", "단가산출"]: - assert f'["{name}",' in picks + assert f'text: "{name}"' in kinds for word in ["서고", "마스터", "회사", "개인", "M01", "새 산근", "로직"]: - assert word not in picks + assert word not in kinds toasts = [part.split(",")[0] for part in link.split("showToast(")[1:]] assert toasts and not any("서고" in t for t in toasts) # 알림 글 쉬운 말 bill = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8") diff --git a/resources/tester/test_246_2_context_submenu_side.py b/resources/tester/test_246_2_context_submenu_side.py index 3c09cc09a..f0bdc831c 100644 --- a/resources/tester/test_246_2_context_submenu_side.py +++ b/resources/tester/test_246_2_context_submenu_side.py @@ -165,4 +165,4 @@ def test_옛_아코디언_없음_공용_꼴(): assert ".ui-ctx-menu__sub" not in css and ".ui-ctx-menu__item.is-open" in css # B09 연결 = children 꼴 그대로(공용 메뉴가 옆 판으로) edit = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Edit.ts").read_text("utf-8") - assert 'parts[1].push({ label: "연결", children: picks }, ...more);' in edit + assert "parts[1].push(...picks, ...more);" in edit # 251-6 단가연결 = 모달 하나(하위 메뉴 없음) diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py index d567527cc..3f303ed2f 100644 --- a/resources/tester/test_249_1_b08_milestone_capture_child.py +++ b/resources/tester/test_249_1_b08_milestone_capture_child.py @@ -29,7 +29,7 @@ LINES = [[[35.6866, 127.9095], [35.66, 127.85]]] MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}] if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로 - cap.page_html = lambda key, lines, marks: HTML + cap.page_html = lambda key, lines, marks, view=None: HTML print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read())))) sys.exit(0) @@ -81,7 +81,7 @@ def test_자식_프로세스로_PNG_생김(tmp_path, monkeypatch): def test_자식_시간_넘김_죽음_까닭(tmp_path, monkeypatch): monkeypatch.setenv(cap.KAKAO_JS, KEY) - tail = " — 그림 올리기를 쓰세요." + tail = " — [그림 변경]으로 넣으세요." head = "경로 그림 자동 캡처 실패 — " hang = tmp_path / "hang.py" hang.write_text("import time\ntime.sleep(30)\n", encoding="utf-8") diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py new file mode 100644 index 000000000..eca1c4cc6 --- /dev/null +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -0,0 +1,178 @@ +"""PLAN 250(사용자 149 · 150 · 152) — 거리이정표 도로 구분 = 선 자리 · 모달 정리 · 폭 · 지도 높이.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, + road_of, +) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _ts(expr: str): + src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts")) + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", + "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_도로_구분_선_자리_짝(): + cases = [ + (r, k) + for r in (None, "2차포장", "1차포장", "포장", "비포장") + for k in ("via", "base", "direct", "mid") + ] + py = [road_of(r, k) for r, k in cases] + assert py == _ts(f"{json.dumps(cases)}.map(([r, k]) => roadOf(r, k))") + assert road_of(None, "via") == "2차포장" and road_of("포장", "base") == "1차포장" + assert road_of("2차포장", "direct") == "포장" and road_of("비포장", "via") == "비포장" + + +def test_기준점_생기면_2차_1차_없으면_포장(): + leg = lambda road, km: {"road": road, "km": km} # noqa: E731 + route = {"id": "r", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "legs": [leg(None, 23.8)], "rows": [{"item": "장비", "dest": "mid", "note": ""}]} # fmt: skip + data = {"site": {"mid_station_m": 800, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "address": "", "dir": None, + "legs": [leg(None, 0.6)]}, + "routes": [route]} # fmt: skip + (a,) = compute_milestone(data, None) + assert (a["s2"], a["s1"], a["pv"], a["un"], a["total"]) == (23.8, 0.6, 0, 0.8, 25.2) + data["base"]["enabled"] = False + (b,) = compute_milestone(data, None) + assert (b["s2"], b["s1"], b["pv"], b["un"], b["total"]) == (0, 0, 23.8, 0.8, 24.6) + + +def test_모달_상자_하나_항목_하나_수정_글(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") + assert '"+ 줄"' not in modal and "same.rows" not in modal + assert 'label: "고침"' not in modal + leg and modal.count('"수정"') >= 2 + assert 'item.root.style.display = dest.root.style.display = k === "base"' in modal # 255-3 + + +def test_폭_전체_지도_높이_한_값(): + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + block = css[css.index(".b08-milestone {") :] + assert "width: 100%;" in block[: block.index("}")] + assert ".b08-milestone > .ui-data-table .ui-data-table__table {\n width: 100%;" in css + assert css.count("height: 480px;") == 1 and ".b08-milestone__map {" in css + + +def test_153_지도_보기_유지_상자_색_끌어_옮김(): + kmap = _read("B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts") + assert ( + "let fitted = false;" in kmap and "map.panTo(" in kmap + ) # 처음 한 번만 맞춤 · 그 뒤 줌 그대로 + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + for kind, var in ( + ("is-site", "--color-accent"), + ("is-base", "--color-warning"), + ("is-route", "--color-success"), + ): + block = css[css.index(f".b08-milestone__box.{kind} rect {{") :] + assert var in block[: block.index("}")], kind + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift);' in ui + assert ( + 'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui + ) # 159 이름 + + +def test_153_끌어_놓은_자리_레이아웃(): + src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")) + data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "기준", "address": "", "dir": "W", "legs": [], "pos": [500, -40]}, + "routes": [{"id": "r", "name": "a", "address": "", "via_base": False, "dir": "N", "legs": [], + "rows": [], "pos": [-700, 300]}]} # fmt: skip + data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1) + expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'b1' || b.id === 'r').map((b) => [b.id, b.x, b.y])" + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", + "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + assert sorted(json.loads(out.stdout)) == [["b1", 500, -40], ["r", -700, 300]] + + +def test_154_연결_점_끌어_잇기(): + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + # 254 기준점 상자에도 연결 점 + assert ( + 'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui + ) + assert "route.to = t;" in ui # 254 놓은 기준점 id · 공사지 = "site" + assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움 + assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번 + assert "if (route.linked !== false && route.to === t) return;" in ui # 같은 연결 = 그대로 + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert ( + 'createCheckbox("기준점 거침")' not in modal and "via_base: via.input.checked" not in modal + ) + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + assert ".b08-milestone__connect {" in css and ".b08-milestone__box.is-drop-target rect {" in css + + +def test_155_요소_추가_안_이은_거래처_수기_입력(): + leg = lambda road, km, auto=None: {"road": road, "km": km, "auto_km": auto} # noqa: E731 + route = {"id": "r", "name": "a", "address": "", "via_base": True, "dir": None, "linked": False, + "legs": [leg(None, 5.0)], "rows": [{"item": "장비", "dest": "mid", "note": ""}]} # fmt: skip + data = {"site": {"mid_station_m": 700, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "b", "address": "", "dir": None, "legs": [leg(None, 0.6)]}, + "routes": [route]} # fmt: skip + (a,) = compute_milestone(data, None) # 안 이음 = 구간 km 없음 · 중점 구간만 + assert (a["s2"], a["s1"], a["un"], a["total"]) == (0, 0, 0.7, 0.7) + assert _ts(f"computeMilestone({json.dumps(data)}, null)") == [a] + route["linked"] = True + (b,) = compute_milestone(data, None) + assert (b["s2"], b["s1"], b["total"]) == (5.0, 0.6, 6.3) + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert "route.linked = true;" in ui and 'label: "요소 추가"' in ui and "plus(box" not in ui + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert "linked: false," in modal and "export type PlaceTarget = { add: true } |" in modal + leg_ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") + assert ( + 'createCheckbox("수기 입력' in leg_ui and "target.manual = manual.input.checked;" in leg_ui + ) + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "if (owner?.manual) return" in state # 수기 입력 = 카카오 길찾기 안 함 + layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + assert "if (!owner || owner.linked === false) return null;" in layout # 254 거래처 · 기준점 + + +def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화(): + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + tools = css[css.index(".b08-milestone__tools {") :] + assert "bottom: 8px;" in tools[: tools.index("}")] and "left: 8px;" in tools[: tools.index("}")] + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert ( + 'label: "요소 추가",\n size: "sm"' not in ui + and 'label: "방위 배치",\n size: "sm"' not in ui + ) + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert 'label: "그림 변경"' in route and 'label: "초기화"' in route + assert "그림 지우기" not in route and "자동 그림으로 되돌리기" not in route.split("*/", 1)[1] + assert ( + "map.root.hidden = user;" in route and "editMap(projectId" in route + ) # 그림 없으면 길찾기 지도 + assert ".b08-milestone__route-head {" in css and "justify-content: space-between;" in css + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "export const hasUserImage" in state and "canRevertImage" not in state diff --git a/resources/tester/test_251_1_haul_groups.py b/resources/tester/test_251_1_haul_groups.py new file mode 100644 index 000000000..3ae95ee38 --- /dev/null +++ b/resources/tester/test_251_1_haul_groups.py @@ -0,0 +1,144 @@ +"""PLAN 251-1 — 총괄자재표 → 운반 묶음(거리이정표 행 · B09 운반공 줄 한 원천).""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul import bill_key, haul_groups # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 + + +def _mats(rows): + return {"material": {"rows": rows}} + + +ROWS = [ + {"key": "sand", "group": "골재", "spec": "모래", "unit": "㎥", "amount": 16}, + {"key": "gravel", "group": "골재", "spec": "자갈", "unit": "㎥", "amount": None}, + {"key": "mixed_aggregate", "group": "골재", "spec": "혼합석", "unit": "㎥", "amount": 3.5}, + {"key": "remicon_25_24_15", "group": "레미콘", "spec": "25-24-15", "unit": "㎥", "amount": 2}, + {"key": "remicon_25_21_80", "group": "레미콘", "spec": "25-21-80", "unit": "㎥", "amount": 1}, + {"key": "rebar_d13", "group": "이형철근", "spec": "D13mm", "unit": "ton", "amount": 0.5}, + {"key": "rebar_d16", "group": "이형철근", "spec": "D16mm", "unit": "ton", "amount": 0.25}, + {"key": "cement", "group": "시멘트", "spec": "40kg/대", "unit": "대", "amount": None}, + {"key": "pipe_600", "group": "파형강관", "spec": "Φ600mm", "unit": "m", "amount": 12}, + {"key": "picked_deduct", "group": "채집석 공제", "unit": "㎥", "amount": 4}, + {"key": "MT000777", "group": "앵커볼트", "spec": "M16", "unit": "EA", "amount": 8}, + {"key": "sod", "group": "떼", "unit": "㎡", "amount": 0}, +] + + +def test_묶음_장비_옛_여섯_늘_수량_없는_새_묶음_없음(): + got = {g["key"]: (g["name"], g["unit"], g["qty"]) for g in haul_groups(_mats(ROWS))} + assert list(got)[:2] == ["equip_heavy", "equip_machine"] # 장비 둘 맨 앞 + assert got["sand"] == ("모래", "㎥", 16) and got["gravel"] == ("자갈", "㎥", None) + assert got["mixed_aggregate"] == ("혼합석", "㎥", 3.5) + assert got["remicon"] == ("레미콘", "㎥", 3) and got["rebar"] == ("철근", "ton", 0.75) + assert got["cement"] == ("시멘트", "대", None) # 옛 여섯 = 수량 없어도 늘 + assert got["pipe"] == ("파형강관", "m", 12) and got["MT000777"] == ("앵커볼트", "EA", 8) + assert "picked_deduct" not in got and "sod" not in got # 공제 · 수량 0 새 묶음 = 행 없음 + + +def test_B09_운반_줄_옛_키_그대로_새_묶음_키(): + items = {i["key"]: i for i in haul_items(_mats(ROWS))} + assert [bill_key(k) for k in ("equip_heavy", "sand", "rebar")] == ["haul:1", "haul:3", "haul:6"] + assert items["haul:3"]["name"] == "모래" and items["haul:3"]["qty"] == 16 + assert items["haul:remicon"]["name"] == "레미콘" and items["haul:remicon"]["unit"] == "㎥" + assert all(i["section"] == "3" for i in items.values()) + assert {"haul:1", "haul:2", "haul:3", "haul:4", "haul:5", "haul:6"} <= set(items) + + +def test_251_2_거리이정표_운반_상자_자동(): + from B08_Quantity.B08_Quantity_Haul import with_haul_routes + + groups = haul_groups(_mats(ROWS)) + data = {"site": {}, "base": {"enabled": False}, "routes": [ + {"id": "h-sand", "name": "골재상", "haul": "sand", "rows": [{"item": "모래"}]}, + {"id": "u1", "name": "내 상자", "rows": []}, + ]} # fmt: skip + out = with_haul_routes(data, groups) + by = {r["id"]: r for r in out["routes"]} + assert by["h-sand"]["name"] == "골재상" # 있는 상자 그대로 + assert "u1" in by # 사용자 상자 그대로 + new = by["h-remicon"] + assert (new["name"], new["address"], new["linked"], new["haul"]) == ("", "", False, "remicon") + assert new["rows"] == [{"item": "레미콘", "dest": "start", "note": ""}] + assert len([r for r in out["routes"] if r.get("haul")]) == len(groups) + assert data["routes"][0] is out["routes"][0] and len(data["routes"]) == 2 # 원본 안 바뀜 + + +def test_251_2_서버_모델_읽기_길(): + from B08_Quantity.B08_Quantity_Router_Milestone import Route + + r = Route(id="h-sand", haul="sand", ref={"tab": "logic", "code": "GF000284"}) + assert r.haul == "sand" and r.ref == {"tab": "logic", "code": "GF000284"} + src = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Milestone.py").read_text(encoding="utf-8") + assert "with_haul_routes(read_data(root), groups)" in src + + +def test_251_5_B09_운반_줄_연결_거리이정표(): + from B09_Estimation.B09_Estimation_Bill_Library import used_folders + from B09_Estimation.B09_Estimation_Sangeun import hand_sources + from B09_Estimation.B09_Estimation_Store import ref_rows + + logic = {"tab": "logic", "code": "GF000284", "name": "덤프 운반", "given": {"L": 5}} + lib = {"tab": "dg", "key": "DG:x", "lib": "precombo@system@-@SC-1"} + items = {i["key"]: i for i in haul_items(_mats(ROWS), {"haul:3": logic, "haul:remicon": lib})} + assert items["haul:3"]["ref"] == logic and items["haul:3"]["why"].startswith("새 산근") + assert items["haul:remicon"]["ref"] == lib and items["haul:6"]["ref"] is None + # 셈 · 수요 · 사본 정리 = 설계내역서 덮개 + 운반 연결 · 같은 줄 덮개 연결이 이김 + user = {"bill": {"v": 2, "rows": [{"key": "haul:remicon", "ref": None}]}, + "haul_refs": {"haul:3": logic, "haul:remicon": lib}} # fmt: skip + assert [r["key"] for r in ref_rows(user)] == ["haul:remicon", "haul:3"] + assert [k for k, _u, _s in hand_sources(user)] == ["haul:3"] + assert used_folders(user) == set() # 덮개가 연결 풀었음 = 사본 안 지킴 + assert used_folders({"haul_refs": {"haul:remicon": lib}}) == {"precombo@system@-@SC-1"} + + +def test_251_5_자동_줄_단가_줄_키_짝(): + from B09_Estimation.B09_Estimation_Calc_Bill import ref_price + + books = {"sg_list": [{"key": "SG:9", "no": 9, "sum": 100}], "sg_hand": {"haul:3": "SG:9"}} + got = ref_price({"tab": "logic", "code": "GF000284"}, books, {}, "haul:3") + assert got and got["key"] == "SG:9" and got["no"] == "산근 9호표" + py = (ROOT / "B09_Estimation" / "B09_Estimation_Calc_Bill.py").read_text(encoding="utf-8") + ts = (ROOT / "B09_Estimation" / "B09_Estimation_Calc_Bill.ts").read_text(encoding="utf-8") + assert 'ref_price(item.get("ref"), books, hopyo, item["key"])' in py + assert "refPrice(item.ref ?? null, book, hopyo, item.key)" in ts + + +def test_251_4_거리이정표_램프_우클릭_단가연결(): + import json + import subprocess + + src = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8") + fn = ( + "export function refText" + + src.split("export function refText")[1].split("\n}\n")[0] + + "\n}\n" + ) + js = ( + fn + + """ +console.log(JSON.stringify([refText(null), + refText({ tab: "logic", code: "GF000134", name: "골재운반 모래" }), + refText({ tab: "dg", key: "DG:x", name: "회사 조합" })])); +""" + ) + run = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=js, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert run.returncode == 0, run.stderr + assert json.loads(run.stdout) == [ + "단가연결 없음 — 줄 우클릭 [단가연결]", + "단가연결 — 단가산출 GF000134 골재운반 모래", + "단가연결 — 일위대가 회사 조합", + ] + # 램프 = 항목 칸 · 우클릭 = [단가연결] · 연결 있으면 [단가연결 풀기] · 창 = B09 와 같은 것 + assert 'key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP' in src + assert "openHaulModal({" in src and '"단가연결 풀기"' in src # 263-1 = 운반 단가 모달 하나 + diagram = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone_Diagram.ts").read_text("utf-8") + assert "Math.abs(h - lastH) > 1" in diagram # 틀 높이 바뀜 = 다시 그림(아래 잘림 없음) diff --git a/resources/tester/test_252_1_b09_schedule.py b/resources/tester/test_252_1_b09_schedule.py new file mode 100644 index 000000000..84b8b3f69 --- /dev/null +++ b/resources/tester/test_252_1_b09_schedule.py @@ -0,0 +1,212 @@ +"""PLAN 252 — B09 공기 초안(공기산출 · 공기집계 셈 짝 · 서버 원천 · 사용자 값 다듬기).""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule # noqa: E402 +from B09_Estimation.draft.B09_Draft_Schedule_Source import ( # noqa: E402 + clean_schedule, + holiday_table, + schedule_inputs, + unit_resources, +) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +#: 참고 설계서(25 거창) 공기산출 — [이름, 시공량, 일작업량(= 8 × Q)] · 기타공 2일 +GEOCHANG = [ + ("토사절취", 7641, 1206.4), ("암석절취", 2669, 160), ("암석절취", 297, 48), + ("측구터파기", 110, 362.24), ("측구터파기", 103, 20), ("지장목제거", 10782, 4519.76), + ("초류종자파종", 2718.4, 2400), ("전석메쌓기", 540, 74.4), ("유입구골막이", 4, 4), + ("유출구물받이", 16, 6), ("터파기", 757, 838.24), ("터파기", 57, 80), ("터파기", 57, 48), + ("되메우기", 523, 904.16), ("관구체다짐", 324, 180), ("파형강관매설", 185, 160), + ("배수관날개벽", 16, 4), ("BOX암거", 5, 0.8), ("BOX암거 난간", 13, 6.4), + ("콘크리트포장", 2170, 200), ("혼합석부설", 2870, 1108), ("노견줄떼", 820, 255.2), +] # fmt: skip +#: 같은 설계서 공기집계 — 기후(강수 + 혹서) · 법정공휴일(2026 기준 · 그 설계서 값) +B_REF = [5, 7, 6, 4, 7, 5, 4, 7, 7, 7, 5, 5] + + +def _bill(rows): + out = [{"kind": "title", "key": "sec:1", "name": "토사", "level": 1, "section": "1"}] + for i, (name, qty, _d) in enumerate(rows): + out.append({"kind": "item", "key": f"r{i}", "name": name, "qty": qty, "level": 2, + "section": "1.2", "unit": "㎥"}) # fmt: skip + return out + + +def _ref_user(): + return { + "rows": {f"r{i}": {"daily": d} for i, (_n, _q, d) in enumerate(GEOCHANG)}, + "etc": 2, + "start": {"year": 2026, "month": 3}, + "holiday": {f"2026-{m:02d}": b for m, b in enumerate(B_REF, 1)}, + } + + +SRC = { + "units": {}, + "holidays": holiday_table(), + "climate": { + "rain": [1.2, 1.4, 3.6, 4.5, 3.6, 4.4, 7.7, 6.9, 5.5, 3.1, 2.2, 1.2], + "heat": [0, 0, 0, 0, 0.5, 1.2, 5.9, 8.4, 0, 0, 0, 0], + }, + "start": {"year": 2027, "month": 3}, +} + + +def test_참고_설계서_거창_공기산출_재현(): + work = compute_schedule(SRC, _bill(GEOCHANG), _ref_user())["work"] + days = [r["days"] for r in work["rows"] if r["kind"] == "item"] + assert days[:6] == [6.4, 16.7, 6.2, 0.4, 5.2, 2.4] # 설계서 F7 · F8 · F9 · F10 · F11 · F14 + assert days[-3:] == [10.9, 2.6, 3.3] # F37 · F38 · F39 + assert (work["prep"], work["subtotal"], work["tidy"], work["total"]) == (4, 87, 4, 95) + assert [r["kind"] for r in work["rows"][:2]] == ["head", "item"] # 묶음 줄 먼저 + + +def test_참고_설계서_거창_작업가능일_채움(): + cal = compute_schedule(SRC, _bill(GEOCHANG), _ref_user())["calendar"] + months = cal["months"] + # 설계서 3 ~ 7월 작업가능일수(달력 − ROUNDDOWN(A + B − A×B÷달력)) + assert [m["avail"] for m in months] == [23, 23, 21, 21, 16] + assert [(m["prep"], m["work"], m["tidy"]) for m in months] == [ + (4, 19, 0), (0, 23, 0), (0, 21, 0), (0, 21, 0), (0, 3, 4), + ] # fmt: skip + # 다 쓴 달 = 비작업 전부 · 마지막 달 = ROUNDUP(15 × 7 ÷ 16) = 7 + assert [m["off_used"] for m in months] == [8, 7, 10, 9, 7] + assert (cal["days"], cal["months_n"], cal["end"]) == (136, 4.5, "2026-07-14") + + +def test_공휴일_자동_2026(): + cal = compute_schedule(SRC, [], {"start": {"year": 2026, "month": 1}, "tidy": 400}) + auto = [m["b_auto"] for m in cal["calendar"]["months"][:12]] + # 설계서 값과 다른 달 = 5월 노동절 · 6월 지방선거 · 7월 제헌절(공휴일 법 바뀜 · 목록이 넣음) + assert auto == [5, 7, 6, 4, 8, 6, 5, 7, 7, 7, 5, 5] + may = cal["calendar"]["months"][4]["names"] + assert "5일 어린이날" in may and "25일 부처님오신날 대체 휴일" in may + assert cal["calendar"]["months"][1]["days"] == 28 # 2026.2 = 28일(설계서는 29) + + +def test_장비_인력_입력_반영률(): + src = {**SRC, "units": { + "a": {"machine": {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066}, "labor": 0.01}, + "b": {"machine": None, "labor": 1.5}, + "c": {"machine": None, "labor": None}, + }} # fmt: skip + bill = [ + {"kind": "item", "key": "a", "qty": 1000, "section": "1.2", "level": 2}, + {"kind": "item", "key": "b", "qty": 80, "section": "2.1", "level": 2}, + {"kind": "item", "key": "c", "qty": 5, "section": "2.3", "level": 2}, + {"kind": "item", "key": "h", "qty": 9, "section": "3", "level": 2}, + {"kind": "item", "key": "mat:x", "qty": 9, "section": "4", "level": 2}, + {"kind": "item", "key": "z", "qty": 0, "section": "1.2", "level": 2}, + ] + rows = {r["key"]: r for r in compute_schedule(src, bill, {})["work"]["rows"]} + a, b, c, h = rows["a"], rows["b"], rows["c"], rows["h"] + # 장비 = 1000 × 0.0066 ÷ 8 = 0.825 → 0.9 · Q = 1 ÷ 0.0066 + assert (a["basis"], a["days"], round(a["q"], 2), a["put"]) == ("장비", 0.9, 151.52, 1) + assert (b["basis"], b["days"], b["put"]) == ("인력", 12, 10) # 80 × 1.5 ÷ 10 + assert (c["basis"], c["days"]) == ("연결 없음", None) + assert (h["rate"], h["days"]) == (0, 0) # 운반공 기본 0 = 단위당 자원 없어도 0일 + assert "mat:x" not in rows and "z" not in rows # 자재대 · 수량 0 = 줄 없음 + user = {"rows": {"a": {"units": 2, "rate": 50}, "b": {"crew": 5}, "c": {"daily": 2}}} + rows = {r["key"]: r for r in compute_schedule(src, bill, user)["work"]["rows"]} + assert rows["a"]["days"] == 0.3 # 1000 × 0.5 × 0.0066 ÷ 16 = 0.20625 → 0.3 + assert rows["b"]["days"] == 24 and (rows["c"]["basis"], rows["c"]["days"]) == ("입력", 2.5) + + +def _ts_schedule(src, bill, user): + base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8")) + calc = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_Calc.ts").read_text("utf-8") + js = ( + base + IMPORTS.sub("", calc) + + f"\nconsole.log(JSON.stringify(computeSchedule({json.dumps(src, ensure_ascii=False)}, " + f"{json.dumps(bill, ensure_ascii=False)}, {json.dumps(user, ensure_ascii=False)})));" + ) # fmt: skip + run = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=js, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_거울_TS_가_같은_값(): + src = {**SRC, "units": {"r0": {"machine": {"key": "M", "name": "굴", "hr": 0.0066}}, + "r1": {"labor": 1.5}}} # fmt: skip + cases = [ + (SRC, _bill(GEOCHANG), _ref_user()), + (src, _bill(GEOCHANG[:4]), {"rows": {"r0": {"rate": 50}}, + "start": {"year": 2027, "month": 11}, + "climate": {"rain": [9] * 12, "heat": [None] * 12}}), + (SRC, [], {}), + ] # fmt: skip + for src_, bill, user in cases: + py = json.loads(json.dumps(compute_schedule(src_, bill, user), ensure_ascii=False)) + assert _ts_schedule(src_, bill, user) == py + + +def test_사용자_값_다듬기(): + raw = { + "hours": 9, "tidy": -1, "etc": "x", "start": {"year": 2027, "month": 13}, + "climate": {"rain": [1] * 12, "heat": [1, 2]}, + "holiday": {"2027-05": 6, "bad": 3, "2027-06": 40}, + "rows": {"a": {"rate": 50, "units": 0, "crew": 5, "daily": None, "x": 1}, + "b": {"rate": 120}}, + "junk": 1, + } # fmt: skip + assert clean_schedule(raw) == { + "hours": 9, + "climate": {"rain": [1] * 12}, + "holiday": {"2027-05": 6}, + "rows": {"a": {"rate": 50, "crew": 5}}, + } + assert clean_schedule(None) == {} + from B09_Estimation.B09_Estimation_Store import clean_user + + assert clean_user({"schedule": {"tidy": 3}})["schedule"] == {"tidy": 3} + assert "schedule" not in clean_user({"schedule": {"junk": 1}}) + + +def test_서버_원천_단위당_자원(): + sheets = { + "bill": {"rows": [ + {"kind": "item", "key": "earth:4", "ref": {"tab": "sg", "key": "S1"}}, + {"kind": "item", "key": "hand:x", "ref": {"tab": "dg", "key": "D1"}}, + {"kind": "item", "key": "earth:5", "ref": None}, + ]}, + "sg": [{"key": "S1", "lines": [ + {"kind": "machine", "ref": {"key": "M1"}, "qty": 0.0066}, + {"kind": "machine", "ref": {"key": "M2"}, "qty": 0.002}, + {"kind": "labor", "ref": {"key": "L1"}, "qty": 0.01}, + ]}], + "daega_blocks": [{"key": "D1", "lines": [ + {"kind": "labor", "ref": {"key": "L1"}, "qty": 0.5}, + {"kind": "sangeun", "ref": {"key": "S1"}, "qty": 2}, + {"kind": "labor", "ref": {"key": "L2"}, "qty": 1.0}, + ]}], + "machine": [{"key": "M1", "name": "굴착기", "spec": "0.7㎥"}], + } # fmt: skip + units = unit_resources(sheets) + assert units["earth:4"]["machine"] == {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066} + assert units["earth:4"]["labor"] == 0.01 + # 겹 호표 = 끝까지(산근 2 단위 → 굴착기 0.0132 시간 · 인 0.5 + 0.02 + 1.0) + assert round(units["hand:x"]["machine"]["hr"], 6) == 0.0132 + assert round(units["hand:x"]["labor"], 6) == 1.52 + assert "earth:5" not in units + got = schedule_inputs(sheets, __import__("datetime").date(2026, 10, 11)) + assert got["start"] == {"year": 2027, "month": 3} and "2027" in got["holidays"] + + +def test_본_코드_훅_지워도_돌게(): + store = (ROOT / "B09_Estimation/B09_Estimation_Store.py").read_text("utf-8") + router = (ROOT / "B09_Estimation/B09_Estimation_Router.py").read_text("utf-8") + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "except ImportError" in store and "except ImportError" in router + assert 'import.meta.glob("./draft/*_UI.ts", { eager: true })' in shell diff --git a/resources/tester/test_253_1_haul_rows.py b/resources/tester/test_253_1_haul_rows.py new file mode 100644 index 000000000..574e91208 --- /dev/null +++ b/resources/tester/test_253_1_haul_rows.py @@ -0,0 +1,60 @@ +"""253-1 거리이정표 행 1:1 → B09 운반공 줄 — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}`.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from B08_Quantity.B08_Quantity_Haul import haul_rows, route_bill_key # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 +from B09_Estimation.B09_Estimation_Router import haul_user # noqa: E402 + +LOGIC = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {}} +DATA = { + "site": {"mid_station_m": 2000, "mid_road": "비포장"}, + "base": {"enabled": False, "legs": []}, + "routes": [ + {"id": "h-sand", "name": "골재상", "haul": "sand", "via_base": False, + "legs": [{"road": "포장", "km": 25.1}], "rows": [{"item": "모래", "dest": "start"}]}, + {"id": "u1", "name": "석산", "ref": LOGIC, "via_base": False, + "legs": [{"road": "포장", "km": 10}, {"road": "비포장", "km": 0.8}], + "rows": [{"item": "혼합골재", "dest": "start"}, {"item": "깬돌", "dest": "mid"}]}, + ], +} # fmt: skip + + +def test_keys(): + assert route_bill_key({"id": "h-sand", "haul": "sand"}) == "haul:3" + assert route_bill_key({"id": "h-x", "haul": "remicon"}) == "haul:remicon" + assert route_bill_key({"id": "u1"}) == "haul:r-u1" + assert route_bill_key({"id": "u1"}, 1) == "haul:r-u1.1" + + +def test_rows_one_per_table_row(): + rows = haul_rows(DATA, compute_milestone(DATA, None)) + got = {r["key"]: r for r in rows} + assert list(got) == ["haul:3", "haul:r-u1", "haul:r-u1.1"] + assert (got["haul:3"]["L1"], got["haul:3"]["L2"], got["haul:3"]["user"]) == (25.1, 0, False) + assert (got["haul:r-u1"]["name"], got["haul:r-u1"]["L1"], got["haul:r-u1"]["L2"]) == ( + "혼합골재", 10, 0.8) # fmt: skip 261-4 항목명 그대로 + assert got["haul:r-u1.1"]["L2"] == 2.8 # 중점 = 시점 → 중점 2 km 비포장 더함 + assert got["haul:r-u1"]["ref"] == LOGIC + + +def test_bill_items_user_rows(): + rows = haul_rows(DATA, compute_milestone(DATA, None)) + extra = haul_user(rows) + items = {i["key"]: i for i in haul_items(None, extra["haul_refs"], rows)} + assert ( + "haul:3" in items and items["haul:3"]["name"] == "모래" + ) # 자동 = 상자 항목명(261-4) + user = items["haul:r-u1"] + assert (user["section"], user["name"], user["qty"], user["ref"]) == ( + "3", + "혼합골재", + None, + LOGIC, + ) + assert items["haul:r-u1.1"]["name"] == "깬돌" and items["haul:r-u1.1"]["ref"] == LOGIC + assert extra["haul_dist"]["haul:r-u1"] == {"L1": 10, "L2": 0.8} diff --git a/resources/tester/test_253_2_haul_cycle.py b/resources/tester/test_253_2_haul_cycle.py new file mode 100644 index 000000000..59bac4dcb --- /dev/null +++ b/resources/tester/test_253_2_haul_cycle.py @@ -0,0 +1,114 @@ +"""253-2 운반 사이클시간 짝 — 거창 25 설계서 단가산출 1308~1347행 재현 · Py ↔ TS 거울 · +로직 입력 이름 채움 · B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time, fill_given # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import hand_sources # noqa: E402 +from B09_Estimation.B09_Estimation_Store import ref_rows # noqa: E402 + +XLSX = next( + (ROOT / "resources/knowledge/original/실무문서").glob( + "01.산불거창*/01.내역서/00.25년_산불_거창*내역+수량.xlsx" + ), + None, +) +#: 칸 → 입력 이름(단가산출 시트 · 32.혼합골재 운반(덤프15톤)) +CELLS = {"S1318": "L1", "AR1318": "L2", "R1320": "V1", "R1321": "V2", "AQ1320": "V3", + "AQ1321": "V4", "K1323": "T", "AB1323": "rt", "Q1325": "f", "L1327": "qs", + "Z1327": "k", "AL1327": "Es", "BA1327": "Cms", "L1337": "t3", "X1337": "t4", + "AK1337": "t5", "AW1337": "t6"} # fmt: skip + + +@pytest.mark.skipif(XLSX is None, reason="거창 설계서 없음") +def test_거창_혼합골재_운반_재현(): + import openpyxl + + ws = openpyxl.load_workbook(XLSX, read_only=False)["단가산출"] + inputs = {} + for cell, name in CELLS.items(): + text = str(ws[cell].value) # =TEXT(25.1,"#,##0.#######") + inputs[name] = float(re.match(r"=TEXT\(([\d.]+),", text).group(1)) + # 식 줄이 같은 꼴인지(t2 · Cm) + assert ( + "(S1318)/(R1320)+(S1318)/(R1321)+(AR1318)/(AQ1320)+(AR1318)/(AQ1321)" in ws["BX1334"].value + ) + assert "BQ1331+BX1334+L1337+X1337+AK1337+AW1337" in ws["CE1339"].value + assert (inputs["L1"], inputs["L2"]) == (25.1, 0.8) + got = cycle_time(inputs) + assert (got["q"], got["n"], got["t1"]) == (8.82, 5.13, 5.33) + assert (got["t2"], got["cm"]) == (101.23, 109.78) + assert cycle_time({"L1": 25.1, "L2": 0.8}) == got # 기본값 = 거창 값 + + +CASES = [ + {"L1": 25.1, "L2": 0.8}, + {"L1": 3.333, "L2": 1.257, "V1": 20, "V2": 25, "V3": 7, "V4": 8, "t5": 3.77}, + {"L1": 0, "L2": 0, "t1": 2.5, "V3": 0}, + {"L1": 12.5, "L2": 4.05, "T": 24, "rt": 1.8, "f": 0.8, "qs": 1.2, "k": 0.9, "Es": 0.75}, +] + + +def test_거울_ts(): + calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts" + body = ( + f"\nconst cases = {json.dumps(CASES)};" + "\nconsole.log(JSON.stringify(cases.map((c) => cycleTime(c))));" + "\nconst ref = {given: {qt: 1, Cm: 9}, cycle: {L1: 99, t1: 7}, own: ['L1']};" + "\nconst got = fillGiven(ref, ['Cm', 't2', '운반거리'], {L1: 25.1, L2: 0.8});" + "\nconsole.log(JSON.stringify(got));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + ts, ts_fill = (json.loads(line) for line in result.stdout.strip().splitlines()) + assert ts == [cycle_time(c) for c in CASES] + ref = {"given": {"qt": 1, "Cm": 9}, "cycle": {"L1": 99, "t1": 7}, "own": ["L1"]} + assert ts_fill == fill_given(ref, ["Cm", "t2", "운반거리"], {"L1": 25.1, "L2": 0.8}) + + +def test_입력_이름_채움(): + ref = {"given": {"qt": 15}, "cycle": {}} + got = fill_given(ref, ["qt", "Cm", "지역"], {"L1": 25.1, "L2": 0.8}) + assert got == {"qt": 15, "Cm": 109.78} + assert fill_given(ref, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"qt": 15, "운반거리": 25.9} + assert fill_given(ref, ["사이클시간", "t2"], {"L1": 25.1, "L2": 0.8}) == { + "qt": 15, "사이클시간": 109.78, "t2": 101.23} # fmt: skip + # 덮은 칸 = 그 값 · 덮은 L1 = 거리 대신 사이클 값 + own = {"given": {"Cm": 50}, "own": ["Cm"]} + assert fill_given(own, ["Cm"], {"L1": 25.1, "L2": 0.8}) == {"Cm": 50} + mine = {"given": {}, "cycle": {"L1": 10}, "own": ["L1"]} + assert fill_given(mine, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"운반거리": 10.8} + + +def test_서버_셈_거리로_다시_채움(): + ref = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {"Cm": 1}, "cycle": {}} + user = {"haul_refs": {"haul:r-u1": ref}, "haul_dist": {"haul:r-u1": {"L1": 25.1, "L2": 0.8}}} + ((_key, _use, src),) = hand_sources(user) + assert src["given"]["Cm"] == 109.78 + user["haul_dist"]["haul:r-u1"] = {"L1": 10, "L2": 0} # 거리 바뀌면 따라감 + ((_key, _use, src),) = hand_sources(user) + assert src["given"]["Cm"] == 45.69 and "운반거리" not in src["given"] # 그 로직 입력만 + + +def test_덮개_ref_사이클_남김(): + from B09_Estimation.B09_Estimation_Store import _clean_ref + + ref = {"tab": "logic", "code": "GF000134", "given": {"Cm": 3}, "cycle": {"V1": 20, "x": "a"}, + "own": ["Cm", 3]} # fmt: skip + got = _clean_ref(ref) + assert got["cycle"] == {"V1": 20} and got["own"] == ["Cm"] + rows = ref_rows({"bill": {"rows": [{"key": "haul:r-u1", "ref": got}]}}) + assert rows[0]["ref"]["cycle"] == {"V1": 20} diff --git a/resources/tester/test_253_3_haul_preview.py b/resources/tester/test_253_3_haul_preview.py new file mode 100644 index 000000000..159563ee9 --- /dev/null +++ b/resources/tester/test_253_3_haul_preview.py @@ -0,0 +1,62 @@ +"""253-3 미리보기 길 `POST …/estimation/logic-preview` + 「운반 단가」 모달 — 거리이정표 우클릭 · +설계내역서 운반공 줄에서 엶.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 +from B09_Estimation.B09_Estimation_Router import PreviewBody, logic_preview, router # noqa: E402 + + +def _src(name: str) -> str: + return (ROOT / name).read_text(encoding="utf-8") + + +def test_미리보기_계산_절차(tmp_path): + cm = cycle_time({"L1": 25.1, "L2": 0.8})["cm"] + given = {"qt": 15, "f": 1, "E": 0.9, "Cm": cm, "지역": "전국평균"} + got = logic_preview(tmp_path, PreviewBody(code="GF000134", given=given)) + assert got["unit"] == "㎥" and got["price"] and got["price"] > 0 + assert "Cm : 109.78 분" in got["lines"] + assert any("60 * 15 * 1 * 0.9 / 109.78" in line for line in got["lines"]) + assert got["lines"][-1].startswith("계 :") + # 거리 늘면 단가도 늘음 + far = logic_preview(tmp_path, PreviewBody(code="GF000134", given={**given, "Cm": 200})) + assert far["price"] > got["price"] + + +def test_미리보기_길(): + paths = {(r.path, tuple(r.methods)) for r in router.routes} + assert ("/api/projects/{project_id}/estimation/logic-preview", ("POST",)) in paths + + +def test_내역_줄_거리(): + one = {"ref": None} + routes = [ + {**one, "key": "haul:3", "name": "모래운반", "user": False, "L1": 25.1, "L2": 0.8}, + {**one, "key": "haul:r-u1", "name": "깬돌운반", "user": True, "L1": 3, "L2": 1}, + ] + items = {i["key"]: i for i in haul_items(None, {}, routes)} + assert items["haul:3"]["dist"] == {"L1": 25.1, "L2": 0.8} + assert items["haul:r-u1"]["dist"] == {"L1": 3, "L2": 1} + assert items["haul:1"]["dist"] is None + + +def test_모달_여는_곳(): + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + for want in ("/estimation/logic-preview", "openPriceLink", "fetchLogicInputs", "fillGiven", + "cycleInputs", "my !== seq", "자동계산", "[고르기]"): # fmt: skip + assert want in modal, want + milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert "openHaulModal({" in milestone and "L1: paved" in milestone + link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts") + assert '[["단가연결", haul && ref ? haulModal : priceLink]]' in link # 276 연결 있는 줄만 + edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") + assert ( + 'key.startsWith("haul:")' in edit + and "linkItems(ctx, ref, applyRef(ctx, rows, i), haul)" in edit + ) diff --git a/resources/tester/test_254_1_b08_milestone_bases.py b/resources/tester/test_254_1_b08_milestone_bases.py new file mode 100644 index 000000000..5cccd62e7 --- /dev/null +++ b/resources/tester/test_254_1_b08_milestone_bases.py @@ -0,0 +1,213 @@ +"""254-1 B08 거리이정표 여러 기준점(사용자 162-10) — `bases[]` · 경로 `to` · 옛 꼴 옮김 · +기준점 사슬 셈(파이썬 · TS 거울) · 돌기 막음 · +도로 구분(시점에 닿는 기준점 구간 = 1차 · 그 밖 = 2차). + +견본 사슬 = 거창군청 → 기준점 A(병곡리) → 기준점 B(신원) → 시점 · 김천역 → B → 시점 · +철근 상회 → 시점(직접). +""" + +import json +import re +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Haul import with_haul_routes # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + base_chain, + compute_milestone, + migrate_milestone, +) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(files, expr): + src = "\n".join(IMPORTS.sub("", _read(f)) for f in files) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _leg(road, km): + return {"road": road, "km": km} + + +def _base(bid, to, legs, linked=True): + return {"id": bid, "name": bid, "address": "", "dir": None, "legs": legs, "to": to, + "linked": linked} # fmt: skip + + +def _route(rid, to, legs, dest="start"): + return {"id": rid, "name": rid, "address": "", "dir": None, "to": to, "legs": legs, + "rows": [{"item": "장비", "dest": dest, "note": ""}]} # fmt: skip + + +CHAIN = { + "site": {"mid_station_m": 800, "mid_road": "비포장"}, + "bases": [ + _base("A", "B", [_leg(None, 4.5), _leg("비포장", 0.3)]), + _base("B", "site", [_leg(None, 0.6)]), + ], + "routes": [ + _route("g", "A", [_leg(None, 23.8)]), + _route("k", "B", [_leg(None, 61.2)], dest="mid"), + _route("c", "site", [_leg(None, 2.0)]), + ], +} + +OLD = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "address": "주소", "dir": "E", "lon": 127.85, + "lat": 35.66, "pos": [10, 20], "legs": [_leg("1차포장", 0.6)]}, + "routes": [ + {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "legs": [_leg("2차포장", 23.8)], "rows": [{"item": "장비", "dest": "start", "note": ""}]}, + {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None, + "legs": [_leg(None, 5)], "rows": [{"item": "철근", "dest": "start", "note": ""}]}, + ], +} # fmt: skip + + +def test_옛_꼴_옮김_거울(): + new = migrate_milestone(OLD) + assert "base" not in new and [b["id"] for b in new["bases"]] == ["b1"] + b1 = new["bases"][0] + assert (b1["to"], b1["linked"], b1["name"], b1["pos"]) == ("site", True, "병곡리", [10, 20]) + assert b1["legs"] == [_leg("1차포장", 0.6)] and b1["lon"] == 127.85 + assert [(r["id"], r["to"], "via_base" in r) for r in new["routes"]] == [ + ("r1", "b1", False), + ("r2", "site", False), + ] + # 꺼진 기준점 = bases 없음 · 거침 경로도 시점으로 + off = migrate_milestone({**OLD, "base": {**OLD["base"], "enabled": False}}) + assert off["bases"] == [] and [r["to"] for r in off["routes"]] == ["site", "site"] + assert migrate_milestone(new) == new # 새 꼴 = 그대로 + # TS 짝 — 옮긴 꼴 · 표 합이 같음 + calc = [B08 + "Milestone_Calc.ts"] + ts = _node(calc, f"migrateMilestone({json.dumps(OLD)})") + assert ts["bases"][0]["id"] == "b1" and [r["to"] for r in ts["routes"]] == ["b1", "site"] + assert ts["bases"][0]["legs"] == b1["legs"] and ts["bases"][0]["pos"] == [10, 20] + assert _node(calc, f"computeMilestone({json.dumps(OLD)}, 1500)") == compute_milestone( + OLD, 1500.0 + ) + # 옛 표 합 그대로 — 2차 23.8 + 1차 0.6 + (a, b) = compute_milestone(OLD, 1500.0) + assert (a["s2"], a["s1"], a["total"]) == (23.8, 0.6, 24.4) and (b["pv"], b["total"]) == (5, 5) + + +def test_서버_읽기_받기_새_꼴(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + # 옛 파일 읽기 = 새 꼴(지우지 않음 · 저장 때 새 꼴) + folder = tmp_path / "B08_Quantity" / "milestone" + folder.mkdir(parents=True) + (folder / "milestone.json").write_text(json.dumps(OLD, ensure_ascii=False), "utf-8") + data = ms.read_data(tmp_path) + assert [b["id"] for b in data["bases"]] == ["b1"] and data["routes"][0]["to"] == "b1" + assert "base" not in data and "via_base" not in data["routes"][0] + + async def root(_pid): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + url = f"/api/projects/{uuid4()}/quantity/milestone" + saved = client.put(url, json=OLD).json() # 옛 꼴 받기 = 새 꼴로 저장 + stored = json.loads((folder / "milestone.json").read_text("utf-8")) + assert "base" not in stored and stored["bases"][0]["id"] == "b1" + assert stored["routes"][0]["to"] == "b1" and "via_base" not in stored["routes"][0] + assert saved["table"] == compute_milestone(OLD, None) + # 새 꼴 사슬 왕복 · 겹친 id · 공사지 상자 id = 거절 + assert client.put(url, json=CHAIN).json()["data"]["bases"][0]["to"] == "B" + twice = {**CHAIN, "bases": [*CHAIN["bases"], _base("g", "site", [])]} + assert client.put(url, json=twice).status_code == 422 + box = {**CHAIN, "bases": [_base("site", "site", [])]} + assert client.put(url, json=box).status_code == 422 + # 운반 자동 상자 = 시점으로(안 이음) + (auto,) = with_haul_routes({"routes": []}, [{"key": "1", "name": "골재"}])["routes"] + assert auto["to"] == "site" and auto["linked"] is False and "via_base" not in auto + + +def test_기준점_둘_사슬_셈_거울(): + g, k, c = compute_milestone(CHAIN, None) + # 거래처 → A = 2차 · A → B(기준점 → 기준점) = 2차 · B → 시점 = 1차 · 비포장은 사용자 값 + assert (g["s2"], g["s1"], g["pv"], g["un"]) == (28.3, 0.6, 0, 0.3) + assert (g["paved"], g["unpaved"], g["total"]) == (28.9, 0.3, 29.2) + # 김천역 → B = 2차 · B → 시점 = 1차 · 목적지 중점 = 시점 → 중점 0.8 비포장 + assert (k["s2"], k["s1"], k["un"], k["total"]) == (61.2, 0.6, 0.8, 62.6) + # 시점으로 바로 = 포장 + assert (c["s2"], c["s1"], c["pv"], c["total"]) == (0, 0, 2.0, 2.0) + calc = [B08 + "Milestone_Calc.ts"] + assert _node(calc, f"computeMilestone({json.dumps(CHAIN)}, null)") == [g, k, c] + # 안 이은 기준점에서 사슬 멈춤(그 구간 · 뒤 구간 안 셈) + cut = {**CHAIN, "bases": [CHAIN["bases"][0], {**CHAIN["bases"][1], "linked": False}]} + g2, k2, _ = compute_milestone(cut, None) + assert (g2["s2"], g2["s1"], g2["un"], g2["total"]) == (28.3, 0, 0.3, 28.6) + assert (k2["s2"], k2["total"]) == (61.2, 62.0) + assert _node(calc, f"computeMilestone({json.dumps(cut)}, null)") == compute_milestone(cut, None) + # 선 자리 — 시점에 닿는 기준점 = base(1차) · 기준점 → 기준점 · 거래처 → 기준점 = via(2차) + kinds = _node( + calc, f"['g', 'A', 'B', 'c', 'start'].map((f) => segKind({json.dumps(CHAIN)}, f))" + ) + assert kinds == ["via", "via", "base", "direct", "mid"] + + +def test_돌기_막음(): + loop = {**CHAIN, "bases": [_base("A", "B", [_leg(None, 1)]), _base("B", "A", [_leg(None, 2)])]} + # 셈 = 방문 집합으로 끊음(A → B → A 멈춤 · 끝없이 안 돎) + assert [b["id"] for b in base_chain(loop, "A")] == ["A", "B"] + (g, *_) = compute_milestone(loop, None) + assert g["total"] == 26.8 + calc = [B08 + "Milestone_Calc.ts"] + assert _node(calc, f"computeMilestone({json.dumps(loop)}, null)") == compute_milestone( + loop, None + ) + self_loop = {**CHAIN, "bases": [_base("A", "A", [_leg(None, 1)])]} + assert [b["id"] for b in base_chain(self_loop, "A")] == ["A"] + # 연결 놓을 때 = 돌기면 취소(makesLoop) — A → B 사슬에서 B 를 A 에 이으면 돎 + layout = [B08 + "Milestone_Layout.ts"] + d = json.dumps(CHAIN) + got = _node( + layout, + f"[makesLoop({d}, 'B', 'A'), makesLoop({d}, 'A', 'B'), makesLoop({d}, 'B', 'site')," + f" makesLoop({d}, 'A', 'A'), chainOf({d}, 'A').map((b) => b.id)," + f" kmSegments({d}, 'g', [127.8, 35.6]).map((s) => s.text)]", + ) + assert got[:4] == [True, False, False, True] + assert got[4] == ["A", "B"] and got[5] == ["g → A", "A → B", "B → 시점"] + + +def test_화면_기준점_여럿_등록_삭제(): + modal = _read(B08 + "UI_Milestone_Modal.ts") + assert "fresh.bases.push({" in modal and 'to: "site",' in modal + assert "기준위치(있음" not in modal # 254 기준점 여럿 — 고르기 막음 없음 + # 지운 기준점에 이은 상자 = 안 이음 + assert "if (o.to === base.id)" in modal + state = _read(B08 + "UI_Milestone_State.ts") + assert "migrateMilestone(" in state and "bases: []," in state + page = _read(B08 + "UI_Milestone.ts") + assert "data.bases.length > 0" in page and 'to: "site",' in page + for name in ("Milestone_Calc.ts", "Milestone_Layout.ts", "UI_Milestone_Diagram.ts", + "UI_Milestone_Modal.ts", "UI_Milestone_State.ts", "UI_Milestone_LegModal.ts", + "UI_Milestone_Route.ts", "UI_Milestone.ts"): # fmt: skip + text = _read(B08 + name) + assert "data.base." not in text and ".via_base" not in text and ".base.legs" not in text diff --git a/resources/tester/test_254_2_b08_milestone_diagram.py b/resources/tester/test_254_2_b08_milestone_diagram.py new file mode 100644 index 000000000..73f092158 --- /dev/null +++ b/resources/tester/test_254_2_b08_milestone_diagram.py @@ -0,0 +1,129 @@ +"""254-2 B08 거리이정표 다이어그램(사용자 162-4 · 5 · 6 · 10) — 기준점 상자 연결 점 · 주소 · +연결 점 왼쪽 위 안쪽 · 항목 글 = 거래처 글 · 주소 줄바꿈 + 상자 높이 · 선 글 두 줄 · +선 길이 = 거리 비례(로그 · 최소 · 최대). +""" + +import json +import math +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" +LONG = "경상남도 거창군 북상면 병곡리 968-31" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = IMPORTS.sub("", _read(B08 + "Milestone_Layout.ts")) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-"], + input=src + f"\nconsole.log(JSON.stringify({expr}));", + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _leg(km): + return {"road": None, "km": km} + + +def _route(rid, dir, km, to="site", address=""): + return {"id": rid, "name": rid, "address": address, "dir": dir, "to": to, + "legs": [_leg(km)] if km is not None else [], + "rows": [{"item": "장비", "dest": "start", "note": ""}]} # fmt: skip + + +def _base(bid, to, km, address="", lon=None, lat=None): + return {"id": bid, "name": bid, "address": address, "dir": None, "to": to, "lon": lon, + "lat": lat, "legs": [_leg(km)], "linked": True} # fmt: skip + + +def _data(bases, routes): + return {"site": {"mid_station_m": None, "mid_road": "비포장"}, "bases": bases, "routes": routes} + + +def _lay(data): + return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, null, 0.5)") + + +def _boxes(lay): + return {b["id"]: b for b in lay["boxes"]} + + +def test_선_길이_거리_비례_최소_최대(): + kms = [0, 1, 10, 23.8, 100, 1e6, -3] + got = _node(f"{json.dumps(kms)}.map(lineLen)") + want = [min(320, max(100, 100 + 90 * math.log10(1 + max(0, k)))) for k in kms] + assert all(abs(a - b) < 1e-9 for a, b in zip(got, want)) + assert got[0] == 100 and got[5] == 320 and got[6] == 100 # 최소 · 최대(너무 멀지 않게) + assert got[0] < got[1] < got[2] < got[3] < got[4] + # 동쪽 거래처 셋 — 공사지 테두리 → 상자 테두리 = 그 구간 선 길이 + lay = _lay(_data([], [_route("a", "E", 1), _route("b", "S", 60), _route("c", "W", None)])) + boxes = _boxes(lay) + site = boxes["site"] + a, b, c = boxes["a"], boxes["b"], boxes["c"] + assert abs((a["x"]) - (site["x"] + site["w"]) - got[1]) < 1e-6 + assert abs(b["y"] - (site["y"] + site["h"]) - _node("lineLen(60)")) < 1e-6 + assert abs(site["x"] - (c["x"] + c["w"]) - 100) < 1e-6 # 빈 km = 최소 + + +def test_기준점_사슬_자리_주소_높이(): + bases = [_base("A", "B", 4.5, address=LONG), _base("B", "site", 0.6, address="거창읍")] + lay = _lay(_data(bases, [_route("g", "N", 23.8, to="A")])) + boxes = _boxes(lay) + a, b, g = boxes["A"], boxes["B"], boxes["g"] + assert (a["kind"], b["kind"]) == ("base", "base") + # 기준점 상자 = 주소(줄바꿈) · 높이 = 이름 + 주소 줄 + assert a["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"] and a["tip"] == LONG + assert a["h"] == 3 * 15 + 13 and b["notes"] == ["거창읍"] and b["h"] == 58 + # B = 공사지 서쪽(좌표 없음) · A = B 둘레(B 에 이음) · g = A 북쪽 + site = boxes["site"] + assert b["x"] + b["w"] < site["x"] and a["x"] + a["w"] < b["x"] + b["w"] + assert g["y"] + g["h"] < a["y"] + lines = {(ln["from"], ln["to"]) for ln in lay["lines"]} + assert {("g", "A"), ("A", "B"), ("B", "start"), ("start", "mid")} == lines + # 아주 긴 주소 = 최대 3줄 · 끝 줄 줄임표 + many = " ".join([LONG] * 4) + rows = _node(f'wrap("{many}", 132, 10)') + assert len(rows) == 3 and rows[-1].endswith("…") + assert all(_node(f'textW("{r}", 10)') <= 132 for r in rows) + + +def test_선_글_두_줄(): + lay = _lay(_data([_base("B", "site", 0.6)], [_route("g", "N", 23.8, to="B")])) + line = next(ln for ln in lay["lines"] if ln["from"] == "B") + assert line["labels"] == ["1차포장\n0.6km"] or line["labels"] == ["도로 구분 고르기\n0.6km"] + assert line["lh"] == 2 * 16 + 2 # 두 줄 높이 + two = _node("legText([{road: '1차포장', km: 0.6}, {road: '비포장', km: null}])") + assert two == ["1차포장\n0.6km", "비포장\n-km"] + ui = _read(B08 + "UI_Milestone_Diagram.ts") + assert 'for (const row of label.split("\\n"))' in ui # 줄마다 tspan + assert "span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;" in ui # 두 줄 다 그 구간 + + +def test_연결_점_왼쪽_위_안쪽_기준점도_돌기_취소(): + ui = _read(B08 + "UI_Milestone_Diagram.ts") + dot = ui[ui.index("const connectDot") :] + dot = dot[: dot.index("return dot;")] + # 왼쪽 위 안쪽 — 가운데 (10, 10) · 반지름 6 + 테두리 2 = 상자 안 + assert "const cx = box.x + 10;" in dot and "const cy = box.y + 10;" in dot and "r: 6" in dot + assert ( + 'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui + ) + assert 'if (box.kind === "base" && makesLoop(fresh, box.id, t))' in dot + assert "if (id === box.id) return null;" in dot # 자기 = 취소 + assert "route.to = t;" in dot and "route.linked = true;" in dot + css = _read(B08 + "UI_Milestone.css") + items = css[css.index(".b08-milestone__items {") :] + assert "font-size: 13px;" in items[: items.index("}")] # 항목 = 거래처 글 + assert "items: 13" in _read(B08 + "Milestone_Layout.ts") + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"): + assert len(_read(B08 + name).splitlines()) <= 700, name diff --git a/resources/tester/test_254_3_b08_milestone_box_modal_pic.py b/resources/tester/test_254_3_b08_milestone_box_modal_pic.py new file mode 100644 index 000000000..0a78098f0 --- /dev/null +++ b/resources/tester/test_254_3_b08_milestone_box_modal_pic.py @@ -0,0 +1,81 @@ +"""254-3 B08 거리이정표 상자 모달 제목 · 장소 그림(사용자 162-6 · 8). + +① 상자 누름 모달 제목 = 이름만(262-1 · 「—」 앞 글자 없음 · 표 머리 글 「항목 정보」 없음) +② 장소 그림(256-1 `place_images`) = 상자 안 글 아래 작게 · 그림 없으면 자리 없음(상자 높이 그대로) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = IMPORTS.sub("", _read(B08 + "Milestone_Layout.ts")) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-"], + input=src + f"\nconsole.log(JSON.stringify({expr}));", + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +DATA = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "bases": [{"id": "B", "name": "병곡리", "address": "거창읍", "dir": None, "to": "site", + "legs": [{"road": None, "km": 0.6}], "linked": True}], + "routes": [ + {"id": "g", "name": "거창군청", "address": "거창읍", "dir": "N", "to": "B", + "legs": [{"road": None, "km": 23.8}], + "rows": [{"item": "장비", "dest": "start", "note": ""}]}, + {"id": "k", "name": "김천역", "address": "", "dir": "E", "to": "site", + "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]}, + ], +} # fmt: skip + + +def test_장소_그림_상자_안_글_아래(): + d = json.dumps(DATA, ensure_ascii=False) + plain, with_pic = _node( + f"[layoutMilestone({d}, null, 0.5), layoutMilestone({d}, null, 0.5, new Set(['B', 'g']))]" + ) + a = {b["id"]: b for b in plain["boxes"]} + b = {b["id"]: b for b in with_pic["boxes"]} + pic = _node("PIC") + assert pic == {"w": 132, "h": 88} # 서버 480 × 320 과 같은 3:2 · 상자 안쪽 폭 + # 그림 없음 = 자리 없음(높이 그대로 · pic 없음) + assert all("pic" not in box for box in a.values()) + assert "pic" not in b["k"] and b["k"]["h"] == a["k"]["h"] + for bid in ("B", "g"): + box, before = b[bid], a[bid] + assert box["h"] == before["h"] + pic["h"] + 8 # 그림 + 아래 여백만큼 늘림 + p = box["pic"] + # 안쪽 여백 안 · 글 아래(글 자리 = 그림 위 높이 = 그림 없을 때 높이) + assert p["x"] == box["x"] + 8 and p["x"] + p["w"] == box["x"] + box["w"] - 8 + assert p["y"] - box["y"] == before["h"] and p["y"] + p["h"] == box["y"] + box["h"] - 8 + + +def test_모달_제목_화면_그림(): + modal = _read(B08 + "UI_Milestone_Modal.ts") + assert 'route!.name || "거래처"' in modal and 'base.name || "기준점"' in modal # 262-1 + assert "항목 정보 —" not in modal + ui = _read(B08 + "UI_Milestone_Diagram.ts") + assert "placeImageBlobUrl(projectId, id)" in ui and "placeImageKey(id)" in ui + assert 'pic.setAttribute("href", url)' in ui and "() => pic.remove()" in ui + assert "if (!got || got.key !== key)" in ui # 키(이름#시각) 바뀔 때만 다시 받음 + assert "const th = box.pic ? box.pic.y - box.y : box.h;" in ui # 글 = 그림 위 가운데 + css = _read(B08 + "UI_Milestone.css") + pic = css[css.index(".b08-milestone__pic {") :] + assert "pointer-events: none;" in pic[: pic.index("}")] # 누름 · 끌기 = 상자 몫 + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_Modal.ts"): + assert len(_read(B08 + name).splitlines()) <= 700, name diff --git a/resources/tester/test_255_1_b08_milestone_site_modal.py b/resources/tester/test_255_1_b08_milestone_site_modal.py new file mode 100644 index 000000000..51ce9d5b0 --- /dev/null +++ b/resources/tester/test_255_1_b08_milestone_site_modal.py @@ -0,0 +1,51 @@ +"""255-1 거리이정표 시점 모달 — 중점 측점 km 로 보이고 받음(저장은 m) · 설명 줄 지움 · +지도 = 공용 카카오 지도(시점 · 중점 표지).""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _site(km): + data = {"site": {"mid_station_m": 999, "mid_road": "비포장"}, "base": {}, "routes": []} + return _node(f'setLeg({json.dumps(data)}, "start", 0, {{ km: {json.dumps(km)} }}).site') + + +def test_중점_km_저장_m(): + assert _site(1.2345)["mid_station_m"] == 1234.5 + assert _site(0.0005)["mid_station_m"] == 0.5 + assert _site(None)["mid_station_m"] is None # 빈칸 = 노선 ÷ 2 + + +def test_시점_모달_km_카카오_지도(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + site = modal[modal.index("export function openSiteInfo") :] + assert 'label: "중점 측점 (km)"' in site + assert "fmtKm(data.site.mid_station_m / 1000)" in site # 보임 = km(261-1 끝수 정리) + assert 'setLeg(fresh, "start", 0, {' in site # 받음 km → 저장 m + # 설명 줄 · 등록 주소 · 브이월드 주소 줄 · B01 타일 지도 없음 + for gone in ("시점 = 노선 첫 점", "프로젝트 등록 주소", "브이월드 주소", "buildAddressMap"): + assert gone not in modal + assert "editMap(projectId" in site + assert '["start", "시점", s.start_lonlat]' in site and '["mid", "중점", s.mid_lonlat]' in site + assert 'dialogClass: "b08-milestone__modal"' in site # 다른 모달과 같은 창 · 지도 높이 diff --git a/resources/tester/test_255_2_b08_milestone_start_pin.py b/resources/tester/test_255_2_b08_milestone_start_pin.py new file mode 100644 index 000000000..bf149c7e4 --- /dev/null +++ b/resources/tester/test_255_2_b08_milestone_start_pin.py @@ -0,0 +1,119 @@ +"""255-2 거리이정표 경로 탭 — 시점 끌기 = 길찾기용 덮개(site.start_pin · 노선 그대로) · +표지 옮기면 그 자리에 닿는 구간(나가는 · 들어오는) 모두 다시 길찾기 · 열 때 사슬 구간 모두 선 · +실패 안내 · 끌기는 그림 캡처 안 부름.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from pydantic import ValidationError + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +STUBS = """ +let cache = null; +const sent = []; +const API_BASE_URL = ""; +const showToast = () => undefined; +const readState = () => cache; +const writeState = (_key, d) => { cache = d; }; +const clearState = () => { cache = null; }; +const request = async (url, opts) => { + if (url.endsWith("/quantity/milestone")) + return { data: DATA, site: { length_m: 1000, mid_default_m: 500, start_lonlat: [127, 35], + mid_lonlat: null }, images: {}, auto_images: {} }; + sent.push(JSON.parse(opts.body)); + return { km: 1.5, reason: null, path: [] }; +}; +""" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _place(pid, to, lon, lat, **more): + return {"id": pid, "name": pid, "address": "", "dir": None, "lon": lon, "lat": lat, + "legs": [], "to": to, **more} # fmt: skip + + +DATA = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "bases": [_place("b1", "site", 127.5, 35.5), _place("b2", "b1", 127.6, 35.6)], + "routes": [ + _place("r1", "site", 127.2, 35.2, rows=[]), + _place("r2", "b1", 127.3, 35.3, rows=[]), + _place("r3", "b2", 127.4, 35.4, rows=[]), + _place("r4", "b1", 127.1, 35.1, rows=[], linked=False), + ], +} + + +def _run(script): + src = "".join( + IMPORTS.sub("", _read(f"B08_Quantity/B08_Quantity_{n}.ts")) + for n in ("Milestone_Calc", "Milestone_Layout", "UI_Milestone_State") + ) + code = f"const DATA = {json.dumps(DATA)};\n{STUBS}\n{src}\n{script}" + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=code, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_시점_덮개_길찾기(): + got = _run(""" +await loadMilestone("p"); +await findSegmentKm("p", "r1", false); +const d = liveMilestone("p"); +d.site.start_pin = { lon: 128.25, lat: 36.5 }; +putMilestone("p", d); +await findSegmentKm("p", "r1", false); +await findSegmentKm("p", "b1", false); +console.log(JSON.stringify({ sent, start: startPoint(liveMilestone("p")) })); +""") + dest = [s["destination"] for s in got["sent"]] + assert dest[0] == {"lon": 127, "lat": 35} # 덮개 없음 = 노선 첫 점 + assert dest[1] == dest[2] == {"lon": 128.25, "lat": 36.5} # 거래처 · 기준점 → 시점 = 덮개 + assert got["start"] == [128.25, 36.5] + + +def test_닿는_구간_모두(): + got = _run(""" +console.log(JSON.stringify(["start", "b1", "b2", "r1", "r3"].map((k) => touchingSegments(DATA, k)))); +""") + start, b1, b2, r1, r3 = got + assert start == ["b1", "r1"] # 시점에 들어오는 구간 + assert b1 == ["b1", "b2", "r2"] # 나가는 구간 + 들어오는 경로들(안 이은 r4 빠짐) + assert b2 == ["b2", "r3"] + assert r1 == ["r1"] and r3 == ["r3"] + + +def test_서버_모델_시점_덮개(): + got = ms.Milestone.model_validate({"site": {"start_pin": {"lon": 127.5, "lat": 35.5}}}) + assert got.model_dump()["site"]["start_pin"] == {"lon": 127.5, "lat": 35.5} + assert ms.Milestone().model_dump()["site"]["start_pin"] is None + with pytest.raises(ValidationError): + ms.Milestone.model_validate({"site": {"start_pin": {"lon": 500, "lat": 35}}}) + + +def test_경로_탭_끌기_다시_길찾기(): + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "movable: true" in route and 'movable: key !== "start"' not in route + assert "fresh.site.start_pin = { lon, lat };" in route + assert "await refind(touchingSegments(fresh, key));" in route + assert "findSegmentKm(projectId, id, false)" in route # 끌기 · 열기 = 그림 캡처 없음 + assert "[routeId, ...chain].filter((id) => !segmentPath(id).length)" in route # 열 때 모든 구간 + assert "status.hidden = !bad.length;" in route # 실패 안내 + calc = _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts") + assert "start_pin?: { lon: number; lat: number } | null;" in calc diff --git a/resources/tester/test_255_3_b08_milestone_place_modal.py b/resources/tester/test_255_3_b08_milestone_place_modal.py new file mode 100644 index 000000000..244906939 --- /dev/null +++ b/resources/tester/test_255_3_b08_milestone_place_modal.py @@ -0,0 +1,42 @@ +"""255-3 거리이정표 거래처 · 기준점 모달 — 칸 이름 ≥ 내용 글 · 「소재지 — 주소 찾기 · 지도에서 고르기」 +줄 지움 · 기준점 이름 한 줄(빈 칸 자리 비움) · 주소 · 지도 위 가로선 · +256-1 장소 그림 = 등록 · 좌표 바뀜 · 경로 탭 끌기 뒤 capturePlace.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _rule(css, selector): + m = re.search(re.escape(selector) + r" \{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_모달_꼴(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert "주소 찾기 · 지도에서 고르기" not in modal and 'el("h4"' not in modal + assert ( + 'item.root.style.display = dest.root.style.display = k === "base" ? "none" : "";' in modal + ) + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + label = _rule(css, ".b08-milestone__modal .ui-field__label") + assert "font-size: var(--text-body-sm);" in label # 입력 글(text-body-sm)과 같음 + assert "white-space: nowrap;" in label + assert "border-top: 1px solid var(--color-border);" in _rule(css, ".b08-milestone__place-field") + place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts") + assert 'className: "b08-milestone__place-field"' in place + + +def test_장소_그림_캡처_부름(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert modal.count("segment = placed = ") == 2 # 기준점 · 거래처 좌표 바뀜 + assert "placed = addRoute?.({" in modal and "id: placed," in modal # 새 거래처 · 새 기준점 + assert "void capturePlace(projectId, placed, {" in modal + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "void capturePlace(projectId, key, { name: target.name || label, lon, lat });" in route diff --git a/resources/tester/test_256_1_b08_milestone_place_image.py b/resources/tester/test_256_1_b08_milestone_place_image.py new file mode 100644 index 000000000..63553beab --- /dev/null +++ b/resources/tester/test_256_1_b08_milestone_place_image.py @@ -0,0 +1,162 @@ +"""256-1 B08 거리이정표 장소 그림 영구 저장(사용자 162-6 · 7). + +캡처는 가짜(make_place_image 자리)로: +① 장소 그림 페이지 = 480×320 · 고정 줌 · 경로 맞춤 안 함 +② [저장] 뒤 좌표 있는 상자(거래처 = 경로 id · 기준점 = b1)만 캡처 → place/.png · + GET 답 place_images · place_times +③ 다시 [저장] = 바뀐 상자 · 그림 없는 상자만 · 실패 = 다음에 다시 · 지문에 좌표 원문 없음 +④ 지운 상자 그림 = [저장] 때 지움 · 화면 캡처 길(place-image) · 그림 파일 길(file · 경로 넘기 막음) +""" + +import sys +from pathlib import Path +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +PNG = b"\x89PNG\r\n\x1a\nfake-place" +DATA = { + "base": {"enabled": True, "name": "거창IC", "lon": 127.91, "lat": 35.68}, + "routes": [ + {"id": "r1", "name": "거창레미콘", "lon": 127.9095, "lat": 35.6866}, + {"id": "r2", "name": "좌표 없음"}, + ], +} + + +class Fake: + """가짜 캡처 — 부른 상자 기록 · ok=False 면 실패.""" + + def __init__(self): + self.calls = [] + self.ok = True + + def __call__(self, out_dir, place_id, mark): + self.calls.append((place_id, mark["name"], mark["lon"], mark["lat"])) + if not self.ok: + return {"saved": False, "reason": "장소 그림 자동 캡처 실패 — 시험"} + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / f"{place_id}.png").write_bytes(PNG) + return {"saved": True, "reason": None} + + +def _save(root, data): + return ms.save_data(root, ms.Milestone.model_validate(data)) + + +def _fake(monkeypatch): + fake = Fake() + monkeypatch.setattr(ms, "make_place_image", fake) + monkeypatch.setattr(ms, "route_image", lambda *a: {"saved": False}) # 경로 그림 = 256-2 시험 + return fake + + +def test_장소_그림_페이지_작은_지도(): + html = cap.page_html( + "k", [], [{"name": "거창레미콘", "lon": 127.9, "lat": 35.6}], cap.PLACE_VIEW + ) + assert "width:480px;height:320px" in html and "level: 4" in html + assert "if (false)" in html # 경로 맞춤 안 함 — 표지 가운데 · 고정 줌 + assert "if (true)" in cap.page_html("k", [], []) # 경로 그림은 그대로 + + +def test_저장_뒤_좌표_있는_상자만_캡처(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + assert sorted(c[0] for c in fake.calls) == ["b1", "r1"] + got = ms.payload(tmp_path, ms.read_data(tmp_path)) + assert got["place_images"] == {"b1": "place/b1.png", "r1": "place/r1.png"} + assert set(got["place_times"]) == {"b1", "r1"} + assert all(isinstance(t, int) and t > 0 for t in got["place_times"].values()) + assert (tmp_path / "B08_Quantity/milestone/place/r1.png").read_bytes() == PNG + prints = (tmp_path / "B08_Quantity/milestone/.prints.json").read_text(encoding="utf-8") + assert "127.9095" not in prints and "35.6866" not in prints # 해시만 + + +def test_바뀐_상자_그림_없는_상자만_다시(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + fake.calls.clear() + ms.refresh_images(tmp_path) + assert fake.calls == [] # 안 바뀜 = 캡처 0 + moved = {**DATA, "routes": [{**DATA["routes"][0], "lat": 35.7}, DATA["routes"][1]]} + _save(tmp_path, moved) + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["r1"] + fake.calls.clear() + (tmp_path / "B08_Quantity/milestone/place/b1.png").unlink() + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["b1"] + # 실패 = 지문 안 적음 → 다음에 다시 + fake.calls.clear() + fake.ok = False + _save(tmp_path, {**moved, "base": {**DATA["base"], "lon": 127.95}}) + ms.refresh_images(tmp_path) + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["b1", "b1"] + + +def test_지운_상자_그림은_저장_때_지움(tmp_path, monkeypatch): + _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + got = _save(tmp_path, {"routes": [DATA["routes"][1]]}) # r1 · 기준점 좌표 지움 + assert got["place_images"] == {} and got["place_times"] == {} + assert not list((tmp_path / "B08_Quantity/milestone/place").iterdir()) + + +def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + + async def root(project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.delenv("PYTEST_CURRENT_TEST") # [저장] 뒤 캡처 켬(시험 중엔 꺼 둠) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + base = f"/api/projects/{uuid4()}/quantity/milestone" + # [저장] = 답 뒤 캡처(BackgroundTasks — TestClient 는 답 전에 끝냄) + assert client.put(base, json=DATA).status_code == 200 + assert sorted(c[0] for c in fake.calls) == ["b1", "r1"] + assert client.get(base).json()["place_images"]["r1"] == "place/r1.png" + # 등록 · 끌기 뒤 화면이 부름 + fake.calls.clear() + mark = {"name": "새 거래처", "lon": 127.8, "lat": 35.5} + res = client.post(base + "/place-image", params={"id": "r9"}, json=mark).json() + assert res["name"] == "place/r9.png" and res["reason"] is None and res["time"] > 0 + assert fake.calls == [("r9", "새 거래처", 127.8, 35.5)] + fake.ok = False + bad = client.post(base + "/place-image", params={"id": "r8"}, json=mark).json() + assert bad == {"name": None, "time": None, "reason": "장소 그림 자동 캡처 실패 — 시험"} + assert client.post(base + "/place-image", params={"id": "../x"}, json=mark).status_code == 400 + # 그림 파일 길 — 하위 폴더 둘 · 경로 넘기 막음 + assert client.get(base + "/file", params={"name": "place/r1.png"}).content == PNG + assert client.get(base + "/file", params={"name": "place/zz.png"}).status_code == 404 + for name in ("../milestone.json", "place/../x.png", "x/r1.png", "place/r1.json", "/etc/x.png"): + assert client.get(base + "/file", params={"name": name}).status_code == 422, name + + +def test_여러_기준점_상자_id(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + bases = [ + {"id": "b1", "name": "거창IC", "lon": 127.91, "lat": 35.68, "to": "b2"}, + {"id": "b2", "name": "함양JC", "lon": 127.72, "lat": 35.52}, + {"id": "b3", "name": "좌표 없음"}, + ] + _save(tmp_path, {"bases": bases, "routes": [{**DATA["routes"][0], "to": "b1"}]}) + ms.refresh_images(tmp_path) + assert sorted(c[0] for c in fake.calls) == ["b1", "b2", "r1"] + got = _save(tmp_path, {"bases": bases[1:], "routes": []}) # b1 · r1 지움 + assert got["place_images"] == {"b2": "place/b2.png"} diff --git a/resources/tester/test_256_2_b08_milestone_route_image.py b/resources/tester/test_256_2_b08_milestone_route_image.py new file mode 100644 index 000000000..50dd56306 --- /dev/null +++ b/resources/tester/test_256_2_b08_milestone_route_image.py @@ -0,0 +1,140 @@ +"""256-2 B08 거리이정표 경로 그림 = 전체 경로(사용자 162-7). + +캡처 · 길찾기는 가짜로: +① 사슬 = 거래처 → 기준점 사슬(254 bases · to) → 시점 · 화면 `mapMarks` 차례 · 좌표 없는 상자 뺌 +② 구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · auto/<경로 id>.png +③ [저장] 뒤 지문 비교 — 안 바뀜 = 0 · 기준점 · 시점 옮김 = 그 사슬 경로만 · 안 이은 거래처 = 없음 · + 화면 [길찾기] 그림(거래처 구간만)이 들어오면 다음 [저장]에 전체로 +""" + +import sys +from pathlib import Path +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +PNG = b"\x89PNG\r\n\x1a\nfake-route" +START = [127.5, 35.4] +BASES = [ + {"id": "b1", "name": "거창IC", "lon": 127.91, "lat": 35.68, "to": "b2"}, + {"id": "b2", "name": "함양JC", "lon": 127.72, "lat": 35.52, "manual": True}, +] +ROUTES = [ + {"id": "r1", "name": "거창레미콘", "lon": 127.95, "lat": 35.70, "to": "b1"}, + {"id": "r2", "name": "직송", "lon": 127.60, "lat": 35.45}, + {"id": "r3", "name": "안 이음", "lon": 127.61, "lat": 35.46, "linked": False}, + {"id": "r4", "name": "좌표 없음", "to": "b2"}, +] +DATA = {"bases": BASES, "routes": ROUTES} + + +class Fake: + def __init__(self): + self.shots = [] # (경로 id, lines, marks) + self.asked = [] # 길찾기 구간 + + def shoot(self, out_dir, route_id, lines, marks): + self.shots.append((route_id, lines, marks)) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / f"{route_id}.png").write_bytes(PNG) + return {"saved": True, "reason": None} + + def directions(self, origin, destination): + self.asked.append((origin.lon, destination.lon)) + if origin.lon == 127.60: # r2 → 시점 = 길찾기 실패 + return {"km": None, "reason": "실패"} + return {"km": 1.0, "reason": None, "path": [[origin.lat, origin.lon], [0, 0]]} + + +def _setup(tmp_path, monkeypatch, start=START): + fake = Fake() + box = {"start": start} + monkeypatch.setattr(ms, "make_route_image", fake.shoot) + monkeypatch.setattr(ms, "directions_km", fake.directions) + monkeypatch.setattr(ms, "make_place_image", lambda *a: {"saved": False}) # 256-1 시험 + monkeypatch.setattr( + ms, "site_info", lambda root, mid: {"start_lonlat": box["start"], "mid_default_m": None} + ) + return fake, box + + +def _save(root, data): + ms.save_data(root, ms.Milestone.model_validate(data)) + ms.refresh_images(root) + + +def test_사슬_화면_차례(): + data = ms.Milestone.model_validate(DATA).model_dump() + chain = ms.route_chain(data, data["routes"][0], START) + assert chain == [ + ("거창레미콘", 127.95, 35.70, False), + ("거창IC", 127.91, 35.68, False), + ("함양JC", 127.72, 35.52, True), + ("시점", 127.5, 35.4, False), + ] + assert [c[0] for c in ms.route_chain(data, data["routes"][3], START)] == ["함양JC", "시점"] + assert [c[0] for c in ms.route_chain(data, data["routes"][1], None)] == ["직송"] + + +def test_전체_경로_그림_구간_선(tmp_path, monkeypatch): + fake, _ = _setup(tmp_path, monkeypatch) + _save(tmp_path, DATA) + shots = {rid: (lines, marks) for rid, lines, marks in fake.shots} + assert sorted(shots) == ["r1", "r2", "r4"] # r3 안 이음 = 없음 + lines, marks = shots["r1"] + assert [m["name"] for m in marks] == ["거창레미콘", "거창IC", "함양JC", "시점"] + assert len(lines) == 3 + assert lines[0] == [[35.70, 127.95], [0, 0]] # 카카오 길 선 + assert lines[2] == [[35.52, 127.72], [35.4, 127.5]] # 수기 기준점 = 곧은 선 · 길찾기 안 함 + assert (127.72, 127.5) not in fake.asked + assert shots["r2"][0] == [[[35.45, 127.60], [35.4, 127.5]]] # 길찾기 실패 = 곧은 선 + assert (tmp_path / "B08_Quantity/milestone/auto/r1.png").read_bytes() == PNG + + +def test_바뀐_경로만_다시(tmp_path, monkeypatch): + fake, box = _setup(tmp_path, monkeypatch) + _save(tmp_path, DATA) + fake.shots.clear() + ms.refresh_images(tmp_path) + assert fake.shots == [] # 안 바뀜 + moved = {**DATA, "bases": [BASES[0], {**BASES[1], "lat": 35.55}]} + _save(tmp_path, moved) + assert sorted(s[0] for s in fake.shots) == ["r1", "r4"] # b2 를 지나는 경로만 + fake.shots.clear() + box["start"] = [127.4, 35.3] # 시점 옮김(노선 수정) = 모두 + ms.refresh_images(tmp_path) + assert sorted(s[0] for s in fake.shots) == ["r1", "r2", "r4"] + fake.shots.clear() + (tmp_path / "B08_Quantity/milestone/auto/r2.png").unlink() # 그림 없음 = 다시 + ms.refresh_images(tmp_path) + assert [s[0] for s in fake.shots] == ["r2"] + + +def test_길찾기_그림_뒤엔_다음_저장에_전체로(tmp_path, monkeypatch): + fake, _ = _setup(tmp_path, monkeypatch) + + async def root(project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.delenv("PYTEST_CURRENT_TEST") # [저장] 뒤 캡처 켬(시험 중엔 꺼 둠) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + base = f"/api/projects/{uuid4()}/quantity/milestone" + assert client.put(base, json=DATA).status_code == 200 # 답 뒤 캡처 + assert sorted(s[0] for s in fake.shots) == ["r1", "r2", "r4"] + fake.shots.clear() + part = {"lines": [[[35.70, 127.95], [35.68, 127.91]]], "marks": []} + assert client.post(base + "/image/auto", params={"route": "r1"}, json=part).status_code == 200 + assert [s[0] for s in fake.shots] == ["r1"] # 화면 길찾기 그림(거래처 구간만) + fake.shots.clear() + ms.refresh_images(tmp_path) + assert [(s[0], len(s[2])) for s in fake.shots] == [("r1", 4)] # 전체 경로로 다시 diff --git a/resources/tester/test_257_1_b09_tabs_lamp.py b/resources/tester/test_257_1_b09_tabs_lamp.py new file mode 100644 index 000000000..4a15af2be --- /dev/null +++ b/resources/tester/test_257_1_b09_tabs_lamp.py @@ -0,0 +1,58 @@ +"""257-1 B09 위 탭 「설계내역」(총괄설계내역서 · 설계내역서 한 묶음) · 설계내역서 단가연결 칸 램프. + +- 위 탭 = `TAB_GROUPS` 「설계내역」[total, bill] · 옛 두 위 탭 없음 +- 단가연결 칸 = 줄 칸 class `b09-bill__lamp`(+ 연결 `is-linked`) · 풍선 = 글(대가N · 연결됨 · 단가연결) +- 머리 = 「단가↵연결」 · 폭 44 · 머리 CSS pre-line +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.test_184_3_b09_bill_menu import ROWS, SRC, _node, _ts # noqa: E402 + +JS = """ +const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser() {} }; +const table = buildBill(ctx); +const at = (key) => table.rows.findIndex((r) => r.key === key); +const keys = ["earth:4", "earth:5", "hand:a", "mat:M1", "fee", "sec:1"]; +const col = table.columns.find((c) => c.key === "connect"); +console.log(JSON.stringify({ + col: { label: col.label, width: col.width, title: col.title }, + cls: keys.map((k) => table.cellClass(at(k), "connect")), + tip: keys.map((k) => table.tip(at(k), "connect")), + other: table.cellClass(at("earth:4"), "qty"), + groups: TAB_GROUPS.map(([label, tabs]) => [label, tabs]), +})); +""" + + +def test_램프_칸_class_풍선_머리(): + body = JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body)) + assert out["col"] == {"label": "단가\n연결", "width": 44, "title": "단가연결"} + assert out["cls"] == [ + "b09-bill__lamp is-linked", # 자동 줄 · 호표 연결 + "b09-bill__lamp", # 연결 없음 = 노랑 + "b09-bill__lamp is-linked", # 손 줄 · 비고 대가3 + None, # 자재 줄 = 연결 대상 아님 + None, + None, # 묶음 줄 + ] + assert out["tip"] == ["연결됨", "단가연결", "대가3", None, None, None] + assert out["other"] is None + labels = [g[0] for g in out["groups"]] + assert "설계내역" in labels and "총괄설계내역서" not in labels and "설계내역서" not in labels + assert ["설계내역", ["total", "bill"]] in out["groups"] + + +def test_CSS_램프_초록_노랑_머리_줄바꿈(): + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + assert "td.b09-bill__lamp::before" in css and "var(--color-warning)" in css + assert "td.b09-bill__lamp.is-linked::before" in css and "var(--color-success)" in css + head = css.split("[data-b09-table] thead th {", 1)[1].split("}", 1)[0] + assert "white-space: pre-line" in head diff --git a/resources/tester/test_257_2_b09_pick_blank_grand.py b/resources/tester/test_257_2_b09_pick_blank_grand.py new file mode 100644 index 000000000..ef5fdd736 --- /dev/null +++ b/resources/tester/test_257_2_b09_pick_blank_grand.py @@ -0,0 +1,64 @@ +"""257-2 B09 이동해 온 줄 = 모든 탭 빨강 상자 · 비고 「빈 줄 N」 없음 · 총공사비 = 표 아래 고정 줄. + +- 쉘 = 설계내역서 밖 · 묶음 아닌 탭에도 줄 범위 [at, at] 빨강(pickClass) · 줄 누름 · Esc · 방향키로 풂(unpickOnRelease) +- 비고 = 제목 줄 「(제외금액)」만 · 묶음 줄 빈칸(Py · TS 짝 · blank 수는 줄 값 그대로) +- 총괄 · 원가계산서 총공사비(grand) = `TabTable.total`(몸 줄에서 빠짐 · 줄 이동 누름 없음 — 185-1 시험이 값 확인) +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 + +B09 = ROOT / "B09_Estimation" + + +def test_쉘_모든_탭_빨강_배선(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "!bill && at >= 0 ? [at, at] : null" in shell + assert "pickClass(range, i)" in shell and "unpickOnRelease(table.root)" in shell + assert "table.focusRow(at, false)" in shell + + +def test_풂_줄_누름_Esc_빨강만_지움(): + out = run( + [COMMON, TABS, GROUPS], + """ +const root = document.createElement("div"); +const rows = [0, 1, 2].map((i) => { + const tr = document.createElement("tr"); + tr.dataset.i = String(i); + if (i === 1) tr.classList.add("is-pick", "is-pick-top", "is-pick-end"); + root.append(tr); + return tr; +}); +unpickOnRelease(root); +const before = rows.map((t) => t.className); +key("Escape"); +const afterEsc = rows.map((t) => t.className); +rows[1].classList.add("is-pick"); +root.click(); +const afterClick = rows.map((t) => t.className); +console.log(JSON.stringify({ before, afterEsc, afterClick })); +""", + RAW + + """ +E.prototype.querySelectorAll = function () { return this.all("TR").filter((t) => t.cls.has("is-pick")); }; +""", + ) + assert out["before"] == ["", "is-pick is-pick-top is-pick-end", ""] + assert out["afterEsc"] == ["", "", ""] + assert out["afterClick"] == ["", "", ""] + + +def test_비고_빈_줄_없음_Py_TS_짝(): + py = (B09 / "B09_Estimation_Calc_Bill.py").read_text("utf-8") + ts = (B09 / "B09_Estimation_Calc_Bill.ts").read_text("utf-8") + assert "빈 줄 {" not in py and not re.search(r"빈 줄 \$\{", ts) + assert 'row["note"] = "(제외금액)" if row["key"] == "sec:G" else ""' in py + assert 'row.note = row.key === "sec:G" ? "(제외금액)" : ""' in ts diff --git a/resources/tester/test_257_3_b09_compare_applied_menu.py b/resources/tester/test_257_3_b09_compare_applied_menu.py new file mode 100644 index 000000000..c631ea294 --- /dev/null +++ b/resources/tester/test_257_3_b09_compare_applied_menu.py @@ -0,0 +1,67 @@ +"""257-3 B09 자재단가대비표 적용 단가 — 초록 테두리 · 우클릭 「적용 단가 바꾸기」(출처 목록). + +- 고른 출처 단가 칸 = 초록 테두리(inset box-shadow · 바탕 색 아님) +- 우클릭 = 적용 단가 칸 · 고른 출처 단가 칸 → 「적용 단가 바꾸기」 하위 목록(왼쪽 누름 메뉴와 같은 항목 · 최저가여도 바꿈) +- 왼쪽 누름 메뉴(choices)는 그대로 +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402 + +BASE = "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts" +PRELUDE = r""" +const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "견적"]; +const openQuotes = () => {}; +""" + +JS = """ +let user = {}; +const sheets = { compare: [ + { key: "m1", no: 1, name: "철근", spec: "D13", unit: "kg", lowest: 1000, lowest_from: "유통물가", + applied: 1000, applied_from: "유통물가", pick: null, + prices: { 관급: null, 물가정보: 1200, 물가자료: 0, 유통물가: 1000, 견적: null } }, +] }; +const t = buildCompare({ sheets, get user() { return user; }, setUser: (u) => (user = u) }); +const menu = t.cellMenu(0, "applied"); +const labels = menu[0].children.map((c) => c[0]); +menu[0].children[0][1](); // 물가정보 = 최저가 아닌 출처로 바꿈 +const afterPick = JSON.stringify(user.materials); +menu[0].children[1][1](); // 유통물가 = 최저가여도 고른 출처로 박음 +const afterLowest = JSON.stringify(user.materials); +console.log(JSON.stringify({ + label: menu[0].label, labels, afterPick, afterLowest, + onPicked: t.cellMenu(0, "p:유통물가")?.[0].label ?? null, + other: [t.cellMenu(0, "name"), t.cellMenu(0, "p:물가정보"), t.cellMenu(5, "applied")], + left: t.choices(0, "applied").length, leftOther: t.choices(0, "name"), + cls: [t.cellClass(0, "p:유통물가"), t.cellClass(0, "p:물가정보")], +})); +""" + + +def test_우클릭_적용_단가_바꾸기_출처_목록(): + out = run([TABS, BASE], JS, PRELUDE) + assert out["label"] == "적용 단가 바꾸기" + assert out["labels"] == ["물가정보 1,200", "✓ 유통물가 1,000 · 최저", "✓ 최저(자동)"] + assert out["afterPick"] == '{"m1":{"pick":"물가정보"}}' + assert out["afterLowest"] == '{"m1":{"pick":"유통물가"}}' # 최저가여도 바꿈 + assert out["onPicked"] == "적용 단가 바꾸기" # 초록 테두리 칸 우클릭도 같은 메뉴 + assert out["other"] == [None, None, None] + assert ( + out["left"] == 4 and out["leftOther"] is None + ) # 왼쪽 누름 메뉴 그대로(출처 둘 · 구분선 · 최저) + assert out["cls"] == ["is-picked", None] + + +def test_배선_쉘_우클릭_CSS_초록_테두리(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert 'made.root.addEventListener("contextmenu"' in shell and "spec.cellMenu" in shell + assert "openMenuAt(event.clientX, event.clientY, items)" in shell + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + box = css.split("td.is-picked {", 1)[1].split("}", 1)[0] + assert "box-shadow: inset 0 0 0 2px var(--color-success)" in box and "background" not in box diff --git a/resources/tester/test_258_1_table_width_var.py b/resources/tester/test_258_1_table_width_var.py new file mode 100644 index 000000000..7831b90d7 --- /dev/null +++ b/resources/tester/test_258_1_table_width_var.py @@ -0,0 +1,52 @@ +"""258-1 · 258-2 · 258-3 — 표 폭 CSS 변수 · 입력 칸 테두리 옅게 · 구조물 조합 미지정 회색(소스 시험). + +- 템플릿 표 인라인 = `--dt-w`(열 합) · css `width: var(--dt-w)` → 페이지 css 가 width 로 덮음 +- 거리이정표 표 = 틀 폭 100% +- 고칠 칸 td.is-can-edit = accent 28% outline · box-shadow 아님 +- B05 미지정 = 회색(text-muted) +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DT_CSS = ROOT / "ui_template" / "ui_template_data_table.css" +DT_TS = ROOT / "ui_template" / "ui_template_data_table.ts" + + +def _rule(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r" \{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_표_폭_인라인_변수_css_width_변수(): + ts = DT_TS.read_text("utf-8") + assert '' in ts + assert 'style="width:${x}px"' not in ts # 인라인 width 가 페이지 css 를 이기지 않음 + body = _rule(DT_CSS.read_text("utf-8"), ".ui-data-table__table") + assert "width: var(--dt-w);" in body + assert "table-layout: fixed;" in body + + +def test_거리이정표_표_틀_폭_전체(): + css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone.css").read_text("utf-8") + body = _rule(css, ".b08-milestone > .ui-data-table .ui-data-table__table") + assert "width: 100%;" in body # 변수 규칙(0,1,0)보다 선택자가 구체적 + + +def test_입력_칸_테두리_옅게_outline(): + css = DT_CSS.read_text("utf-8") + body = _rule(css, ".ui-data-table__table td.is-can-edit") + m = re.search(r"var\(--color-accent\) (\d+)%", body) + assert m and 25 <= int(m.group(1)) <= 30 # 예전 60% → 옅게 + assert "outline: 1px solid" in body and "box-shadow" not in body + assert '"is-can-edit"' in DT_TS.read_text("utf-8") + + +def test_구조물_조합_미지정_회색(): + css = (ROOT / "B05_Profile" / "B05_Profile_UI_Style_Structures.css").read_text("utf-8") + body = _rule(css, ".b05-structure__summary-name:not(.is-assigned)") + assert "border-color: var(--color-text-muted);" in body + assert "--color-text-muted" in body.split("background:")[1] + assert "--color-warning" not in body diff --git a/resources/tester/test_259_detail_region.py b/resources/tester/test_259_detail_region.py new file mode 100644 index 000000000..c5c4351bc --- /dev/null +++ b/resources/tester/test_259_detail_region.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +"""PLAN 259 — B06 [상세] 영역 · 길이 · 표 강조. + +못박는 것 + - 259-1 공용 팬(`A00_Common/b_svg_zoom_pan.ts`) — 가운데 단추 팬 뒤 왼쪽 누름 click 이 안 삼켜짐 · + 거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음. + - 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 · + 암 프리셋 아니면 다시 고름. + - 259-2 닫힌 영역 — 면 찾기(열린 영역 · 매달린 꼬리 제외) · 영역 고름 값(면적 · 둘레) · 붙은 영역 = + 맞닿은 변 뺌 · 서버 거울 · 표 칸 → 저장된 조각 · 띠 짝 · 띠 값 글. + - 259-3 표 강조 — 고른 칸 묶음 바깥 한 줄(칸 사이 겹선 없음). + +./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q +""" + +import json +import os +import subprocess +from math import hypot +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Schema import CrossSectionPatch +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value, pick_lines + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + +LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)] +DESIGN = { + "design_elevation_m": 98.0, + "geometry_preset": "rock", + "rock_boundary_offset_m": -1.0, + "road_edges": { + "left": {"offset_m": 2.0, "elevation_m": 98.0}, + "right": {"offset_m": -2.0, "elevation_m": 98.0}, + }, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE], +} +SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] +AXES = { + "widthPx": 600, + "heightPx": 300, + "pixelsPerMeter": 10, + "maxOffset": 14, + "displayMax": 110, + "elevationMid": 100, + "exaggeration": 2, +} +ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset +GROUND_X = 2 + 4 / 3 # 경사가 원지반 100 m 와 만나는 offset + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_259_detail_region.cjs")], + input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 259-1 공용 팬 ──────────────────────────────────────────────────────── + + +def test_가운데_단추_팬_뒤_왼쪽_누름_통과(out): + assert out["panMiddle"] == {"firstClickStopped": False, "secondClickStopped": False} + + +def test_거리이정표_왼쪽_끌기_팬은_끌기_click_만_막음(out): + assert out["panLeft"] == {"afterDragStopped": True, "nextClickStopped": False} + + +# ── 259-1 암 경계선 나눔 · 고름 ─────────────────────────────────────────── + + +def test_암_경계선이_설계선과_만나는_점에서_나뉨(out): + rock = [p for p in out["pieces"] if p["line"] == "rock"] + assert len(rock) == 3 + ends = sorted(round(p["points"][0][0], 4) for p in rock) + assert ends == pytest.approx([-10.0, -ROCK_X, ROCK_X], abs=1e-3) + # 설계 경사도 암 경계선에서 잘림(경사 끝 · 암 교점 · 원지반 교점) + design_ends = { + round(q[0], 3) + for p in out["pieces"] + if p["line"] == "design" + for q in (p["points"][0], p["points"][-1]) + } + assert round(ROCK_X, 3) in design_ends + + +def test_암_경계선_고름_값_저장_꼴(out): + length = 2 * ROCK_X + assert out["rockLength"] == pytest.approx(length, abs=1e-3) + assert out["rockLabel"] == ( + f"암 경계선 길이 {length:.2f}m · 경사비율 0.0%\n수평 {length:.2f}m · 높이 0.00m" + ) + source = out["rockSource"] + assert source["src"] == "length" and source["pieces"][0]["line"] == "rock" + patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}}) + assert patch.model_dump()["detail_table"]["cells"]["fence"]["pieces"][0]["line"] == "rock" + + +def test_암_경계선_서버_거울(out): + number, _text, again = cell_value(out["rockSource"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(out["rockLocal"], abs=1e-6) + assert pick_lines(DESIGN, SAMPLES)["rock"] == [[(x, 99.0) for x in (-10, -5, 0, 5, 10)]] + # 토사 프리셋이면 암 경계선 없음 → 다시 고름 + soil = {**DESIGN, "geometry_preset": "soil"} + assert pick_lines(soil, SAMPLES)["rock"] == [] + assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True) + + +# ── 259-2 닫힌 영역 · 영역 고름 · 면적 ──────────────────────────────────── + +SLOPE_1M = hypot(4 / 6, 1.0) # 경사 조각 높이 1 m 길이 +FACE_TOP = (2 * GROUND_X + 2 * ROCK_X) / 2 # 원지반 100 ~ 암 99 사이 사다리꼴 +FACE_LOW = (2 * ROCK_X + 4) / 2 # 암 99 ~ 바닥 98 사이 사다리꼴 + + +def test_면_찾기_열린_영역_제외(out): + faces = sorted(out["faces"], key=lambda f: -f["area"]) + assert [f["area"] for f in faces] == pytest.approx([FACE_TOP, FACE_LOW], abs=1e-6) + assert faces[0]["lines"] == ["design", "design", "ground", "rock"] + assert faces[1]["lines"] == ["design", "design", "design", "rock"] + assert out["openFaces"] == 0 # 원지반만 = 열림 + assert out["square"] == [{"area": pytest.approx(1.0), "edges": [0, 1, 2]}] # 꼬리 지움 + + +def test_영역_고름_면적_둘레(out): + top = 2 * GROUND_X + 2 * ROCK_X + 2 * SLOPE_1M + low = 2 * ROCK_X + 4 + 2 * SLOPE_1M + assert sorted(out["faceLabels"]) == sorted( + [ + f"영역 면적 {FACE_TOP:.2f}㎡\n둘레 {top:.2f}m", + f"영역 면적 {FACE_LOW:.2f}㎡\n둘레 {low:.2f}m", + ] + ) + + +def test_붙은_두_영역_맞닿은_변_뺌(out): + union = out["union"] + assert union["lines"] == ["design"] * 5 + ["ground"] # 암 경계선 빠짐 + area = FACE_TOP + FACE_LOW + around = 2 * GROUND_X + 4 + 4 * SLOPE_1M + assert union["label"] == f"영역 2개 · 면적 {area:.2f}㎡\n둘레 {around:.2f}m" + assert ( + union["selectLabel"] + == f"6개 · 길이 합 {around:.2f}m\n면적 {area:.2f}㎡ · 둘레 {around:.2f}m" + ) + assert union["local"] == pytest.approx(area, abs=1e-3) + number, _text, again = cell_value(union["source"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(union["local"], abs=1e-6) + + +# ── 259-2 표 칸 → 그래프 ───────────────────────────────────────────────── + + +def test_표_칸_저장된_조각을_그래프에서_고름(out): + assert out["same"] == { + "forward": True, + "reverse": True, + "otherLine": False, + "otherPiece": False, + } + pieces, band, text, none = out["cellGraph"] + assert pieces == {"pieces": out["rockSource"]["pieces"]} + assert band == {"band": "cut_rock"} + assert text is None and none is None + + +def test_띠_값_글(out): + assert out["bandLabel"] == ["깍기(토사) 면적 1.23㎡", None, None] + + +# ── 259-3 표 강조 바깥 한 줄 ────────────────────────────────────────────── + + +def test_고른_칸_묶음_바깥_한_줄(out): + parts = {"M" + p for p in out["outline"].split("M") if p} + assert parts == { + "M0 0H10", + "M0 0V10", + "M10 0V10", + "M0 20H10", + "M0 10V20", + "M10 10H20", + "M10 20H20", + "M20 10H30", + "M20 20H30", + "M30 10V20", + } # 칸 사이(10 · 20 세로) 겹선 없음 diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py new file mode 100644 index 000000000..569535660 --- /dev/null +++ b/resources/tester/test_260_note_cad.py @@ -0,0 +1,481 @@ +# -*- coding: utf-8 -*- +"""PLAN 260 — B06 [상세] 표 위 CAD 칸(`structure_note_cad`) → B07 횡단도 표 위. + +- 260-1 칸: 스키마 · 재계산 남김 · 세션 초안 → [저장] patch. +- 260-2 미리 넣음: 횡단배수 주인 측점 원형 마크 · 빈 구조물 · 옛 글 옮김 + (`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림) · 오른쪽 글 = 구조물 조합 줄(265-2 · + `test_265_note_cad.py`) — 옛 관보호공 · 관유출부보강 짜 맞춘 글은 버림. + +./venv/Scripts/python.exe -m pytest resources/tester/test_260_note_cad.py -q +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402 +from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402 + +HERE = Path(__file__).resolve().parent +TEXT = { + "id": "t1", + "type": "Text", + "lineColor": "#ff7f7f", + "lineWidth": 1, + "layerId": "기본", + "shapeData": {"label": "NO.71+13", "basePoint": {"x": 15, "y": -1.5}, "options": {}}, +} +CAD = {"entities": [TEXT], "layers": [{"id": "기본", "name": "기본"}]} + +# ── 260-1 칸 ──────────────────────────────────────────────────────────── + + +def test_CAD_칸은_patch_스키마를_지나_그대로_남는다(): + dumped = CrossSectionPatch(chainage_m=40.0, structure_note_cad=CAD).model_dump() + merged = {k: v for k, v in dumped.items() if k != "chainage_m" and v is not None} + assert merged == {"structure_note_cad": CAD} + # 빈 엔티티 = 비움 — 걸러지지 않고 실림(옛 글 대신 아무것도 안 놓음) + empty = CrossSectionPatch(chainage_m=40.0, structure_note_cad={"entities": []}).model_dump() + assert empty["structure_note_cad"] == {"entities": [], "layers": None} + # 안 보냄 = 안 바꿈 + assert CrossSectionPatch(chainage_m=40.0).model_dump()["structure_note_cad"] is None + + +def test_CAD_칸은_엔티티_수_상한(): + with pytest.raises(ValueError): + CrossSectionPatch(chainage_m=40.0, structure_note_cad={"entities": [TEXT] * 2001}) + + +def test_CAD_칸은_재계산에서도_남는다(monkeypatch): + assert "structure_note_cad" in design_mod.USER_TOUCHED_KEYS + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 100.0)]) + monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) + monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) + monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + monkeypatch.setattr( + design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + ) + sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note_cad": CAD}}] + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 + assert sections[0]["design"]["structure_note_cad"] == CAD + + +def test_브라우저가_CAD_칸을_저장_patch_에_싣는다(): + read = lambda rel: (PROJECT_ROOT / rel).read_text(encoding="utf-8") # noqa: E731 + persist = read("B06_Section/B06_Section_UI_Page_Persist.ts") + assert "readStructureNoteCads(" in persist and ".structure_note_cad = cad" in persist + state = read("A00_Common/b_page_state.ts") + assert '"structure-note-cads": { bucket: "draft", scope: "route" }' in state + # 캐시 설계 → B05 [저장] 길(`crossPatchesFromCache`)도 같은 목록 + assert '"structure_note_cad",' in read("B06_Section/B06_Section_Cross_Refresh.ts") + # 모달 = 빈 CAD(캔버스만) · 글 상자 없음 + detail = read("B06_Section/B06_Section_UI_Cross_Detail.ts") + assert "textarea" not in detail and "mountNoteCad(" in detail and "noteCad?.destroy()" in detail + assert "bare: true" in read("B06_Section/B06_Section_UI_Cross_Detail_Cad.ts") + + +# ── 260-2 미리 넣음 ───────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_260_note_cad.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _labels(entities: list[dict]) -> list[str]: + return [entity["label"] for entity in entities if entity["type"] == "Text"] + + +def test_관_유입_유출_도수로_한_벌(out): + """그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 구조물 조합 줄(265-2).""" + full = out["full"] + assert [entity["type"] for entity in full[:2]] == ["Circle", "Text"] # 지시선 없음(169) + assert _labels(full) == [ + "배수관", + "Φ1200m/m", + "L=22m", + "NO.71+13", + "기슭막이 찰쌓기 2.0m", + "돌붙임 찰붙임", + ] + # 원 안 = 가운데 맞춤 · 원 중심 x · 오른쪽 = 왼쪽 맞춤 · 줄 3.0 간격 · 붉은 계열 · 기본 층 + inside, right = full[1:4], full[4:] + assert {(e["align"], e["x"]) for e in inside} == {("center", full[0]["x"])} + assert [e["y"] for e in right] == [-1.5, -4.5, -7.5] + assert {e["x"] for e in right} == {15} + assert {e["align"] for e in right} == {"left"} + assert {e["color"] for e in full} == {"#ff7f7f"} and {e["layer"] for e in full} == {"기본"} + + +def test_관_길이_없으면_L_뒤_빈칸(out): + assert _labels(out["noLength"])[2] == "L=" + # 조합 줄이 없으면 측점 줄까지 + assert _labels(out["noLength"])[3:] == ["NO.71+13"] + + +def test_유입_집수정_은_형태_높이_길이(out): + # 265-2 — 보호공 짜 맞춘 글 버림(조합 줄로) · 원형 마크 · 측점만 + assert _labels(out["basin"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] + + +def test_도수로_옵션이_없으면_세트_구조만(out): + # 265-2 — 관유출부보강 짜 맞춘 글 버림 + assert not any(label.startswith("관유출부보강") for label in _labels(out["chuteFallback"])) + + +def test_보호공을_안_고르면_관과_측점만(out): + assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] + + +@pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"]) +def test_횡단배수_주인_측점이_아니면_빈값(out, case): + """구조물 없음 · 상자형(세월교 · 박스) · 숨은 관 · 이웃 측점 = 빈 도면(줄 없음).""" + assert out[case] == [] + + +def test_마크_없이_조합_줄만이면_측점부터_왼쪽_끝(out): + lines = out["linesOnly"] + assert _labels(lines) == ["NO.71+13", "기슭막이 찰쌓기 2.0m", "돌붙임 찰붙임"] + assert {e["x"] for e in lines} == {0} and [e["y"] for e in lines] == [-1.5, -4.5, -7.5] + + +def test_옛_글은_줄마다_Text_로_옮김(out): + legacy = out["legacy"] + assert _labels(legacy) == ["L형 측구", "집수정 보강"] + # 빈 줄은 자리만 — B07 옛 글 줄과 같은 높이(줄 3.0 · 가운데) + assert [e["y"] for e in legacy] == [-1.5, -7.5] and {e["x"] for e in legacy} == {0} + assert out["legacyEmpty"] == 0 + + +def test_측점_이름은_B07_꼴(out): + assert out["station"] == ["NO.71+13", "NO.71", "NO.72"] + + +# ── 260-3 B07 표 위 ───────────────────────────────────────────────────── + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( # noqa: E402 + CROSS_TABLE_LAYER_ID, + FRAME_LAYER_ID, + build_cross_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import section_block_size # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( # noqa: E402 + cross_note_block_height, + cross_note_cad, + note_cad_bbox, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox # noqa: E402 + +_SOURCE = { + "chainage_m": 20.0, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.3 * x} + for x in range(-10, 11) + ], +} +_QTABLE = {"ground": 100.0, "planned": 99.5} + + +def _text(label: str, x: float, y: float, align: str = "left", font: float = 2.0) -> dict: + return { + "id": label, + "type": "Text", + "lineColor": "#ff7f7f", + "lineWidth": 1, + "layerId": "기본", + "shapeData": { + "label": label, + "basePoint": {"x": x, "y": y}, + "options": {"textAlign": align, "fontSize": font, "textColor": "#ff7f7f"}, + }, + } + + +# 미리 넣음과 같은 꼴 — 원(중심 6.5,-9 · 반지름 6.5) · 지시선 · 오른쪽 글(도면 좌표 아무 자리여도 됨) +_CIRCLE = { + "id": "c", + "type": "Circle", + "lineColor": "#ff7f7f", + "lineWidth": 1, + "layerId": "기본", + "shapeData": {"center": {"x": 106.5, "y": 41.0}, "radius": 6.5}, +} +_CAD = { + "structure_note_cad": { + "entities": [_CIRCLE, _text("NO.1", 115.0, 48.5), _text("관유출부보강", 115.0, 45.5)], + "layers": [{"id": "기본", "name": "기본"}], + } +} + + +def _draw(design: dict | None) -> dict: + return build_cross_drawing(_SOURCE, "cross_00020m", None, design, _QTABLE, "No.1") + + +def _table(drawing: dict) -> dict: + return next(e for e in drawing["entities"] if e["type"] == "Table") + + +def _frame_height(drawing: dict) -> float: + frame = [e for e in drawing["entities"] if e.get("layerId") == FRAME_LAYER_ID] + _x0, y0, _x1, y1 = entities_bbox(frame) + return y1 - y0 + + +def test_CAD_칸_범위는_글_높이_폭까지(): + """글 기준점 = 세로 가운데 · 왼쪽 맞춤 폭 = 한글 글자 크기 · 원 = 중심 ± 반지름.""" + box = note_cad_bbox(_CAD["structure_note_cad"]["entities"]) + assert box == pytest.approx((100.0, 34.5, 115.0 + 6 * 2.0, 49.5)) + assert note_cad_bbox([_text("AB", 10.0, 0.0, "center")]) == pytest.approx((8.8, -1, 11.2, 1)) + assert note_cad_bbox([]) is None + assert cross_note_cad(None) is None and cross_note_cad({"structure_note": "x"}) is None + + +def test_CAD_칸은_표_왼쪽_위_맞춤_표는_범위_높이만큼_내려감(): + plain = _draw(None) + drawn = _draw(_CAD) + height = 49.5 - 34.5 + assert cross_note_block_height(_CAD) == pytest.approx(height) + plain_top = _table(plain)["shapeData"]["origin"]["y"] + drawn_top = _table(drawn)["shapeData"]["origin"]["y"] + assert plain_top - drawn_top == pytest.approx(height) + + placed = [e for e in drawn["entities"] if e.get("id") in _placed_ids()] + left = _table(drawn)["shapeData"]["origin"]["x"] + box = note_cad_bbox(placed) + # 좌상 맞춤 — 범위 왼쪽 = 표 왼쪽 · 범위 위 = 옛 표 윗변(글 없을 때 표 자리) + assert box[0] == pytest.approx(left) and box[3] == pytest.approx(plain_top) + assert box[1] == pytest.approx(drawn_top) + # 표 층으로 · 크기 그대로(배율 1) · 글 색 그대로 · id 는 도면마다 새로 + assert {e["layerId"] for e in placed} == {CROSS_TABLE_LAYER_ID} + circle = next(e for e in placed if e["type"] == "Circle") + assert circle["shapeData"]["radius"] == 6.5 + assert {e["lineColor"] for e in placed} == {"#ff7f7f"} + assert "c" not in {e["id"] for e in placed} + # 블록(테두리) · 시트 칸 크기도 같은 규칙 + assert _frame_height(drawn) - _frame_height(plain) == pytest.approx(height) + _w, plain_h = section_block_size(_SOURCE, None) + _w, cad_h = section_block_size(_SOURCE, None, design=_CAD) + assert cad_h - plain_h == pytest.approx(height) + _w, scaled_h = section_block_size(_SOURCE, None, design=_CAD, table_scale=2.0) + _w, plain2_h = section_block_size(_SOURCE, None, table_scale=2.0) + assert scaled_h - plain2_h == pytest.approx(2 * height) + + +def _placed_ids() -> set[str]: + """옮겨 놓은 CAD 칸 엔티티 id — 결정적 seed `{도면}:notecad:{n}`.""" + from uuid import uuid5 + + from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _ENTITY_NS + + return {str(uuid5(_ENTITY_NS, f"cross_00020m:notecad:{n}")) for n in range(3)} + + +def test_CAD_칸이_있으면_옛_글_대신(): + both = _draw({**_CAD, "structure_note": "옛 글"}) + labels = [e["shapeData"]["label"] for e in both["entities"] if e["type"] == "Text"] + assert "옛 글" not in labels and "NO.1" in labels + + +def test_CAD_칸이_비면_아무것도_안_놓음_옛_글도_안_씀(): + plain = _draw(None) + empty = _draw({"structure_note_cad": {"entities": []}, "structure_note": "옛 글"}) + assert _table(plain)["shapeData"] == _table(empty)["shapeData"] + assert not [e for e in empty["entities"] if e["type"] == "Text"] + + +def test_CAD_칸이_없으면_옛_글_줄_그대로(): + noted = _draw({"structure_note": "L형 측구\n집수정 보강"}) + labels = [e["shapeData"]["label"] for e in noted["entities"] if e["type"] == "Text"] + assert labels == ["L형 측구", "집수정 보강"] + + +# ── 260-4 안 연 측점 = B07 이 파이썬 짝으로 넣음 ───────────────────────── + +from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402 + load_note_sources, + note_entities, + station_no_label, + structure_note_lines, + with_structure_note, +) + +_OPTIONS = { + "pipe_diameter_mm": 1200, + "inlet_type": "기슭막이", + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_height_m": 2, + "inlet_revet_length_m": 10, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_height_m": 2, + "outlet_revet_length_m": 10, + "chute_add_1_structure": "돌붙임", + "chute_add_1_bond": "찰붙임", + "chute_add_1_area": 10, +} +_NO_CHUTE = {k: v for k, v in _OPTIONS.items() if not k.startswith("chute")} + + +def _item(key: str, name: str) -> dict: + return {"layer": "system", "owner": None, "key": key, "name": name} + + +# 관 조합 — 본체(「관」) · 유입 · 유출 · 도수로(265-2 · 본체 줄은 원형 마크가 대신) +_PIPE_THING = { + "name": "배관", + "pipe": True, + "at": 1433.0, + "group_items": { + "grv6lny": _item("SS000001", "흄관 1200"), + "gnbf9m9": _item("SS000055", "기슭막이 메쌓기 1.2m 메쌓기 기초무"), + "gchuteadd1": _item("SS000070", "돌붙임 찰붙임"), + }, +} + + +def _case( + options: dict, pipe_at: float, chainage: float, design: dict | None, things=(), **spec +) -> dict: + culvert = {**_culvert_set(options), "chainage_m": pipe_at, **spec} + return { + "section": {"chainage_m": chainage, "culvert": culvert, "design": design}, + "station": station_no_label(chainage, 20.0), + "things": list(things), + "chainage": chainage, + "interval": 20.0, + } + + +_MIRROR_CASES = [ + _case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}, [_PIPE_THING]), + _case(_NO_CHUTE, 1433.0, 1433.0, {}), + _case(_NO_CHUTE, 1433.0, 1433.0, {"pipe_length_m": 9.5}), + _case(_NO_CHUTE, 80, 80, None, [{**_PIPE_THING, "at": 80.0}]), + _case({**_OPTIONS, "pipe_diameter_mm": 800}, 12.5, 12.5, {}, [{**_PIPE_THING, "at": 12.5}]), + _case(_OPTIONS, 440.241, 440.0, {"pipe_length_m": 7}, [{**_PIPE_THING, "at": 440.241}]), + _case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}, [_PIPE_THING]), + _case(_OPTIONS, 1433.0, 1433.0, {}, body="ford"), + _case(_OPTIONS, 1433.0, 1433.0, {}, hidden_pipe=True), +] + + +def _without_id(entities: list[dict]) -> list[dict]: + return [{k: v for k, v in entity.items() if k != "id"} for entity in entities] + + +def _mirror(cases: list[dict]) -> list[dict]: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"], + input=json.dumps(cases, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티(): + mirrored = _mirror(_MIRROR_CASES) + assert len(mirrored) == len(_MIRROR_CASES) + for case, ts in zip(_MIRROR_CASES, mirrored, strict=True): + section = case["section"] + lines = structure_note_lines(case["things"], section["chainage_m"]) + assert lines == ts["lines"], case["station"] + py = note_entities( + section["chainage_m"], section["culvert"], section["design"], case["station"], lines + ) + assert _without_id(py) == _without_id(ts["entities"]), case["station"] + assert station_no_label(case["chainage"], case["interval"]) == ts["station"] + # 대조가 빈 목록끼리만이 아님 — 주인 측점 여섯은 그림이 있음 · 이웃 · 세월교 · 숨은 관 = 빈값 + assert [bool(item["entities"]) for item in mirrored] == [True] * 6 + [False] * 3 + labels = [e["shapeData"].get("label") for e in mirrored[4]["entities"]] + assert "Φ800m/m" in labels and "돌붙임 찰붙임" in labels and "흄관 1200" not in labels + + +def _write_pipes(root: Path, points: list[dict]) -> None: + path = pipe_points_file(root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8") + + +def test_정본_배수관만_읽음(tmp_path): + _write_pipes( + tmp_path, + [ + {"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}, + {"chainage_m": 80.0, "lonlat": [0, 0], "facility": "pipe", "options": {}}, + {"chainage_m": 90.0, "lonlat": [0, 0], "facility": "ford_bridge", "options": {}}, + ], + ) + pipes, things = load_note_sources(tmp_path) + assert [pipe[0] for pipe in pipes] == [1433.0] # 관경 안 고름 · 세월교 = 빼 + assert pipes[0][1]["chainage_m"] == 1433.0 and pipes[0][2] == _OPTIONS + # 글 줄 재료 = 관 지점 모두(측점 차례 · 시설 이름 = 구조물정의 종류 이름) + assert [(t["name"], t["pipe"], t["at"]) for t in things] == [ + ("배관", True, 80.0), + ("세월교", False, 90.0), + ("배관", True, 1433.0), + ] + assert load_note_sources(tmp_path / "없음") == ([], []) + + +def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_그대로(tmp_path): + _write_pipes( + tmp_path, + [{"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}], + ) + sources = load_note_sources(tmp_path) + design = {"pipe_length_m": 22, "paved": False} + seeded = with_structure_note(design, 1433.0, sources, "NO.71+13") + assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜 + labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]] + # 관 조합 미지정 = 원형 마크 · 측점(배관은 조합 글 줄 없음) · 이어서 좌측 패널 옵션 줄(166-1) + assert labels[1:5] == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] + assert labels[5].startswith("관: 관경 1200") and all(": " in line for line in labels[5:]) + cleared = {**design, "structure_note_cad": {"entities": []}} + assert with_structure_note(cleared, 1433.0, sources, "x") is cleared + old = {**design, "structure_note": "옛 글"} + assert with_structure_note(old, 1433.0, sources, "x") is old + assert with_structure_note(None, 1433.0, sources, "x") is None + assert with_structure_note(design, 1440.0, sources, "x") is design + + +def test_B07_은_안_연_측점에_미리_넣음을_표_위에_그림(tmp_path): + _write_pipes( + tmp_path, + [ + { + "chainage_m": 20.2, + "lonlat": [0, 0], + "facility": "pipe", + "options": _OPTIONS, + "group_items": {"g9lq9uk": _item("SS000055", "기슭막이 메쌓기 1.2m")}, + } + ], + ) + design = with_structure_note({"pipe_length_m": 22}, 20.0, load_note_sources(tmp_path), "NO.1") + drawn = _draw(design) + labels = [e["shapeData"]["label"] for e in drawn["entities"] if e["type"] == "Text"] + assert "NO.1" in labels and "기슭막이 메쌓기 1.2m" in labels + plain_top = _table(_draw({"pipe_length_m": 22}))["shapeData"]["origin"]["y"] + drop = plain_top - _table(drawn)["shapeData"]["origin"]["y"] + assert drop == pytest.approx(cross_note_block_height(design)) and drop > 15 diff --git a/resources/tester/test_261_1_b08_milestone_km_fmt.py b/resources/tester/test_261_1_b08_milestone_km_fmt.py new file mode 100644 index 000000000..85ef4acf0 --- /dev/null +++ b/resources/tester/test_261_1_b08_milestone_km_fmt.py @@ -0,0 +1,71 @@ +"""261-1 거리이정표 km 칸 끝수 — 보이는 km = 3자리(1 m) 반올림 · 끝 0 지움 · 안 고치면 저장 m 그대로 · +고치면 km × 1000 → m(끝수 없이).""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +UI = "B08_Quantity/B08_Quantity_UI_Milestone" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(src, expr): + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _fmt(values): + modal = _read(f"{UI}_Modal.ts") + line = next(ln for ln in modal.splitlines() if ln.startswith("export const fmtKm")) + return _node(line.replace("export ", ""), f"{json.dumps(values)}.map(fmtKm)") + + +def test_km_글_3자리_끝0_지움(): + assert _fmt([576.595 / 1000, 0.3, 0.1 + 0.2, 12, 0, 1.2345, 0.0005, 2 / 3]) == [ + "0.577", "0.3", "0.3", "12", "0", "1.235", "0.001", "0.667", + ] # fmt: skip + + +def test_고치면_km_곱하기_1000_끝수_없이(): + layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + data = {"site": {"mid_station_m": 576.595, "mid_road": "비포장"}, "base": {}, "routes": []} + expr = f'setLeg({json.dumps(data)}, "start", 0, {{ km: 0.3 }}).site.mid_station_m' + assert _node(layout, expr) == 300 + # 안 고침 = km 키 없는 patch → 저장 m 그대로 + expr = f'setLeg({json.dumps(data)}, "start", 0, {{ road: "비포장" }}).site.mid_station_m' + assert _node(layout, expr) == 576.595 + + +def test_모든_km_칸_같은_규칙(): + modal = _read(f"{UI}_Modal.ts") + site = modal[modal.index("export function openSiteInfo") :] + assert "fmtKm(data.site.mid_station_m / 1000)" in site + assert "fmtKm(site.mid_default_m / 1000)" in site + assert "...(text === shown ? {} : { km })" in site # 안 고침 = 덮지 않음 + + leg = _read(f"{UI}_LegModal.ts") + assert "fmtKm(data.site.mid_station_m / 1000)" in leg + assert "fmtKm(site.mid_default_m / 1000)" in leg + assert "...(dist.input.value === shown ? {} : { km: km(dist.input.value) })" in leg + assert "dist.value === shown ? orig : km(dist.value)" in leg # 구간 줄 + assert "fmtKm(leg.km)" in leg and "fmtKm(auto)" in leg + + diagram = _read(f"{UI}_Diagram.ts") + assert "fmtKm(leg.km)" in diagram and "fmtKm(leg.auto_km)" in diagram + assert "km.value === shown" in diagram # 선 위 km 고치기 칸 · 안 고침 = 덮지 않음 + for src in (modal, leg, diagram): + assert "String(leg.km)" not in src and "mid_station_m / 1000)" not in src.replace( + "fmtKm(data.site.mid_station_m / 1000)", "" + ) # 맨 String 변환 없음 diff --git a/resources/tester/test_261_2_b09_cost_link_targets.py b/resources/tester/test_261_2_b09_cost_link_targets.py new file mode 100644 index 000000000..7b6db0414 --- /dev/null +++ b/resources/tester/test_261_2_b09_cost_link_targets.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""261-2 B09 공사원가계산서 줄 이동 대상 — 모든 줄의 대상 키가 총괄 줄 키에 있음(빈 대상 = 이동 없음 허용). + +직접재료비 → 총괄 「가. 순공사비계」(direct) · 직접노무비 · 산출경비와 같은 자리. +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220} + +BODY = f""" +const user = {{ estimate: {{}} }}; +const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }}; +const T = buildTotal(ctx), C = buildCost(ctx); +console.log(JSON.stringify({{ + totalKeys: T.rows.map((r) => r.key), + cost: C.rows.map((r, i) => [r.key, C.link(i)]), +}})); +""" + + +def _run() -> dict: + names = ("Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total") + script = "".join( + IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text(encoding="utf-8")) for n in names + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=script + BODY, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_모든_원가_줄_이동_대상이_총괄_줄에_있음(): + got = _run() + keys = set(got["totalKeys"]) + missing = [k for k, link in got["cost"] if link and link["key"] not in keys] + assert not missing, missing + assert len(got["cost"]) > 20 + + +def test_직접재료비_이동_대상_direct(): + links = {k: link for k, link in _run()["cost"]} + assert links["direct_material"] == {"tab": "total", "key": "direct"} + assert links["direct_labor"] == links["expense"] == links["direct_material"] diff --git a/resources/tester/test_261_3_detail_pick.py b/resources/tester/test_261_3_detail_pick.py new file mode 100644 index 000000000..91779b7ba --- /dev/null +++ b/resources/tester/test_261_3_detail_pick.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +"""PLAN 261-3 — B06 [상세] 영역 고르기: 보통 배율 얇은 띠 누름 · 깎기 토사 / 암석 영역 나눔. + +못박는 것 + - 누름 판정 = 거리(`pickAt`) — 얇은 띠(6 px) 가운데 = 영역 · 선 1 px = 선 · 영역 밖 선 가까이 = 선 · + 넓은 영역은 선 4 px 안 = 선 · 확대 · 축소 배율에서도 같은 판정. + - 벽 · 집수정 둘레 = 구조물 선 — 설계선 끝 ~ 깎기 비탈 밑을 이어 깎기 암석(암선 ↔ 설계선) · + 깎기 토사(지반 ↔ 암선) 영역이 따로 닫힘 · 면적 = 사다리꼴 셈 · 서버 거울. + - 실측 그림(검증_랩탑메인 4+15 배수관 · 암 · 깎기 비탈) — 깎기 암석 영역이 생김 · 보통 배율 띠 누름 = 영역. + +./venv/Scripts/python.exe -m pytest resources/tester/test_261_3_detail_pick.py -q +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +PAD = { + k: float(v) + for k, v in re.findall( + r"(\w+): (\d+)", + re.search( + r"export const CROSS_PAD = \{([^}]*)\}", + (ROOT / "B06_Section" / "B06_Section_UI_Section_Common.ts").read_text(encoding="utf-8"), + ).group(1), + ) +} + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + +# ── 견본: 암 지반(암선 = 지반 − 0.6 m · 보통 배율 6 px 띠) + 오른쪽 설계 비탈 + 왼쪽 집수정 · 깎기 비탈 ── +AXES = { + "pixelsPerMeter": 10, + "maxOffset": 14, + "displayMax": 106, + "elevationMid": 100, + "exaggeration": 1, +} +ROCK = -0.6 +DESIGN = { + "design_elevation_m": 98.0, + "geometry_preset": "rock", + "rock_boundary_offset_m": ROCK, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in [(6, 104), (2, 98), (-2, 98)]], +} +SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] + + +def px(offset: float, elevation: float) -> list[float]: + return [ + PAD["left"] + (AXES["maxOffset"] - offset) * AXES["pixelsPerMeter"], + PAD["top"] + (AXES["displayMax"] - elevation) * AXES["pixelsPerMeter"], + ] + + +def el(tag: str, cls: str, pts, dl: bool = False) -> dict: + return {"tag": tag, "cls": cls, "dl": dl, "pts": [px(*p) for p in pts]} + + +GROUND = [(x, 100.0) for x in (-10, -5, 0, 5, 10)] +ELEMENTS = [ + el("polyline", "b06-chart__cross-profile", GROUND), + el("polyline", "b06-chart__rock-boundary", [(x, y + ROCK) for x, y in GROUND]), + el("line", "b06-chart__design-cross", [(6, 104), (2, 98)], dl=True), + el("line", "b06-chart__design-cross", [(2, 98), (-2, 98)], dl=True), + el("polygon", "b06-chart__culvert-basin", [(-2, 98), (-2, 97), (-3, 97), (-3, 98)]), + el("polyline", "b06-chart__design-cross", [(-3, 98), (-4, 100)]), # 집수정 절토선(구조물) +] +RIGHT_ROCK = 2 + 1.4 * 4 / 6 # 설계 비탈(4 : 6) × 암선 99.4 +RIGHT_GROUND = 2 + 2 * 4 / 6 # 설계 비탈 × 지반 100 +LEFT_ROCK = -3 - 1.4 / 2 # 깎기 비탈(1 : 0.5) × 암선 +LEFT_GROUND = -4.0 +CUT_ROCK = ((RIGHT_ROCK - LEFT_ROCK) + 5) / 2 * 1.4 # 암선 ↔ 노면 · 집수정 윗변(-3 ~ 2) +CUT_SOIL = ((RIGHT_GROUND - LEFT_GROUND) + (RIGHT_ROCK - LEFT_ROCK)) / 2 * 0.6 + +# 누름(그림 px · 배율) — 지반 y 70 · 암선 y 76 · 노면 y 90 +CLICKS = [ + [198, 73, 1], # 띠 가운데 = 영역(토사) + [198, 71, 1], # 지반선 1 px = 선 + [198, 75, 1], # 암선 1 px = 선 + [198, 79, 1], # 넓은 암석 영역 · 암선 3 px = 선 + [198, 81, 1], # 암선 5 px = 영역(암석) + [198, 67, 1], # 영역 밖 · 지반선 3 px = 선 + [198, 60, 1], # 영역 밖 · 먼 곳 = 없음 + [198, 73, 3.7], # 확대 — 띠 가운데 = 영역 + [198, 73, 0.5], # 축소 — 띠 가운데 = 영역 +] + + +def run(cases: list[dict]) -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_261_3_detail_pick.cjs")], + input=json.dumps({"cases": cases}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def out() -> dict: + basinless = [e for e in ELEMENTS if "basin" not in e["cls"]] + return run( + [ + {"name": "sample", "axes": AXES, "elements": ELEMENTS, "clicks": CLICKS}, + {"name": "basinless", "axes": AXES, "elements": basinless}, + ] + ) + + +def face_of(case: dict, lines: list[str]) -> tuple[int, dict]: + found = [(i, f) for i, f in enumerate(case["faces"]) if f["lines"] == lines] + assert len(found) == 1, [f["lines"] for f in case["faces"]] + return found[0] + + +def piece_line(case: dict, pick: dict) -> str: + return case["pieces"][pick["piece"]]["line"] + + +# ── 깎기 토사 · 암석 영역 따로 ───────────────────────────────────────────── + + +def test_깎기_암석_토사_영역_따로_면적(out): + case = out["sample"] + _, rock = face_of(case, ["design", "rock", "structure"]) + _, soil = face_of(case, ["design", "ground", "rock", "structure"]) + assert rock["area"] == pytest.approx(CUT_ROCK, abs=1e-6) + assert soil["area"] == pytest.approx(CUT_SOIL, abs=1e-6) + # 집수정 안 = 따로(넓이 1) · 셋뿐(열린 지반 · 암선 바깥 제외) + assert sorted(round(f["area"], 6) for f in case["faces"]) == sorted( + [round(CUT_ROCK, 6), round(CUT_SOIL, 6), 1.0] + ) + + +def test_비탈_끝이_지반선에_닿기만_해도_나눔(out): + ground_ends = { + round(p["points"][k][0], 4) + for p in out["sample"]["pieces"] + if p["line"] == "ground" + for k in (0, -1) + } + assert {LEFT_GROUND, round(RIGHT_GROUND, 4)} <= ground_ends + + +def test_집수정_없으면_암석_영역_안_닫힘(out): + # 고침 전 꼴 — 설계선 끝 ~ 비탈 밑이 안 이어져 토사 띠만 남음 + faces = out["basinless"]["faces"] + assert [f["lines"] for f in faces] == [["design", "ground", "rock", "structure"]] + assert faces[0]["area"] == pytest.approx(CUT_SOIL, abs=1e-6) + + +def test_암석_영역_서버_거울(out): + _, rock = face_of(out["sample"], ["design", "rock", "structure"]) + number, _text, again = cell_value(rock["source"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(CUT_ROCK, abs=1e-3) + + +# ── 누름 판정(거리) ────────────────────────────────────────────────────── + + +def test_얇은_띠_보통_배율_가운데_누름_영역(out): + case = out["sample"] + soil, _ = face_of(case, ["design", "ground", "rock", "structure"]) + rock, _ = face_of(case, ["design", "rock", "structure"]) + picks = case["clicks"] + assert picks[0] == {"face": soil} + assert picks[4] == {"face": rock} + assert picks[7] == {"face": soil} and picks[8] == {"face": soil} + + +def test_선_가까이_누름_선(out): + case = out["sample"] + picks = case["clicks"] + assert piece_line(case, picks[1]) == "ground" + assert piece_line(case, picks[2]) == "rock" + assert piece_line(case, picks[3]) == "rock" + assert piece_line(case, picks[5]) == "ground" + assert picks[6] is None + + +# ── 실측 그림(검증_랩탑메인 4+15) ───────────────────────────────────────── + + +@pytest.fixture(scope="module") +def real() -> dict: + elements = json.loads( + (HERE / "fixtures" / "b06_261_3_cross_415.json").read_text(encoding="utf-8") + ) + ppm = 241.954642190761 / 20 # 눈금 20 m = 241.95 px · 과장 1 + axes = { + "pixelsPerMeter": ppm, + "maxOffset": (708 - PAD["left"]) / ppm, # 0 m 눈금 x 708 + "displayMax": 875 + (318.52362895766817 - PAD["top"]) / ppm, # 875 m 눈금 y 318.52 + "elevationMid": 870, + "exaggeration": 1, + } + case = {"name": "real", "axes": axes, "elements": elements} + first = run([case])["real"] + band = [(i, f) for i, f in enumerate(first["faces"]) if "ground" in f["lines"]] + soil, face = max(band, key=lambda item: item[1]["area"]) # 깎기 토사 띠 + # 띠의 지반 조각(가장 긴 것) 가운데 꼭짓점 — 그 밑 3 px(띠 가운데) · 1 px(지반선) + ground = max( + (i for i in face["edges"] if first["pieces"][i]["line"] == "ground"), + key=lambda i: len(first["pieces"][i]["points"]), + ) + path = first["px"]["paths"][ground] + x, y = path[len(path) // 2] + case["clicks"] = [[x, y + 3, 1], [x, y + 1, 1]] + return {**run([case])["real"], "soil": soil, "ground": ground} + + +def test_실측_깎기_암석_영역_생김(real): + rock = [f for f in real["faces"] if f["lines"] == ["design", "rock", "structure"]] + assert rock and max(f["area"] for f in rock) == pytest.approx(18.24, abs=0.05) + soil = real["faces"][real["soil"]] + assert soil["lines"] == ["design", "ground", "rock", "structure"] + assert soil["area"] == pytest.approx(6.00, abs=0.05) + + +def test_실측_보통_배율_띠_누름(real): + assert real["clicks"][0] == {"face": real["soil"]} + assert real["clicks"][1] == {"piece": real["ground"]} diff --git a/resources/tester/test_261_4_haul_names.py b/resources/tester/test_261_4_haul_names.py new file mode 100644 index 000000000..57163c791 --- /dev/null +++ b/resources/tester/test_261_4_haul_names.py @@ -0,0 +1,65 @@ +"""261-4 운반공 줄 이름 = 거리이정표 항목명 그대로(「운반」 안 붙임) · 행마다 개별 줄 · 단가연결 무관.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from B08_Quantity.B08_Quantity_Haul import haul_rows # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 +from B09_Estimation.B09_Estimation_Router import haul_user # noqa: E402 + +LOGIC = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {}} +DATA = { + "site": {"mid_station_m": 2000, "mid_road": "비포장"}, + "base": {"enabled": False, "legs": []}, + "routes": [ + # 자동 상자 — 항목명 고침 · 둘째 행(같은 이름) + {"id": "h-sand", "name": "골재상", "haul": "sand", "via_base": False, + "legs": [{"road": "포장", "km": 25.1}], + "rows": [{"item": "강모래", "dest": "start"}, {"item": "강모래", "dest": "mid"}]}, + # 자동 상자 — 항목명 빈칸 · 거래처 없음 = 묶음 이름 + {"id": "h-gravel", "name": "", "haul": "gravel", "via_base": False, + "legs": [], "rows": [{"item": "", "dest": "start"}]}, + # 사용자 상자 — 「운반」 이 든 항목명 · 같은 이름 두 행 · 빈 항목명(거래처) · 연결 없음 + {"id": "u1", "name": "석산", "via_base": False, "legs": [{"road": "포장", "km": 10}], + "rows": [{"item": "전석운반", "dest": "start"}, {"item": "전석운반", "dest": "start"}, + {"item": " ", "dest": "start"}]}, + # 사용자 상자 — 항목명 · 거래처 모두 빈칸 · 연결 있음 + {"id": "u2", "name": "", "ref": LOGIC, "via_base": False, "legs": [], + "rows": [{"item": "", "dest": "start"}]}, + ], +} # fmt: skip + + +def _items() -> dict[str, dict]: + rows = haul_rows(DATA, compute_milestone(DATA, None)) + return {i["key"]: i for i in haul_items(None, haul_user(rows)["haul_refs"], rows)} + + +def test_자동_상자_이름_항목명(): + items = _items() + assert items["haul:3"]["name"] == "강모래" + assert items["haul:4"]["name"] == "자갈" # 항목 · 거래처 빈칸 = 묶음 이름 + assert items["haul:1"]["name"] == "중기" # 상자 없음 = 묶음 이름 + + +def test_자동_상자_둘째_행_개별_줄_묶음_바로_뒤(): + items = _items() + keys = list(items) + assert keys.index("haul:3.1") == keys.index("haul:3") + 1 + extra = items["haul:3.1"] + assert (extra["name"], extra["qty"], extra["dist"]["L2"]) == ("강모래", None, 2) + + +def test_사용자_상자_이름_항목명_같은_이름_두_줄(): + items = _items() + assert items["haul:r-u1"]["name"] == items["haul:r-u1.1"]["name"] == "전석운반" + assert items["haul:r-u1.2"]["name"] == "석산" # 빈 항목명 = 거래처 + assert items["haul:r-u2"]["name"] == "운반" and items["haul:r-u2"]["ref"] == LOGIC + assert items["haul:r-u1"]["ref"] is None # 연결 없어도 줄 + + +def test_운반운반_없음(): + assert not any("운반운반" in i["name"] for i in _items().values()) diff --git a/resources/tester/test_262_1_b08_milestone_title_start_pin.py b/resources/tester/test_262_1_b08_milestone_title_start_pin.py new file mode 100644 index 000000000..c002f4175 --- /dev/null +++ b/resources/tester/test_262_1_b08_milestone_title_start_pin.py @@ -0,0 +1,40 @@ +"""262 B08 거리이정표 상자 창 제목 · 소재지 지도 시점 표지(사용자 164-1). + +262-1 거래처 · 기준점 창 제목 = 이름만(빈 이름 = 「거래처」 · 「기준점」) · 시점 창 = 「시점 · 중점」 · 요소 추가 창 그대로 +262-2 소재지 지도 = 시점 표지 고정(끌기 없음 · 맞춤에서 빠짐) · 시점 없으면 표지 없음 · 소재지 없으면 시점 중심 +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B08 = "B08_Quantity/B08_Quantity_UI_Milestone" + + +def _read(name): + return (ROOT / f"{B08}{name}").read_text(encoding="utf-8") + + +def test_제목_이름만(): + modal = _read("_Modal.ts") + title = modal[modal.index("const title = target.add") : modal.index("openModal({")] + assert '? base.name || "기준점"' in title and ': route!.name || "거래처"' in title + assert '"요소 추가 — 기준점 · 거래처"' in title + assert 'title: "시점 · 중점",' in modal and "공사지 —" not in modal + assert re.search(r"title: `?[^\n]*(거래처|기준점|공사지) —", modal) is None + + +def test_시점_표지_고정(): + place = _read("_Place.ts") + assert "startPoint(liveMilestone(projectId))" in place # 거리이정표가 쓰는 시점 + assert "center: start" in place # 소재지 없음 = 시점 중심 + assert "movable: false, fixed: true" in place and 'name: "시점"' in place + assert "...(s" in place and " : [])," in place # 시점 없음 = 표지 없음 + kakao = _read("_KakaoMap.ts") + assert "fixed?: boolean;" in kakao + assert "const moving = pins.filter((p) => !p.fixed);" in kakao + assert "...moving.map(" in kakao # 처음 보기 = 소재지 중심(시점은 맞춤에서 빠짐) + assert "moving.find(" in kakao # 보기 이동에서도 빠짐 + assert "if (p.fixed)" in kakao and "b08-milestone__map-start" in kakao + css = _read(".css") + assert ".b08-milestone__map-start {" in css diff --git a/resources/tester/test_263_1_haul_modal.py b/resources/tester/test_263_1_haul_modal.py new file mode 100644 index 000000000..c27af2d81 --- /dev/null +++ b/resources/tester/test_263_1_haul_modal.py @@ -0,0 +1,89 @@ +"""263-1 「운반 단가」 모달 = 고른 항목 입력만 + 거리 자동 — 사이클 손 칸 · 8-2-8 표 고르기 없음 · +[변경] = 단가연결 한 목록 · 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 · 손 값 이김 · +옛 `cycle` = 서버 읽기 그대로(모달 [적용]하면 지움).""" + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import FILL, cycle_time, fill_given # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import _input_names, hand_sources # noqa: E402 + +DIST = {"L1": 23.8, "L2": 10.5} + + +def _src(name: str) -> str: + return (ROOT / name).read_text(encoding="utf-8") + + +def test_왼쪽_칸_고른_로직_입력만(): + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + for gone in ("data-haul-cycle", "CC000054", "사이클시간 입력", "표에서 고르기", "다른 종류", + "numberField", "loadTable"): # fmt: skip + assert gone not in modal, gone + # 칸 = 로직 입력 하나에 한 줄(createValueControl) · 사이클 줄 = 거리 칸 있을 때만 + assert "st.inputs.map((input) =>" in modal and "createValueControl(" in modal + # 277 — 거리 칸 · 사이클 칸 = 거리 칸 받는 로직만(test_277_1) + assert "want.some((n) => n in CYCLE_FILL) ? [distBox(o.dist, input)]" in modal + # 거리 받는 마스터 로직 = 거리 칸 하나씩 + for code, want in (("GF000284", "운반거리"), ("GF000134", "Cm"), ("GF000283", "사이클시간")): + names = _input_names(code) + assert [n for n in names if n in FILL] == [want], (code, names) + + +def test_변경_한_목록(): + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + assert "openPriceLink({ ...o, onPick: usePicked })" in modal and "importLib(o, {" in modal + link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts") + assert "o.onPick ??" in link and "openHaulModal({ ...link, ref" in link + milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert "other:" not in milestone # 276 = 연결 없음은 옛 길(openPriceLink) — test_276_1 + + +def test_거리_칸_자동_손_값_이김(): + ref = {"given": {"재료": "토사"}, "cycle": {}, "own": []} + got = fill_given(ref, _input_names("GF000284"), DIST) + assert got["운반거리"] == 34.3 # L1 + L2 + cm = cycle_time(DIST)["cm"] + assert fill_given(ref, ["Cm"], DIST)["Cm"] == cm + assert fill_given(ref, ["사이클시간", "t2"], DIST) == { + "재료": "토사", "사이클시간": cm, "t2": cycle_time(DIST)["t2"]} # fmt: skip + mine = {"given": {"운반거리": 5}, "own": ["운반거리"]} + assert fill_given(mine, ["운반거리"], DIST)["운반거리"] == 5 + + +def test_ts_짝_사이클_비움(): + """모달 = cycle {} · 옛 own L1 · L2 · t1 뺌 → TS fillGiven = 파이썬과 같음.""" + calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts" + ref = {"given": {"qt": 15}, "cycle": {}, "own": []} + body = ( + f"\nconsole.log(JSON.stringify(fillGiven({json.dumps(ref)}, ['Cm', '운반거리']," + f" {json.dumps(DIST)})));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == fill_given(ref, ["Cm", "운반거리"], DIST) + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + assert ( + "cycle: {}, given: st.given" in modal and 'OLD_OWN = new Set(["L1", "L2", "t1"])' in modal + ) + assert "cycle: st.cycle" not in modal # [적용] 새 연결에 cycle 없음 + + +def test_옛_cycle_서버_읽기_그대로(): + old = {"tab": "logic", "code": "GF000134", "given": {"Cm": 1}, "cycle": {"V1": 20}} + user = {"haul_refs": {"haul:r-u1": old}, "haul_dist": {"haul:r-u1": DIST}} + ((_k, _u, src),) = hand_sources(user) + assert src["given"]["Cm"] == cycle_time({**DIST, "V1": 20})["cm"] + new = {**old, "cycle": None} + user["haul_refs"]["haul:r-u1"] = new + ((_k, _u, src),) = hand_sources(user) + assert src["given"]["Cm"] == cycle_time(DIST)["cm"] diff --git a/resources/tester/test_263_2_haul_lib_copy.py b/resources/tester/test_263_2_haul_lib_copy.py new file mode 100644 index 000000000..6f74cbdf0 --- /dev/null +++ b/resources/tester/test_263_2_haul_lib_copy.py @@ -0,0 +1,140 @@ +"""263-2 운반 값 사본 — 사전조합 · 구조물 조합 담은 로직 거리 칸 = 거리이정표 거리 · +같은 값 = 사본 하나 · 다른 거리 = 사본 둘 · 거리 바뀐 뒤 셈 = 새 사본 · 옛 사본 prune · +옛 이름(해시 없음) 폴더 읽힘 · 손 값(own) 이김.""" + +import shutil +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time # noqa: E402 +from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Store import _clean_ref # noqa: E402 +from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402 + +KEY = "SC-999263" +DOC = { + "종류": "사전조합", + "키": KEY, + "이름": "시험 덤프운반 조합", + "단위": "㎥", + "담은로직": [ + {"로직": "GF000284", "입력값": {"재료": "토사", "지역": "전국평균", + "보정작업": "해당 없음"}, "수량": {"손": 1}}, + {"로직": "GF000134", "입력값": {"qt": 15, "f": 1, "E": 0.9, "지역": "전국평균"}, + "수량": {"손": 1}}, + ], +} # fmt: skip +NEAR = {"L1": 10, "L2": 2} +FAR = {"L1": 30, "L2": 2} +LID = f"{refs.ref_id({'층': 'system', '주인': None, '키': KEY})}#0" +LID2 = LID[:-1] + "1" + + +@pytest.fixture(autouse=True) +def fake_precombo(monkeypatch): + real = refs.read_ref + monkeypatch.setattr(refs, "read_ref", lambda ref: DOC if ref.get("키") == KEY else real(ref)) + + +def _import(root, dist, given=None, own=None): + haul = {"given": given, "own": own, "dist": dist} + return library.import_copy(root, "precombo", "system", None, KEY, None, haul)["폴더"] + + +def _given(root, folder): + files = library._read_copy(library.library_dir(root) / folder) + manifest = library.read_json(library.library_dir(root) / folder / library.MANIFEST_NAME) + return manifest.get("값") or {}, files + + +def test_거리_칸_채움_같은_값_사본_하나_다른_거리_둘(tmp_path): + a = _import(tmp_path, NEAR) + b = _import(tmp_path, NEAR) + c = _import(tmp_path, FAR) + assert a == b and a != c and "~" in a + values, _ = _given(tmp_path, a) + assert values[LID] == {"운반거리": 12} # L1 + L2 + assert values[LID2] == {"Cm": cycle_time(NEAR)["cm"]} + # 사본 안 로직 줄 입력값 = 덮은 값 · 원값 = 덮기 전(없던 칸) + labor = library.read_json(library.library_dir(tmp_path) / c / library.LABOR_FILE) + doc = labor["참조사본"][LID.split("#")[0]] + assert ( + doc["담은로직"][0]["입력값"]["운반거리"] == 32 + and doc["담은로직"][0]["입력값"]["재료"] == "토사" + ) + # 대가 블록 둘(값 다름 = 블록 다름) + libs = daega_inputs(tmp_path)["libs"] + assert set(libs) == {a, c} and libs[a] != libs[c] + + +def test_손_값_이김(tmp_path): + own = [f"{LID}|운반거리"] + folder = _import(tmp_path, NEAR, {LID: {"운반거리": 5, "재료": "암절취"}}, own) + values, _ = _given(tmp_path, folder) + assert values[LID] == {"운반거리": 5, "재료": "암절취"} + lines = library.library_inputs(tmp_path, "precombo", "system", None, KEY, folder) + first = next(x for x in lines if x["id"] == LID) + assert first["로직"] == "GF000284" and first["값"]["재료"] == "토사" # 덮기 전 값 + assert [i["이름"] for i in first["입력"]] == ["재료", "운반거리", "지역", "보정작업"] + + +def test_거리_바뀐_뒤_셈_새_사본_옛_사본_prune(tmp_path): + old = _import(tmp_path, NEAR) + ref = {"tab": "dg", "key": "DG:x", "lib": old, "given": {}, "own": []} + user = {"haul_refs": {"haul:r-u1": ref}, "haul_dist": {"haul:r-u1": FAR}} + calc = library.resolve_hauls(tmp_path, user) + new = calc["haul_refs"]["haul:r-u1"]["lib"] + assert new != old and new == _import(tmp_path, FAR) + libs = daega_inputs(tmp_path)["libs"] + library.fix_keys(calc, libs) + assert calc["haul_refs"]["haul:r-u1"]["key"] == libs[new] + assert library.prune(tmp_path, calc) == [old] + # 같은 거리 다시 = 그대로 + assert library.resolve_hauls(tmp_path, user)["haul_refs"]["haul:r-u1"]["lib"] == new + # 설계내역서 덮개 줄도 같이 + bill = {"bill": {"rows": [{"key": "haul:r-u1", "ref": ref}]}, "haul_dist": {"haul:r-u1": NEAR}} + assert library.resolve_hauls(tmp_path, bill)["bill"]["rows"][0]["ref"]["lib"] == old + + +def test_옛_이름_폴더_읽힘(tmp_path): + hashed = _import(tmp_path, NEAR) + plain = library.import_copy(tmp_path, "precombo", "system", None, KEY)["폴더"] + assert "~" not in plain and library.NAME.match(plain) + ref = {"tab": "dg", "key": "DG:x", "lib": plain} + assert library.resolve(tmp_path, ref, NEAR) == hashed # 옛 사본에서 거리 값 사본 + shutil.rmtree(library.library_dir(tmp_path) / hashed) + assert library.resolve(tmp_path, ref, NEAR) == hashed # 없으면 옛 사본에서 다시 만듦 + assert library.resolve(tmp_path, ref, None) == plain # 거리 없음 = 그대로 + assert {f.name for f, _m in library.copies(tmp_path)} == {plain, hashed} + + +def test_연결_손_값_저장_꼴(): + ref = {"tab": "dg", "key": "DG:x", "lib": "precombo@system@-@SC-1~abcdef12", + "given": {LID: {"운반거리": 5, "x": [1]}}, "own": [f"{LID}|운반거리", 3]} # fmt: skip + got = _clean_ref(ref) + assert got["given"] == {LID: {"운반거리": 5}} and got["own"] == [f"{LID}|운반거리"] + assert "given" not in _clean_ref({"tab": "dg", "key": "DG:x"}) + + +def test_있는_사본에서_다시_적용(tmp_path, monkeypatch): + near = _import(tmp_path, NEAR) + monkeypatch.setattr(refs, "read_ref", lambda ref: None) # 원본 지워짐 = 굳힌 사본에서 + haul = {"given": {}, "own": [], "dist": FAR, "lib": near} + got = library.import_copy(tmp_path, "precombo", "system", None, KEY, None, haul) + values, _ = _given(tmp_path, got["폴더"]) + assert got["폴더"] != near and values[LID] == {"운반거리": 32} + + +def test_모달_조합_입력(): + modal = (ROOT / "B09_Estimation/B09_Estimation_UI_Haul_Modal.ts").read_text("utf-8") + for want in ("fetchLibInputs(p)", "libRows()", "st.libOwn.has(id)", "parseLib(", + "importLib(o, { kind, layer, owner, key, lib, haul })"): # fmt: skip + assert want in modal, want + link = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8") + assert '"library-inputs"' in link and "...lib.haul" in link diff --git a/resources/tester/test_264_2_b09_schedule_options.py b/resources/tester/test_264_2_b09_schedule_options.py new file mode 100644 index 000000000..2266968f5 --- /dev/null +++ b/resources/tester/test_264_2_b09_schedule_options.py @@ -0,0 +1,210 @@ +"""PLAN 264 — B09 공기 옵션(착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 · 준비 정리 · 반영률 · +주 장비 기준 · 중복 C 끝수 · 마지막 달) — 옵션마다 결과가 바뀜 · 다듬기 · 거울 TS = Py · 패널 훅.""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule # noqa: E402 +from B09_Estimation.draft.B09_Draft_Schedule_Source import ( # noqa: E402 + CLIMATES, + clean_schedule, + schedule_inputs, + unit_resources, +) +from test_252_1_b09_schedule import GEOCHANG, SRC, _bill, _ref_user, _ts_schedule # noqa: E402 + +SRC2 = {**SRC, "climates": CLIMATES} +UNITS = { + "a": { + "machine": {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066}, + "labor": 0.01, + "machine_sum": {"hr": 0.0086, "n": 2}, + }, + "b": {"machine": None, "labor": 1.5}, +} +BILL = [ + {"kind": "item", "key": "a", "qty": 1000, "section": "1.2", "level": 2}, + {"kind": "item", "key": "b", "qty": 80, "section": "2.1", "level": 2}, + {"kind": "item", "key": "h", "qty": 9, "section": "3", "level": 2}, +] +SRC3 = {**SRC2, "units": UNITS} +YEAR_2026 = {"start": {"year": 2026, "month": 1}, "tidy": 400} + + +def _plain(): + """거창 재현 사용자 값에서 법정공휴일 덮개만 뺌(자동 B 를 봄).""" + user = _ref_user() + del user["holiday"] + return user + + +def _cal(user, src=SRC2, bill=None): + return compute_schedule(src, _bill(GEOCHANG) if bill is None else bill, user)["calendar"] + + +def _rows(user): + return {r["key"]: r for r in compute_schedule(SRC3, BILL, user)["work"]["rows"]} + + +def test_기본값_그대로(): + base = compute_schedule(SRC, _bill(GEOCHANG), _ref_user()) + assert compute_schedule(SRC2, _bill(GEOCHANG), _ref_user()) == base + picked = {**_ref_user(), "climate_set": "geochang25", "rest": "sun", "machine_basis": "max", + "c_round": "none", "last_off": "ratio", "holiday_off": [], "units": 1, "crew": 10, + "rate_haul": 0, "rate_base": 100, "hours": 8, "prep_docs": 2, "prep_setup": 2, + "tidy": 4} # fmt: skip + assert compute_schedule(SRC2, _bill(GEOCHANG), picked) == base + assert (base["work"]["total"], base["calendar"]["days"]) == (95, 136) + + +def test_1_착공일_그날부터(): + user = {**_plain(), "start": {"year": 2026, "month": 3, "day": 15}} + cal = _cal(user) + first = cal["months"][0] + # 3.15 ~ 3.31 = 17일 · A = ROUNDUP(3.6 × 17 ÷ 31, 1) = 2.0 · 일요일 15 · 22 · 29 + assert (first["days"], first["a"], first["b_auto"]) == (17, 2.0, 3) + assert cal["start"] == {"year": 2026, "month": 3, "day": 15} + assert cal["end"] > _cal(_plain())["end"] + assert cal["months"][1]["days"] == 30 # 둘째 달부터 달 전체 + + +def test_2_기후_자료_고르기(): + m = _cal({**_plain(), "climate_set": "uljin24"})["months"][0] + assert (m["rain"], m["heat"]) == (2.9, 0) + m = _cal({**_plain(), "climate_set": "sogwang25"})["months"][5 - 3] + assert (m["rain"], m["heat"]) == (2.1, 0) # 5월 + assert all(m["a"] == 0 for m in _cal({**_plain(), "climate_set": "zero"})["months"]) + user = {**_plain(), "climate_set": "uljin24", "climate": {"rain": [9] * 12}} + assert _cal(user)["months"][0]["rain"] == 9 # 달마다 덮개가 이김 + + +def test_3_쉬는_날_토일(): + base = _cal(_plain())["months"][0]["b_auto"] + assert _cal({**_plain(), "rest": "satsun"})["months"][0]["b_auto"] == base + 4 # 2026.3 토 4번 + + +def test_4_공휴일_빼기(): + def auto(off): + cal = _cal({**YEAR_2026, "holiday_off": off}, bill=[]) + return [m["b_auto"] for m in cal["months"][:12]] + + full = auto([]) + assert full == [5, 7, 6, 4, 8, 6, 5, 7, 7, 7, 5, 5] + assert auto(["labor"])[4] == 7 # 5.1 노동절 + assert auto(["substitute"])[4] == 7 # 5.25 부처님오신날 대체 + assert auto(["election"])[5] == 5 # 6.3 지방선거 + assert auto(["constitution"])[6] == 4 # 7.17 제헌절 + may = _cal({**YEAR_2026, "holiday_off": ["labor"]}, bill=[])["months"][4]["names"] + assert not any("노동절" in n for n in may) + + +def test_5_일_작업시간(): + assert _rows({})["a"]["days"] == 0.9 + assert _rows({"hours": 4})["a"]["days"] == 1.7 # 1000 × 0.0066 ÷ 4 = 1.65 + + +def test_6_투입_기본(): + rows = _rows({"units": 2, "crew": 5}) + assert (rows["a"]["put"], rows["a"]["days"]) == (2, 0.5) # 0.4125 → 0.5 + assert (rows["b"]["put"], rows["b"]["days"]) == (5, 24) + rows = _rows({"crew": 5, "rows": {"b": {"crew": 20}}}) + assert rows["b"]["days"] == 6 # 줄마다 덮개가 이김 + + +def test_7_준비_정리_기타(): + work = compute_schedule(SRC3, BILL, {"prep_docs": 3, "prep_setup": 1, "tidy": 6, "etc": 2}) + w = work["work"] + assert (w["prep"], w["tidy"], w["etc"]) == (4, 6, 2) + assert w["total"] == 4 + round(0.9 + 12 + 0 + 2) + 6 + + +def test_8_반영률_기본(): + rows = _rows({"rate_haul": 100, "rate_base": 50}) + assert (rows["h"]["rate"], rows["a"]["rate"], rows["b"]["rate"]) == (100, 50, 50) + assert rows["b"]["days"] == 6 + assert _rows({"rate_base": 50, "rows": {"b": {"rate": 100}}})["b"]["days"] == 12 + + +def test_9_주_장비_장비_시간_합(): + a = _rows({"machine_basis": "sum"})["a"] + assert (a["per"], a["who"], a["days"]) == (0.0086, "굴착기 0.7㎥ 외 1종", 1.1) # 8.6 ÷ 8 + assert _rows({})["a"]["per"] == 0.0066 + + +def test_10_중복일수_끝수(): + base = _cal(_plain())["months"] + got = _cal({**_plain(), "c_round": "round1"})["months"] + assert all(m["c"] == round(m["c"], 1) for m in got) + assert [m["c"] for m in got] != [m["c"] for m in base] + + +def test_11_마지막_달_비작업_전부(): + base = _cal(_ref_user())["months"][-1] + last = _cal({**_ref_user(), "last_off": "all"}) + assert (base["off_used"], base["off"]) == (7, 15) + assert last["months"][-1]["off_used"] == 15 and last["days"] == 136 + 8 + + +def test_다듬기_새_칸(): + raw = { + "units": 2, "crew": 0, "rate_haul": 50, "rate_base": 120, + "climate_set": "uljin24", "rest": "sat", "machine_basis": "sum", + "c_round": "round1", "last_off": "x", "holiday_off": ["labor", "junk", "election"], + "start": {"year": 2027, "month": 2, "day": 30}, + } # fmt: skip + assert clean_schedule(raw) == { + "rate_haul": 50, + "units": 2, + "climate_set": "uljin24", + "machine_basis": "sum", + "c_round": "round1", + "holiday_off": ["election", "labor"], + "start": {"year": 2027, "month": 2}, + } + got = clean_schedule({"start": {"year": 2027, "month": 3, "day": 15}, "holiday_off": "x"}) + assert got == {"start": {"year": 2027, "month": 3, "day": 15}} + + +def test_원천_기후_셋_장비_합(): + sheets = { + "bill": {"rows": [{"kind": "item", "key": "earth:4", "ref": {"tab": "sg", "key": "S1"}}]}, + "sg": [{"key": "S1", "lines": [ + {"kind": "machine", "ref": {"key": "M1"}, "qty": 0.0066}, + {"kind": "machine", "ref": {"key": "M2"}, "qty": 0.002}, + ]}], + "machine": [], + } # fmt: skip + unit = unit_resources(sheets)["earth:4"] + assert unit["machine_sum"]["n"] == 2 and round(unit["machine_sum"]["hr"], 6) == 0.0086 + got = schedule_inputs(sheets) + assert list(got["climates"]) == ["geochang25", "uljin24", "sogwang25", "zero"] + assert got["climates"]["geochang25"]["rain"] == got["climate"]["rain"] + + +def test_거울_TS_옵션(): + cases = [ + (SRC2, _bill(GEOCHANG), {**_plain(), "start": {"year": 2026, "month": 3, "day": 15}, + "climate_set": "sogwang25", "rest": "satsun", + "holiday_off": ["substitute", "constitution"], + "c_round": "round1", "last_off": "all"}), + (SRC3, BILL, {"machine_basis": "sum", "units": 3, "crew": 7, "rate_haul": 30, + "rate_base": 80, "hours": 9, "prep_docs": 1, "tidy": 2, "etc": 1, + "climate_set": "zero", "start": {"year": 2027, "month": 12, "day": 31}}), + ] # fmt: skip + for src, bill, user in cases: + py = json.loads(json.dumps(compute_schedule(src, bill, user), ensure_ascii=False)) + assert _ts_schedule(src, bill, user) == py + + +def test_패널_훅_draft_없으면_빈칸(): + tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text("utf-8") + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + ui = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_UI.ts").read_text("utf-8") + assert "{ builders: {}, extend: [], panels: [] }" in tabs # draft 없으면 카드 없음 + assert "...DRAFT.panels.map((make) => make(() => user, setUser))" in shell + assert "DRAFT.panels.push(" in ui diff --git a/resources/tester/test_265_note_cad.py b/resources/tester/test_265_note_cad.py new file mode 100644 index 000000000..79a8fee33 --- /dev/null +++ b/resources/tester/test_265_note_cad.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +"""PLAN 265 — B06 [상세] 표 위 CAD 칸 · 접기 · 차례 · 구조물 글 · 관 길이 정수. + +- 265-1 상세 창 차례 = 그림 → 표 → CAD 칸 · CAD 칸 접기(일반 측점 접힘 · 구조물 측점 폄). +- 265-2 미리 넣는 글 = 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 · 배관 = 원형 마크 + (TS = 파이썬 짝 · `helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림). +- 265-3 원형 마크 L = 사사오입 정수(TS `pipeLengthWhole` = 파이썬 짝 같은 값). + +./venv/Scripts/python.exe -m pytest resources/tester/test_265_note_cad.py -q +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B05_Profile.B05_Profile_Structures_Repository import structures_file_path # noqa: E402 +from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402 + load_note_sources, + note_entities, + structure_note_lines, + with_structure_note, +) + +HERE = Path(__file__).resolve().parent +_PIPE = {"pipe_diameter_mm": 800} + + +def _helper(mode: str, payload) -> list: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_260_note_cad.cjs"), mode], + input=json.dumps(payload, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _labels(entities: list[dict]) -> list[str]: + return [e["shapeData"]["label"] for e in entities if e["type"] == "Text"] + + +def _strip(entities: list[dict]) -> list[dict]: + return [{k: v for k, v in e.items() if k != "id"} for e in entities] + + +def _case(things: list, chainage: float, culvert: dict | None, design: dict) -> dict: + return { + "section": {"chainage_m": chainage, "culvert": culvert, "design": design}, + "station": "NO.2", + "things": things, + "chainage": chainage, + "interval": 20.0, + } + + +def _read(rel: str) -> str: + return (PROJECT_ROOT / rel).read_text(encoding="utf-8") + + +# ── 265-1 차례 · 접기 ────────────────────────────────────────────────── + + +def test_상세_창_차례는_그림_표_CAD_칸(): + detail = _read("B06_Section/B06_Section_UI_Cross_Detail.ts") + assert "body.append(cad, tableBox, noteBox);" in detail + # CAD 칸 = 공용 접기 꼴(머리 한 줄 · 캐럿) · 펼 때 한 번 만듦 + assert 'noteBox.className = "b06-detail__notebox ui-collapsible";' in detail + assert 'noteHead.className = "b06-detail__notehead ui-collapsible__title";' in detail + assert 'if (!noteBox.classList.toggle("is-collapsed")) openNote();' in detail + + +def test_열_때_기본_일반_측점_접힘_구조물_측점_폄(): + detail = _read("B06_Section/B06_Section_UI_Cross_Detail.ts") + start = detail.index('noteBox.classList.add("is-collapsed");') + rule = detail[start : detail.index("const cad = ", start)] + # 접힘으로 시작 → 그 측점에 놓인 구조물(`thingsAt`)이 있으면 폄 · 사용자가 먼저 누르면 그대로 + assert "thingsAt(list, section.chainage_m).length" in rule + assert 'noteBox.classList.remove("is-collapsed");' in rule and "touched" in rule + # 판정 = 놓인 구조물 수(구조물 측점 1 이상 · 일반 측점 0) — 아래 거울 시험 `at` + [structure, plain] = _helper( + "mirror", + [_case(_THINGS, 40.0, None, {}), _case(_THINGS, 20.0, None, {})], + ) + assert structure["at"] == 5 and plain["at"] == 0 + + +# ── 265-2 구조물 조합 줄 ─────────────────────────────────────────────── + +_A = {"layer": "system", "owner": None, "key": "SS000055", "name": "기슭막이 메쌓기 1.2m 기초무"} +_B = {"layer": "system", "owner": None, "key": "SS000070", "name": "돌붙임 찰붙임"} +_C = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "옹벽 콘크리트 H=2.0"} +_BODY = {"layer": "system", "owner": None, "key": "SS000001", "name": "흄관 1200"} +_THINGS = [ + # 배관 = 본체(옛 summary_item · 「관」 grv6lny) 줄 없음 · 보호공 · 도수로 조합만 + { + "name": "배관", + "pipe": True, + "at": 40.2, + "summary_item": _BODY, + "group_items": {"grv6lny": _BODY, "gnbf9m9": _A, "gchuteadd1": _B}, + }, + # 옹벽 = 본체 + 그룹(같은 조합 = 한 줄) + {"name": "옹벽", "pipe": False, "at": 40.0, "summary_item": _C, "group_items": {"gx": _C}}, + # 조합 미지정 구조물 + {"name": "돌쌓기(찰)", "pipe": False, "at": 39.6}, + # 미지정 배관(원형 마크가 대신 · 줄 없음) · 모양 틀린 참조 = 미지정 + {"name": "배관", "pipe": True, "at": 40.0, "group_items": {"bad id": _A}}, + {"name": "가드레일", "pipe": False, "at": 40.0, "summary_item": {"key": ""}}, + # 이웃 측점 구조물 = 뺌 + {"name": "옹벽", "pipe": False, "at": 40.6, "summary_item": _C}, +] +_LINES = [ + "기슭막이 메쌓기 1.2m 기초무", + "돌붙임 찰붙임", + "옹벽 콘크리트 H=2.0", + "돌쌓기(찰) 조합 미지정", + "가드레일 조합 미지정", +] + + +def test_구조물_여럿_글은_조합_이름_미지정_배관_본체_뺌_TS_파이썬_같음(): + culvert = {**_culvert_set(_PIPE), "chainage_m": 40.2} + cases = [ + _case(_THINGS, 40.0, culvert, {"pipe_length_m": 8.5}), + _case(_THINGS, 40.0, None, {}), + _case(_THINGS[5:], 40.0, None, {}), + ] + mirrored = _helper("mirror", cases) + for case, ts in zip(cases, mirrored, strict=True): + section = case["section"] + lines = structure_note_lines(case["things"], 40.0) + py = note_entities(40.0, section["culvert"], section["design"], "NO.2", lines) + assert ts["lines"] == lines + assert _strip(ts["entities"]) == _strip(py) + full, bare, none = mirrored + assert full["lines"] == _LINES + # 배관 = 원형 마크(원 안 배수관 · Φ · L 정수) · 오른쪽 측점 + 조합 줄 + assert [e["type"] for e in full["entities"][:2]] == ["Circle", "Text"] # 지시선 없음(169) + assert _labels(full["entities"]) == ["배수관", "Φ800m/m", "L=9m", "NO.2", *_LINES] + assert "흄관 1200" not in _labels(full["entities"]) + # 원형 마크 없음(관 없는 구조물 측점) = 측점 + 줄 · 구조물 없는 측점 = 빈 도면 + assert _labels(bare["entities"]) == ["NO.2", *_LINES] + assert none["entities"] == [] and none["at"] == 0 + + +def test_글_줄_재료는_관_지점_구조물_배치_종류_이름(): + payload = { + "pipes": [ + {"chainage_m": 40.2, "facility": "pipe", "group_items": {"gnbf9m9": _A}}, + {"chainage_m": 60.0, "facility": "box_culvert"}, + ], + "structures": [ + {"type_id": "retaining_wall", "chainage_m": 40.0, "summary_item": _C}, + {"type_id": "guardrail", "start_m": 30.0, "end_m": 80.0}, + {"type_id": "mystery", "chainage_m": None, "start_m": None}, + ], + "types": [ + {"type_id": "pipe", "name": "배관"}, + {"type_id": "box_culvert", "name": "BOX암거"}, + {"type_id": "retaining_wall", "name": "옹벽"}, + {"type_id": "guardrail", "name": "가드레일"}, + ], + } + things = _helper("load", payload) + assert [(t["name"], t["pipe"], t["at"]) for t in things] == [ + ("배관", True, 40.2), + ("BOX암거", False, 60.0), + ("옹벽", False, 40.0), + ("가드레일", False, 30.0), # 구간형 = 시작 측점(기준 측점 없을 때) + ] + assert things[0]["group_items"] == {"gnbf9m9": _A} and things[2]["summary_item"] == _C + + +def test_파이썬_짝_재료는_정본_관_지점_구조물_배치(tmp_path): + path = pipe_points_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + point = {"chainage_m": 40.2, "lonlat": [0, 0], "facility": "pipe", "options": _PIPE} + point["group_items"] = {"gnbf9m9": _A} + path.write_text(json.dumps({"points": [point]}, ensure_ascii=False), encoding="utf-8") + spath = Path(structures_file_path(str(tmp_path))) + spath.parent.mkdir(parents=True, exist_ok=True) + wall = { + "structure_id": "w1", + "type_id": "retaining_wall", + "placement": "point", + "chainage_m": 40.0, + "options": {}, + "memo": "", + "placement_source": "manual", + "status": "draft", + "revision": 1, + "geometry": None, + "summary_item": _C, + } + doc = {"revision": 1, "structures": [wall]} + spath.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + sources = load_note_sources(tmp_path) + assert [(t["name"], t["at"]) for t in sources[1]] == [("배관", 40.2), ("옹벽", 40.0)] + seeded = with_structure_note({"pipe_length_m": 8.4}, 40.0, sources, "NO.2") + labels = _labels(seeded["structure_note_cad"]["entities"]) + # 구조물마다 조합 줄 → 좌측 패널 옵션 줄(166-1) · 옵션 없는 옹벽은 조합 줄만 + assert labels[:5] == ["배수관", "Φ800m/m", "L=8m", "NO.2", _A["name"]] + assert labels[5].startswith("관: 관경 800") and labels[-1] == _C["name"] + assert all(": " in line for line in labels[5:-1]) + + +# ── 265-3 관 길이 정수 ───────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("length", "label"), + [(8.5, "L=9m"), (8.4, "L=8m"), (8.45, "L=8m"), (7.0, "L=7m"), (None, "L=")], +) +def test_원형_마크_L_은_사사오입_정수_TS_파이썬_같음(length, label): + design = {} if length is None else {"pipe_length_m": length} + culvert = {**_culvert_set(_PIPE), "chainage_m": 40.0} + [ts] = _helper("mirror", [_case([], 40.0, culvert, design)]) + py = note_entities(40.0, culvert, design, "NO.2", []) + assert _labels(ts["entities"])[2] == label + assert _labels(py)[2] == label diff --git a/resources/tester/test_266_detail_home.py b/resources/tester/test_266_detail_home.py new file mode 100644 index 000000000..f3ea83ef2 --- /dev/null +++ b/resources/tester/test_266_detail_home.py @@ -0,0 +1,85 @@ +"""PLAN 266-1 — B06 [상세] 처음 가로 범위 = 좌측 「횡단 보기 설정」 반폭(개별 표시 반폭이면 그것). + +짝: `B06_Section/B06_Section_UI_Section_View.ts`(`detailChart` 처음 배율) · +`B06_Section_UI_Cross_Detail.ts`(`attachDetailZoom` homeScale) · `A00_Common/b_svg_zoom_pan.ts`. +실제 TS 를 `helper_266_detail_home.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +B06 = os.path.join(ROOT, "B06_Section") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(name: str) -> str: + with open(os.path.join(B06, name), encoding="utf-8") as f: + return f.read().replace(chr(13), "") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_266_detail_home.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음_가로_반폭_15(out): + # 칸 맞춤이 가로를 넓혀도 처음 배율로 되좁힘 = −15 ~ +15 + case = out["global15"] + assert case["widened"] is True and case["homeScale"] > 1 + assert case["first"] == [-15, 15] + + +def test_개별_반폭_측점(out): + assert out["station10"]["first"] == [-10, 10] + + +def test_안_넓힌_칸_그대로(out): + case = out["tall15"] + assert case["widened"] is False and case["homeScale"] == 1 + assert case["first"] == [-15, 15] + + +@pytest.mark.parametrize("name", ["global15", "station10", "tall15"]) +def test_처음으로_같은_범위_줌_이동_그대로(out, name): + case = out[name] + assert case["reset"] == case["first"] # [⤢] + assert case["dblclick"] == case["first"] # 더블클릭 + assert case["wheelScale"] == pytest.approx(1.15) # 휠 줌 + assert case["panned"] is True # 가운데 단추 끌기 + assert case["minScale"] == 1 # 줄이면 칸 맞춤 전체까지 + + +def test_배선(): + view = _read("B06_Section_UI_Section_View.ts") + assert ( + "const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;" + in view + ) + assert "return { svg, axes, homeScale };" in view + # 반폭 = 개별 표시 반폭 › 좌측 「횡단 보기 설정」(카드와 같은 길) + assert ( + "effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth)" + in view + ) + src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split()) + assert "drawn.homeScale" in src and "homeScale, home, onChange" in src # 295-2 첫 보기 + assert "homeScale: drawn.homeScale" in src diff --git a/resources/tester/test_267_1_pipe_info_font.py b/resources/tester/test_267_1_pipe_info_font.py new file mode 100644 index 000000000..d819bb537 --- /dev/null +++ b/resources/tester/test_267_1_pipe_info_font.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""PLAN 267-1 — B06 횡단 카드 「관매설 …」 글 크기 = 「노폭 …」 글 크기 · 줄 겹침 없음. + +./venv/Scripts/python.exe -m pytest resources/tester/test_267_1_pipe_info_font.py -q -p no:cacheprovider --basetemp=tmp/pt +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CSS = (ROOT / "B06_Section" / "B06_Section_UI_Style_Cross_Areas.css").read_text(encoding="utf-8") +TYPES = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Types.ts").read_text(encoding="utf-8") + + +def _font_px(selector: str) -> float: + """셀렉터 블록의 마지막 font-size(px) — 뒤에 오는 규칙이 이긴다.""" + blocks = re.findall(re.escape(selector) + r"\s*\{([^}]*)\}", CSS) + sizes = [float(m) for b in blocks for m in re.findall(r"font-size:\s*([\d.]+)px", b)] + assert sizes, selector + return sizes[-1] + + +def test_관_정보_글_크기가_노폭_라벨과_같다() -> None: + road = _font_px(".b06-chart__carriageway-label") + info = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info") + assert road == info == 11.0 + # 다른 culvert-label(집수정 · 박스 이름)은 그대로 9px + assert _font_px(".b06-chart__culvert-label") == 9.0 + + +def test_관_정보_글_줄이_노폭_라벨과_겹치지_않는다() -> None: + step = float(re.search(r"- (\d+) \* \(stacked \+ 1\)", TYPES).group(1)) + font = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info") + road_font = _font_px(".b06-chart__carriageway-label") + asc, desc = 0.8, 0.2 # 글자 상자 근사 — 기준선 위 80% · 아래 20% + road_top = 0 - asc * road_font # 노폭 라벨 기준선 y=0 + for n in (1, 2, 3): # 관 정보 글이 쌓인 줄 수 + info_y = -step * n + info_bottom = info_y + desc * font + assert info_bottom < road_top, (n, info_bottom, road_top) + if n > 1: # 윗줄 바닥 < 아랫줄 윗면 + upper_bottom = -step * n + desc * font + lower_top = -step * (n - 1) - asc * font + assert upper_bottom < lower_top, n diff --git a/resources/tester/test_268_b07_cross_ditch_pipe_color.py b/resources/tester/test_268_b07_cross_ditch_pipe_color.py new file mode 100644 index 000000000..d55a4a506 --- /dev/null +++ b/resources/tester/test_268_b07_cross_ditch_pipe_color.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""268 B07 횡단도 — 노란 측구 덧그림 · 배수관 · 박스암거 색. + +못박는 것 + - 268-1 측구 구간 구조물 선(b08-structure)도 design_ids 에 실려 화면 덧그림이 갈아 끼울 때 걷힘 + (장 · 단일 도면 모두) · 설계선 · 측구 본 그림(구조물 선 자체)은 그대로 남음. + - 268-2 박스암거 · 배수관 덧그림은 구조물(노랑) · 설계선(보라) · 지반선과 다른 색 한 벌. +""" + +import re +from pathlib import Path + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DESIGN_COLOR, + DESIGN_LAYER_ID, + GROUND_COLOR, + ROCK_COLOR, + STRUCTURE_COLOR, + STRUCTURE_LAYER_ID, + build_cross_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import build_cross_sheet, plan_cross_sheets +from B07_DesignDetail.B07_DesignDetail_Engine_Template import use_project_templates + +ROOT = Path(__file__).resolve().parents[2] + +_DESIGN = { + "ditch_enabled": True, + "ditch_side": "left", + "ditch": {"type": "standard", "top_width_m": 1.0}, + "road_edges": {"left": {"offset_m": -1.0}}, +} +_LINE = [ + {"offset_m": x, "elevation_m": 100.0 + (0.0 if x >= -1.0 else -0.3)} + for x in (-3.0, -2.0, -1.0, 0.0, 3.0) +] + + +def _source(chainage: float) -> dict: + return { + "chainage_m": chainage, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.1 * x} + for x in range(-5, 6) + ], + } + + +def _ditch_ids(entities: list[dict]) -> list[str]: + return [e["id"] for e in entities if e["lineColor"] == STRUCTURE_COLOR] + + +def test_측구_구조물_선_design_ids_장() -> None: + use_project_templates(None) + sheet = plan_cross_sheets([0.0, 20.0], {"default": [2, 1]})[0] + sections = [ + { + "chainage": c, + "source": _source(c), + "design": _DESIGN, + "design_line": _LINE, + "quantity_table": {"ground": 1.0, "planned": 0.5}, + "title": f"No.{int(c // 20)}", + } + for c in (0.0, 20.0) + ] + drawing = build_cross_sheet(sheet, sections) + by_id = {e["id"]: e for e in drawing["entities"]} + ditch = _ditch_ids(drawing["entities"]) + assert len(ditch) == 2 # 측점마다 측구 구간 선 하나 + for placement in drawing["cross_placements"]: + ids = placement["design_ids"] + assert len(set(ids) & set(ditch)) == 1 # 자기 측점 측구 선만 + assert all(by_id[i]["lineColor"] in (DESIGN_COLOR, STRUCTURE_COLOR) for i in ids) + # 걷기는 화면이 하고, 서버 도면은 측구 구조물 선을 그대로 가짐 + assert all(by_id[i]["lineColor"] == STRUCTURE_COLOR for i in ditch) + + +def test_측구_구조물_선_design_ids_단일_도면() -> None: + use_project_templates(None) + drawing = build_cross_drawing( + _source(0.0), "d", _LINE, _DESIGN, {"ground": 1.0, "planned": 0.5}, "No.0" + ) + ditch = [e for e in drawing["entities"] if e["layerId"] == STRUCTURE_LAYER_ID] + assert len(ditch) == 1 + design_ids = drawing["cross_placements"][0]["design_ids"] + assert ditch[0]["id"] in design_ids + assert all( + e["layerId"] in (DESIGN_LAYER_ID, STRUCTURE_LAYER_ID) + for e in drawing["entities"] + if e["id"] in design_ids + ) + + +def test_측구_없으면_design_ids_설계선만() -> None: + use_project_templates(None) + drawing = build_cross_drawing( + _source(0.0), "d", _LINE, None, {"ground": 1.0, "planned": 0.5}, "No.0" + ) + ids = drawing["cross_placements"][0]["design_ids"] + layers = {e["layerId"] for e in drawing["entities"] if e["id"] in ids} + assert layers == {DESIGN_LAYER_ID} + + +def test_배관_박스암거_색_따로() -> None: + src = (ROOT / "B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts").read_text(encoding="utf-8") + pipe = re.search(r'const PIPE_COLOR = "(#[0-9a-fA-F]{6})"', src) + assert pipe, "PIPE_COLOR 없음" + color = pipe.group(1).lower() + assert color not in {STRUCTURE_COLOR, DESIGN_COLOR, GROUND_COLOR, ROCK_COLOR} + assert color not in { + "#8ea0b5", + "#e8edf4", + "#ffe066", + "#ff4d4d", + } # 표 선 · 글 · 값 · 구조물 평면 색 + # 박스암거 · 배수관만 pipeSvg 로 — 구조물(돌붙임 · 여울)은 그대로 노랑 + assert "appendBoxOverlay(pipeSvg," in src and "appendCulvertOverlay(\n pipeSvg," in src + assert "appendRevetmentOverlay(svg," in src and "appendFordOverlay(svg," in src + assert "harvest(pipeSvg, PIPE_STYLE, frame)" in src diff --git a/resources/tester/test_269_panel_form.py b/resources/tester/test_269_panel_form.py new file mode 100644 index 000000000..33ff60bd2 --- /dev/null +++ b/resources/tester/test_269_panel_form.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +"""269 좌측 패널 컨테이너 공용 양식 — 기준 = B05/B06 「구조물 배치」. + +못박는 것 + - 공용 양식 = ui_template 한 곳(`.ui-panel-form` · `.ui-panel-btn` · 카드 `createPanelCard`) · + 칸 높이 · 라벨 틈 토큰 = theme 한 곳 · 「구조물 배치」도 같은 토큰. + - B07 컨테이너 = 공용 카드 · 도면 단추 = `.ui-panel-btn` · 페이지 덧칠(크기 · 글자 · 바탕) 없음. + - B06 컨테이너(횡단 보기 설정 · 표준 횡단면 설정) = 칸 틀 `createSectionFrame` 이 공용 양식을 붙임 · + 단추 · 입력칸 = 공용 함수(createButton · createInputField · createSelectField) · B06 덧칠 없음. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +THEME = _read("ui_template/ui_template_theme.css") +OVERLAY = _read("ui_template/ui_template_overlay.css") +COLLAPSIBLE = _read("ui_template/ui_template_collapsible.ts") +B05_STRUCT_CSS = _read("B05_Profile/B05_Profile_UI_Style_Structures.css") +B07_CSS = _read("B07_DesignDetail/B07_DesignDetail_UI_Style.css") +B07_PANELS = _read("B07_DesignDetail/B07_DesignDetail_UI_Panels.ts") +B07_PLAN = _read("B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts") +DESIGN_PANEL = _read("A00_Common/design/design_panel.ts") +B06_CSS = _read("B06_Section/B06_Section_UI_Style.css") +B06_PANEL = _read("B06_Section/B06_Section_UI_Page_Panel.ts") +B06_STD = _read("B06_Section/B06_Section_UI_Standard_Panel.ts") + + +def _block(css: str, selector: str) -> str: + m = re.search(r"(?:^|\n)" + re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_공용_양식_토큰은_theme_한_곳_구조물_배치도_같은_토큰(): + assert "--panel-control-h: 26px;" in THEME + assert "--panel-label-gap: 2px;" in THEME + assert "--b05-control-h: var(--panel-control-h);" in B05_STRUCT_CSS + assert "--b05-label-gap: var(--panel-label-gap);" in B05_STRUCT_CSS + + +def test_공용_양식_칸_라벨_단추_규칙(): + field = _block( + OVERLAY, + '.ui-panel-form input:not([type="checkbox"], [type="radio"], [type="range"]),\n' + ".ui-panel-form select", + ) + assert "height: var(--panel-control-h);" in field + assert "padding: 0 6px;" in field + assert "font-size: var(--text-caption);" in field + label = _block(OVERLAY, ".ui-panel-form .ui-field,\n.ui-panel-form .b05-route__field") + assert "gap: var(--panel-label-gap);" in label + assert "display: none;" in _block(OVERLAY, ".ui-panel-form .ui-field__error:not(.is-visible)") + button = _block(OVERLAY, ".ui-panel-form .ui-btn:not(.ui-help-btn)") + # 「구조물 배치」 단추(b05-route__irregular-btn)와 같은 크기 + assert "padding: var(--spacing-8);" in button + assert "font-size: var(--text-caption);" in button + assert "border-radius: var(--radius-inputs);" in button + raw = _block(OVERLAY, ":where(.ui-panel-btn)") + for decl in ( + "padding: var(--spacing-8);", + "border-radius: var(--radius-inputs);", + "background: var(--color-surface);", + "font-size: var(--text-caption);", + ): + assert decl in raw, decl + # 아래 단추 묶음은 컨테이너 밖 — 공용 양식이 `.ui-sidebar-actions` 를 겨누지 않음 + form_rules = OVERLAY[OVERLAY.index("좌측 패널 컨테이너 공용 양식(269)") :] + form_rules = form_rules[: form_rules.index("/* 카드 안에 다시 카드")] + assert "ui-sidebar-actions {" not in form_rules and "ui-sidebar-actions ." not in form_rules + + +def test_공용_카드_함수(): + assert "export function createPanelCard(" in COLLAPSIBLE + assert "ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form" in COLLAPSIBLE + + +def test_B07_컨테이너_공용_카드_도면_단추_공용_꼴(): + assert "createPanelCard(`${group.label} ${items.length}`)" in B07_PANELS + assert "createPanelCard(`평면도 ${items.length}`)" in B07_PLAN + for src in (B07_PANELS, B07_PLAN): + assert "ui-collapsible ui-sidebar-section" not in src # 손으로 짠 카드 없음 + assert B07_PANELS.count('"b07-drawing-button ui-panel-btn"') == 2 + assert '"ui-panel-btn b07-plan-manage-open"' in B07_PLAN + + +def test_B07_페이지_덧칠_없음(): + assert not re.search(r"(?:^|\n)\.b07-drawing-group\s*\{", B07_CSS) + assert ".b07-drawing-group h3 {" not in B07_CSS + assert ".b07-plan-manage-open {" not in B07_CSS + button = _block(B07_CSS, ".b07-drawing-button") + for prop in ("min-height", "padding", "border-radius", "background", "font-size"): + assert not re.search(r"(?:^|\s)" + prop + r"\s*:", button), prop + assert "font-size" not in _block(B07_CSS, ".b07-drawing-button__name") + assert ".b07-cross-layout .ui-btn" not in B07_CSS + assert ".b07-cross-layout .ui-field__error" not in B07_CSS + + +def test_B06_컨테이너_공용_양식(): + frame = DESIGN_PANEL[DESIGN_PANEL.index("export function createSectionFrame(") :] + frame = frame[: frame.index("\n}\n")] + cross = frame[frame.index("b06-profile__group ui-collapsible") :] + assert "ui-panel-form" in cross.splitlines()[0] + for src in (B06_PANEL, B06_STD): + assert "createSectionFrame(" in src or "createButton(" in src + assert " str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_270_1_cross_map.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_횡단_안_누름도_지도_칠함(out): + # 목록 · 카드로 관을 고르면(from b06) 지도 관 · 마름모가 켜지고 넘김 적용은 안 돎 · 알림 없이 칠함 + assert out["b06Pipe"]["pipe"] == 95.38 and out["b06Pipe"]["station"] == 95.38 + assert out["b06Pipe"]["reapplied"] == 0 and out["b06Pipe"]["notify"] is False + + +def test_관_없는_카드는_옛_관_강조_풂(out): + assert out["b06Card"]["pipe"] is None and out["b06Card"]["station"] == 80 + + +def test_지도_고름은_넘김_적용(out): + # 지도(from map)에서 고르면 카드 · 폼 · 알약 · 표 열을 세우는 넘김 적용이 한 번 돎 + assert out["fromMap"]["pipe"] == 292.22 and out["fromMap"]["reapplied"] == 1 + + +def test_노면공은_관_강조_없음(out): + # 노면공(범위) 고름 = 관 없음 → 유역 · 관 마커 끔(노면공 범위가 관을 덮어 켜지 않음) + assert out["pave"]["pipe"] is None and out["pave"]["station"] == 30 + + +def test_풀기_숨은_보기_다시_보임(out): + assert out["release"]["pipe"] is None and out["release"]["station"] is None + assert out["hidden"] == {"calls": 0, "reapplied": 2} # 숨은 횡단 보기는 종단 보기 몫 + assert out["show"]["pipe"] == 95.38 and out["show"]["reapplied"] == 3 + assert out["releaseKeepsMap"] == 95.38 # release 는 횡단 강조만 — 지도는 값이 비면 구독이 풂 + + +def test_횡단_보기_이음(): + view = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert "onBasinSelected: (chainageM) => pickFromMap(chainageM)" in view + assert "onPipeSelected: (chainageM) => pickFromMap(chainageM)" in view + assert '{ from: "map" }' in view and "NO_SELECTION" in view + # 배수 재계산 뒤 다시 얹음 — 횡단 보기가 보일 때만 + assert 'shared.view() === "cross"' in view and "paintMapPick(shared.profile.side" in view + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "paintMap: (selection) =>" in cross and "paintMapPick(shared.profile.side" in cross diff --git a/resources/tester/test_272_1_station_list.py b/resources/tester/test_272_1_station_list.py new file mode 100644 index 000000000..82181a3d4 --- /dev/null +++ b/resources/tester/test_272_1_station_list.py @@ -0,0 +1,185 @@ +"""272-1 측점 한 벌 — 화면(TS)과 서버(파이썬)가 같은 측점 목록을 냄(요구 167 · 측점 겹침 근본). + +- 심는 자리: TS `plantedChainages` ↔ 서버 `resolve_extra_stations`(관 · 독립 기슭막이 시작 · + 기준 · 끝 · A군 · C군 구간형 시작 · 기준 · 끝 · 물넘이포장 기준(286-1) · 다른 군 · 점형 C/D 는 안 심음). +- 같은 m = 측점 하나 · 구조물이 위 · 떠나면 일반 측점: TS `oneStationList` ↔ 서버 + `_merge_irregular_into_longitudinal`. +진짜 TS 는 `helper_272_1_station_list.cjs` 가 Node 로 돌림. +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Sections import resolve_extra_stations # noqa: E402 +from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 +from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402 + +PIPES = [ + {"chainage_m": 95.0, "source": "user"}, + {"chainage_m": 160.0, "source": "user", "facility": "box_culvert"}, + {"chainage_m": 230.0, "source": "user", "facility": "ford_pavement"}, + {"chainage_m": 300.0, "source": "user", "facility": "ford_bridge"}, + { + "chainage_m": 400.0, + "source": "user", + "facility": "revetment", + "start_m": 395.0, + "end_m": 410.0, + }, +] +STRUCTURES = [ + {"type_id": "cross_drain_exposed", "placement": "point", "chainage_m": 120.0}, + { + "type_id": "retaining_wall", + "placement": "interval", + "chainage_m": 510.0, + "start_m": 500.0, + "end_m": 530.0, + }, + { # 옹벽 끝 530 과 같은 자리에서 시작 — 겹친 자리도 그대로 셈(합치기는 측점 만들기 몫) + "type_id": "berm", + "placement": "interval", + "chainage_m": 545.0, + "start_m": 530.0, + "end_m": 560.0, + }, + {"type_id": "erosion_check", "placement": "point", "chainage_m": 600.0}, # D군 점형 + { + "type_id": "pavement_concrete", + "placement": "interval", + "chainage_m": 650.0, + "start_m": 650.0, + "end_m": 700.0, + }, + {"type_id": "evac_road", "placement": "point", "chainage_m": 720.0}, +] +EXPECTED = [95, 120, 160, 230, 300, 395, 400, 410, 500, 510, 530, 530, 545, 560] + +# 서버 종단 측점(맞추기 전) — 격자 0~100 · 옛 관 측점 95.38 · 구조물이 떠난 격자 자리 40(`grid`) · +# 옛 판 「regular + 글」 60 · 새 목록 = 격자 위 100(구조물이 위) · 새 자리 35 +SERVER = [ + {"chainage_m": 0.0, "kind": "bp"}, + {"chainage_m": 20.0, "kind": "regular"}, + {"chainage_m": 40.0, "kind": "irregular", "grid": True, "structure": "옹벽"}, + {"chainage_m": 60.0, "kind": "regular", "structure": "옛 글"}, + {"chainage_m": 80.0, "kind": "regular"}, + {"chainage_m": 95.38, "kind": "irregular", "structure": "파형강관 D800"}, + {"chainage_m": 100.0, "kind": "regular"}, +] +FRESH = [ + {"chainage_m": 35.0, "kind": "irregular", "structure": "파형강관 D800"}, + {"chainage_m": 100.0, "kind": "irregular", "grid": True, "structure": "BOX암거"}, +] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _types() -> list[dict]: + return [ + { + "type_id": item.type_id, + "group": item.group, + "placement": item.placement, + "managed_by": item.managed_by, + } + for item in structure_type_map().values() + ] + + +FIXTURE = ROOT / "resources/tester/fixtures/b05_192_1_moved_pipe_longitudinal.json" + + +def _snapped(data: dict) -> list[dict]: + """옛 소수 구조물 측점을 정수 m 로 옮긴 새 목록(272-4 자동 맞춤 뒤).""" + return [ + {**s, "chainage_m": float(math.floor(s["chainage_m"] + 0.5))} + for s in data["stations"] + if s.get("kind") == "irregular" + ] + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s272") / "in.json" + fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) + payload = { + "pipes": PIPES, + "structures": STRUCTURES, + "types": _types(), + "server": SERVER, + "fresh": FRESH, + "nodes": fixture["profile_alignment"]["base_pvi"], + "gone": [s["chainage_m"] for s in fixture["stations"] if s.get("kind") == "irregular"], + "born": [s["chainage_m"] for s in _snapped(fixture)], + } + source.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_272_1_station_list.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_planted_mirror(tmp_path: Path, ts) -> None: + path = tmp_path / "B05_Profile/route/structures.json" + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False), + encoding="utf-8", + ) + server = [mark for mark, _label in resolve_extra_stations(tmp_path, parse_pipe_points(PIPES))] + assert sorted(server) == EXPECTED + assert ts["planted"] == EXPECTED + + +def test_one_list_mirror(tmp_path: Path, ts) -> None: + rel = "B06_Section/longitudinal/longitudinal.json" + path = tmp_path / rel + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"stations": SERVER}, ensure_ascii=False), encoding="utf-8") + _merge_irregular_into_longitudinal(tmp_path, rel, FRESH) + merged = json.loads(path.read_text(encoding="utf-8"))["stations"] + assert [s["chainage_m"] for s in merged] == [0, 20, 35, 40, 60, 80, 100] + assert [s["chainage_m"] for s in ts["list"]] == [s["chainage_m"] for s in merged] + # 일반 측점은 글을 떼고 일반으로 · 같은 m 는 구조물 측점 하나(새 목록 것) + for side in (merged, ts["list"]): + by = {s["chainage_m"]: s for s in side} + assert by[40]["kind"] == "regular" and "structure" not in by[40] and "grid" not in by[40] + assert "structure" not in by[60] + assert by[100]["structure"] == "BOX암거" + assert 95.38 not in by + + +def test_moved_nodes_mirror(tmp_path: Path, ts) -> None: + """옛 소수 → 정수 m 맞춤 뒤 꼭짓점 — 화면(저장 전 그림)과 서버([저장] 뒤)가 같음.""" + rel = "B06_Section/longitudinal/longitudinal.json" + path = tmp_path / rel + path.parent.mkdir(parents=True) + fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) + path.write_text(json.dumps(fixture, ensure_ascii=False), encoding="utf-8") + _merge_irregular_into_longitudinal(tmp_path, rel, _snapped(fixture)) + server = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]["base_pvi"] + assert [(n["chainage_m"], n["elevation_m"]) for n in ts["nodes"]] == [ + (n["chainage_m"], n["elevation_m"]) for n in server + ] diff --git a/resources/tester/test_272_2_station_sync.py b/resources/tester/test_272_2_station_sync.py new file mode 100644 index 000000000..34a2163a6 --- /dev/null +++ b/resources/tester/test_272_2_station_sync.py @@ -0,0 +1,193 @@ +"""272-2 — 구조물 측점을 지금 목록 전체로 다시 맞춤(요구 167 · 측점 겹침 근본). + +서버는 관 · 구조물 목록 **전체**로 종단 정본의 구조물 측점을 다시 맞춘다 — 빈 목록이어도 옛 구조물 +측점을 지우고, 구조물이 떠난 격자 자리는 일반 측점으로 되돌리며(횡단 파일 글까지), 같은 m 면 +측점 하나(구조물이 위 · `grid`). 종단 정본에 없는 측점의 횡단 행도 지운다(B08 이 옛 자리를 안 셈). +""" + +from __future__ import annotations + +import asyncio +import json +import math +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B05_Profile.B05_Profile_Engine_Sections import cross_filename # noqa: E402 +from B05_Profile.B05_Profile_Repository import sync_cross_section_rows # noqa: E402 +from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402 + +REL = "B06_Section/longitudinal/longitudinal.json" +PIPE = "파형강관 D800" + + +def _write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + +def _cross(root: Path, chainage: float) -> Path: + return root / "B06_Section/cross_sections" / cross_filename(chainage) + + +def _setup(root: Path) -> None: + """격자 0~100(20 m) · 옛 관 측점 95.38 · 옛 판 「regular + 글」 60 m.""" + grid = [ + {"chainage_m": float(c), "kind": "bp" if c == 0 else "regular"} for c in range(0, 101, 20) + ] + grid[3]["structure"] = "옛 글" + stations = sorted( + [*grid, {"chainage_m": 95.38, "kind": "irregular", "structure": PIPE}], + key=lambda s: s["chainage_m"], + ) + _write(root / REL, {"stations": stations}) + for station in stations: + _write(_cross(root, station["chainage_m"]), {**station, "samples": []}) + + +def _stations(root: Path) -> list[dict]: + return json.loads((root / REL).read_text(encoding="utf-8"))["stations"] + + +def _at(root: Path, chainage: float) -> dict: + return next(s for s in _stations(root) if s["chainage_m"] == chainage) + + +def test_관이_격자_위로_가면_측점_하나_구조물이_위(tmp_path: Path) -> None: + _setup(tmp_path) + moved = {"chainage_m": 100.0, "kind": "irregular", "grid": True, "structure": PIPE} + _merge_irregular_into_longitudinal(tmp_path, REL, [moved]) + chainages = [s["chainage_m"] for s in _stations(tmp_path)] + assert 95.38 not in chainages and chainages.count(100.0) == 1 + assert _at(tmp_path, 100.0) == moved + # 옛 판 「regular + 글」 도 글을 지움 — 횡단 파일 글까지 + assert _at(tmp_path, 60.0) == {"chainage_m": 60.0, "kind": "regular"} + cross = json.loads(_cross(tmp_path, 60.0).read_text(encoding="utf-8")) + assert cross["kind"] == "regular" and "structure" not in cross + + +def test_구조물이_떠나면_일반_측점이_돌아옴_빈_목록도(tmp_path: Path) -> None: + _setup(tmp_path) + on_grid = {"chainage_m": 100.0, "kind": "irregular", "grid": True, "structure": PIPE} + _merge_irregular_into_longitudinal(tmp_path, REL, [on_grid]) + _write(_cross(tmp_path, 100.0), {**on_grid, "samples": []}) # 측점 만들기가 쓴 파일 흉내 + _merge_irregular_into_longitudinal(tmp_path, REL, []) + stations = _stations(tmp_path) + assert [s["chainage_m"] for s in stations] == [0.0, 20.0, 40.0, 60.0, 80.0, 100.0] + assert all(s["kind"] in ("bp", "regular") and "structure" not in s for s in stations) + assert all("grid" not in s for s in stations) + cross = json.loads(_cross(tmp_path, 100.0).read_text(encoding="utf-8")) + assert cross["kind"] == "regular" and "structure" not in cross and "grid" not in cross + + +def test_시점_위_구조물이_떠나도_시점은_시점(tmp_path: Path) -> None: + _setup(tmp_path) + at_bp = {"chainage_m": 0.0, "kind": "bp", "grid": True, "structure": PIPE} + _merge_irregular_into_longitudinal(tmp_path, REL, [at_bp]) + assert _at(tmp_path, 0.0) == at_bp + _merge_irregular_into_longitudinal(tmp_path, REL, []) + assert _at(tmp_path, 0.0) == {"chainage_m": 0.0, "kind": "bp"} + + +def test_측점만_옮겨도_계획선_측점_줄을_다시_셈(tmp_path: Path) -> None: + """편집 없는 관 측점 581.9 → 582 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않음.""" + source = PROJECT_ROOT / "resources/tester/fixtures/b05_192_1_moved_pipe_longitudinal.json" + path = tmp_path / REL + _write(path, json.loads(source.read_text(encoding="utf-8"))) + data = json.loads(path.read_text(encoding="utf-8")) + moved = [ + {**s, "chainage_m": 582.0} if s["chainage_m"] == 581.9 else s + for s in data["stations"] + if s.get("kind") == "irregular" + ] + assert _merge_irregular_into_longitudinal(tmp_path, REL, moved) is not None + rows = [row["chainage_m"] for row in _stations_rows(path)] + assert 582.0 in rows and 581.9 not in rows + assert rows == [s["chainage_m"] for s in _stations(tmp_path)] + # 그대로 한 번 더 — 줄이 측점과 같으면 안 건드림 + assert _merge_irregular_into_longitudinal(tmp_path, REL, moved) is None + + +def test_옛_소수_측점을_정수로_옮기면_꼭짓점_편집도_따라감(tmp_path: Path) -> None: + """484.213 · 581.902 · 676.591 · 769.28 → 정수 m — 꼭짓점이 높이 그대로 옮겨 감(지우면 계획선이 + 그 자리에서 바뀜 · 남기면 측점 없는 꼭짓점이 1000% 넘게 꺾음 · 브라우저 실측).""" + source = PROJECT_ROOT / "resources/tester/fixtures/b05_192_1_moved_pipe_longitudinal.json" + data = json.loads(source.read_text(encoding="utf-8")) + _write(tmp_path / REL, data) + snapped = [ + {**s, "chainage_m": float(math.floor(s["chainage_m"] + 0.5))} + for s in data["stations"] + if s.get("kind") == "irregular" + ] + _merge_irregular_into_longitudinal(tmp_path, REL, snapped) + after = json.loads((tmp_path / REL).read_text(encoding="utf-8"))["profile_alignment"] + nodes = {n["chainage_m"]: n["elevation_m"] for n in after["base_pvi"]} + old_nodes = {n["chainage_m"]: n["elevation_m"] for n in data["profile_alignment"]["base_pvi"]} + for old, new in ((484.213, 484.0), (581.902, 582.0), (676.591, 677.0), (769.28, 769.0)): + assert old not in nodes and nodes[new] == old_nodes[old] + offsets = after["edits"]["station_offsets"] + assert "769.280" not in offsets and "769.000" in offsets + + +def _stations_rows(path: Path) -> list[dict]: + return json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]["stations"] + + +class _Cursor: + def __init__(self, rows: list[tuple[int, float]]) -> None: + self.rows = rows + self.moved: list[tuple[float, int]] = [] + self.deleted: list[int] | None = None + + async def __aenter__(self) -> "_Cursor": + return self + + async def __aexit__(self, *_a) -> bool: + return False + + async def execute(self, sql: str, args=None) -> None: + if sql.startswith("UPDATE"): + self.moved.append(tuple(args)) + elif sql.startswith("DELETE"): + self.deleted = list(args) + + async def fetchall(self) -> list[tuple[int, float]]: + return self.rows + + +class _Connection: + def __init__(self, rows: list[tuple[int, float]]) -> None: + self.cursor_ = _Cursor(rows) + + def cursor(self) -> _Cursor: + return self.cursor_ + + +def _sync(rows: list[tuple[int, float]], stations: list[float]) -> tuple[int, _Cursor]: + connection = _Connection(rows) + return asyncio.run(sync_cross_section_rows(connection, 7, stations)), connection.cursor_ + + +def test_옛_소수_측점_행은_새_정수_측점으로_옮김() -> None: + """95.38 → 95 — 사용자 설계 값이 든 행을 그대로 옮김(면적은 저장 뒤 서버 재계산).""" + count, cursor = _sync([(1, 0.0), (2, 95.38), (3, 100.004)], [0.0, 20.0, 95.0, 100.0]) + assert count == 1 and cursor.moved == [(95.0, 2)] and cursor.deleted is None + + +def test_가까운_새_측점이_없으면_지움() -> None: + count, cursor = _sync([(1, 0.0), (2, 95.38), (5, 140.0)], [0.0, 20.0, 100.0]) + assert count == 2 and cursor.moved == [] and cursor.deleted == [2, 5] + + +def test_한_측점에_둘이_다투면_가까운_행만_옮김() -> None: + count, cursor = _sync([(2, 95.38), (6, 94.7), (7, 100.0)], [95.0, 100.0]) + assert count == 2 and cursor.moved == [(95.0, 6)] and cursor.deleted == [2] + + +def test_측점_목록이_비면_아무것도_안_건드림() -> None: + count, cursor = _sync([(1, 0.0), (2, 95.38)], []) + assert count == 0 and cursor.moved == [] and cursor.deleted is None diff --git a/resources/tester/test_273_1_profile_reveal.py b/resources/tester/test_273_1_profile_reveal.py new file mode 100644 index 000000000..13f948ef4 --- /dev/null +++ b/resources/tester/test_273_1_profile_reveal.py @@ -0,0 +1,56 @@ +"""273-1 — 다른 영역(횡단 카드 · 3D · 배수유역도 · 좌측 목록)에서 고르면 종단 그래프가 그 측점을 +가운데로 옮김(요구 168). 칠한 결과(강조된 측점선 · 알약)를 보고 화면 밖일 때만 · 같은 고름을 다시 +칠하면 안 밂 · 그래프 안에서 누른 것(보이는 자리)은 안 움직임. +진짜 TS 는 `helper_273_1_profile_reveal.cjs` 가 Node 로 돌림. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def ts() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_273_1_profile_reveal.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_화면_밖이면_가운데로(ts) -> None: + assert ts["far"] == 3000 - 350 + + +def test_같은_고름을_다시_칠하면_안_밂(ts) -> None: + assert ts["same"] == 100 + + +def test_보이는_자리면_안_움직임(ts) -> None: + assert ts["near"] == 0 + + +def test_그리기_전이면_다음_칠하기에서_옮김(ts) -> None: + assert ts["late"] == 2000 - 350 + + +def test_종단도가_고름마다_부름() -> None: + src = (ROOT / "A00_Common/design/design_profile.ts").read_text(encoding="utf-8") + assert src.count("revealPick();") == 2 diff --git a/resources/tester/test_273_2_preview_stations.py b/resources/tester/test_273_2_preview_stations.py new file mode 100644 index 000000000..69236dacb --- /dev/null +++ b/resources/tester/test_273_2_preview_stations.py @@ -0,0 +1,132 @@ +"""273-2 B06 횡단도 바로 반영 — 초안 자리 횡단 미리 뜨기(요구 168). + +- 미리 뜨기(`sample_structure_sections`)는 파일을 안 쓰고, [저장] 때 정본 쓰기 + (`generate_irregular_sections`)와 같은 횡단을 낸다(같은 길 · 화면이 본 값 = 저장 값). +- 기본 설계(`compute_default_designs`)는 파일 대신 받은 횡단으로도 셈. +- 화면 쪽: 옛 자리 숨김 · 새 자리 얹음 · 이름 · 관 그림 · 늦게 끝난 옛 맞춤이 새 것을 안 덮음 + (`helper_273_2_preview_stations.cjs`). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import numpy as np +import pytest + +import B05_Profile.B05_Profile_Engine_Sections as sections +from B06_Section.B06_Section_Router_Design import compute_default_designs +from common_util.common_util_drainage_pipes import route_signature +from common_util.common_util_route_geometry import RouteVertex + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +LINE = [[0.0, 0.0, 100.0], [350.0, 0.0, 100.0]] +ROUTE = "B05_Profile/route/route.geojson" + + +class _Slope: + """옆으로 기운 지표 — 왼쪽이 높음(상단측 = left).""" + + def sample_xy(self, xy): + points = np.asarray(xy, dtype=np.float64).reshape(-1, 2) + return 100.0 + 0.2 * points[:, 1], np.ones(len(points), dtype=bool) + + +def _write(root: Path, rel: str, data) -> None: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + +def _project(root: Path) -> None: + _write(root, ROUTE, {"geometry": {"type": "LineString", "coordinates": LINE}}) + vertices = [RouteVertex(x=p[0], y=p[1], z=p[2], chainage_m=0.0) for p in LINE] + _write( + root, + "B04_PreProcess/drainage/edits/pipe_points.json", + {"route_signature": route_signature(vertices), "points": []}, + ) + + +def _files(root: Path) -> list[str]: + return sorted(str(p.relative_to(root)) for p in root.rglob("*") if p.is_file()) + + +def test_미리_뜨기는_안_쓰고_저장과_같은_횡단(tmp_path: Path, monkeypatch) -> None: + _project(tmp_path) + monkeypatch.setattr(sections, "build_surface_sampler", lambda *_a, **_k: _Slope()) + before = _files(tmp_path) + stations, crosses = sections.sample_structure_sections( + tmp_path, ROUTE, "csf", "tin", False, extra_stations=((130.0, ""),) + ) + assert _files(tmp_path) == before # 파일 · 폴더 하나도 안 생김 + assert [c["chainage_m"] for c in crosses] == [130.0] and len(stations) == 1 + sections.generate_irregular_sections( + tmp_path, ROUTE, "csf", "tin", False, extra_stations=((130.0, "파형강관 D800"),) + ) + written = json.loads( + (tmp_path / "B06_Section/cross_sections/cross_00130m.json").read_text(encoding="utf-8") + ) + assert written["samples"] == crosses[0]["samples"] + assert written["frame"] == crosses[0]["frame"] + + +def test_기본_설계는_받은_횡단으로도_셈(tmp_path: Path) -> None: + rel = "B06_Section/longitudinal/longitudinal.json" + plan = [{"chainage_m": c, "elevation_m": 851.0} for c in (0.0, 1000.0)] + _write(tmp_path, rel, {"stations": [], "design_profiles": [{"samples": plan}]}) + offsets = np.arange(-20.0, 20.5, 0.5) + cross = { + "chainage_m": 610.0, + "uphill_side": "right", + "samples": [ + {"offset_m": float(o), "elevation_m": 852.0 - 0.1 * float(o), "valid": True} + for o in offsets + ], + } + designs = compute_default_designs(tmp_path, rel, [610.0], crosses={610.0: cross}) + assert [chainage for chainage, _ in designs] == [610.0] + design = designs[0][1] + assert design["status"] == "provisional" and design.get("section_mode") + # 파일에도 없고 받은 횡단에도 없는 자리는 건너뜀 + assert compute_default_designs(tmp_path, rel, [611.0], crosses={610.0: cross}) == [] + + +@pytest.fixture(scope="module") +def ts() -> dict: + if not (ROOT / "config" / "node_modules" / "typescript").is_dir(): + pytest.skip("프론트엔드 의존성 미설치") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_273_2_preview_stations.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_화면_옛_자리_숨기고_새_자리_얹음(ts) -> None: + assert ts["moved"]["chainages"] == [0, 20, 40, 95, 100] # 옛 95.38 숨김 · 새 95 미리 뜬 것 + assert ts["moved"]["asked"] == [[95]] + card = ts["moved"]["preview"] + assert card["preview"] is True and card["structure"] == "파형강관 D800" + assert card["culvert"] is True # 관 그림(세트)도 얹힘 + + +def test_한_번_뜬_자리는_다시_안_물음(ts) -> None: + assert ts["again"]["asked"] == [] + assert ts["again"]["chainages"] == [0, 20, 40, 95, 100] + + +def test_초안이_없으면_서버_목록_그대로(ts) -> None: + assert ts["cleared"]["chainages"] == [0, 20, 40, 95.38, 100] + + +def test_늦게_끝난_옛_맞춤이_새_것을_안_덮음(ts) -> None: + assert ts["race"]["chainages"] == [0, 20, 40, 100, 120] diff --git a/resources/tester/test_276_1_haul_link_flow.py b/resources/tester/test_276_1_haul_link_flow.py new file mode 100644 index 000000000..0a0353374 --- /dev/null +++ b/resources/tester/test_276_1_haul_link_flow.py @@ -0,0 +1,38 @@ +"""276 단가연결 — 연결 없음 = 옛 길(단가연결 목록 → 옵션 값 창) · 연결 있음 = 「운반 단가」 모달(작게) · +B08 거리이정표 · B09 운반공 줄 같음 · 옛 길 연결(own 없음 · 빈칸)도 서버가 그 행 거리로 채움.""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import _input_names # noqa: E402 + + +def _src(name: str) -> str: + return re.sub(r"\s+", " ", (ROOT / name).read_text(encoding="utf-8")) + + +def test_연결_있으면_운반_모달_없으면_옛_길(): + milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert "() => route.ref ? openHaulModal({ ref: route.ref as Row," in milestone + assert ": void openPriceLink({ apply: link })," in milestone + link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts") + assert '[["단가연결", haul && ref ? haulModal : priceLink]]' in link + # 옛 길 = 목록에서 로직 고르면 옵션 값 창(askInputs) + assert 'if (p.kind === "logic") askInputs(o.apply, p.code, p.name);' in link + + +def test_운반_모달_작게(): + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + assert '"--modal-max:min(680px,86vh);width:min(1100px,94vw);height:var(--modal-max)"' in modal + assert "min-height:60vh" not in modal and "flex:1;min-height:0" in modal + + +def test_옛_길_연결도_거리로_채움(): + ref = {"tab": "logic", "code": "GF000284", "given": {}} # askInputs 꼴 — own 없음 · 빈칸 + got = fill_given(ref, _input_names("GF000284"), {"L1": 23.8, "L2": 10.5}) + assert got["운반거리"] == 34.3 diff --git a/resources/tester/test_277_1_haul_steps.py b/resources/tester/test_277_1_haul_steps.py new file mode 100644 index 000000000..aa3f17cd7 --- /dev/null +++ b/resources/tester/test_277_1_haul_steps.py @@ -0,0 +1,79 @@ +"""277 「운반 단가」 창 오른쪽 계산 칸 — 미리보기 `blocks`(머리 · 호표 줄 · 계 · 출처 · 하위 산근) · +옛 `lines` · 단가 그대로 · 칸 고르기(운반거리만 = 거리 칸만 · Cm 받음 = 사이클 칸까지) · +운반거리 칸 높이(`ui-input--sm`) · 「자동계산」 단추.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import FILL # noqa: E402 +from B09_Estimation.B09_Estimation_Router import PreviewBody, logic_preview # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import _input_names # noqa: E402 + +GIVEN = {"재료": "토사", "운반거리": 23.877, "지역": "전국평균", "보정작업": "해당 없음"} +CYCLE_ONLY = {"Cm", "사이클시간", "t2"} + + +def _src(name: str) -> str: + return (ROOT / name).read_text(encoding="utf-8") + + +def test_미리보기_칸_GF000284(tmp_path): + got = logic_preview(tmp_path, PreviewBody(code="GF000284", given=GIVEN)) + (block,) = got["blocks"] # 하위 산근 없음 + assert block["logic"] == "GF000284" and block["source"] == "산림품셈 10-12-1" + assert block["unit"] == got["unit"] == "㎥" + assert block["price"] == got["price"] and got["price"] > 0 + assert block["parts"] == got["parts"] + assert block["head"][0].startswith(block["name"]) and block["foot"].startswith("계 :") + assert any(h.startswith("Cmt = ") for h in block["head"]) # 중간값 + names = [ln["name"] for ln in block["lines"]] + assert names == ["굴착기(무한궤도) 0.7㎥(적재)", "덤프트럭 15ton(운반)"] + truck = block["lines"][1] + assert truck["qty"] == 1.25 and truck["unit"] == "시간" and len(truck["text"]) == 3 + assert set(truck) == {"name", "spec", "qty", "unit", "kind", "labor", "material", "expense", + "why", "text"} # fmt: skip + # 옛 글 줄 그대로 = 머리 + 줄 글 + 계 + texts = [t for ln in block["lines"] for t in ln["text"]] + assert got["lines"] == [*block["head"], *texts, block["foot"]] + + +def test_칸_고르기_규칙(): + def boxes(code: str) -> tuple[bool, bool]: + names = _input_names(code) + return any(n in FILL for n in names), any(n in CYCLE_ONLY for n in names) + + assert boxes("GF000284") == (True, False) # 운반거리만 = 거리 칸만 + assert boxes("GF000134") == (True, True) # Cm = 사이클 칸까지 + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + assert 'CYCLE_ONLY = new Set(["Cm", "사이클시간", "t2"])' in modal + assert "want.some((n) => CYCLE_ONLY.has(n)) ? [cycleBox(input)]" in modal + assert 'el("pre"' not in modal and "cycleLines" not in modal + # 조합 = 담은 로직 줄마다 미리보기 · 최신 응답만 · 하위 산근 이어 붙임 · 셈 중 옛 칸 흐리게 + for want in ("st.lines.map((l) => ({ code: l.로직, given: libFilled(l)", "my !== seq", + '"하위 산근"', 'classList.add("is-stale")', + "조합 단가 = [적용] · [저장] 뒤 서버 셈"): # fmt: skip + assert want in modal, want + + +def test_칸_그리기_테마(): + steps = _src("B09_Estimation/B09_Estimation_UI_Haul_Steps.ts") + for want in ("export function distBox", "export function cycleBox", "export function logicBox", + "export function splitHead", 'toLocaleString("ko-KR"', '"입력값"', '"중간값"', + '"호표"', "빈칸 — "): # fmt: skip + assert want in steps, want + css = _src("B09_Estimation/B09_Estimation_UI_Haul_Steps.css") + assert "#" not in css.replace("/*", "").split("*/", 1)[1] # 색 = 테마 변수만 + assert "var(--color-danger)" in css and "text-align: right" in css + + +def test_운반거리_칸_높이_자동계산_단추(): + values = _src("M02_MasterTemplete/M02_Labor_Values.ts") + assert '"ui-input ui-input--sm m02-inputval__control"' in values + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + assert '"거리에서"' not in modal and 'AUTO = "자동계산"' in modal + for want in ("unown && o.dist", 'variant: "pill"', '"aria-pressed"', '"is-active"', + "st.own.delete(name);", "st.libOwn.delete(id);"): # fmt: skip + assert want in modal, want diff --git a/resources/tester/test_278_1_sg_box_steps.py b/resources/tester/test_278_1_sg_box_steps.py new file mode 100644 index 000000000..7bd8fe191 --- /dev/null +++ b/resources/tester/test_278_1_sg_box_steps.py @@ -0,0 +1,120 @@ +"""278 B09 단가산출근거 [박스보기] 머리 줄 = 운반 단가 창 ③ 칸 꼴(입력값 · 중간값 · 이름 · 값 · 식) · +줄 나눔 · 그리기 한 벌(`Haul_Steps.headSteps`) · 호표 줄 · 총계 · 다른 탭 카드 그대로 · +운반 단가 창 왼쪽 폭(278-1).""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 + +B09 = "B09_Estimation/B09_Estimation_UI_" +STEPS = f"{B09}Haul_Steps.ts" + +# 공용 el 짝(가짜 DOM) — data-* = dataset +EL = r""" +const el = (tag, o = {}) => { + const n = document.createElement(tag); + if (o.className) n.className = o.className; + if (o.text !== undefined) n.textContent = o.text; + for (const [k, v] of Object.entries(o.attrs ?? {})) { + n.setAttribute(k, v); + if (k.startsWith("data-")) n.dataset[k.slice(5)] = v; + } + for (const c of o.children ?? []) n.append(c); + return n; +}; +""" + +SG = { + "key": "SG:A", "no": 1, "name": "골재운반", "unit": "㎥", "spec": "Cm=100", "spec_raw": "Cm=100", + "source": "산림품셈 10-3-1", + "head": ["골재운반 / ㎥", "산림품셈 10-3-1 적용", "qt : 15 ㎥", "Cm : 운반 사이클 = 100 분", + "작업량 = 골재운반 덤프트럭 시간당 작업량 = 8.1", "└ 결과 = 반올림(60 * 15 / 100, 3) = 8.1", + "덤프트럭 15ton 수량 = 1 / 작업량 = 1 / 8.1 = 0.1235 시간"], + "lines": [ + {"kind": "labor", "name": "보통인부", "ref": {"tab": "labor", "key": "LB2"}, "why": None, + "labor": 2979.8, "material": 0.0, "expense": 0.0, "text": ["보통인부 : 165,545 * 0.018 = 2,979.8"]}, + ], + "labor": 2979, "material": 0, "expense": 0, "sum": 2979, "why": None, "foot": "계 : 2,979", +} # fmt: skip + +PANEL = """ +const gone = []; +const o = { tab: TAB, spec, hint: null, goto: (l) => gone.push(l), redraw: () => {}, + setTools: () => {}, makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) }; +const [view] = renderGroupPanel(o); +const top = view.querySelector('.b09-sgsteps'); +const rows = top ? top.querySelectorAll('.b09-haulstep__row').map((r) => + [r.dataset.at ?? null, ...r.children.map((c) => [c.className, c.textContent])]) : null; +const groups = top ? top.querySelectorAll('.b09-haulstep__group').map((g) => g.textContent) : null; +const body = view.querySelectorAll('tr').filter((tr) => tr.dataset.at !== undefined).map((tr) => [tr.dataset.at, tr.children[0].text]); +const foot = view.querySelector('.ui-group-card__foot')?.dataset.at ?? null; +if (top) top.querySelectorAll('.b09-haulstep__row')[2].click(); +const lit = view.querySelectorAll('.is-focus').map((e) => e.dataset.at); +console.log(JSON.stringify({ rows, groups, body, foot, gone, lit })); +""" + + +def _panel(tab, files, build, sheets, prelude=""): + js = ( + f"\nconst TAB = '{tab}';" + f"\nconst spec = {build}({{ sheets: {json.dumps(sheets, ensure_ascii=False)} }});" + PANEL + ) + return run([COMMON, TABS, *files, STEPS, GROUPS], js, EL + prelude) + + +def test_박스보기_머리_줄_나눔(): + out = _panel("sg", [f"{B09}Tabs_Sangeun.ts"], "buildSg", {"sg": [SG]}) + assert out["groups"] == ["산림품셈 10-3-1", "입력값", "중간값"] # 출처 = 칸 맨 위 + v, f = "b09-haulstep__value", "b09-haulstep__formula" + assert out["rows"] == [ + ["2", ["", "qt"], [v, "15 ㎥"]], # 표 줄 번호 그대로(머리 첫 줄 = 카드 머리 · 출처 줄 = 위) + ["3", ["", "Cm"], [v, "100 분"], [f, "운반 사이클"]], + ["4", ["", "작업량"], [v, "8.1"], + [f, "골재운반 덤프트럭 시간당 작업량\n└ 결과 = 반올림(60 * 15 / 100, 3) = 8.1"]], + ["6", ["", "덤프트럭 15ton 수량"], [v, "0.1235 시간"], [f, "1 / 작업량 = 1 / 8.1"]], + ] # fmt: skip + # 호표 줄 · 총계 그대로(머리 줄은 표 몸에서 빠짐) + assert out["body"] == [ + ["7", "보통인부 : 165,545 * 0.018 = 2,979.8노무"] + ] # 가짜 DOM 글 = 이름 + 꼬리표 + assert out["foot"] == "8" + # 머리 줄 누름 = 고름(is-focus) · 이동 없음(표와 같음) + assert out["lit"] == ["4"] and out["gone"] == [None] + + +def test_다른_탭_카드_그대로(): + dg = { + "id": "DG1", "no": 1, "name": "찰쌓기", "spec": "", "spec_raw": "", "unit": "m2", + "kind": "", "cite": "", + "lines": [{"kind": "labor", "key": "L1", "name": "석공", "qty": 0.072, "unit": "인", + "price": 1, "amount": 1, "ref_no": "", + "prices": {"labor": 1, "material": None, "expense": None}, + "amounts": {"labor": 1, "material": None, "expense": None}}], + "sum": 1, "labor": 1, "material": 0, "expense": 0, "blank": 0, "blank_names": [], + } # fmt: skip + out = _panel("dg", [f"{B09}Tabs_Labor.ts"], "buildDaegaBlocks", {"daega_blocks": [dg]}, RAW) + assert out["rows"] is None and out["body"] == [["1", "석공"]] + + +def test_한_벌_잇기(): + groups = (ROOT / GROUPS).read_text("utf-8") + assert 'import { headSteps } from "./B09_Estimation_UI_Haul_Steps";' in groups + assert 'indexOf(" : ")' not in groups # 줄 나눔 복사 없음 + assert "sgHeadCards(tabGroups(o.tab, o.spec), o.spec.rows)" in groups + steps = (ROOT / STEPS).read_text("utf-8") + assert "export function headSteps" in steps and "[...headSteps(b), ...group(" in steps + common = (ROOT / COMMON).read_text("utf-8") + assert "top?: HTMLElement | null;" in common and "if (g.top) body.append(g.top);" in common + sangeun = (ROOT / f"{B09}Tabs_Sangeun.ts").read_text("utf-8") + assert 'source: b.source ?? ""' in sangeun + + +def test_운반_창_왼쪽_폭(): + modal = (ROOT / f"{B09}Haul_Modal.ts").read_text("utf-8") + assert "grid-template-columns:minmax(260px,0.7fr) minmax(340px,1.4fr)" in modal diff --git a/resources/tester/test_279_1_subtab_height.py b/resources/tester/test_279_1_subtab_height.py new file mode 100644 index 000000000..b336ee1b9 --- /dev/null +++ b/resources/tester/test_279_1_subtab_height.py @@ -0,0 +1,62 @@ +"""279 B08 · B09 보조 탭 단추 높이 한 꼴. + +- 테마 토큰 `--subtab-height` = 24px(B09 단가산출근거 박스보기 때 잰 값) +- 공용 양식 `.ui-subtabs`(가운데 맞춤 · 단추 높이 = 토큰) · `.ui-subtabs__tools`(높이 = 토큰 · 줄 높이 안 바꿈) +- B09 아래 줄 · 도구 · B08 보조 탭 줄 모두(빈 줄 자리 포함)가 씀 · 위 탭 줄은 안 씀 +- `ui-btn--sm` 전체는 그대로 +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _block(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_token_is_box_view_height(): + theme = _read("ui_template/ui_template_theme.css") + assert re.search(r"--subtab-height:\s*24px;", theme) + + +def test_shared_class_centers_and_fixes_height(): + css = _read("ui_template/ui_template_general_layout.css") + assert "align-items: center" in _block(css, ".ui-subtabs") + assert "height: var(--subtab-height)" in _block(css, ".ui-subtabs > .ui-btn") + tools = _block(css, ".ui-subtabs__tools") + assert "align-items: center" in tools + assert "height: var(--subtab-height)" in tools + + +def test_sm_button_untouched(): + styles = _read("ui_template/ui_template_elements_styles.ts") + assert "height" not in _block(styles, ".ui-btn--sm") + + +def test_b09_sub_row_and_tools_use_shared_class(): + bar = _read("B09_Estimation/B09_Estimation_UI_TabBar.ts") + assert 'sub.className = "b09-estimation__tabs ui-subtabs"' in bar + assert 'tools.className = "b09-estimation__tools ui-subtabs__tools"' in bar + assert 'top.className = "b09-estimation__tabs"' in bar # 위 탭은 그대로 + + +def test_b08_sub_rows_use_shared_class(): + sub_rows = { + "B08_Quantity/B08_Quantity_UI_Page.ts": 2, # 자재 보조 탭 · 구조물 집계표 빈 줄 자리 + "B08_Quantity/B08_Quantity_UI_Earthwork.ts": 1, + "B08_Quantity/B08_Quantity_UI_Hydro.ts": 1, + "B08_Quantity/B08_Quantity_UI_Milestone.ts": 1, + } + for rel, n in sub_rows.items(): + src = _read(rel) + assert src.count('"b08-quantity__tabs ui-subtabs') == n, rel + page = _read("B08_Quantity/B08_Quantity_UI_Page.ts") + assert 'tabBar.className = "b08-quantity__tabs";' in page # 위 탭은 그대로 diff --git a/resources/tester/test_280_1_combo_label_pick.py b/resources/tester/test_280_1_combo_label_pick.py new file mode 100644 index 000000000..884dc06f4 --- /dev/null +++ b/resources/tester/test_280_1_combo_label_pick.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +"""PLAN 280 — B05 · B06 구조물조합 칸 · 「구조물 조합 선택」 모달. + +280-1 이름 칸 = 고르기 단추 노릇(누름 · Enter · Space · 손 커서 · 풍선 · 초점 테두리) · [선택] 없음 · [해제] 그대로 · + 본체 집계 항목 · 그룹 상자 · 명세 컨테이너 잇기가 같은 줄 부품. +280-2 모달 제목 · 위 표 고름 → 나무(펼쳐 굴림) · 오른쪽 카드 · 층 단추 → 위 표 = 그 층(Node · 대역). +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +UI = ROOT / "ui_template" +B05 = ROOT / "B05_Profile" + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _node(helper: str) -> dict: + proc = subprocess.run( + ["node", str(HERE / helper)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def row(): + return _node("helper_b05_structures_form_summary.cjs") + + +@pytest.fixture(scope="module") +def picker(): + return _node("helper_280_1_combo_pick.cjs") + + +@pytest.fixture(scope="module") +def table(): + return _node("helper_224_1_picker_top_table.cjs") + + +# ── 280-1 ──────────────────────────────────────────────────────────────── + + +def test_이름_칸_누름_Enter_Space_고르기_선택_단추_없음_해제_있음(row): + assert row["rowEmpty"]["buttons"] == ["해제"] + assert row["clicks"][:2] == ["pick", "clear"] + assert row["keyClicks"] == { + "clicks": ["pick", "pick"], + "prevented": ["prevent:Enter", "prevent: "], + } + # 고른 뒤 · 해제 뒤 이름 칸 글 바뀜 그대로 + assert row["rowSet"]["name"] == "돌기슭막이 메쌓기 1.2m" + assert row["rowCleared"] == {"name": "미지정", "clearDisabled": True} + + +def test_이름_칸_풍선_초점_손_커서(): + src = _text(B05 / "B05_Profile_UI_Structures_Summary_Row.ts") + assert 'const PICK_TIP = "눌러서 고르기";' in src + assert "name.title = `${summaryTooltip(item)} · ${PICK_TIP}`;" in src + assert "name.tabIndex = -1;" not in src # Tab 으로 초점이 감 + assert '"선택"' not in src + css = _text(B05 / "B05_Profile_UI_Style_Structures.css") + base = re.search(r"\.b05-structure__summary-name \{([^}]*)\}", css).group(1) + assert "cursor: pointer;" in base + focus = re.search(r"\.b05-structure__summary-name:focus-visible \{([^}]*)\}", css).group(1) + assert "outline:" in focus + + +def test_같은_꼴_줄_모두_한_부품(): + # 본체 집계 항목 · 그룹 상자 · 명세 컨테이너 잇기 = 같은 `buildSummaryRow` + for path in ( + B05 / "B05_Profile_UI_Structures_Form.ts", + B05 / "B05_Profile_UI_Structures_Groups.ts", + ROOT / "A00_Common" / "design" / "design_structure_form.ts", + ): + assert "buildSummaryRow()" in _text(path), path.name + + +# ── 280-2 ──────────────────────────────────────────────────────────────── + + +def test_모달_제목(): + locale = _text(UI / "ui_template_locale_m2.ts") + assert 'M02_PickerTitle: ["구조물 조합 선택", "Select structure combo"],' in locale + assert 'title: t("M02_PickerTitle"),' in _text(UI / "ui_template_item_picker.ts") + + +def test_처음_위_표_보이는_층_표(picker): + assert picker["start"] == { + "table": "personal|u1|", + "layer": ["M02_PickerPersonal"], + "detail": None, + } + + +def test_위_표_고름_나무_펼쳐_고름_오른쪽_카드(picker): + assert picker["tablePick"] == { + "active": "personal|u1|SP2", + "visible": True, + "scrolled": True, + "detail": "SP2", + "table": "personal|u1|rvP2", + } + # 같은 열 · 표에 없는 열 = 안 바뀜(표 → 나무 → 표 되돌이 없음) + assert picker["sameAgain"] == {"detail": 0, "table": 0} + # 접힌 폴더 속 항목도 펼쳐서 고름 + assert picker["systemLeavesBefore"] == [] + assert picker["systemPick"] == { + "active": "system||SS2", + "visible": True, + "detail": "SS2", + "table": "system||rvS2", + } + + +def test_층_단추_위_표_그_층(picker): + assert picker["layerSystem"] == { + "table": "system||", + "layer": ["M02_PickerSystem"], + "detail": "SP2", # 고른 항목은 그대로 + } + assert picker["layerCompany"] == {"table": "company|c1|", "layer": ["M02_PickerCompany"]} + assert picker["layerBack"] == "personal|u1|rvP2" # 고른 항목 층으로 오면 그 열 + + +def test_나무_고름_위_표_열_지금_길(picker): + assert picker["treePick"] == { + "active": "system||SS2", + "detail": "SS2", + "table": "system||rvS2", + } + + +def test_찾기에_가려진_항목_표에서_고르면_찾기_풂(picker): + assert picker["searchCleared"] == {"value": "", "active": "system||SS1"} + + +def test_위_표_열_고름은_부른_쪽에_층만_바꾸면_열_없이(table): + assert table["onColumn"] == ["rv2"] + assert table["layerOnly"]["reads"] == [ + ["readTemplate", "system", "table", "구조물집계표", None, None] + ] diff --git a/resources/tester/test_281_1_drainage_zoom_range.py b/resources/tester/test_281_1_drainage_zoom_range.py new file mode 100644 index 000000000..2895de0f5 --- /dev/null +++ b/resources/tester/test_281_1_drainage_zoom_range.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""281 B05 · B06 아래 종단 패널 배수유역 지도 — 줌아웃 한계 = 노선 기준. + +- 한계 = 노선 범위 2 배가 화면에 드는 배율 · 유역이 밖이면 유역까지 · 도엽 전체(배율 1)보다 멀리 안 감 +- 노선이 없으면 종전 0.5 · 처음 보기(노선 + 여유)보다 좁히지 않음 +- 휠은 한계 · 상한에서 멈춤 · 이미 한계 밖이면 더 안 빠지고 튀지도 않음 + +Node 헬퍼(`helper_281_1_drainage_zoom_range.cjs`)가 TS 를 그 자리에서 돌린다. +도엽 4000 × 3000 m · 화면 800 × 600 — 배율 1 = 화면 폭 4000 m. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_281_1_drainage_zoom_range.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_상수는_한_곳(r): + assert r["factor"] == 2 + assert r["fallbackConst"] == 0.5 + + +def test_노선만_2배가_화면에(r): + """노선 1000 × 500 m → 2 배 2000 × 1000 m · 폭이 이김 → 화면 폭 2000 m(배율 2).""" + assert r["routeOnly"] == pytest.approx(2.0) + assert r["routeOnlyViewM"] == pytest.approx(2000) + + +def test_유역이_밖이면_유역까지(r): + """유역 서쪽 끝 400 m → 범위 400 ~ 3000 m = 폭 2600 m.""" + assert r["basinOutViewM"] == pytest.approx(2600) + + +def test_유역이_안이면_그대로(r): + assert r["basinIn"] == pytest.approx(r["routeOnly"]) + + +def test_노선_없음은_종전(r): + assert r["noRoute"] == 0.5 + assert r["noMeta"] == 0.5 + + +def test_도엽보다_큰_범위는_도엽_전체에서_멈춤(r): + assert r["wide"] == pytest.approx(1.0) + + +def test_짧은_노선은_처음_보기보다_좁히지_않음(r): + assert r["short"] == pytest.approx(r["shortInitial"]) + + +def test_휠이_한계_안에서_멈춤(r): + assert r["wheelOutStop"] == pytest.approx(r["routeOnly"]) + assert r["wheelInStop"] == pytest.approx(200) + assert r["outside"] == pytest.approx(1.2) + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_휠과_창_크기가_한계를_씀(): + interact = _read("B05_Profile/B05_Profile_UI_Drainage_Interact.ts") + assert "Math.max(0.5" not in interact + assert "params.getMinScale()" in interact + panel = _read("B05_Profile/B05_Profile_UI_Drainage_Panel.ts") + assert "getMinScale: zoomOutLimit" in panel + assert "scale < zoomOutLimit() * 0.999) fitToRoute()" in panel + assert len(panel.splitlines()) <= 700 + + +def test_B04_공용은_그대로(): + """B04 지도 · 노선 편집 지도는 0.5 하한을 그대로 씀(공용 함수 안 바꿈).""" + render = _read("B04_PreProcess/B04_PreProcess_UI_MapRender.ts") + assert "ZoomRange" not in render diff --git a/resources/tester/test_282_1_profile_toolbar.py b/resources/tester/test_282_1_profile_toolbar.py new file mode 100644 index 000000000..6160bf50b --- /dev/null +++ b/resources/tester/test_282_1_profile_toolbar.py @@ -0,0 +1,68 @@ +"""282 B05 · B06 아래 종단 패널 도구줄 — [배수유역도] 단추 지움 · [새 창] = [초기화] 왼쪽(요구 177). + +- 282-1 [배수유역도] 켬끔 단추 · 켬끔 값(`drainage-visible`) 없음 · 옛 값 false 여도 판 · 손잡이 보임 +- 282-2 [새 창] = 도구 묶음 맨 앞([초기화] 바로 왼쪽) · 다시 그려도 그 자리 · 열림 · 닫힘 동작 그대로 +- 285-1 단추 글 = [창 분할] · 열린 동안 [창 결합](요구 179-2) +`helper_282_1_profile_toolbar.cjs` 가 진짜 TS 를 가짜 DOM 위에서 돌림. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOLS = ["초기화", "↶", "↷", "직선화", "자유지정", "쉬프트", "▲", "▼"] + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_282_1_profile_toolbar.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_배수유역도_단추_코드_없음(): + for rel in ( + "B05_Profile/B05_Profile_UI_Drainage_Side.ts", + "B05_Profile/B05_Profile_UI_Profile_SideTool.ts", + "A00_Common/design/design_profile.ts", + "A00_Common/design/design_shared.ts", + "A00_Common/design/design_profile_popout.ts", + "A00_Common/b_page_state.ts", + "B05_Profile/B05_Profile_UI_Style_Table.css", + ): + src = (ROOT / rel).read_text(encoding="utf-8") + for gone in ( + "createDrainageToggleButton", + "b05-drainage__toggle", + "drainage-visible", + "sideTool", + ): + assert gone not in src, (rel, gone) + + +def test_새_창_초기화_바로_앞_다시_그려도(out): + assert out["first"] == ["창 분할", *TOOLS] + assert out["again"] == ["창 분할", *TOOLS] + assert "b05-route-profile__popout" in out["buttonClass"] + + +def test_새_창_열림_닫힘(out): + assert out["opened"] == {"text": "창 결합", "inPopup": True} + assert out["openedRender"] == ["창 결합", *TOOLS] + assert out["closed"] == {"text": "창 분할", "inSlot": True, "popupClosed": True} + assert out["closedRender"] == ["창 분할", *TOOLS] + + +def test_옛_값_false_여도_판_손잡이_보임(out): + assert out["side"] == {"rootHidden": False, "handleHidden": False, "hasSetVisible": False} diff --git a/resources/tester/test_284_1_pill_pick.py b/resources/tester/test_284_1_pill_pick.py new file mode 100644 index 000000000..60eef37e8 --- /dev/null +++ b/resources/tester/test_284_1_pill_pick.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""284 종단도 알약 고름 · 풂(요구 178 · 179-1 · 조사 AM). + +못박는 것 + - 측점 있음 = 제 측점을 심는 알약(272 한 벌 — A군 · C · D 구간형 · 관 = 물넘이포장까지 · 286-1) · 종류 모르면 옛 길. + - 측점 없는 알약(B군 · 표지 · 바닥막이)은 같은 자리 · 근처 측점선을 짝으로 켜지 않음. + - 관 측점선을 다시 누르면 측점 없는 표지가 아니라 그 관 알약. + - B06 알약 다시 누름 = 풀기 한 길(Esc 값) · 측점 없는 관 = 카드 안 고름 · 스크롤만 · 고름 값에 알약 id. + - B05 물넘이포장 알약 = 고름 값에 알약 id(같은 m 일반 측점선 안 켬). + +Node 헬퍼 `helper_284_1_pill_pick.cjs` 가 진짜 TS 를 돌림. 화면은 사용자 확인. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_284_1_pill_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_측점_심기_규칙(out): + assert out["plants"] == { + "drain": True, # A군 점 + "wall": True, # C군 구간형 + "ditch": False, # B군 + "sign": False, # E군 표지 + "sill": False, # D군 점 + "unknown": True, # 종류 모름 = 옛 길 + "pipe-80.00": True, # 관 + "pipe-120.00": True, # 물넘이포장(286-1 측점 심음) + } + + +def test_측점_없는_알약은_측점선_짝_없음(out): + line = out["line"] + assert line["drain"] == "irregular:drain" and line["wall"] == "irregular:wall" + assert line["pipe-80.00"] == "irregular:pipe-80.00" + # 표지는 관 측점(80)과 같은 m 여도 그 선을 안 켬 · B군 · D군 점도 없음 · 물넘이포장 = 제 측점선(286-1) + assert line["sign"] is None and line["pipe-120.00"] == "irregular:pipe-120.00" + assert line["ditch"] is None and line["sill"] is None + assert out["lineNoTypes"] == "irregular:pipe-80.00" # 종류 없이 부르면 옛 길 + + +def test_관_측점선_다시_누름은_관_알약(out): + assert out["keepSign"] == "pipe-80.00" + assert out["keepPipe"] == "pipe-80.00" + + +def test_종단도는_종류를_넘김(): + profile = _read("A00_Common", "design", "design_profile.ts") + assert ( + "stationIdAtStructureOf(structureId, irregularStations, structures, structureTypes)" + in profile + ) + assert " detail,\n structureTypes,\n );" in profile + + +def test_B06_알약_풂과_측점_없는_관(): + view = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert "if (structureId === null) {" in view + assert "selectDesign(shared.projectId, NO_SELECTION, { from: ESCAPE_FROM });" in view + assert "if (edit?.selectPipe && !plantsStation(hit, types)) {" in view + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "selectPipe: (chainageM) => structuresPanel.selectPipeAt(chainageM)," in cross + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + body = panel[panel.index("selectPipeAt: (chainageM) => {") :][:900] + assert "deps.focusChainage(pipe.chainage_m, true);" in body + assert "deps.markStructure?.(id);" in body + assert "{ chainageM: pipe.chainage_m, structureId: id }," in body and '{ from: "b06" },' in body + + +def test_B05_물넘이포장_알약은_id_를_남김(): + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "const keepId = chainage === null || bridge.stationlessPipeMark(structureId);" in view + assert "rememberPick(bridge.markChainage(structureId), keepId ? structureId : null);" in view + page = _read("B05_Profile", "B05_Profile_UI_Page_Structures.ts") + assert "stationlessPipeMark: (structureId: string | null): boolean => {" in page + assert "return !!mark && !plantsStation(mark, []);" in page diff --git a/resources/tester/test_286_1_ford_pavement_design.py b/resources/tester/test_286_1_ford_pavement_design.py new file mode 100644 index 000000000..a880eedad --- /dev/null +++ b/resources/tester/test_286_1_ford_pavement_design.py @@ -0,0 +1,201 @@ +"""286 물넘이포장 종횡단 설계(요구 180-2). + +못박는 것 + - 286-1 물넘이포장도 기준 측점 하나 — TS `pipeStationMarks` · `plantedChainages` ↔ 서버 + `resolve_extra_stations` 같은 값 · 라벨 「물넘이포장」 · B05 · B06 거름 걷음. + - 286-2 물넘이 자리 = 측구 없음(서버 정본 · 화면 짝 · 거울 = `test_b06_cross_design_mirror`) · + 노면 = 경사각도로 표고 낮은 쪽(설계선 양 끝 표고 · 같으면 단면유형). + - 286-3 도수로/돌붙임 = 다른 구조물과 같은 칸 + 맨 아래 「길이 (m)」(`ford_chute_{n}_length_m`) · + 비면 원지반까지 · 있으면 사면 따라 그 길이 · 서버 · TS 같은 값 · 집계 · B08 수량에 안 실림. + +진짜 TS 는 `helper_286_1_ford_pavement.cjs` 가 Node 로 돌림. 화면은 사용자 확인. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Sections import resolve_extra_stations # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from B06_Section.B06_Section_Router_Design import ford_pavement_ranges, paved_at # noqa: E402 +from B06_Section.B06_Section_Engine_Culvert import _chute # noqa: E402 +from B08_Quantity.B08_Quantity_Router_Summary import _chutes # noqa: E402 +from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402 +from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows # noqa: E402 + +PIPES = [ + {"chainage_m": 95.0, "source": "user"}, + {"chainage_m": 230.0, "source": "user", "facility": "ford_pavement"}, + {"chainage_m": 300.0, "source": "user", "facility": "ford_bridge"}, +] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + types = [ + { + "type_id": item.type_id, + "group": item.group, + "placement": item.placement, + "managed_by": item.managed_by, + } + for item in structure_type_map().values() + ] + source = tmp_path_factory.mktemp("s286") / "in.json" + source.write_text( + json.dumps({"pipes": PIPES, "structures": [], "types": types}), encoding="utf-8" + ) + proc = subprocess.run( + ["node", str(HERE / "helper_286_1_ford_pavement.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_물넘이포장_측점_TS_파이썬_같은_값(ts, tmp_path): + assert ts["fordMarks"] == [{"chainage_m": 230, "role": "anchor"}] + server = resolve_extra_stations(tmp_path, parse_pipe_points(PIPES)) + assert [mark for mark, _label in server] == ts["planted"] == [95, 230, 300] + assert dict(server)[230.0] == "물넘이포장" + + +def test_B05_B06_물넘이_거름_걷음(): + page = _read("B05_Profile", "B05_Profile_UI_Page_Structures.ts") + assert "withoutFordPavement" not in page + assert "FACILITY_NAMES.ford_pavement" not in page + view = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert '"ford_pavement"' not in view + drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts") + assert 'if (facility === "ford_pavement") return null;' not in drafts + + +def _ground(slope: float) -> list[dict]: + return [ + {"offset_m": o / 2, "elevation_m": round(100.0 + slope * o / 2, 4), "valid": True} + for o in range(-24, 25) + ] + + +def test_물넘이_자리_측구_없음(): + kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"} + plain = compute_cross_design(_ground(0.35), 100.4, **kwargs) + ford = compute_cross_design(_ground(0.35), 100.4, tilted=True, **kwargs) + assert plain["ditch_enabled"] is True and "tilted" not in plain + assert ford["ditch_enabled"] is False and ford["tilted"] is True + assert ford["ditch_area_m2"] == 0 + # 사용자가 측구를 켜도 물넘이 자리는 안 섬 + forced = compute_cross_design(_ground(0.35), 100.4, tilted=True, ditch_choice=True, **kwargs) + assert forced["ditch_enabled"] is False + + +def test_물넘이_범위_서버_판정(tmp_path): + edits = tmp_path / "B04_PreProcess" / "drainage" / "edits" + edits.mkdir(parents=True) + (edits / "pipe_points.json").write_text( + json.dumps({"points": [{**PIPES[1], "options": {"ford_width_m": 8}}]}), encoding="utf-8" + ) + ranges = ford_pavement_ranges(tmp_path) + assert ranges == [(226.0, 234.0)] + assert paved_at(230.0, ranges) and paved_at(226.0, ranges) and not paved_at(240.0, ranges) + + +def test_노면은_표고_낮은_쪽으로(): + # 287-4 — 설계 노면 = 경사각도(%)로 지대 낮은 쪽 한 경사 · 중심 = 계획고(콘크리트 윗면) + kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"} + for slope, high in ((0.35, "left"), (-0.35, "right")): + got = compute_cross_design(_ground(slope), 100.4, tilted=True, tilt_pct=5.0, **kwargs) + left, right = (got["road_edges"][k]["elevation_m"] for k in ("left", "right")) + assert (left > right) == (high == "left") + assert got["design_elevation_m"] == 100.4 and got["cross_slope_pct"] == 5.0 + assert got["tilt_pct"] == 5.0 + + +def test_저장_뒤_서버가_물넘이_측구를_맞춤(): + router = _read("B06_Section", "B06_Section_Router_Design.py") + assert "stored_tilt(design) == tilt" in router + refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") + assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh + + +CHUTE = {"ford_chute_1_structure": "돌붙임", "ford_chute_1_thickness": 30} + + +def test_도수로_길이_서버_TS_같은_값(ts): + assert ( + _chute(CHUTE, "ford_chute") + == ts["chuteNoLength"] + == { + "structure": "돌붙임", + "thickness_m": 0.3, + } + ) + long = {**CHUTE, "ford_chute_1_length_m": 3} + assert _chute(long, "ford_chute") == ts["chuteLength"] + assert ts["chuteLength"]["length_m"] == 3 + + +def test_도수로_길이_없음_원지반까지_있음_그_길이(ts): + # 유출 = 좌(우가 표고 높음) · 노견(2.5, 100) → 원지반(10, 95) + assert ts["lineNoLength"] == [ + {"offset": 2.5, "elevation": 100}, + {"offset": 10, "elevation": 95}, + ] + start, end = ts["lineLength"] + assert start == {"offset": 2.5, "elevation": 100} + assert (end["offset"] - 2.5) ** 2 + (100 - end["elevation"]) ** 2 == pytest.approx(9.0) + assert end["offset"] == pytest.approx(2.5 + 3 * 7.5 / 9.0139, abs=1e-3) + assert ts["lineTooLong"] == ts["lineNoLength"] # 사면보다 길면 원지반에서 멈춤 + + +def test_컨테이너_같은_칸_맨_아래_길이(): + spec = _read("A00_Common", "design", "design_structure_ford_pavement.ts") + assert ( + "items: CHUTE_ITEMS," in spec # 공용 칸 맨 아래 길이(294-4 · CHUTE_ITEMS 로 옮김) + ) + + +def test_길이는_집계_수량에_안_실림(): + options = {**CHUTE, "ford_chute_1_bond": "찰붙임", "ford_chute_1_area": 12} + item = {"type_id": "ford_pavement", "options": options, "복사본": "pipe@50.0"} + plain = container_rows(item, {"gfordpavechute1": "SS1"}) + long = container_rows( + {**item, "options": {**options, "ford_chute_1_length_m": 7}}, {"gfordpavechute1": "SS1"} + ) + # 집계 열이 읽는 칸(length_m · 쪽별 revet_length_m)은 그대로 — 길이는 안 읽히는 이름으로만 + for key in ("length_m", "inlet_revet_length_m", "outlet_revet_length_m", "area_m"): + assert plain[0]["options"].get(key) == long[0]["options"].get(key) + + class Sheet: + def __init__(self) -> None: + self.rows: list[tuple] = [] + + def add(self, at, key, sort, group, label, value, combos) -> None: + self.rows.append((key, label, value)) + + sheet = Sheet() + _chutes(sheet, 50.0, {**options, "ford_chute_1_length_m": 7}, {"gfordpavechute1": "SS1"}, {}) + assert sheet.rows == [("chute|찰붙임", "㎡", 12.0)] # 돌붙임 수량 = 면적 · 길이 안 탐 diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py new file mode 100644 index 000000000..80a7a70c8 --- /dev/null +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -0,0 +1,160 @@ +"""287 물넘이포장 후속(요구 183-2 · 4 · 6 · 7 · 8 · 9). + +못박는 것 + - 287-1 도수로/돌붙임 길이 ↔ 면적 — 길이 넣음 = 월류 폭 × 길이 · 비움 = 칸 기본값 · 월류 폭을 바꾸면 + 길이가 있을 때만 다시 셈 · 폭이 비면 물넘이 기본 폭. + - 287-3 월류 폭 → 높이 자동 = 세월교 한 벌(`fordHeightM` · 폼 `overflow(…, "slope")`) · 유량 없으면 손 입력. + - 287-5 물넘이포장 형상 누름 = 다른 시설과 같은 고름 창구(`toggleRevet("pipe")` → 폼 · 알약 · 측점선) · + 판정면은 보라 손잡이 아래(요구 185 ①) · 끌기(3 px 넘게 움직임) = 고름 아님(②) · 노면공 포장층 + (범위 표식)은 형상 고름 밖(③) · 목록 · 알약 고름이 형상 강조를 따라옴. + - 287-2 · 287-4 콘크리트 윗면 = 설계 노면(서버 · TS 짝이 경사각도로 지대 낮은 쪽 한 경사 · 중심점이 그 위) + · 포장층 = 노면공 포장 형상 노견까지(노면공 항목과 안 섞임) · 월류 높이 점선 = 노면 위 같은 각도 · + 3D 노면을 파지 않음 · 설계 측구 없음. + +진짜 TS 는 `helper_287_ford_pavement.cjs` 가 Node 로 돌림. 화면은 사용자 확인. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def ts() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_287_ford_pavement.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_도수로_길이_면적(ts): + assert ts["areaByLength"] == "24" # 6 m × 4 m + assert ts["areaCleared"] == "30" # 길이 비움 = 기본값 + assert ts["areaByWidth"] == "20" # 폭 8 m × 2.5 m + assert ts["areaWidthNoLength"] == "17" # 길이 없으면 폭을 바꿔도 그대로 + assert ts["areaNoWidth"] == "30" # 폭 비움 = 기본 폭 10 m × 3 m + + +def test_물넘이_폼이_길이_면적을_잇는다(): + form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts") + assert "syncChuteArea(paveForm, c, item), paveFlowBox.onChange(c, item)" in form + + +def _pts(text: str) -> list[float]: + return [float(v) for pair in text.split() for v in pair.split(",")] + + +def test_포장층_노견까지_점선은_노면_위(ts): + assert ts["drew"] is True + # 포장층 하나 = 노견 끝(±3)까지 · 윗면 = 설계 노면 · 두께 0.2 + assert len(ts["pavePoints"]) == 1 + assert _pts(ts["pavePoints"][0]) == pytest.approx([3, 100.15, -3, 99.85, -3, 99.65, 3, 99.95]) + # 월류 높이선 = 노면 + 0.4 · 같은 각도 + assert _pts(ts["planPoints"][0]) == pytest.approx([3, 100.55, -3, 100.25]) + assert ts["inflowLeft"] is True # 노면 높은 쪽 = 유입 · 도수로 띠 = 낮은 쪽 + + +def test_3D_노면을_파지_않고_옛_파임_그림을_걷음(): + station = _read("B05_Profile", "B05_Profile_UI_Corridor_Station.ts") + assert "fordDeckElevationAt" not in station + pave = _read("B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts") + assert "hideFordPavedRoad" not in pave and "fordDeckElevationAt" not in pave + view = _read("B06_Section", "B06_Section_UI_Cross_View.ts") + assert "hideFordPavedRoad" not in view + + +def test_월류_폭_넣으면_높이_자동_세월교_한_벌(ts): + assert ts["paveSlopeItems"] == ["angle", "width", "height"] + assert ts["heightByWidth"] and ts["heightByWidth"] > 0 + assert ts["heightNoFlow"] is None + form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts") + assert 'if (facility === "ford_pavement") paveFlowBox.fill();' in form + + +def test_형상_판정면은_보라_손잡이_아래(ts): + order = ts["shapeOrder"] + # 그림 → 형상 판정면 → 보라 손잡이 판정 → 구조물 판정면(위가 이김) + assert ( + order.index("b06-chart__shape-hit") + < order.index("b06-chart__design-hit") + < order.index("b06-chart__culvert-revet-hit") + ) + + +def test_형상_누름은_고름_끌기는_고름_아님(ts): + assert ts["picked"] == ["pipe-230.00"] # 누름 한 번만 고름 · 끌기는 안 셈 + assert ts["clickStops"] is True and ts["dragStops"] is False # 끌기는 카드로 그대로 + assert ts["pavedShapeIsPicked"] is False # 노면공 포장층은 형상 고름 밖(포장 고름 몫) + + +def test_형상_강조와_표식(ts): + assert ts["activeAfterSet"] is True + assert ts["activeAfterOther"] is False and ts["activeAfterSame"] is True + assert ts["fordTag"] == ["pipe-230.00"] # 포장층 표식 = 관 지점 알약 id + + +def test_보라_손잡이는_계획고만_형상_누름은_고름만(): + view = _read("B06_Section", "B06_Section_UI_Cross_View.ts") + # 형상 누름 = 시설 고름 창구만(설계 · 계획고를 안 건드림) · 보라 선 고름보다 먼저 꽂음(손잡이가 위) + assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view + assert view.index("attachShapePick(") < view.index("attachDesignLinePick({") + pick = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts") + assert "onDesignChange" not in pick and "design_elevation" not in pick + assert '"b06-chart__culvert-revet-hit"' in pick # 구조물 판정면 앞(아래)에 꽂을 기준만 + assert 'hit.setAttribute("class", "b06-chart__shape-hit")' in pick + # 보라 손잡이 판정 = 계획고 · 사면 이동 창구(설계선 고름 · 구조물 판정면 아래) + design_pick = _read("B06_Section", "B06_Section_UI_Cross_Design_Pick.ts") + assert 'el.classList.contains("b06-chart__culvert-revet-hit")' in design_pick + # 목록 · 알약 · 폼 고름 → 형상 강조 + section_view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + assert "highlightShapes(card, pickedMarkId);" in section_view + selection = _read("B06_Section", "B06_Section_UI_Page_Structures_Selection.ts") + assert "sectionAt.ford_pavement?.chainage_m" in selection + + +def test_월류_폭_높이_화면_길_유량_있음(ts): + got = ts["overflowFlow"] + assert got["afterWidth8"] == "0.26" # 폭 8 → 필요 수심(유량 14.58 ㎥/s) + assert got["afterClear"] == "0.26" # 높이 비움 → 다시 채움 + assert got["afterLow"] == "0.26" # 필요 수심 아래 → 끌어올림 + assert got["afterHigh"] == "0.9" # 올려 적은 값은 그대로 + assert got["hint"] == ["필요 수심 0.26 이상"] and got["otherBoxHint"] == 0 + assert got["emitted"] == 4 + + +def test_월류_폭_높이_화면_길_유량_없음(ts): + got = ts["overflowNoFlow"] + assert got["afterWidth8"] == "0.05" and got["afterClear"] == "" # 손 입력 그대로 + assert got["hint"] == ["설계유량 없음 — 수기 입력"] + + +def test_세월교_물넘이_한_벌_안내_훅(): + form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts") + flat = " ".join(form.split()) + assert 'overflowSync( () => fordForm, "ford", () => fordFlow, emit, )' in flat + assert 'overflowSync( () => paveForm, "slope", () => fordFlow, emit, )' in flat + assert "...fordFlowBox.fields(c)" in form and "fields: paveFlowBox.fields," in form diff --git a/resources/tester/test_288_underdrain_cross.py b/resources/tester/test_288_underdrain_cross.py new file mode 100644 index 000000000..51107f608 --- /dev/null +++ b/resources/tester/test_288_underdrain_cross.py @@ -0,0 +1,155 @@ +"""288 맹암거 횡단 형상(요구 183-3 · 9). + +못박는 것 + - 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 암거 경사로 지대 낮은 쪽 한 경사(물넘이 286 · 287 길 — + 서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted`) · 제원 얹기 서버 `attach_underdrains` ↔ TS + `attachUnderdrainSpecs` 같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과 + 만남 · 절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림. + - 288-2 맹암거 형상 누름 = 그 구조물 id 고름(구조물 목록 창구 · 알약 · 폼 · 측점선 따라옴) · 물넘이와 같은 + 판정면(보라 손잡이 아래 · 끌기 = 고름 아님 · 287-5) · 노면공 포장 범위 고름과 따로. + +진짜 TS 는 `helper_288_underdrain.cjs` 가 Node 로 돌림. 화면은 사용자 확인. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B06_Section.B06_Section_Engine_Tilt as tilt # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 + +STRUCTURES = [ + { + "structure_id": "ud-1", + "type_id": "cross_underdrain", + "placement": "point", + "chainage_m": 40.0, + "options": {"culvert_height_m": 0.8, "culvert_slope_pct": 4}, + }, + { # 경사 · 높이 빈 맹암거 — 표준 횡단경사 · 형상 없음 + "structure_id": "ud-2", + "type_id": "cross_underdrain", + "placement": "point", + "chainage_m": 80.0, + "options": {}, + }, + { + "structure_id": "x-1", + "type_id": "cross_drain_exposed", + "placement": "point", + "chainage_m": 60.0, + }, +] +SECTIONS = [{"chainage_m": c} for c in (20.0, 40.0, 60.0, 80.0)] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture() +def project(monkeypatch, tmp_path) -> Path: + items = [StructureInstance(**s) for s in STRUCTURES] + monkeypatch.setattr(tilt, "load_structures", lambda _root: (1, items)) + monkeypatch.setattr(tilt, "load_culvert_sets", lambda _root: {}) + return tmp_path + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s288") / "in.json" + source.write_text( + json.dumps({"structures": STRUCTURES, "sections": SECTIONS}), encoding="utf-8" + ) + proc = subprocess.run( + ["node", str(HERE / "helper_288_underdrain.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_맹암거_자리_기울기_서버(project): + ranges = tilt.tilt_ranges(project) + assert ranges == [(40.0, 40.0, 4.0), (80.0, 80.0, None)] + assert tilt.tilt_args(40.0, ranges) == {"tilted": True, "tilt_pct": 4.0} + assert tilt.tilt_args(80.01, ranges) == {"tilted": True, "tilt_pct": None} + assert tilt.tilt_args(60.0, ranges) == {"tilted": False, "tilt_pct": None} + + +def test_제원_얹기_서버_TS_같은_값(project, ts): + sections = [dict(s) for s in SECTIONS] + assert tilt.attach_underdrains(project, sections) == 2 == ts["attached"] + assert [s.get("underdrain") for s in sections] == ts["sections"] + assert ts["sections"][1] == { + "structure_id": "ud-1", + "chainage_m": 40.0, + "height_m": 0.8, + "slope_pct": 4.0, + } + + +def test_맹암거_측점_설계_측구_없음_낮은_쪽_기울기(): + samples = [ + {"offset_m": o / 2, "elevation_m": round(100.0 + 0.35 * o / 2, 4), "valid": True} + for o in range(-24, 25) + ] + kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"} + got = compute_cross_design(samples, 100.4, tilted=True, tilt_pct=4.0, **kwargs) + assert got["ditch_enabled"] is False and got["cross_slope_pct"] == 4.0 + left, right = (got["road_edges"][k]["elevation_m"] for k in ("left", "right")) + assert left > right # 지반이 좌로 오름 = 우가 낮음 + + +def test_형상_옵셋선_성토_절토와_만남(ts): + outline = [(p["offset"], p["elevation"]) for p in ts["outline"]] + expected = [ + (3, 100.15), # 좌 노면 끝 + (-3, 99.85), # 우 노면 끝 + (-4.632653, 98.768367), # 우 성토 사면 ∩ 옵셋선 + (1.846154, 99.092308), # 좌 절토 사면을 늘인 선 ∩ 옵셋선 + ] + assert len(outline) == len(expected) + for got, want in zip(outline, expected): + assert got == pytest.approx(want, abs=1e-5) + assert ts["noHeight"] is None + assert ts["drew"] is True and ts["stones"] > 0 + + +def test_횡단도_B07_이_맹암거를_그림(): + view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") + assert ( + "appendUnderdrainOverlay(plotLayer, section.underdrain, section.design, x, toDisplayY);" + in view + ) + b07 = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_Cross_Overlay.ts").read_text( + encoding="utf-8" + ) + assert "appendUnderdrainOverlay(svg, section.underdrain, design, x, y);" in b07 + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh + + +def test_맹암거_형상_누름은_그_구조물_고름(ts): + assert ts["bodyId"] == "ud-1" # 둘레 = 형상 고름 표식 + assert ts["routed"] == [["pipe"], [ts["eventName"], "ud-1", True]] + view = (ROOT / "B06_Section" / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8") + assert "grid.addEventListener(SHAPE_PICK_EVENT, (event) => {" in view + assert "if (id) pavementSelectListener?.(id);" in view # = structuresPanel.selectStructure(id) + cross = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") + assert 'revetOffset && shapePickRoute(svg, (at) => toggleRevet("pipe", at)),' in cross diff --git a/resources/tester/test_289_1_mass_badge_size.py b/resources/tester/test_289_1_mass_badge_size.py new file mode 100644 index 000000000..566ad448e --- /dev/null +++ b/resources/tester/test_289_1_mass_badge_size.py @@ -0,0 +1,94 @@ +"""289 종단도 누가토량 알약 크게 · 측점 ▲ 줄 아래로. + +- 테마 토큰 `--pill-font-size-lg` = 글자 한 단 위(14px · 공용 알약 12px) +- 배지(B05 · B06 한 벌 `common_util_mass_haul.css`) = 토큰 글자 · 줄 간격 · 여백 4/12 +- 자리 = 위에서 52px(▲ 줄 바닥 47px 아래 · 잰 값) · 종단 오버레이 한 곳(`design_profile_overlays.ts`) +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _block(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_token_one_step_up(): + theme = _read("ui_template/ui_template_theme.css") + assert "--pill-font-size-lg: var(--text-body-sm);" in theme + + +def test_badge_uses_large_pill(): + badge = _block(_read("common_util/common_util_mass_haul.css"), ".mass-haul-badge") + assert "font-size: var(--pill-font-size-lg)" in badge + assert "line-height: var(--leading-body-sm)" in badge + assert "padding: var(--spacing-4, 4px) var(--spacing-12, 12px)" in badge + + +def test_badge_below_up_buttons(): + overlays = _read("A00_Common/design/design_profile_overlays.ts") + m = re.search(r'"--mass-haul-badge-top", "(\d+)px"', overlays) + assert m and int(m.group(1)) > 47 # 측점 ▲ 줄 바닥(top 30 + 17) + + +# 289-2 붉은 계통 — 테마 위험색 섞음 · 밝음 · 어두움 대비(글자 4.5 : 1 이상) +THEMES = { + "light": {"danger": "#c0392b", "surface": "#ffffff", "body": "#333333", "secondary": "#615e6e"}, + "dark": {"danger": "#e8695a", "surface": "#251f38", "body": "#e4e0f0", "secondary": "#b3aec6"}, +} + + +def _rgb(hex_: str) -> tuple[float, ...]: + return tuple(int(hex_[i : i + 2], 16) / 255 for i in (1, 3, 5)) + + +def _mix(a: str, pct: int, b: str) -> tuple[float, ...]: + """CSS color-mix(in srgb, a pct%, b) — 불투명 근사.""" + return tuple(x * pct / 100 + y * (100 - pct) / 100 for x, y in zip(_rgb(a), _rgb(b))) + + +def _lum(c: tuple[float, ...]) -> float: + lin = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in c] + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] + + +def _contrast(a: tuple[float, ...], b: tuple[float, ...]) -> float: + hi, lo = sorted((_lum(a), _lum(b)), reverse=True) + return (hi + 0.05) / (lo + 0.05) + + +def test_badge_red_tone_uses_theme_danger(): + css = _read("common_util/common_util_mass_haul.css") + badge = _block(css, ".mass-haul-badge") + assert "border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent)" in badge + assert "color: color-mix(in srgb, var(--color-danger) 70%, var(--color-text-body))" in badge + assert "var(--color-danger) 6%" in badge + em = _block(css, ".mass-haul-badge em") + assert "color-mix(in srgb, var(--color-danger) 55%, var(--color-text-secondary))" in em + over = _block(css, ".mass-haul-badge.is-over") + assert "border-color: var(--color-danger)" in over + assert "var(--color-danger) 12%" in over + over_value = _block(css, ".mass-haul-badge.is-over strong") + assert "color-mix(in srgb, var(--color-danger) 85%, var(--color-text-body))" in over_value + assert not re.search(r"#[0-9a-fA-F]{3,6}\b", badge + em + over + over_value) # 하드코딩 색 없음 + + +def test_badge_red_tone_contrast_both_themes(): + for name, t in THEMES.items(): + bg = _mix(t["danger"], 6, t["surface"]) + text = _mix(t["danger"], 70, t["body"]) + caption = _mix(t["danger"], 55, t["secondary"]) + over_bg = _mix(t["danger"], 12, t["surface"]) + over_value = _mix(t["danger"], 85, t["body"]) # 음수 값 = 위험색 더 많이 + assert _contrast(text, bg) >= 4.5, name + assert _contrast(caption, bg) >= 4.5, name + assert _contrast(over_value, over_bg) >= 4.5, name + assert _contrast(caption, over_bg) >= 4.5, name diff --git a/resources/tester/test_290_1_project_purge.py b/resources/tester/test_290_1_project_purge.py new file mode 100644 index 000000000..f401a5705 --- /dev/null +++ b/resources/tester/test_290_1_project_purge.py @@ -0,0 +1,64 @@ +"""290-1 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움(요구 183-1). + +- 들어갈 때(공용 `goToWorkflowStage`) 다른 프로젝트 세션 칸(초안 · 결과 · 옛 키 · 페이지 칸) · 3D 보관함 지움 +- 다른 프로젝트에 저장 안 한 고침이 있으면 확인창(지우고 열기 / 남기기) · 남기기 = 그 초안만 둠 +- 같은 프로젝트로 다시 들어오면 아무것도 안 지움 · 지금 프로젝트 · 계정 취향(pref) 그대로 +`helper_290_1_project_purge.cjs` 가 진짜 TS 를 가짜 저장소 위에서 돌림. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = os.path.dirname(os.path.abspath(__file__)) +A, B, C = "aaaa1111", "bbbb2222", "cccc3333" +MINE = [f"aislo:draft:structures:{B}", f"aislo:result:section-detail:{B}:7"] +UNRELATED = ["aislo.otp-pending", "aislo:entered-project"] + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_290_1_project_purge.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_지우고_열기_다른_프로젝트_모두_지움(out): + assert out["drop"]["keys"] == sorted(MINE + UNRELATED) + assert out["drop"]["purged"] == [B] # 3D 보관함도 + assert out["drop"]["asked"] == [ + [f"「이름-{A}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기", "지우고 열기", "남기기"] + ] + + +def test_남기기_그_프로젝트_초안만_둠(out): + kept = [f"aislo:draft:crossw:{A}:12", f"aislo:draft:structures:{A}", f"b06:culvertopt:{A}:12"] + assert out["keep"]["keys"] == sorted(MINE + UNRELATED + kept) + assert out["keep"]["asked"] == 1 + + +def test_같은_프로젝트_다시_들어오면_안_지움(out): + assert out["same"]["asked"] == 0 and out["same"]["purged"] == 0 + assert len(out["same"]["keys"]) == 11 + + +def test_빈_초안은_고침_아님_취향은_둠(out): + assert out["plan"] == [A] # C 의 빈 초안({})은 묻지 않음 + assert out["pref"] == "{}" + + +def test_공용_이동_한_곳에서_부름(): + nav = (ROOT / "A00_Common/b_workflow_nav.ts").read_text(encoding="utf-8") + assert "void enterProject(" in nav and "./b_project_switch" in nav + ui = (ROOT / "ui_template/ui_template_elements.ts").read_text(encoding="utf-8") + assert 'cancelLabel = "취소"' in ui and "label: cancelLabel" in ui diff --git a/resources/tester/test_291_1_add_only_on_add.py b/resources/tester/test_291_1_add_only_on_add.py new file mode 100644 index 000000000..7510271b5 --- /dev/null +++ b/resources/tester/test_291_1_add_only_on_add.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""PLAN 291-1 — 구조물 배치: 새 구조물은 [추가] 누른 뒤에만 생김. + +A군(관 지점 정본) — 기준 측점 확정(`commitPosition`)은 관을 안 넣음 · 우클릭 「추가」(116-1)만 +임시 배치를 만듦 · [추가](`commit`)가 관 정본에 넣음. B · C 군은 [추가] 전 기록이 없음(`liveCommit` 이 +고르던 항목만). 세월교 · 물넘이 빈 월류 높이는 [추가] 뒤 재계산 유량으로 채움(126-1 `fordWaits`). +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" + + +def _read(name: str) -> str: + return (B05 / name).read_text(encoding="utf-8") + + +def _body(src: str, start: str, end: str) -> str: + at = src.index(start) + return src[at : src.index(end, at)] + + +PANEL = _read("B05_Profile_UI_Structures_Panel.ts") + + +def test_임시_배치는_create_일_때만_관을_넣음(): + body = _body( + PANEL, "function commitTempPipe(create = false): void {", "function handleStationInput" + ) + guard = body.index("if (!create) return;") + add = body.index("callbacks.onPipeAdd(anchor, attributes);") + assert body.index("if (tempPipeChainage === null) {") < guard < add + # 이미 있는 임시 관(우클릭)은 기준 측점으로 옮김 그대로 + assert "callbacks.onPipeUpdate(from, anchor, attributes);" in body + + +def test_측점_확정은_관을_안_만듦(): + body = _body(PANEL, "function commitPosition(): void {", "function renderOptionFields") + assert "commitTempPipe();" in body + assert "commitTempPipe(true)" not in body + + +def test_우클릭_추가만_임시_배치_만듦(): + body = _body(PANEL, " addAt(chainageM, typeId) {", " moveById(") + assert "if (type.managed_by) commitTempPipe(true);" in body + assert PANEL.count("commitTempPipe(true)") == 1 + + +def test_추가_단추가_관_정본에_넣음(): + commit = _read("B05_Profile_UI_Structures_Panel_Commit.ts") + body = _body(commit, "export async function commit(", "const placement = type.placement;") + assert "ctx.callbacks.onPipeAdd(anchor, attributes);" in body + + +def test_고르던_항목만_실시간_반영(): + body = _body(PANEL, "function liveCommit(): void {", "function commitSummary") + assert "if (!editingId && selectedPipeChainage === null) return;" in body + + +def test_새_세월교_물넘이_월류_높이는_추가_뒤_채움(): + drainage = _read("B05_Profile_UI_Drainage_Panel.ts") + body = _body(drainage, " addPipe(chainageM, attributes) {", " updatePipeFacility(") + assert "fordWaits().add(chainageM)" in body + + +def test_패널_700줄_안(): + assert len(PANEL.splitlines()) <= 700 diff --git a/resources/tester/test_294_1_chute_remove_pick.py b/resources/tester/test_294_1_chute_remove_pick.py new file mode 100644 index 000000000..d13c77ab5 --- /dev/null +++ b/resources/tester/test_294_1_chute_remove_pick.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""PLAN 294-1 · 294-2 — 선택 컨테이너 [삭제] 단추 · 물넘이 콘크리트 형상 고름 컨테이너. + +294-1 B06 조정 강조(`setGroupLabel`)가 이름표 글 칸만 고침 — 강조 켜고 끈 뒤에도 [삭제] 단추 · + 원래 글(「삭제」 안 섞임) · 괄호 붙임도 글 칸만. +294-2 물넘이포장 콘크리트 누름(side "pipe" · 배관 명세 숨음) = 「경사각도」 상자 강조 · 배관이면 「관」 상자 그대로. +Node 가짜 DOM 위에서 `B06_Section_UI_Adjust_Dock.ts` 를 돌림(`helper_294_1_chute_remove_pick.cjs`). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", str(HERE / "helper_294_1_chute_remove_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _box(text, buttons, active, base=None): + return {"active": active, "text": text, "buttons": buttons, "base": base or text} + + +def test_도수로_강조_켜도_삭제_단추_원래_글(out): + assert out["chuteOn"] == _box("도수로/돌붙임", ["삭제"], True) + + +def test_괄호_붙임도_글_칸만_단추_그대로(out): + assert out["wingOn"] == _box("유출 날개벽 (기슭막이)", ["삭제"], True, "유출 날개벽") + assert out["chuteOff"] == _box("도수로/돌붙임", ["삭제"], False) + + +def test_물넘이_콘크리트_고름_경사각도_상자(out): + assert out["slopeOn"] == _box("경사각도", [], True) + assert out["pipeOn"] is False + assert out["wingOff"] == _box("유출 날개벽", ["삭제"], False) + + +def test_풀면_원래_글_단추_그대로(out): + assert out["released"] == [ + _box("도수로/돌붙임", ["삭제"], False), + _box("유출 날개벽", ["삭제"], False), + _box("경사각도", [], False), + ] + + +def test_배관이면_관_상자_그대로(out): + assert out["pipeVisible"] == {"pipe": True, "slope": False} + + +def test_형상_고름_길_그대로(): + view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") + # 물넘이 콘크리트 · 맹암거 형상 누름 = 관 지점 고름 키 "pipe"(287-5) · 다시 누름 = 풂(toggle) + assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view + assert 'setShapeActive(key === "pipe");' in view diff --git a/resources/tester/test_294_3_ford_extend_chute.py b/resources/tester/test_294_3_ford_extend_chute.py new file mode 100644 index 000000000..e20bb8560 --- /dev/null +++ b/resources/tester/test_294_3_ford_extend_chute.py @@ -0,0 +1,201 @@ +"""294 물넘이 낮은 쪽 연장 · 도수로/돌붙임 끝단 · 띠 ↔ 성토 사면 고름(요구 188-2 · 188-3 · 189 · 그림_188). + +못박는 것 + - 294-3 물넘이 측점 낮은 쪽 노면 끝 원지반이 노면보다 높으면(절토) 노면을 같은 기울기로 늘여 원지반과 + 처음 만나는 자리까지(그 쪽 절토 사면 없음 · `tilt_reach`) · 성토 쪽 그대로 · 반폭 안에서 못 만나면 옛 길 · + 서버 정본 ↔ TS 짝 같은 값 = 횡단 설계 거울(`test_b06_cross_design_mirror` 「물넘이포장 낮은 쪽 노면 + 연장」) · 그림 = 콘크리트(포장층)가 늘인 끝까지. + - 294-4 도수로/돌붙임 띠 = 낮은 쪽 끝단(늘인 끝 · 아니면 유출 쪽 노견 끝)에서 성토 사면 따라 · 길이 비움 = + 원지반 닿으면 끝 · 양측 절토 물넘이 = 띠 없음 · 길이 = 그 길이(사면보다 길면 원지반 따라) · 다른 구조물 + (배관 · 박스 · 세월교) 같은 규칙(`chuteRun` · 원지반 `layout.ground`) + 같은 「길이 (m)」 칸 · 면적 = 세월교만 + 월류 폭 × 길이(배관 · 박스 = 폭 정의 없어 자동 안 함) · 길이는 집계 · B08 수량에 안 실림. + +진짜 TS 는 `helper_294_3_ford_extend_chute.cjs` 가 Node 로 돌림. 화면은 사용자 확인. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 +from test_b06_cross_design_mirror import CASES, _dip_ground # noqa: E402 + +KW = { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "paved": True, + "tilted": True, + "tilt_pct": 5.0, + "standard": STANDARD_CROSS_SECTION, +} + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _line(samples_fn) -> list[dict]: + return [ + {"offset_m": o / 2, "elevation_m": round(samples_fn(o / 2), 4), "valid": True} + for o in range(-24, 25) + ] + + +@pytest.fixture(scope="module") +def dip() -> dict: + return compute_cross_design(_dip_ground(), 100.4, **KW) + + +@pytest.fixture(scope="module") +def ts(dip, tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s294") / "in.json" + fill_samples = _line(lambda o: 100.0 + 0.35 * o) + fill = compute_cross_design(fill_samples, 100.4, **KW) + payload = { + "design": dip, + "dipSamples": _dip_ground(), + "fillSamples": fill_samples, + "fillDesign": fill, + } + source.write_text(json.dumps(payload), encoding="utf-8") + proc = subprocess.run( + ["node", str(HERE / "helper_294_3_ford_extend_chute.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_양측_절토_낮은_쪽_늘인_끝은_원지반_교점(dip): + reach = dip["tilt_reach"] + # 원지반 101.05 + 0.2·o(−4.5 까지) · 노면 100.4 + 0.05·o → −4.3333 에서 만남 + assert reach["side"] == "right" + assert reach["offset_m"] == pytest.approx(-4.3333, abs=1e-4) + assert reach["elevation_m"] == pytest.approx(100.4 - 0.05 * 4.3333, abs=1e-4) + edge = dip["road_edges"]["right"]["offset_m"] + for p in dip["design_line"]: # 노견 끝 ~ 늘인 끝 = 노면 기울기 그대로(절토 사면 없음) + if reach["offset_m"] - 1e-6 <= p["offset_m"] <= edge: + assert p["elevation_m"] == pytest.approx(100.4 + 0.05 * p["offset_m"], abs=1e-4) + assert dip["ditch_enabled"] is False + + +def test_성토_쪽은_그대로(): + got = compute_cross_design(_line(lambda o: 100.0 + 0.35 * o), 100.4, **KW) + assert "tilt_reach" not in got # 낮은 쪽(우) 노면 끝 원지반이 노면 아래 = 성토 사면 + + +def test_반폭_안에서_못_만나면_옛_길(): + got = compute_cross_design(_line(lambda o: 101.0 + 0.02 * o), 100.4, **KW) + assert "tilt_reach" not in got # 늘인 노면이 끝까지 원지반 아래 = 절토 사면 그대로 + right = [ + p for p in got["design_line"] if p["offset_m"] < got["road_edges"]["right"]["offset_m"] + ] + assert any(p["elevation_m"] > got["road_edges"]["right"]["elevation_m"] for p in right) + + +def test_서버_TS_같은_값은_거울_시험(): + assert any(case["name"] == "물넘이포장 낮은 쪽 노면 연장" for case in CASES) + + +def test_콘크리트는_늘인_끝까지(dip, ts): + assert ts["edges"]["right"] == { + "offset_m": dip["tilt_reach"]["offset_m"], + "elevation_m": dip["tilt_reach"]["elevation_m"], + } + first = ts["pavePoints"].split()[1] # 포장층 윗면 우 끝 + assert float(first.split(",")[0]) == pytest.approx(-4.3333, abs=1e-4) + + +def test_띠_길이_맞춤_공용(ts): + assert ts["runNone"] == [[[0, 0], [3, -4]]] # 길이 없음 = 원지반 닿으면 끝(그대로) + assert ts["runShort"] == [[[0, 0], [1.2, -1.6]]] # 길이 2 = 사면 따라 2 m + assert ts["runLong"] == [[[0, 0], [3, -4]], [[3, -4], [5, -4]]] # 사면 5 + 원지반 2 + assert ts["runPointOnly"] == [] # 끝단이 이미 원지반 · 길이 없음 = 안 그림 + assert ts["runPointLength"] == [[[3, -4], [5, -4]]] + + +def test_양측_절토_물넘이_띠_없음(ts): + assert len(ts["dipLine"]) == 1 # 늘인 끝이 원지반 + assert ts["dipLine"][0]["offset"] == pytest.approx(-4.3333, abs=1e-4) + assert ts["dipChute"] == [] + tail = ts["dipChuteLength"] # 길이를 넣으면 늘인 끝에서 원지반 따라 그 길이 + assert tail[0]["offset"] == pytest.approx(-4.3333, abs=1e-4) and len(tail) >= 2 + + +def test_성토_쪽_띠는_원지반_닿으면_끝(ts): + line = ts["fillLine"] + end = line[-1] + assert end["elevation"] == pytest.approx(100.0 + 0.35 * end["offset"], abs=1e-3) # 원지반 위 + assert len(ts["fillLineOld"]) > len(line) # 원지반을 모르면 내려가는 원지반까지 따라갔음 + chute = ts["fillChuteLength"] + start, stop = chute[0], chute[-1] + assert (stop["offset"] - start["offset"]) ** 2 + ( + stop["elevation"] - start["elevation"] + ) ** 2 == pytest.approx(1.0, abs=1e-6) + + +def test_다른_구조물_같은_규칙과_길이_칸(ts): + assert ts["chuteItems"][-1] == "length_m" + assert ts["fordPaveChuteSame"] is True + assert ts["fordArea"] == "20" # 세월교 월류 폭 8 × 길이 2.5 + hatch = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Wall_Hatch.ts").read_text( + encoding="utf-8" + ) + assert "const lines = chuteRun(allLines, chute.length_m, ground);" in hatch + for name in ("Culvert", "Box", "Ford"): + overlay = (ROOT / "B06_Section" / f"B06_Section_UI_Cross_{name}.ts").read_text( + encoding="utf-8" + ) + assert "layout.ground," in overlay + form = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text( + encoding="utf-8" + ) + assert form.count("syncChuteArea(") == 2 # 물넘이 · 세월교만(배관 · 박스 = 폭 정의 없음) + + +def test_길이는_집계_수량에_안_실림(): + from B06_Section.B06_Section_Engine_Culvert import _chute + from B08_Quantity.B08_Quantity_Router_Summary import _chutes + from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows + + options = { + "chute_add_1_structure": "돌붙임", + "chute_add_1_bond": "찰붙임", + "chute_add_1_area": 12, + } + longer = {**options, "chute_add_1_length_m": 7} + assert _chute(longer)["length_m"] == 7 and "length_m" not in _chute(options) + + class Sheet: + def __init__(self) -> None: + self.rows: list[tuple] = [] + + def add(self, at, key, sort, group, label, value, combos) -> None: + self.rows.append((key, label, value)) + + sheet = Sheet() + _chutes(sheet, 50.0, longer, {"gchuteadd1": "SS1"}, {}) + assert sheet.rows == [("chute|찰붙임", "㎡", 12.0)] + item = {"type_id": "pipe", "options": options, "복사본": "pipe@50.0", "흙막이단": 0} + plain = container_rows(item, {"gchuteadd1": "SS1"}) + long = container_rows({**item, "options": longer}, {"gchuteadd1": "SS1"}) + for key in ("length_m", "inlet_revet_length_m", "outlet_revet_length_m", "area_m"): + assert plain[0]["options"].get(key) == long[0]["options"].get(key) diff --git a/resources/tester/test_294_6_ford_linked_mark.py b/resources/tester/test_294_6_ford_linked_mark.py new file mode 100644 index 000000000..f825fa3fb --- /dev/null +++ b/resources/tester/test_294_6_ford_linked_mark.py @@ -0,0 +1,81 @@ +"""294-6 물넘이 월류 폭으로 옆 측점 카드에 그려진 포장 = 연결 표기(요구 190-3). + +못박는 것 + - 옆 카드(물넘이 측점 ≠ 카드 측점) = 포장층 · 월류 높이선에 `is-linked`(흙막이 연동 꼴) · + 호버 글 「물넘이포장 · 4+10 연결」 · 앞서 그린 것(세월교 점선)은 그대로. + - 주인 카드 = 옛 꼴(연결 표식 · 글 없음). + - 고름 = 주인 구조물 그대로(`data-structure-id` pipe-90.00 · 강조 켜짐) · CSS 연결 꼴이 강조를 안 덮음. + - 카드 그리기(`Cross_View`)가 물넘이포장 바로 뒤에 표기를 부름. + +진짜 TS 는 `helper_294_6_ford_linked_mark.cjs` 가 Node 로 돌림. 화면은 브레인 확인. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_294_6_ford_linked_mark.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_옆_카드_연결_꼴_호버_글(out): + neighbor = out["neighbor"] + assert neighbor["marked"] is True + assert len(neighbor["classes"]) == 2 + assert all("is-linked" in c.split() for c in neighbor["classes"]) + assert neighbor["titles"] == [["물넘이포장 · 4+10 연결"]] * 2 + assert neighbor["beforeClass"] == "b06-chart__ford-deck-plan" + + +def test_주인_카드_옛_꼴(out): + owner = out["owner"] + assert owner["marked"] is False + assert not any("is-linked" in c.split() for c in owner["classes"]) + assert owner["titles"] == [[], []] + assert out["noSpec"] is False + + +def test_고름은_주인_구조물(out): + for card in (out["neighbor"], out["owner"]): + assert card["pick"] == ["pipe-90.00"] + assert "is-active" in card["classes"][0].split() + + +def test_CSS_연결_꼴_강조가_먼저(): + css = (ROOT / "B06_Section" / "B06_Section_UI_Style_Cross_Areas.css").read_text( + encoding="utf-8" + ) + assert ".b06-chart__ford-pavement.is-linked:not(.is-active)" in css + assert ".b06-chart__ford-deck-plan.is-linked" in css + + +def test_카드가_물넘이포장_뒤에_표기_부름(): + view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") + start = view.index("const fordFrom = plotLayer.children.length;") + call = view.index("appendFordPavementOverlay(", start) + mark = view.index( + "markLinkedFordPavement(plotLayer, fordFrom, section, stationInterval);", call + ) + assert start < call < mark diff --git a/resources/tester/test_294_7_ford_slab_pick.py b/resources/tester/test_294_7_ford_slab_pick.py new file mode 100644 index 000000000..28349da3d --- /dev/null +++ b/resources/tester/test_294_7_ford_slab_pick.py @@ -0,0 +1,125 @@ +"""PLAN 294-7 — 물넘이 콘크리트 누름 고침(브레인 검증 297). + +- 기본 배율 콘크리트(약 4 px) = 보라 노면선 판정 띠(위아래 5 px)에 덮였음 → 형상 판정면을 아래쪽으로 + 최소 두께(12 px)까지 늘림 · 노면선 바로 아래 누름 = 콘크리트 · 노면선 위는 보라 선 그대로(185 끌기) +- 콘크리트 누름 = 그 물넘이(자기 id 누가거리) 고름 — 카드에 걸친 연동 관(95 m) · 자기 다른 관으로 안 감 · + 자기 관 없는 카드의 「관」 되살림도 물넘이 · 관 몸통 누름은 자기 관 그대로 · 다시 누름 = 풂 +- 맹암거 형상 = 알림(목록이 id 로 고름) 그대로 +- 294-8 · 295-4 안 골라진 카드 첫 누름 = 눌린 대상 — 카드 고름이 연동 관으로 폼을 옮기지 않음 · 폼을 열 때 + 두 번 그림(기본값 → 시설 값)에 선 도수로 상자를 「새 상자」로 골라 덮지 않음(Esc 뒤 · 다른 카드 고른 뒤) + +실제 TS 를 `helper_294_7_ford_slab_pick.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +B06 = os.path.join(ROOT, "B06_Section") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(name: str) -> str: + with open(os.path.join(B06, name), encoding="utf-8") as f: + return f.read().replace(chr(13), "") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_294_7_ford_slab_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_기본_배율_노면선_바로_아래_누름은_콘크리트(out): + s = out["slab"] + assert s["thick"] == "100,100 200,96 200,108 100,112" # 아랫변만 8 내림(평균 두께 4 → 12) + assert s["bottomAt150"] == 110 + assert s["justBelowOld"] is None and s["justBelow"] == "slab" # 노면선 6 px 아래 + assert s["insideOld"] == "road" # 옛 판정면은 통째로 보라 띠 안 + assert s["onLine"] == "road" # 노면선 위 = 보라 선 그대로(끌기 · 185) + assert s["order"] == ["slab", "road"] + assert s["tallUnchanged"] is True # 두꺼운 형상(맹암거 등)은 그대로 + + +def test_형상_누름_길(out): + assert out["routed"] == [["pipe", 90], ["event", "ud-1"]] + + +def test_연동_관_있는_카드에서도_자기_물넘이(out): + o = out["owner"] + assert o["slabOwnCard"] == [["remember", 90, "pipe"], ["form", 90]] # 옛 = 연동 관 95 + assert o["slabHint"] == [["remember", 90, "pipe"], ["form", 90]] + assert o["slabLinkedCard"] == [["remember", 90, "pipe"], ["form", 90]] + assert o["pipeBody"] == [["remember", 80, "pipe"], ["form", 80]] + assert o["chute"] == [["remember", 90, "chute"], ["form", 90]] + + +def test_다시_누름은_풂(out): + assert out["owner"]["release"] == [["remember", 90, ""], ["release"]] + + +def test_배선_경사각도_상자(): + view = _read("B06_Section_UI_Cross_View.ts") + assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view + assert "revetOffset?.select(section.chainage_m, activeRevet, ownerM);" in view + assert 'setShapeActive(key === "pipe");' in view + assert len(view.splitlines()) <= 700 + shape = _read("B06_Section_UI_Cross_Shape_Pick.ts") + assert 'thickenDown(shape.getAttribute("points") ?? "")' in shape + # 폼에 물넘이(관 상자 없음) = 「경사각도」 상자(294-2 그대로) + dock = _read("B06_Section_UI_Adjust_Dock.ts") + assert 'side === "pipe" && (!own || own.closest("[hidden]")) ? visibleBox(SLOPE_BOX)' in dock + + +def test_안_골라진_카드_콘크리트_첫_누름_294_8(out): + f = out["first"] + want = [["select", 90, "pipe"], ["remember", 90, "pipe"], ["form", 90], ["form", 90]] + assert f["slabAfterEsc"] == want # 옛 = 폼 95 · {at:95, key:"chute"} + assert f["slabAfterOther"] == want # 4+15 배관 고른 뒤도 + + +def test_안_골라진_카드_보라_선_첫_누름_295_4(out): + f = out["first"] + want = [["select", 110, None], ["remember", 110, ""], ["release"], ["form", 110]] + assert f["lineAfterEsc"] == want # 옛 = {at:110, key:"chute"} · 선 풀림 + assert f["lineAfterOther"] == want + + +def test_폼_안_새_상자는_그대로_고름(out): + assert out["first"]["addBox"] == [ + ["select", 95, "extra0"], + ["remember", 95, "extra0"], + ["form", 95], + ] + + +def test_배선_카드_고름_리스너(): + with open( + os.path.join(ROOT, "A00_Common", "design", "design_view_cross.ts"), encoding="utf-8" + ) as f: + raw = f.read() + cross = " ".join(raw.split()) + assert "structuresPanel.showAtChainage(structureSelection.formChainageOf(target))" in cross + assert len(raw.splitlines()) <= 685 # 233-2 · 233-3 · 298 한도 + sel = _read("B06_Section_UI_Page_Structures_Selection.ts") + assert "(wallOwner ?? stationControls.structureSpan.ownerOf(target)?.chainage_m)" in " ".join( + sel.split() + ) + assert "if (!born || chainageM === null || opening) return;" in sel diff --git a/resources/tester/test_295_pick_order_detail.py b/resources/tester/test_295_pick_order_detail.py new file mode 100644 index 000000000..4bc652b36 --- /dev/null +++ b/resources/tester/test_295_pick_order_detail.py @@ -0,0 +1,139 @@ +"""PLAN 295 — 횡단 카드 · [상세] 모달 고르기 차례 · 모달 첫 보기 · 구조물 선 안 나눔(294-5 포함). + +- 295-1 카드 = 판정면 넓이 큰 것부터 아래(작은 것 위) · 보라 선(사면 손잡이) 맨 위 · 띠 있는 성토 사면 누름 = 사면 · + 끌기 = 고름 아님 / 모달 `pickAt` = 선 > 작은 면 > 큰 면 · 띠(띠는 영역보다 작을 때만) +- 295-2 모달 첫 보기 = 중심점이 칸 가운데 · 그림 전체가 들어가게 축마다 큰 쪽 반폭 +- 295-3 모달 구조물 선 = 다른 선을 나누는 기준 그대로 · 고를 땐 한 선 통째 · 닫힌 영역 그대로 · 옛 조각 다시 고름 + +실제 TS 를 `helper_295_pick_order_detail.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +B06 = os.path.join(ROOT, "B06_Section") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(name: str) -> str: + with open(os.path.join(B06, name), encoding="utf-8") as f: + return f.read().replace(chr(13), "") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_295_pick_order_detail.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_카드_작은_것_위_보라_선_맨_위(out): + # 띠(14400) > 벽(4000) > 형상(100) > 관(25) · 보라 선 둘 맨 위 · 면적 띠는 맨 아래 그대로 + assert out["cardOrder"] == ["band", "chute", "wall", "shape", "pipe", "slope", "top"] + assert out["area"] == 200 + + +def test_카드_띠_있는_성토_사면_누름은_사면_294_5(out): + assert out["slopeClickOld"] == "chute" # 옛 차례 = 띠가 사면을 가림 + assert out["slopeClick"] == "slope" + assert out["chuteClick"] == "chute" # 선 밖 띠 자리는 띠 + assert out["pipeOverWall"] == "pipe" + + +def test_카드_끌기는_고름_아님(out): + assert out["dragStopped"] is True + assert out["clickStopped"] is False + + +def test_모달_선_작은_면_큰_면_띠(out): + m = out["modal"] + assert m["line"] == {"piece": 4} + assert m["small"] == {"face": 1} + assert m["big"] == {"face": 0} + assert m["bandBigger"] == {"face": 1} # 띠보다 좁은 영역이 앞(옛 「띠가 앞」 폐기) + assert m["bandSmaller"] == {"band": True} + assert m["bandBigFace"] == {"band": True} + assert m["bandOnly"] == {"band": True} + assert m["lineOverBand"] == {"piece": 4} + + +def test_모달_첫_보기_중심점_가운데(out): + h = out["home"] + assert h["center"] == pytest.approx(h["plotCenter"]) + assert h["scale"] == pytest.approx(140 / 210) # 아래로 먼 쪽 210 px 이 반높이에 + assert h["fits"] is True + assert h["flat"] == pytest.approx(2) # 세로가 낮으면 가로(카드 반폭) 배율 + + +def test_모달_구조물_선_한_조각(out): + s = out["structure"] + assert s["groundPieces"] == 3 # 구조물 선이 원지반선을 나눔(기준 그대로) + assert s["members"] == 2 and s["chosen"] == 2 # 나뉜 조각 둘 = 한 선 통째 + assert s["again"] == 0 + assert s["additiveOff"] == [99] + assert s["label"].startswith("구조물 선 길이 10.00m") + assert s["wholeValue"] == pytest.approx(10) + assert out["collected"] == [ + {"line": "design", "group": None}, + {"line": "design", "group": None}, + {"line": "structure", "group": 1}, + {"line": "structure", "group": 1}, + ] + + +def test_모달_닫힌_영역_옛_조각(out): + s = out["structure"] + assert s["faces"] == [50, 50] + assert s["oldPick"] == 1 + + +def test_배선(): + view = _read("B06_Section_UI_Cross_View.ts") + assert view.index("setLineActive(pickedLineAt(") < view.index("stackPickTargets(plotLayer)") + select = " ".join(_read("B06_Section_UI_Cross_Detail_Select.ts").split()) + assert "groupMembers(pieces, pick.piece)" in select + assert 'pick && "face" in pick' in select + table = _read("B06_Section_UI_Cross_Detail_Table.ts") + assert 'pointsArea(hit.getAttribute("points"))' in table + detail = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split()) + assert "detailHomeView(layerPoints(layer), [cx, cy], plot, homeScale, 16)" in detail + for name in ("B06_Section_UI_Cross_View.ts", "B06_Section_UI_Cross_Detail_Select.ts"): + assert len(_read(name).splitlines()) <= 700 + + +def test_보라_선_첫_누름은_그_선_295_4(): + """안 골라진 카드의 성토 사면선 첫 누름 — 남은 부재키(도수로 띠)를 먼저 풀어 카드 고름이 되살리지 않음.""" + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_294_7_ford_slab_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + first = json.loads(proc.stdout)["firstLine"] + assert first["oldKey"] == "chute" # 옛 길 = 카드 고름이 도수로 띠 부재키를 지킴 → 선 풀림 + assert first["newKey"] is None + view = " ".join(_read("B06_Section_UI_Cross_View.ts").split()) + assert "if (!isSelected) { // 첫 누름 = 이 선" in view + assert ( + "if (activeRevet === null) revetOffset?.select(section.chainage_m, null); onSelect(section.station_id);" + in view + ) diff --git a/resources/tester/test_296_b09_update_skip_deleted.py b/resources/tester/test_296_b09_update_skip_deleted.py new file mode 100644 index 000000000..2809d83e2 --- /dev/null +++ b/resources/tester/test_296_b09_update_skip_deleted.py @@ -0,0 +1,90 @@ +"""296 B09 [업데이트] — 지워진 복사 정보는 업데이트에서 뺌(요구 190-4). + +- ① B09 에서 지운 복사 항목 = 화면 캐시(user)를 같이 보냄 → 그 줄이 안 잇는 서고 사본 = + 수요에서 뺌 · 책에 안 살아남 · 캐시 그대로(지운 줄 안 되살림) · 저장은 안 함 +- ② M01 원본이 지워진 키 = 옛 책 줄 그대로(지우지도 바꾸지도 않음) · 낡음 까닭에서 빠짐 +- 나머지 = 지금처럼 M01 값으로 새로 굳힘 · user 안 보내면 옛 길 그대로 +임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. +""" + +import asyncio +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Router as router # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 +from resources.tester.test_204_2_b09_bill_library import COMBO, world # noqa: E402, F401 + +GONE = "LB999999" # M01 에 없는 키(원본 지워짐) + + +def _json(book): + return json.loads(json.dumps(book, ensure_ascii=False)) + + +def _refresh(monkeypatch, root, body): + seen = {} + + async def fake_root(_): + return root + + async def fake_payload(_, at, draft=None): + seen["draft"] = draft + return {} + + monkeypatch.setattr(router, "_project_root", fake_root) + monkeypatch.setattr(router, "payload", fake_payload) + asyncio.run(router.refresh_prices("00000000-0000-0000-0000-000000000001", body)) + return store_b09.read_book(root), seen["draft"] + + +def test_지운_복사_항목_업데이트_뒤_안_살아남(world, monkeypatch): # noqa: F811 + root = world["root"] + a = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"] + b = library.import_copy(root, "combo", "system", None, COMBO)["폴더"] + keep_a = set(prices.refresh_book(linked_resource_keys(root, {a}))["rows"]) + every = set(prices.refresh_book(linked_resource_keys(root))["rows"]) + assert keep_a < every # b 사본만 부르는 줄이 있음 + # 화면 캐시 = b 줄을 지움(아직 [저장] 안 함 · 사본 폴더는 남음) + ref = {"tab": "dg", "key": "DG:x", "lib": a} + user = {"bill": {"v": 2, "rows": [{"key": "hand:1", "name": "줄", "qty": 1, "ref": ref}]}} + book, draft = _refresh(monkeypatch, root, {"user": user}) + assert set(book["rows"]) == keep_a # 지운 사본 단가 줄 안 살아남 + assert draft["bill"]["rows"][0]["ref"]["lib"] == a # 응답 셈도 캐시로 + assert not (store_b09.folder(root) / store_b09.USER).exists() # 저장 안 함 + assert {f.name for f, _ in library.copies(root)} == {a, b} # 사본 정리는 [저장] 몫 + assert prices.book_stale(_json(book), linked_resource_keys(root, {a})) is None + # user 안 보냄 = 옛 길(다 쓴 사본 전부) + book, draft = _refresh(monkeypatch, root, None) + assert set(book["rows"]) == every and draft is None + + +def test_M01_원본_없는_항목_값_그대로_낡음_까닭_빠짐(): + keys = ["EQ000004", GONE] # 불도저 · 조종원 · 경유(test_144 견본) + 원본 없는 키 + old = _json(prices.refresh_book(["EQ000004"])) + kept = {"key": GONE, "code": "9999", "name": "지워진 직종", "price": 123.0} + old["rows"][GONE] = kept + old["rows"]["LB000048"] = {**old["rows"]["LB000048"], "price": 1} # M01 에서 바뀐 단가 + assert prices.book_stale(old, keys) == "M01 에서 바뀐 단가 1" # 원본 없는 줄 = 까닭에 없음 + new = _json(prices.refresh_book(keys, old)) + assert new["rows"][GONE] == kept # 지우지도 바꾸지도 않음 + assert new["rows"]["LB000048"]["price"] not in (None, 1) # 나머지 = 새로 굳힘 + assert set(new["rows"]) == set(old["rows"]) + assert prices.book_stale(new, keys) is None + assert GONE not in prices.refresh_book(keys)["rows"] # 옛 책 없으면 줄 없음(옛 길) + + +def test_화면_업데이트_캐시_보냄_그대로_둠(): + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + at = shell.index('"/estimation/prices",') + assert "{ user },\n true," in shell[at : at + 200] + assert 'if (!keep) clearState("b09-estimation", projectId);' in shell + assert "apply(next, !keep);" in shell diff --git a/resources/tester/test_298_add_reanalyze.py b/resources/tester/test_298_add_reanalyze.py new file mode 100644 index 000000000..0429a2dd3 --- /dev/null +++ b/resources/tester/test_298_add_reanalyze.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +"""PLAN 298-1 — 신규 관 계열(배관 · 박스암거 · 세월교 · 물넘이포장) [추가] = 횡단도 생김 + 세부유역 뒤에서 다시 나눔. + +B05 [추가] = 배수유역도 판 `addPipe` → 다시 나눔(이미 있던 길) · B06 [추가] = 관 초안 → 판 `reload`(같은 길). +고친 것 — B06 길도 빈 월류 높이를 채움(126-1 · 판과 같은 기다림 표 `fordWaitsOf`) · 채운 높이를 B06 목록 · 폼이 +받음 · B06 알림 글이 「저장 뒤 계산」이라 틀리던 것 · 다시 나눔 실패 = 토스트 한 줄. +Node(`helper_298_add_reanalyze.cjs`) + 배선 대조. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _read(*parts: str) -> str: + return ROOT.joinpath(*parts).read_text(encoding="utf-8") + + +def _body(src: str, start: str, end: str) -> str: + at = src.index(start) + return src[at : src.index(end, at)] + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", str(HERE / "helper_298_add_reanalyze.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +PANEL = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts") +B06 = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") +CROSS = _read("A00_Common", "design", "design_view_cross.ts") + + +# ── 126-1 채움 — B06 [추가]도 판과 같은 표 ────────────────────────────────── + + +def test_B06_추가가_적은_기다림을_판이_채움(out): + assert out["b06Wants"] is True and out["same"] is True + assert out["filled"] is True + assert out["heights"] == [out["need"], None] # 기다리던 측점만 · 옛 빈칸은 그대로 + assert out["left"] == [] + + +def test_다른_프로젝트_같은_측점은_안_채움(out): + assert out["otherFilled"] is False + assert out["otherHeights"] == [None, None] + + +def test_B06_추가_순간_기다림_적고_초안_씀(): + body = _body(B06, " onPipeAdd: (chainageM, attributes) => {", " onPipeUpdate:") + order = [ + body.index("pipeFacilities.push({"), + body.index("if (waitsFordHeight(attributes.facility, attributes.options))"), + body.index("fordWaitsOf(deps.projectId).add(at);"), + body.index("persistPipes();"), + body.index("deps.onStructuresChanged?.();"), + ] + assert order == sorted(order) + + +def test_판은_같은_표로_채움(): + assert "const fordWaits = () => fordWaitsOf(projectId);" in PANEL + assert "new Set()" not in _body(PANEL, "let requestSeq = 0;", "let canvasWidth") + + +def test_채운_높이를_B06_목록_폼이_받음(): + adopt = _body(B06, " adoptFordHeights: () => {", " releasePipe:") + assert "readPendingPipes(deps.projectId)" in adopt + assert "if (entry.options?.ford_height_m !== undefined) return false;" in adopt # 빈 칸만 + assert "section.setPipeFacilities(pipeFacilities);" in adopt + assert "persistPipes" not in adopt # 받기만 — 초안을 다시 안 씀(되돌이 없음) + assert ( + "pipesChanged: () => (structuresPanel.adoptFordHeights(), void refreshDetailForStructures())," + in CROSS + ) + view = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + sig = _body(view, "const signature = JSON.stringify(", "const changed") + assert "p.options?.ford_height_m ?? null" in sig # 늦게 온 유량으로 채운 높이도 알림 + + +# ── 다시 나눔 = 요청 한 번 · 저장 안 함 · 최신 응답만 · 잠금 없음 ──────────── + + +def test_B06_초안_쓰면_판이_한_번_다시_나눔(): + written = _body(CROSS, " onPipesWritten: () => {", " detail: () => sectionDetail,") + assert "window.clearTimeout(pipeReloadTimer);" in written # 잇단 조작 = 한 번 + assert "shared.profile?.side?.reload()" in written + persist = _body(B06, " function persistPipes(): void {", " /** 관 옮기기") + assert persist.index("writePendingPipes(") < persist.index("deps.onPipesWritten?.();") + + +def test_다시_나눔은_세션_초안으로_계산만_저장_안_함(): + load = _body(PANEL, "const loadSaved = ", "const resetPipes") + assert "readPendingPipes(projectId as string)" in load + assert "computeDetailBasins(projectId as string, pending)" in load + analyze = _body(PANEL, "const analyze = ", "analyzeButton.addEventListener") + assert "computeDetailBasins(" in analyze + run = _body(PANEL, "async function run(", "/** 지금 관 목록") + assert "saveDetailPipePoints" not in run + load + analyze + # B05 [추가] = 판 addPipe → 배치 바뀜 → 초안 + 다시 나눔 + commit = _body(PANEL, "const pipeEditor = createPipeEditor(", "(fromM, toM) =>") + assert commit.index("rememberPipes();") < commit.index("void analyze();") + + +def test_최신_응답만_잠금_없이_지도_칸_표시(): + run = _body(PANEL, "async function run(", "/** 지금 관 목록") + assert "const seq = ++requestSeq;" in run + assert run.index("if (seq !== requestSeq) return;") < run.index("apply(response)") + # 지도 칸 안 작은 표시(문구 · 지도 가운데 서클)만 — 화면 전체 잠금 없음 + assert 'status.textContent = L("B05_Drainage_Status_Analyzing");' in run + assert 'showProgress(null, L("B05_Drainage_Status_Analyzing"));' in run + assert "Overlay" not in run and "lock" not in run.lower() + chrome = _read("B05_Profile", "B05_Profile_UI_Drainage_Chrome.ts") + assert "viewport.append(" in chrome and "progress.root," in chrome + + +def test_실패하면_토스트_한_줄(out): + run = _body(PANEL, "async function run(", "/** 지금 관 목록") + assert ( + 'status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed"));' in run + ) + failed = out["failed"] + assert failed["status"] == "502 Bad Gateway" + assert failed["toasts"] == [ + ["세부유역 산정에 실패했습니다. 502 Bad Gateway", "error"], + ["세부유역 산정에 실패했습니다.", "error"], + ] + + +# ── 횡단도 생김 · 알림 글 ──────────────────────────────────────────────── + + +def test_추가_뒤_새_측점_횡단_미리_뜸(): + body = _body(B06, " onPipeAdd: (chainageM, attributes) => {", " onPipeUpdate:") + assert "deps.onStructuresChanged?.();" in body + assert "onStructuresChanged: () => void refreshDetailForStructures()," in CROSS + drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts") + stations = _body( + drafts, "export async function withDraftStations(", "parked.set(detail, stale);" + ) + assert "readPendingPipes(projectId)" in stations + assert "previewOf(projectId, routeId, missing)" in stations + + +def test_B06_알림_글_뒤에서_다시_나눔(): + for name in ("PIPE_ADD_NOTICE", "PIPE_REMOVE_NOTICE", "PIPE_MOVE_NOTICE"): + line = _body(B06, f"const {name} =", ";\n") + assert "세부 배수유역은 뒤에서 다시 나눕니다." in line, name + assert "[저장]·[확정] 뒤에" not in line, name + + +def test_줄_수(): + assert len(PANEL.splitlines()) <= 700 + assert len(CROSS.splitlines()) <= 685 diff --git a/resources/tester/test_299_ford_span_pipe.py b/resources/tester/test_299_ford_span_pipe.py new file mode 100644 index 000000000..d5de86e2a --- /dev/null +++ b/resources/tester/test_299_ford_span_pipe.py @@ -0,0 +1,145 @@ +"""299-1 물넘이 연동 범위 안 측점의 자기 관 — 같은 측점에 함께 얹힘 · 그림 · 수량 한 번. + +까닭 — `attach_culvert_sets` 가 측점마다 첫 맞는 시설 하나만 얹고 멈춰, 검증 프로젝트 95 m 배관 +(관경 800 · 도수로)이 90 m 물넘이포장(폭 10 → 85~95 m 연동)에 가려 횡단도 · 관 연장에서 빠졌다. + +못박는 것 + - 키가 다른 시설(배관 culvert · 세월교 ford · 박스 box · 물넘이 ford_pavement)은 같은 측점에 함께. + - 같은 키는 소유 측점 것이 옆 측점 연동보다 먼저 · 세월교 · 박스 흙막이(guard)는 자기 관과 + 부딪히면 관 먼저(흙막이 없는 자리는 그대로 흙막이). + - 수량 = 소유 측점 한 번 — 소비처(B06 `Structure_Layouts` · B08)가 `chainage_m` 의 가장 가까운 + 측점만 세므로 시설마다 주인 한 곳 · 관 연장은 주인 측점의 `culvert`(흙막이 `body` 아님)만. + - 파이썬 ↔ TS 짝(`common_util_culvert_sets.ts`) 같은 값. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +for path in (ROOT, Path(__file__).resolve().parent): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import test_b06_culvert_sets_mirror as mirror # noqa: E402 +from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets, pipe_points_file # noqa: E402 + +# 검증 프로젝트 꼴 — 20 · 90 물넘이(폭 10) · 95 배관(도수로) · 100 배관 + 100.3 박스(유출 흙막이) · 60 세월교(흙막이) +POINTS = [ + {"chainage_m": 20.0, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}}, + {"chainage_m": 90.0, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}}, + { + "chainage_m": 95.0, + "facility": "pipe", + "options": {"pipe_diameter_mm": 800, "chute_add_1_structure": "도수로"}, + }, + { + "chainage_m": 60.0, + "facility": "ford_bridge", + "options": { + "ford_width_m": 6.0, + "inlet_type": "흙막이", + "inlet_revet_form": "콘크리트", + "inlet_revet_height_m": 1.5, + }, + }, + {"chainage_m": 100.0, "facility": "pipe", "options": {"pipe_diameter_mm": 600}}, + { + "chainage_m": 100.3, + "facility": "box_culvert", + "options": {"outlet_revet_form": "돌쌓기(찰)", "outlet_revet_height_m": 3.5}, + }, +] +CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 85.0, 90.0, 95.0, 100.0, 105.0] + +# 같은 키 — 옆 측점 연동(88.5 · 폭 10 · 주인 88)이 90 m 에 더 가깝지만 소유(92.4 · 주인 90)가 먼저 +SAME_KEY_POINTS = [ + {"chainage_m": 88.5, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}}, + {"chainage_m": 92.4, "facility": "ford_pavement", "options": {"ford_width_m": 0.1}}, +] +SAME_KEY_CHAINAGES = [85.0, 88.0, 90.0, 95.0] + + +def _attach(tmp_path: Path, points: list[dict], chainages: list[float]) -> list[dict]: + target = pipe_points_file(tmp_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8") + sections = [{"chainage_m": value} for value in chainages] + attach_culvert_sets(tmp_path, sections) + return sections + + +def _at(sections: list[dict], chainage: float) -> dict: + return next(s for s in sections if s["chainage_m"] == chainage) + + +@pytest.fixture() +def sections(tmp_path: Path) -> list[dict]: + return _attach(tmp_path, POINTS, CHAINAGES) + + +def test_연동_물넘이_범위_안_자기_관_함께(sections): + at95 = _at(sections, 95.0) + assert at95["culvert"]["chainage_m"] == 95.0 + assert at95["culvert"]["type"] == "pipe" + assert at95["culvert"]["chute"]["structure"] == "도수로" + assert at95["ford_pavement"]["chainage_m"] == 90.0 + assert set(_at(sections, 85.0)) == {"chainage_m", "ford_pavement"} + + +def test_흙막이는_자기_관에_밀림_없으면_그대로(sections): + at100 = _at(sections, 100.0) + assert at100["box"]["chainage_m"] == 100.3 + assert at100["culvert"]["chainage_m"] == 100.0 + assert "body" not in at100["culvert"] # 관 — 흙막이 세트 아님 + at60 = _at(sections, 60.0) + assert at60["ford"]["chainage_m"] == 60.0 + assert at60["culvert"]["chainage_m"] == 60.0 # 세월교 흙막이 + + +def test_같은_키는_소유_측점_먼저(tmp_path): + sections = _attach(tmp_path, SAME_KEY_POINTS, SAME_KEY_CHAINAGES) + assert _at(sections, 90.0)["ford_pavement"]["chainage_m"] == 92.4 + assert _at(sections, 85.0)["ford_pavement"]["chainage_m"] == 88.5 + + +def test_수량은_주인_측점_한_번(sections): + """소비처 규칙 — 시설 `chainage_m` 의 가장 가까운 측점만 셈.""" + owner = { + p["chainage_m"]: min(CHAINAGES, key=lambda c: abs(c - p["chainage_m"])) for p in POINTS + } + counted: dict[tuple[str, float], int] = {} + for section in sections: + for key in ("culvert", "ford", "box", "ford_pavement"): + value = section.get(key) + if value and owner[value["chainage_m"]] == section["chainage_m"]: + name = "guard" if key == "culvert" and "body" in value else key + counted[(name, value["chainage_m"])] = ( + counted.get((name, value["chainage_m"]), 0) + 1 + ) + assert counted == { + ("ford_pavement", 20.0): 1, + ("ford_pavement", 90.0): 1, + ("culvert", 95.0): 1, # 관 연장 · 도수로 — 95 m 한 번 + ("ford", 60.0): 1, + ("guard", 60.0): 1, + ("culvert", 100.0): 1, + ("box", 100.3): 1, + } + # 박스 흙막이는 자기 관에 밀려 어디에도 안 실림 — 관 연장 · 수량을 두 번 세지 않음 + assert not any(s.get("culvert", {}).get("chainage_m") == 100.3 for s in sections) + + +@pytest.mark.skipif(not mirror.TSC.is_file(), reason="프론트엔드 의존성 미설치") +@pytest.mark.parametrize( + ("points", "chainages"), [(POINTS, CHAINAGES), (SAME_KEY_POINTS, SAME_KEY_CHAINAGES)] +) +def test_파이썬_TS_짝_같은_값(tmp_path, monkeypatch, points, chainages): + expected = _attach(tmp_path / "py", points, chainages) + monkeypatch.setattr(mirror, "POINTS", points) + monkeypatch.setattr(mirror, "CHAINAGES", chainages) + assert mirror._ts_sections(tmp_path) == expected diff --git a/resources/tester/test_52_3_structure_spec_ford_pavement.py b/resources/tester/test_52_3_structure_spec_ford_pavement.py index e5a020a1e..eddc984ea 100644 --- a/resources/tester/test_52_3_structure_spec_ford_pavement.py +++ b/resources/tester/test_52_3_structure_spec_ford_pavement.py @@ -25,7 +25,9 @@ const load = (file) => { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const box = { exports: {} }; - new Function("exports", "module", "require", js)(box.exports, box, () => ({})); + // 같은 폴더 명세 import(`CHUTE_ITEMS` · 286-3)만 따라 옮김 + const near = (m) => (m.startsWith("./") ? load(path.join(path.dirname(file), `${m}.ts`)) : {}); + new Function("exports", "module", "require", js)(box.exports, box, near); return box.exports; }; const s = load(path.join(ROOT, "A00_Common/design/design_structure_spec.ts")); @@ -75,8 +77,9 @@ def test_저장_값은_도_와_인스턴스_키(out): "width": "6", "height": "0.3", } # 월류 폭 · 높이 = 옛 키 그대로 읽음(65-5) - # 두께(cm · 90-2) — 빈 칸은 빈 값(새 상자만 기본 30) - assert out["state"] == {"slope": [slope], "chute": [{"structure": "돌붙임", "thickness": ""}]} + # 다른 구조물과 같은 칸(구조 · 형태 · 두께 · 면적) + 길이(286-3) — 빈 칸은 빈 값(새 상자만 기본값) + chute = {"structure": "돌붙임", "bond": "", "thickness": "", "area": "", "length_m": ""} + assert out["state"] == {"slope": [slope], "chute": [chute]} assert out["written"] == { "ford_slope_deg": 15, "ford_width_m": 6, @@ -158,14 +161,15 @@ class Node { } globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; const pave = load(path.join(ROOT, "B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts")); -// +offset = 좌측 · 좌 성토 1:1.5 · 우 절토 오름 — 유입 = 좌(left_cut 아님) → 유출 = 우 -const design = (mode) => ({ +// +offset = 좌측 · 좌 성토 1:1.5 · 우 절토 오름 — 유입 = 표고 높은 쪽(설계선 끝 · 286-2) = 우 → 유출 = 좌 +// `rightEnd` = 우 끝 표고 — 좌 끝(97)과 같으면 단면유형으로 가름(left_cut = 유입 좌 → 유출 우) +const design = (mode, rightEnd = 99) => ({ section_mode: mode, cross_slope_pct: 2, design_elevation_m: 100, pavement_thickness_m: 0.2, road_edges: { left: { offset_m: 2.5, elevation_m: 100 }, right: { offset_m: -2.5, elevation_m: 100 } }, design_line: [ { offset_m: 7, elevation_m: 97 }, { offset_m: 2.5, elevation_m: 100 }, { offset_m: -2.5, elevation_m: 100 }, { offset_m: -5.5, elevation_m: 98 }, - { offset_m: -6.5, elevation_m: 98 }, { offset_m: -8, elevation_m: 99 }, + { offset_m: -6.5, elevation_m: 98 }, { offset_m: -8, elevation_m: rightEnd }, ], }); const spec = { span_m: 9, depth_m: 0.3, slope_pct: 2, chute: { structure: "돌붙임", thickness_m: 0.3 } }; @@ -176,7 +180,7 @@ const run = (mode, withChute = true) => { return band ? band.attrs.points : null; }; process.stdout.write(JSON.stringify({ - outLeft: pave.outletFillLine(design("left_cut")), outRight: pave.outletFillLine(design("right_cut")), + outLeft: pave.outletFillLine(design("left_cut", 97)), outRight: pave.outletFillLine(design("left_cut")), band: run("left_cut"), none: run("left_cut", false), })); """ @@ -188,13 +192,14 @@ def test_유출측_성토사면_띠_136_2(): ) assert proc.returncode == 0, proc.stderr got = json.loads(proc.stdout) - # 유입 = 좌 → 유출 = 우(− 바깥) · 노견에서 내려가는 동안만(평지 · 오름에서 멈춤) + # 양 끝 같음 → 단면유형 left_cut = 유입 좌 → 유출 = 우(− 바깥) · 노견에서 내려가는 동안만 assert got["outLeft"] == [ {"offset": -2.5, "elevation": 100}, {"offset": -5.5, "elevation": 98}, {"offset": -6.5, "elevation": 98}, + {"offset": -8, "elevation": 97}, ] - # right_cut = 유입 우 → 유출 좌 성토 1:1.5 + # 우 끝이 높음 = 유입 우(입력 left_cut 이어도 지형 · 286-2) → 유출 좌 성토 1:1.5 assert got["outRight"] == [{"offset": 2.5, "elevation": 100}, {"offset": 7, "elevation": 97}] - assert got["band"] and got["band"].startswith("-2.5,100 -5.5,98") + assert got["band"] and got["band"].startswith("2.5,100 7,97") assert got["none"] is None diff --git a/resources/tester/test_52_4_structure_spec_ford.py b/resources/tester/test_52_4_structure_spec_ford.py index 218a7f2b2..a7a4dd1ca 100644 --- a/resources/tester/test_52_4_structure_spec_ford.py +++ b/resources/tester/test_52_4_structure_spec_ford.py @@ -113,4 +113,7 @@ def test_정의_파일에_세월교_이름_표가_올라_있음(): os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8" ) as f: doc = json.load(f) - assert doc["종류"]["ford_bridge"]["이름"] == names + # 그림 전용 길이 칸(294-4)은 마스터 정의 이름 표에 안 올림(마스터 손대지 않음) — 대조 밖 + assert doc["종류"]["ford_bridge"]["이름"] == { + k: v for k, v in names.items() if k != "chute_add.length_m" + } diff --git a/resources/tester/test_52_structure_spec_box.py b/resources/tester/test_52_structure_spec_box.py index a9b7bf0ca..5bc8c469d 100644 --- a/resources/tester/test_52_structure_spec_box.py +++ b/resources/tester/test_52_structure_spec_box.py @@ -102,7 +102,8 @@ def test_정의_파일에_이름_표_그룹_선택지가_올라_있음(): os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8" ) as f: doc = json.load(f) - assert set(names) == set(doc["종류"]["box_culvert"]["이름"]) + # 그림 전용 길이 칸(294-4)은 마스터 정의 이름 표에 안 올림(마스터 손대지 않음) — 대조 밖 + assert set(names) - {"chute_add.length_m"} == set(doc["종류"]["box_culvert"]["이름"]) assert [g["id"] for g in doc["그룹"]["box_culvert"]] == ["gboxbody", "gboxin", "gboxout"] box = next(t for t in doc["types"] if t["type_id"] == "box_culvert") wing = {o["key"]: o for o in box["options"]} diff --git a/resources/tester/test_52_structure_spec_pipe.py b/resources/tester/test_52_structure_spec_pipe.py index 88277e3d1..64b33b570 100644 --- a/resources/tester/test_52_structure_spec_pipe.py +++ b/resources/tester/test_52_structure_spec_pipe.py @@ -107,5 +107,6 @@ def test_정의_파일에_이름_표가_올라_있음(): ) as f: doc = json.load(f) on_file = doc["종류"]["pipe"]["이름"] - assert set(names) == set(on_file) + # 그림 전용 길이 칸(294-4)은 마스터 정의 이름 표에 안 올림(마스터 손대지 않음) — 대조 밖 + assert set(names) - {"chute_add.length_m"} == set(on_file) assert on_file["inlet.structure=기슭막이"] == "흙막이" diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 3fc31aa01..e856893c9 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -73,6 +73,7 @@ def test_유입_유출은_구조와_흙막이_칸만(out): ["bond", None, "메붙임"], ["thickness", None, "45"], ["area", None, "30"], + ["length_m", None, None], # 그림 전용 길이(294-4) ] keys = out[f"{spec}Keys"] assert keys["prefixes"] == ["soil_add_", "chute_add_"] diff --git a/resources/tester/test_b05_b06_save_roundtrip.py b/resources/tester/test_b05_b06_save_roundtrip.py index 7bddfb6f8..1ead3975f 100644 --- a/resources/tester/test_b05_b06_save_roundtrip.py +++ b/resources/tester/test_b05_b06_save_roundtrip.py @@ -72,10 +72,12 @@ def api(tmp_path, monkeypatch): import B04_PreProcess.B04_PreProcess_Router_Basins as basins import B05_Profile.B05_Profile_Router as route + import B05_Profile.B05_Profile_Router_Confirm as route_confirm import B05_Profile.B05_Profile_Router_Lifecycle as lifecycle import B05_Profile.B05_Profile_Structures_Router as structures import B06_Section.B06_Section_Repository_Bulk as bulk import B06_Section.B06_Section_Router_Confirm as sections + import B06_Section.B06_Section_Router_Stations as station_router import common_util.common_util_storage as storage import config.config_db as config_db from config import config_system as cfg @@ -98,6 +100,14 @@ def api(tmp_path, monkeypatch): monkeypatch.setattr(sections, "complete_stage", rec.fake("complete_stage")) monkeypatch.setattr(sections, "start_stage", rec.fake("start_stage")) monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute")) + # 272 — 측점을 다시 맞춘 뒤 정본에 없는 측점의 횡단 행 지우기 + monkeypatch.setattr(route_confirm, "sync_cross_section_rows", rec.fake("sync_rows", 0)) + monkeypatch.setattr( + route_confirm, "update_longitudinal_grade_summary", rec.fake("grade_summary") + ) + monkeypatch.setattr( + station_router, "update_longitudinal_grade_summary", rec.fake("grade_summary") + ) real_known = sections.get_cross_section_chainages async def spy_known(connection, route_id): diff --git a/resources/tester/test_b05_drainage_side.py b/resources/tester/test_b05_drainage_side.py index 0650f76e3..7ec1d74d7 100644 --- a/resources/tester/test_b05_drainage_side.py +++ b/resources/tester/test_b05_drainage_side.py @@ -6,7 +6,7 @@ - 파일마다 700줄 이하. - 관 보고 만들기(`_Report`)는 상태 없이 같은 결과: ±0.5m 유역 매칭 · 세월교 유역은 관경 null · 시설 정보 · 출처 기본값 user. -- `createDrainageSide` 가 켜고 끄는 창구를 낸다(두 보기에서 같은 판). +- `createDrainageSide` 가 판 창구를 낸다(두 보기에서 같은 판 · 켬끔 없이 손잡이로만 접기 — 282-1). """ import json @@ -43,16 +43,11 @@ def test_배수유역_파일은_700줄_이하(): assert lines <= 700, (path.name, lines) -def test_켜고_끄는_창구(): +def test_판_창구_켬끔_없음(): side = (B05 / "B05_Profile_UI_Drainage_Side.ts").read_text(encoding="utf-8") - for name in ( - "createDrainageSide", - "createDrainageToggleButton", - "setVisible", - "onVisibleChange", - ): - assert name in side - assert "root.hidden" in side or "panel.root.hidden" in side + assert "createDrainageSide" in side + for name in ("createDrainageToggleButton", "setVisible", "onVisibleChange", "root.hidden"): + assert name not in side # 종단도 판 자리 약속(`DesignSideFactory` · design_profile.ts) 모양 그대로 assert "DesignSideBoard" in side and "DesignSideHost" in side assert "export function createDrainageSide(" in side diff --git a/resources/tester/test_b05_extra_stations_supplier.py b/resources/tester/test_b05_extra_stations_supplier.py index 9aa2c9c9d..f6d078ee0 100644 --- a/resources/tester/test_b05_extra_stations_supplier.py +++ b/resources/tester/test_b05_extra_stations_supplier.py @@ -87,11 +87,12 @@ def test_pipe_without_options_falls_back_to_config_defaults(tmp_path): assert extras == ((30.0, "파형강관 D800"),) -def test_ford_pavement_is_not_an_irregular_station(tmp_path): - """물넘이포장은 비정규 측점이 아니다 — 종단 그래프 구조물 표시 몫(2026-08-28 사용자 확정).""" +def test_ford_pavement_plants_its_station(tmp_path): + """물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면(286-1).""" points = [{"chainage_m": 40.0, "facility": "ford_pavement"}, {"chainage_m": 60.0}] extras = resolve_extra_stations(tmp_path, parse_pipe_points(points)) - assert [chainage for chainage, _label in extras] == [60.0] + assert [chainage for chainage, _label in extras] == [40.0, 60.0] + assert extras[0][1] == "물넘이포장" # ── 구조물 정본 A군 합류 ─────────────────────────────────────────────────── @@ -247,7 +248,9 @@ def test_extra_station_on_grid_within_5mm_labels_grid_station(): stations = _stations(((100.003, "X"), (0.3, "S"))) labelled = {station["chainage_m"]: station for station in stations if station.get("structure")} assert sorted(labelled) == [0.3, 100.0] - assert labelled[100.0]["kind"] == "regular" and labelled[0.3]["kind"] == "irregular" + # 같은 m 면 측점 하나 · 구조물 측점이 위(272) — 격자 자리 표시(`grid`)로 떠나면 되돌림 + assert labelled[100.0]["kind"] == "irregular" and labelled[100.0].get("grid") is True + assert labelled[0.3]["kind"] == "irregular" and "grid" not in labelled[0.3] def test_two_extras_in_same_meter_both_kept(): diff --git a/resources/tester/test_b05_pipe_station_relabel.py b/resources/tester/test_b05_pipe_station_relabel.py index d31a94891..01c5919e1 100644 --- a/resources/tester/test_b05_pipe_station_relabel.py +++ b/resources/tester/test_b05_pipe_station_relabel.py @@ -49,7 +49,7 @@ def test_relabel_follows_saved_kind(tmp_path): 85.05: "BOX암거", 120.0: "옹벽", # 관 지점 라벨이 아닌 측점은 그대로 173.09: "흄관 D1200", - 258.12: None, # 물넘이포장 = 라벨 없음(측점 생성 규칙과 같음) + 258.12: "물넘이포장", # 물넘이포장도 종류 이름(286-1 · 측점 생성 규칙과 같음) } diff --git a/resources/tester/test_b05_station_roundtrip.py b/resources/tester/test_b05_station_roundtrip.py index 6c8ad7142..0c82da4d2 100644 --- a/resources/tester/test_b05_station_roundtrip.py +++ b/resources/tester/test_b05_station_roundtrip.py @@ -30,11 +30,11 @@ def results(): def test_칸을_안_고치면_실어_둔_측점_그대로(results): - assert results["shown"] == ["6", "3.4"] + assert results["shown"] == ["6", "3"] # 칸 표기도 정수 m(167) · 값은 실어 둔 그대로 assert results["untouched"] == 123.37 assert results["untouchedRaw"] == 123.3721 assert results["retypedSame"] == 123.37 - assert results["r119"] == ["5", "19.8", 119.8] + assert results["r119"] == ["5", "20", 119.8] def test_화면_표기는_거리를_정수로(results): @@ -44,7 +44,7 @@ def test_화면_표기는_거리를_정수로(results): def test_칸을_고치면_친_값(results): # 친 값 125.2 → 정수 m 125(234-1 · 저장값도 정수 m — 9-29 「소수 그대로」를 바꿈) assert results["edited"] == 125 - assert results["normalized"] == ["2", "10.0", 50] + assert results["normalized"] == ["2", "10", 50] assert results["cleared"] is None diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index 3fadfc00f..303fe0132 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -84,11 +84,16 @@ def test_집계_항목_줄(out): True, None, ) - assert (empty["label"], empty["pick"], empty["clear"]) == ("구조물 조합", "선택", "해제") + # 280-1 — [선택] 단추 없음(이름 칸이 고르기) · [해제] 그대로 + assert (empty["label"], empty["buttons"], empty["clear"]) == ("구조물 조합", ["해제"], "해제") row = out["rowSet"] assert row["assigned"] is True and row["clearDisabled"] is False assert row["name"] == "돌기슭막이 메쌓기 1.2m" and A_KEY in row["title"] - assert out["clicks"] == ["pick", "clear"] + assert out["clicks"][:2] == ["pick", "clear"] # 이름 칸 누름 = 고르기 + assert out["keyClicks"] == { + "clicks": ["pick", "pick"], + "prevented": ["prevent:Enter", "prevent: "], + } assert out["rowCleared"] == {"name": "미지정", "clearDisabled": True} diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index d5169229f..22f84dad6 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -51,6 +51,16 @@ def _ground(slope: float, *, gap: tuple[float, float] | None = None) -> list[dic return samples +def _dip_ground() -> list[dict]: + """양측 절토 · 우측 노면 끝 너머(−4.5 m 밖)에서 원지반이 급히 내려가는 지반(294-3).""" + samples = [] + for index in range(49): + offset = -12.0 + index * 0.5 + z = 101.05 + 0.2 * offset if offset >= -4.5 else 100.15 + 1.0 * (offset + 4.5) + samples.append({"offset_m": offset, "elevation_m": round(z, 4), "valid": True}) + return samples + + # 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교 # 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간. CASES = [ @@ -218,6 +228,47 @@ CASES = [ "shoulder_extend_left_m": 0.4, }, }, + { + # 286-2 — 물넘이포장 자리 = 측구 없음(좌절토라 자동이면 측구가 섬) + "name": "물넘이포장 자리 측구 없음", + "samples": _ground(0.35), + "design_elevation_m": 100.4, + "options": { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "paved": True, + "tilted": True, + }, + }, + { + # 287-4 — 물넘이포장 자리 노면 = 경사각도로 지대 낮은 쪽 한 경사(지반이 좌로 오름 = 우가 낮음) + "name": "물넘이포장 자리 노면 기울임", + "samples": _ground(0.35), + "design_elevation_m": 100.4, + "options": { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "paved": True, + "tilted": True, + "tilt_pct": 5.2408, + }, + }, + { + # 294-3 — 양측 절토 · 낮은 쪽(우) 노면을 늘여 원지반이 내려와 만나는 자리까지 + "name": "물넘이포장 낮은 쪽 노면 연장", + "samples": _dip_ground(), + "design_elevation_m": 100.4, + "options": { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "paved": True, + "tilted": True, + "tilt_pct": 5.0, + }, + }, ] _TS_RUNNER = """ @@ -241,6 +292,8 @@ const camel = { curve_widening_m: "curveWideningM", shoulder_extend_left_m: "shoulderExtendLeftM", shoulder_extend_right_m: "shoulderExtendRightM", + tilted: "tilted", + tilt_pct: "tiltPct", }; // 사토장 제원만 칸 이름이 다르다 — 파이썬은 snake, TS 는 camel 이다. const spoilCamel = { side: "side", width_m: "widthM", slope_ratio_n: "slopeRatioN" }; diff --git a/resources/tester/test_b06_patch_station.py b/resources/tester/test_b06_patch_station.py index 9abb3e398..ad7a80cb0 100644 --- a/resources/tester/test_b06_patch_station.py +++ b/resources/tester/test_b06_patch_station.py @@ -9,7 +9,6 @@ import asyncio import B06_Section.B06_Section_Router_Stations as stations from B06_Section.B06_Section_Router_Confirm import _stand_patch_stations -from B06_Section.B06_Section_Schema import SectionConfirmRequest PROJECT = "936be972-11bc-46c2-8bf3-b15d8de7df0d" @@ -22,8 +21,7 @@ def _calls(monkeypatch, patches: list[float], known: list[float]) -> list[str]: return 1 monkeypatch.setattr(stations, "ensure_structure_stations", fake) - request = SectionConfirmRequest(cross_patches=[{"chainage_m": c} for c in patches]) - asyncio.run(_stand_patch_stations(PROJECT, request, known)) + asyncio.run(_stand_patch_stations(PROJECT)) return called @@ -31,12 +29,13 @@ def test_행_없는_측점_수정이면_측점을_세움(monkeypatch): assert _calls(monkeypatch, [120.0, 263.47], [120.0, 140.0]) == [PROJECT] -def test_모든_수정에_행이_있으면_안_부름(monkeypatch): - assert _calls(monkeypatch, [120.0, 140.004], [120.0, 140.0]) == [] - assert _calls(monkeypatch, [], [120.0]) == [] +def test_모든_수정에_행이_있어도_맞춤은_부름(monkeypatch): + # 272 — 옮긴 관의 옛 측점 · 떠난 격자 자리 글을 지우려고 늘 부름(맞출 것이 없으면 + # `ensure_structure_stations` 가 0 으로 그냥 돌아옴) + assert _calls(monkeypatch, [120.0, 140.004], [120.0, 140.0]) == [PROJECT] + assert _calls(monkeypatch, [], [120.0]) == [PROJECT] -def test_소수_측점_수정은_정수_격자_행으로_갈음하지_않음(monkeypatch): - # 119.8 은 격자 120 과 다른 측점(횡단 cross_00119.80m · PLAN 20장) — 제 행이 없으면 세움 +def test_소수_측점_수정도_맞춤을_부름(monkeypatch): assert _calls(monkeypatch, [119.8], [120.0]) == [PROJECT] - assert _calls(monkeypatch, [119.8], [119.8, 120.0]) == [] + assert _calls(monkeypatch, [119.8], [119.8, 120.0]) == [PROJECT] diff --git a/resources/tester/test_design_page_single.py b/resources/tester/test_design_page_single.py index 47e57b4d2..8c084bb41 100644 --- a/resources/tester/test_design_page_single.py +++ b/resources/tester/test_design_page_single.py @@ -7,6 +7,7 @@ 화면 수치(문맥 수 · 루프 · 같은 노드 · 전환 시간)는 ORCA 로 잰다 — 여기서는 길이 끊기지 않게 잠근다. """ +import re from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent @@ -77,10 +78,10 @@ def test_창_키_듣기를_뗌(): def test_겹친_알약은_누른_것을_그대로(): profile = _read("A00_Common/design/design_profile.ts") - assert "structureIdKeeping(stationId, selectedStructureId" in profile + assert re.search(r"structureIdKeeping\(\s*stationId,\s*selectedStructureId", profile) # 판정은 `_Profile_Structures` 로 옮김 — 비정규 측점 짝 · 규칙 측점(B06 카드) 기준점(53-2) rule = _read("B05_Profile/B05_Profile_UI_Profile_Structures.ts") - assert "stationIdAtStructure(selectedId, stations, structures) === stationId" in rule + assert "stationIdAtStructure(selectedId, stations, structures, types) === stationId" in rule assert "Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M" in rule diff --git a/resources/tester/test_design_profile_single.py b/resources/tester/test_design_profile_single.py index 20177abb3..7d028e2ea 100644 --- a/resources/tester/test_design_profile_single.py +++ b/resources/tester/test_design_profile_single.py @@ -39,7 +39,7 @@ def test_두_보기가_같은_종단도와_같은_판(): """종단도는 한 페이지 한 벌(계획서 24-3b) — 두 보기는 같은 노드에 제 알림만 건다.""" shared = _read("A00_Common/design/design_shared.ts") assert shared.count("createDesignProfile(") == 1 - assert "side: drainageSide" in shared and "drainageSideTool(board)" in shared + assert "side: drainageSide" in shared and "toolLead: popoutButton" in shared b05 = _read("B05_Profile/B05_Profile_UI_Profile_Panel.ts") b06 = _read("B06_Section/B06_Section_UI_Section_View_Profile.ts") for source, view in ((b05, "profile"), (b06, "cross")): diff --git a/resources/tester/test_ui_data_table.py b/resources/tester/test_ui_data_table.py index 70ab16487..6b61be801 100644 --- a/resources/tester/test_ui_data_table.py +++ b/resources/tester/test_ui_data_table.py @@ -108,7 +108,7 @@ def test_155_1_글자_14px_열_폭_같은_비율(r): assert abs(r["scale"] - 14 / 12) < 1e-9 w = r["widths"] assert '' in w - assert 'style="width:350px"' in w # 표 폭 = 112 + 98 + 140 + assert 'style="--dt-w:350px"' in w # 표 폭 = 112 + 98 + 140 (258-1 · css 변수) assert 'class="is-frozen is-edge" style="left:112px"' in w # 둘째 고정 열 left 도 같이 assert "font-size: var(--text-body-sm);" in r["css"] assert "--dt-row-h: 28px;" in r["css"] diff --git a/resources/tester/ui_checks/275_챗봇_테마_엔터.js b/resources/tester/ui_checks/275_챗봇_테마_엔터.js new file mode 100644 index 000000000..1bca61a6d --- /dev/null +++ b/resources/tester/ui_checks/275_챗봇_테마_엔터.js @@ -0,0 +1,96 @@ +/* 275 · 대시보드 챗봇 — 블랙 · 화이트 테마 색 · 엔터 보내기 + * 시작 화면: B01 대시보드(#/b01-account) · 로그인 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 기대 수치: + * - 밝음 · 어두움 모두 글자 대비 ≥ 4.5(제목 · 답 말풍선 · 내 말풍선 · 보내기 · 입력칸 · 열기 단추 · 머리 아이콘) + * - Enter = 보냄(내 말풍선 +1 · 입력칸 빔) · Shift+Enter · 한글 조합 중 Enter = 안 보냄 + * - 서버로 안 나감(fetch 가로챔) · 끝나면 테마 · 말풍선 · fetch 되돌림 + * ⚠ 대화 기록(화면 메모리)에 시험 한 줄이 남음 — 끝나면 새로고침 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const rgb = (s) => (s.match(/[\d.]+/g) || []).map(Number); + const lum = ([r, g, b]) => { + const f = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); + }; + /** 실제로 보이는 바탕 — 투명이면 조상으로 */ + const bgOf = (el) => { + for (let e = el; e; e = e.parentElement) { + const c = rgb(getComputedStyle(e).backgroundColor); + if (c.length >= 3 && (c.length < 4 || c[3] > 0.5)) return c.slice(0, 3); + } + return [255, 255, 255]; + }; + const ratio = (a, b) => { + const [x, y] = [lum(a), lum(b)]; + return +((Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)).toFixed(2); + }; + const text = (el) => ratio(rgb(getComputedStyle(el).color).slice(0, 3), bgOf(el)); + + const root = document.documentElement; + const startTheme = root.getAttribute("data-theme"); + const chat = document.querySelector(".b01-knowledge-chat"); + if (!chat) return JSON.stringify({ error: "챗봇 없음 — 대시보드에서 부를 것" }); + const panel = chat.querySelector(".b01-knowledge-chat__panel"); + const input = chat.querySelector("textarea"); + const messages = chat.querySelector(".b01-knowledge-chat__messages"); + const users = () => messages.querySelectorAll(".b01-knowledge-chat__user").length; + const wasHidden = panel.hidden; + if (wasHidden) chat.querySelector(".b01-knowledge-chat__toggle").click(); + const before = messages.children.length; + const realFetch = window.fetch; + window.fetch = async (url, init) => + String(url).endsWith("/knowledge-chat") + ? new Response(JSON.stringify({ answer: "시험 답", sources: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + : realFetch(url, init); + const press = (opts) => { + const event = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + ...opts, + }); + input.dispatchEvent(event); + return event.defaultPrevented; + }; + const out = {}; + try { + input.value = "시험 질문"; + const start = users(); + out.shiftEnter = { sent: press({ shiftKey: true }) || users() > start }; + out.composing = { sent: press({ isComposing: true }) || users() > start }; + out.enter = { handled: press({}) }; + await w(300); + out.enter.userBubbles = users() - start; + out.enter.inputEmpty = input.value === ""; + for (const theme of ["light", "dark"]) { + root.setAttribute("data-theme", theme); + await w(50); + const reply = messages.querySelector(".b01-knowledge-chat__reply"); + out[theme] = { + title: text(panel.querySelector("header strong")), + avatar: text(chat.querySelector(".b01-knowledge-chat__avatar")), + reply: text(reply), + user: text(messages.querySelector(".b01-knowledge-chat__user")), + send: text(chat.querySelector(".b01-knowledge-chat__form button")), + input: text(input), + toggle: text(chat.querySelector(".b01-knowledge-chat__toggle")), + panelBg: getComputedStyle(panel).backgroundColor, + replyBg: getComputedStyle(reply).backgroundColor, + inputBg: getComputedStyle(input).backgroundColor, + }; + } + } finally { + window.fetch = realFetch; + if (startTheme === null) root.removeAttribute("data-theme"); + else root.setAttribute("data-theme", startTheme); + [...messages.children].slice(before).forEach((node) => node.remove()); + if (wasHidden) chat.querySelector(".b01-knowledge-chat__close").click(); + } + out.themeRestored = root.getAttribute("data-theme") === startTheme; + return JSON.stringify(out); +})(); diff --git a/ui_template/ui_template_collapsible.ts b/ui_template/ui_template_collapsible.ts index c179e6838..9a0e77b4d 100644 --- a/ui_template/ui_template_collapsible.ts +++ b/ui_template/ui_template_collapsible.ts @@ -11,6 +11,17 @@ * 스타일은 전역 `ui_template_theme.css`에 있어 어떤 페이지에서도 클래스만 붙이면 동작한다. * ========================================================================== */ +/** 좌측 패널 컨테이너 공용 카드(269) — 접는 제목 + 공용 양식(`ui-panel-form`) · 내용은 카드에 바로 붙임. */ +export function createPanelCard(title: string, collapsed = false): HTMLElement { + const section = document.createElement("section"); + section.className = `ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form${collapsed ? " is-collapsed" : ""}`; + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = title; + section.append(heading); + return section; +} + /** root 아래 `.ui-collapsible__title` 클릭을 위임 처리해 해당 컨테이너를 접거나 편다. */ export function attachCollapsible(root: HTMLElement): void { root.addEventListener("click", (event) => { diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index f17dc400a..ed9f05f32 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -33,6 +33,8 @@ } .ui-data-table__table { + /* 258-1 — 표 폭 = 열 합(--dt-w · 인라인) · 페이지 css 가 width 로 덮음 */ + width: var(--dt-w); table-layout: fixed; border-collapse: separate; border-spacing: 0; @@ -175,6 +177,12 @@ text-align: right; } +/* 고칠 수 있는 칸(PLAN 258-2 · editOnDouble) — 글일 때도 옅은 입력 칸 테두리(outline 안쪽 · 줄 고름 box-shadow 와 안 겹침) */ +.ui-data-table__table td.is-can-edit { + outline: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-canvas)); + outline-offset: -1px; +} + .ui-data-table__input:focus { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index afc3bc564..44c2e69d8 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -17,6 +17,7 @@ * 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴) * 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 · * Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기 + * 고칠 수 있는 칸 = 글일 때도 칸에 입력 칸 테두리 class `is-can-edit`(PLAN 258-2 · 옅은 outline) · * 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(PLAN 243-1 · keyEdit false = 표 밖이 맡음) * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) @@ -183,7 +184,7 @@ function rowHtml( more: RowMore = {}, ): string { let tr = ""; - const { lv, spans } = more; + const { lv, spans, can } = more; for (let j = 0; j < cols.length; j++) { const key = cols[j].key; const span = spans?.[j] ?? 1; @@ -209,6 +210,7 @@ function rowHtml( frz && left[j + 1] == null && "is-edge", empty && "is-empty", cellClass?.(key), + can?.(key) && "is-can-edit", lvc.trim(), span > 1 && "is-merged", ] @@ -241,6 +243,8 @@ function rowHtml( interface RowMore { lv?: { key: string; n: number; node: boolean; tree?: string }; spans?: number[]; + /** 고칠 수 있는 칸인지(editOnDouble · 글로 그린 칸에 `is-can-edit` 표시 · 258-2) */ + can?: (key: string) => boolean; } /** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */ @@ -356,6 +360,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin { ...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}), ...(spans ? { spans: spans[i - from] } : {}), + ...(o.editOnDouble && o.editable ? { can: (key: string) => o.editable!(i, key) } : {}), }, ); } @@ -363,7 +368,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; const cap = o.caption ? `` : ""; return ( - `
    ${esc(o.caption)}
    ${cap}${colgroup}` + + `
    ${cap}${colgroup}` + `${headHtml(cols, left)}${body}${foot}
    ` ); } diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index e7a1cc30a..6ed57ae36 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -314,7 +314,11 @@ export function showToast( * -------------------------------------------------------------------------- */ /** 확인/취소 모달 — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */ -export function showConfirmDialog(message: string, confirmLabel = "확인"): Promise { +export function showConfirmDialog( + message: string, + confirmLabel = "확인", + cancelLabel = "취소", +): Promise { return new Promise((resolve) => { const overlay = el("div", { className: "ui-confirm" }); const panel = el("div", { className: "ui-confirm__panel" }); @@ -326,7 +330,11 @@ export function showConfirmDialog(message: string, confirmLabel = "확인"): Pro if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); resolve(result); }; - const cancel = createButton({ label: "취소", variant: "ghost", onClick: () => done(false) }); + const cancel = createButton({ + label: cancelLabel, + variant: "ghost", + onClick: () => done(false), + }); actions.append( cancel, createButton({ label: confirmLabel, variant: "danger", onClick: () => done(true) }), diff --git a/ui_template/ui_template_general_layout.css b/ui_template/ui_template_general_layout.css index cc8b2cd0c..cf4605f71 100644 --- a/ui_template/ui_template_general_layout.css +++ b/ui_template/ui_template_general_layout.css @@ -55,6 +55,21 @@ gap: var(--btn-group-gap); } +/* 보조 탭 줄(B08 · B09) — 단추 높이 = `--subtab-height` · 줄 안 도구는 가운데 맞춤 · 줄 높이를 바꾸지 않음 */ +.ui-subtabs { + align-items: center; +} + +.ui-subtabs > .ui-btn { + height: var(--subtab-height); +} + +.ui-subtabs__tools { + display: flex; + align-items: center; + height: var(--subtab-height); +} + .ui-general-block__table-wrap { overflow-x: auto; } diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 3a7fb9b02..70327f2ba 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -8,6 +8,7 @@ * 접기 없음 — 묶음 모두 펼친 박스 · 같은 가로 줄 카드 = 높이 같음(200-1 · 합계 줄은 아래 붙음) * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선 * (`is-pick`) + 스크롤만(248-1 빨강 하나) · 누름 = 빨강 풂 · 누른 줄만 고름(`is-focus`) + * 몸 위 칸(`top` · 선택) = 부르는 쪽이 그린 것 그대로(278-2) * ========================================================================== */ import "./ui_template_group_cards.css"; @@ -53,6 +54,8 @@ export interface GroupCard { sum?: number | null; lines: GroupLine[]; foot?: GroupFoot | null; + /** 몸 위 칸(부르는 쪽이 그림 · 줄 `data-at` = 누름 · 고름 같음 · 예 278-2 산근 머리 줄) */ + top?: HTMLElement | null; } export interface GroupCardsOptions { @@ -166,6 +169,7 @@ function card(g: GroupCard, o: Required ); const body = document.createElement("div"); body.className = "ui-group-card__body"; + if (g.top) body.append(g.top); if (g.lines.length) body.append(linesTable(g, o)); box.append(head, body); if (g.foot) box.append(footRow(g.foot, o.money)); diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index 6eb58469e..3d6461087 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -10,6 +10,7 @@ * 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정). * 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐). * 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭. + * 280-2 — 위 표 · 나무 · 오른쪽 카드 = 한 상태: 위 표 = 보이는 층 표 · 표에서 열을 고르면 나무(펼쳐 굴림) · 카드도 그 항목. * ========================================================================== */ import "./ui_template_item_picker.css"; @@ -24,6 +25,7 @@ import { ownerOf, pathIds, rowId, + tableKey, toSummary, type PickNode, type PickerLayer, @@ -148,7 +150,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise void load(created.key), }); - const comboTable = createComboTable(); + const comboTable = createComboTable((colId) => pickColumn(colId)); const cols = el("div", { className: "ui-item-picker__cols", children: [ @@ -179,9 +181,22 @@ export function openItemPicker(opts: ItemPickerOptions): Promise el("p", { className: "ui-item-picker__hint", text }); + /** 위 표의 기준 줄 — 고른 항목이 보이는 층이면 그것 · 아니면 그 층 아무 줄(주인을 앎) */ + const tableRow = (): PickerRow | undefined => + chosen && layerOf(chosen) === layer ? chosen : rows.find((row) => layerOf(row) === layer); + + /** 위 표 = 보이는 층(+ 주인) 표 · 고른 항목이 그 층이면 그 열 — 목록을 읽기 전엔 숨김 */ + const paintTable = (): void => { + const row = tableRow(); + comboTable.show( + rows.length ? { layer, owner: row ? ownerOf(row) : null } : null, + row === chosen && row ? row.열 : "", + ); + }; + const paintDetail = (): void => { detailView.show(chosen); - comboTable.show(chosen); + paintTable(); const layer = chosen ? layerOf(chosen) : null; applyButton.disabled = !chosen; clearButton.disabled = !opts.current; @@ -232,6 +247,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { layer = one; paintTree(); + paintTable(); }, }); button.setAttribute("aria-pressed", String(one === layer)); @@ -297,6 +313,19 @@ export function openItemPicker(opts: ItemPickerOptions): Promise tableKey(one) === tableKey(base) && one.열 === colId); + if (!row || (chosen && rowId(row) === rowId(chosen))) return; + chosen = row; + for (const id of pathIds(row)) opened.add(id); + if (!filterRows([row], search.input.value).length) search.input.value = ""; // 찾기에 가려지면 풂 + paintTree(); + paintDetail(); + treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" }); + } + search.input.addEventListener("input", paintTree); // 빈 자리 오른쪽 클릭 = 고른 층 맨 위 메뉴(개인만 「새 항목」 · 줄 위는 줄 메뉴) treeHost.addEventListener("contextmenu", (event) => { diff --git a/ui_template/ui_template_item_picker_table.ts b/ui_template/ui_template_item_picker_table.ts index 5d742be8a..f98011e17 100644 --- a/ui_template/ui_template_item_picker_table.ts +++ b/ui_template/ui_template_item_picker_table.ts @@ -2,8 +2,8 @@ * ui_template_item_picker_table.ts * 항목 고르기 모달 상단 표(PLAN 224-1) — M02 「구조물 조합」 페이지 상단의 구조물집계표를 **같은 부품** * (`ui_template/sheet` `createSheet` · 줄 글 = `numberLabels` · 숨김 열 = `comboView`)으로 보임. - * 보기 전용(`readOnly` · 고침은 M02 에서) · 고른 항목이 속한 층(+ 주인)의 표 · 고른 항목의 열이 밝게 골라짐. - * 같은 층의 다른 항목을 고르면 표는 그대로 두고 열만 옮김 · 다른 층 항목이면 그 층 표로 바뀜. + * 보기 전용(`readOnly` · 고침은 M02 에서) · 보이는 층(+ 주인)의 표 · 고른 항목의 열이 밝게 골라짐(280-2). + * 같은 층이면 표는 그대로 두고 열만 옮김 · 층이 바뀌면 그 층 표로 바뀜 · 표에서 열을 고르면 `onColumn`. * 표 부품 · 집계표 모듈은 처음 보일 때만 `import()`(B05 · B06 번들에 안 섞임). * ========================================================================== */ @@ -12,14 +12,14 @@ import { fetchStructureNumbersAt, readTemplate, } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; -import { layerOf, ownerOf, tableKey, type PickerRow } from "./ui_template_item_picker_data"; +import type { PickerLayer } from "./ui_template_item_picker_data"; import { t } from "./ui_template_locale"; import type { SheetDoc, SheetHandle } from "./sheet/ui_template_sheet"; export interface ComboTable { root: HTMLElement; - /** 고른 항목 — `null` 이면 표를 숨김 */ - show: (row: PickerRow | null) => void; + /** 보일 층(+ 주인) · 고를 열(빈 글 = 안 고름) — `null` 이면 표를 숨김 */ + show: (at: { layer: PickerLayer; owner: string | null } | null, column: string) => void; destroy: () => void; } @@ -29,7 +29,8 @@ const failed = (error: unknown): string => error instanceof Error ? error.message : String(error), ); -export function createComboTable(): ComboTable { +/** `onColumn` = 표에서 고른 열 id(누르기 · 방향키) */ +export function createComboTable(onColumn: (colId: string) => void): ComboTable { const root = el("div", { className: "ui-item-picker__table", attrs: { hidden: "" } }); let seq = 0; let shownKey: string | null = null; @@ -43,14 +44,17 @@ export function createComboTable(): ComboTable { shownKey = null; }; - async function paint(row: PickerRow, key: string, mine: number): Promise { + async function paint( + layer: PickerLayer, + owner: string | null, + key: string, + mine: number, + ): Promise { drop(); root.replaceChildren( el("p", { className: "ui-item-picker__hint", text: t("M02_PickerLoading") }), ); try { - const layer = layerOf(row); - const owner = ownerOf(row); const [{ createSheet }, struct] = await Promise.all([ import("./sheet/ui_template_sheet"), import("../M02_MasterTemplete/M02_MasterTemplete_Structure"), @@ -65,6 +69,7 @@ export function createComboTable(): ComboTable { readOnly: true, lockHead: true, combo: {}, + onSelect: (sel) => sel.colId && onColumn(sel.colId), ...struct.numberLabels(() => numbers), }); shownKey = key; @@ -77,14 +82,14 @@ export function createComboTable(): ComboTable { return { root, - show: (row) => { + show: (at, col) => { const mine = ++seq; - root.hidden = !row; - if (!row) return; - column = row.열; - const key = tableKey(row); + root.hidden = !at; + if (!at) return; + column = col; + const key = `${at.layer}|${at.owner ?? ""}`; if (sheet && key === shownKey) return sheet.selectColumn(column); - void paint(row, key, mine); + void paint(at.layer, at.owner, key, mine); }, destroy: () => { seq += 1; diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 31fc267ea..c568a2181 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -70,7 +70,7 @@ export const ui_locales_m2 = { "Register a company first to manage structure templates", ], M02_NeedCompanyGo: ["회사 등록하러 가기", "Go to company registration"], - M02_PickerTitle: ["항목 고르기", "Pick an item"], + M02_PickerTitle: ["구조물 조합 선택", "Select structure combo"], M02_PickerSearch: ["항목 찾기", "Search items"], M02_PickerSystem: ["마스터", "Master"], M02_PickerCompany: ["회사", "Company"], diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index c9b6c76c5..09a1be164 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -415,6 +415,77 @@ font-size: var(--text-body-sm); } +/* 좌측 패널 컨테이너 공용 양식(269) — 기준 = B05/B06 「구조물 배치」(칸 높이 · 라벨 · 글자 · 단추). + B05~B09 좌측 컨테이너가 `.ui-panel-form`(카드는 `createPanelCard`)을 붙임 · 아래 단추 묶음 + (`.ui-sidebar-actions`)은 컨테이너 밖이라 안 먹음 · 날 단추는 `.ui-panel-btn`. */ +:where(.ui-sidebar-card.ui-panel-form) { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.ui-panel-form .ui-field, +.ui-panel-form .b05-route__field { + gap: var(--panel-label-gap); +} + +.ui-panel-form .ui-field__label, +.ui-panel-form .b05-route__field > span { + font-size: var(--text-caption); + line-height: 1.2; +} + +.ui-panel-form .ui-field__error:not(.is-visible) { + display: none; +} + +.ui-panel-form input:not([type="checkbox"], [type="radio"], [type="range"]), +.ui-panel-form select { + box-sizing: border-box; + height: var(--panel-control-h); + padding: 0 6px; + font-size: var(--text-caption); + line-height: normal; +} + +.ui-panel-form select.ui-select { + padding-right: 24px; + background-position: right 6px center; + background-size: 12px; +} + +/* 숫자 칸 증감 화살표 없음 — 「구조물 배치」와 같게 */ +.ui-panel-form input[type="number"] { + appearance: textfield; + -moz-appearance: textfield; +} + +.ui-panel-form input[type="number"]::-webkit-outer-spin-button, +.ui-panel-form input[type="number"]::-webkit-inner-spin-button { + margin: 0; + appearance: none; + -webkit-appearance: none; +} + +/* 단추 크기 = 「구조물 배치」 단추(`b05-route__irregular-btn`) · 색은 단추 갈래(filled · ghost) 그대로 */ +.ui-panel-form .ui-btn:not(.ui-help-btn) { + padding: var(--spacing-8); + border-radius: var(--radius-inputs); + font-size: var(--text-caption); + line-height: normal; +} + +/* 날