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..48ef93fe1 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -110,6 +110,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_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index bb293e404..5dc6d9c80 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -61,6 +61,8 @@ export interface SvgZoomPanOptions { maxScale?: number; /** 이전 상태 — 다시 그려도 배율이 살아남게. */ initial?: ZoomPanState; + /** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */ + homeScale?: number; /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ onChange?: (state: ZoomPanState) => void; /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ @@ -85,9 +87,15 @@ 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 = { + scale: homeScale, + tx: (plot.x + plot.width / 2) * (1 - homeScale), + ty: (plot.y + plot.height / 2) * (1 - homeScale), + }; + 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 }); @@ -148,6 +156,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 +190,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 +217,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/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..acb2e894e 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을 먼저 방문해야 정상으로 보이던 원인. @@ -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()); /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ @@ -330,6 +332,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 +461,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 +509,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 +559,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17). selectedStructureId = structureIdAtStation(stationId); draw(); + revealPick(); }, /** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */ setEnforceMinCover(enforce: boolean) { @@ -535,9 +574,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 +588,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 +615,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 +627,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 +642,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 +653,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_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_view_cross.ts b/A00_Common/design/design_view_cross.ts index 1f4239ca2..530de041a 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,14 @@ 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), + pipesChanged: () => void refreshDetailForStructures(), }); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { @@ -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..218c5cfdd 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) · @@ -245,6 +246,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 +662,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..fa414226f 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -272,6 +272,8 @@ def resolve_extra_stations( 라벨 형식은 프론트 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 +551,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 +561,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 +590,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..ea39175f2 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, @@ -193,12 +199,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 +228,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 +269,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 +293,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 +318,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 +439,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_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 88036c07d..4875593c6 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -154,7 +154,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 += 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..93c06881b 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -34,6 +34,12 @@ 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 { + pipeStationMarks, + SAME_STATION_M, + structureStationMarks, + type StationMark, +} from "../common_util/common_util_structure_station"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -41,6 +47,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 +67,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,15 +97,17 @@ 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)), + ]; }); } @@ -115,7 +137,7 @@ 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); }); @@ -143,29 +165,45 @@ 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` 가 관을 + * 지우지 않게) · 물넘이포장은 측점을 안 심어 견주지 않음. */ function syncCrossDrainStations(): void { - applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]); + const planting = (station: IrregularStation) => + station.structure !== FACILITY_NAMES.ford_pavement; + const kept: IrregularStation[] = []; + for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) { + const taken = + station.origin !== "pipe" && + kept.some( + (other) => + planting(other) && Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M, + ); + if (!taken) kept.push(station); + } + applyIrregularStations(kept); } /** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null). @@ -326,7 +364,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). 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_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..09f7e76dc 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). */ @@ -542,13 +546,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) { 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_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_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 846d3623c..5f96903d1 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 { @@ -453,10 +453,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 { @@ -650,15 +650,3 @@ .b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area { 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); -} 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..d00053611 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -118,6 +118,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 — 곡선부 확폭·법정 최소반경 판정용. */ @@ -316,6 +318,8 @@ export interface BoxSet { export interface CrossSection extends SectionStation { samples: SectionSample[]; + /** 초안 자리를 서버가 미리 떠 준 횡단 — 캐시에만 있음 · [저장]하면 서버가 정본으로 씀(273-2). */ + preview?: boolean; /** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */ design?: CrossDesign; /** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */ @@ -474,6 +478,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; /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ @@ -625,6 +631,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 +646,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 +657,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..987e4cfdc 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) — 기하 입력이라 재계산에도 넘김 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..65a26467b 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -155,6 +155,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`) @@ -580,9 +582,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). 결과 칸은 늘 새 계획고로 다시 낸다. @@ -600,11 +606,13 @@ def compute_default_designs( 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 +623,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), 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..b4eb74dcc --- /dev/null +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -0,0 +1,186 @@ +/* ============================================================================= + * 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 === "ford_pavement") return null; + 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..9173d08f4 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -40,6 +40,7 @@ import { type PavementStructureInput, } from "@util/common_util_structure_pavement"; import { setGeneralStations } from "../common_util/common_util_structure_station"; +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,8 +64,10 @@ 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). 예전에는 `[]` 가 「초안이 있는데 벽이 // 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도 @@ -185,21 +188,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 +227,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_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 36575f17f..2d8958dd7 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", diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 260405b9b..ad6f93454 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,7 +25,12 @@ 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, @@ -33,18 +39,24 @@ import { import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; 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 +64,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 +72,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 +122,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 +155,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 +193,14 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT /* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */ /** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 · - * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */ + * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) · + * 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */ 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; @@ -214,6 +231,7 @@ export function attachDetailZoom( content: { x: -width, y: -height, width: 3 * width, height: 3 * height }, wheelZoom: true, initial, + homeScale, onChange: (state) => { follow(state); onChange(state); @@ -242,7 +260,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 +273,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 +295,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 +392,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 +437,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..8e7839a35 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"; @@ -44,6 +44,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 +63,7 @@ export function pickLines( design: [polyline(design?.design_line)], ground: groundRuns(samples), spoil: [polyline(design?.spoil_fill_line)], + rock: rockRuns(design, samples), }; } @@ -216,6 +227,89 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] { 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_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..f6f8f101e 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,25 @@ 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"; +/** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */ +export const FACE_HIT_CLASS = "b06-detail__face"; /** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */ export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt { @@ -39,12 +50,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,9 +88,17 @@ 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 => ({ line: piece.structure ? "structure" : "design", @@ -102,6 +122,7 @@ const LINE_NAME: Record = { structure: "구조물 선", ground: "원지반선", spoil: "사토장선", + rock: "암 경계선", }; /** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */ @@ -111,22 +132,55 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie 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)); return ( `${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` + - (area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`) + (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 +203,74 @@ 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` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다 + * 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역. + */ +export function pickAt( + point: Pt, + scale: number, + paths: Pt[][], + rings: Pt[][], + areas: number[], + pieces: SplitPiece[], +): { piece: number } | { face: number } | 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 }; + return face === undefined ? null : { face }; +} + /** 조각의 자리 이름 — 선 종류 + 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 +283,22 @@ export function attachDetailSelect(input: { picked: (additive: boolean) => void; /** [칸에 넣기] 단추. */ commit: () => void; + /** 면적 고르기 중 그 자리에 면적 띠가 있나 — 있으면 영역 대신 띠(표가 맡음). */ + band?: (event: MouseEvent) => boolean; }): 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 +316,60 @@ 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); + return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces); + }; + 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 && !(mode === "area" && input.band?.(event))) { + // 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) + event.stopPropagation(); + 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 = toggleSelection(selected, pick.piece, additive); show(); input.picked(additive); }; @@ -216,19 +380,29 @@ 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; + }); show(); }, setMode(kind) { @@ -241,7 +415,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..366264f28 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,10 @@ 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"; /** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ export const MAX_ROWS = 12; @@ -173,6 +175,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 +308,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 +317,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 +355,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 +387,16 @@ export function attachDetailTable(host: DetailTableHost): { if (picking?.kind === "length" && !additive) commitSelection(); }, commit: commitSelection, + band: (event) => !!bandAt(event), }); - // 면적 띠 · 사토장 누름 = 그 띠 면적(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 거리 판정) + 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 +461,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_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..c22c9745a 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -62,13 +62,13 @@ const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다."; /** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */ const PIPE_ADD_NOTICE = - "계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; + "계곡 통과 시설을 넣었습니다 — 횡단도는 바로 보이고, 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다."; const PIPE_REMOVE_NOTICE = - "계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; + "계곡 통과 시설을 뺐습니다 — 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다."; /** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */ const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다."; const PIPE_MOVE_NOTICE = - "기준 측점을 옮겼습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; + "기준 측점을 옮겼습니다 — 횡단도는 바로 보이고, 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다."; export interface B06StructuresPanelDeps { projectId: string | null; @@ -493,6 +493,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); + deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2) showToast(PIPE_MOVE_NOTICE, "success"); } diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 88f3a9e64..dc327f46f 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -579,7 +579,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(); diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts index 36f17d038..2ef62ef59 100644 --- a/B06_Section/B06_Section_UI_Section_View_Menu.ts +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -33,6 +33,8 @@ export interface SectionStructureEdit { moveStructure: (structureId: string, toChainageM: number) => void; /** 구조물(관 아님) 하나를 폼에 올리고 그 카드로 간다 — 같은 측점 관보다 먼저(52-15). */ selectStructure: (structureId: string) => 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..8dd8478d0 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -15,7 +15,14 @@ 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 { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; import type { CrossSection, @@ -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,6 +214,20 @@ 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) + const signature = JSON.stringify( + pipes.map((p) => [p.chainage_m, p.facility, p.options?.pipe_diameter_mm ?? null]), + ); + const changed = pipeSignature !== null && signature !== pipeSignature; + pipeSignature = signature; + if (changed && shared.view() === "cross") host.edit()?.pipesChanged?.(); + }, // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. // 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15). onStructureSelect: (structureId) => { 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..a724bc1cc 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -360,6 +360,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..3e3e0de14 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 · 사토장 없음) — 회색 · 누름 없음 */ @@ -172,7 +199,8 @@ .b06-detail__piece { 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; @@ -188,6 +216,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..81a1400e1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -29,6 +29,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 +51,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 { @@ -494,9 +497,10 @@ function drawDesign( ); } -/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다. */ +/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다 — 박스암거 · 배수관은 pipeSvg 로 따로(268-2). */ function drawStructures( svg: SVGElement, + pipeSvg: SVGElement, section: CrossSection, layouts: Layouts, x: (offset: number) => number, @@ -504,12 +508,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 +540,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/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_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..2d524c63f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -9,11 +9,17 @@ * 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도 * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) * 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State` + * 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] · + * [단가연결 풀기] — 창 = 「운반 단가」 모달(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 { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; import { @@ -35,6 +41,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 +71,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 +88,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 +101,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 +111,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 +137,36 @@ 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([ + [ + "단가연결", + () => + openHaulModal({ + ref: (route.ref as Row | null) ?? null, + dist: at && { L1: at.L1, L2: at.L2 }, + name: route.rows[at?.index ?? 0]?.item || route.name || "운반", + apply: link, + }), + ], + ...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []), + ]); + }); return t; }; let mode = "false:false"; @@ -130,29 +179,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(); @@ -187,7 +233,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/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_Router.py b/B09_Estimation/B09_Estimation_Router.py index 62ccb65e8..f52e17869 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -6,10 +6,18 @@ [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 POST /api/projects/{id}/estimation/prices [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} + 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · + 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) 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 +33,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 +60,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,10 +120,36 @@ def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> t return sheets, compute_summary(sheets, hopyo_lines(sheets)) +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) -> dict[str, Any]: + """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결 · 거리(화면엔 안 보냄) · + 운반 사본 연결 = 지금 거리의 사본(263-2).""" + user = {**store.read_user(root), **haul_user(haul_routes(root))} + return library.resolve_hauls(root, user) + + async def _demand(root: Path) -> list[str]: - """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2).""" + """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2) + + 거리이정표 운반 연결(251-5).""" + user = await asyncio.to_thread(calc_user, root) # 운반 값 사본 먼저(263-2) keys = await asyncio.to_thread(linked_resource_keys, root) - user = await asyncio.to_thread(store.read_user, root) hand = await asyncio.to_thread(sangeun.hand_resource_keys, user) return [*keys, *(k for k in hand if k not in keys)] @@ -121,11 +164,16 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: ) 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 +199,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) @@ -168,11 +221,53 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]: return await payload(project_id, root) +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) + block = sangeun.Solver(base).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"], + } + + +@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): kind: str 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 +278,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 +296,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..41956e4e1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts @@ -0,0 +1,403 @@ +/* ============================================================================= + * B09_Estimation_UI_Haul_Modal.ts + * 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결. + * 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) · + * 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음 · + * 사전조합 · 구조물 조합 = 담은 로직 이름별 입력 `fetchLibInputs` · 263-2) · + * 오른 = 계산 절차 — 거리 줄 · 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle` · 8-2-8 기본값 · 거리 칸 있을 때만) + + * 로직 산출 줄(서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만). + * 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) · + * 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값). + * 조합 [적용] = 거리 · 손 값 사본(서버 · 같은 값 = 같은 사본) · 연결 {tab dg, key, lib, given, own}. + * 연결 없는 줄도 같은 모달(빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본). + * 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결. + * ========================================================================== */ + +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_DEFAULTS, + CYCLE_FILL, + cycleInputs, + cycleTime, + 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"; + +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 FROM_DIST = "거리에서"; + +const fmt = (v: number | null | undefined): string => + v == null || !Number.isFinite(v) ? "—" : String(v); + +/** 계산 절차 — 사이클 식 줄(거창 단가산출 꼴) */ +export function cycleLines(input: Record): string[] { + 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); + return [ + "사이클시간 — 건설품셈 8-2-8 덤프트럭", + `q = T / rt × f = ${v.T} / ${v.rt} × ${v.f} = ${r.q} ㎥`, + `n = q / (qs × k) = ${r.q} / (${v.qs} × ${v.k}) = ${r.n} 회`, + `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`, + `t2 = (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 = ${r.t2} 분`, + `Cm = t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6} = ${r.cm} 분`, + `운반거리 = L1 + L2 = ${r.L} km`, + ]; +} + +interface Preview { + lines: string[]; + unit: string; + price: number | null; + why: string | null; +} + +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) => { + 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("pre", { + attrs: { style: "margin:0;white-space:pre-wrap;font-size:12px;line-height:1.6" }, + }); + 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; + + const redraw = (): void => { + const fills = + (st.code !== "" && names().some((n) => n in CYCLE_FILL)) || + (isLib(st.other) && + !!o.dist && + st.lines.some((l) => l.입력.some((i) => i.이름 in CYCLE_FILL))); + const input = cycleInputs(ref(), o.dist); + const head2 = fills + ? [ + `거리 — 포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km`, + ...cycleLines(input), + "", + ] + : []; + window.clearTimeout(timer); + const my = ++seq; + if (isLib(st.other)) { + steps.textContent = [ + ...head2, + `${KIND_TEXT[st.other.kind]} — [적용]하면 거리 · 손 값 사본(같은 값 = 같은 사본)`, + "단가 = [저장] 뒤 서버 셈", + ].join("\n"); + return; + } + if (!st.code) { + const what = st.other + ? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결` + : "없음"; + steps.textContent = `연결 — ${what}`; + return; + } + steps.textContent = [...head2, "단가산출 — 셈 중…"].join("\n"); + timer = window.setTimeout(() => { + preview(st.code, filled(), st.rate, st.picks).then( + (got) => { + if (my !== seq) return; // 최신 응답만 + st.unit = got.unit; + const price = got.price == null ? `빈칸 — ${got.why ?? ""}` : `${got.price} 원`; + steps.textContent = [...head2, ...got.lines, "", `단가 = ${price}`].join("\n"); + }, + (error: unknown) => { + if (my === seq) steps.textContent = [...head2, `못 셈 — ${String(error)}`].join("\n"); + }, + ); + }, 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 = 비움) */ + const valueRow = ( + input: ItemInput, + auto: () => unknown, + hand: () => unknown, + set: (value: unknown) => 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 tag = el("span", { className: "m02-labin__muted" }); + shows.push(() => { + const value = auto(); + tag.textContent = value === undefined ? "" : FROM_DIST; + if (value !== undefined && document.activeElement !== field.control) + field.control.value = String(value ?? ""); + }); + return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]); + }; + 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); + }, + ); + }); + /** 조합 = 담은 로직 이름별 입력(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, + () => (fill && !st.libOwn.has(id) ? fromDist(name) : undefined), + () => 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); + }, + ); + }), + ]); + + /** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */ + 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); + 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, + el("div", { + attrs: { + style: + "display:grid;grid-template-columns:minmax(320px,1fr) minmax(360px,1.4fr);gap:16px;min-height:60vh", + }, + 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_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..725235b36 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 => { @@ -332,18 +359,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_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..050b30ed8 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,95 @@ 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) */ +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 ? 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..568979122 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -203,6 +203,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(); 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_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_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..c6a31545d 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,171 @@ 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 }] : [], + ); +} + +/** 관 시설 하나가 심는 측점 — 물넘이포장 없음 · 독립 기슭막이 = 시작 · 기준 · 끝 · 그 밖 = 기준. */ +export function pipeStationMarks(pipe: PlantingPipe): StationMark[] { + const facility = pipe.facility ?? "pipe"; + if (facility === "ford_pavement") return []; + 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/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/요구_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/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index a2ae14ba1..9521fc7cb 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -178,3 +178,23 @@ 147. (브레인 물음 답 · 원문) 1. 백엔드 재시작 2. 폴더만 기준이 되면 됨. 3. 반영 4. 테이블이 아닌 항목은 대상이 아님 5. 다음으로 공사지 +로 추가하는 항목에서 나오는 모달창의 크기를 조금 더 키우고 내부 내용을 정리할 생각임. 항목은 분류(기준위치, 거래처) 항목, 거래처 이름, 목적지를 2열배치 이때 분류에 따라 항목이 바뀜(다이어그램의 기준점+ 버튼은 삭제), 거래처 위치를 소재지로 변경하고 직접입력버튼은 삭제. 전부 주소찾기나 지도에서 찾아야함. 등록에 이제 다음지도로 대체 6. 메일은 내가 알아서 하겠음. 7. 데스크탑은 세션 확인하면 살아있음. 오르카 리스트 확인 8. 추가 거리이정표 항목 - 등록으로 보이는 모달창은 해당 컨테이너를 눌렀을 때 같이 보여주면 됨. - 길찾기는 연결선을 누르면 보이는 모달창에 추가 이때의 항목은 도로 구분과 거리를 항목값으로 받고 하위에 다음맵으로 경로를 보여주면 되는데 기준점이 발생되는 이유는 임도 특성상 도로가 없는 경우 한 지점을 지정하게 됨. 다시말해 기준점이 있다면 기준점과 거래처의 다음맵지도가 필요함. 이때 위치조절을 위해 위치이동은 되어야함. 시점과 기준점은 도로가 연결이 안되는 경우가 있어 수기 입력이 되어야할때도 있음. 시점과 중점은 항상 거리는 수기입력임. 이때 도로 구분의 경우 2차 포장이란 시점이 아니라 기준점이 있을때의 경우임. 다시말해 기준점을 거쳐 시점에 들어가는 경우에만 존재하는 열 항목임. 기준점이 없다면 그냥 포장이 됨. 148. 길찾기 그림이 없는 이유는 뭐야? (브레인 판정: 서버 안에서 헤드리스 브라우저를 못 띄움 — uvicorn 이 쓰는 Windows Selector 이벤트 루프는 하위 프로세스를 못 만들어 Playwright NotImplementedError · 시험은 서버 밖이라 통과) 149. 고침이라는 명칭을 수정으로 변경. 컨테이너 항목에서 + 줄이 있는 이유가 뭐야. 거래처 등록은 거래처에 관련 정보를 선에는 경로관련을 묶어서 표현하려고 하는거잖아. 그리고 기준위치에는 항목이 필요없어. 거쳐가야하는 지점이다보니 의미가 없어. 이번 부터는 너가 직접작업해. 거리이정표의 경우에는 폭을 브라우저에 맞춰서 폭 전체를 사용하게 하고 하단의 테이블도 동일하게 폭에 맞춰 열의 사이즈를 조절해줘. 그리고 현재 다이어그램이 줌아웃이 너무 멀어서 글자가 잘안보여 적당히 줌인해줘. +150. 기준점과 선의 컨테이너 내부 카카오맵의 영역의 높이를 더 높여줘. 카카오맵의 높이는 어디서든 같게 해줘. +151. 그리고 선 성분의 연결정보는 컨테이너들을 드래그해서 연결하고 이정보를 가져오는건 어때? (제안 · 물음) +152. 그리고 포장과 비포장이다가 기준점이 생성되면 당연히 1차 포장, 2차 포장이 되어야할것같아. 그리고 그때 선의 연결여부에 따라 1차냐 2차냐가 되는거고 +153. 카카오맵에서 기준점이나 소재지를 지정하면 보기가 초기화되고 줌아웃 상태가 되고 있어. 변화가 없는게 편할것 같아. 공사지, 기준점, 거래처를 기준으로 색상을 조금 넣어주는 좋겠어. 이때 프로그램의 내부에 화이트와 블랙테마 양식이 있으니 참고해. 다음으로 요소들을 드래그로 위치를 변경하거나 정렬하는 기능이 있으면 좋겠어. +154. (선 연결을 드래그로 — 연결 점 ● 끌어 기준점 · 공사지에 놓기 · 설명 뒤) 동의 작업진행 +155. 컨테이너 요소는 총 4개야. 3개의 컨테이너와 하나의 선 타입이야. 시점과 중점은 자동생성으로 그냥두고 기준점과 거래처의 정보를 등록하고 선으로 연결하면 모달창으로 정보가 나오는데 이때 경로를 찾은 거리와 경로를 카카오맵으로 보여주면 되는거야. 이때 문제가 있을수 있으니 수기입력 기능으로 카카오맵 연결을 취소하게 하면 될것같고 기준점과 공사지의 우측 상단의 +는 삭제하고 자동정렬 버튼 우측에 요소 추가 버튼을 만들어서 대체하게 해줘. +156. 거리이정표탭의 하위 탭중 거래처 항목 탭의 경우 내부에 길찾기가 있는데 이항목은 이제 없어도 될것같은데 어떻게 생각함? 필요없는 로직으로 보임. +157. 1. 요소추가 버튼은 다이어그램 영역의 좌측 하단으로 이동하고 버튼사이즈는 메인 탭 제목과 같은 사이즈양식으로 변경, 자동정렬도 같이 이동 2. 하위 거래처 탭에서 보이는 정보중에 그림 올리기 버튼은 그림 변경으로 이름 변경, 그림 지우기 버튼은 필요 없으나 초기화 버튼으로 사용자가 올린그림을 지우고 원래연결되 그림으로 되돌리는 기능으로 변경. 이름은 초기화. 3. 하위 거래처 탭에서 내가 보기에는 이제 그림으로 표현할 필요는 없어보이는데? 거리 이정표에서 선을 선택할 때 보이는 길찾기 화면을 보여주는게 제어도 되고 좋을것같음. 단 사용자가 그림 변경하여 업로드하면 변경한 그림을 보여주고 초기화하면 다시 길찾기 화면을 보여주면 됨. 추가로 쓸데없는 설명은 삭제 그림 변경과 초기화 버튼은 거래처명이 작성된 라벨과 같은행에 우측맞춤하여 배치, 거래처명, 주소, 버튼의 글자는 크게 변경, 메인 탭 사이즈와 동일하게 +158. 자동정렬의 기준이 뭐임? 혹시 각각 요소들의 위치를 나타내는건가? (질문) +159. (원문) 1. 자동정렬 명칭을 방위배치로 변경(방위 배치 안보이고 테이블이 브라우져 폭을 다사용하게 반영 안됨) 2. 이제 연결작업을 할 생각임. 요소추가 버튼으로 생성할 수 있어야하지만 기본적으로 프로젝트의 사용하고 있는 구조물조합의 일위대가의 자재목록을 하나의 행으로 구성되어야함. - 초기에는 거래처와 주소가 지정되지 않은 컨테이너를 다이어그램에 배치해주고 사용자가 컨테이너의 모달로 접근하여 정보입력하게 됨. - 이후 선을 연결하여 정보 연결을 마무리함. 3. 이후 사용자는 단가산출 로직이나 마스터/회사/개인의 일위대가 사전조합을 연결한다. 이때 연결여부 표현을 위해 (구조물 집계표 측점 램프 그림) 항목 앞에 램프를 추가함. 4. 이 정보들은 모여서 B09의 운반공 하위의 항목으로 각 행들이 들어간다. 이때 B08에서 연결이 안되어 있더라도 설계내역서 행의 기능 중 연결 기능으로 단가산출이나 일위대가 사전조합을 연결할 수 있어야함. 5. 설계내역서/거리이정표의 마우스 우클릭의 기능 중 연결에서 여러정보들이 나오는데 이렇게 하지말고 모달창을 띄워서 보여주고 한번에 검색하거나 필터링하여 검색할수 있는 기능 부여. 이때 연결이라는 명칭보다는 단가연결 이라고 변경하여 표현 6. 연결은 중요한 문제니 너가 직접 조사 작업을 하여 계획서를 작성하고 작업을 이어서 해줘. +160. 추가로 단가연결을 할때는 구조물조합이나 일위대가 사전조합이라면 문제가 없겠지만 단가산출의 경우에는 연결할때 옵션값을 받아야하는 경우가 있을거야. 이때는 추가 모달창을 보여줘서 사용자가 선택할수 있게 반영해줘. 만약 내가 잘못알고 있는거라면 알아서 해주고 +161. (원문) 다음은 B09에 신규 메인탭인 공기를 넣어주고 하위에 공기집계와 공기산출 탭을 만들어줘. 이때 공기관련내용은 초안이니 B09 폴더 내부에 draft폴더를 만들어서 코드를 작업해줘. 나중에 마음에 안들면 그냥 draft폴더를 삭제할거야. 일단 참고문서는 resources\knowledge\original\실무문서\01.산불거창(거창북상병곡)\01.내역서\00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx 파일 중에 공기집계와 공기산출 탭을 참고하면 될거야. 여기서 중요한 점은 참고를 보고 내용을 넣는게 중요한게 아니라 로직을 분석하고 어떻게 계산하는지 확인하고 구현하는게 중요한거야. 다시말하지만 참고 항목이 있다고 테이블을 만드는게 아니야. 우리 프로그램에 맞춰 적용하는게 중요한거야. 그래서 길게 생각하고 작업해도 되니까. 꼼꼼히 분석해서 반영해줘. 이건 현재 작업내용을 이어서 진행하면 됨. 나 쉬러 갈테니까 작업완료하고 내일 아침에 내가 아티팩트를 만들어줘. 일단 이번 구현에 대해서는 방향성 결정이 필요한 내용이 있다면 너가 결정해서 아티팩트로 알려줘. +162. (원문 · 10-11) 1. 거리이정표 - 설계내역서 운반공 연결 - 거리이정표의 항목행 하나당 하나씩 설계내역서의 운반공 하위의 노용으로 들어가야함. - 거리이정표와 설계내역서의 연결 후 선택된 항목에 대해 거리값을 입력받아야하는 경우 연결되거나 될 거리이정표의 거리와 연결되어야 하며 이때 아마 비포장과 포장이 다를것같은데 이부분에 대한 계산 절차를 보여주는 모달창이 있어야함. 이때 모달창에는 옵션값과 계산 절차를 같은 화면에서 보여주며 단가산출을 다른 항목으로 수정할수 있어야함. 단가연결이 안된경우와 된경우를 같은 모달창을 사용하면 좋을것같아. 2. 거리이정표의 하위탭중 항목별 경로는 나타내는 하위탭에서 거래처/기준점/시점을 옮긴다면 주로를 옮겨서 업데이트 3. 거리이정표의 시점에서 나오는 모달창의 중점 측점은 km 단위로 변경, 하위의 설명은 삭제, 맵은 공통 카카오맵으로 변경 4. 거리이정표의 다이어그램에서 항목의 폰트를 거래처의 폰트와 동일하게 변경, 아래 주소가 길경우 자동으로 줄변경하고 컨테이너 세로 높이 증가, 방위 배치나 기본 맵연결에 따라 위치를 잡을때 거리에 따른 선길이가 지정되면 좋겠음. 단 너무 널리떠러지면 안됨. 선, 위의 텍스트 중 포장타입과 거리는 2행 구조로 변경, 거래처 및 기준점에 대한 모달창에서 제목의 폰트사이지가 내용보다 작음, 소재지-주소찯기.지도에서 고르기(...) 항목은 삭제, 기준점의 모달창인 경유 기준점의 이름이 2행구조로 됨. , 주소 및 맵 영역은 상단에 가로선을 추가하여 구분할것. 5. 기준점의 다이어그램 컨테이너에도 주소 추가 6. 거리이정표의 다이어그램에서 선연결의 드래그하는 원을 박스의 좌측 상단에 배치, 단 테두리를 넘어가면 안됨. 추가로 컨테이너의 내부에 작은 주소를 찾은 그림을 캡쳐하여 프로젝트 영구저장소에 넣어주고 컨테이너 내부에 배치 이때 그림을 다른 곳에서도 사용할 예정임. 잘 보관할 것. 7. 거리이정표의 하위 탭의 경로보기 기능 중 그림을 캡쳐하여 프로젝트 영구저장소에 넣어줄것. 프론트엔드 기능변경이 아님. 그림은 나중에 다른곳에 사용할 예정 8. 거리이정표의 다이어그램을 누렀을때나오는 제목에 테이블 제목 행의 정보가 들어오는데 삭제. 9. 거리이정표의 테이블이 컨테이너가 다이어그램의 영역과 동일하게 브라우져의 폭에 맞춰 변경이 되지만 테이블은 맞춤이 안됨. 혹시 테이블 양식과 충돌이 있다면 알려줄것. 10. 거리이정표의 기준점은 2개 이상일 수도 있을것같음. 이럴경우를 대비하여 선연결 기능을 기준점에도 추가 11. B09 총괄설계내역서와 설계내역서를 하위탭으로 이동하고 상위 탭으로 설계내역 탭을 생성 12. B09 단가연결 열의 정보가 텍스트로 구현되어있음. 랩프로 변경(램프양식은 B08의 구조물집계표에 있음), 선택 기능은 유지, 단가연결 열 제목은 단가 > 줄바꿈 > 연결 로 변경하고 테이블 열 폭을 줄일것. 13. B09와 테이블 행선택에서 페이지이동 기능이 구현된 행의 경우 붉은색 박스 하이라이트가 반영안된게 있음. 14. 하이라이트 기능과 연결하여 조심할 부분은 셀에 수기 입력이 가능한 영역을 셀테두리로 표기한게 없어졌음. 조심 테두리의 값을 조금 더 옅게 표현. 15. 자재단가대비표에 선택한 적용단가인 경우에 해당 셀에 색상을 변경했는데 이걸 테두리를 넣는걸로 대체. 이때 입력셀 테두리와 혼돈이 될수 있으니 녹색계열로 변경 16. 자재단가대비표에서 최저가 일지라도 적용단가를 변경해야할 경우가 있음. 마우스 우클릭에 항목을 적용단가 적용 항목 변경 17. 설계내역서의 비고 열에 빈 칸XX 정보는 삭제. 18. 총괄설계내역서/공사원가계산서의 제일하단의 총공사비는 하단고정으로 변경. 양식은 테이블 양식 확인 19. B06의 횡단도에서 상세버튼 모당창의 내용 중 나눠진 면적지정에 대한 기능 구현안됨. 기준은 현재 선들이 교차가 되면 선 요소를 분할하였음. (암경계선도 분할 필요) 이렇게 됨면 오픈된 영역을 제외하면 먼저 지정도 가능해야함. 이때 선택된 면적은 선을 지정하였을때 길이관련 정보(기능 누락됨)보여주는 정보들처럼 면적 정보들을 보여주면 됨.(이때 테이블을 선택하면 그래프에서 선이나 면적을 지정하게 될텐데 이때도 길이나 면적이 보여야함. ) 20. 횡단도 상세버튼 모달창 테이블의 항목들을 선택하면 테이블 하이라이트가 되는데 여기서 셀과 셀 제목을 지정해주는건 좋은데 중복되는 선때문에 보기가 안좋음. 21. 횡단도 상세버튼 모달창에서의 그래프 영역하단은 텍스트 입력창으로 별도의 정보를 B07로 전달되어야하는데 내가 원하는 방향은(그림_162-21_횡단배수_글.png — 배수관 원 표기 · NO.71+13 · 관보호공 · 관유입부보강 글) 구조물이 없는 경우에는 당연히 빈값이어야하고 구조물 중 횡단배수 구조물에 대해서만 미리 넣는 스크립트를 만들어주면 됨. 이때 이질감이 있을수 있으니 텍스트창이아니라 캐드창을 보여주는데 그냥 배경만 있는 캐드창이기만 하면됨. 그럼 다음B07에 테이블 상단에 배치하면 이질감이 없을것같아. 기본적으로 설계자들은 대부분 캐드 단축키를 알고 있으니 빈화면의 캐드화면이라도 문제 없어보임. 22. B05/B06 공통 좌측 패널의 구조물 조합 지정 기능에서 미지정 항목에 대해 노란새 색상이 좋기는 한데 눈에 너무 띄는것같음. 이건 구조물리스트의 램프와 같은 색상으로 가는게 좋겠어. 회색이 될것같네. 23. 이제까지는 너가 직접 코드작업을 요청했지만 이제 폭넓은 부분에 코드작업을 해야하니 너가 조사작업을 하고 계획서를 작성하고 브레인으로써 다른워크트리에게 작업을 지시해줘. 그리고 완료시 너가 작업이 누락된게 없는지 확인해줘. 코더는 자기가 작업한 코드만 검증을 하고 화면 검증은 작업완료시 내가 진행할께. 너도 필요하다고 판단되면 브라우져 검증진행해도 됨. +163. (원문 · 브레인 화면 검증 흠 다섯에 대한 답) 고칠 흠중 1. 소수점 제어 확인 2. 보류 3. 제안대로 4. 제안대로 5. 운반공의 하위에는 단가연결이 연결이 되던안되던 일단 이정표의 항목명으로 운반공에 하위에 개별로 들어와야함. +164. (원문 · 10-11) 1. 거리이정표 - 거래처의 주소를 입력할때 정해져 있다면 공사지나 시점의 마킹을 남기는게 좋음> - 거리이정표의 컨테이너 모달창에서 제일 상단의 제목이 거래처 - 거래처, 공사지 - 시점,종점 , 기준점 - 주소 로 표현됨. 에서 - 앞의 글자는 필요없음 - 단가연결에서 수기로 전체입력을 받는게 아님. 사용자는 미리만들어진 단가산출 로직이나 구조물 조합, 일위대가 사전조합에서 지정하게 될텐데 이때 지정한 항목이 필요한 입력정보를 보여주는데 여기서 이정표로 거리값을 확보했으니 입력에 자동으로 넣어달라는 말이잖아. 처음부터 값을 하나하나 지정해서 만든다는게 아니라. 그리고 어차피 너가만든 기준으로 만든 8-2-8 도 벌써 만들어져 있어. 입력값이 필요할 뿐이지. 2. B09 공기초안의 아티팩트는 봤어. 그런데 이상한건 너가 정한것들은 모두 사용자가 지정해야할 내용들 아니야? 그렇다면 B09의 좌측패널에 별도의 컨테이너를 만들어서 옵션을 선택할 수 있게 변경해줘. 3. B06 횡단도 상세 모달창의 내부의 캐드영역 구현은 잘되었음. 여기서 접었다 폈다하는 기능을 구현해주고 일반측점의 경우에는 접힌상태가 구조물 측점의 경우에는 펼쳐진 상태가 모달창이 열릴때 기본값으로 변경하고 캐드영역과 테이블 순서를 바꿔줘. 4. B06 횡단도 상세 모달창의 내부 횡단도에서의 기본 줌인값은 B06 좌측패널의 횡단반폭 보기설정값을 적용하게 해줘. 5. B06 횡단도 상세 모달창인 캐드화면에 기본 값을 배관만 넣어줬는데 다른 구조물도 넣어야하고 문구를 그냥 내가 넣어준 값을 복사하여 사용한것 같은데 여기서 구조물조합에 연결된 일위대가의 이름과 관련 정보(옵션 값)들을 넣어주면 됨. 그럼 사용자가 수정하여 사용함. (배관은 원형마크로 대체 되어야함. ) 6. B06 횡단도의 상세 모달창의 캐드화면에서 배관의 길이는 정수값을 사용해야함. 사사오입한 값을 사용해야함. 벌써 내부에 계산되어 있음. 7. B06 횡단도에 표기되니 관매설 정보를 알려주는 노폭 정보위의 회색 글씨의 크기는 노폭정보 폰트 크기와 동일하게 변경 +165. (원문 · 10-11) 1. B07의 횡단도에 측구를 표현할때 노란색으로 측구가 하나더있어보임. 확인후 삭제 2. B07의 배관이 구분이 어려움, 색상변경,(박스암거도) 3. B07 좌측 패널의 사이즈는 요소들의 버튼과 입력창의 양식은 B05/B06 좌측 패널의 구조물 배치의 공용양식 사용. 이때 B06의 다른 컨테이너도 포함. B05 이상의 페이지들의 좌측패널의 컨테이너 공용양식 사용확인, 이때 패널의 하단에 버튼셋들은 예외. 4. B05/B06 좌측패널의 구조물 리스트나 B06의 횡단도, 배수유역도의 번호 및 영역, 테이블열, 3D 측점 하이라이트 기능은 다연동되어 있는데 지금 배수유역과 테이블 열 하이라이트 기능을 잃은것같아. 이건 정말 조심히 개선해야하는데 어제 저녁쯤에 상당히 오랫동안 분석해서 나온결과야. 주요 문제점은 노면공의 범위 값에 의한 하이라이트 충돌문제였어. 제대로 확인한 후에 4번만 너가 직접 작업하고 브라우져 검증해줘. +166. (원문 · 10-11 · 브레인 보고 답) 1. CAD 미리넣는 기능에서 사용자가 어떻게 사용할지를 몰라서 일단은 좌측패널 옵션과 구조물조합이름을 다 넣어주면 내가 추가로 선정하겠음. 2. 상세 창 처음화면은 전체가 다안보여도 상관없어. 3. 표열 강조가 무슨의미인지 자기 측점이 없는게 있을수 있나 무슨 말인지 모름. 4. 깃 싱크해도됨. 5. B05/B06 좌측 패널의 구조물 리스트 상단에 정수 맞춤 버튼이 있는데 역할을 전방위 적으로 코드를 확인해줘. 내가 원하는 방향은 전처리과정에서 확보된 배관지점들을 결정할때 반올림하여 횡단배수들의 위치를 결정되어한다. 이후 사용자들이 조절을 하거나 다른 구조물 전체 중 어떤 걸 넣더라고 지정된 위치는 그냥 정수값을 받고 지정해야한다는 거야. 혹시 문제가 있는지 확인해줘. 그리고 만약 구조물 측점과 횡단도 측점이 같은 측점으로 뭉쳐진다면 상위인 구조물 측점이 존재하고 일반측점은 비활성화되어야해. 그리고 만약 구조물 측점을 옮기게 되면 비활성화된 일반측점은 활성화하고 구조물 측점을 이동하는거야. 이건 하이라이트 기능들과 많이 연동되어있어. 간혹 두가지 중첩된 상태로 보이는 상황이거든 정말 자세히 검토해줘. <<< 에게 왠지 3번 항목과 연관되지 않을까 생각됨. +167. (원문 · 10-11 · 조사 AK 답) 너가 뭔가 오해한것 같음. 1. 현재 측점을 가지는 구조물들을 유지해 필요하면 내가 나중에 추가할 수도 있음. 2. 프로그램에서 측점의 수치를 중 '측점 + 거리' or 누가거리에서 소수점을 사용하지 않겠다는 의미야. m 단위로 결정한다는 말이지. 3. 단추는 필요없어. 그냥 소수가 아닌 정수로만 제어하겠다는 의미야. 그러니 옛데이터도 그렇게 되어야겠지. 겹침 개선해. 지금 개발하면서 반복적으로 발생되고 있는 문제야. 이게 아마 하나씩 구조물 측점을 하나씩 추가하면서 발생된 문제같은데 근본적으로 문제를 해결해줘. 현재 전체적인 로직과 개발 방향은 잡힌상태야. 기능 확장과 동일 요소에 적용같은 방향만 있을것같은데 근본적인 문제해결을 위해 리팩토링이 필요하다면 말해. +168. (원문 · 10-11 · 272 뒤) 이상하네 B05와 B06은 하나의 페이지로 동작하게 만들어져 있어. 그렇다면 신규 추가나 이동에 대해 바로 반영이 되면 좋겠는데? 그리고 종단도에 다른 영역들의 선택에 따라 종단 그래프에서도 활성화가 잘되는데 이떄 해당지점으로 이동해주는 기능이 누락되었어. 이전 코드적용 가능한지 확인해봐. 조건을 측전 활성화 결과를 보고 해당지점으로 이동하게 하는게 좋을것같아. / 해당지점을 화면의 중간에 오면 더 좋고 +169. (원문 · 10-11) B06의 횡단도의 상세 모달창에서 모여주는 표위 cad에서 배수관의 경우 도형이 원과 하단 수직선으로 구성도 되는데 이때 하단 수직선은 필요없음. 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/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/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..c45d6ecb1 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) { 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_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_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/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_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_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index e85518359..96e1842e4 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -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_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_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_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_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_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_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..aa8febe95 --- /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 ? haulModal : priceLink]]' in link + 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..dfdc2ad01 --- /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 + assert "names().some((n) => n in CYCLE_FILL)" in modal + assert "포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km" 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 and "openPriceLink" not in milestone + + +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..31dcecc2f --- /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, onChange" in src + 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..f4a03d24b --- /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군 구간형 시작 · 기준 · 끝 · 물넘이포장 · 다른 군 · 점형 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, 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_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_extra_stations_supplier.py b/resources/tester/test_b05_extra_stations_supplier.py index 9aa2c9c9d..fd7c24c47 100644 --- a/resources/tester/test_b05_extra_stations_supplier.py +++ b/resources/tester/test_b05_extra_stations_supplier.py @@ -247,7 +247,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_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_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_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_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; +} + +/* 날