diff --git a/.env b/.env index afc188a45..eae2c17ad 100644 --- a/.env +++ b/.env @@ -46,7 +46,9 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000 # Public URL for email links (프론트엔드 주소) APP_PUBLIC_BASE_URL=http://localhost:5173 - +KAKAO_REST_API_KEY=ea05e4c86be146d9e28586d3f25d5a08 +KAKAO_JS_KEY=98ac6edef14ffbca300d90e540644b68 +# kakao app ID 1603330 # Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f # 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA # 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index cc12ad8eb..0399b7ad2 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -185,6 +185,8 @@ export const STATE_REGISTRY = { "b08-hydro": { bucket: "draft", scope: "project", version: 3 }, /** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */ "b08-options": { bucket: "draft", scope: "project" }, + /** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */ + "b08-milestone": { bucket: "draft", scope: "project" }, /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ "b09-estimation": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index d7d3ff6c4..bb293e404 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -67,6 +67,8 @@ export interface SvgZoomPanOptions { content?: ContentBounds; /** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */ wheelZoom?: boolean; + /** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */ + panStart?: (event: PointerEvent) => boolean; } /** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */ @@ -146,8 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { if (event.button === 1) event.preventDefault(); }); svg.addEventListener("pointerdown", (event) => { - // 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다. - if (event.button !== 1) return; + // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. + if (!(options.panStart ? options.panStart(event) : event.button === 1)) return; event.preventDefault(); panning = true; moved = false; diff --git a/A00_Common/design/design_pick_escape.ts b/A00_Common/design/design_pick_escape.ts new file mode 100644 index 000000000..691c328e5 --- /dev/null +++ b/A00_Common/design/design_pick_escape.ts @@ -0,0 +1,31 @@ +/* ============================================================================= + * A00_Common/design/design_pick_escape.ts + * 풀기 한 길 — Esc = 고름 한 칸 비움 (233-3 · 조사 AF (나)). + * + * 값을 비우면 구독한 화면이 모두 따라 풂 — 종단(측점선 · 알약) · 3D(띠 · 핀 · 부재) · 배수유역도 · + * 좌측 폼 · 횡단 카드 · 범위 테두리 · 포장층 · 형상. 입력 칸 · 창(모달) · 메뉴 · 종단 도구가 먼저 + * 받은 Esc(입력 취소 · 창 닫기 · 도구 끝)는 그쪽 몫이라 건드리지 않음. + * ========================================================================== */ + +import { NO_SELECTION, selectDesign } from "./design_selection"; + +/** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */ +export const ESCAPE_FROM = "esc"; + +const OWN_ESCAPE = + "input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop"; + +/** 보기마다 건다 — 보이는 보기 하나만 받음 · 보기를 떠나면 뗌. */ +export function bindEscapeRelease( + projectId: string | null, + host: { visible: () => boolean; onDispose: (fn: () => void) => void }, +): void { + const onKey = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || event.defaultPrevented || !host.visible()) return; + const target = event.target as HTMLElement | null; + if (target?.closest?.(OWN_ESCAPE) || document.querySelector(".ui-modal__backdrop")) return; + selectDesign(projectId, NO_SELECTION, { from: ESCAPE_FROM }); + }; + window.addEventListener("keydown", onKey); + host.onDispose(() => window.removeEventListener("keydown", onKey)); +} diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts new file mode 100644 index 000000000..13c71b83e --- /dev/null +++ b/A00_Common/design/design_pick_profile.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * A00_Common/design/design_pick_profile.ts + * 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) · + * 3D(측점 띠 · 핀 · 부재 · 233-2) · 배수유역도(관 마커 · 유역 · 측점 마름모 · 233-3) (조사 AF (나) ② ③ ⑤). + * + * 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고, + * 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이 + * 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는 + * 그릴 때마다 지금 관 목록에서 다시 찾으므로 관이 옮겨져도 끊기지 않는다. + * ========================================================================== */ + +import { + nearestWithin, + readSelection, + subscribeSelection, + type DesignSelection, +} from "./design_selection"; +import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape"; +import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; +import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; + +/** 종단도가 켤 것 — 측점선(알약은 그 측점 짝을 종단도가 맞춤) 또는 알약(구조물 · 관 `pipe-*`). */ +export type ProfilePick = { stationId: string | null } | { structureId: string }; + +export interface ProfilePickSource { + /** 구조물 측점(수동 + 관 투영) — id 는 `irregularStationId` 를 씌우기 전 값. */ + irregular: () => ReadonlyArray<{ id: string; chainage_m: number; origin?: string }>; + /** 관 지점 정본(배수유역도). */ + pipes: () => ReadonlyArray<{ chainage_m: number }>; + /** 일반 측점 카드. */ + sections: () => ReadonlyArray<{ station_id: string; chainage_m: number }>; +} + +export interface ProfilePickTarget { + setSelectedStation: (stationId: string | null) => void; + setSelectedStructure: (structureId: string | null) => void; + /** 배수유역도 — 관 마커 · 유역(알림 없이) · 측점 마름모. */ + drainage?: { + selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void; + markStation: (chainageM: number | null) => void; + } | null; +} + +/** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */ +const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6; + +/** + * 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1) + * ② 고른 측점 그대로(일반 카드 → 구조물 측점) ③ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1). + */ +export function profilePickOf( + selection: DesignSelection | null, + source: ProfilePickSource, +): ProfilePick { + if (!selection || selection.chainageM === null) return { stationId: null }; + if (selection.structureId) return { structureId: selection.structureId }; + const at = selection.chainageM; + 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, + ); + if (own) return { stationId: irregularStationId(own.id) }; + const target = graphTargetAt(null, at, source.pipes(), source.sections()); + return "pipeChainageM" in target + ? { structureId: `pipe-${target.pipeChainageM.toFixed(2)}` } + : target; +} + +export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void { + if ("structureId" in pick) target.setSelectedStructure(pick.structureId); + else target.setSelectedStation(pick.stationId); +} + +/** 배수유역도 = 그 자리 관 마커 · 유역(없으면 풂) + 측점 마름모 — 재계산이 지운 뒤에도 다시 얹음(a3 · a6). */ +export function paintMapPick( + map: NonNullable, + selection: DesignSelection | null, +): void { + const at = selection?.chainageM ?? null; + map.selectPipeAtChainage(at, false); + map.markStation(at); +} + +export interface ViewerPickTarget { + markers: { selectStation: (stationId: string | null, notify?: boolean) => void }; + structurePick: { + select: (pick: { chainageM: number; kind: string; key?: string } | null) => void; + }; +} + +/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) · + * 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */ +export function viewerPickOf( + selection: (DesignSelection & { partKey?: string }) | null, + source: ProfilePickSource, +): { stationId: string | null; member: { chainageM: number; kind: string; key?: string } | null } { + if (!selection || selection.chainageM === null) return { stationId: null, member: null }; + const at = selection.chainageM; + const pick = profilePickOf(selection, source); + const own = selection.structureId + ? nearestWithin(source.irregular(), at, (entry) => entry.chainage_m) + : undefined; + const stationId = "stationId" in pick ? pick.stationId : own ? irregularStationId(own.id) : null; + return { stationId, member: { chainageM: at, kind: "", key: selection.partKey } }; +} + +export function paintViewerPick( + target: ViewerPickTarget, + pick: ReturnType, +): void { + target.markers.selectStation(pick.stationId, false); // 칠하기만 — 알림이 사슬을 다시 돌리지 않게 + target.structurePick.select(pick.member); +} + +/** + * 종단 보기(종단도 · 3D · 배수유역도)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 + * 바뀐 뒤 · 재계산 뒤 · 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠) + * 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. Esc(풀기 한 길)는 좌측 폼까지 닫음. + */ +export function bindProfilePick( + projectId: string | null, + target: { + profile: ProfilePickTarget; + viewer?: ViewerPickTarget; + form?: { hasSelection: () => boolean; selectById: (id: null) => void }; + }, + host: { visible: () => boolean; onDispose: (fn: () => void) => void }, + source: ProfilePickSource, +): { paint: () => void } { + const paint = (): void => { + if (!host.visible()) return; + const selection = readSelection(projectId); + paintProfilePick(target.profile, profilePickOf(selection, source)); + if (target.viewer) paintViewerPick(target.viewer, viewerPickOf(selection, source)); + if (target.profile.drainage) paintMapPick(target.profile.drainage, selection); + }; + host.onDispose( + subscribeSelection(projectId, (_selection, from) => { + paint(); + if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection()) + target.form.selectById(null); + }), + ); + bindEscapeRelease(projectId, host); + return { paint }; +} diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 8145a6ba3..7dda48d16 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -77,6 +77,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; 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"; @@ -341,7 +342,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { scheduleCrossPreview(); if (redrawPending) return; redrawPending = true; - requestAnimationFrame(() => { + frameOf(root, () => { redrawPending = false; draw(); }); @@ -443,7 +444,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { }); // 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫). - bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw)); + bindProfileCollapse(root, panelHandle, () => frameOf(root, draw)); /** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */ function syncDrainageSpans(): void { @@ -482,7 +483,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { nextDetail.balloon_offsets ?? undefined, ); draw(); - requestAnimationFrame(draw); + frameOf(root, draw); // 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 — // 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D // 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인). diff --git a/A00_Common/design/design_profile_viewport.ts b/A00_Common/design/design_profile_viewport.ts index ea193f514..3be92e541 100644 --- a/A00_Common/design/design_profile_viewport.ts +++ b/A00_Common/design/design_profile_viewport.ts @@ -6,6 +6,7 @@ import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; +import { viewOf } from "@util/common_util_view_window"; /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ const SCROLL_SETTLE_MS = 80; @@ -29,15 +30,18 @@ export function bindProfileViewport(deps: ProfileViewportDeps): () => void { /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ let settledScrollLeft = 0; let scrollSettleTimer = 0; + /** 시계를 건 창 — 새 창으로 빼면 그 창 시계를 쓴다(가려진 메인 창 시계는 늦게 돎 · 230-1). */ + let timerView: Window = window; // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. body.addEventListener("scroll", () => { if (deps.editing()) return; const fitted = deps.fitElevation(); - window.clearTimeout(scrollSettleTimer); + timerView.clearTimeout(scrollSettleTimer); if (!needsFullRedraw(fitted)) return; - scrollSettleTimer = window.setTimeout(() => { + timerView = viewOf(body); + scrollSettleTimer = timerView.setTimeout(() => { if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = body.scrollLeft; deps.draw(); diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index 0e16185a5..df7a528ef 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -13,7 +13,7 @@ import { readState, writeState } from "../b_page_state"; -/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */ +/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */ export const PIPE_MATCH_M = 0.51; export interface DesignSelection { diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 21bb3380c..1f4239ca2 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -39,6 +39,7 @@ import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pi import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; 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"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { @@ -191,7 +192,7 @@ export async function mountCrossView( if (!isWallSelecting()) focusStationAt(chainageM, revealOnly); }, releaseCard: () => releaseCross(), - markStructure: (structureId) => sectionView.selectMark(structureId), + markStructure: (structureId, span) => sectionView.selectMark(structureId, span), applyPipeOptions: (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), }); @@ -387,7 +388,7 @@ export async function mountCrossView( : null; if (!target) { structuresPanel.showAtChainage(null); - selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다. + releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2) return; } const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null; @@ -494,23 +495,20 @@ export async function mountCrossView( if (applied) appliedPick = signature; } - /** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */ + // 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2) + const crossPick = bindCrossPick(projectId, host, { + sections: () => sectionDetail?.cross_sections ?? [], + controls: stationControls, + releaseForm: () => structuresPanel.showAtChainage(null), + view: sectionView, + reapply: () => { + appliedPick = null; + restoreStructurePick(); + }, + }); + /** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */ function releaseCross(): void { - const { revetOffset, ford, box } = stationControls; - const lit = (sectionDetail?.cross_sections ?? []).filter( - (s) => - revetOffset.highlightFor(s) || - ford.selectedFor(s.chainage_m) || - box.selectedFor(s.chainage_m), - ); - for (const s of lit) { - if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null); - if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null); - if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null); - } - structuresPanel.releasePipe(); - sectionView.focusStation(null); - sectionView.refreshCards(lit.map((s) => s.chainage_m)); + crossPick.release(); appliedPick = null; selectDesign(projectId, NO_SELECTION, { from: "b06" }); } diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 9a1fe7824..2c2113c78 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -31,14 +31,9 @@ import { restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { - coversAtStation, - NO_SELECTION, - nearestWithin, - readSelection, - selectDesign, -} from "./design_selection"; +import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection"; import type { DesignPageHost } from "./design_page"; +import { bindProfilePick } from "./design_pick_profile"; import { bindDesignView, designReady, @@ -173,8 +168,8 @@ export async function mountProfileView( panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), // 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다. - onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), - onPipeSelected: (chainageM) => selectStationOfPipe(chainageM), + onBasinSelected: (chainageM) => pickFromMap(chainageM), + onPipeSelected: (chainageM) => pickFromMap(chainageM), // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). onStructureSelect: (structureId) => { @@ -220,7 +215,7 @@ export async function mountProfileView( * 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */ const uphillOverrides = loadUphillOverrides(activeProjectId); - const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({ + const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({ stations: () => bridge.irregularStations(), isSyncing: () => selectionSyncing, setSyncing: (value) => { @@ -244,9 +239,16 @@ export async function mountProfileView( remember: (chainageM) => rememberPick(chainageM, null), }); + /** 배수유역도 유역 · 관 고름도 고름 한 칸에(사슬 도중 되울림은 안 적음 · 233-3). */ + const pickFromMap = (chainageM: number | null): void => { + if (!selectionSyncing) rememberPick(chainageM, null); + selectStationOfPipe(chainageM); + }; + /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ function restorePick(): void { + profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1) if (panel.structures.hasSelection()) return; const pick = viewer.structurePick; restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe); @@ -318,45 +320,31 @@ export async function mountProfileView( forwardPipeOptions(shared, from, attributes.options as Record); }, onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), + // 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3) onPipeFacilitySelect: (chainage) => { - rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다. - if (selectionSyncing) return; - selectionSyncing = true; - try { - // null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18). - const station = - chainage === null - ? undefined - : nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m); - const id = station ? irregularStationId(station.id) : null; - viewer.markers.selectStation(id); - selectGraphAt(id, chainage); - syncBasinHighlight(id); - // 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화). - profilePanel.drainage.selectPipeAtChainage(chainage); - viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2) - } finally { - selectionSyncing = false; - } + rememberPick(chainage, null); + if (!selectionSyncing) profilePick.paint(); }, + onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3) onStructureSelect: (structure) => { // 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을 // 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문). if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null); - if (selectionSyncing) return; - selectionSyncing = true; - try { - profilePanel.setSelectedStructure(structure?.structure_id ?? null); - viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null); - } finally { - selectionSyncing = false; - } + // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2) + if (!selectionSyncing) profilePick.paint(); }, }, shared, ); - wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe); + // 3D 부재 고름도 측점 고름과 같은 거름 — 노면공 id 를 고름 한 칸에 적지 않음(231-1) + wireStructurePick( + viewer.structurePick, + activeProjectId, + panel.structures, + selectStationOfPipe, + (entry) => coversAtStation(bridge.typeGroup(entry.type_id)), + ); /** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의 * 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */ @@ -402,6 +390,17 @@ export async function mountProfileView( }, isRestoring: () => restoring, }); + // 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2) + const profilePick = bindProfilePick( + activeProjectId, + { profile: profilePanel, viewer, form: panel.structures }, + host, + { + irregular: () => bridge.irregularStations(), + pipes: () => lastPipes ?? [], + sections: () => currentSectionDetail?.cross_sections ?? [], + }, + ); // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); @@ -558,8 +557,7 @@ export async function mountProfileView( ); return; } - viewer.markers.selectStation(null); - profilePanel.setSelectedStation(null); + profilePick.paint(); }, // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. onHide: () => { diff --git a/A00_Common/spreadsheet/spreadsheet_area_table.ts b/A00_Common/spreadsheet/spreadsheet_area_table.ts index 660642655..ec86a2d38 100644 --- a/A00_Common/spreadsheet/spreadsheet_area_table.ts +++ b/A00_Common/spreadsheet/spreadsheet_area_table.ts @@ -8,7 +8,7 @@ * ========================================================================== */ import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address"; -import { cellStyleAt } from "./spreadsheet_cellstyle"; +import { cellStyleAt, shownHAlign } from "./spreadsheet_cellstyle"; import { createCalcEngine } from "./spreadsheet_graph"; import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers"; import { resolveName } from "./spreadsheet_names"; @@ -110,7 +110,7 @@ export function areaTable(ask: TableAsk) { : { 글: "", 정렬: "left" as const }; const key = `${sheet.id}!${toA1(r, c)}`; 값[key] = shown.글; - const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]); + const align: TableCell["align"] = ALIGN[shownHAlign(style.가로)] ?? "center"; // 안 준 칸 = 가운데(237-1) return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key }; }; const cells = rows.map((r) => diff --git a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts index 05c8fc686..31a380977 100644 --- a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts +++ b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts @@ -139,9 +139,9 @@ function borderCss(side: BorderSide | undefined, scale: number): string { } } -/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */ +/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). 안 준 칸(일반) = 가운데(237-1). */ const HALIGN_CSS: Record = { - general: "left", + general: "center", left: "left", right: "right", center: "center", @@ -149,13 +149,23 @@ const HALIGN_CSS: Record = { centerContinuous: "center", }; +/** 칸에 맞춤을 안 준(없음 · 일반) 보이는 가로 맞춤 = 가운데 — 화면 · 도구줄 · 대화상자 · 내보내기 공통(237-1). */ +export function shownHAlign(h: HAlign | undefined): HAlign { + return !h || h === "general" ? "center" : h; +} + +/** 칸에 맞춤을 안 준 보이는 세로 맞춤 = 가운데(237-1). */ +export function shownVAlign(v: VAlign | undefined): VAlign { + return v ?? "center"; +} + const VALIGN_CSS: Record = { top: "flex-start", center: "center", bottom: "flex-end", }; -/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). +/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 = 가운데). * `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */ export function applyCellStyle( cell: HTMLElement, @@ -178,7 +188,7 @@ export function applyCellStyle( s.borderLeft = borderCss(borders.왼, scale); s.borderBottom = borderCss(borders.아래, scale); s.borderRight = borderCss(borders.오른, scale); - s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"]; + s.justifyContent = VALIGN_CSS[shownVAlign(style.세로)]; s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap"; let text = cell.firstElementChild as HTMLElement | null; @@ -187,7 +197,7 @@ export function applyCellStyle( text.className = "aislo-cell__text"; cell.replaceChildren(text); } - text.style.textAlign = HALIGN_CSS[hAlign]; + text.style.textAlign = HALIGN_CSS[shownHAlign(hAlign)]; text.style.textAlignLast = hAlign === "distributed" ? "justify" : ""; } diff --git a/A00_Common/spreadsheet/spreadsheet_clipboard.ts b/A00_Common/spreadsheet/spreadsheet_clipboard.ts index 1eef6e5c0..9164ce8c4 100644 --- a/A00_Common/spreadsheet/spreadsheet_clipboard.ts +++ b/A00_Common/spreadsheet/spreadsheet_clipboard.ts @@ -14,6 +14,7 @@ import { showToast } from "@ui/ui_template_elements"; import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address"; +import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; import { clipWholeToUsed } from "./spreadsheet_grid_cells"; import { formatValue } from "./spreadsheet_numfmt"; import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift"; @@ -70,10 +71,11 @@ function styleToCss(style: CellStyle): string { if (deco) parts.push(`text-decoration:${deco}`); if (style.글자색) parts.push(`color:${style.글자색}`); if (style.채움) parts.push(`background-color:${style.채움}`); - if (style.가로 && style.가로 !== "general") { - parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`); - } - if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`); + // 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양 + const h = shownHAlign(style.가로); + parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`); + const v = shownVAlign(style.세로); + parts.push(`vertical-align:${v === "center" ? "middle" : v}`); if (style.줄바꿈) parts.push("white-space:pre-wrap"); if (style.테두리) { (["위", "아래", "왼", "오른"] as const).forEach((side) => { diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index 8cf4f81a8..b7a23f3a0 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -294,7 +294,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor { ta.style.fontSize = s.크기 ? `${s.크기}pt` : ""; ta.style.fontWeight = s.굵게 ? "bold" : ""; ta.style.fontStyle = s.기울임 ? "italic" : ""; - ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : ""; + ta.style.textAlign = s.가로 === "left" || s.가로 === "right" ? s.가로 : "center"; // 글이 길면 오른쪽 · 아래로 늘림(엑셀) ta.style.width = "0"; ta.style.height = "0"; diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts index 51a2b5769..66d199ee7 100644 --- a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts @@ -23,7 +23,7 @@ import { type NegStyle, } from "./spreadsheet_format_code"; import { dialogKeys } from "./spreadsheet_actions"; -import { isAutoInk } from "./spreadsheet_cellstyle"; +import { isAutoInk, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; import { formatValue } from "./spreadsheet_numfmt"; import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; @@ -233,7 +233,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void { ["centerContinuous", "선택 영역 가운데"], ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), }) as HTMLSelectElement; - hSelect.value = draft.가로 ?? "general"; + hSelect.value = shownHAlign(draft.가로); hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign)); const vSelect = el("select", { @@ -243,7 +243,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void { ["bottom", "아래"], ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), }) as HTMLSelectElement; - vSelect.value = draft.세로 ?? "bottom"; + vSelect.value = shownVAlign(draft.세로); vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign)); const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; diff --git a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts index a98943c5c..21160b525 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts @@ -13,6 +13,7 @@ import { isAutoInk, resolveBoxBorders, setCellText, + shownHAlign, type ResolvedBorders, } from "./spreadsheet_cellstyle"; import type { LineGeometry, OutlineRun } from "./spreadsheet_headers"; @@ -139,7 +140,7 @@ export function buildMergeMap(sheet: Sheet): Map { } const SPILL_ALIGN: Partial> = { - general: "left", + general: "center", left: "left", right: "right", center: "center", @@ -218,7 +219,7 @@ function cellDisplay( const scalar = scalarAt(ctx, sheet, r, c); const formatted = formatValue(scalar, code); const isError = typeof scalar === "object" && scalar !== null && "error" in scalar; - const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬; + const hAlign: HAlign = shownHAlign(hAlign0); // 안 준 칸 = 가운데(237-1) const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : ""; const isNumeric = !isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean"; diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index 5c450eade..d213b4a61 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -13,6 +13,7 @@ import { createButton, el } from "@ui/ui_template_elements"; import { confirmMerge } from "./spreadsheet_actions"; import { colName, rangeToA1, toA1 } from "./spreadsheet_address"; +import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; import { cellToText, textToCell } from "./spreadsheet_editor"; import { st } from "./spreadsheet_text"; import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types"; @@ -528,8 +529,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { strikeBtn.classList.toggle("is-on", !!style.취소선); textColor.value = style.글자색 ?? "#000000"; fillColor.value = style.채움 ?? "#ffffff"; - hAlignSelect.value = style.가로 ?? "general"; - vAlignSelect.value = style.세로 ?? "bottom"; + hAlignSelect.value = shownHAlign(style.가로); + vAlignSelect.value = shownVAlign(style.세로); wrapBtn.classList.toggle("is-on", !!style.줄바꿈); numfmtInput.value = style.형식 ?? ""; const range = ctx.selection.범위[0]; diff --git a/B01_Dashboard/B01_Dashboard_Map.py b/B01_Dashboard/B01_Dashboard_Map.py index 0c26c5f33..5897561a3 100644 --- a/B01_Dashboard/B01_Dashboard_Map.py +++ b/B01_Dashboard/B01_Dashboard_Map.py @@ -114,3 +114,30 @@ def _search_sync(query: str, size: int) -> list[dict[str, Any]]: async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]: return await asyncio.to_thread(_search_sync, query.strip(), size) + + +def _reverse_sync(lon: float, lat: float) -> str | None: + """좌표 → 주소 한 줄(VWorld getAddress · 도로명 먼저 · 없으면 지번).""" + query = urllib.parse.urlencode( + { + "service": "address", + "request": "getAddress", + "version": "2.0", + "crs": "epsg:4326", + "point": f"{lon},{lat}", + "type": "both", + "format": "json", + "key": VWORLD_API_KEY, + } + ) + try: + body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8")) + except Exception: + return None + items = (body.get("response") or {}).get("result") or [] + texts = {item.get("type"): item.get("text") for item in items if isinstance(item, dict)} + return texts.get("road") or texts.get("parcel") or None + + +async def reverse_geocode(lon: float, lat: float) -> str | None: + return await asyncio.to_thread(_reverse_sync, lon, lat) diff --git a/B01_Dashboard/B01_Dashboard_UI_AddressField.ts b/B01_Dashboard/B01_Dashboard_UI_AddressField.ts index 7a50998e4..595a03116 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AddressField.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AddressField.ts @@ -19,6 +19,8 @@ export interface AddressFieldHandle { root: HTMLElement; /** 저장할 한 줄 주소. 비면 null. */ value: () => string | null; + /** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */ + point: () => { lat: number; lon: number } | null; } /** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */ @@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { label: "지도 확인", variant: "ghost", onClick: async function onB01_Address_Map_Click() { - await map.show(base.input.value.trim()); + picked = await map.show(base.input.value.trim()); }, }); mapBtn.hidden = true; @@ -102,6 +104,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root); let manual = false; + let picked: { lat: number; lon: number } | null = null; function pick(item: AddressCandidate): void { base.input.value = candidateLine(item); @@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { zip.hidden = !item.zipcode; list.hidden = true; list.innerHTML = ""; + picked = { lat: item.lat, lon: item.lon }; map.showPoint(item.lat, item.lon); detail.input.focus(); } @@ -160,7 +164,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { }); // 이미 있는 주소는 열자마자 지도로 보여 준다. - if (initial && initial.trim()) void map.show(initial.trim()); + if (initial && initial.trim()) void map.show(initial.trim()).then((p) => (picked ??= p)); return { root, @@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim(); return merged || null; }, + point: () => picked, }; } diff --git a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts index ed616c670..cf8c71eec 100644 --- a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts +++ b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts @@ -32,7 +32,8 @@ function tileIndex( export function buildAddressMap(): { root: HTMLElement; - show: (address: string) => Promise; + /** 찾은 좌표를 돌려줌 — 못 찾으면 null. */ + show: (address: string) => Promise<{ lat: number; lon: number } | null>; /** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */ showPoint: (lat: number, lon: number) => void; } { @@ -110,17 +111,18 @@ export function buildAddressMap(): { note.textContent = message; } - const show = async (address: string): Promise => { + const show = async (address: string): Promise<{ lat: number; lon: number } | null> => { if (!address.trim()) { fail("주소를 먼저 입력하십시오."); - return; + return null; } const point = await geocodeAddress(address).catch(() => null); if (!point) { fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오."); - return; + return null; } draw(point.lat, point.lon); + return { lat: point.lat, lon: point.lon }; }; return { root, show, showPoint: draw }; diff --git a/B04_PreProcess/B04_PreProcess_UI_Basins.ts b/B04_PreProcess/B04_PreProcess_UI_Basins.ts index 251369cf8..39b93d257 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Basins.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Basins.ts @@ -40,6 +40,7 @@ import { resampleRoute, type RoutePoint, } from "./B04_PreProcess_UI_RouteSamples"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -296,7 +297,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl function selectBasinOfPipe(pipeIndex: number): void { const pipe = pipes[pipeIndex]; if (!pipe) return; - const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < 0.51); + const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < PIPE_MATCH_M); selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null; say(summary()); } diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 8e5117630..97668b447 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -15,7 +15,12 @@ import aiomysql from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile -from B05_Profile.B05_Profile_Engine_Sections import generate_irregular_sections +from B05_Profile.B05_Profile_Engine_Sections import ( + _load_pipe_points, + _load_route_polyline, + 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_Schema import RouteConfirmRequest @@ -350,17 +355,25 @@ async def _append_irregular_cross_sections( save_sampling_snapshot(project_root, request) stored_options = await get_latest_section_options(connection, project_id) crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id) - irregular_stations = await asyncio.to_thread( - generate_irregular_sections, - project_root, - str(route["route_data_path"]), - request.filter_key, - request.method, - request.smooth, - extra_stations=request.extra_stations(), - options=_section_options_from_stored(stored_options), - crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, - ) + route_data_path = str(route["route_data_path"]) + + def _generate() -> list[dict[str, Any]]: + # 측점 목록 = 서버 정본(관 · 구조물)에서 — 화면 목록(관 · A군)을 받아 적으면 서버가 심은 + # 소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 종료 측점이 통째로 빠짐(238-1). + polyline = _load_route_polyline(project_root, route_data_path) + extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline)) + return generate_irregular_sections( + project_root, + route_data_path, + request.filter_key, + request.method, + request.smooth, + extra_stations=extras, + options=_section_options_from_stored(stored_options), + crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, + ) + + irregular_stations = await asyncio.to_thread(_generate) if not irregular_stations: return longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts index 4e9983648..f412bcb3a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts @@ -12,6 +12,7 @@ import { FORD_PAVEMENT_DEFAULT_WIDTH_M, } from "@config/config_frontend"; import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; const FORD_KINDS: Record = { ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M, @@ -39,7 +40,7 @@ export function fillFordHeights( ): boolean { let filled = false; for (const wait of [...pending]) { - const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < 0.51); + const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < PIPE_MATCH_M); if (!point?.facility || !(point.facility in FORD_KINDS)) { pending.delete(wait); // 관이 사라졌거나 종류가 바뀜 continue; @@ -48,7 +49,9 @@ export function fillFordHeights( pending.delete(wait); // 사용자가 넣은 값 — 안 덮음 continue; } - const flow = basins.find((entry) => Math.abs(entry.chainage_m - point.chainage_m) < 0.51); + const flow = basins.find( + (entry) => Math.abs(entry.chainage_m - point.chainage_m) < PIPE_MATCH_M, + ); const width = Number(point.options?.ford_width_m) || FORD_KINDS[point.facility]; const need = fordHeightM(flow?.design_flow_m3s ?? null, width); if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 28f7f2d5f..3e5118aa8 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -1,5 +1,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; +import { viewOf } from "../common_util/common_util_view_window"; import { computeDetailBasins, fetchDetailPipePoints, @@ -29,6 +30,8 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows"; import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; @@ -172,6 +175,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let offsetX = 0; let offsetY = 0; let frameHandle = 0; + /** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */ + let frameView: Window = window; let loadSequence = 0; let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택 /** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */ @@ -284,24 +289,25 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage); /** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */ - function syncPipeSelection(): void { + function syncPipeSelection(notify = true): void { const index = pipeEditor.selected(); deleteButton.disabled = index === null; const picked = index === null ? null : pipeEditor.pipes()[index]; - selectBasin(basinIndexOfPipe(basins, picked ?? null)); + selectBasin(basinIndexOfPipe(basins, picked ?? null), notify); // 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은 // Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply) // 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게. const pickedChainage = picked ? picked.chainage_m : null; if (pickedChainage !== lastNotifiedPipeChainage) { lastNotifiedPipeChainage = pickedChainage; - callbacks.onPipeSelected?.(pickedChainage); + if (notify) callbacks.onPipeSelected?.(pickedChainage); } } function scheduleDraw(): void { if (frameHandle) return; - frameHandle = window.requestAnimationFrame(() => { + frameView = viewOf(root); + frameHandle = frameView.requestAnimationFrame(() => { frameHandle = 0; draw(); }); @@ -567,15 +573,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra movePipe(fromChainage, toChainage) { const index = pipeEditor .pipes() - .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < 0.51); + .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < PIPE_MATCH_M); if (index < 0) return; - pipeEditor.moveTo(index, toChainage); + const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게) + facilityStore.move(pipeEditor.pipes()[index].chainage_m, to); + pipeEditor.moveTo(index, to); }, selectBasinByChainage(chainageM) { const picked = chainageM === null ? null - : (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < 0.51) ?? null); + : (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < PIPE_MATCH_M) ?? null); // 밖에서 온 요청이므로 되돌려 보내지 않는다(그래프 ↔ 유역 순환 차단). selectBasin(picked ? picked.index : null, false); }, @@ -601,12 +609,16 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra scheduleDraw(); }, addPipe(chainageM, attributes) { + chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1) // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. facilityStore.set(chainageM, attributes ?? null); if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits.add(chainageM); pipeEditor.addAtChainage(chainageM); }, updatePipeFacility(fromChainageM, toChainageM, attributes) { + // 옮겼으면 정수 m · 속성 열쇠도 그 자리로(234-1) — 값만 고친 옛 소수 관은 제자리 + if (Math.abs(toChainageM - fromChainageM) > 0.005) + toChainageM = snapStructureStation(toChainageM); // ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임. const previous = facilityStore.get(fromChainageM); facilityStore.set(fromChainageM, null); @@ -617,7 +629,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra // attach가 새 위치로 시설 정보를 승계한다. const index = pipeEditor .pipes() - .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < 0.51); + .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M); if (index >= 0) pipeEditor.moveTo(index, toChainageM); else void analyze(); } else { @@ -630,21 +642,23 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra removePipe(chainageM) { const index = pipeEditor .pipes() - .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51); + .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M); if (index < 0) return; pipeEditor.select(index); pipeEditor.deleteSelected(); }, - selectPipeAtChainage(chainageM) { + selectPipeAtChainage(chainageM, notify = true) { const index = chainageM === null ? null - : pipeEditor.pipes().findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51); + : pipeEditor + .pipes() + .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M); const next = index === -1 ? null : index; if (pipeEditor.selected() === next) return; // select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다. pipeEditor.select(next); - syncPipeSelection(); + syncPipeSelection(notify); scheduleDraw(); }, async savePipes() { @@ -667,7 +681,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra dispose() { loadSequence += 1; if (frameHandle) { - window.cancelAnimationFrame(frameHandle); + frameView.cancelAnimationFrame(frameHandle); frameHandle = 0; } resizeObserver.disconnect(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 140f8d9e8..7ed1fe8b7 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -45,7 +45,8 @@ export interface DrainagePanel { selectBasinByChainage: (chainageM: number | null) => void; /** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다. * 이미 같으면 무시(2026-08-17 전역 선택 동기화). */ - selectPipeAtChainage: (chainageM: number | null) => void; + /** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(밖으로 안 알림 · 재계산 뒤 다시 얹음 · 233-3). */ + selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void; /** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */ markStation: (chainageM: number | null) => void; /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 3a9d17fab..88036c07d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -21,6 +21,7 @@ import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_U import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -352,7 +353,7 @@ export function reconcilePipes( return null; } return next.map((chainage) => { - const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < 0.51); + const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < PIPE_MATCH_M); return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" }; }); } @@ -516,7 +517,7 @@ export function createProgressReporter(progress: { /** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */ export function pipeMarkerColor(basins: ReadonlyArray, chainageM: number): string { - const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < 0.51); + const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < PIPE_MATCH_M); if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb"); return basinColor(basin.index).replace(/0\.45\)$/, "1)"); } @@ -527,7 +528,7 @@ export function basinIndexOfPipe( pipe: { chainage_m: number } | null, ): number | null { if (!pipe) return null; - const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51); + const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < PIPE_MATCH_M); return basin ? basin.index : null; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index 5ac77fbf0..864188844 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -1,6 +1,7 @@ import { themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; // 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제. // 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선 @@ -74,6 +75,9 @@ export function createPipeEditor( /** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다 * (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */ let dragDownAt: { x: number; y: number } | null = null; + /** 노선 안 · 관 기준 측점 = 정수 m(일반 측점 1 m 안이면 그 측점 · 234-1). */ + const onRoute = (chainageM: number): number => + Math.min(snapStructureStation(Math.max(0, Math.min(totalChainage, chainageM))), totalChainage); /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ function screenToMetric( @@ -199,14 +203,14 @@ export function createPipeEditor( moveTo(index, chainageM) { const pipe = pipeList[index]; if (!pipe) return; - pipe.chainage_m = Math.max(0, Math.min(totalChainage, chainageM)); + pipe.chainage_m = onRoute(chainageM); pipe.reason = "confirmed"; sortPipes(); onChange(); onCommit(); }, addAtChainage(chainageM) { - const clamped = Math.max(0, Math.min(totalChainage, chainageM)); + const clamped = onRoute(chainageM); pipeList.push({ chainage_m: clamped, reason: "confirmed" }); sortPipes(); selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6); @@ -224,18 +228,17 @@ export function createPipeEditor( if (!metric) return null; const nearest = nearestChainage(metric.x, metric.y); if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return null; - return Number(nearest.chainage.toFixed(2)); + return onRoute(nearest.chainage); }, addAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return false; const nearest = nearestChainage(metric.x, metric.y); if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false; - pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" }); + const at = onRoute(nearest.chainage); + pipeList.push({ chainage_m: at, reason: "confirmed" }); sortPipes(); - selectedIndex = pipeList.findIndex( - (pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6, - ); + selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - at) < 1e-6); onChange(); onCommit(); return true; @@ -271,8 +274,7 @@ export function createPipeEditor( if (!metric) return true; const nearest = nearestChainage(metric.x, metric.y); if (!nearest) return true; - const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage)); - pipeList[draggingIndex].chainage_m = clamped; + pipeList[draggingIndex].chainage_m = onRoute(nearest.chainage); pipeList[draggingIndex].reason = "confirmed"; dragMoved = true; onChange(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Report.ts b/B05_Profile/B05_Profile_UI_Drainage_Report.ts index d5596c4ca..ea809b3a2 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Report.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Report.ts @@ -7,6 +7,7 @@ import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. * 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, @@ -20,7 +21,7 @@ export function buildPipeReports( ): DrainagePipeReport[] { return pipes.map((pipe) => { const chainage = Math.round(pipe.chainage_m * 100) / 100; - const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51); + const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M); const attributes = facilityStore.get(chainage); return { chainage_m: chainage, diff --git a/B05_Profile/B05_Profile_UI_IrregularStations.ts b/B05_Profile/B05_Profile_UI_IrregularStations.ts index 798caaf8a..07e7b3e12 100644 --- a/B05_Profile/B05_Profile_UI_IrregularStations.ts +++ b/B05_Profile/B05_Profile_UI_IrregularStations.ts @@ -23,6 +23,7 @@ import { pickPipeDiameter, type StructureType, } from "@config/config_frontend"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; export interface IrregularStation { id: string; @@ -480,7 +481,9 @@ export function createIrregularStationsSection( // 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다. const restoredGhost = entry.structureType === undefined && - incomingChainages.some((chainage) => Math.abs(entry.chainage_m - chainage) < 0.51); + incomingChainages.some( + (chainage) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M, + ); if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1); } pipes.forEach((pipe) => { @@ -488,7 +491,7 @@ export function createIrregularStationsSection( const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null); const { station, remainder } = splitChainage(chainage); const kept = previousPipes.find( - (entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < 0.51, + (entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M, ); const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE; const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective); @@ -530,7 +533,9 @@ export function createIrregularStationsSection( callbacks.onChange([...stations]); }, removeByChainage(chainageM) { - const index = stations.findIndex((entry) => Math.abs(entry.chainage_m - chainageM) < 0.51); + const index = stations.findIndex( + (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, + ); if (index < 0) return null; const [removed] = stations.splice(index, 1); if (editingId === removed.id) loadForm(null); @@ -540,7 +545,7 @@ export function createIrregularStationsSection( }, moveByChainage(fromChainageM, toChainageM) { const index = stations.findIndex( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < 0.51, + (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); if (index < 0) return false; const { station, remainder } = splitChainage(toChainageM); diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index f6f4e1918..a32733979 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -397,6 +397,8 @@ export function createRouteMarkers( sprite.position.copy(position); sprite.position.y += 3.4; sprite.renderOrder = 10; + // 라벨은 읽기용 — 띠 가운데 위에 떠 누름을 가로채지 않음(232-1) + sprite.raycast = () => undefined; return sprite; } @@ -477,6 +479,8 @@ export function createRouteMarkers( head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다. head.position.y = PIN_HEIGHT + 1.7; group.add(beam, head); + // 핀은 표시만 — 누름을 가로채지 않음(띠 가운데 누름이 핀에 먼저 맞았음 · 232-1) + beam.raycast = head.raycast = () => undefined; group.renderOrder = 9; group.visible = false; interactionGroup.add(group); @@ -490,7 +494,8 @@ export function createRouteMarkers( if (center) selectionPin.position.copy(center); } - function selectStation(stationId: string | null): void { + /** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(알림 없음 · 233-2). */ + function selectStation(stationId: string | null, notify = true): void { selectedStationId = stationId; stationGroup.children.forEach((object) => { // 측점 띠(stationId 보유 Mesh)만 색을 바꾼다. @@ -499,7 +504,7 @@ export function createRouteMarkers( (object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15); }); syncSelectionPin(); - stationSelectionListener?.(selectedStationId); + if (notify) stationSelectionListener?.(selectedStationId); } return { @@ -520,7 +525,9 @@ export function createRouteMarkers( deleteSelected, selectObject(object: THREE.Object3D | undefined) { if (typeof object?.userData.stationId === "string") { - selectStation(object.userData.stationId); + // 같은 띠 다시 누름 = 풂(232-1) + const id = object.userData.stationId as string; + selectStation(id === selectedStationId ? null : id); selectionListener?.(null); return; } diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 8518459e2..70d0400fa 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -33,6 +33,7 @@ import type { createRoutePanel } from "./B05_Profile_UI_Panel"; 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"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -114,7 +115,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ); const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3))); gone.forEach((station) => { - const same = followedStation(station, gone, born); + // 없으면 맞춘 자리의 새 측점 — [정수 맞춤]이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1) + const same = followedStation(station, gone, born) ?? followSnapped(station, born); deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); }); irregularStations = stations; diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index ca7b2e808..8f748552f 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -69,6 +69,8 @@ interface PanelCallbacks { onPipeFacilityRemove: (chainageM: number) => void; /** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */ onPipeFacilitySelect: (chainageM: number | null) => void; + /** 목록 다시 누름 · 새로 놓기 = 풀기 한 길(233-3). */ + onListRelease?: () => void; /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; } @@ -104,6 +106,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh onPipeUpdate: callbacks.onPipeFacilityUpdate, onPipeRemove: callbacks.onPipeFacilityRemove, onPipeSelect: callbacks.onPipeFacilitySelect, + onListRelease: callbacks.onListRelease, }, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index e3cbe853c..b5c22166a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -12,6 +12,7 @@ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_stat import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights"; import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; +import { viewOf } from "../common_util/common_util_view_window"; const MIN_CHART_HEIGHT = 100; /** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. @@ -123,9 +124,12 @@ export interface ProfileDrag { export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { const { root, body, massHaul, tableOverlay } = ctx; let resizeTimer = 0; + /** 시계는 종단이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */ + let timerView: Window = window; const scheduleDraw = (): void => { - window.clearTimeout(resizeTimer); - resizeTimer = window.setTimeout(ctx.draw, 120); + timerView.clearTimeout(resizeTimer); + timerView = viewOf(root); + resizeTimer = timerView.setTimeout(ctx.draw, 120); }; // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. @@ -211,9 +215,9 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { mainDragCooldown = false; }, scheduleDraw, - cancelDraw: () => window.clearTimeout(resizeTimer), + cancelDraw: () => timerView.clearTimeout(resizeTimer), dispose() { - window.clearTimeout(resizeTimer); + timerView.clearTimeout(resizeTimer); heightResizer.dispose(); window.removeEventListener("pointerup", clearDragFlags); window.removeEventListener("pointercancel", clearDragFlags); diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index babaca90c..2c653d574 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -23,6 +23,7 @@ import { useDesignStore, } from "../A00_Common/design/design_store"; import { clearState } from "../A00_Common/b_page_state"; +import { viewOf } from "../common_util/common_util_view_window"; /** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */ const BUTTON_CLEARANCE_PX = 23; @@ -38,13 +39,18 @@ export const HOLD_INTERVAL_MS = 100; * 버튼이 아니라 이 컨트롤러가 window 이벤트로 들고 있어야 반복이 끊기거나 * 반대로 멈추지 않는 상황이 생기지 않는다. */ -function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } { +function createHoldRepeater(node: Node): { + start: (action: () => void) => void; + stop: () => void; +} { let delayTimer = 0; let repeatTimer = 0; + /** 시계는 버튼 층이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */ + let timerView: Window = window; function stop(): void { - window.clearTimeout(delayTimer); - window.clearInterval(repeatTimer); + timerView.clearTimeout(delayTimer); + timerView.clearInterval(repeatTimer); delayTimer = 0; repeatTimer = 0; window.removeEventListener("pointerup", stop); @@ -54,8 +60,9 @@ function createHoldRepeater(): { start: (action: () => void) => void; stop: () = function start(action: () => void): void { stop(); - delayTimer = window.setTimeout(() => { - repeatTimer = window.setInterval(action, HOLD_INTERVAL_MS); + timerView = viewOf(node); + delayTimer = timerView.setTimeout(() => { + repeatTimer = timerView.setInterval(action, HOLD_INTERVAL_MS); }, HOLD_DELAY_MS); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop); @@ -329,7 +336,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { layer.className = "b05-profile-edit"; layer.style.width = `${width}px`; - const repeater = createHoldRepeater(); + const repeater = createHoldRepeater(layer); const edited = new Set(Object.keys(alignment.edits.station_offsets)); /** diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index 24561487d..3771fdf1e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -11,6 +11,7 @@ import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; +import { frameOf } from "../common_util/common_util_view_window"; /** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */ const MIN_CHART_HEIGHT = 100; @@ -182,7 +183,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { function scheduleLightSync(): void { if (lightSyncPending) return; lightSyncPending = true; - requestAnimationFrame(() => { + frameOf(deps.body, () => { lightSyncPending = false; syncHeightsLight(); }); diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index a85a79bf7..0600b48e1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -56,6 +56,7 @@ import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { frameOf } from "../common_util/common_util_view_window"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ /* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ @@ -134,8 +135,6 @@ export interface RouteMassHaulDrawParams { widthPx: number; selectedStationId: string | null; onSelectStation: (stationId: string) => void; - /** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */ - onClearSelection?: () => void; /** * 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛 * 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정: @@ -235,7 +234,7 @@ export function createRouteMassHaulPanel( syncHandlePosition(); if (resizeRedrawPending) return; resizeRedrawPending = true; - requestAnimationFrame(() => { + frameOf(overlay, () => { resizeRedrawPending = false; onChanged(); }); @@ -249,13 +248,6 @@ export function createRouteMassHaulPanel( pendingChip.hidden = true; overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); - // 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시). - let clearSelection: (() => void) | undefined; - scroll.addEventListener("click", (event) => { - if ((event.target as HTMLElement).closest(".b06-chart__station")) return; - clearSelection?.(); - }); - // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. attachWheelHorizontalScroll(scroll); @@ -367,7 +359,6 @@ export function createRouteMassHaulPanel( function draw(params: RouteMassHaulDrawParams): void { lastParams = params; - clearSelection = params.onClearSelection; overlay.hidden = !open; syncHandlePosition(); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. @@ -488,9 +479,13 @@ export function createRouteMassHaulPanel( ); drawnOnce = true; // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. - scheduleMassHaulSettle(windowState, () => { - if (lastParams) draw(lastParams); - }); + scheduleMassHaulSettle( + windowState, + () => { + if (lastParams) draw(lastParams); + }, + overlay, + ); } return { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 61b8c5add..f8a1a2950 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -10,6 +10,7 @@ * ========================================================================== */ import { isCtrlKey } from "../A00_Common/key_shortcut"; +import { viewOf } from "../common_util/common_util_view_window"; import type { AlignmentBase, AlignmentEdits, @@ -30,8 +31,9 @@ import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { createFreeDraw } from "../A00_Common/design/design_profile_free"; import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; +import { stepStructureStation } from "../common_util/common_util_structure_station"; -/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ +/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ const KEY_STEP_M = 0.1; /** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */ @@ -233,8 +235,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해 // 0.1m 만 움직이고 멈춤(2026-09-04 실측). const fromM = holdChainageM ?? station.chainage_m; - const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3)); - if (next < 0) return false; + const next = stepStructureStation(fromM, key === "ArrowRight" ? 1 : -1); + if (next === fromM) return false; ctx.moveStation({ ...station, chainage_m: fromM }, next); holdStation = station; holdChainageM = next; @@ -254,11 +256,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { * 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */ const HOLD_SETTLE_MS = 700; let settleTimer = 0; + /** 시계는 패널이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */ + let timerView: Window = window; /** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */ function stopHold(): void { - window.clearTimeout(holdDelayTimer); - window.clearInterval(holdRepeatTimer); + timerView.clearTimeout(holdDelayTimer); + timerView.clearInterval(holdRepeatTimer); holdDelayTimer = 0; holdRepeatTimer = 0; if (heldKey === null) return; @@ -267,8 +271,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { holdStation = null; window.removeEventListener("keyup", onKeyUp); window.removeEventListener("blur", stopHold); - window.clearTimeout(settleTimer); - settleTimer = window.setTimeout(() => { + timerView.clearTimeout(settleTimer); + settleTimer = timerView.setTimeout(() => { settleTimer = 0; history.resume(); }, HOLD_SETTLE_MS); @@ -278,7 +282,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { function flushHold(): void { stopHold(); if (!settleTimer) return; - window.clearTimeout(settleTimer); + timerView.clearTimeout(settleTimer); settleTimer = 0; history.resume(); } @@ -292,8 +296,9 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { heldKey = key; window.addEventListener("keyup", onKeyUp); window.addEventListener("blur", stopHold); - holdDelayTimer = window.setTimeout(() => { - holdRepeatTimer = window.setInterval(() => { + timerView = viewOf(ctx.root); // 앞선 덩어리는 flushHold 가 이미 닫음 + holdDelayTimer = timerView.setTimeout(() => { + holdRepeatTimer = timerView.setInterval(() => { if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다. }, HOLD_INTERVAL_MS); }, HOLD_DELAY_MS); diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index af5042efd..b940a155a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -199,8 +199,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { const chartWrap = document.createElement("div"); chartWrap.className = "b05-profile__chart"; chartWrap.style.height = `${chartHeight}px`; - // 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시). - // 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation). + // 빈 곳 누름은 고름을 안 풂 — 풀기는 Esc · 같은 것 다시 누름 한 길(3D 와 같은 규칙 · 233-4). + // 도구([직선화] · [쉬프트] · [자유지정]) 중 빈 곳 누름은 그 도구 몫 그대로. chartWrap.addEventListener("click", (event) => { // [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면 // 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02). @@ -219,15 +219,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return; } } - if ((event.target as HTMLElement).closest(".b06-chart__station")) return; - if (ctx.selectedStationId() !== null) ctx.selectStation(null); - // 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지 - // 리셋되는 해제 신호가 여기서만 나갈 수 있다(2026-08-18 사용자 보고). - if (ctx.selectedStructureId() !== null) { - ctx.setSelectedStructureId(null); - callbacks?.onStructureSelect?.(null); - ctx.redraw(); - } }); const designProfiles = alignment ? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])] @@ -454,9 +445,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { widthPx: width, selectedStationId, onSelectStation: ctx.selectStation, - onClearSelection: () => { - if (ctx.selectedStationId() !== null) ctx.selectStation(null); - }, }; massHaul.draw(massHaulParams); // 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시 diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 6e2f67b27..5649dbd79 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -17,6 +17,7 @@ import { } from "./B05_Profile_UI_IrregularStations"; import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import type { PickGroup } from "./B05_Profile_UI_Structures_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 사이드 「구조물 배치」 군 · 종류 드롭다운 목록(`section.pickGroups()`)을 그대로 구조물군 → 종류 * 2단 메뉴 항목으로(129-1 — 종단그래프 · 배수유역도 · 3D · B06 종단 공용 · 가린 군 · 종류는 뺌). @@ -118,7 +119,7 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti } /** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */ -export const SAME_CHAINAGE_M = 0.51; +export const SAME_CHAINAGE_M = PIPE_MATCH_M; /** * 측점선 id ↔ 알약(구조물) id 짝짓기. diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index ea11fbcec..e206a08f0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -15,6 +15,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { frameOf } from "../common_util/common_util_view_window"; /* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ const HEIGHT_KEY = stateKey("table-height") ?? ""; @@ -87,7 +88,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa syncHandlePosition(); if (resizeRedrawPending) return; resizeRedrawPending = true; - requestAnimationFrame(() => { + frameOf(overlay, () => { resizeRedrawPending = false; onChanged(); }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index d174fc835..f1298db83 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -18,6 +18,8 @@ * 유토곡선·측점 테이블)이 함께 얻는다. * ========================================================================== */ +import { viewOf } from "../common_util/common_util_view_window"; + /** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 — * 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */ const EASE = 0.35; @@ -42,11 +44,13 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ let applied = -1; let frame = 0; + /** `frame` 을 받아 둔 창 — 걷을 때도 같은 창에서 걷는다. */ + let frameView: Window = window; /** 직전 프레임 시각 — 좁히는 양을 **흐른 시간**에 맞추는 데 쓴다. */ let lastFrameAt = 0; function stop(): void { - if (frame) cancelAnimationFrame(frame); + if (frame) frameView.cancelAnimationFrame(frame); frame = 0; target = -1; applied = -1; @@ -72,7 +76,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { const ratio = 1 - Math.pow(1 - EASE, elapsed / BASE_FRAME_MS); scroller.scrollLeft = scroller.scrollLeft + rest * ratio; applied = scroller.scrollLeft; - frame = requestAnimationFrame(step); + frame = frameView.requestAnimationFrame(step); } scroller.addEventListener( @@ -89,7 +93,8 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { target = next; if (!frame) { applied = scroller.scrollLeft; - frame = requestAnimationFrame(step); + frameView = viewOf(scroller); + frame = frameView.requestAnimationFrame(step); } event.preventDefault(); }, @@ -128,7 +133,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { }; scroller.addEventListener("click", swallow, { capture: true, once: true }); // 클릭이 안 오는 경우(휠 버튼 등)를 대비해 다음 프레임에 걷어낸다. - requestAnimationFrame(() => + viewOf(scroller).requestAnimationFrame(() => scroller.removeEventListener("click", swallow, { capture: true }), ); } @@ -164,7 +169,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { // 끄는 동안 글자가 파랗게 잡히지 않게 한다. 포인터 잡기를 미룬 뒤로 브라우저가 // 기본 글자 선택을 시작했다(2026-09-06 사용자 지적). 이미 잡힌 것도 푼다. scroller.style.userSelect = "none"; - window.getSelection()?.removeAllRanges(); + viewOf(scroller).getSelection()?.removeAllRanges(); } scroller.scrollLeft = dragLeft - dx; scroller.scrollTop = dragTop - dy; diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts index 585175a53..631e51bff 100644 --- a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -17,6 +17,7 @@ import { } from "../A00_Common/design/design_selection"; import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick"; import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; +import type { StructureInstance } from "./B05_Profile_Api_Structures"; /** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍에 구조물 id 가 붙는다. */ export interface StructurePickHandoff { @@ -53,18 +54,21 @@ export function wireStructurePick( structures: StructuresSection, /** 기존 선택 동기화(`selectStationOfPipe`) — 누가거리 하나로 전 화면을 맞춘다. */ selectStation: (chainageM: number | null) => void, + /** 측점이 덮어 열어도 되는 구조물 — 노면공은 뺌(측점선 · 카드 고름과 같은 거름 · 231-1). */ + coverable?: (entry: StructureInstance) => boolean, ): void { controls.onPick = (pick: StructurePick | null): void => { + // null = 같은 부재 다시 누름(풂) — 빈 곳 누름은 여기까지 안 옴(232-1) if (!pick) { selectDesign(projectId, NO_SELECTION, { from: "b05" }); selectStation(null); return; } const selection: DesignSelection = { chainageM: pick.chainageM, structureId: null }; - const { structureId } = structures.resolveAt(selection); + const { structureId } = structures.resolveAt(selection, coverable); selectDesign(projectId, { ...selection, structureId }, { partKey: pick.key, from: "b05" }); selectStation(pick.chainageM); - if (!structures.hasSelection()) structures.selectAt({ ...selection, structureId }); + if (!structures.hasSelection()) structures.selectAt({ ...selection, structureId }, coverable); }; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 95181eb53..d1bb93c0d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -10,6 +10,7 @@ import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station"; import { DEFAULT_BASIN_SHAPE } from "../B06_Section/B06_Section_UI_Cross_Culvert_Basin"; import { fillPipeHeights } from "../common_util/common_util_culvert_sets"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; export function field(labelText: string, input: HTMLElement): HTMLLabelElement { const wrapper = document.createElement("label"); @@ -141,7 +142,7 @@ export function stationFields( ): StationFields { const station = numberInput("1", "0"); station.placeholder = "측점"; - const remainder = numberInput("0.1", "0"); + const remainder = numberInput("1", "0"); // 기준 측점 = 정수 m(234-1) remainder.placeholder = "+m"; const pair = document.createElement("div"); pair.className = "b05-structure__station-pair"; @@ -195,7 +196,8 @@ export function stationFields( const valid = Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0; markInvalid(!valid); - return valid ? stationNo * intervalM + rest : null; + // 친 값은 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 안 고친 칸은 위에서 옛 값 그대로 + return valid ? snapStructureStation(stationNo * intervalM + rest) : null; }, write(chainageM, intervalM) { written = null; diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index f50e8df9a..5151d8e16 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -23,6 +23,7 @@ import { type SummaryItem, } from "../common_util/common_util_summary_item"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; import { containerTitle, @@ -229,7 +230,8 @@ export function renderStructureList(params: StructureListParams): void { } else { const { pipe } = row; const picked = - selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05; + selectedPipeChainage !== null && + Math.abs(selectedPipeChainage - pipe.chainage_m) < PIPE_MATCH_M; // 고름 찾는 거리 한 값(233-3) item.classList.toggle("is-selected", picked); station.textContent = formatStation(pipe.chainage_m, intervalM); item.addEventListener("click", () => params.onSelectPipe(pipe)); diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index a2a03a295..9f883cc85 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -22,6 +22,7 @@ import { import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon"; import { stationLabel as formatStation } from "../common_util/common_util_station"; +import { frameOf } from "../common_util/common_util_view_window"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 * 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여 @@ -268,7 +269,8 @@ function buildBalloon( return span; }), ); - requestAnimationFrame(() => flipIfClipped(balloon)); + // 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2). + queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon))); return balloon; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 2b7e97e5e..1240ec5d3 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -14,7 +14,6 @@ * ========================================================================== */ import { - structureAnchorM, type StructureInstance, type StructuresSectionOptions, type StructurePlacement, @@ -48,6 +47,7 @@ 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"; /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ @@ -363,7 +363,7 @@ export function createStructuresSection( if (tempPipeChainage === null) return; const chainage = tempPipeChainage; tempPipeChainage = null; - if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < 0.51) { + if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < PIPE_MATCH_M) { selectedPipeChainage = null; } callbacks.onPipeRemove(chainage); @@ -542,7 +542,13 @@ export function createStructuresSection( return { root, facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15) - listRoot: list, + listRoot: snapDock(list, { + 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) { @@ -560,10 +566,10 @@ export function createStructuresSection( // 이 배열을 참조로 받아 두므로, 새 배열로 바꾸면 그쪽이 옛 배열을 고쳐 // 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인). structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry }))); - // 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다. - // [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2). + // 실시간 반영 목록이면 폼 안 닫음(고치던 칸) · [저장] 뒤 같은 id · 관 폼은 그대로(53-2 · 233-3) const kept = structures.find((entry) => entry.structure_id === editingId) ?? null; - if (!applyingLive) loadForm(editingId === null ? null : kept); + if (!applyingLive && selectedPipeChainage === null) + loadForm(editingId === null ? null : kept); renderList(); }, getStructures: () => [...structures], @@ -587,12 +593,12 @@ export function createStructuresSection( syncButtons(); } } - if ( - selectedPipeChainage !== null && - !pipeFacilities.some( - (pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < PIPE_MATCH_M, - ) - ) { + const kept = pipeFacilities.find( + (pipe) => Math.abs(pipe.chainage_m - (selectedPipeChainage ?? NaN)) < PIPE_MATCH_M, + ); + if (kept) + selectedPipeChainage = kept.chainage_m; // 조금 옮겨진 관도 고름 따라감(233-3) + else if (selectedPipeChainage !== null) { // 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다. selectedPipeChainage = null; facilityOptions.setFacility(null); @@ -616,7 +622,8 @@ export function createStructuresSection( if (!pipe) return; loadPipeForm(pipe); }, - resolveAt: (selection) => resolveSelection(selection, pipeFacilities, structures), + resolveAt: (selection, coverable) => + resolveSelection(selection, pipeFacilities, structures, coverable), selectAt(selection, coverable) { const hit = resolveSelection(selection, pipeFacilities, structures, coverable); if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM); @@ -636,6 +643,7 @@ export function createStructuresSection( loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null); }, addAt(chainageM, typeId) { + chainageM = snapStructureStation(chainageM); // 정수 m · 일반 측점 붙기(234-1) const type = typeMap().get(typeId); if (!type) return; // 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서 @@ -669,21 +677,8 @@ export function createStructuresSection( moveById(structureId, toChainageM) { const index = structures.findIndex((entry) => entry.structure_id === structureId); if (index < 0) return false; - const target = structures[index]; - if (target.placement === "interval") { - // 구간은 길이를 유지한 채 통째로 옮긴다 — 기준점을 끌면 시작·종료가 따라온다. - const length = (target.end_m ?? 0) - (target.start_m ?? 0); - const anchorOffset = structureAnchorM(target) - (target.start_m ?? 0); - const start = toChainageM - anchorOffset; - structures[index] = { - ...target, - chainage_m: toChainageM, - start_m: start, - end_m: start + length, - }; - } else { - structures[index] = { ...target, chainage_m: toChainageM }; - } + // 구간은 길이를 유지한 채 통째로 — 기준 · 시작 · 끝 모두 정수 m(234-1) + structures[index] = movedStructure(structures[index], toChainageM); emit(); return true; }, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 5490be851..66e2b3966 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -22,6 +22,10 @@ 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 { readGroupItems, readSummaryItem, @@ -32,6 +36,7 @@ import type { PipeFacilityItem, StructuresCallbacks, } from "./B05_Profile_UI_Structures_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 패널이 쥔 값 중 폼 왕복·저장에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface StructuresCommitContext { @@ -168,7 +173,7 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte // 관으로 본다(다른 곳의 관 매칭 기준과 동일). const isTemp = ctx.tempPipeChainage() !== null && - Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < 0.51; + Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < PIPE_MATCH_M; if (isTemp) { ctx.setTempPipeChainage(pipe.chainage_m); ctx.setSelectedPipeChainage(pipe.chainage_m); @@ -304,8 +309,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis beforeInput?.focus(); return; } - start = Math.max(anchor - before, 0); - end = start + length; + // 시작 · 끝도 정수 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)); } 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 589623338..676d199a2 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -171,8 +171,11 @@ export interface StructuresSection { hasSelection: () => boolean; /** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */ selectById: (structureId: string | null) => void; - /** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). */ - resolveAt: (selection: DesignSelection) => ResolvedSelection; + /** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). `coverable` = selectAt 과 같음. */ + resolveAt: ( + selection: DesignSelection, + coverable?: (entry: StructureInstance) => boolean, + ) => ResolvedSelection; /** 고름 한 값으로 폼을 연다(관 → 구조물 순) — 어느 화면에서 골라도 같은 거리 · 같은 순서다. * 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. `coverable` = 덮어 열어도 되는 구조물(측점 고름 · 216-1). */ selectAt: ( diff --git a/B05_Profile/B05_Profile_UI_Structures_Snap.ts b/B05_Profile/B05_Profile_UI_Structures_Snap.ts new file mode 100644 index 000000000..3f29a57ac --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Snap.ts @@ -0,0 +1,160 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Snap.ts + * 구조물 기준 측점 정수 맞춤(234-1) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과 + * 기존 소수 자료를 옮기는 [정수 맞춤] 단추(좌측 구조물 목록 머리 · B05 · B06 한 벌). + * + * 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 단추는 확인 창 뒤 + * 구조물은 한 번에, 관은 하나씩 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집 + * 따라가기(213-2) · 시설 속성 이어받기가 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다. + * ========================================================================== */ + +import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { + isSnappedStation, + snapStructureStation, +} from "../common_util/common_util_structure_station"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; + +export { snapStructureStation }; +import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; +import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; +import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types"; + +/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤. */ +export function movedStructure(target: StructureInstance, toChainageM: number): StructureInstance { + const anchor = snapStructureStation(toChainageM); + 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; + return { + ...target, + chainage_m: Math.min(Math.max(anchor, start), end), + start_m: start, + end_m: end, + }; +} + +/** 사라진 측점의 계획고 편집이 따라갈 새 측점 — 맞춘 자리(0.006m 안 · 213-2 와 같은 폭)의 것. */ +export function followSnapped( + station: T, + born: ReadonlyArray, +): T | null { + const to = snapStructureStation(station.chainage_m); + return born.find((next) => Math.abs(next.chainage_m - to) < 0.006) ?? null; +} + +const offGrid = (value: number | null | undefined): boolean => + typeof value === "number" && !isSnappedStation(value); + +/** 기준 · 시작 · 끝 중 하나라도 맞춤 밖(소수)인 구조물인가. */ +export const structureOffGrid = (structure: StructureInstance): boolean => + offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m); + +export interface SnapPlan { + /** 목록 자리 · 옮긴 모양. */ + structures: Array<{ index: number; next: StructureInstance }>; + pipes: Array<{ from: number; to: number; pipe: PipeFacilityItem }>; + /** 맞춘 자리에 다른 관이 있어 그대로 둔 관 수. */ + skipped: number; +} + +/** 옮길 것 셈 — 구조물은 옮긴 모양, 관은 (옛 자리 → 새 자리) · 자리 겹친 관은 건너뜀. */ +export function snapPlan( + structures: ReadonlyArray, + pipes: ReadonlyArray, +): SnapPlan { + const moved = structures.flatMap((structure, index) => + structureOffGrid(structure) + ? [{ index, next: movedStructure(structure, structureAnchorM(structure)) }] + : [], + ); + 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)) + .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)) { + plan.skipped += 1; + return; + } + taken.push(to); + plan.pipes.push({ from: pipe.chainage_m, to, pipe }); + }); + return plan; +} + +/** 관 한 건의 지금 속성 그대로 — 폼 [수정]과 같은 꼴(비운 칸 = 지움 규칙에 옛 값이 다 실림). */ +export const pipeAttributes = (pipe: PipeFacilityItem): FacilityAttributes => ({ + facility: pipe.facility, + ...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}), + ...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}), + options: { ...(pipe.options ?? {}) }, + summary_item: pipe.summary_item ?? null, + group_items: pipe.group_items ?? null, +}); + +export interface SnapDockContext { + /** 패널이 쥔 구조물 배열(참조) — 옮긴 것을 제자리에 갈아 끼움. */ + structures: StructureInstance[]; + pipes: () => ReadonlyArray; + /** 목록이 바뀜 알림(한 번). */ + emit: () => void; + /** 관 한 건 옮김 — 폼 [수정]과 같은 길. */ + movePipe: (from: number, to: number, attributes: FacilityAttributes) => void; + /** 열린 폼을 닫음 — 옛 측점을 쥔 폼이 다음 [수정]에 되돌리지 않게. */ + 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 { + const plan = snapPlan(ctx.structures, ctx.pipes()); + const count = plan.structures.length + plan.pipes.length; + if (!count) { + showToast( + plan.skipped + ? `소수 측점 관 ${plan.skipped}건은 맞출 자리에 다른 관이 있어 그대로 둡니다.` + : "소수 기준 측점이 없습니다.", + "info", + ); + 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 ed8512694..846d3623c 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -650,3 +650,15 @@ .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/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index fde2c5705..bd13562a0 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -259,6 +259,7 @@ export function createRouteViewer(): RouteViewer { camera: cameraRig.camera, group: () => corridor.group(), blocked: () => markerInput.blocked(), + pressedMarker: () => markerInput.pressedMarker(), }); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts index afeb0ab9b..7ab032e7e 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -24,6 +24,8 @@ export interface MarkerPointerControls { beginMoveSelected: () => void; /** 마커를 잡고 있거나 이동 대기 중인가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */ blocked: () => boolean; + /** 마지막 누름이 마커 · 측점 띠에 맞았나 — 3D 구조물 고름이 그 누름을 「빈 곳」으로 읽지 않게(232-1). */ + pressedMarker: () => boolean; /** 등록한 캔버스 이벤트를 모두 떼어낸다. */ dispose: () => void; } @@ -43,6 +45,7 @@ export function bindMarkerPointerControls(options: { let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null; let draggingMarker = false; let lastDragPoint: { x: number; y: number; z: number } | null = null; + let downOnMarker = false; /** 화면 좌표 → 정규화 장치 좌표(-1~1). */ function pointerOf(clientX: number, clientY: number): THREE.Vector2 { @@ -86,7 +89,14 @@ export function bindMarkerPointerControls(options: { function markerHit(event: PointerEvent): THREE.Object3D | undefined { const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera()); - return raycaster.intersectObject(markers.group, true)[0]?.object; + // 숨은 것(꺼 둔 측점선 · 라벨)은 누름을 가로채지 않음 — three 레이캐스트는 visible 을 안 봄(232-1) + return raycaster.intersectObject(markers.group, true).find((entry) => shown(entry.object)) + ?.object; + } + + function shown(object: THREE.Object3D | null): boolean { + for (let node = object; node; node = node.parent) if (!node.visible) return false; + return true; } function finishMarkerInteraction(selectCandidate: boolean): void { @@ -105,6 +115,7 @@ export function bindMarkerPointerControls(options: { function handlePointerDown(event: PointerEvent): void { if (event.button !== 0) return; const hit = markerHit(event); + downOnMarker = !!hit; const pointId = markers.pointIdForObject(hit); if (pointId) { dragCandidate = { @@ -184,6 +195,7 @@ export function bindMarkerPointerControls(options: { movingSelected = true; }, blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + pressedMarker: () => downOnMarker, dispose: () => { canvas.removeEventListener("dragover", handleDragOver); canvas.removeEventListener("drop", handleDrop); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts index 1fab0d9d3..b48cd5216 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -20,7 +20,7 @@ export interface StructurePick { } export interface StructurePickControls { - /** 3D에서 골랐을 때 알린다. null = 빈 곳(지형·리본) 클릭으로 해제. */ + /** 3D에서 골랐을 때 알린다. null = 같은 부재 다시 누름으로 해제(빈 곳 누름은 안 알림 · 232-1). */ onPick?: (pick: StructurePick | null) => void; /** 좌측 패널·종단 그래프에서 고른 것을 3D 강조에 반영한다(부재키 없이 측점만). */ selectAtChainage: (chainageM: number | null) => void; @@ -47,8 +47,10 @@ export function bindStructurePick(options: { group: () => THREE.Object3D | null; /** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */ blocked: () => boolean; + /** 이 누름이 마커 · 측점 띠에 맞았나 — 참이면 그 누름은 마커 몫(빈 곳 누름 아님 · 232-1). */ + pressedMarker?: () => boolean; }): StructurePickControls { - const { canvas, camera, group, blocked } = options; + const { canvas, camera, group, blocked, pressedMarker } = options; let selected: StructurePick | null = null; let down: { x: number; y: number; pointerId: number } | null = null; @@ -94,7 +96,7 @@ export function bindStructurePick(options: { }); } - /** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=해제)이다. */ + /** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=빈 곳)이다. */ function pickAt(clientX: number, clientY: number): StructurePick | null { const root = group(); if (!root || !root.visible) return null; @@ -120,7 +122,7 @@ export function bindStructurePick(options: { function handleDown(event: PointerEvent): void { down = - event.button === 0 && !blockedBeforeDown && !blocked() + event.button === 0 && !blockedBeforeDown && !blocked() && !pressedMarker?.() ? { x: event.clientX, y: event.clientY, pointerId: event.pointerId } : null; } @@ -131,7 +133,14 @@ export function bindStructurePick(options: { if (!start || event.pointerId !== start.pointerId) return; if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return; if (blocked()) return; - selected = pickAt(event.clientX, event.clientY); + const pick = pickAt(event.clientX, event.clientY); + // 빈 곳(지형 · 리본) 누름은 고름을 안 풂(08-05 사용자 결정 · 232-1) — 같은 부재 다시 누름만 풂 + if (!pick) return; + const same = + selected !== null && + Math.abs(selected.chainageM - pick.chainageM) < 0.01 && + selected.key === pick.key; + selected = same ? null : pick; applyHighlight(); controls.onPick?.(selected); } diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 85fd95747..d194c8c29 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -39,6 +39,7 @@ import { pavementSpecsFrom, type PavementStructureInput, } from "@util/common_util_structure_pavement"; +import { setGeneralStations } from "../common_util/common_util_structure_station"; 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,6 +64,7 @@ async function withDraftWalls( detail: SectionDetailResponse, projectId: string, ): Promise { + setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1) const drafts = readPendingStructures(projectId); // ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이 // 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도 @@ -214,6 +216,7 @@ export function replaceSectionDetail( ): void { cache.set(keyOf(projectId, routeId), detail); writeState("section-detail", detail, projectId, routeId); + setGeneralStations(detail.longitudinal?.stations); } /** diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index b3e8540ec..2a5aca824 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -143,7 +143,7 @@ export function selectionSource( /* ── 모달 배선 ─────────────────────────────────────────────────────────── */ export interface DetailSelect { - /** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(고름 지움). */ + /** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(같은 자리 조각 고름은 유지 · 233-2). */ redraw: () => void; /** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */ setMode: (kind: "area" | "length" | null) => void; @@ -152,6 +152,10 @@ export interface DetailSelect { detach: () => void; } +/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */ +const pieceKey = (piece: SplitPiece | undefined): string => + piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : ""; + export function attachDetailSelect(input: { cad: HTMLElement; design: () => CrossDesign | undefined; @@ -209,10 +213,12 @@ export function attachDetailSelect(input: { return { redraw() { - selected = []; + // 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(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"); pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : []; + selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : [])); const toPx = axes ? mToPx(axes) : null; hits = toPx ? pieces.map((piece) => { diff --git a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts new file mode 100644 index 000000000..239649672 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * B06_Section_UI_Page_Pick_Sync.ts + * 횡단 보기 강조를 고름 한 칸에 묶음 (233-2 · 조사 AF (나) ④). + * + * 고름 한 칸(`structure-pick`)이 유일 원천이다. 횡단 보기가 보일 때 다른 곳(풀기 Esc 등)이 값을 + * 바꾸면 구독으로 받아 카드 · 범위 테두리(`--span`) · 포장층 · 벽 · 세월교 · BOX 형상 강조를 그 값으로 + * 다시 세운다 — 값이 비면 모두 풂, 값이 있으면 넘김 적용(`applyStructurePick`)과 같은 길로 다시 얹음. + * 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음. + * 풀기(Esc · 카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 · 범위 테두리 · + * 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4). + * ========================================================================== */ + +import { subscribeSelection } 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"; + +export interface CrossPickDeps { + sections: () => ReadonlyArray; + controls: Pick; + /** 좌측 폼(구조물 · 관) 닫기. */ + releaseForm: () => void; + view: { + focusStation: (stationId: string | null) => void; + refreshCards: (chainages: ReadonlyArray) => void; + selectMark: (structureId: string | null, span?: boolean) => void; + }; + /** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */ + reapply: () => void; +} + +let releasing = false; + +/** 횡단 보기 강조를 모두 끔 — 카드 · 벽 · 세월교 · BOX · 폼 관 · 범위 테두리 · 포장층(값은 안 건드림). + * 카드를 풀면 카드 리스너가 다시 풀기를 부름 — 되돌아온 부름은 건너뜀. */ +export function releaseCrossHighlights(deps: CrossPickDeps): void { + if (releasing) return; + releasing = true; + try { + releaseAll(deps); + } finally { + releasing = false; + } +} + +function releaseAll(deps: CrossPickDeps): void { + const { revetOffset, ford, box } = deps.controls; + const lit = deps + .sections() + .filter( + (s) => + revetOffset.highlightFor(s) || + ford.selectedFor(s.chainage_m) || + box.selectedFor(s.chainage_m), + ); + for (const s of lit) { + if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null); + if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null); + if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null); + } + deps.releaseForm(); + deps.view.focusStation(null); + deps.view.selectMark(null); + deps.view.refreshCards(lit.map((s) => s.chainage_m)); +} + +/** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음. + * `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */ +export function bindCrossPick( + projectId: string | null, + host: { visible: () => boolean; onDispose: (fn: () => void) => void }, + deps: CrossPickDeps, +): { release: () => void } { + host.onDispose( + subscribeSelection(projectId, (selection, from) => { + if (!host.visible() || from === "b06") return; + if (selection.chainageM === null) releaseCrossHighlights(deps); + else deps.reapply(); + }), + ); + bindEscapeRelease(projectId, host); // 풀기 한 길(233-3) + return { release: () => releaseCrossHighlights(deps) }; +} diff --git a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts index 4ec8b2bb4..28a495bf7 100644 --- a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts +++ b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts @@ -19,7 +19,7 @@ import type { RevetPick } from "./B06_Section_UI_Cross_Culvert"; import type { StructurePickHandoff } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { coversChainage, - isNear, + nearestWithin, type SelectableStructure, } from "../A00_Common/design/design_selection"; @@ -57,7 +57,8 @@ export function applyStructurePick( const covering = structures.find((entry) => coversChainage(entry, handoff.at)); const target = (byId && cardOfStructure(sections, byId, handoff.at)) || - sections.find((section) => isNear(section.chainage_m, handoff.at)) || + // 찾는 거리 안 가장 가까운 카드 — 첫 카드면 관 80.17 이 이웃 측점 80 카드로 감(231-1) + nearestWithin(sections, handoff.at, (section) => section.chainage_m) || (covering && cardOfStructure(sections, covering, handoff.at)); if (!target) return false; // 조정창은 앞칸(측 이름)만 본다 — 뒤칸은 3D 강조를 부재 하나로 좁히는 몫이다. @@ -83,7 +84,7 @@ export function applyStructurePick( type Sections = SectionDetailResponse["cross_sections"]; /** 구조물이 자리한 카드 — 구간형이면 그 안의 첫 카드, 점형이면 가장 가까운 카드. 옹벽 · 기슭막이는 - * 횡단 제원이라 시작 측점 카드가 주인이다(관은 `isNear` 로 카드를 바로 찾는다). */ + * 횡단 제원이라 시작 측점 카드가 주인이다(관은 `nearestWithin` 으로 카드를 바로 찾는다). */ function cardOfStructure( sections: Sections, structure: SelectableStructure, diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index e3c76208c..29bca82ca 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -48,6 +48,7 @@ import { type DesignSelection, } from "../A00_Common/design/design_selection"; import { showToast } from "@ui/ui_template_elements"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; import { adjustDockRoot, refreshAdjustSlots, @@ -89,8 +90,8 @@ export interface B06StructuresPanelDeps { focusChainage: (chainageM: number, revealOnly?: boolean) => void; /** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */ releaseCard?: () => void; - /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */ - markStructure?: (structureId: string | null) => void; + /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름 · span 끔 = 범위 테두리 없이 · 137-4). */ + markStructure?: (structureId: string | null, span?: boolean) => void; /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — @@ -167,6 +168,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group); /** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */ let preferredId: string | null = null; + /** 측점 고름(카드 하나 · 측점만 남은 고름 다시 세움)이 폼을 여는 중 — 폼 알림이 카드를 옮기지 않게(231-1). */ + let openingAtStation = false; /** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */ const focusOf = (structure: StructureInstance): number => { const anchor = structureAnchorM(structure); @@ -220,13 +223,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc const pave = !coverable(structure); const id = structure.structure_id ?? null; preferredId = id; - deps.focusChainage(focusOf(structure), pave); + // 측점 고름(카드 하나 · 다시 세움)이 연 폼 = 누른 카드 그대로 — 구조물 기준 카드로 옮겨 가지 않음(189-1 · 231-1) + if (!openingAtStation) deps.focusChainage(focusOf(structure), pave); preferredId = null; // 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2) - deps.markStructure?.(id); - // 카드 고름 리스너를 안 거치니 세션 고름은 여기서(B05 로 가도 그 노면공) + deps.markStructure?.(id, !openingAtStation); // 측점 고름이 연 것 = 카드 하나 · 범위 테두리 없음(231-1) + // 목록 · 알약 · 형상 고름은 늘 고름 한 칸에 그 구조물 — 같은 카드라 카드 리스너가 안 돌아도 + // 오감 · 새로 고침 뒤 그 폼(188-1 · 231-2) · 측점 고름이 연 것은 측점만(카드 리스너 몫 · 189-1) const at = { chainageM: focusOf(structure), structureId: id }; - if (pave) selectDesign(deps.projectId, at, { from: "b06" }); + if (!openingAtStation) selectDesign(deps.projectId, at, { from: "b06" }); }, getInterval: deps.stationInterval, // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. @@ -235,7 +240,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc showToast(PIPE_ADD_GUIDE, "error"); return; } - const at = Number(chainageM.toFixed(2)); + const at = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1) if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) { showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error"); return; @@ -259,11 +264,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다 // (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다. onPipeUpdate: (fromChainageM, toChainageM, attributes) => { - // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 - // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 - // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). - if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M) - movePipeTo(fromChainageM, toChainageM); + // 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1) + if (Math.abs(fromChainageM - toChainageM) > 0.005) movePipeTo(fromChainageM, toChainageM); if (attributes.summary_item !== undefined || attributes.group_items !== undefined) { // 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다. const mine = pipeFacilities.find( @@ -406,11 +408,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc const pick = readStructurePick(deps.projectId); const picked = currentChainageM ?? pick?.at ?? null; // 관이면 그 관, 옹벽 · 기슭막이 같은 구조물이면 저장된 구조물 id 로 폼을 세운다. + // 측점만 남은 고름은 노면공을 덮어 열지 않음 — 카드 고름과 같은 거름(152-5 · 231-1) if (picked !== null) { - section.selectAt({ - chainageM: picked, - structureId: currentChainageM === null ? (pick?.structureId ?? null) : null, - }); + const structureId = currentChainageM === null ? (pick?.structureId ?? null) : null; + openingAtStation = structureId === null; + try { + section.selectAt({ chainageM: picked, structureId }, coverable); + } finally { + openingAtStation = false; + } } } catch (error) { showToast( @@ -472,7 +478,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc showToast(PIPE_MOVE_GUIDE, "error"); return; } - const to = Number(toChainageM.toFixed(2)); + const to = snapStructureStation(toChainageM); // 정수 m · 일반 측점 붙기(234-1) const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); @@ -567,7 +573,14 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc section.selectPipeByChainage(hit.pipeChainageM); // 알약은 폼 뒤 — 폼이 연 구조물을 닫으며 알약을 끄므로(165-2 · 측점선 없는 물넘이포장도 53-2) deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); - } else section.selectById(hit.structureId); + } else { + openingAtStation = preferredId === null; + try { + section.selectById(hit.structureId); + } finally { + openingAtStation = false; + } + } return { chainageM, structureId: hit.structureId }; }, }; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index bc95835f8..88f3a9e64 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -418,10 +418,7 @@ export function createSectionView( // 적혀 있어 무엇을 보는지는 이미 분명하다. const grid = document.createElement("div"); grid.className = "b06-section__grid"; - // 카드 사이 빈 자리를 누르면 선택 해제 — 카드 안 클릭은 카드가 먼저 처리한다. - grid.addEventListener("click", (event) => { - if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection(); - }); + // 카드 사이 · 밖 빈 자리 누름은 고름을 안 풂 — 풀기는 Esc · 같은 카드 다시 누름(233-4) // 노면공 형상 누름 = 목록 고름(범위 카드 전부 · 형상 강조) — 카드 고름보다 먼저 가로챔(189-1) grid.addEventListener( "click", diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts index cfddee991..36f17d038 100644 --- a/B06_Section/B06_Section_UI_Section_View_Menu.ts +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -15,6 +15,7 @@ import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures"; import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; import { pickGroups } from "../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */ export interface SectionStructureEdit { @@ -57,7 +58,7 @@ export function moveMarkById( if (!station) return; // 측점선 id 로 온 경우 — 그 측점 자리에 선 구조물을 찾는다(관 매칭과 같은 0.51m). const near = input.structures.find( - (entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < 0.51, + (entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < PIPE_MATCH_M, ); if (near) moveStructureMark(input.structures, input.edit, String(near.structure_id), toChainageM); } diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 0df0e0f12..3cac3c62d 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -15,7 +15,7 @@ import { type DesignPageShared, } from "../A00_Common/design/design_shared"; import { readState } from "../A00_Common/b_page_state"; -import { coversChainage } from "../A00_Common/design/design_selection"; +import { coversChainage, PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; import type { CrossSection, @@ -40,7 +40,7 @@ import { } from "./B06_Section_UI_Section_View_Menu"; /** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */ -const MATCH_M = 0.51; +const MATCH_M = PIPE_MATCH_M; /** 종단 우클릭의 옛 자유 텍스트 구조물 → 레지스트리 타입(종단 보기와 같은 매핑). */ const LEGACY_TYPE_IDS: Record = { 기성막이: "revetment", diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py new file mode 100644 index 000000000..f77d85f6a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -0,0 +1,91 @@ +"""B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함. + +⚠ 짝 = `B08_Quantity_Milestone_Calc.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. + 조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈. + +표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 +시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · +포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · +끝수 = 소수 셋째 자리(반올림). +구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 +pick = 참(화면 「도로 구분 고르기」). +도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간 = 2차포장 · 1차포장 · 비포장 · +그 밖 = 포장 · 비포장 · +포장 = 2차 + 1차 + 포장. +""" + +from __future__ import annotations + +import math +from typing import Any + +ROADS = ("2차포장", "1차포장", "포장", "비포장") +KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"} + + +def r3(value: float) -> float: + """소수 셋째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음).""" + return math.floor(value * 1000 + 0.5) / 1000 + + +def leg_km(leg: dict[str, Any]) -> float | None: + """구간 km — 사용자 값 우선 · 없으면 자동 값.""" + km = leg.get("km") + 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 + 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 + + +def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: + """시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000.""" + station = (data.get("site") or {}).get("mid_station_m") + if station is None: + station = mid_default_m + return float(station or 0) / 1000 + + +def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list[dict[str, Any]]: + """표 줄 — 경로 차례 · 경로 안 줄 차례.""" + 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 + if row.get("dest") == "mid": + add_legs(sums, [mid]) + paved = sums["s2"] + sums["s1"] + sums["pv"] + out.append( + { + "route": route.get("id"), + "index": index, + "item": row.get("item") or "", + "company": route.get("name") or "", + "dest": row.get("dest") or "start", + "s2": r3(sums["s2"]), + "s1": r3(sums["s1"]), + "pv": r3(sums["pv"]), + "un": r3(sums["un"]), + "paved": r3(paved), + "unpaved": r3(sums["un"]), + "total": r3(paved + sums["un"]), + "note": row.get("note") or "", + "pick": pick, + } + ) + return out diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts new file mode 100644 index 000000000..50aa3ae78 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B08_Quantity_Milestone_Calc.ts + * B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림). + * + * ⚠ 짝 = `B08_Quantity_Milestone_Calc.py`(같은 식) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. + * 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈. + * + * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 + * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · + * 끝수 = 소수 셋째 자리(반올림) + * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 + * pick = 참(화면 「도로 구분 고르기」) + * 도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · + * 비포장 · 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장 · 포장 = 2차 + 1차 + 포장 + * ========================================================================== */ + +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차포장", "비포장"] : ["포장", "비포장"]; +/** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ +export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; +export type Dir = (typeof DIRS)[number]; +export type Dest = "start" | "mid"; + +export interface Leg { + /** null = 사용자가 안 고름(자동 판정 없음 · 244-7) */ + road: Road | null; + /** 사용자 값 — 있으면 우선 */ + km: number | null; + /** [길찾기] 자동 값(km 만 저장 · 경로 좌표 · 응답은 저장 안 함) */ + auto_km?: number | null; +} + +export interface MilestoneRow { + item: string; + dest: Dest; + note: string; +} + +export interface MilestoneRoute { + id: string; + /** 업체 이름 = 하위 탭 이름 */ + name: string; + address: string; + /** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */ + via_base: boolean; + dir: Dir | null; + lon?: number | null; + lat?: number | null; + /** 업체 → 기준점(거침) · 시점 구간 */ + legs: Leg[]; + rows: MilestoneRow[]; +} + +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[]; + }; + routes: MilestoneRoute[]; +} + +export interface MilestoneTableRow { + route: string; + index: number; + item: string; + company: string; + dest: Dest; + s2: number; + s1: number; + pv: number; + un: number; + paved: number; + unpaved: number; + total: number; + note: string; + /** 도로 구분 안 고른 구간이 있음 */ + pick: boolean; +} + +type Key = "s2" | "s1" | "pv" | "un"; +const KEYS: Record = { "2차포장": "s2", "1차포장": "s1", 포장: "pv", 비포장: "un" }; + +/** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */ +export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000; + +/** 구간 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; +} + +/** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */ +export function midKm(data: MilestoneData, midDefaultM: number | null): number { + const station = data.site?.mid_station_m ?? midDefaultM; + return Number(station ?? 0) / 1000; +} + +/** 표 줄 — 경로 차례 · 경로 안 줄 차례 */ +export function computeMilestone( + data: MilestoneData, + midDefaultM: number | null, +): MilestoneTableRow[] { + const base = data.base; + 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]); + const paved = sums.s2 + sums.s1 + sums.pv; + out.push({ + route: route.id, + index, + item: row.item ?? "", + company: route.name ?? "", + dest: row.dest ?? "start", + s2: r3(sums.s2), + s1: r3(sums.s1), + pv: r3(sums.pv), + un: r3(sums.un), + paved: r3(paved), + unpaved: r3(sums.un), + total: r3(paved + sums.un), + note: row.note ?? "", + pick, + }); + }); + } + return out; +} diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py new file mode 100644 index 000000000..ca6a9cff1 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -0,0 +1,184 @@ +"""B08 거리이정표 경로 그림 자동 캡처(PLAN 244-11 · 사용자 139번) — 서버 헤드리스 Edge. + +[등록] · [길찾기] 뒤 화면이 그 경로의 길 선(마지막 길찾기 경로 좌표) · 표지를 보냄 → +서버가 헤드리스 Edge(Playwright channel msedge · 없으면 chrome · 번들 chromium 안 씀)로 +카카오 지도 JS + 길 선 + 표지 + 경로 맞춤 → tilesloaded 뒤 1200×800 PNG → `<폴더>/<경로 id>.png` +(사용자 그림이 있으면 화면은 사용자 그림 우선). +페이지 주소 = .env KAKAO_CAPTURE_ORIGIN(없으면 http://localhost:5173) + /__route_capture — +page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · vite 안 떠도 됨). +경로 좌표는 그림 만든 뒤 버림(저장 = 그림 · km) · 동시 요청 = 하나씩(잠금) · 실패 = 까닭 한 줄 · +키 값은 돌려주지도 적지도 않음. +캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 · +키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가 +드라이버 하위 프로세스를 못 만듦(249-1). +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import threading +import time +import urllib.parse +from pathlib import Path +from typing import Any + +KAKAO_JS = "KAKAO_JS_KEY" +ORIGIN_KEY = "KAKAO_CAPTURE_ORIGIN" +DEFAULT_ORIGIN = "http://localhost:5173" +PAGE = "/__route_capture" +WIDTH, HEIGHT = 1200, 800 +TIMEOUT_S = 20 +CHILD_TIMEOUT_S = 25 # 자식 프로세스 전체(파이썬 · 드라이버 기동 포함) +CHILD = __file__ # 자식으로 돌릴 스크립트 +NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" +CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 +LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 +TAIL = " — 그림 올리기를 쓰세요." + +_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초) + + +class CaptureError(Exception): + """까닭 한 줄(키 · 예외 원문 없음).""" + + +def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str: + """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.""" + 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""" +
+ +""" + + +def shoot(html: str, origin: str) -> bytes: + """헤드리스 브라우저로 그 페이지 → 지도 칸 PNG. 실패 = CaptureError(까닭 한 줄).""" + try: + from playwright.sync_api import Error as PwError + from playwright.sync_api import TimeoutError as PwTimeout + from playwright.sync_api import sync_playwright + except ImportError as error: + raise CaptureError("서버에 Playwright 가 없습니다(pip install playwright)") from error + url = origin + PAGE + deadline = time.monotonic() + TIMEOUT_S + + def left() -> float: + return max(1.0, deadline - time.monotonic()) * 1000 + + try: + with sync_playwright() as p: + browser = None + for channel in CHANNELS: + try: + browser = p.chromium.launch(channel=channel, headless=True, timeout=left()) + break + except PwError: + continue + if browser is None: + raise CaptureError(NO_BROWSER) + try: + page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT}) + page.route( + url, + lambda route: route.fulfill( + status=200, content_type="text/html; charset=utf-8", body=html + ), + ) + page.goto(url, timeout=left()) + page.wait_for_function("window.__done || window.__fail", timeout=left()) + if page.evaluate("window.__fail"): + raise CaptureError( + f"카카오 지도 JS 가 캡처 주소({origin})를 받지 않음(키 도메인 등록 확인)" + ) + page.wait_for_timeout(500) # 마지막 타일 · 표지 그림 + return page.locator("#m").screenshot(timeout=left()) + finally: + browser.close() + except CaptureError: + raise + except PwTimeout as error: + raise CaptureError(f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남") from error + except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 + raise CaptureError(NO_BROWSER) from error + + +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"]) + except CaptureError as error: + return {"saved": False, "reason": str(error)} + out_dir = Path(job["out"]) + out_dir.mkdir(parents=True, exist_ok=True) + new = out_dir / f".{job['route_id']}.png.new" + new.write_bytes(png) + new.replace(out_dir / f"{job['route_id']}.png") + return {"saved": True, "reason": None} + + +def spawn(job: dict[str, Any]) -> dict[str, Any]: + """자식 파이썬 프로세스로 capture — 서버 이벤트 루프 정책과 무관(기본 Proactor).""" + try: + done = subprocess.run( + [sys.executable, CHILD], + input=json.dumps(job).encode(), + capture_output=True, + timeout=CHILD_TIMEOUT_S, + ) + except subprocess.TimeoutExpired: + return {"saved": False, "reason": f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남"} + except OSError: + return {"saved": False, "reason": NO_BROWSER} + try: + return json.loads(done.stdout.decode().strip().splitlines()[-1]) + except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음 + return {"saved": False, "reason": NO_BROWSER} + + +def make_route_image( + out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]] +) -> 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}", + } + if not any(lines) and not marks: + 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} + with _lock: + got = spawn({**job, "origin": origin}) + if not got.get("saved"): + return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"} + return {"saved": True, "reason": None} + + +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 new file mode 100644 index 000000000..12f3c648b --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -0,0 +1,569 @@ +/* ============================================================================= + * B08_Quantity_Milestone_Layout.ts + * 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림). + * + * 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 거래처들 + * 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면 + * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · + * 같은 방위 여럿 = 옆으로 나란히 + * 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5) + * 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 + * 화면 = 북 위(SVG y 아래로 = 남) + * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) + * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 + * 연결선 구간(244-13) = `segmentEnds` · 도로 구분 갈래 = `chainVia`(기준점 거침 = 2차 · 1차 · 비포장) + * 바탕(244-12) = 공사지 둘레 거래처 · 기준점 자리까지 담는 고정 범위 `BASE_VIEW` · 넘는 상자가 생길 때만 넓힘 + * 작은 글씨(244-12) = 시점 · 중점 주소 · 좌표(위도, 경도 소수 5자리) · 거래처 주소 — 길면 줄임표(`clip`) + 호버 + * 여유(244-12 ④) = 상자 사이 GAP(겹치면 바깥 방향으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 · + * 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표 + * ========================================================================== */ + +import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; + +export interface DiagramBox { + id: string; + kind: "site" | "start" | "mid" | "base" | "route"; + x: number; + y: number; + w: number; + h: number; + label: string; + /** 거래처 상자 윗줄 — 표 줄 항목(244-6 · 예 「장비」) */ + items?: string; + /** 이름 아래 작은 글씨 줄(244-12 · 주소 · 좌표) */ + notes?: string[]; + /** 호버 = 줄이지 않은 주소 */ + tip?: string; +} + +export interface DiagramLine { + from: string; + to: string; + x1: number; + y1: number; + x2: number; + y2: number; + labels: string[]; + /** 글마다 호버(사용자 km ≠ 자동 값이면 「자동 값 ○○ km」 · 아니면 "") */ + tips: string[]; + /** 선 글 자리(가운데 · 244-12 ④ 상자 · 다른 선 · 다른 글 피함) · 폭 · 높이 */ + lx: number; + ly: number; + lw: number; + lh: number; +} + +export interface DiagramLayout { + boxes: DiagramBox[]; + lines: DiagramLine[]; + view: [number, number, number, number]; +} + +type LonLat = [number, number] | null | undefined; + +/** 8방 → 방위각(북 0 · 시계 방향) */ +export const DIR_DEG: Record = { + N: 0, + NE: 45, + E: 90, + SE: 135, + S: 180, + SW: 225, + W: 270, + NW: 315, +}; +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; +export const BASE_VIEW: [number, number, number, number] = [ + -(REACH + BOX.w / 2 + PAD), + -(REACH + BOX.h / 2 + PAD), + 2 * (REACH + BOX.w / 2 + PAD), + 2 * (REACH + BOX.h / 2 + PAD), +]; + +/** 여유(244-12 ④) — 상자 사이 · 선 끝 · 상자 안 글 */ +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 }; +const KM_LINE = 16; + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** 글 폭 어림 — 한글 · 한자 = 글 크기 · 나머지 = 0.6 배 */ +export const textW = (text: string, size: number): number => + [...text].reduce( + (w, ch) => + w + (/[\u1100-\u11ff\u3130-\u318f\u3400-\u9fff\uac00-\ud7a3]/.test(ch) ? 1 : 0.6) * size, + 0, + ); + +/** 한 줄 — 폭 넘으면 줄임표 */ +export function clip(text: string, maxW: number, size: number): string { + if (textW(text, size) <= maxW) return text; + let out = ""; + for (const ch of text) { + if (textW(`${out}${ch}…`, size) > maxW) break; + out += ch; + } + return `${out}…`; +} + +/** 상자 작은 글씨 — 주소(줄임) · 좌표(위도, 경도 소수 5자리) */ +const notesOf = (w: number, address: string | null | undefined, lonlat?: LonLat): string[] => [ + ...(address ? [clip(address, w - 2 * INSET, FONT.note)] : []), + ...(lonlat ? [`${lonlat[1].toFixed(5)}, ${lonlat[0].toFixed(5)}`] : []), +]; + +/** 두 네모가 gap 안으로 붙거나 겹침 */ +export const hits = (a: Rect, b: Rect, gap = 0): boolean => + a.x < b.x + b.w + gap && b.x < a.x + a.w + gap && a.y < b.y + b.h + gap && b.y < a.y + a.h + gap; + +/** 선분이 네모에 들어가는 자리(0~1 · Liang–Barsky) · 안 지나면 null */ +function segEnter(x1: number, y1: number, x2: number, y2: number, r: Rect): number | null { + let t0 = 0; + let t1 = 1; + const dx = x2 - x1; + const dy = y2 - y1; + for (const [p, q] of [ + [-dx, x1 - r.x], + [dx, r.x + r.w - x1], + [-dy, y1 - r.y], + [dy, r.y + r.h - y1], + ]) { + if (p === 0) { + if (q < 0) return null; + continue; + } + const t = q / p; + if (p < 0) t0 = Math.max(t0, t); + else t1 = Math.min(t1, t); + if (t0 > t1) return null; + } + return t0; +} + +/** 선분이 네모를 지남 */ +export const segHits = (x1: number, y1: number, x2: number, y2: number, r: Rect): boolean => + segEnter(x1, y1, x2, y2, r) !== null; + +/** 상자 가운데 → 테두리까지 = 선 방향(dx, dy)의 몇 배 */ +const toEdge = (b: Rect, dx: number, dy: number): number => + Math.min(dx ? b.w / 2 / Math.abs(dx) : Infinity, dy ? b.h / 2 / Math.abs(dy) : Infinity); + +/** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */ +export function bearingDeg(from: [number, number], to: [number, number]): number { + const dx = (to[0] - from[0]) * Math.cos((((from[1] + to[1]) / 2) * Math.PI) / 180); + const dy = to[1] - from[1]; + return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360; +} + +/** 자리 방위 — 경위도 둘 다 → 방위각 · 아니면 8방 · 없으면 null */ +export function placeDeg( + own: { dir: Dir | null; lon?: number | null; lat?: number | null }, + anchor: LonLat, +): number | null { + if (own.lon != null && own.lat != null && anchor) return bearingDeg(anchor, [own.lon, own.lat]); + return own.dir ? DIR_DEG[own.dir] : null; +} + +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`; + }); + +/** 자동 값 호버 — 사용자 km 가 자동 값과 다를 때만 */ +export const legTip = (leg: Leg): string => + leg.auto_km != null && leg.km != null && leg.km !== leg.auto_km + ? `자동 값 ${leg.auto_km} km` + : ""; + +/** [길찾기] 자동 km → 그 구간 첫 줄(없으면 도로 구분 안 고른 줄 하나) · 사용자 km 는 그대로 */ +export function setAutoKm(legs: Leg[], km: number): Leg[] { + if (!legs.length) legs.push({ road: null, km: null, auto_km: km }); + else legs[0].auto_km = km; + return legs; +} + +export interface KmSegment { + /** route = 거래처 구간 · base = 기준점 → 시점 공통 구간 */ + key: "route" | "base"; + text: string; + from: [number, number] | null; + to: [number, number] | null; +} + +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(시점 → 중점 = 손 입력 · 길찾기 안 함) */ +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; + return { + key: "route", + text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, + from: pointOf(route), + to: viaBase ? pointOf(data.base) : (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; +} + +/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점 + * (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */ +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]; +} + +/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */ +export function mapMarks( + data: MilestoneData, + routeId: string, + start: LonLat, +): Array<{ name: string; lon: number; lat: number }> { + const route = data.routes.find((r) => r.id === routeId); + if (!route) return []; + const out: Array<{ name: string; lon: number; lat: number }> = []; + const add = (name: string, p: [number, number] | null): void => { + 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)); + 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; + if (legs) setAutoKm(legs, km); + return data; +} + +const unit = (deg: number): [number, number] => { + const rad = (deg * Math.PI) / 180; + return [Math.sin(rad), -Math.cos(rad)]; +}; + +/** 선 = 두 상자 테두리에서 끊김(④ · 상자 안으로 안 들어감) · end = 끝 테두리(바깥 → 시점 = 공사지 바탕 테두리 · + * 바탕 안 중점 · 시점 상자를 가로지르지 않음) · 글 자리는 `placeLabels` */ +function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): DiagramLine { + const [ax, ay] = [a.x + a.w / 2, a.y + a.h / 2]; + const [bx, by] = [b.x + b.w / 2, b.y + b.h / 2]; + const [dx, dy] = [bx - ax, by - ay]; + const len = Math.hypot(dx, dy) || 1; + 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); + return { + from: a.id, + to: b.id, + x1: ax + dx * t1, + y1: ay + dy * t1, + x2: ax + dx * t2, + y2: ay + dy * t2, + labels, + 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, + }; +} + +export const labelRect = (ln: DiagramLine): Rect => ({ + x: ln.lx - ln.lw / 2, + y: ln.ly - ln.lh / 2, + w: ln.lw, + h: ln.lh, +}); + +/** 선 글 자리(④) — 선 위 가운데부터 앞뒤 · 위아래 차례로 보아 상자 · 다른 선 · 다른 글을 안 덮는 첫 자리 + * (시점 → 중점 글은 공사지 바탕 안이 제자리) · 다 덮이면 가장 적게 덮는 자리 */ +function placeLabels(lines: DiagramLine[], boxes: DiagramBox[]): void { + const placed: Rect[] = []; + for (const ln of lines) { + if (!ln.labels.length) continue; + const [dx, dy] = [ln.x2 - ln.x1, ln.y2 - ln.y1]; + const len = Math.hypot(dx, dy) || 1; + const [px, py] = [-dy / len, dx / len]; + const side = (Math.abs(px) * ln.lw + Math.abs(py) * ln.lh) / 2 + 4; + const score = (r: Rect): number => + boxes.filter((b) => !(b.kind === "site" && ln.from === "start") && hits(r, b, 2)).length + + lines.filter((o) => o !== ln && segHits(o.x1, o.y1, o.x2, o.y2, r)).length + + placed.filter((p) => hits(r, p, 2)).length; + let best: { r: Rect; s: number; x: number; y: number } | null = null; + for (const t of [0.5, 0.4, 0.6, 0.3, 0.7, 0.2, 0.8]) { + for (const o of [0, side, -side]) { + const x = ln.x1 + dx * t + px * o; + const y = ln.y1 + dy * t + py * o; + const r = labelRect({ ...ln, lx: x, ly: y }); + const s = score(r); + if (!best || s < best.s) best = { r, s, x, y }; + if (s === 0) break; + } + if (best?.s === 0) break; + } + ln.lx = best!.x; + ln.ly = best!.y; + placed.push(best!.r); + } +} + +export function layoutMilestone( + data: MilestoneData, + site: { + start_lonlat?: LonLat; + mid_lonlat?: LonLat; + start_address?: string | null; + mid_address?: string | null; + } | null, + midKm: number, +): DiagramLayout { + const boxes: DiagramBox[] = []; + const pending: Array<[string, string, Leg[]]> = []; + const byId = new Map(); + const center = (id: string): [number, number] => { + const b = byId.get(id)!; + return [b.x + b.w / 2, b.y + b.h / 2]; + }; + /** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */ + 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++) { + box.x += out[0] * 4; + box.y += out[1] * 4; + } + boxes.push(box); + byId.set(box.id, box); + }; + const line = (from: string, to: string, legs: Leg[]): void => { + pending.push([from, to, legs]); + }; + + // 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) · 사이 = 시점 → 중점 선 · 글 + put({ + id: "site", + kind: "site", + x: -SITE.w / 2, + y: -SITE.h / 2, + w: SITE.w, + h: SITE.h, + label: "공사지", + }); + for (const [id, label, x, address, lonlat] of [ + ["start", "시점", -SITE.w / 2 + 12, site?.start_address, site?.start_lonlat], + ["mid", "중점", SITE.w / 2 - 12 - NODE.w, site?.mid_address, site?.mid_lonlat], + ] as const) { + put({ + id, + kind: id, + x, + y: -SITE.h / 2 + 34, + w: NODE.w, + h: NODE.h, + label, + notes: notesOf(NODE.w, address, lonlat), + tip: address ?? undefined, + }); + } + 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; + put( + { + id: "base", + kind: "base", + x: cx - BOX.w / 2, + y: cy - BOX.h / 2, + w: BOX.w, + h: BOX.h, + label: data.base.name || "기준점", + }, + [ux, uy], + ); + line("base", "start", data.base.legs); + } + + // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 + const groups = new Map< + string, + Array<{ + id: string; + label: string; + items: string; + address: string; + anchor: string; + deg: number; + legs: Leg[]; + }> + >(); + 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) }; + }); + 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(" · "); + groups.get(key)!.push({ + id: r.id, + label: r.name || "거래처", + items, + address: r.address, + anchor, + deg: d, + legs: r.legs, + }); + } + for (const group of groups.values()) { + group.forEach((g, k) => { + const [ax, ay] = center(g.anchor === "base" ? "base" : "site"); + const [ux, uy] = unit(g.deg); + // 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④) + const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP; + const off = (k - (group.length - 1) / 2) * spread; + const reach = g.anchor === "site" ? R_BASE : R_ROUTE; // 공사지 둘레 = 기준점과 같은 거리(선 글 자리) + const cx = ax + ux * reach - uy * off; + const cy = ay + uy * reach + ux * off; + put( + { + id: g.id, + kind: "route", + x: cx - BOX.w / 2, + y: cy - BOX.h / 2, + w: BOX.w, + h: BOX.h, + label: g.label, + items: g.items, + notes: notesOf(BOX.w, g.address), + tip: g.address, + }, + [ux, uy], + ); + line(g.id, g.anchor === "base" ? "base" : "start", g.legs); + }); + } + + const lines = pending.map(([from, to, legs]) => + edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!), + ); + placeLabels(lines, boxes); + + // 바탕 = 고정 범위 · 넘는 상자가 있을 때만 그쪽으로 넓힘(새 상자가 와도 다시 줌아웃 없음) + const [bx, by, bw, bh] = BASE_VIEW; + const xs = [bx + PAD, bx + bw - PAD, ...boxes.flatMap((b) => [b.x, b.x + b.w])]; + const ys = [by + PAD, by + bh - PAD, ...boxes.flatMap((b) => [b.y, b.y + b.h])]; + const minX = Math.min(...xs) - PAD; + const minY = Math.min(...ys) - PAD; + return { + boxes, + lines, + view: [minX, minY, Math.max(...xs) + PAD - minX, Math.max(...ys) + PAD - minY], + }; +} + +/** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/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"(시점 → 중점) */ +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; +} + +/** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */ +export function setLeg( + data: MilestoneData, + from: string, + i: number, + patch: { road?: Leg["road"]; km?: number | null }, +): MilestoneData { + if (from === "start") { + if (patch.road) data.site.mid_road = patch.road; + if (patch.km !== undefined) + 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]; + if (leg) Object.assign(leg, patch); + return data; +} + +/** 다이어그램 팬 시작 — 가운데 버튼 · 빈 곳 왼쪽 끌기(상자 · 선 글 · [+] 위는 그 화면 고르기 몫 · 244-6) */ +export const PAN_SKIP = + ".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus, .b08-milestone__line-hit"; +export function panStart(e: { button: number; target: EventTarget | null }): boolean { + if (e.button === 1) return true; + const target = e.target as { closest?: (selector: string) => unknown } | null; + return e.button === 0 && !target?.closest?.(PAN_SKIP); +} + +/** 팬 한계 — 원배율에서도 그림 둘레 한 폭씩 끌어 옮길 수 있게(공용 팬은 이 범위까지 엶) */ +export const panContent = (w: number, h: number) => ({ x: -w, y: -h, width: 3 * w, height: 3 * h }); diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index d8e6626cb..e0bb13ba7 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -219,7 +219,7 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any]) " ".join(filter(None, (names[k]["name"], names[k]["spec"], names[k]["unit"]))) for k in lost ) - notes.append(f"2공구 열 맞춤 없음 {len(lost)}종(총괄 끝 줄 그대로) — {listed}") + notes.append(f"2공구 열 맞춤 없음 {len(lost)}종 — {listed}") return { "source": source, "overrides": overrides, diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py new file mode 100644 index 000000000..01f281924 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -0,0 +1,440 @@ +"""B08 거리이정표 길(PLAN 244-1) — 공사지 · 기준점 · 경로(업체) · 표 · 경로 그림. DB 표 없음. + + GET /api/projects/{id}/quantity/milestone {data, site, table, images} + PUT /api/projects/{id}/quantity/milestone [저장] — 입력만 · 표는 서버가 셈 + POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체) + GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기 + DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기(자동 그림까지) · + &user_only=true = 사용자 그림만(「자동 그림으로 되돌리기」 · 244-11) + POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 · + 표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` · + 좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄 + GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · + 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램 + 지금 중점 측점(244-12 · 상자 작은 글씨) + POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 + 길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함) + + 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함) + GET /api/projects/{id}/quantity/milestone/reverse?lon=&lat= 지도에서 고른 소재지(244-13) → + VWorld 주소 한 줄(서버 단독 · 저장 안 함 · 못 찾음 = null) + GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 · + 공개용 · .env KAKAO_JS_KEY · 없으면 null) + +정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 사용자 +그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자 +그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님). +그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움. +공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 +(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). +표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). +""" + +from __future__ import annotations + +import asyncio +import json +import math +import os +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Annotated, Any, Literal +from uuid import UUID + +from fastapi import APIRouter, 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_Router_Hydro import _project_root +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 ( + expected_route_csv_path, + interpolate_vertex, + planned_route_initial_path, + planned_route_working_path, + read_planned_route_csv, +) + +router = APIRouter(prefix="/api/projects", tags=["B08 Milestone"]) +PATH = "/{project_id}/quantity/milestone" +FOLDER = Path("B08_Quantity") / "milestone" +DATA_FILE = "milestone.json" +AUTO = "auto" # 자동 그림 하위 폴더(244-11) +IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") +ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" + +Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13) +Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + + +class Leg(BaseModel): + road: Road | None = "2차포장" # None = 사용자가 안 고름(244-7 · 표에 「도로 구분 고르기」) + km: float | None = Field(default=None, ge=0, le=10000) # 사용자 값 — 있으면 우선 + auto_km: float | None = Field(default=None, ge=0, le=10000) # [길찾기] 자동 값(km 만 저장) + + +class Row(BaseModel): + item: str = Field(default="", max_length=100) + dest: Literal["start", "mid"] = "start" + note: str = Field(default="", max_length=200) + + +class Place(BaseModel): + name: str = Field(default="", max_length=100) + address: str = Field(default="", max_length=200) + dir: Dir | None = None + lon: float | None = Field(default=None, ge=-180, le=180) + lat: float | None = Field(default=None, ge=-90, le=90) + legs: list[Leg] = Field(default_factory=list, max_length=20) + + +class Route(Place): + id: str = Field(pattern=ROUTE_ID) + via_base: bool = True + rows: list[Row] = Field(default_factory=list, max_length=50) + + +class Base(Place): + enabled: bool = False + + +class Site(BaseModel): + mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000) + mid_road: Road = "비포장" + + +class Milestone(BaseModel): + site: Site = Field(default_factory=Site) + base: Base = Field(default_factory=Base) + routes: list[Route] = Field(default_factory=list, max_length=100) + + @model_validator(mode="after") + def _unique_ids(self) -> Milestone: + ids = [route.id for route in self.routes] + if len(ids) != len(set(ids)): + raise ValueError("경로 id 가 겹칩니다.") + return self + + +def folder(root: str | Path) -> Path: + return Path(root) / FOLDER + + +def read_data(root: str | Path) -> dict[str, Any]: + """저장본 — 없거나 깨졌으면 빈 꼴.""" + path = folder(root) / DATA_FILE + try: + return Milestone.model_validate_json(path.read_text(encoding="utf-8")).model_dump() + except (OSError, ValidationError, json.JSONDecodeError): + return Milestone().model_dump() + + +def _route_vertices(root: Path) -> list[Any]: + for path in ( + planned_route_working_path(root), + planned_route_initial_path(root), + expected_route_csv_path(root), + design_route_csv_path(root), + ): + route = read_planned_route_csv(path) if path.is_file() else None + if route is not None and len(route.vertices) >= 2: + return route.vertices + return [] + + +def _lonlat(root: Path, x: float, y: float) -> list[float] | None: + """작업 좌표계 → 경도 · 위도(다이어그램 방위 · 244-3 지도) — 못 하면 None.""" + try: + from pyproj import Transformer + + from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + + crs = project_epsg_from_prj(root) + lon, lat = Transformer.from_crs(crs, "EPSG:4326", always_xy=True).transform(x, y) + return [round(lon, 7), round(lat, 7)] + except Exception: # 좌표계 못 읽음 = 좌표 없음(방위는 사용자 8방) + return None + + +def site_info(root: str | Path, mid_station_m: float | None) -> dict[str, Any]: + """노선 연장 · 중점 기본(연장 ÷ 2) · 시점 · 중점 경위도.""" + root = Path(root) + vertices = _route_vertices(root) + if not vertices: + return {"length_m": None, "mid_default_m": None, "start_lonlat": None, "mid_lonlat": None} + length = vertices[-1].chainage_m + mid_default = r3(length / 2) + mx, my, _ = interpolate_vertex( + vertices, mid_default if mid_station_m is None else mid_station_m + ) + return { + "length_m": r3(length), + "mid_default_m": mid_default, + "start_lonlat": _lonlat(root, vertices[0].x, vertices[0].y), + "mid_lonlat": _lonlat(root, mx, my), + } + + +def route_images(root: str | Path, route_id: str, sub: str = "") -> list[Path]: + path = folder(root) / sub + if not path.is_dir(): + return [] + return sorted( + f for f in path.glob(f"{route_id}.*") if f.is_file() and f.suffix.lower() in IMAGE_EXT + ) + + +def images(root: str | Path, sub: str = "") -> dict[str, str]: + """경로 id → 그림 파일 이름(폴더가 목록 · sub = 하위 폴더 · 이름에 붙음).""" + path = folder(root) / sub + if not path.is_dir(): + return {} + return { + f.stem: f"{sub}/{f.name}" if sub else f.name + for f in sorted(path.iterdir()) + if f.is_file() and f.suffix.lower() in IMAGE_EXT and not f.name.startswith(".") + } + + +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) + return { + "data": data, + "site": site, + "table": compute_milestone(data, site["mid_default_m"]), + "images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선 + "auto_images": auto, + } + + +def save_data(root: str | Path, body: Milestone) -> dict[str, Any]: + data = body.model_dump() + path = folder(root) + 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 stem, name in images(root, sub).items(): + if stem not in keep: + (path / name).unlink(missing_ok=True) + return payload(root, data) + + +def _route_id(route: str) -> str: + try: + return Route(id=route).id + except ValidationError as error: + raise HTTPException(status_code=400, detail="경로 id 가 바르지 않습니다.") from error + + +@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))) + + +@router.put(PATH) +async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: + root = await _project_root(project_id) + return await asyncio.to_thread(save_data, root, body) + + +@router.get(PATH + "/site-address") +async def milestone_site_address( + project_id: UUID, mid_m: Annotated[float | None, Query(ge=0)] = None +) -> dict[str, Any]: + """시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소. + mid_m = 다이어그램 지금 중점 측점(캐시 · 244-12) · 없으면 저장본.""" + root = await _project_root(project_id) + data = await asyncio.to_thread(read_data, root) + mid = data["site"]["mid_station_m"] if mid_m is None else mid_m + site = await asyncio.to_thread(site_info, root, mid) + project = await get_project_by_id(str(project_id)) or {} + + async def where(lonlat: list[float] | None) -> str | None: + return await reverse_geocode(*lonlat) if lonlat else None + + start, mid = await asyncio.gather(where(site["start_lonlat"]), where(site["mid_lonlat"])) + return { + "region": project.get("region") or "", + "start_lonlat": site["start_lonlat"], + "mid_lonlat": site["mid_lonlat"], + "start_address": start, + "mid_address": mid, + } + + +@router.get(PATH + "/reverse") +async def milestone_reverse( + project_id: UUID, + lon: Annotated[float, Query(ge=-180, le=180)], + lat: Annotated[float, Query(ge=-90, le=90)], +) -> dict[str, Any]: + """지도에서 고른 소재지(244-13) — 좌표 → VWorld 주소(못 찾음 = null).""" + return {"address": await reverse_geocode(lon, lat)} + + +@router.post(PATH + "/image") +async def milestone_image_upload( + project_id: UUID, route: str = Query(...), file: UploadFile = File(...) +) -> dict[str, Any]: + route_id = _route_id(route) + ext = Path(file.filename or "").suffix.lower() + if ext not in IMAGE_EXT: + raise HTTPException(status_code=400, detail="그림 파일(png · jpg · gif · webp · bmp)만.") + root = await _project_root(project_id) + path = folder(root) + path.mkdir(parents=True, exist_ok=True) + try: + await save_upload_stream(file, path / f".{route_id}{ext}.new") + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + for old in route_images(root, route_id): + old.unlink(missing_ok=True) + (path / f".{route_id}{ext}.new").replace(path / f"{route_id}{ext}") + return {"name": f"{route_id}{ext}"} + + +@router.get(PATH + "/image") +async def milestone_image(project_id: UUID, route: str = Query(...)): + route_id = _route_id(route) + root = await _project_root(project_id) + found = route_images(root, route_id) + route_images(root, route_id, AUTO) + if not found: + raise HTTPException(status_code=404, detail="경로 그림이 없습니다.") + return FileResponse(found[0]) # 사용자 그림 우선 + + +@router.delete(PATH + "/image") +async def milestone_image_delete( + project_id: UUID, route: str = Query(...), user_only: bool = False +) -> dict[str, Any]: + route_id = _route_id(route) + root = await _project_root(project_id) + auto = route_images(root, route_id, AUTO) + for old in route_images(root, route_id) + ([] if user_only else auto): + old.unlink(missing_ok=True) + return {"name": f"{AUTO}/{auto[0].name}" if user_only and auto else None} + + +class Mark(BaseModel): + name: str = Field(default="", max_length=100) + lon: float = Field(ge=-180, le=180) + lat: float = Field(ge=-90, le=90) + + +Lat = Annotated[float, Field(ge=-90, le=90)] +Lon = Annotated[float, Field(ge=-180, le=180)] + + +class AutoImage(BaseModel): + """길 선 = 구간마다 [위도, 경도] 목록(마지막 [길찾기] 응답) · 표지 = 거래처 · 기준점 · 시점.""" + + lines: list[Annotated[list[tuple[Lat, Lon]], Field(max_length=20000)]] = Field( + default_factory=list, max_length=20 + ) + marks: list[Mark] = Field(default_factory=list, max_length=10) + + +@router.post(PATH + "/image/auto") +async def milestone_image_auto( + project_id: UUID, body: AutoImage, route: str = Query(...) +) -> dict[str, Any]: + """경로 그림 자동(244-11) — 서버 헤드리스 캡처 → auto/<경로 id>.png · 좌표는 저장 안 함.""" + route_id = _route_id(route) + root = await _project_root(project_id) + 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) + return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]} + + +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 + + +def _kakao_open(request: urllib.request.Request) -> bytes: + with urllib.request.urlopen(request, timeout=8) as response: + return response.read() + + +def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]: + """카카오 길찾기 → {km, reason, path} — km = 총 거리 ÷ 1000 소수 1자리 · 실패 = reason 한 줄 · + path = 경로 좌표 [위도, 경도] 목록(성공 때만 · 지도 길 선 · 저장 안 함). + 키 · 응답 원문은 돌려주지도 적지도 않음(까닭 = 고정 글 · 카카오 result_msg 만).""" + key = os.environ.get(KAKAO_KEY, "").strip() + if not key: + return { + "km": None, + "reason": f"카카오 길찾기 키가 없습니다(.env {KAKAO_KEY}) — km 를 손으로 넣으세요.", + } + query = urllib.parse.urlencode( + { + "origin": f"{origin.lon},{origin.lat}", + "destination": f"{destination.lon},{destination.lat}", + "priority": "RECOMMEND", + } + ) + request = urllib.request.Request( + f"{KAKAO_URL}?{query}", headers={"Authorization": f"KakaoAK {key}"} + ) + try: + body = json.loads(_kakao_open(request).decode("utf-8")) + except urllib.error.HTTPError as error: + reason = { + 401: "카카오가 키를 받지 않았습니다(키 · 앱 설정 확인)", + 403: "카카오가 길찾기를 막았습니다(앱 · 권한 확인)", + 429: "오늘 카카오 길찾기 요청 수를 넘었습니다", + }.get(error.code, f"카카오 길찾기 응답 오류(HTTP {error.code})") + return {"km": None, "reason": f"{reason} — km 를 손으로 넣으세요."} + except ( + Exception + ): # 끊김 · 시간 넘김 · 깨진 응답 — 원문 · 예외 글은 내지 않음(키가 섞일 수 있음) + return {"km": None, "reason": "카카오 길찾기에 닿지 못했습니다 — km 를 손으로 넣으세요."} + route = ((body or {}).get("routes") or [{}])[0] if isinstance(body, dict) else {} + distance = (route.get("summary") or {}).get("distance") + if route.get("result_code") != 0 or not isinstance(distance, (int, float)): + msg = str(route.get("result_msg") or "결과 없음")[:80] + return {"km": None, "reason": f"길찾기 실패 — {msg} · km 를 손으로 넣으세요."} + path: list[list[float]] = [] + try: # vertexes = 경도 · 위도 번갈아(도로마다) + for section in route.get("sections") or []: + for road in section.get("roads") or []: + v = [float(n) for n in road.get("vertexes") or []] + path += [[lat, lon] for lon, lat in zip(v[0::2], v[1::2])] + except (TypeError, ValueError, AttributeError): + path = [] # 깨진 좌표 = 길 선만 없음(km 는 그대로) + return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None, "path": path} + + +@router.post(PATH + "/directions") +async def milestone_directions(project_id: UUID, body: Directions) -> dict[str, Any]: + """[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음).""" + return await asyncio.to_thread(directions_km, body.origin, body.destination) + + +KAKAO_JS = "KAKAO_JS_KEY" + + +@router.get(PATH + "/map-key") +async def milestone_map_key(project_id: UUID) -> dict[str, Any]: + """카카오 지도 JavaScript 키(공개용 · 도메인 등록으로 막힘) — 없으면 null = 화면 안내 한 줄.""" + return {"key": os.environ.get(KAKAO_JS, "").strip() or None} diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index f8c318187..ac64372d5 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -15,6 +15,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createCard } from "@ui/ui_template_elements"; import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { stationLabel } from "../common_util/common_util_station"; +import { footNote } from "./B08_Quantity_UI_Foot"; import { stationInterval } from "./B08_Quantity_UI_Summary"; import { renderEarthworkSummary, @@ -216,17 +217,13 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); return; } - const c = (kind: string): string => table.conversion_factors[kind]?.compacted.toFixed(2) ?? ""; const flagged = table.rows.filter((row) => row.notes.length); const info = [ - note( - `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + - `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 암 ` + - `${table.conversion_factors.ripping_rock?.compacted.toFixed(3) ?? ""}(연암 리핑 ` + - `${table.rock_c.ripping_rock} · 경암 발파 ${table.rock_c.blasting_rock} 비율 가중) · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` + - `단위 m · ㎡ · ㎥`, + footNote( + `노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + + `측점 ${table.station_count} · 단위 m · ㎡ · ㎥`, ), - ...(flagged.length ? [note(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []), + ...(flagged.length ? [footNote(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []), ]; const grid = createDataTable({ columns: COLUMNS, diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index e7acf989c..ee9e58c22 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -9,6 +9,7 @@ import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { note, tableCard, type EarthworkTable, type HaulList } from "./B08_Quantity_UI_Earthwork"; +import { footNote } from "./B08_Quantity_UI_Foot"; const fmt = (value: number | null | undefined): string => value == null || !Number.isFinite(value) @@ -26,9 +27,8 @@ const SUMMARY_COLUMNS: DataColumn[] = [ /** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { - const info = note( - "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + - "연암 · 경암 = 암 × 좌측 패널 비율 · 「비율 미정」 = 반영 비율 확인 뒤 채움(- = 아직 값 없음)", + const info = footNote( + "연암 · 경암은 좌측 패널 비율대로 나눈 암 · 「비율 미정」은 비율 확인 뒤 채움", ); const grid = createDataTable({ columns: SUMMARY_COLUMNS, @@ -50,7 +50,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable) function haulNote(table: EarthworkTable): HTMLElement[] { return table.haul_saved ? [] - : [note("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; + : [footNote("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; } /** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */ @@ -58,7 +58,7 @@ function haulSheet( host: HTMLElement, table: EarthworkTable, groups: Array<[string, HaulList]>, - info: string, + caption: string, ): void { const columns: DataColumn[] = [ { key: "no", label: "번호", width: 56 }, @@ -98,10 +98,10 @@ function haulSheet( rows, frozen: 1, total, - caption: info, + caption, fillViewport: true, }); - host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages)); + host.replaceChildren(tableCard(haulNote(table), grid.root, averages)); } export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void { @@ -112,7 +112,7 @@ export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): vo ["덤프트럭", table.haul.dump_truck], ["불도우저", table.haul.dozer], ], - "유토곡선 배분의 덤프 · 도저 띠와 떨어진 구간 운반 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m", + "덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m", ); } @@ -121,6 +121,6 @@ export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void { host, table, [["종방향무대", table.haul.free_haul]], - "유토곡선 배분의 무대(20 m 안) 띠 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m", + "종방향 무대운반거리 — 단위 ㎥ · m", ); } diff --git a/B08_Quantity/B08_Quantity_UI_Foot.ts b/B08_Quantity/B08_Quantity_UI_Foot.ts new file mode 100644 index 000000000..1376cc3ad --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Foot.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * B08_Quantity_UI_Foot.ts + * B08 표 아래 안내 · 경고 줄(PLAN 202-1 · 236-1) — 늘 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문. + * 한 줄만 남겨 창 끝까지 셈(199-1)에 한 줄 높이만 듦 → 페이지 스크롤 0. + * ========================================================================== */ + +import { attachHoverInfo } from "@ui/ui_template_hover_info"; + +export function footNote(text: string): HTMLElement { + const p = document.createElement("p"); + p.className = "ui-general-block__empty b08-quantity__foot"; + p.textContent = text; + attachHoverInfo(p, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); + return p; +} diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 53d2223ed..849d7b6ea 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -34,6 +34,7 @@ import { type MaterialSheets, type SummarySource, } from "./B08_Quantity_Material_Calc"; +import { footNote } from "./B08_Quantity_UI_Foot"; import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options"; export type MaterialSheetId = "material" | "concrete" | "quantity"; @@ -51,11 +52,9 @@ interface Payload { const BASIS = "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; const HINTS: Record = { - material: - "합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널", - concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음", - quantity: - "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1 · 콘크리트(기계) · 모르터 1:3 단위수량 → 콘크리트소요", + material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널", + concrete: "㎥당 배합은 칸에서 고침", + quantity: "단위수량은 칸에 수기 입력", }; const HIDDEN = "값 없는 줄은 숨김"; @@ -190,9 +189,7 @@ function excelDiffs(s: MaterialSheets): string { if (want != null && (row.surcharge ?? 0) !== want) out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`); } - return out.length - ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거` - : ""; + return out.length ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")}` : ""; } /** 콘크리트소요 원천 = 콘크리트(기계) · 모르터 1:3 열 + 그 둘에 맞는 사본 자재 열(짝 계산 `matched` 그대로) */ @@ -213,7 +210,7 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string { const blank = s.quantity.rows.filter((row) => [...keys].every((k) => row.units[k] == null)); const copied = blank.filter((row) => source.snapshot?.units?.[row.id]).length; return blank.length - ? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 비었음 ${blank.length}줄(사본 자재에 콘크리트 · 모르터 없음 ${copied} · 사본 자재 없음 · 미연결 ${blank.length - copied}) — 자재수량 탭 그 열에 넣음` + ? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 빈 ${blank.length}줄(사본에 없음 ${copied} · 미연결 ${blank.length - copied}) — 자재수량 탭에서 넣음` : ""; } @@ -340,10 +337,7 @@ export async function renderMaterialSheet( const toolbar = document.createElement("div"); toolbar.className = "b08-material__toolbar"; const notes = document.createElement("div"); - notes.append( - note(BASIS), - note(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`), - ); + notes.append(footNote(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`)); if (sheet !== "quantity") { // 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로 const blank = blankUnitRows(sheets); @@ -352,12 +346,12 @@ export async function renderMaterialSheet( ...(payload.notes ?? []), ]; if (why.length) { - const reason = note(`빈 칸 까닭 — ${why.join(" · ")}`); + const reason = footNote(`빈 칸 까닭 — ${why.join(" · ")}`); reason.dataset.b08MaterialWhy = ""; notes.append(reason); } } - const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시 + const concreteNote = footNote(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시 concreteNote.dataset.b08MaterialConcreteWhy = ""; if (sheet === "concrete") notes.append(concreteNote); toolbar.append(save); @@ -373,6 +367,7 @@ export async function renderMaterialSheet( caption: BASIS, fillViewport: true, editable: (i, key) => built.editable(i, key), + editOnDouble: true, // 243-1 값은 더블클릭 · Enter · F2 로만 고침 onEdit: (i, key, text) => { const value = parse(text); if (value !== undefined) { @@ -390,7 +385,7 @@ export async function renderMaterialSheet( }, }); table.root.dataset.b08Material = sheet; - const excel = note(""); + const excel = footNote(""); excel.dataset.b08MaterialExcel = ""; const redraw = (): void => { built = BUILD[sheet](sheets, overrides); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css new file mode 100644 index 000000000..90ff5b010 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -0,0 +1,214 @@ +/* B08 거리이정표(244) — 경로 컨테이너 · 단추 줄 · 그림. 색은 테마 변수만(밝음 · 어두움 같이). */ + +.b08-milestone { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + /* 틀 너비 = 표 너비(244-6) — 다이어그램은 너비를 보태지 않고 표에 맞춰 늘음 */ + width: fit-content; + max-width: 100%; +} + +.b08-milestone > .b08-milestone__diagram { + contain: inline-size; +} + +.b08-milestone__route { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16); +} + +.b08-milestone__route h4 { + margin: var(--spacing-8) 0 0; + font-size: var(--text-caption); + color: var(--color-text-secondary); +} + +.b08-milestone__head { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); +} + +.b08-milestone__head > .ui-field { + min-width: 160px; +} + +.b08-milestone__bar { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.b08-milestone__image { + max-width: 100%; + max-height: 70vh; + object-fit: contain; + align-self: flex-start; + border: 1px solid var(--color-border); +} + +/* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ +.b08-milestone__diagram { + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); +} + +.b08-milestone__diagram svg { + display: block; + margin: 0 auto; + font-size: 13px; +} + +.b08-milestone__line { + stroke: var(--color-text-secondary); + stroke-width: 1.5; +} + +.b08-milestone__box rect { + fill: var(--color-surface); + stroke: var(--color-text-secondary); + stroke-width: 1.2; +} + +.b08-milestone__box text, +.b08-milestone__plus text { + fill: var(--color-text); +} + +.b08-milestone__box.is-site rect { + fill: var(--color-surface-raised); + stroke-dasharray: 4 3; +} + +.b08-milestone__box.is-base rect, +.b08-milestone__box.is-start rect, +.b08-milestone__box.is-mid rect { + stroke: var(--color-accent); +} + +.b08-milestone__box.is-route { + cursor: pointer; +} + +.b08-milestone__box.is-route:hover rect, +.b08-milestone__box.is-route:focus-visible rect { + stroke: var(--color-accent); + 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; + paint-order: stroke; + stroke: var(--color-surface); + stroke-width: 4px; + stroke-linejoin: round; +} + +/* 모달 구간 · 표 줄 — 칸 · [×] 한 줄(244-4) */ +.b08-milestone__line-edit { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.b08-milestone__diagram svg.is-panning { + cursor: grabbing; +} + +/* 선 글 바로 고침(244-4 ⑥) — 글 아래 작은 판 */ +.b08-milestone__km tspan { + cursor: pointer; +} + +.b08-milestone__leg-edit { + position: fixed; + z-index: 1000; + display: flex; + align-items: flex-end; + gap: 6px; + padding: 6px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.b08-milestone__leg-edit .ui-input { + width: 80px; +} + +/* 거래처 상자 윗줄 = 항목(244-6) */ +.b08-milestone__items { + fill: var(--color-text-secondary); + font-size: 12px; +} + +/* 상자 작은 글씨 = 주소 · 좌표(244-12) */ +.b08-milestone__note { + fill: var(--color-text-secondary); + font-size: 10px; +} + +/* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */ +.b08-milestone .is-pick-road { + color: var(--color-danger); +} + +/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 */ +.b08-milestone__map { + width: 100%; + height: 360px; + border: 1px solid var(--color-border); +} + +.b08-milestone__map-label { + padding: 2px 6px; + border: 1px solid #3c3c3c; + background: #fff; + color: #141414; + font-size: 12px; + white-space: nowrap; +} + +/* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */ +.ui-modal.b08-milestone__modal { + width: min(960px, 94vw); + --modal-max: 90vh; +} + +.b08-milestone__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 16px; + align-items: end; +} + +.b08-milestone__place { + margin: 6px 0; + font-weight: 600; +} + +/* 연결선 누름 자리 — 투명한 굵은 선(244-13) */ +.b08-milestone__line-hit { + stroke: transparent; + stroke-width: 14; + pointer-events: stroke; + cursor: pointer; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts new file mode 100644 index 000000000..df187f338 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -0,0 +1,219 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone.ts + * B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(거래처마다 · 이름 = 거래처 이름). + * 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음). + * + * [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 도로 구분 · 계 · 비고) — 열(244-13) = 기준점 있음 = + * 2차포장 · 1차포장 (· 포장) · 비포장 + 계 포장 · 비포장 · 합계 / 없음 = 포장 · 비포장 · 합계 + * 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈 + * 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도 + * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) + * 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State` + * ========================================================================== */ + +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 { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; +import { + liveMilestone, + loadMilestone, + onMilestoneChange, + putMilestone, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; +import { + DEST_TEXT, + editRoute, + pickAtCell, + routeView, + trackCells, + type MilestoneView, +} from "./B08_Quantity_UI_Milestone_Route"; + +const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; +const ROAD = ["도로 구분 (km)"]; +const SUM = ["계 (km)"]; + +/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · + * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ +export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] { + const roads: DataColumn[] = withBase + ? [ + { key: "s2", label: "2차포장", group: ROAD, ...KM }, + { key: "s1", label: "1차포장", group: ROAD, ...KM }, + ...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []), + { key: "un", label: "비포장", group: ROAD, ...KM }, + { key: "paved", label: "포장", group: SUM, ...KM }, + { key: "unpaved", label: "비포장", group: SUM, ...KM }, + ] + : [ + { key: "paved", label: "포장", group: ROAD, ...KM }, + { key: "un", label: "비포장", group: ROAD, ...KM }, + ]; + return [ + { key: "item", label: "항목", width: 120 }, + { key: "company", label: "거래처 이름", width: 120 }, + { key: "dest", label: "목적지", width: 72 }, + ...roads, + { key: "total", label: "합계", group: SUM, ...KM, width: 108 }, + { key: "note", label: "비고", width: 160 }, + ]; +} + +/** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */ +export function addRoute(projectId: string, init: Partial = {}): string { + const data = liveMilestone(projectId); + const id = `r${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`; + data.routes.push({ + id, + name: `거래처 ${data.routes.length + 1}`, + address: "", + via_base: true, + dir: null, + legs: [{ road: null, km: null }], // 도로 구분 = 사용자 고름(244-7) + rows: [{ item: "", dest: "start", note: "" }], + ...init, + }); + putMilestone(projectId, data); + return id; +} + +/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */ +function tableView(projectId: string): MilestoneView { + const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); + let keys: Array<{ route: string; index: number }> = []; + let pickRows: boolean[] = []; + const makeTable = (columns: DataColumn[]) => { + const t = createDataTable({ + columns, + rows: [], + caption: "거리이정표", + empty: "", + editOnDouble: true, + cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null), + editable: (_i, key) => key === "item" || key === "company" || key === "note", + onEdit: (i, key, text) => { + const at = keys[i]; + if (!at) return; + editRoute(projectId, at.route, (_, r) => { + if (key === "company") r.name = text; + else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text; + }); + }, + onCellClick: (i, key) => { + const at = keys[i]; + if (key !== "dest" || !at) return; + pickAtCell( + (["start", "mid"] as const).map((dest) => [ + DEST_TEXT[dest], + () => + editRoute(projectId, at.route, (_, r) => { + if (r.rows[at.index]) r.rows[at.index].dest = dest; + }), + ]), + ); + }, + }); + trackCells(t.root); + t.root.dataset.b08Milestone = "table"; + return t; + }; + let mode = "false:false"; + let table = makeTable(milestoneColumns(false, false)); + const none = el("p", { + className: "ui-general-block__empty", + text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.", + }); + const refresh = (): void => { + const data = liveMilestone(projectId); + const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null); + // 기준점 생김 · 없앰 = 열 바뀜 → 표 새로 + const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`; + if (next !== mode) { + mode = next; + const fresh = makeTable( + milestoneColumns( + data.base.enabled, + 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); + none.hidden = rows.length > 0; + // 도로 구분 안 고른 구간 = 합계 칸에 「도로 구분 고르기」(그 km 는 합에 안 듦 · 244-7) + table.setRows( + rows.map((r) => ({ + ...r, + dest: `${DEST_TEXT[r.dest]} ▾`, + total: r.pick ? "도로 구분 고르기" : r.total, + })), + ); + diagram.refresh(); + }; + refresh(); + return { + root: el("div", { className: "b08-milestone", children: [diagram.root, none, table.root] }), + refresh, + }; +} + +export function renderMilestone(host: HTMLElement, projectId: string): void { + const tabs = el("div", { + className: "b08-quantity__tabs b08-structure__tabs", + attrs: { role: "tablist" }, + }); + const view = el("div"); + let current = "table"; + let body: MilestoneView | null = null; + + const drawTabs = (): void => { + const routes = liveMilestone(projectId).routes; + const tab = (id: string, label: string): HTMLButtonElement => { + const button = createButton({ + label, + variant: id === current ? "filled" : "ghost", + size: "sm", + onClick: () => id !== current && open(id), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(id === current)); + button.dataset.b08MilestoneTab = id; + return button; + }; + tabs.replaceChildren( + tab("table", "거리이정표"), + ...routes.map((r, i) => tab(r.id, r.name.trim() || `거래처 ${i + 1}`)), + ); + }; + + function open(id: string): void { + current = id; + drawTabs(); + body = id === "table" ? tableView(projectId) : routeView(projectId, id); + view.replaceChildren(body.root); + } + + host.replaceChildren( + tabs, + el("p", { className: "ui-general-block__empty", text: "거리이정표를 불러오는 중…" }), + ); + loadMilestone(projectId).then( + () => { + host.replaceChildren(tabs, view); + open("table"); + onMilestoneChange(host, () => { + // 지운 경로 탭이면 [거리이정표] 로 · 아니면 탭 이름 · 몸만 다시 셈(입력 칸은 그대로) + if (current !== "table" && !liveMilestone(projectId).routes.some((r) => r.id === current)) + return open("table"); + drawTabs(); + body?.refresh(); + }); + }, + (error: unknown) => showToast(`거리이정표를 불러오지 못했습니다 — ${String(error)}`, "error"), + ); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts new file mode 100644 index 000000000..91cb5e2d6 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -0,0 +1,280 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Diagram.ts + * [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`. + * + * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 거래처들 · 선마다 「도로 구분 km」 글 + * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 거래처 = 항목 정보 · 공사지 = 시점 주소 · + * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) + * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) + * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 = [+] 등록 모달 분류 「기준위치」(244-13 · [기준점 +] 없앰) + * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 고침 · 삭제) · 선 · 선 글 누름 = 연결선 모달 + * (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로) + * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) + * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) + * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 + * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 + * ========================================================================== */ + +import { createSelectField, el } 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, + layoutMilestone, + legAt, + 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 { + liveMilestone, + putMilestone, + roadOptions, + siteAddress, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; + +const NS = "http://www.w3.org/2000/svg"; + +function svg( + tag: K, + attrs: Record, + text?: string, +): SVGElementTagNameMap[K] { + const node = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v)); + if (text !== undefined) node.textContent = text; + return node; +} + +/** 선 글 바로 고침 — 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소 */ +function editLegAt( + projectId: string, + anchor: Element, + from: string, + i: number, + midKm: number, +): void { + const data = liveMilestone(projectId); + const leg = legAt(data, from, i, midKm); + if (!leg) return; + document.querySelector(".b08-milestone__leg-edit")?.remove(); + const road = createSelectField({ + label: "도로 구분", + options: roadOptions(chainVia(data, from), from !== "start", leg.road), // 시점 → 중점 = 늘 고름 + compact: true, + }); + road.select.value = leg.road ?? ""; + const km = el("input", { + className: "ui-input", + attrs: { type: "number", min: "0", step: "any" }, + }); + km.value = leg.km == null ? "" : String(leg.km); + km.setAttribute("aria-label", "km"); + if (leg.auto_km != null) { + km.placeholder = String(leg.auto_km); // 빈칸 = 자동 값 + km.title = `자동 값 ${leg.auto_km} km`; + } + const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] }); + box.dataset.b08MilestoneLegEdit = `${from}:${i}`; + const at = anchor.getBoundingClientRect(); // 팬 · 줌 뒤 글 자리 그대로 + box.style.left = `${at.left}px`; + box.style.top = `${at.bottom + 4}px`; + let done = false; + const finish = (apply: boolean): void => { + if (done) return; + done = true; + box.remove(); + if (!apply) return; + const n = Number(km.value); + 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 }), + }; + putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch)); + }; + box.addEventListener("keydown", (e) => { + if (e.key === "Enter") finish(true); + if (e.key === "Escape") finish(false); + }); + box.addEventListener("focusout", (e) => { + if (!box.contains(e.relatedTarget as Node | null)) finish(true); + }); + document.body.append(box); + km.focus(); + km.select(); +} + +/** 다이어그램 — refresh() 로 캐시 값 다시 그림 */ +export function createDiagram( + projectId: string, + addRoute: (init: Partial) => string, +): { root: HTMLElement; refresh: () => void } { + const host = el("div", { className: "b08-milestone__diagram" }); + host.dataset.b08Milestone = "diagram"; + // 틀 위 휠 = 줌만(244-8) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 새지 않음 + host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false }); + let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로 + 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" }), + }; + + 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; + }; + + const refresh = (): void => { + const data = liveMilestone(projectId); + const site = siteInfo(); + const mk = midKm(data, site?.mid_default_m ?? null); + // 시점 · 중점 주소 · 좌표(244-12) — 지금 중점 측점으로 서버 VWorld(뒤에서 · 오면 다시 그림) + const addr = siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null); + const layout = layoutMilestone( + data, + { + start_lonlat: addr?.start_lonlat ?? site?.start_lonlat, + mid_lonlat: addr?.mid_lonlat ?? site?.mid_lonlat, + start_address: addr?.start_address, + mid_address: addr?.mid_address, + }, + mk, + ); + const [vx, vy, vw, vh] = layout.view; + // viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김 + const root = svg("svg", { + viewBox: `0 0 ${vw} ${vh}`, + width: vw, + height: vh, + 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); + root.append(layer); + // 선 = 공사지 바탕 위 · 시점 · 중점 · 거래처 상자 아래(244-12 · 시점 → 중점 선이 바탕에 안 가림) + const lineLayer = svg("g", {}); + for (const ln of layout.lines) { + const ends = { x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }; + // 누르는 자리 = 투명한 굵은 선(244-13 · 연결선 모달) + const hit = svg("line", { class: "b08-milestone__line-hit", ...ends }); + hit.dataset.b08MilestoneLine = ln.from; + hit.addEventListener("click", (e) => { + e.stopPropagation(); + openLegInfo(projectId, ln.from); + }); + lineLayer.append(svg("line", { class: "b08-milestone__line", ...ends }), hit); + } + for (const box of layout.boxes) { + const g = svg("g", { class: `b08-milestone__box is-${box.kind}` }); + g.dataset.b08MilestoneBox = box.id; + // 글 줄 = 항목(거래처 윗줄) · 이름 · 작은 글씨(주소 · 좌표) — 상자 가운데 맞춤 + const rows: Array<[string, string]> = [ + ...(box.kind === "route" && box.items + ? [[box.items, "b08-milestone__items"] as [string, string]] + : []), + [box.label, ""], + ...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]), + ]; + const x = box.x + box.w / 2; + const text = svg("text", { + x, + y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2, + "text-anchor": "middle", + "dominant-baseline": "central", + }); + rows.forEach(([row, cls], i) => + 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); + if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소 + // 상자 누름 = 모달(탭 이동 아님) + const info = (): void => INFO[box.kind](box.id); + g.setAttribute("role", "button"); + g.setAttribute("tabindex", "0"); + g.addEventListener("click", (e) => { + e.stopPropagation(); // 공사지 안 시점 · 중점 상자 = 한 번만 + 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)); + shift.append(g); + if (box.kind === "site") shift.append(lineLayer); + } + // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄 + for (const ln of layout.lines) { + // 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④) + const x = ln.lx; + const y = ln.ly - ((ln.labels.length - 1) * 16) / 2; + const text = svg("text", { + class: "b08-milestone__km", + x, + y, + "text-anchor": "middle", + "dominant-baseline": "central", + }); + 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); + }); + shift.append(text); + } + host.replaceChildren(root); + attachSvgZoomPan({ + svg: root, + layer, + widthPx: vw, + heightPx: vh, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, + initial: zoomState, + onChange: (state) => (zoomState = state), + wheelZoom: true, + content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임) + panStart, // 가운데 버튼 · 빈 곳 왼쪽 끌기 + }); + }; + refresh(); + return { root: host, refresh }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts new file mode 100644 index 000000000..af216b5cb --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -0,0 +1,276 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_KakaoMap.ts + * 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 · 표지(보기만). + * + * SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key + * (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기 + * 길 선 = 마지막 [길찾기] 응답 경로 좌표(`routePaths` · 메모리만 · 저장 안 함) · 표지 = 거래처 · 기준점 · + * 시점(`mapMarks`) · 경로 전체 맞춤 + * 경로 그림 = 서버 자동 캡처(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 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; +const SDK_FAIL = "KakaoSdkFail"; +const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; +const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 + +interface LatLng { + getLat(): number; + getLng(): number; +} +interface KMap { + setBounds(bounds: object): void; + setCenter(p: LatLng): void; + setLevel(level: number): void; + relayout(): void; +} +interface KOverlay { + setMap(map: KMap | null): void; +} +interface KMarker extends KOverlay { + getPosition(): LatLng; +} +interface KakaoMaps { + load(done: () => void): void; + LatLng: new (lat: number, lng: number) => LatLng; + LatLngBounds: new () => { extend(p: LatLng): void }; + Map: new (box: HTMLElement, o: { center: LatLng; level: number }) => KMap; + Polyline: new (o: { + path: LatLng[]; + strokeWeight: number; + strokeColor: string; + strokeOpacity: number; + }) => KOverlay; + Marker: new (o: { position: LatLng; title: string; draggable?: boolean }) => KMarker; + event: { + addListener(target: object, type: string, fn: (e: { latLng: LatLng }) => void): void; + }; + CustomOverlay: new (o: { position: LatLng; content: HTMLElement; yAnchor: number }) => KOverlay; +} + +let sdk: Promise | null = null; + +/** SDK 한 번 싣기 — 키 없음 · 못 받음 = NO_MAP 으로 거절(다음 부름 때 다시) */ +export function loadKakaoMaps(projectId: string): Promise { + sdk ??= (async () => { + const { key } = await request<{ key: string | null }>( + `/projects/${projectId}/quantity/milestone/map-key`, + ); + if (!key) throw new Error(NO_MAP); + const w = window as unknown as { kakao?: { maps: KakaoMaps } }; + if (!w.kakao?.maps) { + await new Promise((done, fail) => { + const script = document.createElement("script"); + script.src = `${SDK}?appkey=${encodeURIComponent(key)}&autoload=false`; + script.onload = () => done(); + script.onerror = () => + fail( + Object.assign( + new Error( + `카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요`, + ), + { name: SDK_FAIL }, + ), + ); + document.head.append(script); + }); + } + const maps = w.kakao?.maps; + if (!maps) throw new Error(NO_MAP); + await new Promise((done) => maps.load(done)); + return maps; + })(); + sdk.catch(() => (sdk = null)); + 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; + name: string; + lon: number; + lat: number; + movable: boolean; +} + +export interface EditMap { + root: HTMLElement; + /** 표지 · 길 선 다시 놓고 맞춤 */ + set: (pins: MapPin[], lines: Array>) => void; +} + +/** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */ +export function editMap( + projectId: string, + opts: { + onMove: (key: string, lon: number, lat: number) => void; + onClick?: (lon: number, lat: number) => void; + center?: () => [number, number] | null; + hint: string; + }, +): EditMap { + const box = el("div", { className: "b08-milestone__map" }); + const hint = el("p", { className: "b08-options__hint", text: opts.hint }); + hint.dataset.b08MilestoneMapHint = ""; + let map: KMap | null = null; + let drawn: KOverlay[] = []; + + const set = (pins: MapPin[], lines: Array>): void => { + loadKakaoMaps(projectId).then( + (maps) => + requestAnimationFrame(() => { + const points = [ + ...pins.map((p) => new maps.LatLng(p.lat, p.lon)), + ...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)), + ]; + const c = opts.center?.(); + const first = points[0] ?? (c ? new maps.LatLng(c[1], c[0]) : null); + if (!first) return void (box.hidden = true); + box.hidden = false; + if (!map) { + map = new maps.Map(box, { center: first, level: 7 }); + if (opts.onClick) + maps.event.addListener(map, "click", (e) => + opts.onClick!(e.latLng.getLng(), e.latLng.getLat()), + ); + } + 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, + }), + ), + ...pins.flatMap((p) => { + const at = new maps.LatLng(p.lat, p.lon); + const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable }); + if (p.movable) + maps.event.addListener(marker, "dragend", () => { + const to = marker.getPosition(); + opts.onMove(p.key, to.getLng(), to.getLat()); + }); + return [ + marker, + new maps.CustomOverlay({ + position: at, + content: el("span", { className: "b08-milestone__map-label", text: p.name }), + yAnchor: 2.6, + }), + ]; + }), + ]; + drawn.forEach((o) => o.setMap(map)); + if (points.length > 1) { + const bounds = new maps.LatLngBounds(); + points.forEach((p) => bounds.extend(p)); + map.setBounds(bounds); + } else { + map.setCenter(first); + map.setLevel(points.length ? 4 : 9); + } + }), + (error: unknown) => { + box.hidden = true; + hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP; + }, + ); + }; + + return { + root: el("div", { className: "b08-milestone__map-wrap", children: [hint, box] }), + set, + }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts new file mode 100644 index 000000000..f8f7cd3e6 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -0,0 +1,251 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_LegModal.ts + * 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름. + * + * 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7) + * 도로 구분 = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · 비포장 · + * 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장(`roadOptions` · `chainVia`) + * 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음) + * 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정) + * 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음) + * 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`) + * ========================================================================== */ + +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 { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { toastKm } from "./B08_Quantity_UI_Milestone_Modal"; +import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; +import { + findSegmentKm, + liveMilestone, + putMilestone, + roadOptions, + segmentPath, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; + +const km = (text: string): number | null => { + const n = Number(text); + return text.trim() && Number.isFinite(n) && n >= 0 ? n : null; +}; + +/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · 거리 km(빈칸 = 자동 값) · [×] · [+ 구간] */ +export function legsEditor( + initial: Leg[], + via: boolean, +): { root: HTMLElement; value: () => Leg[] } { + const list = el("div"); + const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = []; + const add = (leg: Leg): void => { + const road = createSelectField({ + label: "도로 구분", + options: roadOptions(via, true, leg.road), + 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); + const auto = leg.auto_km ?? null; + if (auto != null) { + dist.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 + dist.input.title = `자동 값 ${auto} km`; + } + const ref = { road: road.select, km: dist.input, auto }; + rows.push(ref); + const drop = createButton({ + label: "×", + size: "sm", + variant: "ghost", + onClick: () => { + rows.splice(rows.indexOf(ref), 1); + row.remove(); + }, + }); + const row = el("div", { + className: "b08-milestone__line-edit", + children: [road.root, dist.root, drop], + }); + row.dataset.b08MilestoneLeg = ""; + list.append(row); + }; + (initial.length ? initial : [{ road: null, km: null }]).forEach(add); + const more = createButton({ + label: "+ 구간", + size: "sm", + variant: "ghost", + onClick: () => add({ road: null, km: null }), + }); + return { + root: el("div", { children: [list, more] }), + value: () => + rows.map(({ road, km: dist, auto }) => ({ + road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null, + km: km(dist.value), + auto_km: auto, + })), + }; +} + +const hint = (text: string): HTMLElement => el("p", { className: "b08-options__hint", text }); + +/** 시점 → 중점 — 도로 구분 · 거리 늘 손 입력(빈칸 = 노선 ÷ 2) */ +function openMidLeg(projectId: string): void { + const data = liveMilestone(projectId); + const site = siteInfo(); + openModal({ + title: "구간 — 시점 → 중점", + closeLabel: "닫기", + dialogClass: "b08-milestone__modal", + mount: (body, close, actions) => { + const road = createSelectField({ + label: "도로 구분", + options: roadOptions(false, false, data.site.mid_road), + compact: true, + }); + road.select.value = data.site.mid_road; + 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 + dist.input.dataset.b08MilestoneLegKm = "start"; + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + const fresh = liveMilestone(projectId); + putMilestone( + projectId, + setLeg(fresh, "start", 0, { + road: road.select.value as Road, + km: km(dist.input.value), + }), + ); + close(); + }, + }); + save.dataset.b08MilestoneLegSave = ""; + actions.append(save); + body.append( + el("div", { className: "b08-milestone__grid", children: [road.root, dist.root] }), + hint("시점 → 중점 = 늘 손 입력(길찾기 없음) · 빈칸 = 노선 ÷ 2"), + ); + }, + }); +} + +/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "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 seg = segmentEnds(liveMilestone(projectId), from, start()); + if (!seg) return; + openModal({ + title: `구간 — ${seg.text}`, + 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 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); + if (!target) return; + target.legs = legs.value(); + putMilestone(projectId, fresh); + }; + const redrawLegs = (): void => { + const next = legsEditor(legsOf(), via); + legs.root.replaceWith(next.root); + legs = next; + }; + const pins = (): MapPin[] => { + const d = liveMilestone(projectId); + const route = d.routes.find((r) => r.id === 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); + else add("start", "시점", start()?.[0], start()?.[1]); + if (from === "base") add("start", "시점", start()?.[0], start()?.[1]); + return out; + }; + const drawMap = (): void => { + const path = segmentPath(from); + map.set(pins(), path.length >= 2 ? [path] : []); + }; + const run = async (image = true): Promise => { + find.disabled = true; + status.textContent = "길찾기 중…"; + const typed = JSON.stringify(legs.value()); // 기다리는 동안 손댄 칸은 그대로 + const text = await findSegmentKm(projectId, from, image).finally( + () => (find.disabled = false), + ); + status.textContent = text; + if (image) toastKm(text); + if (JSON.stringify(legs.value()) === typed) redrawLegs(); + drawMap(); + }; + // 끝점 끌어 옮김 = 그 소재지 고침(주소 다시) + 다시 길찾기 + const map = editMap(projectId, { + hint: "경로 = 카카오 길찾기 · 거래처 · 기준점 표지를 끌어 옮기면 다시 길찾기", + onMove: (key, lon, lat) => { + 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); + if (!target) return; + Object.assign(target, { lon, lat, address }); + putMilestone(projectId, fresh); + void run(); + }); + }, + }); + const find = createButton({ + label: "길찾기", + variant: "ghost", + onClick: () => { + apply(); + void run(); + }, + }); + find.dataset.b08MilestoneLegFind = ""; + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + apply(); + close(); + }, + }); + save.dataset.b08MilestoneLegSave = ""; + actions.append(find, save); + body.append( + legs.root, + hint( + from === "base" + ? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" + : "빈칸 = 길찾기 자동 값 · 넣은 값이 우선", + ), + status, + el("h4", { text: "경로 — 다음(카카오) 지도" }), + map.root, + ); + drawMap(); + // 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음) + if (!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 new file mode 100644 index 000000000..f0454ab76 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -0,0 +1,331 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Modal.ts + * 거리이정표 다이어그램 모달(PLAN 244-4 · 244-13 · 사용자 147번 5 · 8) — 페이지 이동 없이 창에서 보고 고침. + * + * 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 · + * 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · + * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제] + * 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정 + * 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리 + * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 + * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 + * 조작 = 캐시 · [저장] 때 서버 + * ========================================================================== */ + +import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { + createButton, + createInputField, + createSelectField, + el, + showConfirmDialog, + 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 { request } from "./B08_Quantity_UI_Hydro"; +import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; +import { + findSegmentKm, + liveMilestone, + putMilestone, + roadOptions, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; + +const DEST_OPTIONS = [ + { value: "start", text: "시점" }, + { value: "mid", text: "중점" }, +]; + +/** 분류 → 항목 고르기 목록(거래처 = 견본 「거리이정표」 시트 다섯) */ +export const ITEMS: Record<"route" | "base", string[]> = { + route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"], + base: ["기준점"], +}; + +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"); +} + +let listSeq = 0; + +/** 항목(손 입력 + 제안 목록 · 244-14) · 목적지 ▾ 한 줄 — 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜 */ +function rowField(r: MilestoneRow, kind: "route" | "base") { + const item = createInputField({ label: "항목", compact: true }); + const list = el("datalist", { attrs: { id: `b08-milestone-items-${++listSeq}` } }); + item.input.setAttribute("list", list.id); + item.input.value = r.item; + item.root.append(list); + const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); + 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" : ""; // 기준점 → 시점 고정 + }; + setKind(kind); + dest.select.value = r.dest; + item.input.dataset.b08MilestoneAdd = "item"; + return { item, dest, note: r.note, setKind }; +} + +export type PlaceTarget = { add: true; viaBase: boolean } | { add: false; id: string }; + +/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */ +export function openPlaceModal( + projectId: string, + target: PlaceTarget, + addRoute?: (init: Partial) => string, +): void { + const data = liveMilestone(projectId); + const editBase = !target.add && target.id === "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 || "거래처"}`; + openModal({ + title, + closeLabel: "닫기", + dialogClass: "b08-milestone__modal", + mount: (body, close, actions) => { + let kind: "route" | "base" = editBase ? "base" : "route"; + const sort = createSelectField({ + label: "분류", + compact: true, + options: [ + { value: "base", text: "기준위치" }, + { value: "route", text: "거래처" }, + ], + }); + 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.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; + const place = placePicker( + projectId, + { address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null }, + () => name.input.value.trim() || (kind === "base" ? "기준점" : "거래처"), + ); + 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"; + }; + 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 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 ? "등록" : "고침", + 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 }]; + 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; + } 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; + } + close(); + if (segment) void findSegmentKm(projectId, segment).then(toastKm); + }, + }); + ok.dataset.b08MilestoneInfo = "save"; + const remove = createButton({ + label: "삭제", + variant: "danger", + onClick: async () => { + const label = editBase ? data.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); // 경로 탭도 같이 + putMilestone(projectId, fresh); + close(); + }, + }); + remove.dataset.b08MilestoneInfo = "delete"; + remove.hidden = target.add; + actions.append(remove, ok); + body.append( + el("div", { + className: "b08-milestone__grid", + children: [sort.root, first.item.root, name.root, first.dest.root], + }), + more, + plusRow, + via.root, + head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"), + place.root, + ); + }, + }); +} + +export interface SiteAddress { + region: string; + start_lonlat: [number, number] | null; + mid_lonlat: [number, number] | null; + start_address: string | null; + mid_address: string | null; +} + +/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */ +export function openSiteInfo(projectId: string): void { + openModal({ + title: "공사지 — 시점 · 중점", + closeLabel: "닫기", + mount: (body, close, actions) => { + const text = (label: string, value: string): HTMLElement => + el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); + // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) + 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)", + 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.dataset.b08Milestone = "mid_station_m"; + const road = createSelectField({ + label: "시점 → 중점 도로", + options: roadOptions(false, false, data.site.mid_road), + compact: true, + }); + road.select.value = data.site.mid_road; + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + 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); + 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"), + ); + }, + }); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts new file mode 100644 index 000000000..aa65196e9 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Place.ts + * 거리이정표 등록 모달 「소재지」 칸(PLAN 244-13 · 사용자 147번 5) — 주소 찾기 · 지도에서 고르기만(직접 입력 없음). + * + * 주소 찾기 = B01 회사 주소와 같은 서버 후보 검색(VWorld 대리 · `searchAddress`) → 후보 고름 = 소재지 · 표지 + * 지도 = 다음(카카오) 지도 JS(`…_KakaoMap` editMap) — 지도 누름 = 그 자리 소재지 · 표지 끌어 옮김 = 다시 고름 · + * 고른 좌표 → 서버 GET …/milestone/reverse(VWorld 주소 한 줄 · 못 찾음 = 위도, 경도 글) + * 값 = 주소 글 · 경도 · 위도(캐시 · [저장] 때 서버) + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +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"; + +export interface Place { + address: string; + lon: number | null; + lat: number | null; +} + +export interface PlaceHandle { + root: HTMLElement; + value: () => Place; +} + +/** 지번 후보 = 지번 줄이 시 · 도까지 갖춤(B01 주소 칸과 같은 고름) */ +const lineOf = (c: AddressCandidate): string => + (c.category === "parcel" ? c.parcel : c.road) || c.road || c.parcel; + +/** 좌표 → 주소 한 줄(서버 VWorld) · 못 찾음 = 「위도, 경도」 글 */ +export async function reverseAddress(projectId: string, lon: number, lat: number): Promise { + const got = await request<{ address: string | null }>( + `/projects/${projectId}/quantity/milestone/reverse?lon=${lon}&lat=${lat}`, + ).catch(() => null); + return got?.address || `${lat.toFixed(5)}, ${lon.toFixed(5)}`; +} + +/** 소재지 칸 — name() = 지도 표지 이름(거래처 · 기준점 이름) */ +export function placePicker(projectId: string, initial: Place, name: () => string): PlaceHandle { + let place: Place = { ...initial }; + let seq = 0; // 늦게 온 주소가 새 자리를 덮지 않게 + const query = createInputField({ + label: "주소 찾기", + compact: true, + placeholder: "도로명 · 지번 · 건물명 일부 (예 병곡리 968-31)", + }); + const find = createButton({ label: "주소 찾기", variant: "ghost", onClick: () => void search() }); + find.dataset.b08MilestonePlace = "find"; + const list = el("div", { className: "b01-dashboard__address-list" }); + list.hidden = true; + const where = el("p", { className: "b08-milestone__place" }); + where.dataset.b08MilestonePlace = "value"; + 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, + }); + + const draw = (): void => { + where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`; + map.set( + place.lon != null && place.lat != null + ? [ + { + key: "place", + name: name() || "소재지", + lon: place.lon, + lat: place.lat, + movable: true, + }, + ] + : [], + [], + ); + }; + + async function moveTo(lon: number, lat: number): Promise { + const mine = ++seq; + place = { ...place, lon, lat }; + draw(); + const address = await reverseAddress(projectId, lon, lat); + if (mine !== seq) return; + place.address = address; + draw(); + } + + async function search(): Promise { + const text = query.input.value.trim(); + if (text.length < 2) return showToast("주소를 두 글자 이상 넣으세요.", "error"); + find.disabled = true; + try { + const items = await searchAddress(text); + list.hidden = false; + list.replaceChildren( + ...(items.length + ? items.map((item) => { + const option = el("button", { + className: "b01-dashboard__address-item", + attrs: { type: "button" }, + children: [ + el("strong", { text: lineOf(item) }), + el("small", { text: [item.building, item.zipcode].filter(Boolean).join(" · ") }), + ], + }); + option.addEventListener("click", () => { + seq++; + place = { address: lineOf(item), lon: item.lon, lat: item.lat }; + list.hidden = true; + draw(); + }); + return option; + }) + : [el("p", { text: "찾은 주소가 없습니다 — 다른 낱말로 찾거나 지도에서 고르세요." })]), + ); + } catch { + showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하세요.", "error"); + } finally { + find.disabled = false; + } + } + // 검색 칸 Enter = 찾기만(모달 안 닫힘) + query.input.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + void search(); + }); + + draw(); + return { + root: el("div", { + className: "b08-milestone__place-field", + children: [ + el("div", { className: "b08-milestone__line-edit", children: [query.root, find] }), + list, + where, + map.root, + ], + }), + value: () => ({ ...place }), + }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts new file mode 100644 index 000000000..d61fee88b --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -0,0 +1,200 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Route.ts + * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). + * + * 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자 + * 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버 + * 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기 + * [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · 경유 = 기준점 좌표(거침) · + * 도착 = 목적지 좌표) — 보기만 + * 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지(보기만) · JS 키 없음 = 안내 한 줄 + * ========================================================================== */ + +import { + createMapContextMenu, + type MapContextMenu, + type MapContextMenuItem, +} 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 { + autoImageNote, + canRevertImage, + deleteImage, + imageBlobUrl, + liveMilestone, + putMilestone, + routeImageKey, + siteInfo, + 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); +} + +export function pickAtCell(items: MapContextMenuItem[]): void { + const box = lastCell?.getBoundingClientRect(); + if (!box) return; + menu ??= createMapContextMenu("b08-milestone"); + if (!menu.element.isConnected) { + menu.element.style.position = "fixed"; + document.body.append(menu.element); + } + menu.open(box.left, box.bottom, items); +} + +export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; + +/** 그 경로를 고쳐 캐시에 */ +export function editRoute( + projectId: string, + routeId: string, + fn: (data: MilestoneData, route: MilestoneRoute) => void, +): void { + const data = liveMilestone(projectId); + const route = data.routes.find((r) => r.id === routeId); + if (!route) return; + fn(data, route); + putMilestone(projectId, data); +} + +export interface MilestoneView { + root: HTMLElement; + 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 = ""; + + // 그림 — 경로 하나 = 그림 하나 + 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; + if (img.src) URL.revokeObjectURL(img.src); + img.removeAttribute("src"); + if (name) + 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); + }); + + 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", + variant: "ghost", + onClick: () => file.click(), + }); + const revert = createButton({ + label: "자동 그림으로 되돌리기", + size: "sm", + 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"), + ), + }); + + 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], + }), + hint, + mapView.root, + noImage, + img, + ], + }); + + const 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}` : ""}`; + loadImage(); + revert.hidden = !canRevertImage(routeId); + hint.textContent = autoImageNote(routeId) ?? HINT; + mapView.draw(); + }; + refresh(); + return { root, refresh }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts new file mode 100644 index 000000000..301afa94e --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -0,0 +1,285 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_State.ts + * 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길(좌측 패널 카드는 244-6 에서 없앰 · + * 공사지 · 기준점은 다이어그램 모달). + * + * 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 + + * 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음. + * 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴). + * [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 · + * 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음) + * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) + * 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림 + * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 + * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) · + * 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점) + * ========================================================================== */ + +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 type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; + +export interface SiteInfo { + length_m: number | null; + mid_default_m: number | null; + start_lonlat: [number, number] | null; + mid_lonlat: [number, number] | null; +} + +interface Payload { + data: MilestoneData; + site: SiteInfo; + images: Record; + auto_images: Record; +} + +export const EMPTY: MilestoneData = { + site: { mid_station_m: null, mid_road: "비포장" }, + base: { enabled: false, name: "", address: "", dir: null, legs: [] }, + routes: [], +}; + +const changes = new EventTarget(); +let saved: Payload | null = null; +let savedFor: string | null = null; + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +/** 저장본 읽기(프로젝트마다 한 번 · force = 다시) */ +export async function loadMilestone(projectId: string, force = false): Promise { + if (!saved || savedFor !== projectId || force) { + saved = await request(`/projects/${projectId}/quantity/milestone`); + savedFor = projectId; + changes.dispatchEvent(new Event("change")); + } + return saved; +} + +export const siteInfo = (): SiteInfo | null => saved?.site ?? null; + +/** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 · + * 오면 다시 그림) · 아직 · 실패 = null(좌표만) */ +const siteAddresses = new Map(); +export function siteAddress(projectId: string, midM: number | null): SiteAddress | null { + const key = `${projectId}:${midM ?? ""}`; + if (!siteAddresses.has(key)) { + siteAddresses.set(key, null); + const query = midM == null ? "" : `?mid_m=${midM}`; + request(`/projects/${projectId}/quantity/milestone/site-address${query}`).then( + (s) => { + siteAddresses.set(key, s); + changes.dispatchEvent(new Event("change")); + }, + () => undefined, + ); + } + return siteAddresses.get(key) ?? null; +} +export const routeImages = (): Record => saved?.images ?? {}; + +/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */ +export const canRevertImage = (routeId: string): boolean => { + const name = saved?.images[routeId]; + const auto = saved?.auto_images?.[routeId]; + return !!name && !!auto && name !== auto; +}; + +/** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */ +const imageRev = new Map(); +export const routeImageKey = (routeId: string): string | null => { + const name = saved?.images[routeId]; + 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; + +/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */ +export function liveMilestone(projectId: string): MilestoneData { + return clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY); +} + +/** 고친 한 벌을 캐시에 · 화면에 알림 */ +export function putMilestone(projectId: string, data: MilestoneData): void { + writeState("b08-milestone", data, projectId); + changes.dispatchEvent(new Event("change")); +} + +/** host 가 붙어 있는 동안 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌 */ +export function onMilestoneChange(host: HTMLElement, fn: () => void): void { + const listener = (): void => { + if (host.isConnected) fn(); + else changes.removeEventListener("change", listener); + }; + changes.addEventListener("change", listener); +} + +/** [저장] — 캐시가 있을 때만 PUT · 서버가 표를 다시 셈 */ +export async function saveMilestone(projectId: string): Promise { + const draft = readState("b08-milestone", projectId); + if (!draft) return; + saved = await request(`/projects/${projectId}/quantity/milestone`, { + method: "PUT", + body: JSON.stringify(draft), + }); + savedFor = projectId; + clearState("b08-milestone", projectId); + changes.dispatchEvent(new Event("change")); +} + +const imageUrl = (projectId: string, routeId: string): string => + `${API_BASE_URL}/projects/${projectId}/quantity/milestone/image?route=${encodeURIComponent(routeId)}`; + +async function call(input: string, init: RequestInit = {}): Promise { + const response = await fetch(input, { credentials: "include", ...init }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; + throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + return response; +} + +/** 경로 그림 — 올리기(교체) · 지우기는 즉시 서버 · 목록 고쳐 알림 */ +export async function uploadImage(projectId: string, routeId: string, file: File): Promise { + const form = new FormData(); + form.append("file", file); + const body = (await ( + await call(imageUrl(projectId, routeId), { method: "POST", body: form }) + ).json()) as { name: string }; + if (saved) saved.images = { ...saved.images, [routeId]: body.name }; + changes.dispatchEvent(new Event("change")); +} + +/** 그림 지우기(자동 그림까지) · userOnly = 사용자 그림만 = 자동 그림으로 되돌리기 */ +export async function deleteImage( + projectId: string, + routeId: string, + userOnly = false, +): Promise { + const url = imageUrl(projectId, routeId) + (userOnly ? "&user_only=true" : ""); + const body = (await (await call(url, { method: "DELETE" })).json()) as { name: string | null }; + if (saved) { + const rest = { ...saved.images }; + const auto = { ...saved.auto_images }; + delete rest[routeId]; + if (body.name) rest[routeId] = body.name; + else delete auto[routeId]; + saved.images = rest; + saved.auto_images = auto; + } + changes.dispatchEvent(new Event("change")); +} + +/** 경로 그림 자동(244-11) — 길 선 · 표지만 보냄 → 서버 헤드리스 캡처 → 자동 그림(사용자 그림 우선) */ +export async function autoRouteImage( + projectId: string, + routeId: string, + 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")); + let reason: string | null; + try { + const res = await request<{ name: string | null; reason: string | null }>( + `/projects/${projectId}/quantity/milestone/image/auto?route=${encodeURIComponent(routeId)}`, + { method: "POST", body: JSON.stringify({ lines, marks }) }, + ); + reason = res.reason; + if (res.name && saved) { + const user = saved.images[routeId] && saved.images[routeId] !== saved.auto_images?.[routeId]; + saved.auto_images = { ...saved.auto_images, [routeId]: res.name }; + if (!user) saved.images = { ...saved.images, [routeId]: res.name }; + imageRev.set(routeId, (imageRev.get(routeId) ?? 0) + 1); + } + } catch (error) { + reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — 그림 올리기를 쓰세요.`; + } + if (reason) { + autoNotes.set(routeId, reason); + showToast(reason, "warning"); + } else autoNotes.delete(routeId); + changes.dispatchEvent(new Event("change")); +} + +/** 그림 → 화면 주소(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 })), + ]; +} + +/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */ +const paths = new Map>(); +export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? []; +/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */ +export const routePaths = (routeId: string): Array> => + paths.has(routeId) ? [paths.get(routeId)!] : []; + +/** [길찾기] 한 구간(연결선 · 244-13) — 서버가 카카오로 km · 자동 값을 캐시에(sameKm = 같으면 안 씀) · + * 거래처 쪽 구간 = 자동 그림(뒤에서) · 돌려줌 = 결과 한 줄(까닭 줄엔 「 — 」) */ +export async function findSegmentKm( + projectId: string, + from: string, + image = true, +): Promise { + const data = liveMilestone(projectId); + const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null); + if (!seg) return ""; + if (!seg.from || !seg.to) { + const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; + return `${seg.text} — ${missing} 좌표가 없습니다(소재지 · 노선) · km 를 손으로 넣으세요.`; + } + try { + const res = await request<{ + km: number | null; + reason: string | null; + path?: Array<[number, number]>; + }>(`/projects/${projectId}/quantity/milestone/directions`, { + method: "POST", + body: JSON.stringify({ + origin: { lon: seg.from[0], lat: seg.from[1] }, + destination: { lon: seg.to[0], lat: seg.to[1] }, + }), + }); + if (res.km == null) return `${seg.text} — ${res.reason ?? "길찾기 실패"}`; + 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); + putMilestone(projectId, fresh); + } else changes.dispatchEvent(new Event("change")); + if (image && seg.key === "route" && paths.has(from)) + void autoRouteImage(projectId, from, routePaths(from)); // 뒤에서 · km 알림 안 기다림 + return `${seg.text} ${res.km} km`; + } catch (error) { + return `${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`; + } +} diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index a6f645812..62d0a3147 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -1,12 +1,15 @@ /* ============================================================================= * B08_Quantity_UI_Options.ts - * 좌측 패널(PLAN 122 · 160-1) — 메인 탭과 같은 컨테이너 넷(구조물 집계표 · 자재 · 토공 · 수리집수면적표 · 접이) + * 좌측 패널(PLAN 122 · 160-1 · 244-1) — 메인 탭과 같은 컨테이너 다섯(구조물 집계표 · 자재 · 토공 · 수리집수면적표 · + * 거리이정표 · 접이) * + [저장] 줄. * * 구조물 집계표 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` * 자재 › 할증 모래 · 자갈 · 시멘트 %(PLAN 124) — 총괄자재수량집계표 그 세 줄 · 0~100 * 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100) * 수리집수면적표 — 빈 자리(다음 옵션) + * 거리이정표 — 카드 없음(244-6 · 공사지 · 기준점은 다이어그램 모달) · 캐시 `b08-milestone` 은 + * [저장] 한 번에 같이 PUT(`…_UI_Milestone_State`) * 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) · * [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음. * ========================================================================== */ @@ -24,6 +27,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { clampPct } from "./B08_Quantity_Options_Calc"; import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc"; +import { saveMilestone } from "./B08_Quantity_UI_Milestone_State"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -210,6 +214,7 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void surcharge: body.material_surcharge_pct, }; clearState("b08-options", projectId); + await saveMilestone(projectId); show(); showToast("수량 산출 옵션을 저장했습니다.", "success"); onSaved(); diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index cf2275cd9..e07c12821 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -24,6 +24,16 @@ visibility: hidden; } +/* 236-1 표 아래 안내 · 경고 줄 — 늘 한 줄(줄임표 · 잘리면 공용 호버로 전문) */ +.b08-quantity__foot { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-caption); +} + /* 자재 집계표 셋(121) — 안내 글 · [저장] 한 줄 */ .b08-material__toolbar { display: flex; @@ -56,6 +66,11 @@ vertical-align: middle; } +/* 247-1 — 모두 연결 = 같은 램프 초록 */ +[data-b08-summary="table"] td.b08-summary__lamp.is-linked::before { + background: var(--color-success); +} + /* 좌측 패널 옵션(122) — 연암 · 경암 두 칸 나란히 · 설명 한 줄 */ .b08-options__row { display: grid; diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 78f27502f..385aebf8c 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -2,7 +2,7 @@ * B08_Quantity_UI_Page.ts * 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61) * - * 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1). 위 탭 단추 = 공용 + * 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1) · 거리이정표(244-1). 위 탭 단추 = 공용 * 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1). 구조물도는 집계표 행을 눌러 여는 모달(95-3). * 자재 아래 보조 탭 = 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표(121-1 `…_UI_Material`). * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. @@ -22,6 +22,7 @@ import { renderMaterialSheet } from "./B08_Quantity_UI_Material"; import { renderHydro } from "./B08_Quantity_UI_Hydro"; import { renderEarthwork } from "./B08_Quantity_UI_Earthwork"; import { createOptionsPanel } from "./B08_Quantity_UI_Options"; +import { renderMilestone } from "./B08_Quantity_UI_Milestone"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -32,6 +33,7 @@ const TABS = [ { id: "material", label: "B08_Quantity_Tab_Materials" }, { id: "earthwork", label: "B08_Quantity_Tab_Earthwork" }, { id: "hydrology", label: "B08_Quantity_Tab_Hydrology" }, + { id: "milestone", label: "B08_Quantity_Tab_Milestone" }, ] as const; type TabId = (typeof TABS)[number]["id"]; @@ -101,6 +103,7 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): voi } if (tab === "hydrology" && projectId) return void renderHydro(view, projectId); if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId); + if (tab === "milestone" && projectId) return renderMilestone(view, projectId); view.append(pendingNote()); } diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index ba9a28b1e..4d74c1927 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -8,6 +8,7 @@ * 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 미연결 시설은 그 측점 * 줄만 측점 칸 왼쪽 노랑 램프(값 빈칸 · 서버 `unlinked` · PLAN 154-1 · 198-1) * · 램프 호버(공용 `ui_template_hover_info`) = 그 측점 빈 조합 칸(160-2 · 화면만) + * · 시설이 모두 조합에 이은 측점 줄 = 같은 자리 초록 램프(호버 「모두 연결됨」 · 247-1) * 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격 * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는 * 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로 @@ -46,6 +47,7 @@ import { } from "./B08_Quantity_Summary_Data"; import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; import type { SheetsRequest } from "./B08_Quantity_UI_Drawing"; +import { footNote } from "./B08_Quantity_UI_Foot"; import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { @@ -100,6 +102,8 @@ async function fetchPipeSummary(projectId: string): Promise { const PIPE_TOP = "관 공"; /** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ const LAMP_CLASS = "b08-summary__lamp"; +/** 모두 연결 = 같은 램프 초록(247-1) */ +const LINKED_CLASS = `${LAMP_CLASS} is-linked`; const LAMP_W = 14; const stationKey = (station: number): number => Math.round(station * 100); /** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ @@ -158,6 +162,8 @@ function buildTable( const missing = stations.map((station) => unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), ); + // 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프 + const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0); const table = createDataTable({ columns: [ { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, @@ -178,7 +184,8 @@ function buildTable( })), total, onRowClick: (i) => onRowClick(stations[i], labels[i]), - cellClass: (i, key) => (key === "station" && missing[i] ? LAMP_CLASS : null), + cellClass: (i, key) => + key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null, frozen: 1, empty: "", caption: L("B08_Quantity_Tab_StructureSummary"), @@ -190,7 +197,9 @@ function buildTable( table.root, (cell) => { const why = missing[Number(cell.closest("tr")?.dataset.i)]; - return why && `${L("B08_Quantity_Summary_LampTip")} — ${why}`; + return why + ? `${L("B08_Quantity_Summary_LampTip")} — ${why}` + : L("B08_Quantity_Summary_LampOk"); }, `td.${LAMP_CLASS}`, ); @@ -258,7 +267,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin const summary = live(); const parts: HTMLElement[] = []; if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`)); - for (const text of summary?.notes ?? []) parts.push(note(text)); + if (summary?.notes.length) parts.push(footNote(summary.notes.join(" · "))); // 236-1 한 줄 if (!columns.length && !summary?.columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); const draw = (): HTMLElement => diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 0ee98c311..cf25e1acf 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -56,7 +56,7 @@ PARTS = ("labor", "material", "expense") COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} DEPTH = 4 # 하위 산근 깊이 상한 _Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …) -NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)" +NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음" EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음" diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 9e41af3dc..56f923c40 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -66,11 +66,6 @@ font-size: var(--text-caption); } -/* 182 설계내역서 묶음 줄 굵게 · 레벨 들여쓰기 = 공용 표 level 옵션(201-1) */ -.b09-estimation .ui-data-table__table tbody tr.is-group td { - font-weight: var(--font-weight-bold); -} - /* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */ .b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child { background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px); @@ -81,16 +76,33 @@ text-align: left; } -/* 200-2 레벨 도구 줄 — 단추 넷 + 안내 한 줄 */ -.b09-estimation__level { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); +/* 235-1 설계내역서 고른 줄 — 줄 전체 상자 테두리(공용 is-focus 밑줄 · 배경 대신) */ +.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td { + box-shadow: + inset 0 2px 0 var(--color-accent), + inset 0 -2px 0 var(--color-accent); } -.b09-estimation__level .b09-estimation__hint { - margin: 0; +.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td:first-child { + box-shadow: + inset 0 2px 0 var(--color-accent), + inset 0 -2px 0 var(--color-accent), + inset 2px 0 0 var(--color-accent); +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td:last-child { + box-shadow: + inset 0 2px 0 var(--color-accent), + inset 0 -2px 0 var(--color-accent), + inset -2px 0 0 var(--color-accent); +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-focus:not(:hover) td:not(.is-edited) { + background-color: var(--color-canvas); +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-focus.is-alt:not(:hover) td:not(.is-edited) { + background-color: var(--dt-zebra); } /* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */ @@ -146,6 +158,11 @@ background: var(--color-warning); } +/* 122 데이터 점검 [업데이트] — 제목 아래 틈 = 카드 왼쪽 여유와 같게 */ +.b09-options [data-b09-refresh] { + margin-top: calc(var(--spacing-8) + var(--spacing-4)); +} + /* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */ .b09-check__head, .b09-check__note { @@ -162,13 +179,51 @@ /* 줄 끌기(204-1) — 놓을 자리 · 레벨 미리 보기 선 · 못 놓는 자리 = 빨간 점선 */ .b09-estimation__drop { + --drop-c: var(--color-primary); position: fixed; z-index: 1000; height: 0; - border-top: 2px solid var(--color-primary); + border-top: 2px solid var(--drop-c); + color: var(--drop-c); pointer-events: none; } .b09-estimation__drop.is-bad { - border-top: 2px dashed var(--color-danger); + --drop-c: var(--color-danger); + border-top-style: dashed; +} + +/* 121 선 머리 = 레벨 시작 점 + 「어디 안 · 몇 레벨」 · 들어갈 폴더 줄 = 테두리 · 끄는 줄 = 흐리게 */ +.b09-estimation__drop::before { + content: ""; + position: absolute; + top: -6px; + left: -5px; + width: 8px; + height: 8px; + border: 2px solid; + border-radius: 50%; + background: var(--color-canvas); +} + +.b09-estimation__drop-tag { + position: absolute; + top: -11px; + padding: 1px var(--spacing-8); + border: 1px solid; + border-radius: var(--radius-pills); + background: var(--color-surface-raised); + font-size: var(--text-caption); + white-space: nowrap; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-drop-into td { + box-shadow: + inset 0 2px 0 var(--color-primary), + inset 0 -2px 0 var(--color-primary); + background-color: color-mix(in srgb, var(--color-primary) 14%, transparent); +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-drag-src td { + opacity: 0.45; } diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index c3c67a6c6..78682d64f 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -52,7 +52,7 @@ import { } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; -import { levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; +import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { buildSumExpense, buildSumLabor, @@ -61,7 +61,7 @@ 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, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; +import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -204,6 +204,9 @@ export function renderB09Estimation(root: HTMLElement): void { empty: "", caption: TABS.find(([id]) => id === tab)?.[1], editable: spec.editable, + // 243-1 고칠 수 있는 칸 = 모든 표 더블클릭 · Enter · F2 (설계내역서 키 = 레벨 키 몫) + editOnDouble: spec.editOnDouble ?? true, + keyEdit: !(spec as BillTable).menu, onEdit: spec.onEdit, rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null @@ -243,7 +246,7 @@ export function renderB09Estimation(root: HTMLElement): void { ); return made; }; - const hint = spec.hint ? note(spec.hint) : null; + const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄 // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표 if (isGroupTab(tab)) { const redraw = (): void => show(tab); @@ -260,15 +263,18 @@ export function renderB09Estimation(root: HTMLElement): void { }), ); } - // 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조 · 202-1 안내 줄 = 표 아래 + // 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력 + // 239-1 · 242-1 이동해 온 줄 = 그 줄 + 자손 빨강 외곽선(is-pick) · 고른 줄 상자와 한때 하나(오면 고름 풂) const bill = (spec as BillTable).menu ? (spec as BillTable) : null; - const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined); - const level = bill ? levelBar(table.root, bill) : null; - panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : [])); - if (focus !== undefined) { - const index = rowIndex(spec.rows, focus); - if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); - } + const at = focus === undefined ? -1 : rowIndex(spec.rows, focus); + if (bill) jumpTo(bill.rows, at); + const table = makeTable( + bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined, + ); + 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)); // 설계내역서 = 스크롤만(표시 = 빨강) }; const apply = (next: Row, fromServer: boolean): void => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1a3405e96..1b5a903b7 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -99,6 +99,8 @@ export interface TabTable { /** 칸 누름 → 갈 줄(호표 칸) */ link?: (index: number, key: string) => Link | null; editable?: (index: number, key: string) => boolean; + /** 고칠 칸 = 더블클릭 · Enter · F2 때만 글 상자(235-1 · 공용 표 editOnDouble) */ + editOnDouble?: boolean; onEdit?: (index: number, key: string, text: string) => void; /** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */ onCell?: (index: number, key: string) => void; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index fdd74a838..4eb8eb286 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -52,14 +52,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) => priceList( sheets.labor || [], "단가(원/인)", - `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`, + `굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`, ); export const buildExpense: TabBuild = ({ sheets, payload }) => priceList( sheets.expense || [], "기준가(천원)", - `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`, + `굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`, ); function madeText(payload: Row): string { @@ -104,7 +104,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { rows, frozen: 2, mergeSame: ["part"], - hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표", + hint: "인건비 줄을 누르면 노무비목록표로 이동", link: (i) => { const row = rows[i]; return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null; @@ -196,7 +196,7 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸(견적 단가를 넣은 줄만) 누름 = 파일 올리기 · 보기 · 출처 단가 칸에 마우스 = 출처 뜻(책 · 호 · 쪽 · 기준일)", + hint: "「적용 단가」 칸을 누르면 출처 고르기 · 「관급 · 별도」 칸을 누르면 구분 바꿈 · 견적 단가는 수기 입력", tip: (i, key) => key.startsWith("p:") && rows[i] ? sourceInfo( @@ -297,7 +297,7 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => { ? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) } : null, frozen: 2, - hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`, + hint: LINK_HINT, link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null), }; }; @@ -317,7 +317,7 @@ export const buildMachine: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 2, - hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`, + hint: LINK_HINT, link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null), }; }; @@ -388,7 +388,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 1, - hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭", + hint: LINK_HINT, link: (i) => links[i] ?? null, level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")), }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 314939f03..fc8e13020 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -9,6 +9,8 @@ * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) * 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level` * 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴 + * 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) · + * 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -24,6 +26,8 @@ import { } from "./B09_Estimation_UI_Tabs_Bill_Edit"; const TEXT_KEYS = ["name", "spec", "unit"]; +/** 폴더 줄 수식 칸 — 자손 합(셈) */ +const SUM_KEYS = ["sum", "labor", "material", "expense"]; export const buildBill: TabBuild = (ctx): BillTable => { const { sheets, user, setUser } = ctx; @@ -60,10 +64,11 @@ export const buildBill: TabBuild = (ctx): BillTable => { total: { name: "순공사비 계(관급 제외)", ...direct }, frozen: 1, hint: - "기본 로직이 짠 내역 = 초기값 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(우클릭 · 고친 칸 = 색 칸 · 풍선 = 자동 값) · " + - "금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 묶음 금액 = 아래 항목 합 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재(관급자재대 밖으로 못 옮김) · " + - "조달수수료 율(%)은 수량 칸 · 「연결 ▾」 = 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · 처음으로 = 왼쪽 [내역 초기화] · " + - `빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`, + "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" + + (direct.blank + ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` + : ""), + editOnDouble: true, editable: (i, key) => { const r = rows[i]; if (!r) return false; @@ -86,6 +91,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null), tip: (i, key) => { const r = rows[i]; + 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}`]; return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`; @@ -93,10 +99,17 @@ export const buildBill: TabBuild = (ctx): BillTable => { rowClass: (i) => { const r = rows[i]; if (!r) return null; - const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : ""; + // 240 폴더 줄 = 처음 · 레벨 고쳐 생긴 것 모두 같은 줄 꼴(글자 크기 = 레벨 · 공용 is-lvN) + const title = r.kind === "item" ? "" : "is-title"; return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null; }, - level: { key: "name", of: (i) => rows[i]?.level as number | undefined }, + // 228-1 레벨 = 셈 나무 깊이(들여 · 내어쓰기 · 끌기 = 다시 셈 · 다시 그림) · 글꼴 = 레벨 · 묶음(빈 공종도) + level: { + key: "name", + of: (i) => rows[i]?.level as number | undefined, + group: (i) => rows[i]?.kind !== "item", + tree: true, + }, menu: (i) => billMenu(ctx, raw, i), drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before), onCell: (i, key) => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts index 786ccc285..951c43559 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts @@ -5,6 +5,7 @@ * 놓을 자리 = 줄 사이 틈(포인터가 줄 가운데 위 = 그 줄 앞) · 끄는 줄 자손 = 함께 감 · 자기 자손 안 = 못 놓음 * 레벨 = 위 줄 + 1(위 줄 아래로 · 항목이면 저절로 묶음) ~ 아래 줄 레벨 · 맨 위(1) = 맨 위 줄만 * 놓기 = 표 drop(`…_Bill_Edit` dropMove — 메뉴와 같은 지킬 것) · 미리 보기 선(못 놓음 = is-bad · 커서 not-allowed) + * 미리 보기(121) = 선 머리 글 「○○ 안 · 레벨 N」 + 들어갈 폴더 줄 테두리 + 끄는 줄 흐리게 · 좌우 한 단 = 들여쓰기 폭(--dt-indent) * 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름) * ========================================================================== */ @@ -64,9 +65,15 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri const tr = target.closest("tr[data-i]"); if (!tr || !root.contains(tr)) return; const from = Number(tr.dataset.i); - const step = parseFloat(getComputedStyle(tr).fontSize) || 14; + const step = parseFloat(getComputedStyle(tr).getPropertyValue("--dt-indent")) || 14; const line = document.createElement("div"); line.className = "b09-estimation__drop"; + const tag = document.createElement("span"); + tag.className = "b09-estimation__drop-tag"; + line.append(tag); + const rowEl = (i: number): HTMLElement | null => root.querySelector(`tr[data-i="${i}"]`); + let into: HTMLElement | null = null; + let lifted: HTMLElement[] = []; let on = false; let run: (() => void) | null = null; let seen = ""; @@ -76,6 +83,13 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri if (!on) { on = true; document.body.append(line); + const lv = Number(table.rows[from]?.level ?? 1); + for (let i = from; i === from || Number(table.rows[i]?.level ?? 1) > lv; i++) { + const el = rowEl(i); + if (!el) break; + el.classList.add("is-drag-src"); + lifted.push(el); + } document.body.style.userSelect = "none"; window.getSelection()?.removeAllRanges(); } @@ -95,14 +109,24 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri if (key !== seen) { seen = key; run = at ? table.drop!(from, at.parent, at.before) : null; + const p = at?.parent ? table.rows.findIndex((r) => String(r.key) === at.parent) : -1; + into?.classList.remove("is-drop-into"); + into = p >= 0 ? rowEl(p) : null; + into?.classList.add("is-drop-into"); + tag.textContent = !run + ? "못 놓음" + : `${p >= 0 ? `「${String(table.rows[p].name ?? "").trim()}」 안` : "맨 위"} · 레벨 ${at!.level}`; } + const cell = tr.querySelector("td.ui-data-table__lv") ?? tr; const box = root.getBoundingClientRect(); - const left = box.left + ((at?.level ?? want) - 1) * step + 8; + const left = cell.getBoundingClientRect().left + ((at?.level ?? want) - 1) * step + 8; Object.assign(line.style, { top: `${y - 1}px`, left: `${left}px`, width: `${Math.max(box.right - left, 40)}px`, }); + // 머리 글 = 명칭 칸 바로 오른쪽 · 선 위에 걸침(줄 이름을 덮지 않게) + tag.style.left = `${Math.max(12, cell.getBoundingClientRect().right - left + 8)}px`; line.classList.toggle("is-bad", !run); document.body.style.cursor = run ? "grabbing" : "not-allowed"; }; @@ -111,6 +135,9 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop); line.remove(); + into?.classList.remove("is-drop-into"); + for (const el of lifted) el.classList.remove("is-drag-src"); + lifted = []; document.body.style.userSelect = ""; document.body.style.cursor = ""; if (!on) return; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index d8ea35242..f036c2206 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -5,12 +5,14 @@ * 저장 = 사용자 값 `user.bill` v2(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시 * {v: 2, rows: [표 차례 {key · parent(없음 = 맨 위) · 고친 칸만}], gone: [지운 자동 줄 키]} * 자동 줄 = 키 · 부모 + 고친 칸(name · spec · unit · qty · ref · null = 일부러 빈칸 · 없음 = 원천) - * 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위 묶음 · cost direct + * 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위로 내어 쓴 줄(묶음 · cost direct) * 첫 손질 = 보이는 줄의 키 · 부모 · 차례만 v2 로 굳힘(옛 꼴 qty · 손 줄 칸은 옮김 · 자동 값은 안 옮김) * 계약 = `Calc_Bill.py` 머리 — 셈 출력 줄 parent · auto(기본 로직 줄) · auto_{칸}(고친 칸의 자동 값) · level · * 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비) - * 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기 + * 바뀌는 옮김 · 자재 줄 삭제(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 삭제 * 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) + * 더함(246) = 「항목 추가」 하나 — 누른 줄이 항목 = 바로 아래 같은 부모 · 폴더 = 그 폴더 첫 하위 · + * 맨 위 1단(새 공종) = 레벨 단추 · ← 로 내어 씀(관급자재대 앞) * 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 · * 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`) * ========================================================================== */ @@ -33,7 +35,6 @@ export type BillTable = TabTable & { }; const NEW_NAME = "새 줄"; -const NEW_SECTION = "새 공종"; /** 고칠 칸 — 자동 줄은 덮개 */ const FIELDS = ["name", "spec", "unit", "qty", "ref"]; /** 깊이 한도 — 1 = 맨 위 공종 */ @@ -242,26 +243,13 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex if (run) parts[part].push([label, run]); }; - // 더함 — 위 · 아래(같은 부모) · 하위(묶음) · 새 공종(맨 위 · 관급자재대 앞) - add(0, "위에 줄 추가", (w, e) => { - if (!e.parent) return false; - const at = find(w.bill.rows, key); - w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, newItem(e.parent)); - }); - add(0, "아래에 줄 추가", (w, e) => { - if (!e.parent || key === "fee") return false; - w.bill.rows.splice(find(w.bill.rows, key) + 1, 0, newItem(e.parent)); - }); - add(0, "아래에 하위 줄 추가", (w) => { - if (!isGroup(w, key)) return false; - w.bill.rows.push(newItem(key)); - }); - add(0, "새 공종 추가(1단)", (w, e) => { + // 항목 추가 — 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모(조달수수료 = 관급 묶음 끝 · 셈이 그 뒤에 둠) + add(0, "항목 추가", (w, e) => { const list = w.bill.rows; - const from = find(list, key) < 0 ? String(e.parent) : key; - const top = chain(list, from).at(-1)!; - const at = find(list, top.key) + (costOf(w, list, from) === "supply" ? 0 : 1); - list.splice(at, 0, { key: newKey(), kind: "group", cost: "direct", name: NEW_SECTION }); + const at = find(list, key); + if (isGroup(w, key)) list.splice(at + 1, 0, newItem(key)); + else if (!e.parent) return false; + else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent)); }); if (linkable(row)) { const [picks, more] = linkFor(ctx, rows, i); @@ -292,10 +280,18 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex }); add(2, "내어쓰기 ←", (w, e) => { const up = e.parent ? entryOf(w, e.parent) : undefined; - if (!up?.parent) return false; // 맨 위로는 안 냄(새 공종 = 메뉴) - e.parent = up.parent; + if (!up || (!up.parent && !canHold(key))) return false; // 자재 줄 = 1단 안 됨 pull(w.bill.rows, key); - w.bill.rows.splice(find(w.bill.rows, up.key) + 1, 0, e); + if (up.parent) e.parent = up.parent; + else { + // 새 공종(1단) = 직접비 맨 위 · 손 줄은 묶음(자재 줄 돈 갈래 바뀜 = fits 가 막음) + delete e.parent; + e.cost = "direct"; + if (!row.auto) Object.assign(e, { kind: "group", qty: null, ref: null }); + } + // 맨 위로 = 관급자재대 앞 · 그 밖 = 옛 부모 바로 뒤 + const top = !up.parent && costOf(w, w.bill.rows, up.key) === "supply"; + w.bill.rows.splice(find(w.bill.rows, up.key) + (top ? 0 : 1), 0, e); }); } if (!row.auto && row.kind === "item") @@ -315,7 +311,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex }); // 지움 — 자식은 지운 줄 부모로 · 자동 줄 = gone · 자식 있는 맨 위 묶음은 못 지움 if (!fixed(key) && !key.startsWith("mat:")) - add(3, "줄 지우기", (w, e) => { + add(3, "줄 삭제", (w, e) => { const list = w.bill.rows; if (!e.parent && list.some((c) => c.parent === key)) return false; for (const c of list) if (c.parent === key) c.parent = e.parent; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index 2d0b40f52..aff2f53c6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -1,12 +1,17 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Bill_Level.ts - * B09 설계내역서 레벨 도구 줄(PLAN 200-2) — 표 위 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · 키 · 안내 한 줄. + * B09 설계내역서 레벨 단추(PLAN 200-2 · 229-1) — 보조 탭 줄 오른쪽 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · + * 키 · 안내 한 줄(표 아래). * + * 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) · 안내 글 = 표 아래(202-1) * 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음 - * 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) + * 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) · + * 꼴 = 줄 전체 상자 테두리(235-1 · 페이지 CSS) · 글 칸도 한 번 누름 = 고름 · 더블클릭 · Enter · F2 = 입력(공용 표) * 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂) * 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄) * 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름 + * 242-1 이동해 온 범위(총괄 등 → 그 줄 + 자손 · 빨강 is-pick) = 고름과 한때 하나 — 오면 고름 풂 · + * 줄 누름 · 방향키 · Esc · 끌어 놓음 = 빨강 풂 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -23,24 +28,19 @@ const ACTS: [string, string][] = [ ["↓ 아래로", "아래로 ↓"], ]; -const GUIDE = - "레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기"; -const GUIDE_MORE = - "레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " + - "들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " + - "내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " + - "위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " + - "끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " + - "키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " + - "묶음 줄로 바꾸기 · 추가 · 지우기 = 줄 우클릭"; +const GUIDE = "줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력"; let picked: string | null = null; +/** 242-1 이동해 온 범위 [첫 줄, 끝 줄] — 고름과 한때 하나 */ +let jump: [number, number] | null = null; /** 지금 그린 도구 줄 — 키가 부름 */ let live: { root: HTMLElement; - bar: HTMLElement; + tools: HTMLElement[]; run: (label: string) => void; sync: () => void; + edit: (index: number) => void; + index: () => number; } | null = null; let keysOn = false; @@ -48,6 +48,23 @@ let keysOn = false; export const pickedClass = (row: DataRow | undefined): string | null => row && picked !== null && String(row.key) === picked ? "is-focus" : null; +/** 그 줄 + 자손(뒤 줄 중 레벨이 더 깊은 연속 줄) 끝 줄 번호 */ +export function subtreeEnd(rows: DataRow[], at: number): number { + const lv = Number(rows[at]?.level ?? 1); + let end = at; + while (end + 1 < rows.length && Number(rows[end + 1]?.level ?? 1) > lv) end++; + return end; +} + +/** 242-1 이동해 온 줄(-1 = 없음) — 범위 = 그 줄 + 자손 · 오면 고름 풂(Shell 이 표 그리기 전에) */ +export function jumpTo(rows: DataRow[], at: number): void { + jump = at >= 0 ? [at, subtreeEnd(rows, at)] : null; + if (jump) picked = null; +} + +/** 지금 빨강 범위 — Shell 줄 class 곁값(pickClass) */ +export const jumpRange = (): [number, number] | null => jump; + /** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */ export function menuActs(table: BillTable, index: number): Map void> { const items = index >= 0 && table.menu ? table.menu(index) : []; @@ -76,9 +93,21 @@ function listenKeys(): void { if (keysOn) return; keysOn = true; document.addEventListener("keydown", (event) => { - if (!live?.root.isConnected || picked === null) return; + if (!live?.root.isConnected || (picked === null && !jump)) return; const at = document.activeElement; - if (at && at !== document.body && !live.root.contains(at) && !live.bar.contains(at)) return; + if ( + at && + at !== document.body && + !live.root.contains(at) && + !live.tools.includes(at as HTMLElement) + ) + return; + // 242-1 빨강 중 = 방향키 · Esc 가 빨강만 풂(고른 줄 없음) + if (picked === null) { + if (event.key !== "Escape" && !keyAct(event, editing(at))) return; + jump = null; + return live.sync(); + } // Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂 if (event.key === "Escape") { if (!editing(at)) { @@ -88,6 +117,12 @@ function listenKeys(): void { (at as HTMLElement).blur(); return live.root.focus(); } + // Enter · F2 = 고른 줄 입력 시작(235-1) — 도구 단추 위 Enter 는 단추 몫 + if ((event.key === "Enter" || event.key === "F2") && !editing(at)) { + if (live.tools.includes(at as HTMLElement) || event.isComposing) return; + event.preventDefault(); + return live.edit(live.index()); + } const label = keyAct(event, editing(at)); if (!label) return; event.preventDefault(); @@ -95,8 +130,12 @@ function listenKeys(): void { }); } -/** 표 위 레벨 도구 줄 — 표 root 에 줄 누름(고름)을 붙임 */ -export function levelBar(root: HTMLElement, table: BillTable): HTMLElement { +/** 레벨 단추 넷(보조 탭 줄로) · 안내 글(표 아래로) — 표 root 에 줄 누름(고름)을 붙임 */ +export function levelBar( + root: HTMLElement, + table: BillTable, + edit: (index: number) => void = () => {}, +): { tools: HTMLElement[]; guide: HTMLElement } { const index = (): number => picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked); const run = (label: string): void => menuActs(table, index()).get(label)?.(); @@ -110,17 +149,17 @@ export function levelBar(root: HTMLElement, table: BillTable): HTMLElement { button.dataset.b09Level = label; return button; }); - const guide = document.createElement("span"); - guide.className = "b09-estimation__hint"; + const guide = document.createElement("p"); + guide.className = "ui-general-block__empty b09-estimation__line-hint"; guide.textContent = GUIDE; - attachHoverInfo(guide, GUIDE_MORE); - const bar = document.createElement("div"); - bar.className = "b09-estimation__level"; - bar.append(...buttons, guide); + attachHoverInfo(guide, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); // 236-2 잘릴 때만 const sync = (): void => { const acts = menuActs(table, index()); ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label))); for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus"); + if (!jump) + for (const tr of root.querySelectorAll("tr.is-pick")) + tr.classList.remove("is-pick", "is-pick-top", "is-pick-end"); root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus"); }; root.addEventListener("click", (event) => { @@ -128,11 +167,15 @@ export function levelBar(root: HTMLElement, table: BillTable): HTMLElement { const row = tr ? table.rows[Number(tr.dataset.i)] : undefined; if (!row) return; picked = String(row.key); + jump = null; sync(); }); - attachDrag(root, table, (key) => (picked = key)); + attachDrag(root, table, (key) => { + picked = key; + jump = null; + }); sync(); - live = { root, bar, run, sync }; + live = { root, tools: buttons, run, sync, edit, index }; listenKeys(); - return bar; + return { tools: buttons, guide }; } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 0e384d428..9152d1120 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -100,7 +100,7 @@ async function importLib( ): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) return; - showToast("서고에서 들여오는 중…", "info"); + showToast("가져오는 중…", "info"); try { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, { method: "POST", @@ -119,11 +119,11 @@ async function importLib( const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); showToast( - got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함", + got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "가져온 호표로 연결함", "success", ); } catch (error) { - showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error"); + showToast(`못 가져옴 — ${error instanceof Error ? error.message : error}`, "error"); } } @@ -157,12 +157,9 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L const picks: Leaf[] = [ ["일위대가 호표", () => pickHopyo(ctx, apply, "dg")], ["단가산출 호표", () => pickHopyo(ctx, apply, "sg")], - ["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)], - ["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)], - [ - "단가산출 로직(M01 · 새 산근)", - () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)), - ], + ["구조물 조합", () => 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 313a30d7a..8618460f7 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -6,6 +6,8 @@ * 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음 * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 + * 248-1 빨강 하나(공용 고름 강조 안 붙임 · 스크롤만) · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · + * 누른 줄만 고름(is-focus) · Esc = 고름도 풂 * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 * 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 * 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1) @@ -17,6 +19,7 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { clampColumns, createGroupCards, + unpickCards, type GroupCard, type GroupFoot, type GroupLine, @@ -178,13 +181,28 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine /* ── 그리기 ───────────────────────────────────────────────────────────── */ /** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */ -function lineHint(hint: HTMLElement | null): HTMLElement | null { - if (!hint) return null; +export function lineHint(hint: HTMLElement | null): HTMLElement | null { + if (!hint || hint.classList.contains("b09-estimation__line-hint")) return hint; hint.classList.add("b09-estimation__line-hint"); attachHoverInfo(hint, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); return hint; } +/** 248-1 지금 그린 묶음 판 빨강 풀기(esc = 고름도) — 방향키 · Esc 가 부름 */ +let live: { root: HTMLElement; unpick: (esc: boolean) => void } | null = null; +let keysOn = false; +const UNPICK_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Escape"]); + +function listenKeys(): void { + if (keysOn) return; + keysOn = true; + document.addEventListener("keydown", (event) => { + if (!live?.root.isConnected || !UNPICK_KEYS.has(event.key)) return; + if ((event.target as Element | null)?.closest?.("input, textarea, select")) return; + live.unpick(event.key === "Escape"); + }); +} + /** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ const chosen = new Map(); @@ -227,11 +245,28 @@ export function renderGroupPanel(o: { placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽 }); cards.dataset.b09Table = o.tab; + live = { root: cards, unpick: (esc) => unpickCards(cards, esc) }; + listenKeys(); return [cards, ...(lead ? [lead] : [])]; } o.setTools([toggle]); - const range = pickRange(o.spec.rows, o.focus); - const table = o.makeTable((i) => pickClass(range, i)); - if (at >= 0) requestAnimationFrame(() => table.focusRow(at)); + let range = pickRange(o.spec.rows, o.focus); + let chose = -1; // 누른 줄(다시 그려도 유지) + const table = o.makeTable((i) => (i === chose ? "is-focus" : pickClass(range, i))); + // 줄 class 만 고침(다시 그리지 않음 — 더블클릭 입력 그대로) + const mark = (row: number): void => { + range = null; + chose = row; + for (const tr of table.root.querySelectorAll("tr.is-pick, tr.is-focus")) + tr.classList.remove("is-pick", "is-pick-top", "is-pick-end", "is-focus"); + table.root.querySelector(`tr[data-i="${row}"]`)?.classList.add("is-focus"); + }; + table.root.addEventListener("click", (event) => { + const tr = (event.target as Element).closest("tr[data-i]"); + mark(tr ? Number(tr.dataset.i) : chose); + }); + live = { root: table.root, unpick: (esc) => mark(esc ? -1 : chose) }; + listenKeys(); + if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) return [table.root, ...(lead ? [lead] : [])]; } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index 50b500e2f..26490385e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -27,8 +27,7 @@ const EMPTY = "연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]"; /** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */ const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1) -const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0"; -const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김"; +const BLANK = "빈 줄은 금액을 모르는 줄(합계는 아는 줄만)"; /** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */ const blankNote = (b: Row): string => (b.blank ? `빈 줄 ${b.blank}` : ""); const blankTip = (b: Row | undefined): string | null => @@ -65,7 +64,7 @@ export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => { ], rows, frozen: 3, - hint: `호표 = 일위대가표 블록 합계 · ${BLANK} · 줄을 누르면 그 블록으로`, + hint: `줄을 누르면 그 블록으로 이동 · ${BLANK}`, link: (i: number): Link | null => (rows[i] ? { tab: "dg", key: String(rows[i].key) } : null), tip: (i, key) => key === "spec" @@ -152,7 +151,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { ], rows, frozen: 1, - hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`, + hint: `줄을 누르면 단가를 낸 탭으로 이동 · ${BLANK}`, link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), level: blockLevel("name", (i) => titles.has(i)), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index 1776afdf3..fc51ab5d0 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -56,9 +56,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 2, - hint: - "산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" + - notes(sheets), + hint: "줄을 누르면 그 블록으로 이동" + notes(sheets), link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null), tip: (i, key) => (key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) : null), }; @@ -159,8 +157,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { rows, frozen: 1, hint: - "산출내역 = 값이 든 식(B09 굳힌 단가 * 수량 = 금액 · 작업량 Q = ROUND 2) · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" + - notes(sheets), + "줄을 누르면 단가를 낸 탭으로 이동 · 자재 적용 단가를 고치면 [저장] 뒤 반영" + notes(sheets), link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), level: blockLevel("text", (i) => titles.has(i)), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index 5fd4fba08..f3cef1dd2 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -18,8 +18,7 @@ import { type TabTable, } from "./B09_Estimation_UI_Tabs"; -const HINT = - "총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표"; +const HINT = "줄을 누르면 목록표로 이동"; /** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */ const hint = (payload: Row, extra = ""): string => { @@ -28,7 +27,7 @@ const hint = (payload: Row, extra = ""): string => { }; /** 줄이 없을 때 표 위 맨 앞 안내 */ -const EXPENSE_NONE = "경비 갈래 자원 없음 — 중기 · 자재로 셈 · 경비 로직이 들어오면 채워짐"; +const EXPENSE_NONE = "경비 자원 없음"; function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): TabBuild { return ({ payload }): TabTable => { @@ -86,7 +85,7 @@ export const buildSumMachine: TabBuild = ({ payload }): TabTable => { } : null, frozen: 2, - hint: hint(payload, " · 비목마다 ROUND(시간 × 시간당, 0)"), + hint: hint(payload), link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null), }; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 96a3691aa..c95485580 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -2,7 +2,8 @@ * B09_Estimation_UI_Tabs_Total.ts * B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`. * - * 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바) + * 총괄 = 맨 위 내역 줄(239-1 · 설계내역서 레벨 1 · 2 집계 + 순공사비 계 · 누름 = 설계내역서 그 줄 빨강) + + * 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바) * 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1) * 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로 * 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · @@ -84,6 +85,42 @@ const nameLevel = (rows: DataRow[]) => ({ of: (i: number) => rows[i]?.level as number | undefined, }); +/** 239-1 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도 — 합이 순공사비) · + * 금액 = 설계내역서 셈 그대로 · 끝 = 순공사비 계 · 누름 = 설계내역서 그 줄(`bill`) */ +function billRows(bill: Row | undefined): DataRow[] { + const top = (bill?.rows || []).filter( + (r: Row) => r.cost === "direct" && (r.level === 1 || (r.level === 2 && r.kind !== "item")), + ); + if (!top.length) return []; + const direct: Row = bill?.totals?.direct || {}; + const amounts = (r: Row) => ({ + amount: r.sum ?? null, + labor: r.labor ?? null, + material: r.material ?? null, + expense: r.expense ?? null, + }); + const blank = direct.blank ? ` · 빈 줄 ${direct.blank} — 아는 금액만` : ""; + return [ + ...top.map((r: Row) => ({ + key: `bill:${r.key}`, + part: "내역", + name: r.no ? `${r.no} ${r.name}` : r.name, + ...amounts(r), + note: "누르면 설계내역서 그 줄로", + level: r.level, + bill: r.key, + })), + { + key: "bill_sum", + part: "내역", + name: "순공사비 계", + ...amounts(direct), + note: `설계내역서 레벨 1 합(관급 제외)${blank}`, + level: 1, + }, + ]; +} + function rateText(formula: string, rate: Num): string { return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`); } @@ -139,6 +176,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { const sums: Row = t.sums || {}; const env = est.environment_kind === "road" ? "road" : "other"; const rows: DataRow[] = [ + ...billRows(ctx.sheets.bill), row("in_price", "요율", "추정가격(원)", null, "일반관리비 · 이윤 · 고용보험 구간", { hand: hand("in_price"), }), @@ -211,16 +249,13 @@ export const buildTotal: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["part"], - hint: - "요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " + - "2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " + - "수기 입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " + - "공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음", + hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값", editable: (i, key) => (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || (key === "hand" && String(rows[i]?.key) in HAND), onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), onCell: (i, key) => clickEstimate(ctx, String(rows[i]?.key), key, rows[i]?.from), + link: (i) => (rows[i]?.bill ? { tab: "bill", key: String(rows[i].bill) } : null), tip: (i, key) => { const r = rows[i]; if (!r?.rt || (key !== "rate" && key !== "candidate")) return null; @@ -292,7 +327,7 @@ export const buildCost: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["group", "part"], - hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄", + hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동", editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), link: (i) => { diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 8e2a2f40b..7ae990503 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -47,6 +47,7 @@ from common_util.common_util_route_geometry import ( interpolate_vertex, is_uphill_at, ) +from common_util.common_util_structure_station import snap_candidates from common_util.common_util_wamis_rainfall import idf_intensity from config.config_system import ( DRAINAGE_BOX_THRESHOLD_MM, @@ -198,7 +199,8 @@ def build_detail( ) for pipe in routing.base_pipes ] - pipes = place_pipes(vertices, base, routing.strength_curve) + # 자동 관 자리 = 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 확정본(위)은 단추로만 + pipes = snap_candidates(place_pipes(vertices, base, routing.strength_curve), vertices) detail = DrainageDetail( route_lonlat=routing.route_lonlat, diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 2a3c67f3e..1b8454b1c 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -35,6 +35,10 @@ from shapely.geometry import LineString, Point from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex +from common_util.common_util_structure_station import ( + route_general_stations, + snap_structure_station, +) from common_util.common_util_summary_item import parse_group_items, parse_summary_item from config.config_system import ( DRAINAGE_CACHE_DIRNAME, @@ -216,10 +220,12 @@ def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> line = LineString([(vertex.x, vertex.y) for vertex in vertices]) if line.length <= 0: return points + # 새 노선 위 자리도 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 시설 속성은 관과 함께 옮겨 감 + stations = route_general_stations(vertices[-1].chainage_m) for point in points: if point.x is None or point.y is None: continue - moved = float(line.project(Point(point.x, point.y))) + moved = snap_structure_station(float(line.project(Point(point.x, point.y))), stations) shift = moved - float(point.chainage_m) point.chainage_m = moved if point.start_m is not None: diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 06149fb23..95d618d03 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -23,6 +23,7 @@ import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul"; import { appendBalanceLayer, haulPlanChips } from "./common_util_mass_haul_balance_view"; import { segmentOf } from "./common_util_mass_haul_curve"; import { L, stationLabel, svgElement, svgText } from "./common_util_svg"; +import { viewOf } from "./common_util_view_window"; /** * 유토곡선 X축을 **호출한 쪽 종단 그래프와 맞추는** 값. 좌우 여백과 누가거리 최댓값이 @@ -74,6 +75,8 @@ export interface MassHaulWindowState { settled: boolean; /** 예약해 둔 다음 프레임(중복 예약 방지). */ frame: number; + /** `frame` 을 받아 둔 창 — 걷을 때도 같은 창에서(230-2). */ + view?: Window; } export function createMassHaulWindowState(): MassHaulWindowState { @@ -81,11 +84,17 @@ export function createMassHaulWindowState(): MassHaulWindowState { } /** 아직 목표에 못 갔으면 다음 프레임에 한 번 더 그리게 예약한다. */ -export function scheduleMassHaulSettle(state: MassHaulWindowState, redraw: () => void): void { - if (state.frame) cancelAnimationFrame(state.frame); +/** `node` = 곡선이 놓인 요소 — 그 창의 틀에서 잰다(새 창 최대화 때 메인 창 틀은 멈춤 · 230-2). */ +export function scheduleMassHaulSettle( + state: MassHaulWindowState, + redraw: () => void, + node?: Node, +): void { + if (state.frame) (state.view ?? window).cancelAnimationFrame(state.frame); state.frame = 0; if (state.settled) return; - state.frame = requestAnimationFrame(() => { + state.view = viewOf(node); + state.frame = state.view.requestAnimationFrame(() => { state.frame = 0; redraw(); }); diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py new file mode 100644 index 000000000..79eb2cdd3 --- /dev/null +++ b/common_util/common_util_structure_station.py @@ -0,0 +1,80 @@ +"""구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안이면 그 측점에 붙음(234-1). + +TS 짝 = `common_util/common_util_structure_station.ts`(화면 입력 · 끌기 · 방향키 · 정수 맞춤 단추). +거울 시험 = `resources/tester/test_234_1_structure_station.py`. +일반 측점 = 격자(간격) · 시종점 — 서버 자동 배치 · 노선 재투영이 쓴다. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import replace +from typing import TypeVar + +from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex +from config.config_system_design import SECTION_STATION_INTERVAL_M + +_Candidate = TypeVar("_Candidate") + +# 일반 측점에 붙는 폭(m) — 이 안이면 그 측점 값(끝 측점처럼 정수가 아니어도). +GENERAL_SNAP_M = 1.0 +_EPS = 1e-6 + + +def general_stations(total_m: float, interval_m: float) -> list[float]: + """격자 측점 + 끝 측점(`Sections_Core._chainages` 와 같은 줄).""" + if not (total_m > 0 and interval_m > 0): + return [0.0] + values = [index * interval_m for index in range(int(total_m // interval_m) + 1)] + if total_m - values[-1] > _EPS: + values.append(float(total_m)) + 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 안이면 그 측점. 두 번 맞춰도 같다.""" + value = float(chainage_m) + if not math.isfinite(value): + return value + direct = _near_general(value, stations) + if direct is not None: + return direct + whole = float(math.floor(value + 0.5)) + near = _near_general(whole, stations) + return whole if near is None else near + + +def route_general_stations(total_m: float) -> list[float]: + """노선 길이의 일반 측점 — 서버 측점 간격(`SECTION_STATION_INTERVAL_M`).""" + return general_stations(total_m, SECTION_STATION_INTERVAL_M) + + +def snap_candidates( + candidates: Sequence[_Candidate], vertices: Sequence[RouteVertex] +) -> list[_Candidate]: + """자동 배치 관 자리 맞춤 — 좌표는 맞춘 자리로 다시 찍고, 같은 자리로 모이면 앞선 것만. + + `candidates` 는 `chainage_m · x · y` 를 가진 dataclass(`StructureCandidate`).""" + total_m = float(vertices[-1].chainage_m) + stations = route_general_stations(total_m) + snapped: list[_Candidate] = [] + taken: set[float] = set() + for candidate in candidates: + at = min(snap_structure_station(candidate.chainage_m, stations), total_m) + if at in taken: + continue + taken.add(at) + x, y, _ = interpolate_vertex(list(vertices), at) + snapped.append(replace(candidate, chainage_m=at, x=x, y=y)) + return snapped diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts new file mode 100644 index 000000000..4c5cc21d2 --- /dev/null +++ b/common_util/common_util_structure_station.ts @@ -0,0 +1,69 @@ +/* ============================================================================= + * common_util/common_util_structure_station.ts + * 구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안이면 그 측점에 붙음(234-1). + * + * 파이썬 짝 = `common_util/common_util_structure_station.py`(서버 자동 배치 · 노선 재투영). + * 거울 시험 = `resources/tester/test_234_1_structure_station.py`. + * 일반 측점 = 격자 · 시종점(구조물 측점 kind "irregular" 은 뺌) — 상세를 읽을 때 한 번 둔다. + * ========================================================================== */ + +/** 일반 측점에 붙는 폭(m) — 이 안이면 그 측점 값(끝 측점처럼 정수가 아니어도). */ +export const GENERAL_SNAP_M = 1; +const EPS = 1e-6; + +let general: number[] = []; + +/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). */ +export function setGeneralStations( + stations: ReadonlyArray<{ chainage_m: number; kind?: string }> | null | undefined, +): void { + general = (stations ?? []) + .filter((station) => station.kind !== "irregular") + .map((station) => station.chainage_m); +} + +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). 두 번 맞춰도 같다. */ +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 whole = Math.floor(chainageM + 0.5); + return nearGeneral(whole, stations) ?? whole; +} + +/** 방향키 한 걸음(1 m) — 붙기가 제자리로 되돌리면 한 걸음 더(끝 119.8 → 118). 노선 밖이면 제자리. */ +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; +} + +/** 이미 맞춘 값인가(정수 맞춤 단추가 옮길 것을 가름). */ +export const isSnappedStation = ( + chainageM: number, + stations: readonly number[] = general, +): boolean => Math.abs(snapStructureStation(chainageM, stations) - chainageM) < EPS; diff --git a/common_util/common_util_view_window.ts b/common_util/common_util_view_window.ts new file mode 100644 index 000000000..88070a390 --- /dev/null +++ b/common_util/common_util_view_window.ts @@ -0,0 +1,18 @@ +/* ============================================================================= + * common_util_view_window.ts + * 요소가 지금 놓인 창 — 종단 패널을 새 창으로 빼면 그 창이다(230-1 · 230-2). + * + * 메인 창 전역 `requestAnimationFrame` · 시계를 쓰면, 새 창을 최대화해 메인 창이 가려질 때 + * 브라우저가 메인 창 화면 틀을 멈추고 시계를 늦춰 휠 · 편집 · 다시 그리기가 멈췄다. + * 화면 틀 · 시계는 이 함수로 그 요소의 창에서 받는다. + * ========================================================================== */ + +/** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */ +export function viewOf(node: Node | null | undefined): Window { + return node?.ownerDocument?.defaultView ?? window; +} + +/** 요소가 놓인 창의 다음 화면 틀에 부른다. */ +export function frameOf(node: Node | null | undefined, callback: FrameRequestCallback): number { + return viewOf(node).requestAnimationFrame(callback); +} diff --git a/docs/raw/plans/2026-10-10_plan_229-236.md b/docs/raw/plans/2026-10-10_plan_229-236.md new file mode 100644 index 000000000..b781a5010 --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_229-236.md @@ -0,0 +1,59 @@ +# 2026-10-10 계획 229~236장 기록 — 내역서 레벨 단추 · 새 창 휠 · 노면공 범위 · 3D 띠 · 강조 한 칸 · 정수 측점 · 내역서 트리 · 안내 글 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 101~113 · 조사 AD · AE · AF · AG. + +## 229. B09 설계내역서 레벨 단추 = 보조 탭 줄 (sub1) +정함(브레인) = 사용자 101번 · 설계내역서는 하위 탭이 없으니 보조 탭 줄(181 · 202 오른쪽 자리 꼴)에 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] 옮김 · 표 위 도구 줄(200-2)은 비움(안내 글은 202 대로 표 아래) · 못 하면 흐림 그대로 +- [x] 229-1 레벨 단추 보조 탭 줄로 — test_200_2 · 202_1 고침 · 새 시험 + - 검증완료 — test_229_1(새) · 200_2 · 202_1 · 204_1 · 228_1 25 통과 · typecheck 통과 · 단추 넷 = 보조 줄 setTools · 안내 글 = 표 아래 · 표 위 줄 없음 + +## 230. B05 종단 새 창 + 브라우저 최대화 = 휠로 종단 안 움직임 (sub7) +정함(브레인) = 사용자 102번 · 까닭 판정(213-3 design_profile_viewport 크기 듣개 · 새 창 window 대 부모 window · 220 돋보기 휠 잡기 · 227 · resize 뒤 영역 셈) · 고침 — 새 창 최대화 · 보통 둘 다 휠 동작 +- [x] 230-1 까닭 · 고침 · 새 시험 + - 검증완료 — 까닭: 휠 이동이 메인 창 화면 틀(requestAnimationFrame)을 씀 · 새 창 최대화로 메인 창이 가려지면 틀이 멈춰 휠은 막히고 이동 0 · 고침: 스크롤러가 놓인 창의 틀 · 시계(휠 · 끌기 · 스크롤 뒤 다시 그리기) · test_230_1 5건(옛 코드 이동 0px → 새 창 보통 360px · 최대화 1920 뒤 240px · 원래 창 360px) · 종단 관련 37 passed · typecheck 통과 + +## 231. 노면공 범위 고름 고침 — 조사 AD 1안 + 강조 연동 폭넓게 (sub8) +정함(브레인) = 사용자 103번 · 1안(B06 폼 다시 세울 때 load() → selectAt 에 노면공 거름 coverable) · 강조는 여러 군데 이어져 있으니 먼저 폭넓게 조사(횡단 카드 --span · 포장층 · 종단 알약 · 측점선 · 3D 띠 · 좌측 목록 · 폼 · 고름 기록 188 · B05 3D 고름 Structure_Pick_Session 같은 구멍) → 같은 규칙으로 한 벌 고침 · 189 세 갈래 규칙 그대로 +- [x] 231-1 강조 연동 조사 표 · 1안 고침 · 같은 구멍(B05 3D) · 새 시험 + - 검증완료 — test_231_1 7건(옛 코드 = 카드 120 오감 뒤 노면공 · 3D 노면공 id · 카드 100 · 80 재현 → 고침 뒤 모두 풂 · 목록 노면공은 그대로 범위) · 165 · 177_3 · 177_4 · 188_1 · 189_1 · 216_1 · 152_5 · 106 · 118 · 152_4 · selection_single 통과 · typecheck +- [x] 230-2 (sub7) 같은 꼴 나머지 — design_profile rebuild · Heights · MassHaul · TableOverlay 의 메인 창 전역 requestAnimationFrame → 놓인 창 틀(공용 함수 하나) · 새 창 최대화 중 편집 · 다시 그리기 즉시 — 새 시험 + - 검증완료 — 공용 common_util_view_window(viewOf · frameOf) · 고친 곳: design_profile(rebuild · 접기 · render) · viewport · Wheel · Heights · TableOverlay · MassHaul · mass_haul_view(세로창 미끄러짐) · Drag(120ms 그리기) · Edit(▲▼ 길게 누르기) · Panel_Tools(방향키 길게 누르기) · Drainage_Panel · Structures_Marks(말풍선) · B06 횡단 카드는 새 창으로 안 가 해당 없음 · test_230_2 11건(메인 틀 멈춤 흉내 — 옛 코드 다시 그리기 0회 → 모두 1회 · 길게 누르기 1초 5회) + 메인 창 틀 · 시계 0줄 훑기 · 종단 관련 135 passed(test_b05_structures_form_summary 15 errors 는 B06_Section_Structure_Layouts 의 @util 경로 — 이번 변경 밖 · 전부터) · typecheck 통과 · design_profile 642줄 +- [x] 231-2 (sub8) 조사 AD 2안 — 목록에서 구조물을 고르면(노면공 아니어도) 늘 고름 기록에 그 구조물 id(같은 카드 안 목록 고름도 오감 · 새로 고침 뒤 그 폼 유지) — 새 시험 + - 검증완료 — test_231_2 6건(카드 120 → 목록 관 = 오감 · F5 뒤 관 폼 · 카드 80.17 / 같은 카드 표지 = 기록 sign · 오감 · F5 뒤 표지 폼 / 목록 노면공 F5 = 범위 그대로 / 카드 고름은 측점만) · 231_1 · 189_1 · 188_1 · 216_1 · 165 · 177_3 · 177_4 · 152_5 · 106 · selection_single 82 통과 · typecheck +- [x] 230-3 (sub7) test_b05_structures_form_summary 15 errors(헬퍼가 @util/common_util_cross_structure_areas 못 찾음 · 178-1 뒤) — 헬퍼 별칭 · 가짜 모듈 고침(코드 안 고침) — 시험 통과 + - 검증완료 — 헬퍼 @util → common_util 별칭 · 가짜 요소에 style.setProperty · select.options · Option 보탬 · 기대값 한 줄 54-26(숨긴 길이 = 칸 없이 값만 쥠)에 맞춤(그린 칸 height_m 만 확인 추가) · 코드 안 고침 · structures_form_summary 15 · 177_1 · 178_1 합 21 passed + +## 232. 3D 띠 강조 바로 꺼짐(104) — 구멍 둘 (sub8) +정함(브레인) = 사용자 108 「제안대로」 · 조사 AF a1 + a2 · a1 = 3D 띠(측점 노란 띠)를 누른 누름은 3D 구조물 고름이 「빈 곳 누름」으로 읽지 않고 건너뜀 · a2 = 숨은 핀 · 꺼 둔 측점선은 누름을 가로채지 않음 · 3D 빈 곳 누름 = 고름 안 풂(08-05 사용자 결정 쪽) · 풀기는 Esc(또는 같은 것 다시 누름) 한 길로 · 3D 누름 길을 통째로 도는 시험 새로 +- [x] 232-1 a1 · a2 · 빈 곳 누름 안 풂 · 3D 누름 통째 시험 — 새 시험 + - 검증완료 — test_232_1 7건(진짜 MK3 · MI · SPK · SY · PKS · DSel + three 한 누름씩 · 옛 코드 = 띠 누름 뒤 노랑 · 핀 끔 · 기록 null → 고침 뒤 뗌 뒤도 빨강 · 핀 · 기록 120 · 빈 곳 누름 안 풂 · 같은 띠 · 부재 다시 누름 = 풂 · 숨은 핀 · 라벨 · 꺼 둔 측점선 안 가로챔) · 231_1 · 231_2 · 188_1 · 189_1 · 216_1 · 219_1 · 177_3 · 177_4 · 165 · selection_single · 176_3 · design_page_single 99 통과 · typecheck + +## 233. 고름 원천 한 칸 · 강조는 거기서 파생 — 단계별 (sub8 · 232 뒤) +정함(브레인) = 사용자 108 · 조사 AF (나) · ① 고름 한 칸(세션 · 188 remember)을 유일 원천 + 바뀜 알림(구독) 하나 · ② 종단(세로선 · 알약 · 띠) ③ 3D(띠 · 핀) ④ B06 카드(고름 · --span · 형상 · 포장층) · [상세] ⑤ 좌측 목록 · 폼 · 지도 — 화면마다 손으로 서로 부르던 것을 구독해 그 값으로 그림 · 풀기 = Esc 한 길로 세션 · 폼 · 범위 강조 모두 · 다시 계산 · 다시 그림이 강조를 버리지 않음 · 단계마다 커밋 · 시험(바로 꺼지는 자리 넷 포함 · 조사 AF 6절 a3~a6) +- [x] 233-1 ① 원천 + 구독 · ② 종단 + - 검증완료 — 새 design_pick_profile(고름 한 칸 구독 → 종단 측점선 · 알약 · 띠 · 목록 바뀐 뒤 · 폼 닫힌 뒤 다시 얹음 · 숨은 보기 안 그림) · test_233_1 7건(카드 120 · 표지 127.86 · 관 옆 카드 80 = 카드 선 · 관 80.17 · 측점선 없는 관 300.4 → 옮김 300.45 알약 따라감 · 남이 꺼도 값대로 다시 · 풂 · 떠나면 구독 0) · 232_1 · 231_1 · 231_2 · 188_1 · 189_1 · 216_1 · 219_1 · 177_3 · 177_4 · 165 · selection_single · 116 · 76 · design_view_parity 등 130 통과 · typecheck · design_view_profile 700줄 +- [x] 233-2 ③ 3D · ④ B06 카드 · [상세] + - 검증완료 — 종단 보기 구독이 3D 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이) · 새 B06_Section_UI_Page_Pick_Sync(횡단 보기 구독 · 다른 곳 값 = 넘김 적용 길 · 풀기 한 길 = 카드 · 폼 관 · --span · 포장층 · 형상 · 카드 다시 누름도 이 길 · a4) · [상세] 다시 그림 뒤 같은 자리 조각 유지 · test_233_2 8건(카드 120 → 3D 띠 · 핀 · 벽 부재 · 부재키 = 그 부재만 · 표지 = 그 측점 띠 · 풂 · 마커 알림 0 · 횡단 제 누름 안 되받음 · 다른 곳 값 reapply · 풀기 = releasePipe · focus null · mark null · refresh · [상세] 창 크기 뒤 1 → 1 · 자리 바뀌면 0) · 관련 226 통과 · typecheck · design_view_cross 683줄 · design_view_profile 699줄 +- [x] 233-3 ⑤ 좌측 목록 · 폼 · 지도 · 남은 바로 꺼짐 자리 + - 검증완료 — 종단 보기 구독이 배수유역도(관 마커 · 유역 · 마름모)까지 알림 없이 칠함 · 재계산 뒤 다시 얹음(a3) · 지도 고름 · 목록 관 고름 · 목록 다시 누름도 고름 한 칸에(a6 · 칠하기는 구독) · 목록 관 줄 폭 PIPE_MATCH_M · 옮겨진 관 고름 따라감(a5) · 저장 뒤 목록 주입이 관 폼 안 닫음 · 새 design_pick_escape — Esc = 고름 한 칸 비움 → 종단 · 3D · 지도 · 폼 · 횡단 카드 · 범위 · 형상 모두 풂(입력 칸 · 창 · 도구가 먼저 받은 Esc 제외) · test_233_3 7건(관 80.17 → 지도 관 · 마름모 · 알림 0 · 재계산 뒤 다시 · Esc 입력칸 · 창 · 먼저 받음 = 안 풂 · Esc = 값 · 종단 · 지도 · 폼 풂 · 숨은 보기 안 받음 · 횡단 Esc = 폼 · 카드 · 범위) · 관련 시험 합 378 통과(item_picker_detail 1건 실패 = M02 DrawingCard · 이번 변경 밖) · typecheck · design_view_profile 683 · design_view_cross 683 · Structures_Panel 700(그대로) + +## 234. 구조물 기준 측점 = 정수 m · 일반 측점 붙기 · [정수 맞춤] (sub7) +정함(브레인) = 사용자 108 · 조사 AF 7절 A + B + C · 9-29 「저장값 소수 · 표기만 정수」(20장)를 「저장값도 정수 m」로 바꿈(사용자 확정 10-10) · 입력(좌측 폼 · 끌기 · 방향키 · 자동설계가 놓는 자리 · 관 지점) = 1 m 단계 · 일반 측점에서 1 m 안이면 그 측점에 붙음 · 구간형 시작 · 끝도 정수 m · 고름 찾는 폭 한 값(213 0.006 m 측점 폭과 정리) · 기존 소수 자료 = 좌측(또는 페이지 머리) [정수 맞춤] 단추(하나씩 옮기며 213-2 계획고 편집 따라감) · 225 관보호공 0.1 m 이동은 벽 위치라 그대로 · 깨지는 시험 다섯 기대값 고침 +- [x] 234-1 입력 1 m · 붙기 · 구간 끝 정수 · 찾는 폭 · [정수 맞춤] — 새 시험 + - 검증완료 — 맞춤 한 벌 common_util_structure_station(파이썬 · TS 짝) · test_234_1 25건(80.17 → 80 · 80.6 → 80 · 81 그대로 · 끝 279.3 → 279.8 · 거울 13값 같음 · 폼 친 4+0.17 → 80 · 안 고친 80.17 그대로 · 방향키 1 m · 벽 80.17/75.17~95.67 → 80/75~96 · 단추 5건(관 2 · 구조물 3 · 겹친 관 1 그대로) · 한꺼번에 옮겨도 편집 따라감 80 · 263 · 지도 관 141.37 → 141 · 서버 자동 80.374 → 80 · 재투영 80) · 기대값 고침 219_1(1 m) · station_roundtrip(125) · 깨질 다섯 중 213_1 spike 만 실패 = 엄상준 storage 편집 키가 이미 263.310(전부터) · 관련 403 통과 · item_picker_detail 실패 = M02(무관 · 전부터) · typecheck · 찾는 폭 = PIPE_MATCH_M 한 값(213 0.006 은 반올림 같은 측점 폭이라 그대로) + +## 235. B09 설계내역서 = 접히지 않는 트리 · 더블클릭 입력 · 고른 줄 상자 (sub3) +정함(브레인) = 사용자 111 · 112번 · 표 = 접히지 않는 트리(구조물조합 마스터 · 회사 · 개인 나무와 같은 생각) · 중간 줄(폴더 = 자식 있는 줄) = 값 입력 없음 · 금액 · 재료비 · 노무비 · 경비 = 자손 합 수식(읽기 전용 · 연결 없음 · 수량 · 단가 칸 비움) · 최하위 줄(파일 = 항목) = 이름 · 규격 · 단위 · 수량 입력 + 일위대가 · 단가산출 연결 · 트리 안내선(├ └ │ · 폴더/파일 표시 · 구조물조합 나무 꼴 재사용) · 글 칸은 한 번 누름 = 줄 고름(끌기 됨) · 더블클릭(또는 Enter · F2) = 입력 시작 · 고른 줄 = 줄 전체 상자 테두리(밑줄 대신) · 204 끌기 · 방향키 · 228 글꼴 그대로 +- [x] 235-1 폴더 줄 수식 읽기 전용 · 파일 줄 입력 · 연결 · 트리 안내선 · 더블클릭 입력 · 상자 고름 — test_184_3 · 204_1 · 228_1 · 229_1 고침 · 새 시험 + - 검증완료 — 공용 표 editOnDouble(고칠 칸 = 글 · 더블클릭 · Enter · F2 = 그 칸만 글 상자 · Enter · 초점 떠남 = 끝 · 한글 조합 Enter 안 끊음) · level.tree(├ └ │ 안내선 + 공용 나무 폴더 · 파일 아이콘 한 벌 = TREE_ICON) · 고른 줄 = accent 상자 테두리(배경 대신) · test_235_1 9건(폴더 = 이름만 · 수량 · 단가 빈칸 · 금액 넷 = 자손 합 · 수식 풍선 · 연결 없음 · 파일 = 이름 · 규격 · 단위 · 수량 + 연결 ▾ · 안내선 = 파이썬 거울과 같음 · 한 번 누름 = 글 상자 0 · 더블클릭 = 그 칸 1 · Enter · F2 = 고른 줄 입력 · 편집 중 · 도구 단추 · 조합 중 = 안 먹음) · 184_3 · 204_1 · 228_1(들여쓰기 식 3 곳으로 고침) · 229_1 · 200_2 · 182 · 166_2 · 공용 표 시험(ui_data_table · 199_1 · 61_1 · 201_1 · 121 · 183_3 · 185_1 · 198_1 · 202_1) 125 통과 · item_picker_detail 실패 = M02(무관 · 전부터) · typecheck + +## 236. B08 · B09 표 아래 안내 글 — 제작 설명 지움 · 짧게 · 페이지 스크롤 없음 (sub1) +정함(브레인) = 사용자 113번 · 202 로 표 아래로 옮긴 안내 글 중 제작 · 셈 설명(코드 · 장 번호 · 「= … · …」 꼴 긴 설명)은 지움 · 사용자에게 쓸모 있는 한 줄만(예 「줄을 누르면 그 탭 그 줄로」) · 늘 한 줄(줄임표 · 길면 공용 호버로 전문은 두지 않음 — 지운 설명은 화면에 안 둠) · 199 fillViewport 셈에 한 줄 높이만 · 어떤 창 크기에서도 페이지 스크롤 0 +- [x] 236-1 B08 · B09 안내 글 정리 · 한 줄 · 페이지 스크롤 0 — 새 시험 + - 검증완료 — test_236_1(7) + 202_1 · 199_1 · 200_1 · 229_1 · 160_3 · 160_4 · 163 · 171_1 · 183_2 · 184_3 · 관련 B08 · B09 시험 59 파일 통과(181_2 빨강 1 = 236 밖 · B09 Page.css 드롭 선 danger 색 수 5 ≠ 4) · typecheck 통과 · 창 높이 1080 · 768 · 600 에서 표 칸 끝 = 창 끝 +- [x] 234-2 (sub7) 빨강 둘 — test_213_1 spike(엄상준 storage 원본에 기댐 → 고정 사본) · test_item_picker_detail(M02 모달 같은 카드 — 224-1 · 222 뒤 바뀐 까닭 판정 · 시험 낡음이면 기대값 · 흠이면 고침) — 두 시험 통과 + - 검증완료 — 판정: ① 원본 편집 키가 이미 263.310 이라 재현 조건 빠짐 → 고정 사본 fixtures/b05_213_1_hidden_station_longitudinal.json(initial_snapshot 종단 160~360m · 측점 263.21 · 꼭짓점 · 편집 키 263.206 · 263.210 · 옛 코드 15009% 재현 · 지금 100% 아래) ② 시험 낡음 — 105-3(36fc3d6c3 · 10-08)부터 카드 도면 = mountStructureSheets(여러 장 탭) · 224-1 · 222 와 무관 · 코드 흠 없음 → 기대값 고침 · 213_1 · item_picker_detail · m02_drawing_card · 192_1 · 213_2 · cad_host_tabs 49 통과 +- [x] 236-2 (sub1) 235-1 과 합친 뒤 어긋남 — Bill_Level.ts 레벨 안내 글(235 가 더한 「입력 = 더블클릭 …」 · 긴 레벨 설명)을 236 규칙(「=」 없음 · 짧게 · 한 줄)으로 · test_181_2 danger 색 수 5(204-1 끌기 못 놓음 빨간 점선 = 맞는 쓰임이면 기대값 5) — 세 시험 통과 + - 검증완료 — test_236_1 · 181_2 · 235_1 · 229_1 · 200_2 · 204_1 · 228_1 37건 통과 · typecheck · danger 5번째 쓰임 = `.b09-estimation__drop.is-bad` 점선(204-1)이라 기대값 5 · 안내 글 「줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력」 · 긴 레벨 설명 호버는 지움(잘릴 때만 전문) +- [x] 233-4 (sub8) 빈 곳 누름 = 어디서도 안 풂(종단 · B06 그리드도 3D 처럼) · 풀기 = Esc · 같은 것 다시 누름 한 길 — 새 시험 + - 검증완료 — 종단 그래프 · 유토곡선 · 횡단 그리드 빈 곳 누름 듣개 뗌(도구 중 빈 곳 누름은 그대로) · test_233_4 6건(진짜 소스 본문 — 종단 빈 곳 = 호출 0(옛 = 측점 · 알약 · 폼 풂) · 도구 중 = 도구 몫 · 그리드 빈 곳 = 0(옛 = clearSelection) · 같은 측점선 · 카드 다시 = 풂 · Esc = 풂) · 233_1~3 · 232_1 · 188 · 189 · 216 · 219 · 231 · 107 · 215 등 229 통과 · typecheck diff --git a/docs/raw/plans/2026-10-10_plan_237-245.md b/docs/raw/plans/2026-10-10_plan_237-245.md new file mode 100644 index 000000000..f043cd58b --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_237-245.md @@ -0,0 +1,44 @@ +# 2026-10-10 계획 237~245장 기록(244 빼고) — 엑셀 맞춤 · 저장 측점 · 총괄 레벨 2 · 내역서 글꼴 · 끌기 표시 · 입력 칸 테두리 · 빨강 범위 · 더블클릭 · 병합 호버 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 114~127. + +## 237. 엑셀 프로그램(공용 스프레드시트 · M02 카드 · 조합 편집) 셀 맞춤 기본값 = 가로 가운데 · 세로 가운데 (sub1) +정함(브레인) = 사용자 114번 · 칸에 맞춤을 따로 안 준 경우(가로 「일반」 · 세로 없음)의 기본 = 가로 가운데 · 세로 가운데 · 도구줄 맞춤 표시도 가운데가 기본 · 칸에 맞춤을 준 칸은 그대로 · xlsx 내보내기에서도 같은 모양(안 준 칸 = 가운데로 써 넣음) +- [x] 237-1 기본 맞춤 가운데 · 가운데 — 새 시험 + - 검증완료 — 안 준 칸(가로 없음 · 일반 · 세로 없음) = 가운데 · 가운데(칸 · 도구줄 · 서식 창 · 편집창 · 영역 표 · 복사 HTML) · 준 칸 그대로 · 새 시험 test_spreadsheet_align_default 14 항목 + test_m02_area_table 21 + M02 카드 · 스프레드시트 관련 48 · typecheck 통과 · xlsx 내보내기 · 가져오기는 이 프로그램에 없음(복사 HTML 이 같은 몫) · 붙여넣기는 가로 · 세로 안 읽는 칸 = 가운데로 보임 + +## 238. B05 [저장]이 구조물 측점을 지우는 구멍 막기 (sub7) +정함(브레인) = 사용자 115번 · 조사 AG 5장 ① · [저장]이 관 · A군 목록으로 종단 구조물 측점을 통째 갈아 끼우지 않음 → 서버가 심는 측점 규칙(소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 기준 · 종료) 그대로 살림 · 소단 깊이(194)도 남음 · 3D 표기 넓히기 · B06 측점선 · 꺼진 종류 가림은 보류(116) +- [x] 238-1 [저장] 뒤 소단 · 옹벽 · 흙막이 · 기슭막이 측점 남음 — 새 시험(저장 앞뒤 측점 목록 같음) + - 검증완료 — test_238_1 2건(앞뒤 구조물 측점 같음 · 소단 196 · 201 · 206 남음 · 관 150→165 반영 · 고치기 전 코드는 2 실패) + 234_1 · 194_1 · 192_1 · 213_1 · b06_missing_stations · b05_extra_stations_supplier · 74 · 176_3 · app_imports 통과(83) + +## 239. B09 총괄설계내역서 탭 맨 위 = 설계내역서 레벨 2 까지 집계 · 누르면 설계내역서로 · 빨강 강조 (sub3) +정함(브레인) = 사용자 118번 · 총괄설계내역서 탭 표 맨 위에 설계내역서의 레벨 1 · 2 줄(폴더 줄)을 그 금액(재료비 · 노무비 · 경비 · 합계)과 함께 차례대로 · 그 합 = 순공사비(지금 총괄 순공사비와 같은 값) · 줄을 누르면 설계내역서 탭으로 옮겨 그 줄을 빨강으로 강조 · 값은 설계내역서 셈(Calc_Bill) 그대로 · 아래 원가계산 줄은 그대로 +- [x] 239-1 총괄 맨 위 레벨 2 집계 · 누름 이동 · 빨강 강조 — 새 시험 + - 검증완료 — test_239_1 5건(맨 위 = 레벨 1 · 2 폴더 줄 + 레벨 1 항목 · 관급 뺌 · 금액 = 설계내역서 그 줄 · 순공사비 계 = 레벨 1 합 = 총괄 가. 순공사비계 · 누름 = 설계내역서 그 줄 is-pick 빨강) + 235_1 · 229_1 · 145_4 · 185_1 · 181_2 · 200_2 · 166_1 · 171_1 · 201_1 · 228_1 통과 · typecheck 통과 + +## 240. B09 설계내역서 탭 — 폴더가 된 줄 글자 = 같은 레벨 줄과 같은 크기 · 색 · 들여쓰기 넓힘 · 끌기 표시(121) · 데이터 점검 틈(122) (브레인 직접) +정함 = 사용자 119번 · 「너가 직접 확인하고 코드작업」 · 화면에서 레벨 바꿔 폴더가 된 줄 · 원래 폴더 줄의 글자 크기 · 색을 재고 맞춤 +- [x] 240-1 폴더 줄 꼴 한 벌(레벨 고쳐 생긴 폴더도 is-title · 크기 = 레벨) · 121 들여쓰기 한 단 14 → 22px(끌기 한 단 같음) · 끌기 미리 보기(레벨 시작 점 · 「○○ 안 · 레벨 N」 · 들어갈 폴더 줄 테두리 · 끄는 줄 흐림 · 못 놓음 빨강) · 122 데이터 점검 [업데이트] 위 틈 = 카드 왼쪽 여유 12px — 검증완료(test_240_1 + 관련 91 통과 · typecheck · 화면 수치: 새 레벨 2 폴더 색 · 크기 = 다른 레벨 2 폴더 · 틈 12.0 = 왼쪽 12.9 · 미리 보기 글 · 테두리 · 흐림 확인) + +## 241. B08 · B09 표 — 입력 칸 표시 = 칸 색 대신 칸 테두리 (sub1) +정함(브레인) = 사용자 123 · 124번 · 테두리 색 = 어두움 · 밝음 테마 둘 다 또렷하게(테마 변수만 · 두 테마 대비 잼) · 사용자가 값을 넣을 수 있는 칸(공용 표 글 상자 · 시트 is-editable · 페이지 따로 칠한 입력 칸)의 채움 색을 없애고 칸 안쪽 테두리(얇은 accent 선)로 · 칸 크기 · 줄 높이 안 바뀜(inset) · 초점 테두리(focus-ring)는 그대로 · 사용자가 고친 값 표시(is-edited 색 칸)는 뜻이 달라 그대로 +- [x] 241-1 B08 · B09 입력 칸 테두리 — 새 시험 + - 검증완료 — test_241_1 3 + 공용 표 · B08 · B09 관련 시험 통과 · typecheck · prettier · 공용 표 글 상자 채움 색 → 칸 안쪽 1px accent 테두리 +- [x] 241-2 (sub1) 124번 테마 — 입력 칸 테두리 = color-mix(accent 60% · canvas)(밝음 #8b66af 대비 4.5 · 어두움 #7864a5 대비 3.7 · 둘 다 3:1 넘음 · 초점 테두리 2px 진한 accent 와 또렷이 갈림) — 시험 기대값 + - 검증완료 — test_241_1 3 + 공용 표 관련 시험 통과 · prettier · 입력 칸 테두리 색 = 두 테마 변수 color-mix(accent 60% · canvas) + +## 242. B09 총괄 → 설계내역서 빨강 강조 = 그 줄 + 하위 전부 · 고름 표시 하나만 (sub3) +정함(브레인) = 사용자 125번 · ① 총괄설계내역서 탭 맨 위 줄을 누르면 설계내역서 탭에서 그 줄과 하위 줄 전부를 한 범위로 빨강(범위 바깥 테두리 한 벌) ② 고름 상자(235 accent 상자)와 빨강이 한 줄에 겹쳐 그려지지 않음 — 한때 하나만 ③ 설계내역서에서 다른 줄을 누르면 빨강 풀림 · 그 줄만 고름 상자 ④ 이미 고른 줄이 있어도 총괄에서 누르고 오면 고름 풀림 · 빨강만 +- [x] 242-1 범위 빨강 · 표시 하나 · 서로 풀기 — 새 시험 + - 검증완료 — test_242_1 5 + 239_1 · 235_1 · 181_2 · 240_1 · 200_2 통과(30) · typecheck · 「1.1 절토」로 오면 그 줄 + 자손 5줄 빨강(첫 줄 위 · 끝 줄 아래) · 고름 0 · 다른 줄 누름 · 방향키 · Esc = 빨강 0 + +## 243. B08 · B09 표 수기 입력 = 모두 더블클릭 (sub1) +정함(브레인) = 사용자 126번 · 「모든 값들은 쉽게 변경되면 안 됨」 · B08 · B09 의 값 고칠 수 있는 표 전부 235 editOnDouble(한 번 누름 = 고름 · 더블클릭 · Enter · F2 = 입력 · Enter · Esc · 초점 떠남 = 끝) · 늘 글 상자로 그리던 표도 같음 · 241 입력 칸 테두리는 글 상자가 아닐 때도 칸(td)에 보임(고칠 수 있는 칸 표시) · 칸 누름으로 여는 메뉴 · 고르기(출처 · 관급 · 별도 · 연결 ▾)는 그대로 +- [x] 243-1 B08 · B09 모든 입력 표 더블클릭 · 입력 칸 테두리 늘 보임 — 새 시험 + - 검증완료 — test_243_1 5건(공용 표 한 번 누름 = 글 상자 없음 · 더블클릭 · Enter · F2 = 그 칸 입력 · 고칠 칸 td `is-can-edit` 테두리 늘 · 못 고치는 칸 · 옛 표 = 없음 · B09 표 만드는 길 Shell 한 곳 · B08 고칠 칸 있는 표 editOnDouble · 메뉴 · 이동 그대로) + 235_1 · 241_1 · 121 · 183_3 · 185_1 · 145_4 · 166_1 · B08 · B09 관련 152건 통과 · typecheck · prettier + +## 245. B09 공사원가계산서 탭 표 — 병합 칸 줄 호버 강조 정리 (sub3) +정함(브레인) = 사용자 127번 · 같은 값 세로 병합(mergeSame · rowSpan) 표에서 줄에 마우스를 올리면 병합 칸 일부만 칠해져 모양이 어지러움 → 공용 표 호버 규칙 정리(병합 칸은 그 칸이 덮는 줄 중 하나에 올리면 칸 전체를 한 번에 · 또는 병합 칸은 칠하지 않음 중 깔끔한 쪽 · 화면 두 꼴 비교 뒤 하나) +- [x] 245-1 병합 표 호버 정리 — 새 시험 + - 검증완료 — 규칙 (가) 병합 칸은 호버 · 고른 줄 · 줄무늬 안 칠함(CSS 한 덩이 · 공용 표라 B08 · B09 병합 표 모두) · 빨강 범위는 그대로 · test_245_1 4 + 201_1 · 228_1 · 239_1 · 242_1 · ui_data_table 합 36 통과 · typecheck · prettier diff --git a/docs/raw/plans/2026-10-10_plan_244-248.md b/docs/raw/plans/2026-10-10_plan_244-248.md new file mode 100644 index 000000000..959dd8622 --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_244-248.md @@ -0,0 +1,57 @@ +# 2026-10-10 계획 244 · 246~248장 기록 — 거리이정표 · 설계내역서 메뉴 · 초록 램프 · 다른 탭 빨강 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 120 · 128~147 · 조사 AH. + +## 244. B08 수량산출 · 메인 탭 「거리이정표」 (sub8) +정함 = 사용자 120번 + 120-답 · 조사 AH · 브이월드는 길찾기 API 없음(공개 목록에 없음) → 길찾기 = 카카오모빌리티 길찾기 REST(키 = 사용자 발급 · 오기 전엔 km 손 입력) · 그림 = 서버가 VWorld 바탕 + 길 선(이미 쓰는 VWorld 길) + 사진 올리기 +- 짜임: 메인 탭 「거리이정표」 → 하위 탭 [거리이정표](다이어그램 + 표) · [경로 하나씩](컨테이너 하나 = 그림 하나 + 구간 km) · [+ 추가] · [삭제] +- 공사지 = 시점(노선 첫 점) + 중점(사용자 입력 측점 · 기본 = 노선 길이 ÷ 2) 한 컨테이너 · 기준점 = 있거나 없음(예 병곡리 968-31) +- 다이어그램 = 컨테이너(상자) 로 추가 · 고침 · 상자 자리 방향 = 기준점(없으면 공사지) 기준 지도 방위 · 선마다 구간 km · 도로 구분 +- 도로 구분 = 구간(기점)마다 드롭다운(2차포장 · 1차포장 · 비포장) 사용자 고름 · 자동 판정 없음 +- 표 = 항목(수기) · 업체 이름(수기) · 목적지(시점 · 중점 등 고름) · 도로 구분 km(2차포장 · 1차포장 · 비포장) · 계(포장 · 비포장 · 합계) · 비고 — 견본 수식(포장 = 2차 + 1차) +- 항목 추가 = 구분 정보 + 지도 위치(주소 검색 → 좌표) + 길찾기 값(km · 길 선)을 받음 +- 정본 = 프로젝트 storage B08_Quantity/milestone/(json + 경로 그림) · 표 합 = 파이썬 · TS 짝 · 주소 · 길찾기 · 그림 = 서버 단독 +- [x] 244-1 데이터 · 라우터(GET · PUT) · 메인 탭 · 하위 탭(추가 · 삭제) · 공사지(시점 · 중점) · 표(구간 드롭다운 · 합 짝) · km 손 입력 · 사진 올리기 — 새 시험 + - 검증완료 — test_244_1 6건(견본 거창군청 2차 23.8 + 1차 0.6 + 비포장 0.8 = 포장 24.4 · 합계 25.2 · 중점 목적지 = 노선 ÷ 2 비포장 더함 · TS 거울 같음 · 중점 기본 1500 m 노선 → 750 · 저장 왕복 · 그림 교체 · 지우기 · 경로 삭제 뒤 [저장] = 그 그림 지움 · 겹친 id 422) + B08 관련 · 243_1 · 122 통과(203) · typecheck · prettier · ruff +- [x] 244-2 다이어그램(컨테이너 · 방위 자리 · 선 · km · 도로 구분 · 상자로 추가 · 고침) — 새 시험 + - 검증완료 — test_244_2 6건(공사지 컨테이너 안 시점 왼 · 중점 오른 · 선 「비포장 0.75km」 · 8방 자리 E = 오른 · N = 위 · 기준점 W = 공사지 서쪽 · 거창군청 N = 기준점 북쪽 · 선 글 「2차포장 23.8km」 · 「1차포장 0.6km」 「비포장 0.8km」 · 경위도 동쪽이면 고른 N 보다 동 · 같은 방위 둘 = 옆으로 64 · 안 고름 = 빈 방위 북 → 북서 · 상자 안 겹침) + B08 관련 · 243_1 · 244_1 통과(209) · typecheck · prettier · ruff +- [x] (244-4 로 바뀜 · 130번) 244-3 지도 위치(주소 → 좌표 · VWorld 지오코더) · 경로 그림(VWorld 바탕 + 길 선) · 길찾기(카카오 REST · 키 있을 때만 · 없으면 안내 한 줄) — 새 시험 + +## 246. B09 설계내역서 — 입력 칸 테두리 되돌림 · 우클릭 「항목 추가」 하나 · 연결 이름 줄임 · 「줄 삭제」 (sub3) +정함(브레인) = 사용자 128번 · ① 241 · 243 입력 칸 테두리(글 상자 테두리 · td 늘 테두리)를 241 앞 모양으로 되돌림(더블클릭 입력은 그대로) ② 우클릭 「위에 줄 추가」 · 「아래에 줄 추가」 · 「아래에 하위 줄 추가」 · 「새 공종 추가(1단)」 → 「항목 추가」 하나(누른 줄이 항목 = 바로 아래 같은 레벨 · 폴더 = 그 폴더 첫 하위 · 1단은 레벨 단추 · 방향키로) ③ 연결 메뉴 이름 = 「구조물 조합」 · 「일위대가 사전조합」 · 「단가산출」(서고 · 마스터 · 회사 · 개인 · M01 · 새 산근 글 지움 · 호표 둘은 그대로) ④ 「줄 지우기」 → 「줄 삭제」 +- [x] 246-1 네 가지 — 시험 기대값 · 새 시험 + - 검증완료 — test_246_1 3건(입력 칸 accent 5% 채움 · box-shadow · is-can-edit 없음 · 줄마다 더함 = 「항목 추가」 하나 · 「줄 삭제」 · 연결 이름 다섯(서고 · 층 · M01 · 새 산근 글 없음) · 알림 글 서고 없음 · 안내 글 · 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모 · ← 한 단씩 · 관급 안 손 묶음 ← = 새 공종 직접비 · 관급자재대 앞 · 자재 줄 1단 막음 · 1단 손 항목 = 묶음) + 241_1 · 243_1 · 166_2 · 182 · 184_3 · 204_2 기대값 고침 · 204_1 · 235_1 · 200_2 · 240_1 · 236_1 · 202_1 · 201_1 · 121 · 242_1 · 245_1 통과 · typecheck · prettier · ruff +- [x] 246-2 (129번) 우클릭 「연결 ▸」 = 같은 패널 안에 펼침 대신 오른쪽 옆 새 판(하위 메뉴)으로 연결 다섯(일위대가 호표 · 단가산출 호표 · 구조물 조합 · 일위대가 사전조합 · 단가산출) · 공용 우클릭 메뉴 children 꼴 · 화면 끝이면 왼쪽으로 + - 검증완료 — test_246_2 2건(「연결 ▸」 한 줄 · 올림 = 옆 판(메뉴 오른쪽 260 · 첫 줄 = 머리 줄 높이 · fixed) · 다른 줄 올림 · ← = 닫힘(머리로 초점) · 누름 · → = 열고 첫 줄 초점 · 고름 = 닫고 동작 · 오른쪽 끝 = 메뉴 왼쪽 · 아래 끝 = 올림 · Esc · 바깥 누름 = 닫힘 · 안 누름 = 그대로 · 옛 아코디언 없음) + 129(B05 구조물 메뉴) 통과 · 영향 = 공용 메뉴 children 쓰는 곳 모두 옆 판(B09 연결 · B05 종단 · 배수 구조물군 · 스프레드시트 칸 메뉴 붙여넣기 · 삽입 · 삭제 · 고정 · 그룹 · 시트 탭 숨김 해제) +- [x] 244-4 (130번 고침) ① 공사지 [+] = 모달(항목 · 업체 이름 · 목적지 + 아래 주소 검색 · 등록되면 그 자리 지도) · 방위 = 지도 위치로 자동 자리(고르기 없앰) · 주소 검색 · 지도 = B02 프로젝트 등록 · B01 회사 주소(geocode · 후보 검색 · 지도 미리 보기) 재활용 ② 시점 상자 누름 = 모달(프로젝트 등록 때 주소 · 브이월드 주소 · 아래 지도) ③ 다이어그램 영역 팬 · 줌 = 공용 A00_Common/b_svg_zoom_pan.ts ④ 경로 하위 탭 = 길찾기 지도 결과만(구간 표 · 표 줄 · 하위 탭 [추가] 단추 없앰 · 탭은 항목 추가 · 삭제로만 생기고 없어짐) · 길찾기 API 없으면 [다음 지도에서 길찾기] = 새 창 map.kakao.com 길찾기 주소(출발 · 도착 좌표) → 사용자가 길 확인 → [화면 캡처](getDisplayMedia 로 그 창 한 장) → 그 탭 그림 ⑤ 상자 누름 = 그 항목 정보 모달(탭 이동 아님 · km · 도로 구분 · 고침 · 삭제는 모달 안) ⑥ (130-6) 다이어그램 선 위 km · 도로 구분 글을 다이어그램에서 바로 고침(더블클릭 = km 글 상자 · 도로 구분 ▾ · 모달과 한 벌) — 새 시험 + - 검증완료 — test_244_4 6건(업체 좌표 = 방위 자동 · 기준점 좌표 없음 = 시점 기준 남 · 있음 = 기준점 기준 북 · 다음 지도 /link/by/car/이름,위도,경도 꼴 · 이름 쉼표 · 빗금 = 빈칸 · VWorld getAddress 도로명 먼저 · 지번 · 깨진 응답 None · site-address = 사업지역 · 시점 · 중점 주소 · [+] 모달 칸 · 방위 칸 없음 · B01 주소 칸 재활용 · 공용 팬 줌 · 경로 탭 표 없음 · 새 창 · getDisplayMedia → 그림 올리기 · 선 글 고침 = 업체 23.8 · 공통 비움 · 중점 1.2345 km = 측점 1234.5 m · 표 1차 23.8) + 244_1 · 244_2 · 243_1(B08 표 4 → 2) 기대값 고침 · B08 관련 통과(215) · typecheck · prettier · ruff +- [x] 244-5 기준점 좌측 패널 「공사지에서 본 방위」 칸 없앰(130번 = 방위는 지도 위치로 자동) · 기준점 자리 = 기준점 주소 좌표 · 좌표 없으면 견본처럼 공사지 서쪽 기본 · 새 시험 + - 검증완료 — test_244_5 3건(좌표 없음 = 서쪽 · 옛 dir 「동」 무시 · 시점 좌표 모름 = 서쪽 · 기준점 북 좌표 = 위 · 동 좌표 = 오른 · 옛 「서」 무시 · 좌측 패널 방위 칸 없음 · 기준점 모달 안내 한 줄) + 244_* 20건 · 122 14건 통과 · typecheck · prettier · ruff +- [x] 244-6 (132번) 표 폭 = 위 다이어그램 폭 · 다이어그램 상자 안에 항목 글도 · 좌측 패널 거리이정표 카드 없앰(중점 측점 · 기준점 = 다이어그램 상자 모달에서 고침) · 팬 안 됨 고침(가운데 단추 끌기 실제 동작 + 빈 곳 왼쪽 끌기도 옮김) · (133번) 화면 글 「업체」 → 「거래처」(다이어그램 · 모달 · 표 머리 · 탭 이름 · 안내 · 알림) — 새 시험(실제 포인터 사건으로 팬 판정) + - 검증완료 — test_244_6 4건(pointerdown · move(+60 · +30) · up 사건 = 원배율에서 빈 곳 왼쪽 · 상자 위 가운데 끌기 모두 transform translate(60 30) · 상자 위 왼쪽 · 오른쪽 = 그대로 · 팬 안 되던 까닭 = 원배율에서 공용 팬 범위가 0 · 거래처 상자 항목 「장비 · 골재」(겹침 · 빈칸 뺌) + 이름 · 이름 없음 = 「거래처」 · 틀 fit-content + 다이어그램 contain inline-size · svg 너비 100% · 좌측 카드 없음 · 공사지 모달 중점 측점 · 도로 · 기준점 모달 등록 · 없앰 · [기준점 +] · 화면 파일 6개 「업체」 0) + 122 · 244_1 · 244_4 · 244_5 기대값 고침 · B08 관련 · 243_1 · 164(공용 팬 줌) 통과(222 · 13) · typecheck · prettier · ruff + +## 247. B08 구조물 집계표 — 측점 줄 구조물조합 모두 연결 = 초록 램프 (sub7) +정함(브레인) = 사용자 131번 · 지금 노랑 램프(미연결 · 154-1 · 198-1)만 있음 → 그 측점 줄의 시설이 모두 조합 연결된 줄 = 초록 램프(같은 자리 · 같은 크기 · 호버 글 「모두 연결됨」) · 미연결 = 노랑 그대로 · 시설 없는 줄 = 램프 없음 · 색 = 테마 변수(밝음 · 어두움) +- [x] 247-1 초록 램프 — 새 시험 + - 검증완료 — test_247_1 3건(공용 표 실제 그림: 모두 연결 측점 칸 = 램프 + is-linked · 빈 칸 하나 = 노랑 · 시설 없음 · 합계 줄 = 없음 · 호버 「모두 연결됨」 · 성공 토큰 밝음 · 어두움) + 198_1 · 160_2 · 61_1 · 143_1 · 154_2 · 156_1 · 145_3 · 120 · 98 통과(52) · typecheck · prettier · ruff +- [x] 244-7 (136번) 카카오 길찾기 REST 로 km 자동 — 키 .env KAKAO_REST_API_KEY(브레인 확인: 거창군청 → 병곡리 10,463 m · 길찾기 성공) · 서버만 부름(키 브라우저로 안 감) · 거래처 등록 · [길찾기] 때 구간마다(거래처 → 기준점 · 기준점 → 시점 · 기준점 없으면 거래처 → 시점) km 자동 · 시점 → 중점 = 노선 측점 거리 · 도로 구분 = 사용자 고름 그대로(자동 판정 없음) · 경로 좌표 · 응답은 저장 안 함(km 만) · 키 없음 · 실패 = 안내 한 줄 + 손 입력 · 경로 그림(카카오 지도 JS)은 244-9 로(자바스크립트 키 · 도메인 등록 뒤) + - 검증완료 — test_244_7 6건(가짜 응답 10463 m = 10.5 km · 10450 → 10.5 · 10449 → 10.4 · origin · destination = 경도,위도 · KakaoAK 머리 · 경로 좌표 응답에 없음 · 키 없음 = 안내 · 카카오 안 부름 · 401 · 429 · 500 · 끊김 · 깨진 응답 · result_code 104 = 까닭 한 줄 · 키 값 응답 · 로그 0 · 구간 = 거래처 → 기준점 · 기준점 → 시점 · 기준점 없음 = 거래처 → 시점 · 사용자 23.8 우선 + 호버 「자동 값 10.5 km」 · 안 고름 = 「도로 구분 고르기 1.4km」 · 합에 안 듦 · TS 거울 같음) + 실제 카카오 한 번 = 10.5 km + 244_* · 122 · 243_1 통과(41 · 5) · typecheck · prettier · ruff +- [x] 244-8 (135번) 다이어그램 영역 스크롤 없앰 — 틀 overflow 숨김 · 휠 = 줌만(페이지 · 틀 스크롤로 안 샘 · passive false preventDefault) · 다이어그램 크기 = 틀 안 맞춤 · 표 영역 스크롤은 그대로 — 새 시험 + - 검증완료 — test_244_8(진짜 wheel 사건: 그림 위 = scale 1.15 · defaultPrevented · 틀 위 = passive false · defaultPrevented · 틀 overflow hidden) + 244_* 26 통과 · typecheck · prettier +- [x] 244-9 (138번 · 가 배제 → 나) 경로 그림 = 카카오 지도 JS 위 경로 — 거래처 모달 · 경로 하위 탭에 카카오 지도(JS SDK · 키 = .env KAKAO_JS_KEY · 서버 설정 길로 줌 · 도메인 = localhost:5173~5181 등록) · 길 선 = 서버 길찾기 응답 경로 좌표(그때그때 · 저장 안 함) · 표지 · [캡처] = getDisplayMedia(preferCurrentTab) + 지도 칸만 잘라(CropTarget · 안 되면 자리 수치) → 그 탭 그림 · JS 키 없음 = 안내 한 줄 · VWorld 바탕 그림 없앰 — 새 시험 + - 검증완료 — test_244_9 8건(가짜 vertexes 2구간 → path [위도, 경도] 4점 · 원문 없음 · 깨진 좌표 = km 10.5 그대로 · path [] · 화면이 path 실어 보내도 milestone.json 에 좌표 0 · map-key 없음 · 빈칸 = null · 표지 = 거래처 · 기준점 · 시점 · 좌표 없는 것 뺌 · 캡처 자르기 ×2 = 200 · 100 · 800 · 600 · 영상 밖 자름 · 키 없음 = SDK 안 부름 + 안내 한 줄 + [지도 캡처] 숨김 · 가짜 kakao.maps = 길 선 2 · 표지 3 · 맞춤 점 8) + 실제 카카오 거창군청 → 병곡리 = 10.5 km · 경로 좌표 159점(JS 키 없음 = 지도 실제 확인은 브레인) + 244_* 40 통과 · typecheck · prettier · ruff +- [x] 244-10 테일스케일 주소(http://100.72.32.27:5173 · 보안 문맥 아님) — [지도 캡처]는 getDisplayMedia 없으면 끄고 까닭 한 줄(「캡처는 localhost 또는 https 주소에서만 — 사진 올리기」) · 지도 SDK 도메인 불일치(401)면 「카카오 JavaScript 키 도메인에 지금 주소 등록 필요(예 http://100.72.32.27:5173)」 안내 한 줄 — 새 시험 + - 검증완료 — test_244_10 3건(navigator.mediaDevices 없음 = [지도 캡처] disabled · title · 안내 「길 선 = 카카오 길찾기 · 캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요」 · 지도 그대로 · 있음 = 켜짐 · SDK script onerror = 「카카오 JavaScript 키 도메인에 지금 주소(http://100.72.32.27:5173) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요」 · 지도 · 캡처 숨김) + 244_* 43 통과 · typecheck · prettier · ruff +- [x] 244-11 (139번) 경로 그림 = 서버 자동 캡처 — 서버가 헤드리스 Edge(Playwright channel msedge · 없으면 chrome)로 등록 도메인 주소(기본 http://localhost:5173/__route_capture · 가로채 채움 · .env KAKAO_CAPTURE_ORIGIN 로 바꿈)에 카카오 지도 JS + 길 선 + 표지 + 경로 맞춤 → tilesloaded 뒤 1200×800 PNG → 그 거래처 경로 하위 탭 그림 · [등록] · [길찾기] 때 자동 · 사용자 그림 우선 · 브라우저 [지도 캡처] 없앰(접속 주소 무관 · 테일스케일도 됨) · 모달 · 탭 안 카카오 지도 보기는 그대로 · 실패 = 까닭 한 줄 · 사진 올리기 · 브레인 견본 = sub8 tmp/route_capture_proto.py(2.4초 · 159점 · 10.5 km) — 새 시험 + - 검증완료 — test_244_11 6건(가짜 브라우저: 길 선 · 표지 → auto/r1.png · 주소 = 기본 · KAKAO_CAPTURE_ORIGIN(100.72.32.27:5173) + /__route_capture page.route · Edge 없음 = chrome · 폴더 파일 = 그림 하나(좌표 0) · 키 값 응답 0 · 사용자 그림 우선 · user_only 되돌리기 = 자동 그림 · 지우기 = 둘 다 · 경로 지우고 [저장] = 자동 그림도 · 까닭 한 줄 = 키 없음 · Playwright 없음 · 브라우저 못 띄움 · 20초 넘김 · 도메인 불일치 · 동시 3건 = 한 번에 하나 · 화면 getDisplayMedia · 지도 캡처 0) + 실제 한 번(엔드포인트: 거창군청 → 시점 10.5 km · 159점 → 1200×800 PNG 2.2초 · 파일 생김) + 244_* 48 통과 · typecheck · prettier · ruff +- [x] 244-12 (142~144번) 다이어그램 ① 처음 보기 = 넉넉한 바탕 영역(공사지만 있어도 거래처 · 기준점 들어올 자리까지 담는 고정 바탕 범위 · 새 상자 들어와도 다시 줌아웃 필요 없음 · 바탕 넘는 상자가 생기면 그때만 넓힘) ② 시점 ↔ 중점 연결선(도로 구분 · km 글 = 시점 → 중점 구간) · 시점 · 중점 상자 안 작은 글씨 주소 · 좌표(위도 · 경도 소수 5자리) ③ 거래처 상자 = 항목 · 거래처 이름 · 주소(작은 글씨 · 길면 줄임표 + 호버) ④ (145번) 상자 · 선 · 선 글 서로 여유(상자 사이 최소 간격 · 선 글이 상자 · 다른 선을 덮지 않게 자리 피함 · 선은 상자 테두리에서 끊김) — 새 시험 + - 검증완료 — test_244_12 6건(바탕 = 고정 1024×934 · 공사지만 · 공사지 둘레 8방 · 기준점만 = 바탕 그대로 · 기준점 너머 거래처만 그쪽 넓힘 · 시점 → 중점 선 = 공사지 바탕 위 · 시점 · 중점 작은 글씨 주소(줄임표 · 호버 원문) · 좌표 35.70000, 127.81235 · 주소 없음 = 좌표만 · ?mid_m= 중점 주소 = 지금 측점 · 음수 422 · 거래처 = 항목 · 이름 · 주소 · ④ 몰린 꼴 13 상자 = 상자 사이 12 이상 · 선 끝 상자 밖(바깥 → 시점 = 공사지 테두리) · 선 글 ↔ 상자 · 다른 선 · 다른 글 겹침 0) + 244_* 54 통과 · typecheck · prettier · ruff +- [x] 244-13 (147번 5 · 8) ① 공사지 [+] 모달 = 더 크게 · 2열(분류(기준위치 · 거래처) · 항목(분류 따라 바뀜) · 거래처 이름 · 목적지) · 다이어그램 [기준점 +] 없앰(분류 = 기준위치로 넣음) · 「거래처 위치」 → 「소재지」 · 직접 입력 단추 없앰(주소 찾기 · 지도 고르기만) · 등록 모달 지도 = 다음(카카오) 지도 ② 상자 누름 = 같은 등록 모달(고침) ③ 길찾기 = 연결선 누름 모달 — 값 = 도로 구분 · 거리 · 아래 다음 지도 경로(기준점 있으면 거래처 ↔ 기준점 경로 · 지도에서 위치 끌어 옮김) · 시점 ↔ 기준점 = 도로 없을 수 있어 거리 손 입력 가능 · 시점 ↔ 중점 = 늘 손 입력 ④ 도로 구분 = 기준점 거쳐 시점 들어가는 경로만 「2차포장 · 1차포장 · 비포장」 · 기준점 없으면 「포장 · 비포장」(표 열도 그에 따름) — 새 시험 + - 검증완료 — test_244_13 7건(포장 거울: 기준점 없음 포장 10.4 + 비포장 0.5 = 10.9 · 중점 줄 11.7 · 기준점 거침 2차 23.8 + 1차 0.6 + 비포장 0.8 = 25.2 · 거침 안 함 포장 5 · 파이썬 = TS · 3차포장 거절 · 도로 구분 갈래 = 거침 3종 · 아니면 포장 · 비포장 · 구간 끝 g → 병곡리 · d → 시점 · 병곡리 → 시점 · 시점 → 중점 = 없음 · 표 열 = 기준점 없음 포장 · 비포장 · 합계 · 있음 2차 · 1차 · 비포장 + 계 · 소재지 역지오코딩 · 위도 91 = 422 · 지도 누름 = 소재지 · 끌기 = 거래처만(시점 고정) · 점 없음 = 시점 가운데 · 연결선 길찾기 한 구간 · 거래처 쪽만 자동 그림(표지 둘) · 같은 km = 캐시 안 씀 · 사용자 23.8 그대로) + 244_* 61 · 122 9 통과(옛 화면 글 기대값 고침 8개) · typecheck · prettier · ruff + +## 248. B09 다른 탭 이동 빨강 = 설계내역서 규칙과 같게 (sub3) +정함 = 사용자 147번 3 「반영」(242-1 보고의 목록) · 일위대가표 · 중기사용료 · 단가산출근거(Groups pickRange 호표 구간 빨강) · 묶음 카드 보기 is-pick — 공용 focusRow 강조와 겹치지 않음(빨강 하나만) · 다른 줄 · 카드 누름 · 방향키 · Esc = 빨강 풀림 · 산근 시트 칸 td.is-pick 은 뜻 달라 그대로 +- [x] 248-1 — 묶음 카드 보기 = 이동해 온 박스 빨강만(줄 강조 안 붙음) · 카드 · 줄 누름 = 빨강 풂 · 누른 줄만 고름 / 표 보기 = 호표 구간 빨강만(focusRow 스크롤만) · 줄 누름 = 빨강 풂 · 누른 줄만 고름(다시 그려도) / 방향키 = 빨강 풂 · Esc = 고름도 풂 · 글 칸 안 키 그대로 — 새 시험 + - 검증완료 — test_248_1 3건 + 242_1 · 239_1 · 181_2 · 245_1 · 171_2 · 171_3(도착 줄 강조 기대 고침) · 묶음 카드 시험 통과 · typecheck · prettier · ruff +- [x] 244-14 [+] 모달 「항목」 = 손 입력 + 고르기 제안(120-답 ③ 「항목: 수기 입력」 · 거래처 제안 = 견본 다섯 장비(20톤) · 장비(8톤) · 혼합골재 · 전석운반 · 철근 · 기준위치 제안 = 기준점 · 목록 밖 글도 받음) — 시험 고침 + - 검증완료 — test_244_14 2건(빈 칸 + 제안 다섯 · 기준위치 = 제안 기준점 · 목록 밖 글 유지 · 줄마다 제안 목록 따로) + 244_* 63건 통과 · typecheck · prettier · ruff diff --git a/docs/raw/plans/2026-10-10_plan_249.md b/docs/raw/plans/2026-10-10_plan_249.md new file mode 100644 index 000000000..9862c4c83 --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_249.md @@ -0,0 +1,8 @@ +# 2026-10-10 계획 249장 기록 — 거리이정표 자동 그림 서버 안 캡처 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 148. + +## 249. B08 거리이정표 자동 그림이 서버 안에서 안 됨 (sub8) +정함(브레인 판정) = 사용자 148번 · 서버(uvicorn reload · Windows Selector 이벤트 루프) 안에서 Playwright 가 드라이버 하위 프로세스를 못 만듦(NotImplementedError) → 「브라우저 못 띄움」 · 시험은 서버 밖이라 통과 → 캡처를 서버 프로세스와 떼어 자식 파이썬 프로세스로(sys.executable · 입력 = JSON stdin · 출력 = PNG 경로 · 20초 넘김 · 키는 환경으로) · 서버 안 실제 경로 시험(Selector 정책 걸고 돌림) +- [x] 249-1 자식 프로세스 캡처 · Selector 정책 아래 시험 — 실제 서버 API 로 그림 생김 + - 검증완료 — test_249_1(Selector 정책 · 스레드: 옛 같은 프로세스 = 「브라우저 못 띄움」 재현 · 자식 프로세스 = 실제 Edge PNG 생김 · 시간 넘김 · 자식 죽음 까닭) 3 · 244_* 포함 66 통과 · typecheck · ruff · 실제 API 재확인은 브레인 diff --git a/docs/raw/요구_1010_2/조사_AD_노면공범위고름.md b/docs/raw/요구_1010_2/조사_AD_노면공범위고름.md new file mode 100644 index 000000000..8a816448d --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AD_노면공범위고름.md @@ -0,0 +1,73 @@ +# 조사 AD — B06 목록에서 다른 구조물을 골랐는데 노면공 범위 전체가 켜짐 (요구 98) + +## 1. 한눈 결론 + +- 목록을 누르는 길 자체는 정상 — 다른 구조물을 고르면 노면공 강조가 꺼짐(스크래치 재현 12 경우 · 무작위 3000 차례에서 샌 경우 0). +- 노면공이 켜지는 자리 = B06 이 폼을 다시 세우는 `load()`(보기 오감 · 새로고침 · 재진입). 측점만 남은 고름(구조물 id 없음)을 「그 측점을 덮는 첫 구조물」로 여는데, 노면공을 빼는 거름(152-5)을 안 넘겨 목록 맨 앞의 노면공이 열림. +- 목록과 잇는 고리 = 이미 고른 카드에 있는 구조물을 목록에서 고르면 고름 한 칸(세션)이 안 바뀜 → 옛 「측점만」 고름이 남아 있다가 위 `load()` 에서 노면공으로 바뀜. + +## 2. 클릭 길 표 + +| 단계 | 어디 | 하는 일 | +| --- | --- | --- | +| 목록 줄 누름 | `B05_Profile_UI_Structures_List.ts:225`(구조물) · `:235`(관) | 패널 콜백 부름 | +| 폼 올림 | `B05_Profile_UI_Structures_Panel.ts:438-455` → `_Commit.ts:94-158`(loadForm → onSelect) · `:163-205`(loadPipeForm) | 폼 = 고른 것 · 알림 | +| B06 받음 | `B06_Section_UI_Page_Structures_Panel.ts:212-230` onSelect · `:314-324` onPipeSelect | preferredId = 고른 id · 카드 찾기 · 알약 · **세션은 노면공일 때만 직접 적음(:229)** · 관은 늘 적음 | +| 카드 찾기 | `design_view_cross.ts:118-126` → `B06_Section_UI_Section_View.ts:587-597` focusStation · `:598-606` revealStation | 다른 카드면 카드 리스너 돎 · **같은 카드면 스크롤만(리스너 안 돎)** · 노면공 = 카드 고르지 않고 스크롤만 | +| 카드 리스너 | `design_view_cross.ts:384-411` | showAtChainage(`B06 panel :556-572` · 노면공 뺀 거름) · 세션 = 목록이 고른 것만 id(:397) · 알약 맞춤 selectMark(…, false)(:403) | +| 범위 강조 | `B06_Section_UI_Section_View.ts:607-612` selectMark → `:246-270` applySpan | 테두리 = 고른 구간형 · 노면공이면 span 끔이어도 늘 켬 + 포장층 `is-active` | +| 다시 그릴 때 | `design_view_cross.ts:464-495` restoreStructurePick | 세션 값이 바뀐 때만 다시 얹음(prefer → 카드 → selectStructure) | +| 보기 다시 보임 · 진입 | `design_view_cross.ts:564-572`(onShow) · `:619`(첫 마운트) → `B06 panel load() :406-414` | 세션으로 폼 다시 세움 — **`section.selectAt` 에 거름 없음** | + +## 3. 까닭(근거) + +① 본 원인 — `load()` 가 노면공을 덮어 엶 +- `B06_Section_UI_Page_Structures_Panel.ts:410` — `section.selectAt({ chainageM, structureId })` 를 거름(coverable) 없이 부름. +- 공용 `selectAt`(`B05_Profile_UI_Structures_Panel.ts:620-623`) → `resolveSelection` 기본 거름 = 전부(`design_selection.ts:86`) → 「덮는 첫 구조물」(`:96`) = 노면공(목록 맨 앞 · 시점 30). +- 이어지는 일: selectById(노면공) → onSelect(노면공) → 177-4 길(범위 첫 카드로 스크롤 · 알약 · 세션 = {첫 카드, 노면공}) → 범위 카드 전부 테두리 + 포장층 → `.then(restoreStructurePick)` 이 노면공을 한 번 더 얹음. +- `load()` 는 폼에 이미 고른 것이 있어도(`hasSelection`) 보지 않고 덮음 — 목록으로 고른 구조물이 노면공으로 바뀜. +- 같은 파일 `:561-563` 주석이 「공용 selectAt 은 노면공을 덮어 엶」이라 적고 showAtChainage 는 피했으나 `load()` 한 곳이 빠짐(216-1 은 B05 측점선만 고침). + +② 목록 고름이 세션에 안 적히는 경우 +- onSelect 는 노면공일 때만 selectDesign(`:229`) · 그 밖은 카드 리스너(`design_view_cross.ts:397-398`)에 맡김. +- 그 구조물이 가는 카드가 이미 고른 카드면 focusStation 이 스크롤만(`Section_View.ts:590-596`) → 리스너 안 돎 → 세션 = 옛 값 그대로. + +③ 「측점만」 세션이 흔한 까닭 +- 189-1 카드 하나 고름 = id 없음(`design_view_cross.ts:397`) — 카드가 옹벽 폼을 열어도 세션은 측점만. +- 216-1 B05 종단 측점선 = `remember(측점, null)`(`design_view_profile.ts:244`). +- 위 ② 의 같은 카드 목록 고름. + +## 4. 재현 조건 + +스크래치(시스템 임시 폴더 · 저장소 밖) — `helper_165_pavement_flow.cjs` 를 옮겨 진짜 B06 패널 · 세션 · 넘김 모듈에 `load()` 끝 · `restoreStructurePick` 을 덧붙임. 자료 = 226fc805 꼴(노면공 30~405 맨 앞 · 소단 35~45 · 표지 127.86 · 관 95.38 · 세월교 195.3 · 카드 20 m + 35 · 45 · 관 측점). + +| 차례 | 결과 | +| --- | --- | +| 카드 120 누름(관 없는 범위 안) → B05↔B06 오감 | 폼 = 노면공 · 범위 카드 23 · 포장층 켬 · 세션 id = 노면공 | +| 카드 120 → 목록 표지(같은 카드 120 · 폼 표지 · 세션 {120, 없음} 그대로) → 오감 | 노면공 · 23 | +| B05 측점선 120 → B06 | 노면공 · 23 | +| 대조 — 세션이 관 자리(95.38) · 목록 표지(카드 바뀜 · 세션 id 표지) → 오감 | 관 · 표지 그대로(안 샘) | +| 대조 — 노면공 고름 뒤 목록 관 · 소단 · 표지 · 세월교(오감 없음) | 모두 노면공 강조 꺼짐 | + +- 조건 셋: 그 측점이 노면공 범위 안 · 0.51 m 안에 관 없음 · 세션에 구조물 id 없음 + B06 폼 다시 세움(B05↔B06 오감 · F5 · 다른 페이지 갔다 옴). +- 관 자리 측점은 관이 먼저라 안 생김 · 옹벽 등을 다른 카드로 고르면 세션에 id 가 적혀 안 생김. +- 사용자 화면 차례(오감 · 새로고침 여부)는 미확인 — 오감 없이 목록 누름 즉시 켜졌다면 다른 길 · 화면 재현 필요. + +## 5. 고칠 방향 제안(고치지 않음) + +1안(본 원인 · 한 곳 · 1줄) — `B06_Section_UI_Page_Structures_Panel.ts:410` 의 `section.selectAt(…)` 에 `coverable` 을 둘째 인자로 넘김. +- 효과: 측점만 남은 세션은 노면공을 덮어 열지 않음(152-5 · 189-1 ③ 과 같은 규칙) · id 있는 세션(노면공 고름 포함)과 관은 그대로. +- 위험 낮음 — 바뀌는 것은 「id 없음 + 관 없음 + 노면공만 덮음」 한 갈래. 시험: test_189_1 · 188_1 · 165 · 216_1 + 새 시험(오감 뒤 노면공 안 엶). + +2안(세션 맞춤 · 1~2줄) — 같은 파일 `:229` 의 `if (pave)` 를 떼어 구조물 고름은 늘 selectDesign({기준 카드, id}). +- 효과: 같은 카드 목록 고름도 고름 한 칸이 그 구조물로(188-1 「마지막 고름 하나」) → `load()` 가 id 로 엶. +- 위험 낮음 — 리스너가 같은 값을 또 적어도 selectDesign 의 같은 값 거름으로 알림 없음 · 부재키는 다른 대상이면 버리는 규칙 그대로. + +덧 — `load()` 에 `if (!section.hasSelection())` 가드(B05 `restorePick` `design_view_profile.ts:250` 꼴)도 가능하나 1안과 겹침 · 선택. +권 — 1안 + 2안(파일 하나 · 2~3줄). + +같은 구멍(별건 · 사용자 판단): B05 3D 고름 `B05_Profile_UI_Structure_Pick_Session.ts:64-67` 도 기본 거름이라 노면공 id 를 세션에 적음 → B06 에서 노면공. + +곁가지(스크래치에서 봄): +- 옹벽 범위 안 카드 120 누름 → 옹벽 폼이 열리며 카드가 옹벽 기준 카드 100 으로 옮겨 감(showAtChainage → onSelect → focusChainage). +- 넘김 되얹기의 카드 찾기가 「0.51 m 안 첫 카드」(`B06_Section_UI_Page_Structure_Pick.ts:60`) — 관 80.17 세션이 카드 80 으로 감. diff --git a/docs/raw/요구_1010_2/조사_AE_표레벨외부사례.md b/docs/raw/요구_1010_2/조사_AE_표레벨외부사례.md new file mode 100644 index 000000000..67747815f --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AE_표레벨외부사례.md @@ -0,0 +1,109 @@ +# 조사 AE — 표 레벨 기능 외부 사례 (요구 99 · 조사만) + +대상 = B09 설계내역서 레벨(182 · 184 · 200-2 · 201 · 204-1) + 공용 표 `ui_template_data_table.ts` level 옵션. 코드 · 자료 고침 없음. + +## 1. 한눈 결론 + +- 지금 레벨은 「들여쓰기 + 굵게」 보기와 「한 줄씩 옮기는 손질」뿐. 외부 표가 모두 가진 **접기 · 펴기(▸ ▾)와 레벨 단추(1 · 2 · 3 · 전체)가 없음** — 사용자가 「제대로 안 됨」이라 느낄 가장 큰 까닭. +- 키가 일반 표와 거꾸로 — 우리 ↑↓ = 줄을 옮김(내역이 바뀜) · ←→ = 레벨. 외부(엑셀 · MS Project · 웹 표 · 접근성 기준)는 ↑↓ = 고른 줄만 옮김 · ←→ = 접기 · 펴기 · 레벨은 Alt+Shift+←→ 나 Tab. 게다가 **되돌리기(Ctrl+Z)가 없음**. +- 번호는 1 · 2단 묶음만 이름 앞에 붙음 — 실무 엑셀은 「공종」 칸을 따로 두고 Ⅰ · 1 · 4.1 · Ⅱ 로 적음. 경쟁사 STmate 는 번호 꼴까지 고르게 함. +- 한 줄만 고를 수 있어 여러 줄 한꺼번에 들여쓰기 · 옮기기 · 지우기 · 복사 · 붙여넣기 불가. 끌기는 표 가장자리에서 저절로 내려가지 않음. +- 넣는 길 — 새 라이브러리 없이 공용 표에 「접기」 옵션 하나(줄 차례 + 레벨만으로 숨김 셈) → 설계내역서 · 총괄 · 원가계산서 · 목록표가 같이 얻음. 키 체계 · 번호 칸은 사용자 결정 거리. + +## 2. 지금 구현 · 빈틈 + +지금 생김새: + +| 부분 | 지금 | 자리 | +|---|---|---| +| 저장 꼴 | 평면 배열 + parent — `user.bill` v2 `{v:2, rows:[{key · parent · kind group · 고친 칸}], gone}` · 배열 차례 = 형제 차례 | `B09_Estimation_UI_Tabs_Bill_Edit.ts` 머리 | +| 셈 | `Calc_Bill` 짝(.py ↔ .ts)이 나무를 다시 세움 → 부모 먼저 차례 · `level` 1~5 · `kind` title / group / item · 자식 생긴 항목 = 저절로 묶음 · 묶음 금액 = 자손 항목 합(묶음 머리 줄에) · 깊이 5 | `B09_Estimation_Calc_Bill.ts` tree() · computeBill() | +| 번호 | 직접비 1 · 2단 묶음만 `1` · `1.1` — 이름 글 앞에 붙임(고칠 때 떼어 냄) · 관급 · 3단 이하 없음 | 같은 파일 끝 · `…_UI_Tabs_Bill.ts` | +| 그리기 | 공용 표 `level {key, of}` → 명칭 칸 왼쪽 맞춤 + 레벨마다 1em 들여쓰기 · 묶음 · 제목 굵게 · 손 줄 왼쪽 띠 · 줄 1,000개용 보이는 구간만 그림 | `ui_template_data_table.ts` · `.css` 177~189 | +| 손질 | 우클릭(위 · 아래 · 하위 줄 추가 · 새 공종 · 위로 · 아래로 · 들여 · 내어쓰기 · 묶음 ↔ 항목 · 자동값으로 · 지우기) · 도구 단추 넷 · 끌기(위아래 = 자리 · 좌우 = 레벨 · 자손 함께 · 빨간 점선 = 못 놓음) | `…_Bill_Edit.ts` · `…_Bill_Level.ts` · `…_Bill_Drag.ts` | +| 키 | 고른 줄 + 글 칸 편집 아님 → Tab / Shift+Tab · ← → = 레벨 · ↑ ↓ · Alt+↑↓ = 옮김 · Esc = 편집 끝 / 고름 풂 | `…_Bill_Level.ts` keyAct() | +| 고름 | 한 줄(줄 키 하나) · 표시 = 호버와 같은 색 + 밑줄(`is-focus`) | 같은 파일 · `.css` 149 | + +빈틈: + +| # | 빈틈 | 지금 | 사용자가 겪는 것 | 외부 기준 | +|---|---|---|---|---| +| 1 | 접기 · 펴기 없음 | 늘 전부 펼침 | 줄이 많으면 공종 뼈대가 한눈에 안 보임 | 엑셀 ± · MS Project · AG Grid · Tabulator · STmate 레벨 거름 | +| 2 | 레벨 단추 없음 | — | 「1 · 2단만 보기」(실무 내역서총괄표 꼴) 불가 | 엑셀 1 2 3 · STmate 「= 이상 ~ ====== 이상」 | +| 3 | 계층 표시 약함 | 1em 들여쓰기 + 굵게 · ▸▾ 없음 · 트리 선 없음 · 글 크기 차 지움(200-2) | 3 · 4 · 5단 구분이 흐림 · 자식 있는 줄인지 모름 | Tabulator 가지 선 · AG Grid 쉐브론 · Handsontable 머리 들여쓰기 | +| 4 | 키 충돌 | ↑↓ = 줄 옮김 · ←→ = 레벨 · Tab 가로챔 | 표를 키로 훑다 내역 차례가 바뀜 · Tab 으로 표 밖에 못 나감 | ↑↓ = 고름 · ←→ = 접기/펴기 · Alt+Shift+←→ = 레벨 | +| 5 | 되돌리기 없음 | 키 한 번 = 바로 바뀜 | 잘못 누르면 손으로 되돌려야 함 | 모든 도구 Ctrl+Z | +| 6 | 한 줄만 고름 | 고름 = 줄 키 하나 | 10줄을 한 단 내리려면 10번 | MS Project · 구글 시트 = 여러 줄 고르고 Alt+Shift+→ | +| 7 | 복사 · 붙여넣기 없음 | — | 비슷한 묶음을 또 만들 때 줄마다 새로 | 엑셀 · Handsontable 줄 복사 | +| 8 | 번호 약함 | 1 · 2단만 · 이름 글에 붙음 · 관급 없음 | 실무 엑셀 꼴(공종 칸 Ⅰ · 1 · 4.1 · Ⅱ)과 다름 | MS Project 개요 번호 자동 · WBS 꼴 · STmate 번호 자동입력 | +| 9 | 끌기 다듬기 | 가장자리 자동 스크롤 없음 · 잡을 손잡이 없음(글 칸은 못 잡음) · 그린 구간 밖 못 놓음 | 긴 내역에서 먼 자리로 못 끎 · 잡을 곳을 모름 | AG Grid 끌기 = 자동 스크롤 · 줄 위 0.5초 머물면 자식 | +| 10 | 고른 줄 · 함께 갈 줄 표시 | 호버 색과 같음 · 자손 표시 없음 | 무엇을 골랐는지 · 옮기면 몇 줄이 가는지 흐림 | AG Grid · MS Project 고른 줄 진한 색 | +| 11 | 접근성 | role · aria 없음 | 화면 읽기 프로그램이 계층을 모름 | ARIA treegrid `aria-level` · `aria-expanded` | +| 12 | 보기 전용 표도 같음 | 총괄 · 원가계산서 · 목록표 · 노임 · 산근 · B08 = 들여쓰기만 | 큰 목록표에서도 접을 수 없음 | 공용 옵션 하나면 같이 해결 | + +## 3. 외부 공통 꼴 + +| 기능 | 엑셀(그룹 · 윤곽) | MS Project | AG Grid · TanStack · Tabulator | 견적 프로그램(STmate · 실무 엑셀) | ARIA treegrid | +|---|---|---|---|---|---| +| 저장 꼴 | 시트 차례 + 행마다 윤곽 수준(최대 8) | 작업 차례 + 개요 수준 | AG = 평면 + `parentId`(`treeDataParentIdField`) · TanStack = `getSubRows` → 펼친 줄만 평면 + `row.depth` · Tabulator = `_children` | STmate `BOQ11` 한 표에 말단 + 제목(T1~T5) + 계(E1~E5) + 제외(C) · `SORTCODE` / `ENDCODE` 로 짝 | 줄마다 `aria-level`(1부터) | +| 접기 · 펴기 | 묶음마다 ± | 요약 작업 ▸ · Alt+Shift+- / = | 쉐브론 ▸▾ · 처음 펼칠 레벨 지정(`groupDefaultExpanded` · `dataTreeStartExpanded` 레벨별) | STmate = 레벨 거름 · 실무 = 내역서총괄표가 2단까지 접은 같은 나무 | `aria-expanded` · → 펴기 · ← 접기 | +| 레벨 한꺼번에 | 1 2 3 단추(그 레벨까지 펼침) | 개요 레벨 1~9 보기 | 레벨별 펼침 배열 | STmate 「= 이상 ~ ====== 이상」 · 「1레벨(소계) 이상 ~ 6레벨(총대계) 이상」 | — | +| 계층 표시 | 여백의 윤곽 막대 | 들여쓰기 + 요약 굵게 | 들여쓰기 px + 가지 선 + 아이콘 | 실무 = 공종 번호 칸 + 묶음 뒤 빈 줄(들여쓰기 없음) | 레벨 값 | +| 요약 줄 합 | SUBTOTAL · 요약 줄 아래(기본) / 위 선택 | 요약 작업 = 하위 합 자동 | `aggFunc` 로 부모 줄 합 · 접혀도 합은 보임 | 실무 = 묶음 머리 줄에 합 · STmate = 제목 줄 + 계 줄 둘 다 | — | +| 들여 · 내어쓰기 | 그룹 / 그룹 해제 | Alt+Shift+→ / ← · 여러 줄 함께 · 위 작업이 요약이 됨 | Handsontable 메뉴 「자식 줄 넣기 · 부모에서 떼기」 | STmate 「계표시(레벨) 1단계 상향 / 하향」 | 편집 규칙 없음(앱 몫) | +| 번호 | 없음(손 · 수식) | 개요 번호 1 · 1.1 자동(손으로 못 고침) · WBS 꼴 = 레벨마다 숫자 · 대문자 · 소문자 · 구분자 | 없음 | STmate 「공종번호 자동입력」 = 가..하 · a..z · 0 · 00 · 000 · 앞뒤 글자 · 실무 Ⅰ / 1 / 4.1 / Ⅱ | `aria-posinset` · `aria-setsize` | +| 끌어 놓기 | 행 잘라 붙이기 | 끌기 | AG = 줄 위 0.5초 머물면 그 줄 자식 · 자기 아래 금지 · Tabulator = 나무에선 끌기 막음 | — | — | +| 키 이동 | ↑↓ 칸 이동 | ↑↓ 고름 | ↑↓ 고름 | — | ↑↓ 줄 · Home / End · Tab = 다음 단추 · 표 밖 | +| 여러 줄 | 범위 고름 | 범위 고른 뒤 들여쓰기 | 여러 줄 고름 | STmate 「선택된 항목만」 일괄 보정 | — | + +공통 꼴 요약 — 줄은 평면 배열(차례 + 깊이 또는 부모 키) · 화면엔 펼친 줄만 · 자식 있는 줄에 ▸▾ · 접혀도 묶음 합은 보임 · 레벨 단추로 한 번에 접음 · 번호는 차례로 자동. + +## 4. 우리 표에 넣을 것 (제안 · 새 의존성 없음) + +| 차례 | 무엇 | 크기 | 꼴 | +|---|---|---|---| +| ① | 공용 표 접기 · 펴기 | 작음~중간 · `ui_template_data_table.ts` +60줄 안팎(428 → 500 안) · CSS +15 · 새 시험 1 | `level: { key, of, fold?: { id: (i) => string, open?: number } }` — 자식 = 바로 아래 줄 레벨이 더 큼(차례 + 레벨만, parent 필요 없음) · 접힌 줄 아래 더 깊은 줄 숨김 · ▸▾ 단추 = 레벨 칸 글 앞 · `data-i` = 원래 줄 번호 그대로(메뉴 · 끌기 · 고침 그대로) · 보이는 구간 셈은 펼친 줄 목록 위에서 · 접힘 = 줄 키 모음(표 안에 두고 setRows 뒤에도 지킴) · `t.foldTo(n)` | +| ② | 레벨 단추 [1] [2] [3] [전체] | 작음 · `…_Bill_Level.ts` +20 | 설계내역서 도구 줄 · 보기 전용 표는 줄 많은 표(목록표 · 집계표)만 | +| ③ | 되돌리기 Ctrl+Z · 다시 Ctrl+Y | 작음 · +30 | `user.bill` 이전 판을 메모리에 쌓음(탭 안 · 저장 안 함) · 캐시 · [저장] 흐름 그대로 | +| ④ | 키 정리 | 작음 · keyAct +10 · 시험 200_2 · 204_1 기대값 | 안 = ↑↓ 고름 옮김 · ←→ 접기 / 펴기(ARIA) · Alt+Shift+←→ 와 Tab / Shift+Tab = 레벨 · Alt+↑↓ = 줄 옮김 — 204 「방향키 = 레벨」 뒤집음 | +| ⑤ | 계층 표시 | 작음 · CSS +15 | ▸▾(①) + 레벨 칸 세로 안내선(배경 그라데이션 · 1em 간격 · 요소 안 늘림) + 고른 줄 진한 색 + 함께 갈 자손 옅은 띠 | +| ⑥ | 여러 줄 고르기 | 중간 · +100 · 시험 | Shift+클릭 · Shift+↑↓ = 이어진 줄 · 같은 부모 형제끼리만 한꺼번에 들여 · 내어 · 옮김 · 지움(MS Project 꼴) | +| ⑦ | 끌기 다듬기 | 작음 · `…_Bill_Drag.ts` +20 | 표 위아래 끝 근처 자동 스크롤 · 레벨 칸 왼쪽 손잡이 ⋮⋮ · 접힌 묶음은 자손째(지금 규칙 그대로) | +| ⑧ | 번호 칸 | 중간 · 셈 짝 .py ↔ .ts + 거울 시험 | 실무 꼴 「공종」 칸 따로(Ⅰ · 1 · 4.1 · Ⅱ) · 3단 이하 번호 여부 · 번호 꼴(STmate · WBS 꼴) | +| ⑨ | 줄 복사 · 붙여넣기 · 접근성 | 중간 / 작음 | 묶음째 복사(새 hand: 키) · `role="treegrid"` · 줄 `aria-level` · `aria-expanded`(① 안에서 몇 줄) | + +- 설계내역서 = ①~⑨ · 보기 전용 표(총괄 · 원가계산서 · 목록표 · 노임 · 산근 · B08 집계) = ① · ② · ⑤ · ⑨(접근성)만 — 옵션을 켠 표만 바뀜. +- 접힘 · 펼침 = 보기 상태 — 작업본 · 초기값에 넣지 않음. +- 묶음 합은 이미 머리 줄에 있어 접어도 금액이 보임(외부 꼴과 같음). + +사용자 결정 거리: + +- ⬜ AE-a 키 체계 — 추천 표준(④) / 지금(방향키 = 레벨) 그대로. +- ⬜ AE-b 처음 펼침 — 추천 전부 펼침 + 레벨 단추 / 2단까지만. +- ⬜ AE-c 번호 — 추천 실무 꼴 「공종」 칸 따로 · 1 · 2단 + 관급 Ⅱ / 지금대로 이름 앞. +- ⬜ AE-d 접힘 기억 — 추천 탭 안에서만(새로 열면 펼침) / 브라우저에 기억. + +## 5. 출처 + +웹: + +- 엑셀 윤곽 · 그룹 — https://support.microsoft.com/en-us/office/outline-group-data-in-a-worksheet-08ce98c4-0063-4d42-8ac7-8278c49e9aff +- 엑셀 단축키(Ctrl+8 윤곽 기호) — https://support.microsoft.com/en-us/office/keyboard-shortcuts-in-excel-1798d9d5-842a-42b8-9c99-9b7213f0040f +- 구글 시트 행 그룹(Alt+Shift+→ / ←) — https://www.howtogeek.com/755108/how-to-group-and-ungroup-rows-and-columns-in-google-sheets/ +- MS Project 단축키(들여 · 내어쓰기 · 하위 숨김) — https://support.microsoft.com/en-us/office/keyboard-shortcuts-for-project-7a4dc22b-ceb8-4a97-ac6f-6f9eaf36414a +- MS Project WBS · 개요 번호 — https://support.microsoft.com/en-us/project/create-wbs-codes-in-project-desktop · https://support.microsoft.com/en-us/project/wbs-code-definition-dialog-box +- AG Grid 평면 + parentId — https://www.ag-grid.com/vue-data-grid/tree-data-self-referential/ +- AG Grid 나무 끌기 — https://www.ag-grid.com/vue-data-grid/tree-data-row-dragging/ +- TanStack Table 펼치기 — https://tanstack.com/table/v8/docs/guide/expanding · https://tanstack.com/table/v8/docs/api/features/expanding +- Handsontable 부모 · 자식 줄 — https://handsontable.com/docs/row-parent-child +- Tabulator 나무 — https://tabulator.info/docs/6.3/tree +- ARIA treegrid — https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/ · https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/examples/treegrid-1/ +- 견적 도구 접기(접어도 합 보임) — https://help.constructiononline.com/en/expand/collapse-estimate-category-1 + +지식DB · 실무: + +- STmate 내역 편집 「계표시(레벨) 1단계 상향/하향」 · `+=` ~ `+=====` 레벨 — `resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/26_편집기와_사이클식.md` §5 +- STmate 레벨 거름 · 공종번호 자동입력 `wITNum` — 같은 폴더 `34_화면구조_정리.md` §2.1 · `22_UI_폼_분석.md` §3.5 +- STmate `BOQ11` T · E · C 줄 · `SORTCODE` / `ENDCODE` — 같은 폴더 `16_골든셋_회귀_결과.md` §4.1 · `02_데이터셋과_파일형식.md` +- 다산 ESTX `Level` 칸(-3 ~ 3 · 뜻 확인 불가) — `resources/knowledge/original/원가계산/다산소프트/ESTX_값사전_견적내역.md` +- 실무 내역서 꼴(공종 칸 Ⅰ · 1 · 4.1 · Ⅱ · 묶음 머리 줄 합 · 내역서총괄표 = 2단까지) — `resources/knowledge/original/실무문서/01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(…)내역+수량.xlsx` 「내역서」 · 「내역서총괄표」 시트 diff --git a/docs/raw/요구_1010_2/조사_AF_강조체계.md b/docs/raw/요구_1010_2/조사_AF_강조체계.md new file mode 100644 index 000000000..f4b602a42 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AF_강조체계.md @@ -0,0 +1,166 @@ +# 조사 AF — 강조(하이라이트) 체계 전수 · 3D 측점 띠 바로 꺼짐 (요구 104 · 105) + +기준 = HEAD `a22deacf6`(231-1 · 231-2 · 230-2 합친 뒤) · 코드 · 자료 · git 안 바꿈 · 재현은 시스템 임시 폴더 스크래치. +약칭 — DSel `A00_Common/design/design_selection.ts` · DVP `…/design_view_profile.ts` · DVC `…/design_view_cross.ts` · DP `…/design_profile.ts` · MK3 `B05_Profile/B05_Profile_UI_Markers.ts` · MI `…_Viewer_Marker_Input.ts` · SPK `…_Viewer_Structure_Pick.ts` · PKS `…_Structure_Pick_Session.ts` · SY `…_Selection.ts` · R `…_Profile_Render.ts` · SP5 `…_Structures_Panel.ts` · CM `…_Structures_Panel_Commit.ts` · LI `…_Structures_List.ts` · DrP `…_Drainage_Panel.ts` · DrPi `…_Drainage_Pipes.ts` · SV `B06_Section/B06_Section_UI_Section_View.ts` · SVP `…_Section_View_Profile.ts` · CV `…_Cross_View.ts` · SP6 `…_Page_Structures_Panel.ts` · DET `…_Cross_Detail.ts` · DTS `…_Cross_Detail_Select.ts` · NC `…_Cross_Notice_Chips.ts` + +## 1. 한눈 결론 + +- 104 까닭 확정 — 3D 띠를 누르면 **누를 때 켜고(마커 듣개) 뗄 때 끔(3D 부재 고름 듣개)**. 부재 고름은 띠를 못 보고 「빈 곳 누름」으로 읽어 고름 전체를 지움(세션 · 띠 · 핀 · 종단 선). 09-04(2878ab961)부터 있던 구멍 · 오늘 띠 누름이 폼 · 세션까지 움직이게 되어(188-1 · 216-1) 눈에 띔. +- 스크래치 재현 — 진짜 코드로 누름 뒤 빨강 · 핀 · 세션 {at:120} → 뗌 뒤 노랑 · 핀 끔 · 세션 비움(코리도 있든 없든 같음). 「누름이 띠에 맞으면 부재 고름 건너뜀」 흉내로 뗌 뒤에도 유지. +- 강조 자리 = 화면 7곳 · 25개 남짓 · 상태 변수 15개 넘게 · 공유는 세션 한 칸(`structure-pick`)뿐. 알림 장치(`subscribeSelection`)는 있으나 **듣는 곳 0** — 화면끼리 손으로 서로 부르는 사슬. +- 되풀이 까닭 — 원천 여럿 · 같은 누름을 듣개 둘이 받음 · 재진입 가드가 뒤 고름을 버림 · 「해제」 뜻이 화면마다 다름 · 다시 계산이 강조를 버림 · 찾는 거리 제각각. +- 추천 — (가) 104 구멍 두 줄 먼저(작음) → (나) 「세션 한 칸 = 유일 원천 · 화면은 구독해 파생」으로 단계별 묶음(해제 규칙은 사용자 협의 뒤). + +## 2. 강조 지도 + +| 자리 | 켬 | 끔 · 리셋 | 상태 | 듣는 것 · 다시 그림 때 | +| --- | --- | --- | --- | --- | +| 3D 측점 띠(빨강) + 핀 | MK3:493 `selectStation` ← MI:124 누름 · DVP:143 · :149 · :229 · :332 | 같은 함수 null ← PKS:63 → SY:112 · DVP:332 · :568 | MK3:121 `selectedStationId` | 다시 그림(MK3:403)은 유지(:437) · 숨은 핀은 마지막 자리에 남음(:487-491) | +| 3D 구조물 부재(자체발광) | SPK:134 누름 · :153 `selectAtChainage` · :160 `select` | SPK:134 빈 곳 = null · `selectAtChainage(null)` | SPK:53 `selected` | 코리도 새로 만듦 → reapply(`B05_Profile_UI_Viewer.ts:166`) 유지 | +| 종단 측점 세로선 · 표 값 칸 · 유토곡선 선 | DP:243 `selectStation`(토글 · 바깥 알림 :248 는 이것만) · :519 `setSelectedStation` · :569 `setSelectedStructure` | R:223 빈 곳 · R:457 · DP:610 · `setSelectedStructure(null)`(keepStation 거짓이면 끔 · B06 만 참 SVP:268 · 177-3) | DP:211 | 매 그림 다시 칠함(R:130 · :267) | +| 종단 구조물 알약 · 구간 띠 | R:326-332 누름 · DP:246 · :522 · :570 짝 | 다시 누름 · R:226 · DP:562-564 목록에 없으면 조용히 버림 | DP:218 | 관 알약 id = `pipe-`+toFixed(2)(`B05_Profile_UI_Page_Structures.ts:207`) → 관이 움직이면 끊김 | +| 배수유역도 유역 채움 · 유역 목록 줄 | DrP:323 `selectBasin` | **DrP:364 재계산마다 끔** | DrP:128 | 지도 그림 rAF(DrP:305) | +| 배수유역도 관 마커 | DrPi:178 ← DrP:642 | **DrPi:172 `setPipes` 재계산마다 null** | DrPi:70 | 폼 · 목록 · 종단은 관을 계속 고름 → 지도만 꺼짐 | +| 배수유역도 측점 마름모 | DrP:598 `markStation`(SY:73 만) | 같은 함수 null | DrP:130 | 지도 · 유역에서 고를 땐 안 옮김(SY:98-125) | +| 좌측 목록 줄 | LI:223(구조물) · LI:231-233(관 · **폭 0.05 m**) | `renderList` 마다 다시 셈 | SP5:177 · :179 · :183 | 관 0.51 m 안 옮김에 `selectedPipeChainage` 안 고침(SP5:590-600) → 줄 강조 빠짐 | +| 좌측 폼 | CM:94 `loadForm`(늘 onSelect) · CM:163 `loadPipeForm` | SP5:374 `resetForm` · EV `startNew`(+세션 비움) | 위와 같음 | `setStructures`(SP5:556 · 저장 · 새로 읽기 뒤)가 관 폼 닫고 onSelect(null) → 종단 선 · 알약 끔(DVP:349) | +| B06 카드 고름 테두리 | SV:293 `selectStation` · SV:587 `focusStation` | SV:295 다시 누름 · :322 `clearSelection` · :598 `revealStation`(알림 없이) | SV:102 | 다시 그림이 다시 얹음(SV:350) | +| B06 `--span` 범위 테두리 · 포장층 | SV:607 `selectMark` → :256 `applySpan` | `selectMark(null)` · span 거짓(SV:609) | SV:245 `spanMarkId` | 다시 얹음(SV:374) · **카드 해제 DVC:388 · `releaseCross` DVC:498 이 `selectMark(null)` 안 부름 → 남을 수 있음** | +| B06 면적 띠 | CV:286 · SV:309 | SV:298 · CV:221 | SV:104 · CV:192 | 고른 카드에서만 다시 얹음 | +| B06 벽 · 집수정 · 관 · 도수로 형상 · 세월교 · BOX 몸체 | CV:232 · `…_View_Box.ts:52` | 같은 토글 · CV:262 · DVC:498 | `…_Page_Station_Controls.ts:272` · `…_Ford_Controls.ts:227` · :428 | 다시 얹음 | +| B06 계획선 조각(보라) | CV:308 → `…_Design_Pick.ts:523` | CV:205 | `…_Design_Pick.ts:278` | 고른 카드 · 다른 고름 없을 때만 다시 얹음(CV:325) | +| B06 좌측 조정 묶음 | `…_Adjust_Dock.ts:162` | :114 | DOM | 카드 다시 그릴 때마다 다시 붙고 좌측을 스크롤(:184) | +| 횡단 → 종단 선 · 알약 | SVP:270 `showCard` · SVP:266 | `showCard(null)` | DP 상태 공유 | — | +| [상세] 모달 칸 · 그림 | DET:304 | 다시 누름 · 키 없어짐 DET:297 | DET:293 | paint 마다 다시 얹음(DET:337) | +| [상세] 모달 선 조각 | DTS:193 | **DTS:212 다시 칠할 때마다 지움**(창 크기 · 카드 다시 세움 DET:316 · :412) | DTS:167 | 「바로 꺼짐」 또 한 자리 | +| 알림 칩 측점 단추(207) | NC:65(SV 카드 고름 읽음) | 고르면 목록 닫음 NC:88 | NC:47 | 구독 없음 → 열린 동안 고름이 바뀌면 낡음 | + +## 3. 고름 원천 · 차례 + +원천 여덟 무리(공유는 ①뿐): +① 세션 한 칸 `structure-pick` {at · sid · key} — DSel:150 `selectDesign`. 쓰는 곳 B05: DVP:118 `rememberPick`(알약 :182 · 측점 :244 · 관 목록 :322 · 구조물 목록 :345) · PKS:62 · :68(3D) · EV `startNew` / B06: DVC:372(부재) · :390(카드 해제) · :398(카드) · :515(`releaseCross`) · SP6:207 · :233(231-2 목록 고름 늘) · :319(관). 읽는 곳: DVP:135 · PKS:34(되살림 DVP:252 · :560) · DVC:466 · :569 · SP6:197 · :409. +② 3D 띠 MK3:121 ③ 3D 부재 SPK:53 ④ 종단 DP:211 · :218 ⑤ 좌측 폼 SP5:177-183 ⑥ 배수유역도 DrP:128 · :130 · DrPi:70 ⑦ B06 SV:102 · :104 · :245 + 형상 · 계획선 · `preferredId` · `openingAtStation`(SP6) ⑧ 모달 · 칩. + +누가 이기나: +- B05 안 = 손 사슬 + 재진입 가드 `selectionSyncing`(DVP:216). 가드가 켜진 동안 들어온 고름은 **통째로 버려짐**(SY:79 · :99 · DVP:323 · :346) → 188-1 이 끝에 `remember`(SY:96)로 덮어 「마지막 하나」를 맞춤. +- 세션 = 한 누름 안에서도 여러 번 적힘 — 측점선 누름 → 폼 콜백이 구조물 id 적음 → 끝에 측점으로 덮음. 마지막 `selectDesign` 이 이김. +- B05 ↔ B06 = 숨은 보기는 세션만 남기고 보일 때 다시 풂(DVP:558 `syncPick` · DVC:564 onShow → SP6 `load()` → `restoreStructurePick`). 다시 풀 때 거름(노면공 `coverable`)이 길마다 따로 넘겨져 한 곳 빠지면 다른 것이 열림(조사 AD · 231). +- 덮어쓰는 길 — (a) 3D 빈 곳 누름 PKS:62 세션 비움 = 104 (b) 새로 놓기 (c) 카드 해제 · `releaseCross` (d) `setStructures` 가 관 폼 닫음 (e) 배수 재계산이 지도 강조 끔. + +## 4. 104번 까닭 · 재현 + +차례(3D 띠 하나 누름): +1. 누름 — MI:105 `handlePointerDown` → :124 `markers.selectObject(hit)` → MK3:523 → :493 띠 빨강 · 핀 · :502 알림 → DVP:380 → 종단 선 + SY:78 → 폼 열기(`openAtStation`) · 세션 {at}. MI:125 는 `stopPropagation` — 같은 캔버스의 다른 듣개는 그대로 돎. +2. 같은 누름을 SPK:121 `handleDown` 도 받아 적음 — `blocked()`(MI:184)는 「점 끌기 · 이동 대기」만이라 띠 누름은 안 막힘. +3. 뗌 — SPK:128 `handleUp` → :98 `pickAt` 은 **코리도 무리만** 봄(띠는 마커 무리) → 띠 뒤 리본 · 지형이거나 코리도가 없으면 null → :136 `onPick(null)` → PKS:61-64 세션 비움 + `selectStationOfPipe(null)` → SY:112 띠 노랑 · 핀 끔 → DVP:381 종단 선 끔 · 좌측 `selectPipeByChainage(null)`. +4. 결과 — 누름~뗌 사이(0.1초 안팎)만 빨강. 1에서 연 구조물 폼은 남을 수 있음(관 폼만 닫음 SP5:603-609) · 세션이 비어 B06 로 가면 카드 없음 · `--span` 은 남을 수 있음(2장). + +갈래: +- 관 측점 띠 = 뗌이 띠 뒤 관 부재를 집으면 관 측점으로 다시 켜짐(SY:109) · 리본에 맞으면 꺼짐. +- 벽 등 구조물 위 일반 측점 = 부재 강조만 남고 띠는 꺼짐(`selectStationOfPipe` 는 관 측점만 찾음 SY:103-108). +- 언제부터 — 2878ab961(09-04 3D 부재 고름)의 「null → `selectStation(null)`」. 08-05 규칙 「3D 빈 곳 누름은 측점 고름을 안 풂」(MK3:526-529)과 정면으로 어긋남. + +스크래치 재현(진짜 MK3 · MI · SPK · SY · PKS · DSel + three 0.185 · 가짜 캔버스 · 저장소 밖): + +| 경우 | 누름 뒤 | 뗌 뒤 | +| --- | --- | --- | +| 코리도 있음(리본) | 띠 #ef4444 · 핀 보임 · 세션 {at:120} | 띠 #facc15 · 핀 끔 · 세션 null | +| 코리도 없음 | 같음 | 같음 | +| 흉내 고침(누름이 마커 · 띠에 맞으면 부재 고름 건너뜀) | 같음 | 빨강 · 핀 · 세션 그대로 | + +시험 빈칸 — MI · SPK 를 한 누름으로 함께 도는 시험 없음(test_231_1 은 `onPick` 을 바로 부름) → 104 를 못 잡음. 위 스크래치 꼴이 고칠 때 시험 바탕. +곁 구멍 — three 레이캐스트는 `visible` 을 안 봄(three.cjs `intersect`) → 숨은 핀(마지막 고른 자리에 남음) · 꺼 둔 측점선 · 라벨이 누름을 가로챔(MI:89 첫 맞음). 띠 가운데를 누르면 숨은 핀에 먼저 맞아 띠가 안 켜짐(스크래치에서 실제 겪음). + +## 5. 되풀이 구조 원인 + +1. 원천이 여럿 — 공유 세션 하나 + 화면마다 자기 변수. 세션은 「넘김용」이고 화면 안 강조는 자기 변수가 진짜라 둘이 어긋남(104 = 변수 켬 → 세션 지움 → 변수 끔). +2. 알림 장치 놀고 있음 — `subscribeSelection`(DSel:135) 듣는 곳 0 · DVP 한 파일에 고름 길 일곱(측점선 :148 · 지도 :176-177 · 알약 :180 · 관 목록 :321 · 구조물 목록 :342 · 3D 띠 :380 · 3D 부재 PKS:60)이 저마다 다른 화면을 다른 차례로 부름 → 길마다 빠지는 화면이 다름(지도 마름모 · 관 → 구조물 때 지도 강조 남음). +3. 재진입 가드가 「막기」 — 순서 의존 · 뒤 고름 버림 → 덮어쓰기 덧댐(188-1). +4. 같은 누름을 듣개 둘이 받음 — 3D(마커 · 부재) · 종단(측점선 · 도구 R:207-221) · B06(카드 · 형상 SV:426 캡처). 서로 다른 결론을 차례로 적음. +5. 「해제」 뜻이 제각각 — 3D 빈 곳(08-05 안 풂 ↔ 09-04 풂) · 종단 빈 곳 풂 · 카드 다시 누름 풂 · 보기 오감 `releaseCross` 풂. 어디까지 푸는지(세션 · 폼 · span)도 다름. +6. 다시 계산이 상태를 버림 — 배수 재계산(DrP:364 · DrPi:172) · [상세] 조각(DTS:212) · `setStructures`. 반대로 B06 카드는 다시 그림이 다시 얹음 → 화면마다 규칙이 다름. +7. 찾는 거리 · 키 제각각 — 0.51(DSel) · 0.05(LI:232) · 0.01(SPK:75) · 0.006(213) · 관 알약 id toFixed(2) → 관이 조금만 움직여도 강조가 안 이어짐(7장과 같은 뿌리). +8. 거름(노면공)을 부르는 쪽마다 넘겨야 함 → 한 곳 빠지면 샘(AD · 231-1 이 다섯 곳에 덧댐). + +## 6. 고칠 방향 비교 · 추천 + +(가) 구멍만 막기 — 각 1~5줄 · 위험 낮음 · 고친 곳마다 시험 하나 +- a1(104) SPK 가 마커 · 띠에 맞은 누름을 건너뜀(`B05_Profile_UI_Viewer.ts:261` `blocked` 에 「누름이 마커에 맞음」 · MI 에 값 하나) + `onPick(null)` 은 3D 부재 강조만 풀고 세션 · 측점 고름은 안 지움(PKS:61-64 · 08-05 규칙). +- a2 숨은 핀 · 꺼진 측점선을 레이캐스트에서 뺌(MI:89 · `visible` 거름). +- a3 배수 재계산 뒤 고른 관 · 유역 다시 얹음(DrP:364). +- a4 카드 해제 · `releaseCross` 에 `selectMark(null)`(DVC:388 · :498). +- a5 목록 관 줄 폭 0.05 → `PIPE_MATCH_M` · 옮김 때 고른 자리 갱신(LI:232 · SP5:590-600). +- a6 지도 · 유역 고름 때도 마름모 옮김(SY:98-125 에 `markStation`). +- 흠 — 오늘만 177-3 · 177-4 · 188 · 189 · 216 · 231 · 104 일곱 번째 덧댐 · 다음 길이 또 샘. + +(나) 원천 하나 + 강조 파생 +- 원천 = DSel 세션 한 칸(이미 있음) + 고름 갈래 칸 하나(측점 · 구조물 · 부재 · 관 — 189 세 갈래를 값으로). +- 쓰기 = 모든 누름은 `selectDesign` 만 · 화면끼리 직접 부르기와 `selectionSyncing` 지움. +- 읽기 = 화면마다 `subscribeSelection` 듣개 하나 → 「값 → 그 화면 강조」 함수 하나(3D 띠 = 측점 id · 종단 = `graphTargetAt` · 폼 = `resolveSelection` + `coversAtStation` · 카드 · span = `applyStructurePick`) · 다시 그림 · 다시 계산 뒤에도 같은 함수로 다시 얹음. +- 해제 규칙 표 하나 · 찾는 거리 한 값(7장). +- 크기 큼(DVP · SY · PKS · DVC · SP6 · SV · DP · CM · DrP 등 10파일 안팎 · 300~500줄) · 위험 중~높음 — 지금 규칙을 박은 시험(165 · 177_3 · 177_4 · 188_1 · 189_1 · 216_1 · 231_1 · selection_single)이 안전망. +- 차례 — ① 값 모양 · 해제 규칙 사용자 협의 ② B05 3D · 종단을 구독으로(104 길 포함) ③ 좌측 폼 ④ B06 카드 · span ⑤ 지도 · 모달 · 칩 ⑥ 직접 부르기 · 가드 지움. 단계마다 위 시험 통과. + +231 겹침 — 231-1(64c9470f9) · 231-2(4db793117) 모두 메인에 합쳐짐. a1 은 231 이 만진 PKS `onPick` 안 null 갈래만 → 충돌 없음 · test_231_1 함께 돌림. 231 의 `coverable` 은 (나) ③ 폼 파생 함수 한 곳으로 들어감. + +추천 — a1 + a2 먼저(104 바로 풂) → (나)는 협의 뒤 단계별 · a3~a6 은 (나) ②~⑤에 흡수(급하면 따로). +⬜ 사용자 협의 — 「3D 빈 곳 누름 = 고름 해제」 여부(08-05 안 풂 ↔ 09-04 풂) · 해제가 어디까지(세션 · 폼 · span)인지. + +## 7. 소수 측점 1단위 반올림 (요구 105) + +언제 · 어떻게 +| 날짜 · 커밋 | 장 | 내용 | +| --- | --- | --- | +| 07-30 12f166650 · 08-02 32fd199db | — | 「같은 자리」 폭 0.51 m — ±0.5 라 사실상 1 m 사사오입 맞춤(고름 · 유역↔관 짝) | +| **08-28 0979927df** | — | **기억하신 그것** — 서버가 관 · 구조물 측점을 `int(round())` 로 같은 정수 m 격자 측점에 붙임(관 80.17 = 카드 80 에 이름표만 · 까닭 = 횡단 파일 이름이 정수 m) | +| 09-29 867c4650c · 08e493f13 | 20장(사용자 확정 9-29) | 붙임 풂 — 카드 80 과 80.17 따로 · 파일 이름 소수 2자리 · 0.005 m 안만 합침(`B05_Profile_Engine_Sections_Core.py:349-353`) · **화면 표기만 정수**(119.8 → 5+20) | +| 09-29 8a9456579 | 24-2d | 0.51 을 `PIPE_MATCH_M`(DSel:17) 한 값으로 — 사본 · 손으로 적은 0.51 은 TS 21곳 남음 | +| 10-10 002165f04 · ca3786115 | 213-1 · 219-1 | 같은 측점 폭 0.006 m(서버 · TS 짝 `B05_Profile_UI_Profile_Alignment.ts:202`) | +| 10-10 42673505e | 231-1 | 넘김 카드 한 곳만 「찾는 거리 안 가장 가까운 카드」(관 80.17 → 카드 80 막음) | + +지금 동작(관 80.17 · 카드 80) +- 고름 풀기 = 0.51 안 관 먼저(DSel:94 · SY:131-142 · SVP:43 · :276-279) → 카드 80 을 눌러도 관 80.17 폼 · 종단 관 선. 「관 먼저」는 9-29 전 「한 카드」 전제. +- 0.51 안 **첫** 항목 — DVC:128-131 `ownerAt` · `B06_Section_UI_Page_Structures_Spec.ts:146-148` · `…_Pipe_Options.ts:68-69` · `B05_Profile_UI_Profile_Structures.ts:121` · `:181` · `B05_Profile_UI_IrregularStations.ts:483-543` → 1 m 안 소수 측점 둘이면 엉뚱한 것을 고름 · 옮김 · 지움(앞 셋은 코드로 읽은 추정). +- 표기 `common_util/common_util_station.ts:24` 반올림 → 80 과 80.17 둘 다 「4+0」 · 119.8 = 「5+20」. +- 바른 본보기 — `B06_Section_UI_Page_Structure_Pick.ts:61`(231-1) · `design_structure_pipe_calc.ts:63-68`. + +흠 — ① 9-29 뒤 0.17 m 떨어진 두 카드를 폭 0.51 이 한 자리로 봄 ② 관 측점 ↔ 일반 측점 혼동 ③ 폭 일곱 갈래(0.005 · 0.006 · 0.01 · 0.02 · 0.05 · 0.1 · 0.51) ④ 다른 두 카드가 같은 글자로 보임. + +지금 자료(검증 프로젝트 13개 · 읽기만) +- 관 136/136 개 모두 소수(자동 배치 3자리 484.213 · 고친 것 2자리) · 20 m 일반 측점 1 m 안 관 = 프로젝트당 0~4개(대부분 11 중 4) · 엄상준_검증 80.17(초기 80.374 · 카드 80 옆) · 263.31(초기 263.206 · 213 · 219 방향키 흠 자취). +- 구조물(structures.json 있는 4개) 1/4 소수(표지 127.86). + +근거(지식DB) +- 별표2(`resources/knowledge/original/법률/산림자원의 조성 및 관리에 관한 법률 시행규칙/별표/별표2_…md` L25) — 20 m 중심말뚝 · 구조물 지점 보조말뚝 · 정밀도 규정 없음. +- 교재 `산림과임업기술(임도)/2. 임도/5. 설계.md` L157 — 「No.11+13」 정수 예 · 소수는 곡선 BC · EC(L184-189)뿐. +- 실무 자료 — 오솔길 PIN 보조측점 449개 · 구조물BOM 42개 · 울진 · 영덕 수량산출 = 구조물 · 횡단 측점 전부 정수 m · 소수는 곡선점 · 운반거리 셈표뿐. +- 품셈 1-2-2(`원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md` L53-60) — 연장 · 관 길이 소수 2자리(측점 규정 아님). +- 관이 꼭 소수 자리여야 할 까닭 — 못 찾음. 계곡 관도 0.5 m 안 옮김은 실무 꼴과 같음. 곡선점은 소수가 맞으나 구조물 기준 측점 아님. + +정수 맞춤 — 넣을 자리(지금은 어디서도 정수로 안 맞춤 · 0.01 이거나 날값) +| 길 | 자리 | 지금 | 맞춤 | +| --- | --- | --- | --- | +| 좌측 폼 시작 · 기준 · 종료 측점 | `B05_Profile_UI_Structures_Fields.ts:142` · `:144`(잔여 칸 step 0.1 · 막지 않음) · `:156-171` 정규화 · `:182-199` 읽기(날값) | 날값 | step 1 · 확정 때 반올림 | +| 구간형 시작 · 끝 | `B05_Profile_UI_Structures_Panel_Commit.ts:307-308`(기준 − 앞 길이 · + 길이 0.1 m) | 소수 | 기준만 맞추면 시작 · 끝 소수 남음 · 측점은 세 곳에 심김(`B05_Profile_Engine_Sections.py:285-311`) | +| 종단 알약 · 측점선 끌기 · 우클릭 추가 | `B05_Profile_UI_Structures_Marks.ts:339` · `B06_Section_UI_Longitudinal.ts:186` · `B05_Profile_UI_Profile_Structures.ts:81` · `B05_Profile_UI_Viewer_Menu.ts:79` | 0.01 | 1 m | +| 방향키 | `B05_Profile_UI_Profile_Panel_Tools.ts:36` · `:237` | 0.1 m | 1 m(219 소속 규칙 그대로) | +| 배수유역도 관 놓기 · 끌기 | `B05_Profile_UI_Drainage_Pipes.ts:236` · `:264-294` | 날값 | 1 m | +| B06 관 옮김 · 폼 | `B06_Section_UI_Page_Structures_Panel.ts:483` · `:265-270`(**0.51 m 넘을 때만 옮김 → 80.17→80 고침은 무시됨**) | 0.01 | 1 m · 폭 거름 손봄 | +| 서버 자동 배치 · 재계산 · 노선 바뀜 재투영 | `common_util/common_util_drainage_detail.py:437-457`(1 m 걸음이나 시작이 소수) · `:476-490`(관 재계산 한 곳 `round(,2)`) · `common_util/common_util_drainage_pipes.py:207-230`(좌표 정본 → 다시 소수) | 소수 | 재계산 한 곳 · 재투영 뒤 반올림 | +| 기존 자료 | pipe_points.json · structures.json | 소수 | [정수 맞춤] 단추 | + +- 받는 곳에서 한 번 — 화면은 `moveById`(SP5:670) · 폼 commit · 관 목록 문(DrPi `moveTo` · `addAtChainage` · `addAt` · 끌기) · B06 `movePipeTo` · `KEY_STEP_M`, 서버는 관 재계산 한 곳 + 재투영 뒤. 서버에서만 자르면 시설 옵션 이어받기 폭 0.05(`common_util_drainage_pipes.py:371`)를 넘어 관 종류 · 옵션이 기본값으로 돌아감 → 화면 입력에서 맞춤이 먼저. +- 다시 쓸 함수 — `design_profile_free.ts:79` `snapStation`(215-1 79 m → 80 m) · `B05_Profile_UI_Profile_Guard.ts:79` `snapToMasterStations` · 서버 격자 합침 `B05_Profile_Engine_Sections_Core.py:347-354`. + +영향 — 관 길이 · 형상은 가까운 측점에 붙어 그대로(`B06_Section_Engine_Culvert.py:639-666`) · 정수 관이 일반 측점 자리면 서버가 이미 한 카드로 합침(Sections_Core:349-353 · 08-28 꼴) · 정수 파일 이름은 옛 규칙과 같아짐(`B05_Profile_Engine_Sections.py:130-134`) · 225 관보호공 0.1 m 이동은 횡단 안 자리라 무관. +기존 자료 정리 함정 — 한꺼번에 옮기면 213 편집 키 「사라진 것 하나 · 새것 하나」 규칙(`B05_Profile_UI_Profile_Alignment.ts:189-199`)이 안 맞아 옛 계획고 편집이 [저장] 때 버려짐 · B08 · M02 `pipe@측점` 키는 [저장]을 거쳐야 이름이 따라감(`M02_Item_Snapshot.py:227`) · 80.17 자리 횡단 설계 · B07 도면 id 는 고아 · 관 id `pipe-`+toFixed(2) 세션 고름 한 번 풀림 → 단추는 하나씩 옮기는 길(213 따라감) + [저장] 거침. +깨질 시험 — test_b05_station_roundtrip(119.8 · 120 따로) · test_b06_patch_station · test_b05_b06_save_roundtrip · test_b05_extra_stations_supplier · test_213_1(263.206). + +방안 +- A 정수 강제 — 입력 · 끌기 · 방향키 · 자동 배치 모두 1 m. 실무 표기와 같음 · 9-29 확정을 뒤집음. 단 81 처럼 격자 옆 1 m 카드는 남음. +- B 일반 측점 붙기(스냅) — 일반 측점 ±폭 안이면 그 측점으로(snapStation 재사용) · 밖이면 정수 m. 「격자 옆 소수 측점 없애기」에 가장 바로. +- C [정수 맞춤] 단추 — 기존 소수 관 · 구조물을 하나씩 옮겨 편집 키가 따라가게 · [저장]으로 마침. +- 공통 — 고름 · 강조는 측점 id(카드 `station_id` · 관 id)로 정확히 잇기 · 같은 측점 폭 상수 하나 · 반올림은 표시에만. + +추천 — A + B + C(입력 1 m 단계 · 일반 측점 붙기 · 기존 자료 단추) · 고름 폭은 한 값으로. 정수 · 붙기면 관 80 = 카드 80 한 자리라 흠 ①②④가 함께 사라짐. 크기 중(화면 받는 곳 6~7곳 각 1~3줄 + 서버 두 곳 + 단추) · 위험 중(213 편집 키 · B08 키 · 깨질 시험 다섯). +⬜ 사용자 확정 — 9-29 「저장값 소수 · 표기만 정수」를 「저장값도 정수 m」로 바꿀지 · 붙기 폭(예 1 m) · 구간형 시작 · 끝도 정수인지. diff --git a/docs/raw/요구_1010_2/조사_AG_구조물목록대정의.md b/docs/raw/요구_1010_2/조사_AG_구조물목록대정의.md new file mode 100644 index 000000000..0f93d2b6f --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AG_구조물목록대정의.md @@ -0,0 +1,84 @@ +# 조사 AG — 구조물 목록 대 정의 · 3D 측점 표기 (109 · 108 뒷부분) (10-10 · 읽기 전용 조사 에이전트) + +약칭: DEF `resources/master_template/구조물정의.json` · TBL `docs/raw/구조물정의_표/0N_*.md` · `B05_Profile/B05_Profile_` + API `Api_Structures.ts` · PS `UI_Page_Structures.ts` · PT `UI_Structures_Panel_Types.ts` · PNL `UI_Structures_Panel.ts` · PO `UI_Structures_Panel_Options.ts` · DEFN `UI_Structures_Define.ts` · LST `UI_Structures_List.ts` · MRK `UI_Structures_Marks.ts` · STN `UI_Page_Stations.ts` · MK `UI_Markers.ts` · PR `UI_Profile_Render.ts` · PA `UI_Page_Actions.ts` · CS `UI_Corridor_Structures.ts` · SCH `Structures_Schema.py` · ES `Engine_Sections.py` · RC `Router_Confirm.py` · RL `Router_Lifecycle.py` / `B06_Section/B06_Section_` + SP `UI_Page_Structures_Panel.ts` · VP `UI_Section_View_Profile.ts` · SS `Section_Store.ts` · WL `Engine_Structures_Wall.py` · RS `Router_Stations.py` · RC6 `Router_Confirm.py` / B07P `B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py` · B07O `B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts` · B08S `B08_Quantity/B08_Quantity_Router_Summary.py` · M02D `M02_MasterTemplete/StructOptions/M02_SO_Def.ts` + +판정은 코드 읽기 · storage 6 프로젝트에 C군(옹벽 · 흙막이 · 소단) 자료가 없어 화면 실측은 못 함(노면공 · 위험표시만 있음). + +## 1. 한눈 결론 + +1. 지금 고를 수 있는 「노면공이 아닌데 범위를 갖는 구조물」은 **옹벽 · 흙막이 · 소단(C) + 가드레일(E)** 넷뿐. 107 답의 B군 6종 · 비탈면 녹화(F)는 정의에서 꺼짐 · 대피소 · 정차·작업장은 「대피로(점형) 상세」로 흡수된 옛 자료. 소단은 길이 · 전 · 후 칸이 숨어 **기준 ±5 m(10 m) 고정**. +2. 109 까닭: 3D 노란 띠 · 라벨은 **관 + A군만** 그림. 서버가 소단 시작 · 기준 · 종료 측점을 심어도 3D 와 종단 그래프가 서버 비정규 측점을 일부러 걷어 내고(STN:27 · PR:240), 대신 넣는 화면 목록이 A군만 담음(PS:147-151). +3. 더 큰 구멍(코드 판정): **B05 [저장]이 「관 + A군」 목록으로 종단 비정규 측점을 통째 갈아 끼움** → 심어 둔 C군 측점 · 독립 기슭막이 시작/종료 측점이 지워짐(관이 하나라도 있을 때). +4. 같은 누락: 옹벽 · 흙막이 · 독립 기슭막이 시작/종료 · E군 전부 · 노면공 · 기타 = 3D 측점 표기 없음. B06 종단 측점선은 관만(A군 수동 3종도 없음). +5. 그 밖 어긋남: B06 고르기에 꺼진 F 2종 · 쇄석 포설이 보임 · M02 「안 씀」과 화면 가림(코드 상수)이 따로 놂 · B07 평면 대피소 기호가 옛 `refuge` 만 봄 · DEF comment 낡음. + +## 2. 지금 켜진 구성 표 + +고르기 규칙 — B05 = 정의 켜짐 · 군 B · D 가림 · 종류 돌쌓기(찰 · 메) · 큰돌쌓기 가림 · 옛 대피 3종 뺌(PS:320-323 · PT:41-65 · 85 · DEFN:221-229). B06 = 정의 켜짐을 안 봄(SP:366-370 `setTypes(types)` · 가림 상수만). M02 = 42종 전부 + 「안 씀」 표시(M02D:137-147 · 가림 상수 모름). + +| 군 | type_id | 이름 | placement | 정의 enabled | B05 고름 | B06 고름 | 비고 | +|---|---|---|---|---|---|---|---| +| A | pipe · box_culvert · ford_pavement · ford_bridge | 배관 · BOX암거 · 물넘이포장 · 세월교 | point | 켜짐 | 있음 | 있음 | 관 정본(pipe_points) | +| A | cross_drain_exposed · open_ditch · cross_underdrain | 노출형 횡단수로 · 개거 · 맹암거 | point | 켜짐 | 있음 | 있음 | structures.json | +| B | ditch_side · ditch_ridge · ditch_berm · chute · slope_drain · underdrain | 측구 등 6 | interval | 꺼짐 | 없음 | 없음(군 가림) | 표 07 숨김 | +| C | retaining_wall · soil_guard | 옹벽 · 흙막이 | interval | 켜짐 | 있음 | 있음 | 기준 + 길이 · 전 · 후 | +| C | berm | 소단 | interval | 켜짐 | 있음 | 있음 | 길이 10 · 전 5 · 후 5 숨김(PO:162-175 · DEF 표시 3575) | +| C | masonry_wet · masonry_dry · boulder_masonry | 돌쌓기(찰 · 메) · 큰돌쌓기 | interval | 켜짐 | 없음 | 없음 | 코드 가림(PT:44-48 · 54-31) | +| D | erosion_check · bed_sill · check_dam_small | 골막이 · 바닥막이 · 소형사방댐 | point | 켜짐 | 없음 | 없음 | 군 가림 · bed_sill 은 comment 상 「삭제」인데 남음(DEF:2295) | +| D | revetment | 기슭막이 | point(관 정본) | 켜짐 | 관 시설로 | 관 시설로 | 독립 기슭막이 = 시작~종료 | +| E | evac_road | 대피로 | point | 켜짐 | 있음 | 있음 | 상세 = 대피소 · 정차/작업장 · 차돌림곳 | +| E | refuge · work_yard · turnaround | 대피소 · 정차·작업장 · 차돌림곳 | interval · interval · site | 켜짐 | 없음 | 없음 | 옛 종류 · 고치면 대피로로 저장 | +| E | mirror · curb · hazard_sign · position_sign · water_intake · barrier_gate · completion_sign | 반사경 등 7 | point | 켜짐 | 있음 | 있음 | | +| E | guardrail | 가드레일 | interval | 켜짐 | 있음 | 있음 | 기준 + 길이 · 전 · 후 · 「측점 범위」 줄 보임(PNL:159) | +| E | spoil_bank · borrow_pit | 사토장 · 토취장 | site | 켜짐 | 있음 | 있음 | 한 측점 + 부피(범위 아님 · SCH:217-226) | +| F | wildlife_path · revegetation | 야생동물 이동통로 · 비탈면 녹화 | point · interval | 꺼짐 | 없음 | **있음** | B06 고르기에 새어 나옴 | +| G | pavement_concrete | 콘크리트 포장 | interval | 켜짐 | 있음 | 있음 | 시작 · 종료 직접(byRange) | +| G | gravel_surfacing | 쇄석(자갈) 포설 | interval | 꺼짐 | 없음 | **있음** | B06 고르기에 새어 나옴 | +| 호환 | etc | 기타 | point | 켜짐 | 있음 | 있음 | 수기 이름 | + +- 「노면공이 아닌 범위형 · 지금 켜짐 · 고를 수 있음」 확정 = **옹벽 · 흙막이 · 소단 · 가드레일**. 돌쌓기 3종은 정의상 켜짐이나 화면에서 가림 · 옛 값만 열림. +- TBL 01~09 대조: 표의 종류는 정의에 모두 있음. 정의에만 있는 것(B · D · F군 · 돌쌓기 3 · 옛 대피 3 · 쇄석)은 화면이 가림 — 단 B06 의 F 2 · 쇄석은 새어 나옴. 표 09 「소단 높이」는 178-2 로 숨김(깊이 칸도 194 로 숨기고 횡단도에서 측점마다 고름) — 결정된 변경이라 누락 아님. + +## 3. 종류별 화면 반영 대조 표 + +| 종류 | ① 좌측 목록 | ② 종단 알약 · 측점선 | ③ 3D 띠 · 라벨 / 메쉬 | ④ B06 카드 · 측점 심음 | ⑤ B07 · B08 | +|---|---|---|---|---|---| +| 관 4종 | 있음(기준) | 알약 · 측점선 있음(물넘이 = 알약만) | 띠 · 라벨 있음(물넘이 없음) · 메쉬 있음 | 형상 · 심음 있음 | B07 평면 기호 · 횡단 덧그림 · B08 횡단배수 집계표 | +| 독립 기슭막이 | 있음 | 알약 · 측점선 **기준만** | 띠 · 라벨 **기준만** · 벽 메쉬 있음 | 시작 · 기준 · 종료 심음 · 형상 | B07 횡단 · B08 흙막이 열 | +| A 수동 3종 | 있음 | 알약 · 측점선(B05) · **B06 측점선 없음** | 띠 · 라벨 있음 · 메쉬 없음 | 심음 · 형상 없음(카드 제목만) | B08 개소 1(SIMPLE) | +| 옹벽 · 흙막이 | 있음(시작~종료) | 알약 + 범위 띠 · **측점선 없음** | **띠 · 라벨 없음** · 벽 메쉬 있음(측점 있을 때) | 시작 · 기준 · 종료 심음 · 벽 형상 | B07 횡단 덧그림 · B08 조합 연결 때만 | +| 소단 | 있음(시작~종료) | 알약 + 띠 · **측점선 없음** | **띠 · 라벨 없음** · 메쉬 = 비탈 계단(측점 행에서만) | 심음 · 형상(design.berm · 178-2 한 단 · 194 깊이) | B07 평탄부 판정 · 토공 면적에 섞임 · 조합 연결 때만 | +| 가드레일 | 있음 | 알약 + 띠 | 없음 | 없음(안 심음) | 조합 연결 때만 | +| 대피로 · E 점형 7 | 있음 | 알약 | 없음 | 없음 | **B07 평면 대피소 기호 = 옛 refuge 만** · 조합 | +| 사토장 · 토취장 | 있음 | 알약 | 없음 | 사토 단면(가까운 측점) · 토취장 없음 | B07 평면 사토장 기호 · 조합 | +| 콘크리트 포장 | 있음(시작~종료) | 알약 + 띠 | 없음 | 카드 포장 · 안 심음 | B07 횡단 포장 덧그림 · 조합 | +| 기타 | 있음(이름) | 알약 | 없음 | 없음 | 조합 | + +근거: ① LST:54-57 · 156-243 / ② MRK:176-188 · PS:139-167 · PR:236-245 · VP:83-104 / ③ STN:22-63 · MK:334-352 · 405-464 · CS:285-289 / ④ SCH:143-157 · ES:296-311 · SS:76-101 · WL:35-41 · 123-176 · `B06_Section_Engine_SpoilFill.py`:97 / ⑤ B07P:82 · 431-434 · B07O:485-506 · B08S:79-81 · `M02_MasterTemplete/M02_Table_Fill.py`:104-108 · `B07_DesignDetail_Engine_SlopeGeometry.py`:178-180. + +## 4. 109 — 소단이 3D 에서 측점 표기가 안 되는 까닭 + +- **그리는 규칙**: 3D 띠 = 종단 정본의 규칙 측점 + 화면 목록 비정규 측점. 서버 비정규 측점은 「옮기기 전 자리 남음」을 막으려 버림(STN:24-27). 화면 목록 = 관 투영 + A군 수동만(PS:147-151 · 165-167). ⇒ 소단 측점이 종단 정본에 있어도 3D 띠 · 라벨 없음. 종단 그래프 세로선도 같은 규칙(PR:236-245). 라벨은 비정규면 「측점 + 이름」(MK:345-348)이라 목록에만 들면 바로 「소단」이 붙음. +- **측점 심기**: 178-1(24aba4008)로 C군 구간형 시작 · 기준 · 종료를 심음(SCH:143-157 · ES:296-311). 심는 때 = B06 [측점 만들기] · B06 저장 중 행 없는 측점 수정(RS:209-228 · RC6:90-103) · 전체 재생성. **B05 구조물 저장은 안 심음**. +- **B05 [저장]이 지움**: PA:52-68 이 「관 + A군」 목록을 보냄 → RL:59-61 → RC:341-375 → RC:265-290 이 비정규 측점을 **통째 교체**. 심어 둔 소단 · 옹벽 · 흙막이 측점과 독립 기슭막이 시작/종료가 빠짐. ES:446 「단일 공급자 = resolve_extra_stations」 원칙과 어긋남(B06 재생성 RS:145-200 은 서버 목록을 씀). +- **10 m 고정 범위와 겹침**: 소단 = 기준 ±5 m. 측점간격 20 m 면 범위 안 규칙 측점이 없을 수 있음 → 심은 측점이 없거나 지워지면 카드 · 3D 어디에도 소단 형상이 안 섬. +- **194 측점별 깊이**: `berm_depth_{side}_m` 은 측점 patch → 측점이 지워지면 그 깊이도 받을 행 없이 떠돎. **178-2 한 단**: 각 측점 설계선에만 서므로 3D 계단도 측점 행에서만 생김(측점 사이는 로프트 보간). +- **같은 누락**: 옹벽 · 흙막이(띠 · 라벨 없음 · 벽 메쉬는 있음) · 독립 기슭막이 시작/종료(기준만) · E군 · 노면공 · 기타(처음부터 측점 표기 대상 아님) · B06 종단 측점선(관만). + +## 5. 누락 목록 · 고칠 거리 (고치지 않음) + +| # | 누락 | 고칠 거리 | 크기 | 위험 | +|---|---|---|---|---| +| 1 | 3D · 종단 그래프에 C군 측점 표기 없음 | PS `crossDrainStationsOf` 를 서버 규칙 거울로(A + C · D 구간형 시작 · 기준 · 종료) · 두 파일 머리에 짝 · 거울 시험 · ▲▼ 기울기 단추는 A군만 남김 | 작음~중 | 계획고 편집 키가 새 측점에도 붙음 · 0.1 m 겹침 지움(STN:53) | +| 2 | B05 [저장]이 C군 측점을 지움 | RC `_append_irregular_cross_sections` 도 `resolve_extra_stations`(서버 목록)로 — 화면 목록은 안 믿음 | 작음 | 관 초안이 저장 차례상 먼저 나가 서버 목록이 최신(`A00_Common/design/design_save.ts`:31-38) · 시험 test_192_1 · test_213_1 손봄 | +| 3 | 독립 기슭막이 시작 · 종료 3D 표기 | 1 과 함께 — PS `pipesToStations` 에 시작 · 종료 | 작음 | 1 과 같음 | +| 4 | B06 종단 측점선 = 관만 | VP `pipeStations` 를 B05 와 같은 목록으로 | 작음 | B06 카드 고름 연동 | +| 5 | B06 고르기에 꺼진 F 2 · 쇄석 | SP:370 `setTypes(types, t => t.enabled)` | 한 줄 | 옛 값 열기는 그대로 | +| 6 | M02 「안 씀」 ≠ 화면 가림 | 가림 상수(B · D군 · 돌쌓기 3 · 옛 대피 3)를 정의 `enabled:false` 로 옮기거나 M02 에 「화면 가림」 표시 | 중 | B06 재사용 뜻(DEF comment)과 엮임 · M02 판 올림 | +| 7 | B07 평면 대피소 기호 = 옛 refuge 만 | B07P:431-434 에 대피로 상세 「대피소」도 셈 | 한 줄 | 없음 | +| 8 | DEF comment 낡음(「G 꺼짐」 · 「bed_sill 삭제」) · bed_sill 남음 | comment 고침 · bed_sill 정리 여부 결정 | 작음 | M02 판 올림 | +| 9 | E군 · 노면공 3D 표기 없음 | 필요 여부 결정 — 측점을 안 심는 종류라 띠 대신 3D 알약 · 범위 띠 같은 새 표기 | 중 | 3D 글자 겹침 | + +- ⬜ 3D 표기 범위: (가) C군만 / (나) C군 + 독립 기슭막이 시작 · 종료(= 서버가 실제로 심는 측점과 같게) / (다) 모든 구조물 — 추천 (나). +- ⬜ 5번 B06 F 2 · 쇄석: (가) 가림(표 07 · 08 에 없음) / (나) 그대로 — 추천 (가). +- 묶음 추천: 2 → 1 · 3 → 4 차례(2 가 원천 · 1 은 표기). diff --git a/docs/raw/요구_1010_2/조사_AH_거리이정표.md b/docs/raw/요구_1010_2/조사_AH_거리이정표.md new file mode 100644 index 000000000..957983878 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AH_거리이정표.md @@ -0,0 +1,92 @@ +# 조사 AH — B08 거리이정표 메인 탭 · 경로 하위 탭 · 다음(카카오) 지도 (120) (10-10 · 읽기 전용 조사 에이전트) + +약칭: PG `B08_Quantity/B08_Quantity_UI_Page.ts` · OP `B08_Quantity/B08_Quantity_UI_Options.ts` · TB `B09_Estimation/B09_Estimation_UI_TabBar.ts` · BI `B09_Estimation/B09_Estimation_Bill_Items.py` · SG `B09_Estimation/B09_Estimation_Sangeun.py` · RG `common_util/common_util_route_geometry.py` · CRS `common_util/common_util_crs.py` · MAP `B01_Dashboard/B01_Dashboard_Map.py` · L10 `resources/master_data/로직_산림품셈_10장_자재․장비_운반.json` · 견본 = `resources/knowledge/original/실무문서/01.산불거창(거창북상병곡)/01.내역서/…내역+수량.xlsx`(병곡리 968-31 견본의 원본 · 「거리이정표」 시트 A2:AI30) + +## 1. 한눈 결론 + +1. B08 탭은 공용 탭 양식 없이 페이지마다 `createButton` 줄로 만듦(PG:30-35 · 118-134). 메인 탭 하나 = 배열에 한 줄 + `renderTab` 가지 한 줄 + 글자 키 하나. 사용자가 늘리고 줄이는 하위 탭 선례는 프로그램 어디에도 없음 — 새로 만듦. +2. 거리 km · 도로 구분(포장 · 비포장)을 받는 칸은 프로그램에 없음. B09 운반 줄 6개는 「운반거리 원천 없음」으로 빈칸(BI:21 · 50-57), B09 산근은 운반 산근을 만들지 않음(SG:22). 거리이정표가 그 원천 자리. +3. 시점 좌표 = 설계 노선 CSV 첫 점(프로젝트 작업 좌표계 · m). 중점 = 측점 거리 → 좌표 보간 함수가 이미 있음(RG:572 · 593). 위경도 변환 = pyproj(설치됨 · 선례 있음). +4. 지도는 지금 VWorld 하나뿐(서버가 타일 · 주소검색을 대리). 카카오 · 다음은 코드 · `.env` 어디에도 없음 → 키부터 새로 받아야 함. +5. 카카오 길찾기 응답 = 총 거리 · 도로명별 거리 · 좌표뿐. 도로 종류 · 차로 · 포장 정보 없음 → 2차포장 · 1차포장 · 비포장 나누기는 사용자 입력이 맞음. +6. 견본 엑셀은 1차 · 2차를 「포장」 한 칸으로 합쳐 단가산출에 씀(L1 포장 · L2 비포장 · 속도 V1~V4). 단가에 쓰이는 축은 포장 · 비포장 둘. + +## 2. 지금 B08 화면 짜임 · 붙일 자리 + +- 메인 탭 = `TABS` 넷(구조물 집계표 · 자재 · 토공 · 수리집수면적표 · PG:30-35) · `select()`가 단추 줄을 다시 그리고 `renderTab()`(PG:86-105)이 탭마다 새 몸통을 붙임(늦은 응답이 남의 탭을 덮지 않게 함). +- 하위 탭 = 같은 꼴의 sm 단추 줄 — 자재 `SHEETS`(PG:40-83) · 토공 `SHEETS` 넷(`B08_Quantity_UI_Earthwork.ts:240-245 · 292-320`). 하위 탭이 없는 탭은 높이만 채우는 빈 줄(PG:90-100). +- B09 는 두 층 탭 줄을 묶은 `createTabBars`(TB:35-77)가 있으나 B09 전용 · 탭 목록 고정. +- 좌측 패널 = 「메인 탭과 같은 컨테이너」(OP:2 · `card()` OP:84-93) + [저장] 한 줄 · 캐시 `b08-options`(sessionStorage · `A00_Common/b_page_state.ts` `readState`·`writeState`) → [저장] PUT. +- 붙일 자리: ① PG `TABS` 다섯째 `milestone` + `renderTab` 가지 + 글자 키(`ui_template_locale_b4.ts` B08 줄 옆) ② 새 파일 하나에 하위 탭 줄 — 첫 탭 「거리이정표」(고정) · 다음부터 경로 탭(업체마다) · 줄 끝 [+ 추가] · [삭제] ③ OP 에 「거리이정표」 컨테이너(공사지 · 기준점 칸). PG 는 154줄이라 넉넉함. + +## 3. 지금 프로그램 안 거리 · 운반 값 + +| 자리 | 단위 · 값 | 받는 곳 | 이정표와 관계 | +|---|---|---|---| +| B08 토공 평균운반거리 · 무대운반 | m · 유토곡선 배분(`mass_haul.haul_plan`) | B05~B07 자동 | 현장 안 흙 운반 — 별개 | +| `project_settings.json › quantity` `spoil_site_distance_m` · `topsoil_haul_distance_m` | m · 기본 None | 칸만 있고 읽는 곳 없음 | 사토장 · 표토 거리 — 별개 | +| B09 운반 줄(중기 · 기계 · 모래 · 자갈 · 시멘트 · 철근운반 · BI:50-57) | 수량만 B08 총괄자재 · 단가 「연결 없음」(BI:21) | — | **잇는 자리 후보 1** | +| B09 단가산출근거(산근) | 「조합 밖 운반 산근 = 원천 없음」(SG:22) | — | **후보 2** — 산근 입력 L(포장) · L(비포장) | +| M01 로직(L10) | GF000136 혼합골재 운반 = 입력 Cm(분) · GF000137 중기운반 = 입력 t2(분) · GF000284 덤프운반 = 운반거리 ㎞ 하나 | 사용자 · 견본 예시값 | **후보 3** — 포장 · 비포장 둘로 나눈 입력이 없음(견본 꼴로 가려면 입력 늘림) | +| 구조물정의 · M02 조합 | 거리 칸 없음 | — | 없음 | + +- 견본 단가산출(1315-1339행) 혼합골재 운반: L1 포장 25.1 km(= 이정표 O25) · L2 비포장 0.8 km(= R25) · V1 30 · V2 35 · V3 10 · V4 15 km/h · t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4)×60 = 101.23 분. 시트끼리 수식 연결 없음 — 손으로 옮겨 적음. +- 속도 근거 = 건설표준품셈 8-2-8 덤프트럭 「운반도로와 평균주행속도」(`resources/knowledge/original/원가계산/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계/8-02-08_덤프트럭.md:44-66`) — 2차로 이상 공사용도로 30 · 35 / 교차 힘든 산간지도로 10 · 15. 임도 안 토사 덤프는 QF000337 V1 5 · V2 6. +- 견본 표 수식: 장비(거창군청→시점) = 2차 23.8 + 1차 0.6 + 비포장 0.8 · 자재(골재원 · 김천역→임도중점) 비포장 = 0.8 + X10(시점→중점 · 견본은 빈칸) · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장. + +## 4. 시점 · 중점 좌표 + +- 설계 노선 정본 순서(RG:300-305): `B05_Profile/route/planned_route.csv`(수정본) → `planned_route_initial.csv` → 예상노선 → `initial_snapshot/planned_route.csv`. 열 = sequence · x · y · 값은 프로젝트 작업 좌표계(m · storage 견본 x≈208,403 · y≈367,920). +- 좌표계 = `project_epsg_from_prj(root)` · 사다리 창구 `resolve_project_crs`(CRS:231-261 · 폴백 EPSG:5186). DB `projects.crs_epsg`(기본 5178)는 라벨일 뿐 · 실좌표와 다른 사례 기록 있음. +- 위경도 = `pyproj.Transformer.from_crs(작업 좌표계, "EPSG:4326", always_xy=True)` — 선례 `B04_PreProcess_Engine_Extent.py:189` · `B04_PreProcess_Engine_VWorld.py:96`. 전용 함수는 없으나 한 줄. +- 중점: `build_route_vertices`(RG:572) + `interpolate_vertex(정점, 누가거리 m)`(RG:593)로 측점 거리 → x · y. 「사용자가 거리 입력」 그대로 가능 · 기본 후보 = 노선 연장 ÷ 2(「임도중점」). +- 시점→중점 km = 그 측점 거리 그대로(임도 위 = 비포장). 견본 X10 칸의 자리. +- 기준점(병곡리 968-31) 검색 씨앗 = `projects.region`(지역명) · 지번 주소 검색은 VWorld · 카카오 둘 다 됨. + +## 5. 지금 지도 쓰임 · 키 + +- B01 회사 주소 지도: VWorld 주소검색 · 후보 검색 · WMTS 배경 타일을 서버가 대리(MAP · `GET /dashboard/map/tile/{z}/{x}/{y}` `B01_Dashboard_Router.py:213`) · 화면 = 라이브러리 없이 타일 3×3 img(`B01_Dashboard_UI_MapPreview.ts` · Web Mercator 타일 수식 `tileIndex` :21) · 주소 칸 `B01_Dashboard_UI_AddressField.ts`. +- B04 · B05 지도: 자체 Canvas 그리기 + VWorld(`B04_PreProcess_UI_MapViewer.ts` · `B05_Profile_UI_RouteEdit_Recommend_Map.ts`). +- `maplibre-gl` = `package.json` 의존성 · vite alias 뿐 · import 0. Leaflet · OpenLayers · 카카오 · 다음 = 0. +- `.env` 지도 키 이름 = `VWORLD_LOGIN_ID` · `VWORLD_LOGIN_PW` · `VWORLD_SESSION_COOKIE` 뿐 · KAKAO · DAUM 없음. VWorld API 키는 `config_system` 에 없고 `B04_PreProcess_Engine_VWorld.py:22-29` 코드 안 폴백 값을 씀(값 생략). +- CSP 머리 없음 → 외부 `sdk.js` 로드 막힘 없음. Pillow 12.3 · pyproj 3.7 · httpx 설치됨. + +## 6. 다음(카카오) 지도 API 외부 조사 (2026-10 기준 문서) + +| 쓰임 | 길 · 조건 | 무료 · 요금 | 막힘 | +|---|---|---|---| +| 지도 보기 | JS SDK `dapi.kakao.com/v2/maps/sdk.js?appkey=` · JavaScript 키 · 키의 「JavaScript SDK 도메인」에 `http://localhost:5173` · 운영 주소 등록(프로토콜 · 포트까지 · 어긋나면 401) · 앱에서 카카오맵 사용 설정 ON · 선 = `kakao.maps.Polyline` | 일 300,000 | JS 키는 브라우저에 보이는 것이 전제 · 도메인 제한이 보호 | +| 주소 → 좌표 | `dapi.kakao.com/v2/local/search/address`(지번 · 도로명) · `…/search/keyword` · 좌표계 변환 `…/geo/transcoord`(WGS84 · WTM · TM 등) · 머리 `Authorization: KakaoAK {REST 키}` | 일 100,000 | FAQ 「로컬 API 응답 별도 저장 금지」 — 업체 좌표를 파일에 두면 회색 | +| 길찾기 | `apis-navi.kakaomobility.com/v1/directions` GET · origin · destination = 「경도,위도」 · 경유지 ≤ 5 · 응답 `summary.distance`(m) · `duration`(초) · `fare`(택시 · 통행료) · `sections[].roads[]` = name · distance · duration · traffic_speed · traffic_state · vertexes(경도 · 위도 평면 배열) · `guides[]` | 일 10,000 · 초과 8원/건 | 도로 종류 · 등급 · 차로 · 포장 필드 없음(`road_details` 는 추가 필드가 문서에 없음) · 상업 이용은 제휴 필요라는 답변이 있음(데브톡 · 엇갈림) | +| 지도 그림 | 정적 지도 `dapi.kakao.com/v2/maps/staticmap` · center · lv 1~15 · size ≤ 2048×1024 · markers ≤ 5 · 응답 = 이미지 바이트 · 로고 못 지움 | 일 1,000 · 초과 2원 | **선 · 경로 파라미터 없음** → 길 선은 서버가 덧그려야 함 · 레벨별 축척 문서 없음(맞춤 실측 필요) | +| 화면 캡처 | JS 지도 타일 = 다른 출처 이미지 | — | canvas 오염으로 `toDataURL` 막힐 공산(데브톡 미해결 문의) · 지도 이미지 저장 · 배포는 약관 회색 | +| 정책 | 2026-07-21 개편 — 계정에서 처음 켠 앱만 무료 쿼터 · 그 밖 · 초과 = 비즈월렛 유료 · 초과 시 429 | — | — | + +- 키 숨김: REST 키(주소 · 길찾기 · 정적 지도) = 서버만(`.env` 새 키) · JS 키 = 화면에 넘김(서버 설정 길 또는 Vite env). +- 길찾기 도착점이 임도 시점이면 API 가 가장 가까운 등록 도로에 붙임 → 마지막 비포장 · 임도 구간은 응답 거리에 안 들거나 어긋남 → 사용자 km 칸 필요. + +## 7. 도로 구분(2차포장 · 1차포장 · 비포장) + +- 응답으로 자동 판정 불가: roads 에는 도로명 · 거리 · 실시간 속도만 있음 · 「○○로」 이름으로 차로 수 · 포장 여부를 알 수 없음 · traffic_speed 는 교통 상태라 도로 등급 대용 아님. +- 맞는 방식 = 경로마다 km 칸 셋(2차포장 · 1차포장 · 비포장) 사용자 입력 · 길찾기 총 km 는 도움값(기본으로 2차포장 칸에 넣고 사용자가 나눔) · 도로명별 거리 목록을 곁에 보여 나누기 도움. +- 견본 짜임이 이를 받침: 업체→기준점 = 2차포장 하나 · 기준점→시점 = 1차포장(0.6) · 시점 쪽 = 비포장(0.8) — 1차 · 비포장은 업체가 달라도 같은 공통 구간 → 기준점이 있으면 공통 구간 km 를 한 번만 적음. + +## 8. 설계 갈래 · 추천 + +- 데이터 모양(한 파일): 공사지 = 시점 측점(0) · 중점 측점(m · 사용자) / 기준점 = 없음 또는 {이름 · 주소 · 기준점→시점 km 셋} / 경로 목록 = [{업체 이름 · 주소 · 도착(기준점 · 시점) · 길찾기 총 km · km 셋 · 쓰임 줄[{구분 · 도착 시점 · 중점 · 비고}] · 그림 파일 이름}]. +- 표 한 줄 = 경로 km 셋 + (기준점 경유면 기준점→시점 km) + (도착 = 중점이면 시점→중점 km 를 비포장에) · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 — 견본 수식과 같음. 장비 20톤 · 8톤처럼 한 업체 두 줄 = 쓰임 줄 둘. +- 정본 자리 = 프로젝트 storage `B08_Quantity/milestone/`(json 한 벌 + 경로 그림 png · 폴더가 목록 — B09 견적서 파일 길 `B09_Estimation_Router_Quotes.py` 꼴) · DB 표 없음. 초기값 층 없음(자동설계가 낳는 값 아님 · 시점 좌표는 읽을 때마다 노선에서). 캐시 `b08-milestone` → [저장]. 업체 좌표는 저장하지 않고 주소 글만(카카오 로컬 저장 금지 회피 · 열 때 다시 찾음). +- 계산 자리: 주소 검색 · 길찾기 · 시점 · 중점 위경도 · 경로 그림 = ③ 서버 단독(REST 키 숨김 · [길찾기] 누를 때만 왕복 · 조작 중 계산 아님) / 표 합 = ① 파이썬 · TS 짝(두 파일 머리 표기 + 거울 시험 · 화면 즉시 · [저장] 때 서버가 다시 셈) / 화면 지도 = 카카오 JS(그리기만). +- 경로 그림 갈래: **A 서버 그림** — VWorld 배경 타일(이미 서버 대리 · Web Mercator 수식 있음) + 길찾기 vertexes 선 + 업체 · 기준점 · 시점 표지 · Pillow · 같은 출처라 막힘 없음 · 카카오 저장 약관 비껴감 · 바탕이 다음 지도 아님 / **B 카카오 정적 지도 + 서버 덧그림** — 바탕 = 다음 지도 · 선 맞춤을 실측으로 · 저장 약관 회색 / **C 화면 캡처** — CORS 로 깨질 공산 / **D 사진 올리기** — 가장 단순. 추천 = 보기는 카카오 JS · 저장 그림은 A · D 를 늘 곁에. +- 화면: 메인 「거리이정표」 → 하위 탭 [거리이정표](상자 그림 SVG + 공용 표 자동 · 업체 → 기준점 → 시점 → 중점 · 선마다 도로 구분 · km) · [경로1 · 업체명] … · [+ 추가] · [삭제] / 경로 탭 = 컨테이너 하나(위 = 업체 · 주소 검색 · 도착 · [길찾기] · km 셋 · 쓰임 줄 · 아래 = 지도 + 그림 한 장) / 좌측 컨테이너 = 공사지(중점 측점) · 기준점(있음 · 없음 · 이름 · 주소 · 공통 km 셋). +- 새 파일 후보: 화면(하위 탭 · 경로 컨테이너) · 상자 그림 · 표 · 라우터(GET · PUT · 주소 · 길찾기 · 그림) · 합 짝(.py · .ts) · 시험 `resources/tester/`. 카카오 키가 오기 전에도 km 손 입력 · 사진 올리기로 먼저 쓸 수 있게 길찾기는 덧붙이는 칸으로. + +## 9. 사용자에게 물을 것 + +1. 경로 그림 바탕 — A 서버 그림(VWorld 바탕 + 길 선) vs B 다음 정적 지도 바탕. 추천 A + 사진 올리기(다음 지도는 화면 보기용). +2. 카카오 키 — 카카오 개발자 앱 하나(첫 앱이어야 무료) · JavaScript 키(도메인 `http://localhost:5173` + 운영 주소 등록) · REST API 키 발급은 사용자 몫. 판매 프로그램이면 카카오모빌리티 제휴 문의 필요 공산. 추천: 키 전에는 손 입력판부터. +3. 도로 구분 나누기 — 추천: 업체→기준점 길찾기 km 전부 「2차포장」 기본 + 사용자가 고침 · 기준점→시점 공통 구간(1차포장 · 비포장)은 기준점 칸에 한 번만 입력. +4. 표 「구분」 줄 — 견본 다섯(장비 20톤 · 8톤 · 혼합골재 · 전석운반 · 철근) 고정 vs 자유. 추천: 경로 탭 안 쓰임 줄 자유 + 견본 다섯을 고르기 목록으로. +5. 중점 기본값 — 추천: 노선 연장 ÷ 2 측점 기본 · 사용자가 고침 · 시점→중점 = 비포장으로 자동 더함. + +출처: [카카오 쿼터](https://developers.kakao.com/docs/ko/getting-started/quota) · [카카오맵 개요](https://developers.kakao.com/docs/ko/kakaomap/common) · [카카오맵 REST(정적 지도 · 좌표 변환 · 주소)](https://developers.kakao.com/docs/ko/kakaomap/rest-api) · [로컬 개요](https://developers.kakao.com/docs/ko/local/common) · [자동차 길찾기](https://developers.kakaomobility.com/guide/navi-api/directions) · [길찾기 가격](https://developers.kakaomobility.com/price) · [2026-07-21 개편 공지](https://devtalk.kakao.com/t/api-notice-on-new-kakao-map-api-features-and-free-quota-policy/150222) · [길찾기 무료 쿼터 답변](https://devtalk.kakao.com/t/api-directions-api/151192) · [JS SDK 도메인 401](https://devtalk.kakao.com/t/js-sdk-401-domain-mismatched-https-4fugfgg-anonymous-8081-exp-direct-caller-30/151184) · [지도 캡처 CORS 문의](https://devtalk.kakao.com/t/api/137347) diff --git a/main.py b/main.py index 35dec32f1..05ca3a1fc 100644 --- a/main.py +++ b/main.py @@ -69,6 +69,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router +from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router @@ -656,6 +657,7 @@ app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_summary_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b08_options_router, dependencies=protected_with_company) +app.include_router(b08_milestone_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) # B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3). # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. diff --git a/requirements.txt b/requirements.txt index b185b9940..903388fd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,7 @@ openpyxl==3.1.2 packaging==26.2 pandas==3.0.3 pillow==12.3.0 +playwright==1.62.0 psutil>=6.1,<7 pydantic==2.5.0 pydantic_core==2.14.1 diff --git a/resources/tester/fixtures/b05_213_1_hidden_station_longitudinal.json b/resources/tester/fixtures/b05_213_1_hidden_station_longitudinal.json new file mode 100644 index 000000000..38caa4025 --- /dev/null +++ b/resources/tester/fixtures/b05_213_1_hidden_station_longitudinal.json @@ -0,0 +1,2261 @@ +{ + "_note": "213-1 시험 고정 자료 — 엄상준_검증 d2245e3b initial_snapshot 종단 160~360m 사본, 관 측점 263.206 → 263.21(화면 0.01m 반올림) · 꼭짓점은 옛 글자 263.206 · 편집 키 263.206 · 263.210 둘(213-1 이전 재현 상태)", + "length_m": 1196.88459, + "samples": [ + { + "chainage_m": 160.0, + "x": 208393.562479, + "y": 367768.040752, + "z": 880.274875, + "elevation_m": 880.274875, + "valid": true + }, + { + "chainage_m": 161.0, + "x": 208394.009662, + "y": 367767.14631, + "z": 880.066701, + "elevation_m": 880.066701, + "valid": true + }, + { + "chainage_m": 162.0, + "x": 208394.456845, + "y": 367766.251867, + "z": 879.974874, + "elevation_m": 879.974874, + "valid": true + }, + { + "chainage_m": 163.0, + "x": 208394.904028, + "y": 367765.357425, + "z": 879.939525, + "elevation_m": 879.939525, + "valid": true + }, + { + "chainage_m": 164.0, + "x": 208395.351211, + "y": 367764.462982, + "z": 879.783039, + "elevation_m": 879.783039, + "valid": true + }, + { + "chainage_m": 165.0, + "x": 208395.798394, + "y": 367763.56854, + "z": 879.522523, + "elevation_m": 879.522523, + "valid": true + }, + { + "chainage_m": 166.0, + "x": 208396.245578, + "y": 367762.674097, + "z": 879.721375, + "elevation_m": 879.721375, + "valid": true + }, + { + "chainage_m": 167.0, + "x": 208396.692761, + "y": 367761.779655, + "z": 879.909006, + "elevation_m": 879.909006, + "valid": true + }, + { + "chainage_m": 168.0, + "x": 208397.139944, + "y": 367760.885212, + "z": 880.000277, + "elevation_m": 880.000277, + "valid": true + }, + { + "chainage_m": 169.0, + "x": 208397.587127, + "y": 367759.99077, + "z": 880.150005, + "elevation_m": 880.150005, + "valid": true + }, + { + "chainage_m": 170.0, + "x": 208398.067013, + "y": 367759.113789, + "z": 880.207492, + "elevation_m": 880.207492, + "valid": true + }, + { + "chainage_m": 171.0, + "x": 208398.598118, + "y": 367758.268125, + "z": 879.901536, + "elevation_m": 879.901536, + "valid": true + }, + { + "chainage_m": 172.0, + "x": 208399.192951, + "y": 367757.465031, + "z": 879.853283, + "elevation_m": 879.853283, + "valid": true + }, + { + "chainage_m": 173.0, + "x": 208399.861358, + "y": 367756.721235, + "z": 879.748083, + "elevation_m": 879.748083, + "valid": true + }, + { + "chainage_m": 174.0, + "x": 208400.562, + "y": 367756.008, + "z": 879.519311, + "elevation_m": 879.519311, + "valid": true + }, + { + "chainage_m": 175.0, + "x": 208401.269107, + "y": 367755.300893, + "z": 879.336245, + "elevation_m": 879.336245, + "valid": true + }, + { + "chainage_m": 176.0, + "x": 208401.976214, + "y": 367754.593786, + "z": 879.025666, + "elevation_m": 879.025666, + "valid": true + }, + { + "chainage_m": 177.0, + "x": 208402.683321, + "y": 367753.886679, + "z": 878.89015, + "elevation_m": 878.89015, + "valid": true + }, + { + "chainage_m": 178.0, + "x": 208403.390427, + "y": 367753.179573, + "z": 878.965976, + "elevation_m": 878.965976, + "valid": true + }, + { + "chainage_m": 179.0, + "x": 208404.097534, + "y": 367752.472466, + "z": 879.083304, + "elevation_m": 879.083304, + "valid": true + }, + { + "chainage_m": 180.0, + "x": 208404.804641, + "y": 367751.765359, + "z": 878.960423, + "elevation_m": 878.960423, + "valid": true + }, + { + "chainage_m": 181.0, + "x": 208405.526937, + "y": 367751.074275, + "z": 879.03746, + "elevation_m": 879.03746, + "valid": true + }, + { + "chainage_m": 182.0, + "x": 208406.279346, + "y": 367750.416559, + "z": 878.932674, + "elevation_m": 878.932674, + "valid": true + }, + { + "chainage_m": 183.0, + "x": 208407.090763, + "y": 367749.832091, + "z": 879.027649, + "elevation_m": 879.027649, + "valid": true + }, + { + "chainage_m": 184.0, + "x": 208407.950598, + "y": 367749.323011, + "z": 879.307757, + "elevation_m": 879.307757, + "valid": true + }, + { + "chainage_m": 185.0, + "x": 208408.833749, + "y": 367748.854692, + "z": 879.54669, + "elevation_m": 879.54669, + "valid": true + }, + { + "chainage_m": 186.0, + "x": 208409.728224, + "y": 367748.407575, + "z": 879.538158, + "elevation_m": 879.538158, + "valid": true + }, + { + "chainage_m": 187.0, + "x": 208410.6227, + "y": 367747.960457, + "z": 879.281805, + "elevation_m": 879.281805, + "valid": true + }, + { + "chainage_m": 188.0, + "x": 208411.517175, + "y": 367747.513339, + "z": 878.973901, + "elevation_m": 878.973901, + "valid": true + }, + { + "chainage_m": 189.0, + "x": 208412.419339, + "y": 367747.082534, + "z": 878.77534, + "elevation_m": 878.77534, + "valid": true + }, + { + "chainage_m": 190.0, + "x": 208413.342822, + "y": 367746.701142, + "z": 878.869338, + "elevation_m": 878.869338, + "valid": true + }, + { + "chainage_m": 191.0, + "x": 208414.294782, + "y": 367746.396907, + "z": 879.691937, + "elevation_m": 879.691937, + "valid": true + }, + { + "chainage_m": 192.0, + "x": 208415.269068, + "y": 367746.172536, + "z": 880.470388, + "elevation_m": 880.470388, + "valid": true + }, + { + "chainage_m": 193.0, + "x": 208416.259321, + "y": 367746.033253, + "z": 880.612699, + "elevation_m": 880.612699, + "valid": true + }, + { + "chainage_m": 194.0, + "x": 208417.257753, + "y": 367745.981861, + "z": 881.503248, + "elevation_m": 881.503248, + "valid": true + }, + { + "chainage_m": 195.0, + "x": 208418.257573, + "y": 367745.974, + "z": 882.19521, + "elevation_m": 882.19521, + "valid": true + }, + { + "chainage_m": 196.0, + "x": 208419.257573, + "y": 367745.974, + "z": 882.327939, + "elevation_m": 882.327939, + "valid": true + }, + { + "chainage_m": 197.0, + "x": 208420.257573, + "y": 367745.974, + "z": 882.119903, + "elevation_m": 882.119903, + "valid": true + }, + { + "chainage_m": 198.0, + "x": 208421.257573, + "y": 367745.974, + "z": 880.314188, + "elevation_m": 880.314188, + "valid": true + }, + { + "chainage_m": 199.0, + "x": 208422.257573, + "y": 367745.974, + "z": 878.282163, + "elevation_m": 878.282163, + "valid": true + }, + { + "chainage_m": 200.0, + "x": 208423.257573, + "y": 367745.974, + "z": 877.309908, + "elevation_m": 877.309908, + "valid": true + }, + { + "chainage_m": 201.0, + "x": 208424.257573, + "y": 367745.974, + "z": 877.12456, + "elevation_m": 877.12456, + "valid": true + }, + { + "chainage_m": 202.0, + "x": 208425.257573, + "y": 367745.974, + "z": 877.052589, + "elevation_m": 877.052589, + "valid": true + }, + { + "chainage_m": 203.0, + "x": 208426.257573, + "y": 367745.974, + "z": 876.634767, + "elevation_m": 876.634767, + "valid": true + }, + { + "chainage_m": 204.0, + "x": 208427.257573, + "y": 367745.974, + "z": 876.139693, + "elevation_m": 876.139693, + "valid": true + }, + { + "chainage_m": 205.0, + "x": 208428.257573, + "y": 367745.974, + "z": 876.149218, + "elevation_m": 876.149218, + "valid": true + }, + { + "chainage_m": 206.0, + "x": 208429.257573, + "y": 367745.974, + "z": 876.096325, + "elevation_m": 876.096325, + "valid": true + }, + { + "chainage_m": 207.0, + "x": 208430.257573, + "y": 367745.974, + "z": 875.870445, + "elevation_m": 875.870445, + "valid": true + }, + { + "chainage_m": 208.0, + "x": 208431.257573, + "y": 367745.974, + "z": 875.785863, + "elevation_m": 875.785863, + "valid": true + }, + { + "chainage_m": 209.0, + "x": 208432.257573, + "y": 367745.974, + "z": 875.788061, + "elevation_m": 875.788061, + "valid": true + }, + { + "chainage_m": 210.0, + "x": 208433.257573, + "y": 367745.974, + "z": 875.853658, + "elevation_m": 875.853658, + "valid": true + }, + { + "chainage_m": 211.0, + "x": 208434.257573, + "y": 367745.974, + "z": 875.661856, + "elevation_m": 875.661856, + "valid": true + }, + { + "chainage_m": 212.0, + "x": 208435.257573, + "y": 367745.974, + "z": 875.290313, + "elevation_m": 875.290313, + "valid": true + }, + { + "chainage_m": 213.0, + "x": 208436.257573, + "y": 367745.974, + "z": 875.127237, + "elevation_m": 875.127237, + "valid": true + }, + { + "chainage_m": 214.0, + "x": 208437.257573, + "y": 367745.974, + "z": 874.938191, + "elevation_m": 874.938191, + "valid": true + }, + { + "chainage_m": 215.0, + "x": 208438.257573, + "y": 367745.974, + "z": 874.889529, + "elevation_m": 874.889529, + "valid": true + }, + { + "chainage_m": 216.0, + "x": 208439.257573, + "y": 367745.974, + "z": 875.186213, + "elevation_m": 875.186213, + "valid": true + }, + { + "chainage_m": 217.0, + "x": 208440.257573, + "y": 367745.974, + "z": 875.477006, + "elevation_m": 875.477006, + "valid": true + }, + { + "chainage_m": 218.0, + "x": 208441.257573, + "y": 367745.974, + "z": 875.443878, + "elevation_m": 875.443878, + "valid": true + }, + { + "chainage_m": 219.0, + "x": 208442.257573, + "y": 367745.974, + "z": 875.186807, + "elevation_m": 875.186807, + "valid": true + }, + { + "chainage_m": 220.0, + "x": 208443.257573, + "y": 367745.974, + "z": 875.077858, + "elevation_m": 875.077858, + "valid": true + }, + { + "chainage_m": 221.0, + "x": 208444.257573, + "y": 367745.974, + "z": 875.625482, + "elevation_m": 875.625482, + "valid": true + }, + { + "chainage_m": 222.0, + "x": 208445.257573, + "y": 367745.974, + "z": 875.758555, + "elevation_m": 875.758555, + "valid": true + }, + { + "chainage_m": 223.0, + "x": 208446.257573, + "y": 367745.974, + "z": 875.482925, + "elevation_m": 875.482925, + "valid": true + }, + { + "chainage_m": 224.0, + "x": 208447.257573, + "y": 367745.974, + "z": 875.712811, + "elevation_m": 875.712811, + "valid": true + }, + { + "chainage_m": 225.0, + "x": 208448.257053, + "y": 367745.996679, + "z": 875.756986, + "elevation_m": 875.756986, + "valid": true + }, + { + "chainage_m": 226.0, + "x": 208449.252676, + "y": 367746.078262, + "z": 875.40165, + "elevation_m": 875.40165, + "valid": true + }, + { + "chainage_m": 227.0, + "x": 208450.238368, + "y": 367746.241833, + "z": 875.503535, + "elevation_m": 875.503535, + "valid": true + }, + { + "chainage_m": 228.0, + "x": 208451.207667, + "y": 367746.485362, + "z": 875.628622, + "elevation_m": 875.628622, + "valid": true + }, + { + "chainage_m": 229.0, + "x": 208452.153942, + "y": 367746.808156, + "z": 875.624947, + "elevation_m": 875.624947, + "valid": true + }, + { + "chainage_m": 230.0, + "x": 208453.068087, + "y": 367747.213544, + "z": 875.447687, + "elevation_m": 875.447687, + "valid": true + }, + { + "chainage_m": 231.0, + "x": 208453.963849, + "y": 367747.657925, + "z": 874.69746, + "elevation_m": 874.69746, + "valid": true + }, + { + "chainage_m": 232.0, + "x": 208454.858277, + "y": 367748.105138, + "z": 873.796567, + "elevation_m": 873.796567, + "valid": true + }, + { + "chainage_m": 233.0, + "x": 208455.752704, + "y": 367748.552352, + "z": 873.630186, + "elevation_m": 873.630186, + "valid": true + }, + { + "chainage_m": 234.0, + "x": 208456.647131, + "y": 367748.999565, + "z": 873.355806, + "elevation_m": 873.355806, + "valid": true + }, + { + "chainage_m": 235.0, + "x": 208457.541558, + "y": 367749.446779, + "z": 872.944746, + "elevation_m": 872.944746, + "valid": true + }, + { + "chainage_m": 236.0, + "x": 208458.435985, + "y": 367749.893993, + "z": 872.594761, + "elevation_m": 872.594761, + "valid": true + }, + { + "chainage_m": 237.0, + "x": 208459.330413, + "y": 367750.341206, + "z": 872.323602, + "elevation_m": 872.323602, + "valid": true + }, + { + "chainage_m": 238.0, + "x": 208460.22484, + "y": 367750.78842, + "z": 871.908658, + "elevation_m": 871.908658, + "valid": true + }, + { + "chainage_m": 239.0, + "x": 208461.119267, + "y": 367751.235633, + "z": 871.521665, + "elevation_m": 871.521665, + "valid": true + }, + { + "chainage_m": 240.0, + "x": 208462.013694, + "y": 367751.682847, + "z": 871.348918, + "elevation_m": 871.348918, + "valid": true + }, + { + "chainage_m": 241.0, + "x": 208462.908121, + "y": 367752.130061, + "z": 871.178815, + "elevation_m": 871.178815, + "valid": true + }, + { + "chainage_m": 242.0, + "x": 208463.802548, + "y": 367752.577274, + "z": 870.5225, + "elevation_m": 870.5225, + "valid": true + }, + { + "chainage_m": 243.0, + "x": 208464.696976, + "y": 367753.024488, + "z": 869.978886, + "elevation_m": 869.978886, + "valid": true + }, + { + "chainage_m": 244.0, + "x": 208465.591403, + "y": 367753.471701, + "z": 869.385986, + "elevation_m": 869.385986, + "valid": true + }, + { + "chainage_m": 245.0, + "x": 208466.48583, + "y": 367753.918915, + "z": 868.741635, + "elevation_m": 868.741635, + "valid": true + }, + { + "chainage_m": 246.0, + "x": 208467.380257, + "y": 367754.366129, + "z": 868.389385, + "elevation_m": 868.389385, + "valid": true + }, + { + "chainage_m": 247.0, + "x": 208468.274684, + "y": 367754.813342, + "z": 867.839514, + "elevation_m": 867.839514, + "valid": true + }, + { + "chainage_m": 248.0, + "x": 208469.169112, + "y": 367755.260556, + "z": 867.749827, + "elevation_m": 867.749827, + "valid": true + }, + { + "chainage_m": 249.0, + "x": 208470.063539, + "y": 367755.707769, + "z": 867.725932, + "elevation_m": 867.725932, + "valid": true + }, + { + "chainage_m": 250.0, + "x": 208470.957966, + "y": 367756.154983, + "z": 867.237421, + "elevation_m": 867.237421, + "valid": true + }, + { + "chainage_m": 251.0, + "x": 208471.852393, + "y": 367756.602197, + "z": 867.328371, + "elevation_m": 867.328371, + "valid": true + }, + { + "chainage_m": 252.0, + "x": 208472.74682, + "y": 367757.04941, + "z": 866.683088, + "elevation_m": 866.683088, + "valid": true + }, + { + "chainage_m": 253.0, + "x": 208473.641248, + "y": 367757.496624, + "z": 865.303387, + "elevation_m": 865.303387, + "valid": true + }, + { + "chainage_m": 254.0, + "x": 208474.535675, + "y": 367757.943837, + "z": 864.735997, + "elevation_m": 864.735997, + "valid": true + }, + { + "chainage_m": 255.0, + "x": 208475.430102, + "y": 367758.391051, + "z": 864.7865, + "elevation_m": 864.7865, + "valid": true + }, + { + "chainage_m": 256.0, + "x": 208476.324529, + "y": 367758.838265, + "z": 864.81515, + "elevation_m": 864.81515, + "valid": true + }, + { + "chainage_m": 257.0, + "x": 208477.218956, + "y": 367759.285478, + "z": 864.240971, + "elevation_m": 864.240971, + "valid": true + }, + { + "chainage_m": 258.0, + "x": 208478.113384, + "y": 367759.732692, + "z": 864.200528, + "elevation_m": 864.200528, + "valid": true + }, + { + "chainage_m": 259.0, + "x": 208479.007811, + "y": 367760.179905, + "z": 864.034127, + "elevation_m": 864.034127, + "valid": true + }, + { + "chainage_m": 260.0, + "x": 208479.902238, + "y": 367760.627119, + "z": 863.832762, + "elevation_m": 863.832762, + "valid": true + }, + { + "chainage_m": 261.0, + "x": 208480.796665, + "y": 367761.074333, + "z": 863.496394, + "elevation_m": 863.496394, + "valid": true + }, + { + "chainage_m": 262.0, + "x": 208481.691092, + "y": 367761.521546, + "z": 862.687865, + "elevation_m": 862.687865, + "valid": true + }, + { + "chainage_m": 263.0, + "x": 208482.585519, + "y": 367761.96876, + "z": 862.708177, + "elevation_m": 862.708177, + "valid": true + }, + { + "chainage_m": 264.0, + "x": 208483.479947, + "y": 367762.415973, + "z": 862.616929, + "elevation_m": 862.616929, + "valid": true + }, + { + "chainage_m": 265.0, + "x": 208484.374374, + "y": 367762.863187, + "z": 862.370077, + "elevation_m": 862.370077, + "valid": true + }, + { + "chainage_m": 266.0, + "x": 208485.268801, + "y": 367763.310401, + "z": 862.183629, + "elevation_m": 862.183629, + "valid": true + }, + { + "chainage_m": 267.0, + "x": 208486.163228, + "y": 367763.757614, + "z": 862.068982, + "elevation_m": 862.068982, + "valid": true + }, + { + "chainage_m": 268.0, + "x": 208487.057655, + "y": 367764.204828, + "z": 862.077233, + "elevation_m": 862.077233, + "valid": true + }, + { + "chainage_m": 269.0, + "x": 208487.952083, + "y": 367764.652041, + "z": 861.94402, + "elevation_m": 861.94402, + "valid": true + }, + { + "chainage_m": 270.0, + "x": 208488.863804, + "y": 367765.062569, + "z": 861.552265, + "elevation_m": 861.552265, + "valid": true + }, + { + "chainage_m": 271.0, + "x": 208489.803805, + "y": 367765.401435, + "z": 861.348876, + "elevation_m": 861.348876, + "valid": true + }, + { + "chainage_m": 272.0, + "x": 208490.768228, + "y": 367765.662155, + "z": 861.811783, + "elevation_m": 861.811783, + "valid": true + }, + { + "chainage_m": 273.0, + "x": 208491.750693, + "y": 367765.842721, + "z": 862.30889, + "elevation_m": 862.30889, + "valid": true + }, + { + "chainage_m": 274.0, + "x": 208492.744713, + "y": 367765.941972, + "z": 862.347235, + "elevation_m": 862.347235, + "valid": true + }, + { + "chainage_m": 275.0, + "x": 208493.743964, + "y": 367765.974, + "z": 862.18813, + "elevation_m": 862.18813, + "valid": true + }, + { + "chainage_m": 276.0, + "x": 208494.743964, + "y": 367765.974, + "z": 862.329525, + "elevation_m": 862.329525, + "valid": true + }, + { + "chainage_m": 277.0, + "x": 208495.743964, + "y": 367765.974, + "z": 862.652236, + "elevation_m": 862.652236, + "valid": true + }, + { + "chainage_m": 278.0, + "x": 208496.743964, + "y": 367765.974, + "z": 862.779696, + "elevation_m": 862.779696, + "valid": true + }, + { + "chainage_m": 279.0, + "x": 208497.743964, + "y": 367765.974, + "z": 862.697969, + "elevation_m": 862.697969, + "valid": true + }, + { + "chainage_m": 280.0, + "x": 208498.743964, + "y": 367765.974, + "z": 862.784431, + "elevation_m": 862.784431, + "valid": true + }, + { + "chainage_m": 281.0, + "x": 208499.743964, + "y": 367765.974, + "z": 863.016989, + "elevation_m": 863.016989, + "valid": true + }, + { + "chainage_m": 282.0, + "x": 208500.743964, + "y": 367765.974, + "z": 863.06037, + "elevation_m": 863.06037, + "valid": true + }, + { + "chainage_m": 283.0, + "x": 208501.743964, + "y": 367765.974, + "z": 863.169186, + "elevation_m": 863.169186, + "valid": true + }, + { + "chainage_m": 284.0, + "x": 208502.743964, + "y": 367765.974, + "z": 863.309619, + "elevation_m": 863.309619, + "valid": true + }, + { + "chainage_m": 285.0, + "x": 208503.743964, + "y": 367765.974, + "z": 863.551038, + "elevation_m": 863.551038, + "valid": true + }, + { + "chainage_m": 286.0, + "x": 208504.743964, + "y": 367765.974, + "z": 863.764148, + "elevation_m": 863.764148, + "valid": true + }, + { + "chainage_m": 287.0, + "x": 208505.743964, + "y": 367765.974, + "z": 863.89876, + "elevation_m": 863.89876, + "valid": true + }, + { + "chainage_m": 288.0, + "x": 208506.743964, + "y": 367765.974, + "z": 864.164324, + "elevation_m": 864.164324, + "valid": true + }, + { + "chainage_m": 289.0, + "x": 208507.743964, + "y": 367765.974, + "z": 864.336218, + "elevation_m": 864.336218, + "valid": true + }, + { + "chainage_m": 290.0, + "x": 208508.742891, + "y": 367765.928137, + "z": 864.070881, + "elevation_m": 864.070881, + "valid": true + }, + { + "chainage_m": 291.0, + "x": 208509.734387, + "y": 367765.801764, + "z": 864.008954, + "elevation_m": 864.008954, + "valid": true + }, + { + "chainage_m": 292.0, + "x": 208510.711727, + "y": 367765.593928, + "z": 864.503795, + "elevation_m": 864.503795, + "valid": true + }, + { + "chainage_m": 293.0, + "x": 208511.668704, + "y": 367765.307081, + "z": 864.617185, + "elevation_m": 864.617185, + "valid": true + }, + { + "chainage_m": 294.0, + "x": 208512.598739, + "y": 367764.942594, + "z": 864.949289, + "elevation_m": 864.949289, + "valid": true + }, + { + "chainage_m": 295.0, + "x": 208513.504625, + "y": 367764.519687, + "z": 865.163788, + "elevation_m": 865.163788, + "valid": true + }, + { + "chainage_m": 296.0, + "x": 208514.399052, + "y": 367764.072474, + "z": 865.441189, + "elevation_m": 865.441189, + "valid": true + }, + { + "chainage_m": 297.0, + "x": 208515.29348, + "y": 367763.62526, + "z": 865.797692, + "elevation_m": 865.797692, + "valid": true + }, + { + "chainage_m": 298.0, + "x": 208516.187907, + "y": 367763.178047, + "z": 866.464496, + "elevation_m": 866.464496, + "valid": true + }, + { + "chainage_m": 299.0, + "x": 208517.075808, + "y": 367762.718621, + "z": 867.214534, + "elevation_m": 867.214534, + "valid": true + }, + { + "chainage_m": 300.0, + "x": 208517.944672, + "y": 367762.22357, + "z": 867.864402, + "elevation_m": 867.864402, + "valid": true + }, + { + "chainage_m": 301.0, + "x": 208518.757896, + "y": 367761.641914, + "z": 868.228745, + "elevation_m": 868.228745, + "valid": true + }, + { + "chainage_m": 302.0, + "x": 208519.523465, + "y": 367761.000501, + "z": 868.587501, + "elevation_m": 868.587501, + "valid": true + }, + { + "chainage_m": 303.0, + "x": 208520.252968, + "y": 367760.317032, + "z": 868.710721, + "elevation_m": 868.710721, + "valid": true + }, + { + "chainage_m": 304.0, + "x": 208520.960075, + "y": 367759.609925, + "z": 870.026336, + "elevation_m": 870.026336, + "valid": true + }, + { + "chainage_m": 305.0, + "x": 208521.667182, + "y": 367758.902818, + "z": 870.609514, + "elevation_m": 870.609514, + "valid": true + }, + { + "chainage_m": 306.0, + "x": 208522.374288, + "y": 367758.195712, + "z": 871.522133, + "elevation_m": 871.522133, + "valid": true + }, + { + "chainage_m": 307.0, + "x": 208523.081395, + "y": 367757.488605, + "z": 871.571255, + "elevation_m": 871.571255, + "valid": true + }, + { + "chainage_m": 308.0, + "x": 208523.788502, + "y": 367756.781498, + "z": 871.704316, + "elevation_m": 871.704316, + "valid": true + }, + { + "chainage_m": 309.0, + "x": 208524.495609, + "y": 367756.074391, + "z": 872.173686, + "elevation_m": 872.173686, + "valid": true + }, + { + "chainage_m": 310.0, + "x": 208525.202715, + "y": 367755.367285, + "z": 872.223643, + "elevation_m": 872.223643, + "valid": true + }, + { + "chainage_m": 311.0, + "x": 208525.871593, + "y": 367754.623956, + "z": 872.384133, + "elevation_m": 872.384133, + "valid": true + }, + { + "chainage_m": 312.0, + "x": 208526.482219, + "y": 367753.833569, + "z": 872.308009, + "elevation_m": 872.308009, + "valid": true + }, + { + "chainage_m": 313.0, + "x": 208527.030651, + "y": 367752.999042, + "z": 871.762187, + "elevation_m": 871.762187, + "valid": true + }, + { + "chainage_m": 314.0, + "x": 208527.51956, + "y": 367752.126878, + "z": 871.648424, + "elevation_m": 871.648424, + "valid": true + }, + { + "chainage_m": 315.0, + "x": 208527.966769, + "y": 367751.232448, + "z": 871.724875, + "elevation_m": 871.724875, + "valid": true + }, + { + "chainage_m": 316.0, + "x": 208528.413977, + "y": 367750.338019, + "z": 871.83015, + "elevation_m": 871.83015, + "valid": true + }, + { + "chainage_m": 317.0, + "x": 208528.861185, + "y": 367749.443589, + "z": 871.535179, + "elevation_m": 871.535179, + "valid": true + }, + { + "chainage_m": 318.0, + "x": 208529.308394, + "y": 367748.549159, + "z": 871.494106, + "elevation_m": 871.494106, + "valid": true + }, + { + "chainage_m": 319.0, + "x": 208529.755602, + "y": 367747.654729, + "z": 871.29612, + "elevation_m": 871.29612, + "valid": true + }, + { + "chainage_m": 320.0, + "x": 208530.20281, + "y": 367746.760299, + "z": 871.872531, + "elevation_m": 871.872531, + "valid": true + }, + { + "chainage_m": 321.0, + "x": 208530.650019, + "y": 367745.86587, + "z": 872.09275, + "elevation_m": 872.09275, + "valid": true + }, + { + "chainage_m": 322.0, + "x": 208531.097227, + "y": 367744.97144, + "z": 872.006481, + "elevation_m": 872.006481, + "valid": true + }, + { + "chainage_m": 323.0, + "x": 208531.544436, + "y": 367744.07701, + "z": 871.972749, + "elevation_m": 871.972749, + "valid": true + }, + { + "chainage_m": 324.0, + "x": 208531.991644, + "y": 367743.18258, + "z": 871.819964, + "elevation_m": 871.819964, + "valid": true + }, + { + "chainage_m": 325.0, + "x": 208532.438852, + "y": 367742.28815, + "z": 871.483305, + "elevation_m": 871.483305, + "valid": true + }, + { + "chainage_m": 326.0, + "x": 208532.886061, + "y": 367741.393721, + "z": 870.837869, + "elevation_m": 870.837869, + "valid": true + }, + { + "chainage_m": 327.0, + "x": 208533.333269, + "y": 367740.499291, + "z": 870.770714, + "elevation_m": 870.770714, + "valid": true + }, + { + "chainage_m": 328.0, + "x": 208533.780477, + "y": 367739.604861, + "z": 870.903201, + "elevation_m": 870.903201, + "valid": true + }, + { + "chainage_m": 329.0, + "x": 208534.227686, + "y": 367738.710431, + "z": 870.158915, + "elevation_m": 870.158915, + "valid": true + }, + { + "chainage_m": 330.0, + "x": 208534.674894, + "y": 367737.816001, + "z": 870.2466, + "elevation_m": 870.2466, + "valid": true + }, + { + "chainage_m": 331.0, + "x": 208535.122103, + "y": 367736.921571, + "z": 870.481656, + "elevation_m": 870.481656, + "valid": true + }, + { + "chainage_m": 332.0, + "x": 208535.569311, + "y": 367736.027142, + "z": 870.637386, + "elevation_m": 870.637386, + "valid": true + }, + { + "chainage_m": 333.0, + "x": 208536.016519, + "y": 367735.132712, + "z": 870.740964, + "elevation_m": 870.740964, + "valid": true + }, + { + "chainage_m": 334.0, + "x": 208536.463728, + "y": 367734.238282, + "z": 870.315482, + "elevation_m": 870.315482, + "valid": true + }, + { + "chainage_m": 335.0, + "x": 208536.910936, + "y": 367733.343852, + "z": 870.455116, + "elevation_m": 870.455116, + "valid": true + }, + { + "chainage_m": 336.0, + "x": 208537.358144, + "y": 367732.449422, + "z": 870.773586, + "elevation_m": 870.773586, + "valid": true + }, + { + "chainage_m": 337.0, + "x": 208537.805353, + "y": 367731.554993, + "z": 871.170461, + "elevation_m": 871.170461, + "valid": true + }, + { + "chainage_m": 338.0, + "x": 208538.252561, + "y": 367730.660563, + "z": 870.856249, + "elevation_m": 870.856249, + "valid": true + }, + { + "chainage_m": 339.0, + "x": 208538.69977, + "y": 367729.766133, + "z": 870.772348, + "elevation_m": 870.772348, + "valid": true + }, + { + "chainage_m": 340.0, + "x": 208539.146978, + "y": 367728.871703, + "z": 870.732001, + "elevation_m": 870.732001, + "valid": true + }, + { + "chainage_m": 341.0, + "x": 208539.594186, + "y": 367727.977273, + "z": 870.954294, + "elevation_m": 870.954294, + "valid": true + }, + { + "chainage_m": 342.0, + "x": 208540.041395, + "y": 367727.082844, + "z": 871.692873, + "elevation_m": 871.692873, + "valid": true + }, + { + "chainage_m": 343.0, + "x": 208540.488603, + "y": 367726.188414, + "z": 871.204913, + "elevation_m": 871.204913, + "valid": true + }, + { + "chainage_m": 344.0, + "x": 208540.935811, + "y": 367725.293984, + "z": 870.361649, + "elevation_m": 870.361649, + "valid": true + }, + { + "chainage_m": 345.0, + "x": 208541.38302, + "y": 367724.399554, + "z": 870.112888, + "elevation_m": 870.112888, + "valid": true + }, + { + "chainage_m": 346.0, + "x": 208541.830228, + "y": 367723.505124, + "z": 870.160481, + "elevation_m": 870.160481, + "valid": true + }, + { + "chainage_m": 347.0, + "x": 208542.277437, + "y": 367722.610695, + "z": 870.356587, + "elevation_m": 870.356587, + "valid": true + }, + { + "chainage_m": 348.0, + "x": 208542.724645, + "y": 367721.716265, + "z": 870.213329, + "elevation_m": 870.213329, + "valid": true + }, + { + "chainage_m": 349.0, + "x": 208543.171853, + "y": 367720.821835, + "z": 869.701063, + "elevation_m": 869.701063, + "valid": true + }, + { + "chainage_m": 350.0, + "x": 208543.619062, + "y": 367719.927405, + "z": 869.28203, + "elevation_m": 869.28203, + "valid": true + }, + { + "chainage_m": 351.0, + "x": 208544.06627, + "y": 367719.032975, + "z": 869.502922, + "elevation_m": 869.502922, + "valid": true + }, + { + "chainage_m": 352.0, + "x": 208544.513478, + "y": 367718.138546, + "z": 869.69136, + "elevation_m": 869.69136, + "valid": true + }, + { + "chainage_m": 353.0, + "x": 208544.960687, + "y": 367717.244116, + "z": 869.508172, + "elevation_m": 869.508172, + "valid": true + }, + { + "chainage_m": 354.0, + "x": 208545.407895, + "y": 367716.349686, + "z": 869.283434, + "elevation_m": 869.283434, + "valid": true + }, + { + "chainage_m": 355.0, + "x": 208545.855103, + "y": 367715.455256, + "z": 869.157154, + "elevation_m": 869.157154, + "valid": true + }, + { + "chainage_m": 356.0, + "x": 208546.302312, + "y": 367714.560826, + "z": 868.784861, + "elevation_m": 868.784861, + "valid": true + }, + { + "chainage_m": 357.0, + "x": 208546.74952, + "y": 367713.666397, + "z": 868.386015, + "elevation_m": 868.386015, + "valid": true + }, + { + "chainage_m": 358.0, + "x": 208547.196729, + "y": 367712.771967, + "z": 867.871342, + "elevation_m": 867.871342, + "valid": true + }, + { + "chainage_m": 359.0, + "x": 208547.643937, + "y": 367711.877537, + "z": 867.712103, + "elevation_m": 867.712103, + "valid": true + }, + { + "chainage_m": 360.0, + "x": 208548.091145, + "y": 367710.983107, + "z": 867.67227, + "elevation_m": 867.67227, + "valid": true + } + ], + "stations": [ + { + "station_id": "station_000000160000", + "chainage_m": 160.0, + "label": "STA.0+160.000", + "kind": "regular", + "center_x": 208393.562479, + "center_y": 367768.040752, + "center_z": 880.274875, + "azimuth_deg": 153.436904, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 1.3949, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208393.562479, + "y": 367768.040752, + "z": 880.274875 + }, + "tangent_xy": [ + 0.447183081, + -0.894442447 + ], + "left_xy": [ + 0.894442447, + 0.447183081 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 4.13, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000178290", + "chainage_m": 178.29, + "label": "STA.0+178.290", + "kind": "irregular", + "center_x": 208403.595488, + "center_y": 367752.974512, + "center_z": 878.972372, + "azimuth_deg": 135.0, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 1.7334, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208403.595488, + "y": 367752.974512, + "z": 878.972372 + }, + "tangent_xy": [ + 0.707106781, + -0.707106781 + ], + "left_xy": [ + 0.707106781, + 0.707106781 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "structure": "세월교", + "pavement_suggested": false, + "pavement_grade_pct": 5.95, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000180000", + "chainage_m": 180.0, + "label": "STA.0+180.000", + "kind": "regular", + "center_x": 208404.804641, + "center_y": 367751.765359, + "center_z": 878.960423, + "azimuth_deg": 134.367573, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 2.1181, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208404.804641, + "y": 367751.765359, + "z": 878.960423 + }, + "tangent_xy": [ + 0.714868541, + -0.699258871 + ], + "left_xy": [ + 0.699258871, + 0.714868541 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 6.15, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000200000", + "chainage_m": 200.0, + "label": "STA.0+200.000", + "kind": "regular", + "center_x": 208423.257573, + "center_y": 367745.974, + "center_z": 877.309908, + "azimuth_deg": 90.0, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 0.9385, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208423.257573, + "y": 367745.974, + "z": 877.309908 + }, + "tangent_xy": [ + 1.0, + 0.0 + ], + "left_xy": [ + -0.0, + 1.0 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 9.54, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000220000", + "chainage_m": 220.0, + "label": "STA.0+220.000", + "kind": "regular", + "center_x": 208443.257573, + "center_y": 367745.974, + "center_z": 875.077858, + "azimuth_deg": 90.0, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 1.2363, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208443.257573, + "y": 367745.974, + "z": 875.077858 + }, + "tangent_xy": [ + 1.0, + 0.0 + ], + "left_xy": [ + -0.0, + 1.0 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": true, + "pavement_grade_pct": 14.18, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000240000", + "chainage_m": 240.0, + "label": "STA.0+240.000", + "kind": "regular", + "center_x": 208462.013694, + "center_y": 367751.682847, + "center_z": 871.348918, + "azimuth_deg": 63.434949, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 0.0151, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208462.013694, + "y": 367751.682847, + "z": 871.348918 + }, + "tangent_xy": [ + 0.894427191, + 0.447213595 + ], + "left_xy": [ + -0.447213595, + 0.894427191 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": true, + "pavement_grade_pct": 29.16, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000260000", + "chainage_m": 260.0, + "label": "STA.0+260.000", + "kind": "regular", + "center_x": 208479.902238, + "center_y": 367760.627119, + "center_z": 863.832762, + "azimuth_deg": 63.434949, + "plan_radius_m": null, + "curve_outer_side": "left", + "curve_widening_m": 0.1973, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208479.902238, + "y": 367760.627119, + "z": 863.832762 + }, + "tangent_xy": [ + 0.894427191, + 0.447213595 + ], + "left_xy": [ + -0.447213595, + 0.894427191 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": true, + "pavement_grade_pct": 39.87, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000263206", + "chainage_m": 263.21, + "label": "STA.0+263.206", + "kind": "irregular", + "center_x": 208482.769771, + "center_y": 367762.060886, + "center_z": 862.691564, + "azimuth_deg": 63.434949, + "plan_radius_m": null, + "curve_outer_side": "left", + "curve_widening_m": 0.9187, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208482.769771, + "y": 367762.060886, + "z": 862.691564 + }, + "tangent_xy": [ + 0.894427191, + 0.447213595 + ], + "left_xy": [ + -0.447213595, + 0.894427191 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "structure": "파형강관 D800", + "pavement_suggested": true, + "pavement_grade_pct": 22.37, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000280000", + "chainage_m": 280.0, + "label": "STA.0+280.000", + "kind": "regular", + "center_x": 208498.743964, + "center_y": 367765.974, + "center_z": 862.784431, + "azimuth_deg": 90.0, + "plan_radius_m": null, + "curve_outer_side": "left", + "curve_widening_m": 1.054, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208498.743964, + "y": 367765.974, + "z": 862.784431 + }, + "tangent_xy": [ + 1.0, + 0.0 + ], + "left_xy": [ + -0.0, + 1.0 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": true, + "pavement_grade_pct": 14.46, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000300000", + "chainage_m": 300.0, + "label": "STA.0+300.000", + "kind": "regular", + "center_x": 208517.944672, + "center_y": 367762.22357, + "center_z": 867.864402, + "azimuth_deg": 122.623368, + "plan_radius_m": 12.0, + "curve_outer_side": "left", + "curve_widening_m": 2.25, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208517.944672, + "y": 367762.22357, + "z": 867.864402 + }, + "tangent_xy": [ + 0.842232593, + -0.539114328 + ], + "left_xy": [ + 0.539114328, + 0.842232593 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": true, + "pavement_grade_pct": 25.84, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000320000", + "chainage_m": 320.0, + "label": "STA.0+320.000", + "kind": "regular", + "center_x": 208530.20281, + "center_y": 367746.760299, + "center_z": 871.872531, + "azimuth_deg": 153.435283, + "plan_radius_m": null, + "curve_outer_side": "left", + "curve_widening_m": 0.8735, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208530.20281, + "y": 367746.760299, + "z": 871.872531 + }, + "tangent_xy": [ + 0.447208374, + -0.894429802 + ], + "left_xy": [ + 0.894429802, + 0.447208374 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 7.89, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000340000", + "chainage_m": 340.0, + "label": "STA.0+340.000", + "kind": "regular", + "center_x": 208539.146978, + "center_y": 367728.871703, + "center_z": 870.732001, + "azimuth_deg": 153.435283, + "plan_radius_m": null, + "curve_outer_side": null, + "curve_widening_m": 0.0, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208539.146978, + "y": 367728.871703, + "z": 870.732001 + }, + "tangent_xy": [ + 0.447208374, + -0.894429802 + ], + "left_xy": [ + 0.894429802, + 0.447208374 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 8.56, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000344122", + "chainage_m": 344.122, + "label": "STA.0+344.122", + "kind": "irregular", + "center_x": 208540.990371, + "center_y": 367725.184864, + "center_z": 870.282781, + "azimuth_deg": 153.435283, + "plan_radius_m": null, + "curve_outer_side": null, + "curve_widening_m": 0.0, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208540.990371, + "y": 367725.184864, + "z": 870.282781 + }, + "tangent_xy": [ + 0.447208374, + -0.894429802 + ], + "left_xy": [ + 0.894429802, + 0.447208374 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "structure": "파형강관 D800", + "pavement_suggested": true, + "pavement_grade_pct": 15.26, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000360000", + "chainage_m": 360.0, + "label": "STA.0+360.000", + "kind": "regular", + "center_x": 208548.091145, + "center_y": 367710.983107, + "center_z": 867.67227, + "azimuth_deg": 153.435283, + "plan_radius_m": null, + "curve_outer_side": null, + "curve_widening_m": 0.0, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208548.091145, + "y": 367710.983107, + "z": 867.67227 + }, + "tangent_xy": [ + 0.447208374, + -0.894429802 + ], + "left_xy": [ + 0.894429802, + 0.447208374 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 9.6, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + } + ], + "design_profiles": [ + { + "schema_version": 1, + "id": "design_grade_line", + "name": "계획선", + "basis": "ground_polyline", + "criteria": { + "max_grade_pct": 14.0, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 100.0, + "min_curve_length_m": 20.0, + "min_tangent_length_m": 20.0, + "vertical_curve_skip_delta_pct": 5.0, + "design_speed_kph": 20, + "terrain_type": "special", + "paved": false, + "main_direction": "auto", + "balance_segment_length_m": null, + "start_elevation_offset_m": 0.0, + "end_elevation_offset_m": 0.0, + "enforce_pipe_clearance": false, + "resolved_main_direction": "descending" + } + } + ], + "profile_alignment": { + "schema_version": 1, + "policy": { + "station_interval_m": 20.0, + "curve_radius_ratio": 0.4, + "default_curve_length_m": 15.0, + "curve_tangent_max_ratio": 0.45, + "curve_skip_legal_exception": false, + "default_curve_radius_m": 8.0, + "balance_tolerance_percent": 10.0, + "edit_step_m": 0.1, + "grade_violation_policy": "warn", + "max_grade_pct": 14.0, + "curve_skip_delta_pct": 5.0, + "paved": false, + "min_plan_radius_m": 0.0, + "hairpin_min_radius_m": 0.0 + }, + "only_explicit_curves": false, + "base_pvi": [ + { + "chainage_m": 160.0, + "elevation_m": 878.698 + }, + { + "chainage_m": 178.29, + "elevation_m": 877.9714 + }, + { + "chainage_m": 180.0, + "elevation_m": 877.925 + }, + { + "chainage_m": 200.0, + "elevation_m": 876.3436 + }, + { + "chainage_m": 220.0, + "elevation_m": 873.5503 + }, + { + "chainage_m": 240.0, + "elevation_m": 870.7218 + }, + { + "chainage_m": 260.0, + "elevation_m": 868.183 + }, + { + "chainage_m": 263.206, + "elevation_m": 867.9361 + }, + { + "chainage_m": 280.0, + "elevation_m": 866.4094 + }, + { + "chainage_m": 300.0, + "elevation_m": 865.7391 + }, + { + "chainage_m": 320.0, + "elevation_m": 866.0486 + }, + { + "chainage_m": 340.0, + "elevation_m": 867.2154 + }, + { + "chainage_m": 344.122, + "elevation_m": 867.2302 + }, + { + "chainage_m": 360.0, + "elevation_m": 867.3354 + } + ], + "edits": { + "station_offsets": { + "263.206": 1.5, + "263.210": 0.9 + }, + "curve_radii": {} + } + } +} \ No newline at end of file diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 209ea2d1f..f9fc900b3 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -148,6 +148,10 @@ const panelModule = loadTs( "../B05_Profile/B05_Profile_UI_Structure_Pick_Session": { readStructurePick: () => null }, "../A00_Common/design/design_selection": selection, "@ui/ui_template_elements": { showToast() {} }, + // 관 기준 측점 정수 맞춤(234-1) — 일반 측점 없으면 정수 m 만 + "../common_util/common_util_structure_station": { + snapStructureStation: (value) => Math.floor(value + 0.5), + }, "./B06_Section_UI_Adjust_Dock": { adjustDockRoot: () => ({}), refreshAdjustSlots() {}, diff --git a/resources/tester/helper_230_1_popout_wheel.cjs b/resources/tester/helper_230_1_popout_wheel.cjs new file mode 100644 index 000000000..b05bb981b --- /dev/null +++ b/resources/tester/helper_230_1_popout_wheel.cjs @@ -0,0 +1,162 @@ +/* 종단 새 창 + 최대화 휠(계획서 230-1) 단위검증 헬퍼 — 휠 이동(`B05_Profile_UI_Profile_Wheel.ts`) · + * 스크롤 뒤 다시 그리기 시계(`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, "..", ".."); +function load(file, mods = {}) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (p) => mods[p] ?? require(p)); + return box.exports; +} + +// ---- 가짜 창 — frozen 이면 화면 틀 · 시계가 안 돎 ---- +function fakeWindow(name, frozen) { + const win = { name, frozen, now: 0, timers: [], seq: 0, frames: 0 }; + win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq); + win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id)); + win.requestAnimationFrame = (fn) => win.setTimeout(() => (win.frames++, fn(win.now)), 16); + win.cancelAnimationFrame = win.clearTimeout; + win.getSelection = () => ({ removeAllRanges: () => (win.cleared = true) }); + win.tick = (ms) => { + if (win.frozen) return; + const end = win.now + ms; + for (;;) { + const next = win.timers.filter((t) => t.at <= end).sort((a, b) => a.at - b.at)[0]; + if (!next) break; + win.now = next.at; + win.clearTimeout(next.id); + next.fn(); + } + win.now = end; + }; + return win; +} + +function fakeScroller(view, { width, scrollWidth }) { + const calls = {}; + let left = 0; + const el = { + ownerDocument: { defaultView: view }, + clientWidth: width, + clientHeight: 400, + scrollWidth, + scrollTop: 0, + style: {}, + addEventListener: (type, fn) => (calls[type] ??= []).push(fn), + removeEventListener: () => {}, + hasPointerCapture: () => false, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + fire: (type, ev = {}) => { + const e = { prevented: false, preventDefault: () => (e.prevented = true), ...ev }; + (calls[type] ?? []).forEach((fn) => fn(e)); + return e; + }, + }; + Object.defineProperty(el, "scrollLeft", { + get: () => left, + set: (v) => (left = Math.min(Math.max(0, v), el.scrollWidth - el.clientWidth)), + }); + return el; +} + +const main = fakeWindow("main", true); +globalThis.window = main; +globalThis.requestAnimationFrame = main.requestAnimationFrame; // 옛 코드가 쓰던 전역 = 메인 창 +globalThis.cancelAnimationFrame = main.cancelAnimationFrame; +const roCallbacks = []; +globalThis.ResizeObserver = class { + constructor(fn) { + roCallbacks.push(fn); + } + observe() {} + disconnect() {} +}; + +const viewWindow = load(path.join(ROOT, "common_util", "common_util_view_window.ts")); +const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), { + "../common_util/common_util_view_window": viewWindow, +}); +const viewport = load(path.join(ROOT, "A00_Common", "design", "design_profile_viewport.ts"), { + "../../B05_Profile/B05_Profile_UI_Profile_Wheel": wheelMod, + "@util/common_util_view_window": viewWindow, + "@util/common_util_chart_ywindow": { needsFullRedraw: () => true }, +}); + +function wheelDown(scroller, view, notches) { + const prevented = []; + for (let i = 0; i < notches; i++) + prevented.push(scroller.fire("wheel", { deltaY: 120 }).prevented); + view.tick(1000); + return { left: Math.round(scroller.scrollLeft), prevented }; +} + +const out = {}; + +// ① 새 창 보통 크기(1400) — 메인 창은 가려짐 +const popup = fakeWindow("popup", false); +const s1 = fakeScroller(popup, { width: 1400, scrollWidth: 6000 }); +wheelMod.attachWheelHorizontalScroll(s1); +out.popupNormal = { + ...wheelDown(s1, popup, 3), + popupFrames: popup.frames, + mainTimers: main.timers.length, +}; + +// ② 새 창 최대화(1400 → 1920) 뒤 휠 — 크기 바뀜 듣개가 돈 뒤에도 이동 +const popup2 = fakeWindow("popup2", false); +const s2 = fakeScroller(popup2, { width: 1400, scrollWidth: 6000 }); +let draws = 0; +let lightSyncs = 0; +const last = { width: 1400, height: 400 }; +const unbind = viewport.bindProfileViewport({ + body: s2, + editing: () => false, + fitElevation: () => ({}), + draw: () => draws++, + lastSize: () => last, + scheduleLightSync: () => lightSyncs++, + scheduleDraw: () => {}, +}); +s2.clientWidth = 1920; +s2.clientHeight = 1000; +roCallbacks.at(-1)(); +const before = main.timers.length; +const moved = wheelDown(s2, popup2, 2); +s2.fire("scroll"); +popup2.tick(200); +out.popupMax = { + ...moved, + limit: s2.scrollWidth - s2.clientWidth, + lightSyncs, + draws, + mainTimersAdded: main.timers.length - before, +}; +unbind(); + +// ③ 끝까지 굴림 — 한도에서 멈추고 더는 막지 않음(기본 스크롤로 넘김) +const s3 = fakeScroller(popup, { width: 1920, scrollWidth: 2200 }); +wheelMod.attachWheelHorizontalScroll(s3); +const atEnd = wheelDown(s3, popup, 5); +out.popupEnd = { ...atEnd, after: s3.fire("wheel", { deltaY: 120 }).prevented }; + +// ④ 원래 창(가려지지 않음) — 자기 창 화면 틀로 그대로 이동 +main.frozen = false; +const s4 = fakeScroller(main, { width: 1400, scrollWidth: 6000 }); +wheelMod.attachWheelHorizontalScroll(s4); +out.mainWindow = wheelDown(s4, main, 3); + +// ⑤ 끌기 — 글자 선택은 스크롤러가 놓인 창에서 풂 +const s5 = fakeScroller(popup, { width: 1400, scrollWidth: 6000 }); +wheelMod.attachWheelHorizontalScroll(s5); +s5.fire("pointerdown", { button: 0, pointerId: 1, clientX: 500, clientY: 100, target: null }); +s5.fire("pointermove", { pointerId: 1, clientX: 400, clientY: 100 }); +out.drag = { left: s5.scrollLeft, popupCleared: !!popup.cleared, mainCleared: !!main.cleared }; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_230_2_popout_frames.cjs b/resources/tester/helper_230_2_popout_frames.cjs new file mode 100644 index 000000000..00fbd81f7 --- /dev/null +++ b/resources/tester/helper_230_2_popout_frames.cjs @@ -0,0 +1,227 @@ +/* 종단 새 창 최대화 — 같은 꼴 나머지(계획서 230-2) 단위검증 헬퍼. + * 메인 창은 「가려짐」(화면 틀 · 시계 멈춤) · 종단 요소는 새 창 소속. 실제 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 read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8"); +const run = (code, scope) => { + const js = ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const names = Object.keys(scope); + return new Function(...names, js)(...names.map((n) => scope[n])); +}; +/** `start` 부터 짝 맞는 닫는 중괄호까지 잘라 냄 — 본문 여는 괄호가 반환 타입 뒤면 `open` 으로 짚음. */ +function block(src, start, open = "{") { + const at = src.indexOf(start); + if (at < 0) throw new Error(`본문 없음: ${start}`); + let depth = 0; + for (let i = src.indexOf(open, at) + open.length - 1; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}" && --depth === 0) return src.slice(at, i + 1); + } + throw new Error(`닫힘 없음: ${start}`); +} + +// ---- 가짜 창 — frozen 이면 화면 틀 · 시계가 안 돎 ---- +function fakeWindow(name, frozen) { + const win = { name, frozen, now: 0, timers: [], seq: 0, frames: 0, listeners: [] }; + win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq); + win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id)); + win.setInterval = (fn, ms) => { + const id = ++win.seq; + const again = (at) => win.timers.push({ id, at, fn: () => (fn(), again(win.now + ms)) }); + again(win.now + ms); + return id; + }; + win.clearInterval = win.clearTimeout; + win.requestAnimationFrame = (fn) => win.setTimeout(() => (win.frames++, fn(win.now)), 16); + win.cancelAnimationFrame = win.clearTimeout; + win.addEventListener = (type, fn) => win.listeners.push([type, fn]); + win.removeEventListener = (type, fn) => + (win.listeners = win.listeners.filter(([t, f]) => t !== type || f !== fn)); + win.tick = (ms) => { + if (win.frozen) return; + const end = win.now + ms; + for (;;) { + const next = win.timers.filter((t) => t.at <= end).sort((a, b) => a.at - b.at)[0]; + if (!next) break; + win.now = next.at; + win.clearTimeout(next.id); + next.fn(); + } + win.now = end; + }; + return win; +} + +const main = fakeWindow("main", true); +const popup = fakeWindow("popup", false); +globalThis.window = main; +globalThis.requestAnimationFrame = main.requestAnimationFrame; // 옛 코드가 쓰던 전역 = 메인 창 +globalThis.cancelAnimationFrame = main.cancelAnimationFrame; +const inPopup = () => ({ ownerDocument: { defaultView: popup }, style: { setProperty() {} } }); + +const vw = {}; +run( + read("common_util", "common_util_view_window.ts").replace(/export /g, "") + + "\nObject.assign(vw, { viewOf, frameOf });", + { vw }, +); +const { viewOf, frameOf } = vw; +const out = {}; +const mainBusy = () => main.timers.length; + +// ① 공용 함수 — 새 창 요소는 새 창 · 안 붙은 요소는 메인 창 +out.viewOf = { + popup: viewOf(inPopup()).name, + detached: viewOf({ ownerDocument: { defaultView: null } }).name, + none: viewOf(null).name, +}; + +// ② 계획고 편집 뒤 다시 그리기(design_profile rebuild) — 두 번 불러도 한 번만 그림 +{ + const s = { draws: 0, previews: 0 }; + const src = block( + read("A00_Common", "design", "design_profile.ts"), + "function rebuild(): void {", + ); + run(`let redrawPending = false; ${src}\nrebuild(); rebuild(); s.rebuild = true;`, { + s, + base: null, + alignment: null, + buildAlignment: () => null, + store: { edits: () => ({}) }, + scheduleCrossPreview: () => s.previews++, + draw: () => s.draws++, + root: inPopup(), + frameOf, + }); + popup.tick(50); + out.rebuild = { draws: s.draws, previews: s.previews, mainTimers: mainBusy() }; +} + +// ③ 높이 맞춤(Heights scheduleLightSync) +{ + const s = { syncs: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Profile_Heights.ts"), + "function scheduleLightSync(): void {", + ); + run(`let lightSyncPending = false; ${src}\nscheduleLightSync(); scheduleLightSync();`, { + deps: { body: inPopup() }, + syncHeightsLight: () => s.syncs++, + frameOf, + }); + popup.tick(50); + out.heights = { syncs: s.syncs, mainTimers: mainBusy() }; +} + +// ④ 표 · 유토곡선 크기 끌기(TableOverlay · MassHaul onResize) +for (const [key, file] of [ + ["table", "B05_Profile_UI_Profile_TableOverlay.ts"], + ["massHaul", "B05_Profile_UI_Profile_MassHaul.ts"], +]) { + const s = { changed: 0, handles: 0 }; + const src = block(read("B05_Profile", file), "onResize: () => {").replace( + /^onResize: /, + "const onResize = ", + ); + run(`let resizeRedrawPending = false; ${src}\nonResize(); onResize();`, { + syncHandlePosition: () => s.handles++, + onChanged: () => s.changed++, + overlay: inPopup(), + frameOf, + }); + popup.tick(50); + out[key] = { changed: s.changed, handles: s.handles, mainTimers: mainBusy() }; +} + +// ⑤ 유토곡선 세로창 미끄러짐(scheduleMassHaulSettle) +{ + const s = { redraws: 0 }; + const src = block( + read("common_util", "common_util_mass_haul_view.ts"), + "export function scheduleMassHaulSettle(", + ).replace(/^export /, ""); + const state = { held: null, settled: false, frame: 0 }; + run( + `${src}\nscheduleMassHaulSettle(state, () => s.redraws++, node); scheduleMassHaulSettle(state, () => s.redraws++, node);`, + { + s, + state, + node: inPopup(), + viewOf, + }, + ); + popup.tick(50); + out.settle = { + redraws: s.redraws, + frame: state.frame, + view: state.view?.name, + mainTimers: mainBusy(), + }; +} + +// ⑥ 끌기 뒤 다시 그리기(Profile_Drag scheduleDraw · 120ms 모음) +{ + const s = { draws: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Profile_Drag.ts"), + "const scheduleDraw = (): void => {", + ); + run(`let resizeTimer = 0; let timerView = window; ${src}\nscheduleDraw(); scheduleDraw();`, { + window: main, + root: inPopup(), + ctx: { draw: () => s.draws++ }, + viewOf, + }); + popup.tick(200); + out.drag = { draws: s.draws, mainTimers: mainBusy() }; +} + +// ⑦ ▲▼ 길게 누르기(Profile_Edit createHoldRepeater) — 0.5초 뒤 초당 10회 +{ + const s = { acts: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Profile_Edit.ts"), + "function createHoldRepeater(", + "} {", + ); + run(`${src}\nconst r = createHoldRepeater(node); r.start(() => s.acts++); s.r = r;`, { + s, + window: main, + node: inPopup(), + viewOf, + HOLD_DELAY_MS: 500, + HOLD_INTERVAL_MS: 100, + }); + popup.tick(1000); + const acts = s.acts; + s.r.stop(); + popup.tick(1000); + out.hold = { acts, afterStop: s.acts, mainTimers: mainBusy(), popupTimers: popup.timers.length }; +} + +// ⑧ 배수유역도(Drainage_Panel scheduleDraw) +{ + const s = { draws: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts"), + "function scheduleDraw(): void {", + ); + run(`let frameHandle = 0; let frameView = window; ${src}\nscheduleDraw(); scheduleDraw();`, { + window: main, + root: inPopup(), + draw: () => s.draws++, + viewOf, + }); + popup.tick(50); + out.drainage = { draws: s.draws, mainTimers: mainBusy() }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs new file mode 100644 index 000000000..0f8153e01 --- /dev/null +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -0,0 +1,433 @@ +/* 231-1 노면공 범위 고름 — 「측점만 남은 고름」이 B05↔B06 오감 · 다시 세움에서 노면공 범위를 켜지 않는지. + * 진짜 `createB06StructuresPanel` · `applyStructurePick` · `wireStructurePick` · `readStructurePick` 를 Node 로 돌리고 + * 폼 · 카드 · 알약 흉내는 helper_165 와 같은 꼴(공용 폼 `selectAt` = B05 패널 본문 그대로). 표준출력 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(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; +} + +global.requestAnimationFrame = (fn) => fn(); + +const store = new Map(); +const selection = loadTs(path.join(ROOT, "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 pickSession = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"), + { "../A00_Common/design/design_selection": selection }, +); +const structurePick = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structure_Pick.ts"), + { "../A00_Common/design/design_selection": selection }, +); + +const PID = "p231"; +const TYPES = [ + { type_id: "pavement_concrete", group: "G" }, + { type_id: "retaining_wall", group: "C" }, + { type_id: "hazard_sign", group: "E" }, + { type_id: "pipe", group: "A", managed_by: "pipe_points" }, +]; +const groupOf = (typeId) => TYPES.find((type) => type.type_id === typeId)?.group; +const atStation = (entry) => selection.coversAtStation(groupOf(entry.type_id)); +const PAVE = { + structure_id: "pave", + type_id: "pavement_concrete", + placement: "interval", + chainage_m: 30, + start_m: 30, + end_m: 250, +}; +const WALL = { + structure_id: "wall", + type_id: "retaining_wall", + placement: "interval", + chainage_m: 100, + start_m: 100, + end_m: 160, +}; +const SIGN = { structure_id: "sign", type_id: "hazard_sign", chainage_m: 127.86 }; +const PIPES = [{ chainage_m: 80.17, facility: "pipe", source: "spacing", options: {} }]; +const CARDS = [...Array.from({ length: 16 }, (_, i) => i * 20), 80.17] + .sort((a, b) => a - b) + .map((at) => ({ station_id: `s${at}`, chainage_m: at })); +const cardAt = (at) => CARDS.find((card) => Math.abs(card.chainage_m - at) < 0.01); + +/* ── 공용 폼 흉내(B05 패널 loadForm · loadPipeForm · resetForm · selectAt 본문) ── */ +let binding = null; +let structures = []; +const form = { id: null, pipe: null }; +const loadForm = (target) => { + form.id = target?.structure_id ?? null; + form.pipe = null; + binding.onSelect(target); +}; +const loadPipeForm = (chainageM) => { + const hadStructure = form.id !== null; + form.id = null; + form.pipe = chainageM; + if (hadStructure) binding.onSelect(null); +}; +const resetForm = () => { + const hadPipe = form.pipe !== null; + form.pipe = null; + loadForm(null); + if (hadPipe) binding.onPipeSelect(null); +}; +const node = { addEventListener() {}, querySelector: () => ({ append() {} }) }; +const section = { + root: node, + listRoot: node, + facility: { root: node, setRevetSideLabels() {}, recount() {} }, + setTypes() {}, + setStructures(next) { + structures = next; + }, + setPipeFacilities() {}, + hasSelection: () => form.id !== null || form.pipe !== null, + selectById(id) { + if (id === null) { + if (form.id !== null || form.pipe !== null) resetForm(); + return; + } + loadForm(structures.find((entry) => entry.structure_id === id) ?? null); + }, + selectPipeByChainage(chainageM) { + if (chainageM === null) { + if (form.pipe !== null) resetForm(); + return; + } + loadPipeForm(chainageM); + }, + resolveAt: (sel, coverable) => selection.resolveSelection(sel, PIPES, structures, coverable), + selectAt(sel, coverable) { + const hit = selection.resolveSelection(sel, PIPES, structures, coverable); + if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM); + else this.selectById(hit.structureId); + return hit; + }, +}; + +const current = { structures: [] }; +const panelModule = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structures_Panel.ts"), + { + "../A00_Common/design/design_shared": { + bindDesignView: (_shared, _view, parts) => { + binding = parts.structures; + }, + sharedStructures: () => section, + }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructureTypes: async () => TYPES, + fetchStructures: async () => ({ structures: [] }), + readPendingStructures: () => current.structures, + pipesToStructureMarks: (pipes) => + pipes.map((pipe) => ({ + structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + type_id: pipe.facility, + chainage_m: pipe.chainage_m, + start_m: null, + end_m: null, + })), + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + writePendingStructures() {}, + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { + readPendingPipes: () => null, + writePendingPipes() {}, + }, + "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge": { mergeFacilityOptions: (x) => x }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: PIPES, basins: [] }), + }, + "../common_util/common_util_summary_item": { + linkFields: () => ({}), + readGroupItems: (x) => x ?? {}, + readSummaryItem: (x) => x ?? null, + }, + "../B05_Profile/B05_Profile_UI_Structure_Pick_Session": pickSession, + "../A00_Common/design/design_selection": selection, + "@ui/ui_template_elements": { showToast() {} }, + // 관 기준 측점 정수 맞춤(234-1) — 일반 측점 없으면 정수 m 만 + "../common_util/common_util_structure_station": { + snapStructureStation: (value) => Math.floor(value + 0.5), + }, + "./B06_Section_UI_Adjust_Dock": { + adjustDockRoot: () => ({}), + refreshAdjustSlots() {}, + resetAdjustDock() {}, + setAdjustSideSlots() {}, + }, + "./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} }, + }, +); + +/* ── 종단 알약 · 횡단 카드 흉내(helper_165 와 같음) ── */ +let marks = []; +const profile = { + markedId: null, + pill: null, + selectMark(id) { + this.markedId = id; + this.pill = id; + }, + showCard(card) { + if (!card) return; + const pipe = PIPES.find((entry) => Math.abs(entry.chainage_m - card.chainage_m) < 0.51); + this.pill = pipe ? `pipe-${pipe.chainage_m.toFixed(2)}` : null; + const marked = marks.find((entry) => entry.structure_id === this.markedId); + if (!pipe && marked && selection.coversChainage(marked, card.chainage_m)) + this.pill = this.markedId; + }, +}; +const isPavement = (id) => { + const mark = marks.find((entry) => entry.structure_id === id); + return !!mark && groupOf(mark.type_id) === "G"; +}; +const view = { + selected: null, + spanMarkId: null, + listener: null, + selectStation(id) { + const previous = this.selected; + this.selected = this.selected === id ? null : id; + profile.showCard(CARDS.find((card) => card.station_id === this.selected) ?? null); + if (this.selected !== previous) this.listener(this.selected); + }, + revealStation() { + this.selected = null; + profile.showCard(null); + }, + focusStation(id) { + if (id === null) this.selected = null; + else if (this.selected !== id) this.selectStation(id); + else profile.showCard(CARDS.find((card) => card.station_id === id)); + }, + selectMark(id, span = true) { + this.spanMarkId = span || isPavement(id) ? id : null; + profile.selectMark(id); + }, +}; +const focusStationAt = (at, revealOnly = false) => { + const nearest = CARDS.reduce((best, card) => + Math.abs(card.chainage_m - at) < Math.abs(best.chainage_m - at) ? card : best, + ); + if (revealOnly) view.revealStation(nearest.station_id); + else view.focusStation(nearest.station_id); +}; + +let panel = null; +/** design_view_cross.ts 카드 고름 리스너 본문(벽 누름 없음). */ +view.listener = (stationId) => { + const target = stationId ? CARDS.find((card) => card.station_id === stationId) : null; + if (!target) { + panel.showAtChainage(null); + selection.selectDesign(PID, selection.NO_SELECTION, { from: "b06" }); + return; + } + const preferred = panel.preferred() !== null; + const opened = panel.showAtChainage(target.chainage_m); + const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null }; + selection.selectDesign(PID, picked, { from: "b06" }); + const pipeAt = panel.currentChainage(); + const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); + view.selectMark(mark, false); +}; + +/** 새로 세움 — keep = 새로 고침(F5 · 세션은 남고 화면 · 폼은 처음부터). */ +async function build(list, keep = false) { + current.structures = list; + if (!keep) store.clear(); + form.id = null; + form.pipe = null; + view.selected = null; + view.spanMarkId = null; + profile.markedId = null; + profile.pill = null; + panel = panelModule.createB06StructuresPanel({ + projectId: PID, + shared: { view: () => "cross" }, + detail: () => ({ cross_sections: CARDS }), + stationInterval: () => 20, + focusChainage: (at, revealOnly) => focusStationAt(at, revealOnly), + markStructure: (id, span) => view.selectMark(id, span), + onMarks: (next) => { + marks = next; + }, + }); + await panel.load(); +} + +/** design_view_cross.ts `restoreStructurePick` 의 focusStation 차례 그대로. */ +function restore() { + const handoff = pickSession.readStructurePick(PID); + structurePick.applyStructurePick( + handoff, + { cross_sections: CARDS }, + { + focusStation: (stationId) => { + panel.prefer(handoff?.structureId ?? null); + view.focusStation(stationId); + panel.prefer(null); + const sid = handoff?.structureId; + if (sid && structures.some((entry) => entry.structure_id === sid)) + panel.selectStructure(sid); + }, + refreshCard() {}, + revetSelect() {}, + fordSelect() {}, + boxSelect() {}, + }, + structures, + ); +} + +/** B05 로 갔다 B06 으로 돌아옴 — B05 `restorePick`(폼 비었을 때만 · id 길) → B06 onShow(load → 되얹기). */ +async function roundTrip() { + const pick = pickSession.readStructurePick(PID); + if (!section.hasSelection() && pick?.structureId) + section.selectAt({ chainageM: pick.at, structureId: pick.structureId }); + await panel.load(); + restore(); +} + +/** 새로 고침(F5) — 첫 마운트 `load().then(restoreStructurePick)` 차례(231-2). */ +async function refresh() { + await build(current.structures, true); + restore(); +} + +function snap() { + const mark = marks.find((entry) => entry.structure_id === view.spanMarkId); + const pave = mark && isPavement(mark.structure_id); + const card = CARDS.find((entry) => entry.station_id === view.selected); + return { + form: form.id ?? (form.pipe === null ? null : `pipe-${form.pipe.toFixed(2)}`), + paveLayer: pave ? mark.structure_id : null, + span: view.spanMarkId, + card: card ? card.chainage_m : null, + sid: selection.readSelection(PID)?.structureId ?? null, + }; +} + +const cardPick = (at) => view.selectStation(cardAt(at).station_id); +const listPick = (id) => loadForm(structures.find((entry) => entry.structure_id === id)); +/** 좌측 목록 관 줄 — B05 패널 `onSelectPipe`(loadPipeForm → onPipeSelect). */ +const listPipe = (at) => { + loadPipeForm(at); + binding.onPipeSelect(at); +}; + +/** B05 3D 부재 고름 — 진짜 `wireStructurePick` 에 B05 와 같은 거름(design_view_profile). */ +function pick3d(chainageM) { + const controls = {}; + pickSession.wireStructurePick(controls, PID, section, () => undefined, atStation); + controls.onPick({ chainageM, kind: "revet", key: "own" }); +} + +(async () => { + const out = {}; + // A. 노면공 범위 안 빈 카드 → 오감 + await build([PAVE, SIGN]); + cardPick(120); + out.cardBefore = snap(); + await roundTrip(); + out.cardAfter = snap(); + await roundTrip(); + out.cardAfter2 = snap(); + + // B. 같은 카드에서 목록 표지 → 오감(세션 = 측점만 그대로) + await build([PAVE, SIGN]); + cardPick(120); + listPick("sign"); + out.signPick = snap(); + await roundTrip(); + out.signAfter = snap(); + + // C. 목록 노면공 → 오감 = 그대로 범위 강조(id 길) + await build([PAVE, SIGN]); + listPick("pave"); + out.paveBefore = snap(); + await roundTrip(); + out.paveAfter = snap(); + + // D. 옹벽 범위 안 카드 = 그 카드 그대로(기준 카드 100 으로 안 옮김) · 오감 뒤도 + await build([PAVE, WALL]); + cardPick(120); + out.wallCard = snap(); + await roundTrip(); + out.wallAfter = snap(); + listPick("wall"); // 목록 고름은 기준 카드로 감(그대로) + out.wallList = snap(); + + // E. 관 80.17 넘김(측점만) = 카드 80.17(이웃 80 아님) + await build([PAVE]); + selection.selectDesign(PID, { chainageM: 80.17, structureId: null }, { from: "b05" }); + restore(); + out.pipeHandoff = snap(); + + // F. B05 3D 부재 고름 — 노면공 범위 안이라도 노면공 id 를 적지 않음 + await build([PAVE, WALL]); + pick3d(120); + out.pick3dWall = { sid: snap().sid, form: snap().form }; + await build([PAVE]); + pick3d(200); + out.pick3dBare = { sid: snap().sid, form: snap().form }; + await roundTrip(); + out.pick3dAfter = snap(); + + // 231-2 목록 고름도 늘 고름 기록 — 카드 120 → 목록 관 · 표지 · 옹벽 → 오감 · 새로 고침 + await build([PAVE, WALL, SIGN]); + cardPick(120); + listPipe(80.17); + out.listPipe = snap(); + await roundTrip(); + out.listPipeTrip = snap(); + await refresh(); + out.listPipeF5 = snap(); + + await build([PAVE, SIGN]); + cardPick(120); + listPick("sign"); // 같은 카드 120 — 카드 리스너 안 돎 + out.listSign = snap(); + await roundTrip(); + out.listSignTrip = snap(); + await refresh(); + out.listSignF5 = snap(); + + await build([PAVE, WALL]); + cardPick(120); + listPick("wall"); + out.listWall = snap(); + await roundTrip(); + out.listWallTrip = snap(); + + await build([PAVE, SIGN]); + cardPick(120); + listPick("pave"); + await refresh(); + out.listPaveF5 = snap(); + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/helper_232_1_3d_press.cjs b/resources/tester/helper_232_1_3d_press.cjs new file mode 100644 index 000000000..26ebb5991 --- /dev/null +++ b/resources/tester/helper_232_1_3d_press.cjs @@ -0,0 +1,213 @@ +/* 232-1 3D 누름 길 통째 — 진짜 마커(MK3) · 마커 입력(MI) · 3D 부재 고름(SPK) · 선택 동기화(SY) · + * 넘김(PKS) · 고름 한 칸(DSel) 을 three + 가짜 캔버스로 한 누름(창 캡처 → 캔버스 듣개 차례)씩 돌림. + * 배선은 `B05_Profile_UI_Viewer.ts` · `design_view_profile.ts` 와 같은 꼴. 표준출력 JSON 한 벌. + */ +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")); +const THREE = require(path.join(NM, "three", "build", "three.cjs")); + +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 (request === "three") return THREE; + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/* ── 창 · 캔버스 흉내(듣개 차례 = 창 캡처 → 캔버스 등록 차례) ── */ +const windowListeners = []; +globalThis.window = { + addEventListener: (type, fn) => windowListeners.push({ type, fn }), + removeEventListener: (type, fn) => { + const i = windowListeners.findIndex((entry) => entry.type === type && entry.fn === fn); + if (i >= 0) windowListeners.splice(i, 1); + }, +}; +globalThis.document = { createElement: () => ({ getContext: () => null, width: 0, height: 0 }) }; +const canvasListeners = []; +const canvas = { + addEventListener: (type, fn) => canvasListeners.push({ type, fn }), + removeEventListener() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 400, height: 400 }), + setPointerCapture() {}, + hasPointerCapture: () => false, + releasePointerCapture() {}, +}; +function fire(type, clientX, clientY) { + const event = { + type, + button: 0, + pointerId: 1, + clientX, + clientY, + preventDefault() {}, + stopPropagation() {}, + }; + for (const entry of windowListeners.filter((e) => e.type === type)) entry.fn(event); + for (const entry of canvasListeners.filter((e) => e.type === type)) entry.fn(event); +} + +/* ── 진짜 모듈 ── */ +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 MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { + "@util/common_util_svg": { + inferStationInterval: () => 20, + stationLabel: (chainage) => `0+${chainage}`, + }, +}); +const MI = loadTs("B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts", { + "./B05_Profile_UI_Markers": MK3, +}); +const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", + }, +}); +const PKS = loadTs("B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts", { + "../A00_Common/design/design_selection": DSel, +}); + +/* ── 장면: 지형 범위 · 위에서 내려다보는 직교 카메라 ── */ +const PID = "p232"; +const bounds = { x: [0, 200], y: [0, 200], z: [0, 10] }; +const scene = new THREE.Scene(); +const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 500); +camera.position.set(0, 200, 0); +camera.up.set(0, 0, -1); +camera.lookAt(0, 0, 0); +camera.updateProjectionMatrix(); +camera.updateMatrixWorld(true); +const clientOf = (x, y, z = 5) => { + const p = MK3.modelToScene({ x, y, z }, bounds).project(camera); + return [((p.x + 1) / 2) * 400, ((1 - p.y) / 2) * 400]; +}; + +const markers = MK3.createRouteMarkers(scene, () => bounds); +const STATIONS = [100, 120].map((at) => ({ + station_id: `s${at}`, + chainage_m: at, + center_x: at, + center_y: 100, + center_z: 5, + frame: { left_xy: [0, 1] }, + kind: "regular", +})); +markers.renderStationLines(STATIONS, 3); + +// 코리도 — 리본(신원표 없음 · 「빈 곳」) + 부재 하나(측점 160 · inlet) +const corridor = new THREE.Group(); +const ribbon = new THREE.Mesh( + new THREE.BoxGeometry(400, 0.1, 400), + new THREE.MeshLambertMaterial(), +); +ribbon.position.y = -0.5; +const member = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial()); +member.position.copy(MK3.modelToScene({ x: 160, y: 160, z: 6 }, bounds)); +member.userData = { chainageM: 160, kind: "pipe", key: "inlet" }; +corridor.add(ribbon, member); +scene.add(corridor); +scene.updateMatrixWorld(true); + +const markerInput = MI.bindMarkerPointerControls({ + canvas, + camera: () => camera, + controls: { enabled: true }, + markers, + getTerrain: () => null, + getBounds: () => bounds, +}); +const structurePick = SPK.bindStructurePick({ + canvas, + camera: () => camera, + group: () => corridor, + blocked: () => markerInput.blocked(), + pressedMarker: () => markerInput.pressedMarker(), +}); + +/* ── 종단 보기 배선 흉내(design_view_profile) ── */ +let graph = null; +let form = null; +let syncing = false; +const sync = SY.createSelectionSync({ + stations: () => [], + isSyncing: () => syncing, + setSyncing: (v) => (syncing = v), + selectMarker: (id) => markers.selectStation(id), + selectGraph: (id) => (graph = id), + selectSidebar: (at) => (form = at), + openAtStation: (at) => (form = at), + selectBasin() {}, + markStation() {}, + selectStructure3D: (at) => structurePick.selectAtChainage(at), + regularChainage: (id) => STATIONS.find((s) => s.station_id === id)?.chainage_m, + remember: (at) => + DSel.selectDesign(PID, at === null ? DSel.NO_SELECTION : { chainageM: at, structureId: null }), +}); +markers.onStationSelectionChange((id) => { + graph = id; + sync.syncIrregularSelection(id); +}); +PKS.wireStructurePick( + structurePick, + PID, + { + resolveAt: () => ({ structureId: null }), + hasSelection: () => form !== null, + selectAt: (sel) => (form = sel.chainageM), + }, + sync.selectStationOfPipe, +); + +/* ── 읽기 ── */ +const bar = (id) => markers.group.children[1].children.find((o) => o.userData.stationId === id); +const pin = () => markers.group.children.find((g) => g.renderOrder === 9); +const state = () => ({ + s100: "#" + bar("s100").material.color.getHexString(), + s120: "#" + bar("s120").material.color.getHexString(), + pin: pin().visible, + session: DSel.readSelection(PID), + graph, + member: member.material.emissive.getHex() !== 0, +}); +const press = (xy) => { + fire("pointerdown", ...xy); + const down = state(); + fire("pointerup", ...xy); + return { down, up: state() }; +}; + +const out = {}; +const edge120 = clientOf(120, 102); // 띠 끝쪽(가운데 핀 · 라벨 자리 밖) +const center120 = clientOf(120, 100); +const empty = clientOf(40, 40); // 리본만 +out.bar = press(edge120); +out.empty = press(empty); +out.again = press(edge120); // 같은 띠 다시 = 풂 +out.center = press(center120); // 숨은 핀 · 라벨이 있던 가운데 +out.memberPick = press(clientOf(160, 160)); +out.memberEmpty = press(empty); +out.memberAgain = press(clientOf(160, 160)); // 같은 부재 다시 = 풂 +press(edge120); +markers.setStationLinesVisible(false); +out.hiddenLines = press(clientOf(100, 102)); // 꺼 둔 측점선은 누름을 안 가로챔 +out.hiddenPressedMarker = markerInput.pressedMarker(); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_233_1_profile_pick.cjs b/resources/tester/helper_233_1_profile_pick.cjs new file mode 100644 index 000000000..22811c127 --- /dev/null +++ b/resources/tester/helper_233_1_profile_pick.cjs @@ -0,0 +1,120 @@ +/* 233-1 종단도 강조 = 고름 한 칸에서 파생 — 진짜 `design_pick_profile` · `design_selection` · + * `graphTargetAt`(B05_Profile_UI_Selection) 를 Node 로 돌림. 종단도는 고른 측점 · 알약 두 칸만 흉내. + * 표준출력 JSON 한 벌 · 짝 = test_233_1_profile_pick.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "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; +} + +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), + }, +}); +globalThis.window = { addEventListener() {}, removeEventListener() {} }; +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 PID = "p233"; +const sections = [80, 100, 120, 140, 300].map((at) => ({ station_id: `s${at}`, chainage_m: at })); +let pipes = [{ chainage_m: 80.17 }, { chainage_m: 300.4 }]; +const irregular = [ + { id: "p80", chainage_m: 80.17, origin: "pipe" }, + { id: "sign", chainage_m: 127.86, origin: "user" }, +]; + +/* 종단도 흉내 — 고른 측점 · 알약 */ +const profile = { station: null, structure: null, paints: 0 }; +const target = { + setSelectedStation: (id) => { + profile.station = id; + profile.structure = null; + profile.paints += 1; + }, + setSelectedStructure: (id) => { + profile.structure = id; + profile.station = null; + profile.paints += 1; + }, +}; +let visible = true; +const disposers = []; +const pick = PP.bindProfilePick( + PID, + { profile: target }, + { visible: () => visible, onDispose: (fn) => disposers.push(fn) }, + { irregular: () => irregular, pipes: () => pipes, sections: () => sections }, +); + +const snap = () => ({ station: profile.station, structure: profile.structure }); +const sel = (chainageM, structureId = null) => + DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }); + +const out = {}; +sel(120); +out.card = snap(); +sel(127.86); +out.irregularSign = snap(); +sel(80); +out.card80 = snap(); // 관 80.17 옆 카드 80 = 카드 선(관 아님) +sel(80.17); +out.pipe80 = snap(); +sel(300.2); +out.pipePill = snap(); +pipes = [{ chainage_m: 80.17 }, { chainage_m: 300.45 }]; // 관이 옮겨짐 +pick.paint(); +out.pipeMoved = snap(); +sel(100, "wall"); +out.structure = snap(); +// 다른 길이 강조를 끔(저장 뒤 관 폼 닫힘 · 목록 다시 세움) → 값은 그대로 → 다시 얹음 +target.setSelectedStructure(null); +out.clearedByOther = snap(); +pick.paint(); +out.repainted = snap(); +// 숨은 보기는 안 그림 · 보이면 다시 얹음 +visible = false; +sel(140); +out.hidden = snap(); +visible = true; +pick.paint(); +out.shownAgain = snap(); +// 풂(값 비움) = 선 · 알약 모두 끔 +sel(null); +out.released = snap(); +// 보기를 떠나면 구독 끊음 +disposers.forEach((fn) => fn()); +const before = profile.paints; +sel(120); +out.paintsAfterDispose = profile.paints - before; +out.disposers = disposers.length; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs new file mode 100644 index 000000000..0158756a5 --- /dev/null +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -0,0 +1,319 @@ +/* 233-2 3D · 횡단 카드 · [상세] 강조 = 고름 한 칸에서 파생 — 진짜 TS 를 Node 로 돌림. + * ① 3D: `bindProfilePick`(종단 보기 구독) + 진짜 마커(MK3) · 3D 부재 고름(SPK) · three. + * ② 횡단: `bindCrossPick` · `releaseCrossHighlights`(B06_Section_UI_Page_Pick_Sync) + 카드 · 형상 흉내. + * ③ [상세]: `attachDetailSelect` 다시 그림(창 크기 바뀜) 뒤 고른 선 조각 유지. + * 표준출력 JSON 한 벌 · 짝 = test_233_2_view_pick.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")); +const THREE = require(path.join(NM, "three", "build", "three.cjs")); + +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 (request === "three") return THREE; + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/* ── 가짜 DOM(라벨 캔버스 · [상세] 값 칸 · 조각) ── */ +const node = (tag) => { + const self = { + tag, + hidden: false, + textContent: "", + attrs: {}, + classes: new Set(), + parentElement: null, + setAttribute: (k, v) => { + self.attrs[k] = String(v); + if (k === "class") + String(v) + .split(" ") + .forEach((c) => self.classes.add(c)); + }, + getAttribute: (k) => self.attrs[k] ?? null, + addEventListener() {}, + classList: { + toggle: (c, on) => (on ? self.classes.add(c) : self.classes.delete(c)), + contains: (c) => self.classes.has(c), + }, + closest: (sel) => (self.classes.has(sel.slice(1)) ? self : null), + getContext: () => null, + }; + return self; +}; +globalThis.document = { + createElement: node, + createElementNS: (_ns, tag) => node(tag), + addEventListener() {}, + removeEventListener() {}, +}; +globalThis.window = { 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 MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { + "@util/common_util_svg": { inferStationInterval: () => 20, stationLabel: (c) => `0+${c}` }, +}); +const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); +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 CROSS_PAD = Function( + `return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(fs.readFileSync(path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"), "utf8"))[1]}`, +)(); +// [상세] 조각 모듈은 helper_209_1 과 같은 꼴로 딸린 모듈까지 진짜로 읽음(카드 공용만 흉내) +const DEEP_STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolveTs = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadDeep(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 (DEEP_STUBS[request]) return DEEP_STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadDeep(resolveTs(path.join(ROOT, "common_util", request.slice(6)))); + return loadDeep(resolveTs(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const DTS = loadDeep(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Select.ts")); + +const out = {}; +const PID = "p233b"; +const sel = (chainageM, structureId = null, options = {}) => + DSel.selectDesign( + PID, + chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }, + options, + ); + +/* ── ① 3D ── */ +const bounds = { x: [0, 200], y: [0, 200], z: [0, 10] }; +const scene = new THREE.Scene(); +const markers = MK3.createRouteMarkers(scene, () => bounds); +const STATIONS = [ + ...[100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at, center_x: at })), + { station_id: "irregular:sign", chainage_m: 127.86, center_x: 127.86 }, +].map((s) => ({ ...s, center_y: 100, center_z: 5, frame: { left_xy: [0, 1] }, kind: "regular" })); +markers.renderStationLines(STATIONS, 3); +let notified = 0; +markers.onStationSelectionChange(() => (notified += 1)); +const corridor = new THREE.Group(); +const member = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial()); +member.userData = { chainageM: 160, kind: "pipe", key: "inlet" }; +const wall = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial()); +wall.userData = { chainageM: 120, kind: "wall", key: "own" }; +corridor.add(member, wall); +const structurePick = SPK.bindStructurePick({ + canvas: { addEventListener() {}, removeEventListener() {} }, + camera: () => null, + group: () => corridor, + blocked: () => false, +}); +const profile = { station: null, structure: null }; +let b05Visible = true; +const disposers = []; +PP.bindProfilePick( + PID, + { + profile: { + setSelectedStation: (id) => Object.assign(profile, { station: id, structure: null }), + setSelectedStructure: (id) => Object.assign(profile, { station: null, structure: id }), + }, + viewer: { markers, structurePick }, + }, + { visible: () => b05Visible, onDispose: (fn) => disposers.push(fn) }, + { + irregular: () => [{ id: "sign", chainage_m: 127.86, origin: "user" }], + pipes: () => [], + sections: () => STATIONS.filter((s) => s.station_id.startsWith("s")), + }, +); +const bar = (id) => markers.group.children[1].children.find((o) => o.userData.stationId === id); +const pin = () => markers.group.children.find((g) => g.renderOrder === 9); +const lit = (mesh) => mesh.material.emissive.getHex() !== 0; +const view3d = () => ({ + red: STATIONS.map((s) => s.station_id).filter( + (id) => bar(id).material.color.getHexString() === "ef4444", + ), + pin: pin().visible, + member: lit(member), + wall: lit(wall), + profile: { ...profile }, +}); +sel(120, null, { from: "b06" }); // 횡단 카드 120 고름 → 종단 보기도 값을 받음 +out.card120 = view3d(); +sel(160, null, { partKey: "inlet", from: "b06" }); +out.part160 = view3d(); +sel(127.86, "sign", { from: "b05" }); // 목록 표지 — 그 자리 구조물 측점 띠 +out.sign = view3d(); +sel(null); +out.released3d = view3d(); +out.notified = notified; // 칠하기만 — 마커 알림으로 사슬이 다시 돌지 않음 +disposers.forEach((fn) => fn()); + +/* ── ② 횡단 카드 · 범위 · 형상 ── */ +const calls = []; +const sections = [100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at })); +const revetLit = new Set([120]); +let cardSelected = "s120"; +let crossVisible = true; +let releaseCross; +const deps = { + sections: () => sections, + controls: { + revetOffset: { + highlightFor: (s) => revetLit.has(s.chainage_m), + selectedFor: (s) => revetLit.has(s.chainage_m), + select: (at) => revetLit.delete(at), + }, + ford: { selectedFor: () => false, select() {} }, + box: { selectedFor: () => false, select() {} }, + }, + releaseForm: () => calls.push("releaseForm"), + view: { + focusStation: (id) => { + calls.push(`focus:${id}`); + if (id === null && cardSelected !== null) { + cardSelected = null; + releaseCross(); // 카드 리스너(null) → 풀기 한 길 다시 부름 — 되돌아온 부름은 건너뜀 + } + }, + refreshCards: (list) => calls.push(`refresh:${list.join(",")}`), + selectMark: (id) => calls.push(`mark:${id}`), + }, + reapply: () => calls.push("reapply"), +}; +const crossDisposers = []; +const crossPick = SYNC.bindCrossPick( + PID, + { visible: () => crossVisible, onDispose: (fn) => crossDisposers.push(fn) }, + deps, +); +releaseCross = () => { + crossPick.release(); + sel(null, null, { from: "b06" }); +}; +sel(120, null, { from: "b06" }); +out.ownPick = calls.splice(0); +sel(140, null, { from: "esc" }); +out.otherPick = calls.splice(0); +releaseCross(); // 카드 다시 누름 · 목록 다시 누름 · 보기 오감 +out.release = calls.splice(0); +out.revetAfter = [...revetLit]; +revetLit.add(100); +cardSelected = "s100"; +sel(null, null, { from: "esc" }); // 다른 곳 풀기(Esc) +out.otherRelease = calls.splice(0); +crossVisible = false; +sel(100, null, { from: "esc" }); +out.hidden = calls.splice(0); +crossDisposers.forEach((fn) => fn()); + +/* ── ③ [상세] 선 조각 — 다시 그린 뒤 고름 유지 ── */ +let axes = { + maxOffset: 10, + pixelsPerMeter: 10, + displayMax: 110, + elevationMid: 100, + exaggeration: 1, +}; +const groundM = [ + [ + [-5, 100], + [0, 101], + ], + [ + [0, 101], + [5, 99], + ], +]; +const toPx = ([o, e]) => + [ + CROSS_PAD.left + (axes.maxOffset - o) * axes.pixelsPerMeter, + CROSS_PAD.top + (axes.displayMax - e) * axes.pixelsPerMeter, + ].join(","); +const layerChildren = []; +const layer = { + querySelectorAll: () => [...layerChildren], + append: (hit) => layerChildren.push(hit), +}; +const drawGround = () => { + layerChildren.length = 0; + for (const pts of groundM) { + const el = node("polyline"); + el.tagName = "polyline"; + el.setAttribute("class", "b06-chart__cross-profile"); + el.setAttribute("points", pts.map(toPx).join(" ")); + el.hasAttribute = () => false; + layerChildren.push(el); + } +}; +let click = null; +const cad = { + addEventListener: (_type, fn) => (click = fn), + querySelector: (s) => (s === "g.b06-chart__plot-layer" ? layer : null), +}; +const select = DTS.attachDetailSelect({ + cad, + design: () => undefined, + axes: () => axes, + picked() {}, + commit() {}, +}); +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() {} }); +out.detailPicked = select.source("length")?.pieces.length ?? 0; +axes = { ...axes, pixelsPerMeter: 23 }; // 창 크기 바뀜 → 다시 그림 +drawGround(); +select.redraw(); +out.detailAfterRedraw = select.source("length")?.pieces.length ?? 0; +groundM.splice(0, 1, [ + [-6, 100], + [0, 102], +]); // 그 조각 자리가 바뀌면 고름도 빠짐 +drawGround(); +select.redraw(); +out.detailAfterChange = select.source("length")?.pieces.length ?? 0; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_233_3_release.cjs b/resources/tester/helper_233_3_release.cjs new file mode 100644 index 000000000..ff6114e70 --- /dev/null +++ b/resources/tester/helper_233_3_release.cjs @@ -0,0 +1,171 @@ +/* 233-3 풀기 한 길(Esc) · 배수유역도 다시 얹음 — 진짜 `design_pick_escape` · `design_pick_profile` · + * `B06_Section_UI_Page_Pick_Sync` · `design_selection` 을 Node 로 돌림. 창 키 · 폼 · 지도 · 카드는 흉내. + * 표준출력 JSON 한 벌 · 짝 = test_233_3_release.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "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; +} + +/* 창 키 듣기 · 창(모달) 흉내 */ +const keyListeners = new Set(); +globalThis.window = { + addEventListener: (type, fn) => type === "keydown" && keyListeners.add(fn), + removeEventListener: (type, fn) => keyListeners.delete(fn), +}; +let modalOpen = false; +globalThis.document = { querySelector: () => (modalOpen ? {} : null) }; +const press = (key, { inInput = false, prevented = false } = {}) => { + const event = { + key, + defaultPrevented: prevented, + target: { closest: () => (inInput ? {} : null) }, + }; + for (const fn of [...keyListeners]) fn(event); +}; + +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 = "p233c"; +const sel = (chainageM, structureId = null, from = "b05") => + DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }, { + from, + }); + +/* ── 종단 보기(B05) — 종단도 · 배수유역도 · 좌측 폼 흉내 ── */ +const map = { pipe: null, mark: null, notifies: 0 }; +const profile = { station: null, structure: null }; +const form = { open: "wall", resets: 0 }; +let b05Visible = true; +const b05 = PP.bindProfilePick( + PID, + { + profile: { + setSelectedStation: (id) => Object.assign(profile, { station: id, structure: null }), + setSelectedStructure: (id) => Object.assign(profile, { station: null, structure: id }), + drainage: { + // 진짜 판 꼴 — 0.51 m 안 관을 고름 · notify 거짓이면 밖으로 안 알림 + selectPipeAtChainage: (at, notify = true) => { + map.pipe = at === null ? null : Math.abs(at - 80.17) < 0.51 ? 80.17 : null; + if (notify) map.notifies += 1; + }, + markStation: (at) => (map.mark = at), + }, + }, + form: { + hasSelection: () => form.open !== null, + selectById: () => { + form.open = null; + form.resets += 1; + }, + }, + }, + { visible: () => b05Visible, onDispose() {} }, + { + irregular: () => [{ id: "p80", chainage_m: 80.17, origin: "pipe" }], + pipes: () => [{ chainage_m: 80.17 }], + sections: () => [80, 100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at })), + }, +); + +/* ── 횡단 보기(B06) — 카드 · 형상 · 폼 흉내 ── */ +const cross = []; +let b06Visible = false; +SYNC.bindCrossPick( + PID, + { visible: () => b06Visible, onDispose() {} }, + { + sections: () => [], + controls: { + revetOffset: { highlightFor: () => false, selectedFor: () => false, select() {} }, + ford: { selectedFor: () => false, select() {} }, + box: { selectedFor: () => false, select() {} }, + }, + releaseForm: () => cross.push("releaseForm"), + view: { + focusStation: (id) => cross.push(`focus:${id}`), + refreshCards: () => undefined, + selectMark: (id) => cross.push(`mark:${id}`), + }, + reapply: () => cross.push("reapply"), + }, +); + +const snap = () => ({ + session: DSel.readSelection(PID), + profile: { ...profile }, + map: { ...map }, + form: { ...form }, +}); +const out = {}; +sel(80.17); +out.pipePick = snap(); +// 배수유역도 재계산이 관 · 유역 고름을 지움 → 같은 값으로 다시 얹음(알림 없이) +map.pipe = null; +map.mark = null; +b05.paint(); +out.afterRecompute = snap(); +press("Escape", { inInput: true }); +out.escInInput = snap(); +modalOpen = true; +press("Escape"); +modalOpen = false; +out.escModal = snap(); +press("Escape", { prevented: true }); +out.escPrevented = snap(); +press("Escape"); +out.esc = snap(); +out.crossWhileHidden = cross.splice(0); +// 목록 다시 누름 같은 B05 풀기(값 비움)는 폼을 그 길이 이미 닫음 — 구독은 폼을 또 닫지 않음 +form.open = "sign"; +sel(100, "sign"); +sel(null); +out.listRelease = snap(); +// 횡단 보기가 보일 때 Esc = 카드 · 폼 · 범위 · 형상 모두 풂 +b05Visible = false; +b06Visible = true; +sel(120, null, "b06"); +press("Escape"); +out.crossEsc = cross.splice(0); +out.crossSession = DSel.readSelection(PID); +out.listeners = keyListeners.size; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_233_4_empty_click.cjs b/resources/tester/helper_233_4_empty_click.cjs new file mode 100644 index 000000000..b474e6ed5 --- /dev/null +++ b/resources/tester/helper_233_4_empty_click.cjs @@ -0,0 +1,144 @@ +/* 233-4 빈 곳 누름 = 어디서도 안 풂 — 진짜 소스의 누름 처리 본문을 떼어(TS 그 자리 트랜스파일) 돌림. + * ① 종단 그래프 빈 곳 누름(`_Profile_Render` chartWrap click) ② 횡단 그리드 빈 곳 누름(`_Section_View` grid click) + * ③ 같은 것 다시 누름 = 풂(종단 측점선 `design_profile` selectStation · 횡단 카드 `_Section_View` selectStation) + * ④ Esc = 풂(`design_pick_escape` + `design_selection`). 표준출력 JSON 한 벌 · 짝 = test_233_4_empty_click.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const read = (...parts) => fs.readFileSync(path.join(ROOT, ...parts), "utf8").replace(/\r/g, ""); +const run = (code, names, values) => { + const js = ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + return new Function(...names, js)(...values); +}; +/** `start` 줄부터 같은 들여쓰기로 닫는 `end` 줄까지 떼어 냄. */ +const block = (src, start, end) => { + const from = src.indexOf(start); + if (from < 0) throw new Error(`본문 없음: ${start}`); + const to = src.indexOf(end, from); + return src.slice(from, to + end.length); +}; + +const out = {}; +const target = (inStation = false, inCard = false) => ({ + closest: (sel) => + (sel.includes("b06-chart__station") && inStation) || (sel.includes("b06-cross-card") && inCard) + ? {} + : null, +}); + +/* ① 종단 그래프 빈 곳 누름 */ +const render = read("B05_Profile", "B05_Profile_UI_Profile_Render.ts"); +const chartClick = block(render, ` chartWrap.addEventListener("click", (event) => {`, "\n });"); +const calls = []; +let tool = false; +const ctx = { + toolActive: () => tool, + handleToolPick: (at) => (calls.push(`tool:${at}`), true), + selectedStationId: () => "s120", + selectedStructureId: () => "wall", + selectStation: (id) => calls.push(`station:${id}`), + setSelectedStructureId: (id) => calls.push(`structure:${id}`), + redraw: () => calls.push("redraw"), +}; +const callbacks = { onStructureSelect: (id) => calls.push(`onStructureSelect:${id}`) }; +let handler = null; +const chartWrap = { + addEventListener: (_type, fn) => (handler = fn), + getBoundingClientRect: () => ({ left: 0 }), + scrollLeft: 0, +}; +run( + chartClick, + ["ctx", "callbacks", "chartWrap", "chainageInverter", "longitudinal", "width", "layout"], + [ctx, callbacks, chartWrap, () => (x) => x, null, 100, { originOffset: 0 }], +); +handler({ target: target(), clientX: 42 }); +out.profileEmpty = calls.splice(0); +tool = true; // 도구 중 빈 곳 누름은 도구 몫 그대로 +handler({ target: target(), clientX: 42 }); +out.profileEmptyTool = calls.splice(0); + +/* ② 횡단 그리드 빈 곳 누름 — 그리드에 걸린 누름 듣개를 모두 돌림 */ +const view = read("B06_Section", "B06_Section_UI_Section_View.ts"); +const gridStart = view.indexOf(' grid.className = "b06-section__grid";'); +const gridCode = view.slice(gridStart, view.indexOf(" // 열 수 = 사용자 값", gridStart)); // 그리드 누름 듣개 전부 +const gridCalls = []; +const grid = { listeners: [], addEventListener: (_t, fn) => grid.listeners.push(fn) }; +run( + gridCode.replace(' grid.className = "b06-section__grid";', ""), + ["grid", "clearSelection", "pavementAt", "spanMarks", "isPavement", "pavementSelectListener"], + [grid, () => gridCalls.push("clearSelection"), () => null, [], () => false, null], +); +for (const fn of grid.listeners) + fn({ target: target(), stopPropagation() {}, stopImmediatePropagation() {} }); +out.gridListeners = grid.listeners.length; +out.gridEmpty = gridCalls.splice(0); + +/* ③ 같은 것 다시 누름 = 풂 */ +const dpSrc = read("A00_Common", "design", "design_profile.ts"); +const dpSelect = block( + dpSrc, + " function selectStation(stationId: string | null): void {", + "\n }", +); +const profileState = run( + `let selectedStationId = null; let selectedStructureId = null; + ${dpSelect} + return { selectStation, line: () => selectedStationId };`, + ["structureIdAtStation", "draw", "onSelectStation"], + [() => null, () => undefined, () => undefined], +); +profileState.selectStation("s120"); +out.profileFirst = profileState.line(); +profileState.selectStation("s120"); +out.profileAgain = profileState.line(); + +const svSelect = block( + view, + " const selectStation = (stationId: string, scroll: boolean): void => {", + "\n };", +); +const cardState = run( + `let selectedStationId = null; let activeAreaKey = null; + ${svSelect} + return { selectStation, card: () => selectedStationId };`, + ["applySelection", "revealCard"], + [() => undefined, () => undefined], +); +cardState.selectStation("s120", true); +out.cardFirst = cardState.card(); +cardState.selectStation("s120", true); +out.cardAgain = cardState.card(); + +/* ④ Esc = 풂 */ +const store = new Map(); +const loadTs = (file, stubs) => { + const box = { exports: {} }; + const js = ts.transpileModule(read(...file.split("/")), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r]); + return box.exports; +}; +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (k, p) => store.get(`${k}:${p}`) ?? null, + writeState: (k, v, p) => store.set(`${k}:${p}`, v), + }, +}); +let onKey = null; +globalThis.window = { addEventListener: (_t, fn) => (onKey = fn), removeEventListener() {} }; +globalThis.document = { querySelector: () => null }; +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); +ESC.bindEscapeRelease("p", { visible: () => true, onDispose() {} }); +DSel.selectDesign("p", { chainageM: 120, structureId: null }, { from: "b06" }); +out.beforeEsc = DSel.readSelection("p"); +onKey({ key: "Escape", defaultPrevented: false, target: { closest: () => null } }); +out.afterEsc = DSel.readSelection("p"); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs new file mode 100644 index 000000000..e6717ba84 --- /dev/null +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -0,0 +1,192 @@ +/* 234-1 구조물 기준 측점 = 정수 m · 일반 측점 붙기 · [정수 맞춤]. + * 진짜 `common_util_structure_station` · `_Structures_Snap` · `_Structures_Fields` · `_Drainage_Pipes` · + * `followedStation` 을 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, 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} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/* 가짜 DOM — 폼 칸(input · label · span)만. */ +function fakeElement(tag) { + const listeners = {}; + const classes = new Set(); + return { + tagName: tag, + value: "", + className: "", + style: {}, + children: [], + classList: { + toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)), + add: (name) => classes.add(name), + remove: (name) => classes.delete(name), + contains: (name) => classes.has(name), + }, + append(...nodes) { + this.children.push(...nodes); + }, + addEventListener(type, fn) { + (listeners[type] ??= []).push(fn); + }, + fire(type) { + (listeners[type] ?? []).forEach((fn) => fn({ type })); + }, + focus() {}, + }; +} +global.document = { createElement: fakeElement }; + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const station = loadTs("common_util/common_util_structure_station.ts"); +const STATIONS = input.stations; +const out = {}; + +// ① 맞춤 · 걸음 — 파이썬과 같은 값(거울) +out.snap = input.samples.map((value) => station.snapStructureStation(value, STATIONS)); +out.step = input.steps.map(([from, dir]) => station.stepStructureStation(from, dir, STATIONS)); +out.snapTwice = input.samples.every( + (value) => + station.snapStructureStation(station.snapStructureStation(value, STATIONS), STATIONS) === + station.snapStructureStation(value, STATIONS), +); + +// 일반 측점 등록 — 구조물 측점(irregular)은 뺌 · 등록값이 기본으로 쓰임 +station.setGeneralStations( + STATIONS.map((value) => ({ chainage_m: value, kind: "regular" })).concat([ + { chainage_m: 80.17, kind: "irregular" }, + ]), +); +out.general = [...station.generalStations()]; +out.defaultSnap = station.snapStructureStation(80.4); + +// ② 좌측 폼 칸 — 친 값 80.17 → 80 · 안 고친 옛 값은 그대로 +const fields = loadTs("B05_Profile/B05_Profile_UI_Structures_Fields.ts", { + "./B05_Profile_Util_Station": loadTs("B05_Profile/B05_Profile_Util_Station.ts"), + "../B06_Section/B06_Section_UI_Cross_Culvert_Basin": { DEFAULT_BASIN_SHAPE: {} }, + "../common_util/common_util_culvert_sets": { fillPipeHeights: () => {} }, + "../common_util/common_util_structure_station": station, +}); +const anchor = fields.stationFields("기준 측점", () => 20); +out.remainderStep = anchor.remainder.step; +anchor.station.value = "4"; +anchor.remainder.value = "0.17"; +out.typed = anchor.read(20, true); +anchor.remainder.value = "0.6"; +out.typedNear = anchor.read(20, true); +anchor.remainder.value = "1"; +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", { + "@ui/ui_template_elements": { + showToast: (message, kind) => toasts.push({ message, kind }), + showConfirmDialog: async (message) => ((confirmText = message), true), + }, + "../common_util/common_util_structure_station": station, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "./B05_Profile_Api_Structures": { + structureAnchorM: (entry) => entry.chainage_m ?? entry.start_m ?? 0, + }, +}); +out.movedPoint = snap.movedStructure({ placement: "point", chainage_m: 127.86 }, 127.86); +out.movedWall = snap.movedStructure( + { placement: "interval", chainage_m: 80.17, start_m: 75.17, end_m: 95.67 }, + 80.17, +); +out.movedDrag = snap.movedStructure( + { placement: "interval", chainage_m: 50, start_m: 50, end_m: 62 }, + 133.4, +); + +const structures = input.structures.map((entry) => ({ ...entry })); +const pipes = input.pipes.map((chainage_m) => ({ + chainage_m, + facility: "pipe", + options: { pipe_kind: "흄관" }, + summary_item: null, + group_items: null, +})); +const plan = snap.snapPlan(structures, pipes); +out.plan = { + structures: plan.structures.map(({ index, next }) => ({ index, next })), + pipes: plan.pipes.map(({ from, to }) => [from, to]), + skipped: plan.skipped, +}; +const moves = []; +let emits = 0; +let closed = 0; +snap + .runSnap({ + 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.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] }; + + // ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘) + const alignment = loadTs("B05_Profile/B05_Profile_UI_Profile_Alignment.ts"); + const gone = [ + { id: "pipe-80.17", chainage_m: 80.17 }, + { id: "pipe-263.31", chainage_m: 263.31 }, + ]; + const born = [ + { id: "pipe-80.00", chainage_m: 80 }, + { id: "pipe-263.00", chainage_m: 263 }, + ]; + out.follow = gone.map((entry) => { + const old = alignment.followedStation(entry, gone, born); + const next = old ?? snap.followSnapped(entry, born); + return { old: old?.chainage_m ?? null, next: next?.chainage_m ?? null }; + }); + + // ⑤ 배수유역도 관 지점 — 옮김 · 우클릭 추가 = 정수 m + const pipesEditor = loadTs("B05_Profile/B05_Profile_UI_Drainage_Pipes.ts", { + "@ui/ui_template_palette": { themeColor: () => "#000" }, + "../common_util/common_util_structure_station": station, + }); + const editor = pipesEditor.createPipeEditor(() => {}); + editor.setContext(null, [ + { x: 0, y: 0, chainage_m: 0 }, + { x: 300, y: 0, chainage_m: 300 }, + ]); + editor.setPipes([{ chainage_m: 80.17, reason: "stream" }]); + editor.moveTo(0, 141.37); + editor.addAtChainage(200.6); + out.editor = editor.pipes().map((pipe) => pipe.chainage_m); + + 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_236_1_fill_sizes.cjs b/resources/tester/helper_236_1_fill_sizes.cjs new file mode 100644 index 000000000..64c240b5d --- /dev/null +++ b/resources/tester/helper_236_1_fill_sizes.cjs @@ -0,0 +1,72 @@ +/* 236-1 — 표 아래 안내 글이 한 줄일 때 창 끝까지 셈(`ui_template_data_table.ts` fillViewport)이 + * 창 크기마다 문서 맨 아래 = 창 끝(페이지 스크롤 0)이 되는지 단위검증 헬퍼. JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; + +const ONE_LINE = 18; // 안내 글 한 줄(caption 글꼴) 높이 +const win = { innerHeight: 0, scrollY: 0, addEventListener() {}, removeEventListener() {} }; +const observers = []; +class RO { + constructor(fn) { + observers.push(fn); + } + observe() {} + disconnect() {} +} +const el = (css = {}, height = 0) => ({ + css, + height, + top: 0, + parentElement: null, + nextElementSibling: null, + isConnected: true, + style: {}, + getBoundingClientRect() { + return { top: this.top, height: this.height }; + }, +}); +const sty = (n) => ({ + rowGap: n.css.rowGap ?? "normal", + paddingBottom: n.css.paddingBottom ?? "0px", + borderBottomWidth: n.css.borderBottomWidth ?? "0px", + marginBottom: n.css.marginBottom ?? "0px", +}); +const box = { exports: {} }; +new Function( + "exports", + "module", + "require", + "window", + "document", + "getComputedStyle", + "ResizeObserver", + js, +)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)), win, { body: null }, sty, RO); + +const out = []; +for (const h of [1080, 768, 600]) { + win.innerHeight = h; + observers.length = 0; + // 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [표 칸][안내 한 줄] + const body = el({ paddingBottom: "8px" }); + const card = el({ paddingBottom: "12px", rowGap: "8px" }); + const root = el(); + const hint = el({}, ONE_LINE); + card.parentElement = body; + root.parentElement = card; + hint.parentElement = card; + root.nextElementSibling = hint; + root.top = 200; + box.exports.fillViewport(root); + observers.forEach((fn) => fn()); + const grow = parseFloat(root.style.maxHeight); + const below = box.exports.spaceBelow(root); + out.push({ viewport: h, bottom: root.top + grow + below, grow, below }); +} +console.log(JSON.stringify(out)); diff --git a/resources/tester/helper_b05_drainage_report.cjs b/resources/tester/helper_b05_drainage_report.cjs index 402b32bba..c8aac2cfa 100644 --- a/resources/tester/helper_b05_drainage_report.cjs +++ b/resources/tester/helper_b05_drainage_report.cjs @@ -8,23 +8,24 @@ const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); +// 찾는 폭 한 값(234-1 · `PIPE_MATCH_M`) — 세션 저장소까지 끌고 오지 않게 값만 줌 +const localRequire = (request) => + request === "../A00_Common/design/design_selection" ? { PIPE_MATCH_M: 0.51 } : require(request); +new Function("exports", "module", "require", js)(box.exports, box, localRequire); const { buildPipeReports } = box.exports; const store = { get: (chainage) => - chainage === 100.5 ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } : null, + chainage === 100.5 + ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } + : null, }; const basins = [ { chainage_m: 40, pipe_diameter_mm: 800, design_flow_m3s: 1.2, bridge_required: false }, { chainage_m: 100.5, pipe_diameter_mm: 1000, design_flow_m3s: 3.4, bridge_required: true }, ]; const out = buildPipeReports( - [ - { chainage_m: 40.004, reason: "auto" }, - { chainage_m: 100.499 }, - { chainage_m: 200 }, - ], + [{ chainage_m: 40.004, reason: "auto" }, { chainage_m: 100.499 }, { chainage_m: 200 }], basins, store, ); diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 09c3e4f7c..1d65a9cc3 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -16,7 +16,11 @@ class FakeNode { this.tagName = tag; this.children = []; this.dataset = {}; - this.style = {}; + this.style = { + setProperty(name, value) { + this[name] = value; + }, + }; this.listeners = {}; this._text = ""; this._classes = new Set(); @@ -66,6 +70,10 @@ class FakeNode { (this.listeners.click || []).forEach((h) => h({ target: this })); } scrollIntoView() {} + // 고르기 칸 보기(syncChoices) — 자식 중 option 만 + get options() { + return this.children.filter((c) => c.tagName === "option"); + } // 시험에 필요한 두 가지 선택자만 — `[data-option-keys]` · `.클래스` querySelectorAll(selector) { const match = selector.startsWith("[") @@ -82,6 +90,9 @@ class FakeNode { } } globalThis.document = { createElement: (tag) => new FakeNode(tag) }; +globalThis.Option = function Option(text, value) { + return Object.assign(new FakeNode("option"), { _text: text, value }); +}; globalThis.Option = function Option(text, value) { const node = new FakeNode("option"); node._text = String(text); @@ -115,6 +126,10 @@ function loadTs(file) { if (STUBS[request]) return STUBS[request]; // 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠 if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); + // 공용 계산(@util/…) — tsconfig 별칭대로 common_util 폴더에서 읽음 + if (request.startsWith("@util/")) { + return loadTs(path.join(ROOT, "common_util", `${request.slice("@util/".length)}.ts`)); + } if (request.startsWith(".") && request.endsWith(".json")) { return JSON.parse(fs.readFileSync(path.join(path.dirname(full), request), "utf8")); } @@ -297,7 +312,12 @@ const draw = (includeDetail) => { }, { height_m: 2 }, ); - return { keys: optionInputs.map((e) => e.key), hidden: optionRow.hidden }; + return { + keys: optionInputs.map((e) => e.key), + // 그린 칸 — 숨긴 길이(54-26)는 값만 쥐고 칸은 안 그림 + drawn: optionRow.querySelectorAll("[data-option-keys]").map((e) => e.dataset.optionKeys), + hidden: optionRow.hidden, + }; }; out.drawB05 = draw(false); // B05 — 상세 칸 없음 + visible:false 숨김 out.drawB06 = draw(true); // B06 — 상세 칸까지, 단 visible:false 는 여기서도 숨김 diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 11887582b..5fb08a813 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -66,6 +66,9 @@ class E { setAttribute(k, v) { this.attrs[k] = String(v); } scrollIntoView() { this.scrolled = true; } matches(sel) { + if (sel.includes(",")) return sel.split(",").some((s) => this.matches(s.trim())); + const tag = sel.match(/^([a-z]+)([.[].*)$/); // tr.is-pick · tr[data-i="3"] + if (tag) return this.matches(tag[1]) && this.matches(tag[2]); if (sel.startsWith(".")) return this.cls.has(sel.slice(1)); const m = sel.match(/^\[data-([\w-]+)(?:="(.*)")?\]$/); const k = m ? m[1].replace(/-(\w)/g, (_, c) => c.toUpperCase()) : ""; @@ -75,11 +78,15 @@ class E { closest(sel) { for (let e = this; e; e = e.parent) if (e.matches(sel)) return e; return null; } all(sel, out = []) { for (const c of this.children) { if (c.matches(sel)) out.push(c); c.all(sel, out); } return out; } querySelector(sel) { return this.all(sel)[0] ?? null; } + querySelectorAll(sel) { return this.all(sel); } + get isConnected() { return true; } contains(x) { for (let e = x; e; e = e.parent) if (e === this) return true; return false; } get text() { return this.textContent + this.children.map((c) => c.text).join(""); } click() { const event = { target: this }; for (let e = this; e; e = e.parent) (e.on.click ?? []).forEach((f) => f(event)); } } -globalThis.document = { createElement: (t) => new E(t) }; +const docKeys = []; // 248-1 문서 keydown — key(이름, 대상) 로 부름 +globalThis.document = { createElement: (t) => new E(t), addEventListener: (t, f) => t === "keydown" && docKeys.push(f) }; +const key = (k, target = null) => docKeys.forEach((f) => f({ key: k, target })); globalThis.requestAnimationFrame = (f) => f(); """ diff --git a/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts b/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts new file mode 100644 index 000000000..54417bd46 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts @@ -0,0 +1,65 @@ +/* 맞춤 기본 = 가운데 · 가운데(237-1) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. + * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_align_default.py` 가 읽음) */ +import { + applyCellStyle, + shownHAlign, + shownVAlign, + type ResolvedBorders, +} from "../../../A00_Common/spreadsheet/spreadsheet_cellstyle"; +import { blockToClipboard } from "../../../A00_Common/spreadsheet/spreadsheet_clipboard"; +import type { CellStyle, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +let pass = 0; +const failures: string[] = []; +const eq = (name: string, got: unknown, want: unknown): void => { + if (JSON.stringify(got) === JSON.stringify(want)) pass += 1; + else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`); +}; + +// 도구줄 · 대화상자가 보이는 값 — 안 준 칸(없음 · 일반) = 가운데 · 준 칸 그대로 +eq("가로 없음", shownHAlign(undefined), "center"); +eq("가로 일반", shownHAlign("general"), "center"); +eq("가로 왼쪽", shownHAlign("left"), "left"); +eq("가로 오른쪽", shownHAlign("right"), "right"); +eq("가로 균등", shownHAlign("distributed"), "distributed"); +eq("세로 없음", shownVAlign(undefined), "center"); +eq("세로 위", shownVAlign("top"), "top"); +eq("세로 아래", shownVAlign("bottom"), "bottom"); + +// 칸 CSS — 가짜 칸(DOM 없이 style 만 봄) +function paint(style: CellStyle, hAlign: Parameters[3]) { + const text = { classList: { contains: () => true }, style: {} as Record }; + const cell = { + style: {} as Record, + firstElementChild: text, + replaceChildren: () => undefined, + }; + applyCellStyle(cell as unknown as HTMLElement, style, {} as ResolvedBorders, hAlign); + return { v: cell.style.justifyContent, h: text.style.textAlign }; +} +eq("칸 안 준 = 가운데 · 가운데", paint({}, "general"), { v: "center", h: "center" }); +eq("칸 왼쪽 · 아래 그대로", paint({ 세로: "bottom" }, "left"), { v: "flex-end", h: "left" }); +eq("칸 오른쪽 · 위 그대로", paint({ 세로: "top" }, "right"), { v: "flex-start", h: "right" }); + +// 내보내기(복사 HTML) — 안 준 칸 = 가운데 · 가운데 · 준 칸 그대로 +const book = { + 종류: "통합문서", + 판: 1, + 열: "rv", + 서식: [{}, { 가로: "left", 세로: "top" }], + 시트: [{ id: "s1", 이름: "시트1", 칸: { A1: { 값: "가" }, B1: { 값: "나", 서식: 1 } } }], + 활성: "s1", +} as unknown as Workbook; +const { html } = blockToClipboard(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 1 }, (_r, c) => + c === 0 ? "가" : "나", +); +const tds = [...html.matchAll(/]*style="([^"]*)"/g)].map((m) => m[1]); +eq("내보내기 칸 수", tds.length, 2); +eq( + "내보내기 안 준 칸", + /text-align:center/.test(tds[0]) && /vertical-align:middle/.test(tds[0]), + true, +); +eq("내보내기 준 칸", /text-align:left/.test(tds[1]) && /vertical-align:top/.test(tds[1]), true); + +console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_106_span_highlight_wording.py b/resources/tester/test_106_span_highlight_wording.py index e0b5f9c97..c3275bb7a 100644 --- a/resources/tester/test_106_span_highlight_wording.py +++ b/resources/tester/test_106_span_highlight_wording.py @@ -40,7 +40,10 @@ def test_측점_고름은_카드_하나_목록_고름은_여럿(): """137-4 — 측점 고름 리스너는 span 끔 · 목록(노면공) 길 · 알약은 기본(여럿) 그대로.""" cross = _read("A00_Common", "design", "design_view_cross.ts") assert "sectionView.selectMark(mark, false);" in cross # 152-5 덮는 것 없으면 null = 풂 - assert "markStructure: (structureId) => sectionView.selectMark(structureId)," in cross + # 목록 길 = 기본(여럿) · 측점 고름이 연 폼만 span 끔(231-1) + assert ( + "markStructure: (structureId, span) => sectionView.selectMark(structureId, span)," in cross + ) types = _read("B06_Section", "B06_Section_UI_Section_View_Types.ts") assert "selectMark: (structureId: string | null, span?: boolean) => void;" in types diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index fa255f5d9..3e87c8bfd 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -140,7 +140,7 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8") assert "leftPanel: createOptionsPanel(projectId, () => select(current))" in page # 160-1 — 메인 탭 차례 = 구조물 집계표 · 자재 · 토공 · 수리 · 카드도 같은 차례(1본 → 집계표 · 할증 → 자재) - tabs = ("summary", "material", "earthwork", "hydrology") + tabs = ("summary", "material", "earthwork", "hydrology", "milestone") # 244-1 거리이정표 ids = re.findall(r'\{ id: "(\w+)", label: "B08_Quantity_Tab_', page) assert ids == [ *tabs, @@ -149,8 +149,10 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): "quantity", ] # 자재 탭 안 = 총괄 · 콘크리트소요 · 자재수량 assert 'select("material");' in page and 'select("summary");' in page - assert [panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in tabs] == sorted( - panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in tabs + cards = tabs[:-1] # 244-6 — 거리이정표 카드 없음(다이어그램 모달) + assert 'card("milestone"' not in panel + assert [panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in cards] == sorted( + panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in cards ) assert 'card("summary", "B08_Quantity_Tab_StructureSummary", one.root),' in panel assert 'card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock),' in panel diff --git a/resources/tester/test_160_3_b08_unmatched_total.py b/resources/tester/test_160_3_b08_unmatched_total.py index 2bb24e775..eacc1d617 100644 --- a/resources/tester/test_160_3_b08_unmatched_total.py +++ b/resources/tester/test_160_3_b08_unmatched_total.py @@ -68,9 +68,7 @@ def test_알림에_맞춤_없는_이름(tmp_path, monkeypatch): monkeypatch.setattr(router, "structure_summary", summary) monkeypatch.setattr(router, "snapshot_units", lambda root, s: SUMMARY["snapshot"]) got = asyncio.run(router._sheets("p", tmp_path, clean_overrides({}))) - assert got["notes"] == [ - "2공구 열 맞춤 없음 2종(총괄 끝 줄 그대로) — 이형철근 D13 ton · 토목용부직포 ㎡" - ] + assert got["notes"] == ["2공구 열 맞춤 없음 2종 — 이형철근 D13 ton · 토목용부직포 ㎡"] SC = {"층": "project", "주인": None, "키": "SC-000001"} diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index 45a41bbca..8941dc397 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -194,7 +194,7 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path): assert qty["LB000033"] >= round(10 * 0.195, 2) # 석공 = 손 줄 수량 × 0.195(+ 낙석망) -def test_우클릭_메뉴_줄_추가_지우기_연결_TS(): +def test_우클릭_메뉴_항목_추가_줄_삭제_연결_TS(): # 184-3 — 모든 줄 메뉴 · 셈 출력 줄 = 계약 꼴(parent · auto · auto_{칸}) · 첫 손질 = v2 로 굳힘 src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) rows = [ @@ -219,24 +219,24 @@ const run = (i, label) => {{ return user.bill; }}; const out = {{ title: labels(1), auto: labels(2), hand: labels(3) }}; -out.above = run(2, "위에 줄 추가"); +out.below = run(2, "항목 추가"); out.reset = run(2, "자동값으로 되돌리기"); -out.left = run(3, "줄 지우기"); +out.left = run(3, "줄 삭제"); console.log(JSON.stringify(out)); """ out = json.loads(_node(src + body)) - add = ["위에 줄 추가", "아래에 줄 추가"] - assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"] - assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←", - "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip + # 246-1 더함 = 「항목 추가」 하나 · 지움 = 「줄 삭제」 · 2단 제목 ← = 1단으로 + assert out["title"] == ["항목 추가", "-", "내어쓰기 ←", "-", "줄 삭제"] + assert out["auto"] == ["항목 추가", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←", + "-", "자동값으로 되돌리기", "줄 삭제"] # fmt: skip # 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음) - assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑", - "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip - # 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) - above = out["above"] - assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"] - new, earth, hand = above["rows"][2:] - assert (new["name"], new["parent"], above["v"]) == ("새 줄", "sec:1.2", 2) + assert out["hand"] == ["항목 추가", "-", "연결", "연결 풀기", "-", "위로 ↑", + "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip + # 항목 추가(항목 줄) = 바로 아래 같은 부모 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) + below = out["below"] + assert [e["key"][:5] for e in below["rows"]] == ["sec:1", "sec:1", "earth", "hand:", "hand:"] + earth, new, hand = below["rows"][2:] + assert (new["name"], new["parent"], below["v"]) == ("새 줄", "sec:1.2", 2) assert earth == {"key": "earth:4", "parent": "sec:1.2", "qty": 100} assert (hand["key"], hand["qty"], hand["ref"]) == ("hand:b", 4, HAND[1]["ref"]) assert "qty" not in out["reset"]["rows"][2] # 자동값으로 = 수량 덮개 지움 diff --git a/resources/tester/test_171_1_manual_input_label.py b/resources/tester/test_171_1_manual_input_label.py index de80196ea..9cd4645bc 100644 --- a/resources/tester/test_171_1_manual_input_label.py +++ b/resources/tester/test_171_1_manual_input_label.py @@ -14,8 +14,8 @@ SPOTS = { "B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Sangeun.py": ['"수기 줄" if'], - "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"], - "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'], + "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가는 수기 입력"], + "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"'], "ui_template/ui_template_locale_b2.ts": [ "수량 원천: 수기 입력(직접비)", "수량 원천: 수기 입력 공종", diff --git a/resources/tester/test_171_2_group_cards.py b/resources/tester/test_171_2_group_cards.py index 3a5fe8211..a57b32cff 100644 --- a/resources/tester/test_171_2_group_cards.py +++ b/resources/tester/test_171_2_group_cards.py @@ -122,9 +122,9 @@ def test_묶음_많아도_접힘_없음_고른_박스_붉은_외곽선(): " foot: cards.every((c) => c.querySelector('.ui-group-card__foot'))," " focus: [focused.classList.contains('is-focus'), cards[4].scrolled === true] }));" ) - # 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 그 줄 강조 · 박스 스크롤 + # 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 박스 스크롤(248-1 줄 강조 안 붙임) assert out["first"] == [False] * 4 + [True] + [False] * 8 - assert out["focus"] == [True, True] + assert out["focus"] == [False, True] assert out["closed"] == [False] * 13 # 13 묶음도 접힘 없음 assert out["picks"] == [10] # 머리 누름 = 이동(표 줄 번호 그대로) assert out["buttons"] == ["1", "2", "3", "4"] # 모두 펼침 · 접기 단추 없음 diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index 4ac6abbc9..139aab8d6 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -149,10 +149,10 @@ def test_화면_보기_바꿈_누름_이동(): "\nconst [view] = renderGroupPanel(o);" "\nconst btn = calls[0].find((e) => e.dataset.b09GroupView);" "\nconst label = btn.textContent;" - "\nview.querySelector('[data-at=\"2\"]').click();" - "\nview.querySelector('[data-at=\"0\"]').click();" "\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');" "\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');" + "\nview.querySelector('[data-at=\"2\"]').click();" + "\nview.querySelector('[data-at=\"0\"]').click();" "\nbtn.click();" "\nconst [view2] = renderGroupPanel(o);" "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws," @@ -162,7 +162,7 @@ def test_화면_보기_바꿈_누름_이동(): ) assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드 assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동 - assert out["lit"] is True # 이동해 온 줄(키 DG1|1 = 표 3번째 줄) 강조 + assert out["lit"] is False # 248-1 이동해 온 줄 = 빨강 하나(줄 강조 안 붙임) assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2) assert out["redraws"] == 1 assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3] diff --git a/resources/tester/test_177_4_pavement_span_box.py b/resources/tester/test_177_4_pavement_span_box.py index b604678ee..324224a05 100644 --- a/resources/tester/test_177_4_pavement_span_box.py +++ b/resources/tester/test_177_4_pavement_span_box.py @@ -71,4 +71,5 @@ def test_스크롤만_길은_리스너를_안_거침(): assert "if (revealOnly) sectionView.revealStation(nearest.station_id);" in cross panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") assert "const pave = !coverable(structure);" in panel - assert 'if (pave) selectDesign(deps.projectId, at, { from: "b06" });' in panel + # 목록 고름은 노면공뿐 아니라 늘 고름 기록(231-2) + assert 'if (!openingAtStation) selectDesign(deps.projectId, at, { from: "b06" });' in panel diff --git a/resources/tester/test_181_2_b09_box_pick.py b/resources/tester/test_181_2_b09_box_pick.py index f078bc23f..0233c6458 100644 --- a/resources/tester/test_181_2_b09_box_pick.py +++ b/resources/tester/test_181_2_b09_box_pick.py @@ -71,6 +71,8 @@ def test_글_접기_없음_CSS_잇기(): page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") for sel in ("tr.is-pick td", "tr.is-pick.is-pick-top td", "tr.is-pick.is-pick-end td"): assert sel in page - assert page.count("var(--color-danger)") == 4 # 위 · 아래 · 왼 · 오른 2px + assert ( + page.count("var(--color-danger)") == 5 + ) # 위 · 아래 · 왼 · 오른 2px + 204-1 끌기 못 놓음 점선 shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert "extra?: (index: number) => string | null" in shell diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index cccc9ecf4..1ae488159 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -189,15 +189,15 @@ const out = { g1: labels("hand:g1"), a: labels("hand:a"), g2: labels("hand:g2"), b: labels("hand:b"), x: labels("hand:x"), auto: labels("earth:4"), }; -out.child = order(run("hand:g2", "아래에 하위 줄 추가")); -out.below = order(run("hand:g1", "아래에 줄 추가")); -out.belowB = order(run("hand:b", "아래에 줄 추가")); +out.child = order(run("hand:g2", "항목 추가")); +out.below = order(run("hand:g1", "항목 추가")); +out.belowB = order(run("hand:b", "항목 추가")); out.indentBill = run("hand:x", "들여쓰기 →"); out.indent = order(out.indentBill); out.outdent = order(run("hand:g2", "내어쓰기 ←")); out.toItem = run("hand:g2", "항목으로 바꾸기").rows.filter((r) => ["hand:g2", "hand:b"].includes(r.key)); out.toGroup = run("hand:x", "묶음 줄로 바꾸기").rows.find((r) => r.key === "hand:x"); -out.removed = order(run("hand:g1", "줄 지우기")); +out.removed = order(run("hand:g1", "줄 삭제")); console.log(JSON.stringify(out)); """ @@ -221,15 +221,16 @@ def test_우클릭_레벨_메뉴_TS(): out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) + body)) level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"} pick = lambda key: [x for x in out[key] if x in level] # noqa: E731 - assert out["g1"][:3] == ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가"] + assert [x for x in out["g1"] if "추가" in x] == ["항목 추가"] # 246-1 더함 하나 assert "연결" not in out["g1"] and pick("g1") == ["내어쓰기 ←", "항목으로 바꾸기"] assert pick("a") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 없음 → 들여쓰기 없음 assert pick("g2") == ["내어쓰기 ←", "항목으로 바꾸기"] # 위 형제 a = 항목 assert pick("b") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 레벨 5 묶음까지 assert pick("x") == ["들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 g1 - assert pick("auto") == ["내어쓰기 ←"] and "줄 지우기" in out["auto"] # 자동 줄도 지움 - assert out["child"] == _put(BASE, 6, ["NEW", "hand:g2"]) # g2 마지막 자식 뒤 - assert out["below"] == _put(BASE, 6, ["NEW", "sec:1.2"]) # g1 자손 끝 뒤 · 같은 부모 + assert pick("auto") == ["내어쓰기 ←"] and "줄 삭제" in out["auto"] # 자동 줄도 지움 + # 246-1 항목 추가 — 폴더 = 그 폴더 첫 하위 · 항목 = 바로 아래 같은 부모 + assert out["child"] == _put(BASE, 4, ["NEW", "hand:g2"]) + assert out["below"] == _put(BASE, 2, ["NEW", "hand:g1"]) assert out["belowB"] == _put(BASE, 5, ["NEW", "hand:g2"]) assert out["indent"] == [*BASE[:6], ["hand:x", "hand:g1"], BASE[7]] # g1 마지막 자식 assert out["outdent"] == [*BASE[:3], ["hand:g2", "sec:1.2"], *BASE[4:]] # g1 뒤 @@ -311,7 +312,7 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함 assert out["cls"] == [ "is-title", - "is-group is-hand", + "is-title is-hand", "is-hand", None, ] # fmt: skip 201-1 레벨 = 공용 표 level @@ -320,10 +321,10 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): picks = [ "일위대가 호표", "단가산출 호표", - "서고 구조물조합(마스터 · 회사 · 개인)", - "서고 일위대가 사전조합(마스터 · 회사 · 개인)", - "단가산출 로직(M01 · 새 산근)", - ] # 204-2 + "구조물 조합", + "일위대가 사전조합", + "단가산출", + ] # 204-2 · 246-1 이름 셋 # 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 unlink = [10, 40, [*picks, "-", "연결 풀기"]] assert out["menus"] == [unlink, unlink, [10, 40, picks]] diff --git a/resources/tester/test_183_2_b09_compare_head.py b/resources/tester/test_183_2_b09_compare_head.py index 21fd23dcb..97ff1246e 100644 --- a/resources/tester/test_183_2_b09_compare_head.py +++ b/resources/tester/test_183_2_b09_compare_head.py @@ -27,4 +27,4 @@ def test_견적은_단가_견적서_그_밖은_단가_하나(): def test_견적_면_수기_칸_없앰(): body = _compare() assert 'key === "g:견적"' not in body and "quote_page" not in body - assert "견적 단가 = 수기 입력" in body and "견적 단가 · 면" not in body + assert "견적 단가는 수기 입력" in body and "견적 단가 · 면" not in body diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index 653d8a64d..e7e72e92b 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -1,4 +1,4 @@ -"""184-3 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 지움 → gone · 옮김 · 새 공종 · +"""184-3 · 246-1 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 삭제 → gone · 옮김 · 새 공종(←) · 자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [내역 초기화] 배선. - 셈 출력 줄 = 계약 꼴(`Calc_Bill.py` 머리 · parent · auto · auto_{칸} · level · no · cost) 가짜 줄 @@ -119,26 +119,26 @@ ORDER = [(r["key"], r["parent"]) for r in ROWS if r["key"] != "fee"] def test_메뉴_모든_줄(): out = _menu({}, labels=["sec:1", "earth:4", "earth:5", "hand:g", "hand:a", "fee", "mat:M2", "mat:M1", "sec:G"])["labels"] # fmt: skip - add = ["위에 줄 추가", "아래에 줄 추가"] - # 맨 위 제목 = 하위 줄 · 새 공종 · 아래로(자식 있는 맨 위 = 못 지움) - assert out["sec:1"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "아래로 ↓"] - # 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음) - assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", - "아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip + add = ["항목 추가"] # 246-1 더함 하나 + # 맨 위 제목 = 항목 추가(첫 하위) · 아래로(자식 있는 맨 위 = 못 지움 · 맨 위 = 내어쓰기 없음) + assert out["sec:1"] == [*add, "-", "아래로 ↓"] + # 자동 항목 = 더함 · 연결 · 옮김 · 삭제(고친 칸 없음 = 자동값으로 없음) + assert out["earth:4"] == [*add, "-", "연결", "연결 풀기", "-", + "아래로 ↓", "내어쓰기 ←", "-", "줄 삭제"] # fmt: skip # 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음) - assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓", + assert out["earth:5"] == [*add, "-", "연결", "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기", - "줄 지우기"] # fmt: skip - assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑", - "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-", - "줄 지우기"] # fmt: skip - assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←", - "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip - # 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음 - assert out["fee"] == ["위에 줄 추가", "새 공종 추가(1단)"] # 조달수수료 = 관급자재대 끝 고정 - assert out["mat:M2"] == [*add, "새 공종 추가(1단)"] - assert out["mat:M1"] == [*add, "새 공종 추가(1단)"] - assert out["sec:G"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)"] + "줄 삭제"] # fmt: skip + assert out["hand:g"] == [*add, "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", + "항목으로 바꾸기", "-", "줄 삭제"] # fmt: skip + assert out["hand:a"] == [*add, "-", "연결", "연결 풀기", "-", "내어쓰기 ←", + "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip + # 조달수수료 · 관급 자재 = 연결 · 옮김 · 삭제 없음 · 관급자재대 제목 = 옮김 · 삭제 없음 · + # 자재 줄 = 1단으로 못 냄(별도 자재 mat:M1 도) + assert out["fee"] == add # 조달수수료 = 관급자재대 끝 고정 + assert out["mat:M2"] == add + assert out["mat:M1"] == add + assert out["sec:G"] == add def test_칸_고침_덮개_옛_꼴_굳힘(): @@ -166,38 +166,34 @@ V2 = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k if k != "hand:b"], "gone": []}} # fmt: skip -def test_지움_옮김_새_공종_자동값으로(): +def test_삭제_옮김_새_공종_자동값으로(): user = {"bill": {**V2["bill"], "rows": [ {**e, **({"qty": 9} if e["key"] == "earth:5" else {})} for e in V2["bill"]["rows"]]}} # fmt: skip - runs = [("earth:4", "줄 지우기"), ("hand:g", "줄 지우기"), ("earth:5", "위로 ↑"), - ("earth:4", "아래로 ↓"), ("earth:4", "새 공종 추가(1단)"), - ("mat:M2", "새 공종 추가(1단)"), ("earth:5", "자동값으로 되돌리기"), - ("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "아래에 하위 줄 추가"), - ("earth:4", "위에 줄 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"), - ("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "위에 줄 추가"), - ("fee", "새 공종 추가(1단)")] # fmt: skip + runs = [("earth:4", "줄 삭제"), ("hand:g", "줄 삭제"), ("earth:5", "위로 ↑"), + ("earth:4", "아래로 ↓"), ("sec:1.2", "내어쓰기 ←"), + ("mat:M2", "내어쓰기 ←"), ("earth:5", "자동값으로 되돌리기"), + ("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "항목 추가"), + ("earth:4", "항목 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"), + ("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 · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘 base = keys(out["earth:5|위로 ↑"]) assert "hand:b" in base and base.index("hand:b") == base.index("hand:a") + 1 # 지움 — 자동 줄 = gone · 새 줄 = 자식은 지운 줄 부모로 - b = out["earth:4|줄 지우기"] + b = out["earth:4|줄 삭제"] assert b["gone"] == ["earth:4"] and "earth:4" not in keys(b) - b = out["hand:g|줄 지우기"] + b = out["hand:g|줄 삭제"] assert b["gone"] == [] and ("hand:a", "sec:1.2") in _order(b) and "hand:g" not in keys(b) # 위로 · 아래로 = 같은 부모 형제와 자리 바꿈 for k in ("earth:5|위로 ↑", "earth:4|아래로 ↓"): got = keys(out[k]) assert got[got.index("earth:5") + 1] == "earth:4" - # 새 공종 = 맨 위 묶음 · cost direct · 그 맨 위 공종 뒤 / 관급자재대 앞 - b = out["earth:4|새 공종 추가(1단)"] - new = [e for e in b["rows"] if e["key"].startswith("hand:") and e.get("cost")][0] - assert (new["kind"], new["cost"], new["name"], "parent" in new) == ( - "group", "direct", "새 공종", False) # fmt: skip - assert keys(b)[keys(b).index(new["key"]) + 1] == "sec:4" - b = out["mat:M2|새 공종 추가(1단)"] - assert keys(b)[keys(b).index("sec:G") - 1].startswith("hand:") + # 246-1 새 공종 = 2단을 ← 로 맨 위(cost direct · 옛 부모 바로 뒤) · 관급 자재 = 못 냄 + b = out["sec:1.2|내어쓰기 ←"] + assert [k for k, p in _order(b) if p is None] == ["sec:1", "sec:1.2", "sec:4", "sec:G"] + assert {e["key"]: e for e in b["rows"]}["sec:1.2"]["cost"] == "direct" + assert out["mat:M2|내어쓰기 ←"] is None # 자동값으로 = 고친 칸 지움(차례 · 부모 그대로) b = out["earth:5|자동값으로 되돌리기"] assert {e["key"]: e for e in b["rows"]}["earth:5"] == {"key": "earth:5", "parent": "sec:1.2"} @@ -207,23 +203,22 @@ def test_지움_옮김_새_공종_자동값으로(): assert got[got.index(("hand:g", "sec:1.2")) + 1] == ("hand:a", "sec:1.2") got = _order(out["earth:4|내어쓰기 ←"]) # 1.1 → 1 아래(제목 뒤) assert got[got.index(("sec:1.2", "sec:1")) + 1 :].index(("earth:4", "sec:1")) >= 0 - # 하위 줄 = 마지막 자식 뒤 · 위에 줄 = 같은 부모 앞 · 새 줄 = kind 없음(항목) - got = out["sec:1.2|아래에 하위 줄 추가"]["rows"] + # 246-1 항목 추가 — 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모 · 새 줄 = kind 없음(항목) + got = out["sec:1.2|항목 추가"]["rows"] kids = [e for e in got if e.get("parent") == "sec:1.2"] - assert kids[-1]["name"] == "새 줄" and "kind" not in kids[-1] - got = out["earth:4|위에 줄 추가"]["rows"] + assert kids[0]["name"] == "새 줄" and "kind" not in kids[0] + got = out["earth:4|항목 추가"]["rows"] at = [e["key"] for e in got].index("earth:4") - assert (got[at - 1]["name"], got[at - 1]["parent"]) == ("새 줄", "sec:1.2") + assert (got[at + 1]["name"], got[at + 1]["parent"]) == ("새 줄", "sec:1.2") a = {e["key"]: e for e in out["hand:a|묶음 줄로 바꾸기"]["rows"]}["hand:a"] assert (a["kind"], a["qty"], a["ref"]) == ("group", None, None) 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 - # 조달수수료 위 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) · 새 공종 = 관급자재대 앞 - got = out["fee|위에 줄 추가"]["rows"] + # 조달수수료 항목 추가 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) + got = out["fee|항목 추가"]["rows"] assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2") - assert keys(out["fee|새 공종 추가(1단)"])[-3].startswith("hand:") # … 새 공종 · sec:G · mat:M2 def test_관급_옮김_막힘_깊이_5(): @@ -237,15 +232,15 @@ def test_관급_옮김_막힘_깊이_5(): out = _menu(user, labels=["sec:4", "hand:n"], rows=rows)["labels"] assert "들여쓰기 →" not in out["sec:4"] # 별도 자재가 관급자재대 안으로 → 막음 assert "들여쓰기 →" in out["hand:n"] # 자재 없는 묶음 = 들여쓰기 됨(위 형제 4 자재대) - # 깊이 5 — 레벨 5 묶음에 하위 줄 · 레벨 5 줄 들여쓰기 = 막음 + # 깊이 5 — 레벨 5 묶음에 항목 추가(첫 하위) · 레벨 5 줄 들여쓰기 = 막음 deep = [ _row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"), _row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"), _row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5), ] # fmt: skip out = _menu({}, labels=["hand:g3", "hand:z", "hand:g2"], rows=deep)["labels"] - assert "아래에 하위 줄 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"] - assert "아래에 하위 줄 추가" in out["hand:g2"] + assert "항목 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"] + assert "항목 추가" in out["hand:g2"] TABLE_JS = """ @@ -279,7 +274,7 @@ def test_표_칸_강조_번호_연결_칸(): assert out["cls"] == ["is-edited", None, None] assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]" assert out["edit"] == [True, False, True, True, False] - assert "엑셀 꼴 고정" not in out["hint"] and "내역 초기화" in out["hint"] + assert "엑셀 꼴 고정" not in out["hint"] and "우클릭" in out["hint"] assert out["renamed"] == "절토공" # 번호는 떼고 이름만 diff --git a/resources/tester/test_185_1_b09_total_level.py b/resources/tester/test_185_1_b09_total_level.py index 6608e4ae1..896400301 100644 --- a/resources/tester/test_185_1_b09_total_level.py +++ b/resources/tester/test_185_1_b09_total_level.py @@ -71,6 +71,6 @@ def test_CSS_옛_레벨_규칙_없음_공용_표로(): assert "is-indent" not in css and "is-flat" not in css and "is-lv" not in css common = (ROOT / "ui_template" / "ui_template_data_table.css").read_text(encoding="utf-8") assert re.search( - r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);", + r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* var\(--dt-indent\)\);", common, ) diff --git a/resources/tester/test_198_1_b08_summary_lamp.py b/resources/tester/test_198_1_b08_summary_lamp.py index 184e9396d..2088141f8 100644 --- a/resources/tester/test_198_1_b08_summary_lamp.py +++ b/resources/tester/test_198_1_b08_summary_lamp.py @@ -39,7 +39,7 @@ def _lamp_class() -> str: def test_미연결_줄_측점_칸만_램프(): - cell = re.search(r"cellClass: (\(i, key\) => .*?),\n", UI) + cell = re.search(r"cellClass: (\(i, key\) =>.*?),\n\s*frozen", UI, re.S) assert cell table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts")) got = _node( @@ -50,6 +50,8 @@ new Function("exports", "module", "require", js(fs.readFileSync({table}, "utf8") box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p))); const LAMP_CLASS = {json.dumps(_lamp_class())}; const missing = ["", "BOX", ""]; +const linked = [false, false, false]; +const LINKED_CLASS = LAMP_CLASS + " is-linked"; const cellClass = {cell.group(1)}; const html = box.exports.renderDataTableHtml({{ columns: [{{ key: "station", label: "측점" }}, {{ key: "c1", label: "조합" }}], diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 3a544d33c..8da7177e0 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -1,5 +1,6 @@ """200-2 B09 설계내역서 레벨 글 · 레벨 도구 줄 — 글 칸 왼쪽 맞춤 · 레벨 = 공종 · 명칭 칸 padding 들여쓰기 · -글자 크기 한 벌 · 단추 넷(고른 줄 · 못 하면 disabled) · 키 셋(Tab · Shift+Tab · Alt+↑↓) · 편집 중엔 키 안 먹음. +글꼴 = 공용 표 레벨 글꼴(228-1 · 레벨 · 묶음 따라) · 단추 넷(고른 줄 · 못 하면 disabled) · +키 셋(Tab · Shift+Tab · Alt+↑↓) · 편집 중엔 키 안 먹음. - 단추 · 키 = 우클릭 메뉴(billMenu) 같은 항목을 부름 — 줄 꼴 · 저장 꼴은 184-3 시험 그대로 - 표 · 문서 = 가짜 DOM(줄 tr · 글 칸 input) · 다시 그림 = buildBill + levelBar 다시 @@ -67,7 +68,7 @@ const draw = () => { const at = (key) => table.rows.findIndex((r) => r.key === key); const tr = (key) => root.kids[at(key)]; const pick = (key) => root.fire("click", { target: tr(key).kids[0] }); -const buttons = () => bar.kids.filter((k) => k.tagName === "BUTTON"); +const buttons = () => bar.tools; const off = () => buttons().map((b) => b.disabled); const order = () => (user.bill?.rows ?? []).map((e) => [e.key, e.parent ?? null]); const key = (k, more = {}) => { @@ -78,7 +79,7 @@ const key = (k, more = {}) => { const out = {}; draw(); out.labels = buttons().map((b) => [b.label, b.dataset.b09Level, b.variant]); -out.guide = bar.kids.at(-1).textContent; +out.guide = bar.guide.textContent; out.hover = hovers.length; out.none = off(); pick("earth:4"); @@ -140,8 +141,10 @@ def test_단추_넷_고른_줄_못_하면_흐림(): out = _bar() assert out["labels"] == [["→ 들여쓰기", IN, "ghost"], ["← 내어쓰기", OUT, "ghost"], ["↑ 위로", UP, "ghost"], ["↓ 아래로", DOWN, "ghost"]] # fmt: skip - assert "Tab" in out["guide"] and "Shift+Tab" in out["guide"] and "Alt+↑↓" in out["guide"] - assert out["hover"] == 1 # 안내 한 줄 = 공용 호버로 자세히 + assert ( + "방향키" in out["guide"] and "더블클릭" in out["guide"] and "=" not in out["guide"] + ) # 236-2 + assert out["hover"] == 1 # 안내 한 줄 = 잘릴 때만 공용 호버(236-2) assert out["none"] == [True] * 4 # 고른 줄 없음 = 모두 흐림 # earth:4 = 1.1 첫 줄 — 들여(위 형제 없음) · 위로 = 못 함 · 내어 · 아래로 = 됨(184-3 메뉴와 같음) assert out["earth4"] == [True, False, True, False] @@ -173,24 +176,31 @@ def test_키_셋_편집_중엔_안_먹음(): assert out["keyAct"] == [IN, OUT, UP, DOWN, None, UP, None] -def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌(): +def test_왼쪽_맞춤_레벨_들여쓰기_글꼴_공용_표(): css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") bill = '.b09-estimation [data-b09-table="bill"]' - # 201-1 레벨 들여쓰기 = 공용 표 level 옵션(test_201_1) · 레벨별 글자 크기 규칙 없음(한 벌) + # 레벨 들여쓰기 · 글꼴 = 공용 표 level 옵션(test_201_1 · test_228_1) · 페이지 CSS 에 따로 두지 않음 assert "is-lv" not in css and "font-size: 13px" not in css + common = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + assert "td.ui-data-table__lv.is-lv1.is-node" in common src = (B09 / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8") - assert 'level: { key: "name", of: (i) => rows[i]?.level' in src + assert "of: (i) => rows[i]?.level as number | undefined," in src + assert 'group: (i) => rows[i]?.kind !== "item",' in src # 글 칸 왼쪽 맞춤(수량만 오른쪽) assert re.search( re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;", css, ) - assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css) + assert ".b09-estimation__level" not in css # 229-1 표 위 도구 줄 없앰 def test_셸_배선(): src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src - assert "...(level ? [level] : []), table.root" in src + assert ( + "bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in src + ) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나) + assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 + assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수 + assert "b09-estimation__level" not in lvl diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py index dbcf7093d..de42f1eea 100644 --- a/resources/tester/test_201_1_table_level_merge.py +++ b/resources/tester/test_201_1_table_level_merge.py @@ -1,6 +1,7 @@ """201-1 공용 표 level(보기 전용) · mergeSame(같은 값 세로 병합) 옵션 + B08 · B09 적용 표. - level { key, of } = 그 열 칸 class ui-data-table__lv · 줄 style --dt-lv(레벨 − 1) · 글 상자 칸도 · 다루기 단추 없음 + · 레벨 글꼴 class is-lv1~5 · 묶음 is-node(228-1 · test_228_1) - mergeSame [열] = 위아래 같은 글이 이어지면 rowspan · 앞 병합 열이 갈리면 갈림 · 빈 값 · 빈칸 글 · 고칠 칸 안 합침 · 합계 줄 안 합침 · 그린 구간(win) 안에서만 · 병합 표 칸 = data-c(열 번호) - 옵션 없으면 무변(rowspan · data-c · --dt-lv 없음) @@ -93,12 +94,13 @@ def test_mergeSame_같은_값만_rowspan(): def test_level_보기_전용_들여쓰기(): rows = _run()["level"] assert [re.search(r'style="--dt-lv:(\d)"', r).group(1) for r in rows] == ["0", "1", "0"] - assert 'a' in rows[0] + # 아래 줄이 더 깊음 = 묶음 + assert 'a' in rows[0] assert rows[0].count("ui-data-table__lv") == 1 # 지정 열만 - assert '' in rows[1] # 글 상자 칸도 + assert '' in rows[1] # 글 상자 칸도 assert "button" not in "".join(rows) # 다루기 단추 없음(설계내역서만) css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") - pad = r"padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);" + pad = r"padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* var\(--dt-indent\)\);" assert re.search(r"td\.ui-data-table__lv \{\s*" + pad + r"\s*text-align: left;", css) assert re.search(r"td\.ui-data-table__lv \.ui-data-table__input \{\s*" + pad, css) assert re.search(r"td\.is-merged \{\s*vertical-align: top;", css) @@ -117,7 +119,7 @@ APPLIED = [ ("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'mergeSame: ["kind"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["group", "part"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["part"]'), - ("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'level: { key: "name"'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'group: (i) => rows[i]?.kind !== "item"'), ("B09_Estimation/B09_Estimation_UI_Shell.ts", "mergeSame: spec.mergeSame"), ] diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index a4db1829a..35bacaafc 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -105,9 +105,9 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): def test_잇기_쉘_안내_줄_아래_도구_비움(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert ( - "panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []))" + "panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []))" in shell - ) # 200-2 레벨 줄 · 203-1 연결 점검 탭 없앰과 합침 + ) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 안내 줄 = 맨 아래 assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음 assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell) assert "setTools: bars.setTools" in shell diff --git a/resources/tester/test_204_1_b09_bill_drag.py b/resources/tester/test_204_1_b09_bill_drag.py index 20367728b..185ffb4cf 100644 --- a/resources/tester/test_204_1_b09_bill_drag.py +++ b/resources/tester/test_204_1_b09_bill_drag.py @@ -57,7 +57,7 @@ out.altLeft = press("earth:4", "ArrowLeft", { altKey: true }); user = {}; draw(); pick("earth:5"); doc.activeElement = tr("earth:5").kids[0].kids[0]; out.editing = [key("ArrowRight"), key("ArrowUp"), order()]; -out.guide = bar.kids.at(-1).textContent; +out.guide = bar.guide.textContent; console.log(JSON.stringify(out)); """ diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index c89d23564..4da59ffb2 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -202,9 +202,9 @@ 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].startsWith("서고 일위대가"))[1](); +picks.find((x) => x[0] === "일위대가 사전조합")[1](); await new Promise((r) => setTimeout(r, 0)); -picks.find((x) => x[0].startsWith("서고 구조물"))[1](); +picks.find((x) => x[0] === "구조물 조합")[1](); await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); Object.assign(out, { sent, applied, payload: ctx.payload, toasts }); @@ -226,10 +226,10 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): assert out["picks"] == [ "일위대가 호표", "단가산출 호표", - "서고 구조물조합(마스터 · 회사 · 개인)", - "서고 일위대가 사전조합(마스터 · 회사 · 개인)", - "단가산출 로직(M01 · 새 산근)", - ] + "구조물 조합", + "일위대가 사전조합", + "단가산출", + ] # 246-1 이름 셋(서고 · 층 · M01 글 없음) assert out["more"] == ["연결 풀기"] pre, (url, body) = out["sent"][0], out["sent"][1] assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋 diff --git a/resources/tester/test_213_1_hidden_station_pvi.py b/resources/tester/test_213_1_hidden_station_pvi.py index 4e5480c8c..088edaba4 100644 --- a/resources/tester/test_213_1_hidden_station_pvi.py +++ b/resources/tester/test_213_1_hidden_station_pvi.py @@ -5,7 +5,10 @@ 편집 「263.210」과 0.004m 사이 두 꼭짓점(−15003% 계단). 고침: 계획선 공용 함수(파이썬 `snap_to_stations` ↔ TS `snapToStations`)가 측점 0.006m 안의 꼭짓점 · 편집 키를 그 측점으로 모음(측점 위 것이 이김 · 측점 계획고 그대로) · 192 병합도 -옛 글자를 같은 측점으로 봄. 사용자 storage 는 읽기만 — 사본(tmp_path). +옛 글자를 같은 측점으로 봄. +실자료 = 고정 사본(fixtures/b05_213_1_hidden_station_longitudinal.json · +d2245e3b initial_snapshot 종단 160~360m · 213-1 이전 재현 상태 · 234-2) +— 사용자 storage 에 기대지 않음. """ from __future__ import annotations @@ -32,12 +35,9 @@ from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profi from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402 TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" -SOURCE = ( - PROJECT_ROOT - / "storage/1/3/d2245e3b-0468-4a90-ac5c-50b2adcb56e7/B06_Section/longitudinal/longitudinal.json" -) +SOURCE = PROJECT_ROOT / "resources/tester/fixtures/b05_213_1_hidden_station_longitudinal.json" REL = "B06_Section/longitudinal/longitudinal.json" -needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="엄상준_검증 storage 없음") +needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="213-1 고정 사본 없음") # 합성 — 격자 20m · 관 측점 263.206 → 263.21 반올림 · 끝 꼭짓점 1000.0(끝 측점 999.998) STATIONS = [0.0, 20.0, 40.0, 263.21, 280.0, 300.0, 999.998] diff --git a/resources/tester/test_219_1_arrow_owner.py b/resources/tester/test_219_1_arrow_owner.py index f9884020d..cd6ae78f9 100644 --- a/resources/tester/test_219_1_arrow_owner.py +++ b/resources/tester/test_219_1_arrow_owner.py @@ -63,9 +63,10 @@ def test_횡단_카드를_누른_뒤_좌우키는_관을_안_옮김(out) -> None @needs_node def test_종단도를_누른_뒤_좌우키는_지금처럼_이동(out) -> None: - assert out["profileThenArrows"]["move"] == [[263.21, 263.31]] - assert out["popoutThenArrows"]["move"] == [[263.21, 263.11]] # 새 창 종단도도 - assert out["chuteThenProfile"]["move"] == [[263.21, 263.31]] + # 좌우 한 걸음 = 1 m · 정수 m(234-1 · 옛 0.1 m) + assert out["profileThenArrows"]["move"] == [[263.21, 264]] + assert out["popoutThenArrows"]["move"] == [[263.21, 262]] # 새 창 종단도도 + assert out["chuteThenProfile"]["move"] == [[263.21, 264]] @needs_node diff --git a/resources/tester/test_228_1_table_level_font.py b/resources/tester/test_228_1_table_level_font.py new file mode 100644 index 000000000..56bab2b46 --- /dev/null +++ b/resources/tester/test_228_1_table_level_font.py @@ -0,0 +1,230 @@ +"""228-1 표 레벨 = 들여쓰기에 따라 레벨 · 글꼴(크기 · 굵기) 자동 — 공용 표 level 옵션 · 설계내역서 · 보기 전용 표. + +- 공용 표(`ui_template_data_table.ts`) level 칸 class = is-lv1~5(6 이상 = 5) · 묶음 줄 = is-node + (group 이 주면 그 값 · 안 주면 바로 아래 줄 레벨이 더 큰 줄 · 그린 구간 밖 줄도 봄) +- CSS = 묶음 굵게 · 크기 Lv1 16 · Lv2 15 · Lv3 14(기본) · Lv4 13 · Lv5 12 · 항목 줄은 14 보다 크지 않게 · + 들여쓰기 한 단 = 표 기본 1em(레벨 글자 크기와 무관) +- 설계내역서 = 들여 · 내어쓰기(메뉴 · 단추 · 키 · 끌기 같은 함수) → 셈 다시 → 그 줄과 자손 레벨 · 글꼴 바로 바뀜 +- 보기 전용 2단 표(blockLevel) · 총괄 · 원가계산서 = 같은 규칙(머리 줄 16 굵게 · 내용 14 보통) +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_182_b09_bill_level import BOOKS, INPUTS, USER # noqa: E402 +from test_184_3_b09_bill_menu import _node, _ts # noqa: E402 +from test_185_1_b09_total_level import SUMS # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) +TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")) +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", + "UI_Tabs_Bill_Level"] # fmt: skip +IN, OUT = "들여쓰기 →", "내어쓰기 ←" + +#: 줄마다 [레벨 칸 class(레벨 · 묶음만), --dt-lv] — 레벨 열 칸만 +CLS = """ +const cls = (html) => html.split("")[1].split("")[0].split("").filter(Boolean) + .map((r) => [(r.match(/class="([^"]*ui-data-table__lv[^"]*)"/) || [])[1]?.split(" ") + .filter((c) => /^(ui-data-table__lv|is-lv\\d|is-node)$/.test(c)).join(" ") ?? null, + Number((r.match(/--dt-lv:(\\d+)/) || [])[1])]); +""" + + +def _cls(body: str, names=SRC) -> dict: + return json.loads(_node(_ts(names) + TABLE + CLS + body)) + + +# --- 공용 표 ------------------------------------------------------------------------------ + +TABLE_JS = """ +const columns = [{ key: "n", label: "명칭" }, { key: "v", label: "값", kind: "number" }]; +const lv = [1, 2, 3, 4, 5, 6, 2, null]; +const rows = lv.map((_, i) => ({ n: `r${i}`, v: i })); +const of = (i) => lv[i]; +console.log(JSON.stringify({ + auto: cls(renderDataTableHtml({ columns, rows, level: { key: "n", of } })), + given: cls(renderDataTableHtml({ columns, rows, level: { key: "n", of, group: (i) => i === 7 } })), + win: cls(renderDataTableHtml({ columns, rows, level: { key: "n", of } }, { from: 2, to: 4, rowH: 28 })), + edit: cls(renderDataTableHtml({ columns, rows, level: { key: "n", of }, editable: (i, k) => k === "n" })), +})); +""" + + +def test_공용_표_레벨_글꼴_class(): + out = _cls(TABLE_JS, []) + assert out["auto"] == [ + ["ui-data-table__lv is-lv1 is-node", 0], + ["ui-data-table__lv is-lv2 is-node", 1], + ["ui-data-table__lv is-lv3 is-node", 2], + ["ui-data-table__lv is-lv4 is-node", 3], + ["ui-data-table__lv is-lv5 is-node", 4], + ["ui-data-table__lv is-lv5", 5], # 6 이상 = 5 글꼴 · 들여쓰기는 그대로 + ["ui-data-table__lv is-lv2", 1], # 아래 줄이 얕음 = 항목 + ["ui-data-table__lv is-lv1", 0], # 끝 줄 · 빈 레벨 = 1 + ] + assert [c for c, _ in out["given"]][6:] == [ + "ui-data-table__lv is-lv2", + "ui-data-table__lv is-lv1 is-node", + ] + assert "is-node" not in "".join(c for c, _ in out["given"][:6]) + # 그린 구간(2~3줄)의 끝 줄도 구간 밖 아래 줄로 묶음을 셈 + assert [c for c, _ in out["win"] if c] == [ + "ui-data-table__lv is-lv3 is-node", + "ui-data-table__lv is-lv4 is-node", + ] + assert out["edit"] == out["auto"] # 글 상자 칸도(글 상자 = font: inherit) + + +def test_CSS_레벨_글꼴_규칙(): + css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + tokens = dict(re.findall(r"(--dt-(?:lv\d-size|indent)): ([^;]+);", css)) + assert tokens == { + "--dt-indent": "22px", + "--dt-lv1-size": "var(--text-body)", + "--dt-lv2-size": "15px", + "--dt-lv4-size": "13px", + "--dt-lv5-size": "var(--text-caption)", + } + theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") + assert "--text-body: 16px;" in theme and "--text-body-sm: 14px;" in theme + assert "--text-caption: 12px;" in theme + + def rule(sel: str) -> str: + m = re.search( + re.escape(f".ui-data-table__table td.ui-data-table__lv{sel} {{") + r"([^}]*)\}", css + ) + return m.group(1).strip() if m else "" + + assert rule(".is-node") == "font-weight: var(--font-weight-bold);" + assert rule(".is-lv1.is-node") == "font-size: var(--dt-lv1-size);" + assert rule(".is-lv2.is-node") == "font-size: var(--dt-lv2-size);" + assert rule(".is-lv4") == "font-size: var(--dt-lv4-size);" + assert rule(".is-lv5") == "font-size: var(--dt-lv5-size);" + # 항목 Lv1 · 2 · Lv3 = 표 기본 14px(크기 규칙 없음) · 굵게 = 묶음만 + assert not rule(".is-lv1") and not rule(".is-lv2") and not rule(".is-lv3") + # 들여쓰기 = 글 칸 · 글 상자 · 나무 글 상자(235-1 · 아이콘 자리 더함) + assert css.count("var(--dt-lv, 0) * var(--dt-indent)") == 3 and "* 1em" not in css + # 줄 높이 28px 안 — 가장 큰 글자(16px × 줄 간격 1.3) + assert "--dt-row-h: 28px;" in css and 16 * 1.3 < 28 + + +# --- 설계내역서: 들여 · 내어쓰기 → 레벨 · 글꼴 바로 ------------------------------------------ + +BILL_JS = """ +let user = __USER__; +const sheets = { bill: { rows: __ROWS__, totals: null } }; +const ctx = { sheets, payload: {}, get user() { return user; }, setUser: (u) => (user = u) }; +const spec = buildBill(ctx); +const html = cls(renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: spec.level, + editable: spec.editable })); +const out = { view: Object.fromEntries(spec.rows.map((r, i) => [r.key, html[i]])) }; +const runs = __RUNS__; +if (runs.length) { + const [key, label] = runs[0]; + const item = billMenu(ctx, spec.rows, spec.rows.findIndex((r) => r.key === key)) + .find((x) => Array.isArray(x) && x[0] === label); + item[1](); + out.bill = user.bill; +} +console.log(JSON.stringify(out)); +""" + + +def _bill(user, run=None): + rows = compute_estimation(INPUTS, user, None, BOOKS)["bill"]["rows"] + body = ( + BILL_JS.replace("__USER__", json.dumps(user, ensure_ascii=False)) + .replace("__ROWS__", json.dumps(rows, ensure_ascii=False)) + .replace("__RUNS__", json.dumps([run] if run else [], ensure_ascii=False)) + ) + return _cls(body) + + +def _after(run): + """메뉴 한 번(들여 · 내어쓰기) → 저장 꼴 → 셈 다시(화면 setUser 와 같은 길) → 다시 그림.""" + bill = _bill(USER, run)["bill"] + return _bill({**USER, "bill": bill})["view"] + + +def test_내어쓰기_그_줄과_자손_레벨_글꼴_바로(): + before = _bill(USER)["view"] + # 손 묶음 g2(Lv4 · 자식 b · c Lv5) + assert before["hand:g2"] == ["ui-data-table__lv is-lv4 is-node", 3] + assert before["hand:b"] == ["ui-data-table__lv is-lv5", 4] + assert before["hand:c"] == ["ui-data-table__lv is-lv5", 4] + assert before["hand:g1"] == ["ui-data-table__lv is-lv3 is-node", 2] + after = _after(["hand:g2", OUT]) + assert after["hand:g2"] == ["ui-data-table__lv is-lv3 is-node", 2] # 14 굵게 + assert after["hand:b"] == ["ui-data-table__lv is-lv4", 3] # 자손도 한 단 위 · 13 + assert after["hand:c"] == ["ui-data-table__lv is-lv4", 3] + assert after["hand:g1"] == before["hand:g1"] # 손 묶음 = 자식 없어도 묶음(group) + + +def test_들여쓰기_항목이_묶음이_되면_굵게(): + before = _bill(USER)["view"] + assert before["earth:5"] == ["ui-data-table__lv is-lv3", 2] + assert before["hand:x"] == ["ui-data-table__lv is-lv3", 2] + after = _after(["earth:5", IN]) # 위 형제 hand:x 아래로 + assert after["earth:5"] == ["ui-data-table__lv is-lv4", 3] + assert after["hand:x"] == ["ui-data-table__lv is-lv3 is-node", 2] # 자식 생긴 항목 = 묶음 굵게 + # 제목 줄 = 묶음 · Lv1 16 · Lv2 15 + assert before["sec:1"] == ["ui-data-table__lv is-lv1 is-node", 0] + assert before["sec:1.2"] == ["ui-data-table__lv is-lv2 is-node", 1] + + +# --- 보기 전용 표: 같은 규칙 -------------------------------------------------------------- + +BLOCK_JS = """ +const columns = [{ key: "name", label: "명칭" }]; +const rows = ["머리", "줄1", "줄2", "합계", "머리2", "줄3"].map((name) => ({ name })); +const top = (i) => [0, 3, 4].includes(i); +console.log(JSON.stringify(cls(renderDataTableHtml({ columns, rows, level: blockLevel("name", top) })))); +""" + +TOTAL_JS = f""" +const user = {{ estimate: {{}} }}; +const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, + setUser: () => {{}} }}; +const view = (t) => {{ + const html = cls(renderDataTableHtml({{ columns: t.columns, rows: t.rows, level: t.level }})); + return t.rows.map((r, i) => [r.key, r.level, html[i][0]]); +}}; +console.log(JSON.stringify({{ total: view(buildTotal(ctx)), cost: view(buildCost(ctx)) }})); +""" + + +def test_보기_전용_2단_표_머리_16_굵게_내용_14_보통(): + out = _cls(BLOCK_JS) + assert [c for c, _ in out] == [ + "ui-data-table__lv is-lv1 is-node", + "ui-data-table__lv is-lv2", + "ui-data-table__lv is-lv2", + "ui-data-table__lv is-lv1", # 합계(아래 줄 = 다음 머리) = 14 · 굵게는 줄 class is-title + "ui-data-table__lv is-lv1 is-node", + "ui-data-table__lv is-lv2", + ] + + +def test_총괄_원가계산서_같은_규칙(): + out = _cls(TOTAL_JS, ["Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total"]) + for tab in ("total", "cost"): + rows = out[tab] + for i, (key, lv, c) in enumerate(rows): + node = i + 1 < len(rows) and (rows[i + 1][1] or 1) > (lv or 1) + assert c == f"ui-data-table__lv is-lv{min(5, lv or 1)}" + ( + " is-node" if node else "" + ), (tab, key) + cost = {k: c for k, _, c in out["cost"]} + assert ( + "is-lv3" in cost["direct_material"] and "is-node" not in cost["direct_material"] + ) # 세목 14 보통 + assert cost["material"].endswith("is-lv2 is-node") # 비목 소계(아래 세목) 15 굵게 diff --git a/resources/tester/test_229_1_b09_level_buttons_subbar.py b/resources/tester/test_229_1_b09_level_buttons_subbar.py new file mode 100644 index 000000000..2d40334c7 --- /dev/null +++ b/resources/tester/test_229_1_b09_level_buttons_subbar.py @@ -0,0 +1,94 @@ +"""229-1 B09 설계내역서 레벨 단추 = 보조 탭 줄 오른쪽 — 단추 넷 · 다른 탭엔 없음 · 표 위 도구 줄 없음. + +- 단추 = 보조 탭 줄 도구 자리(`setTools`) · 안내 글 = 표 아래 · 못 하는 동작 = 흐림(200-2 그대로) +- 쉘 배선 = 소스 글 대조(test_200_2 · test_202_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 TABS, run # noqa: E402 + +B09 = "B09_Estimation/B09_Estimation_UI_" +FILES = [TABS, f"{B09}TabBar.ts", f"{B09}Tabs_Bill_Level.ts"] +# 가짜 DOM 에 줄 찾기(closest · querySelector(All))만 더함 · 끌기 = 빈 함수 +PRELUDE = r""" +document.addEventListener = () => {}; +const attachDrag = () => {}; +E.prototype.closest = function () { for (let e = this; e; e = e.parent) if (e.tagName === "TR") return e; return null; }; +E.prototype.querySelectorAll = function () { return this.all("TR").filter((t) => t.cls.has("is-focus")); }; +E.prototype.querySelector = function (s) { + const i = s.match(/data-i="(-?\d+)"/)?.[1]; + return this.children.find((c) => c.dataset.i === i) ?? null; +}; +""" + + +def test_설계내역서_보조_줄에_단추_넷_다른_탭엔_없음(): + out = run( + FILES, + """ +const bars = createTabBars(() => {}); +const items = ["들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"].map((t) => [t, () => {}]); +const table = { rows: [{ key: "r1" }], menu: (i) => (i === 0 ? items.slice(1, 3) : []) }; +const root = document.createElement("div"); +const tr = document.createElement("tr"); +tr.dataset.i = "0"; +root.append(tr); +const toolsOf = () => bars.sub.children[bars.sub.children.length - 1]; +const snap = (lv) => ({ + labels: toolsOf().children.map((b) => b.textContent), + levels: toolsOf().children.map((b) => b.dataset.b09Level ?? null), + guideIn: lv ? lv.guide.textContent.length > 0 : null, +}); +const show = (tab) => { + bars.draw(tab); + bars.setTools([]); + if (tab !== "bill") return null; + const lv = levelBar(root, table); + bars.setTools(lv.tools); + return lv; +}; +const bill = show("bill"); +const a = snap(bill); +const disabledBefore = bill.tools.map((b) => b.disabled); +tr.click(); +const disabledAfter = bill.tools.map((b) => b.disabled); +show("dg"); +const b = snap(null); +show("bill"); +const c = snap(null); +console.log(JSON.stringify({ + a, disabledBefore, disabledAfter, b: b.labels, c: c.labels, + guideTag: bill.guide.tagName, guideCls: bill.guide.className, subBlank: bars.sub.classList.contains("b09-estimation__tabs--blank"), +})); +""", + PRELUDE, + ) + names = ["→ 들여쓰기", "← 내어쓰기", "↑ 위로", "↓ 아래로"] + assert out["a"]["labels"] == names # 설계내역서 = 단추 넷(보조 줄 오른쪽 도구 자리) + 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 out["disabledBefore"] == [True] * 4 # 고른 줄 없음 = 흐림 + assert out["disabledAfter"] == [True, False, False, True] # 줄 누름 = 메뉴 항목대로 + assert out["b"] == [] # 다른 탭(일위대가표) = 단추 없음 + assert out["c"] == names # 설계내역서로 돌아오면 다시 있음 + + +def test_표_위_도구_줄_없음_쉘_배선(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "if (level) bars.setTools(level.tools);" in shell + assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in shell + assert "...(level ? [level] : [])" not in shell # 표 위 자리 없음 + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + lvl = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") + assert "b09-estimation__level" not in css and "b09-estimation__level" not in lvl + # 키 · 단추 = 우클릭 메뉴 같은 항목 · 키 안 바뀜(Tab · Shift+Tab · Alt+↑↓ · 방향키) + assert re.search(r"ACTS\[e\.shiftKey \? 1 : 0\]\[1\]", lvl) diff --git a/resources/tester/test_230_1_popout_wheel.py b/resources/tester/test_230_1_popout_wheel.py new file mode 100644 index 000000000..26e4ef6e5 --- /dev/null +++ b/resources/tester/test_230_1_popout_wheel.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""종단 새 창 + 최대화 휠(계획서 230-1) 회귀검증. + +까닭 — 휠 이동 · 스크롤 뒤 다시 그리기가 메인 창 전역 화면 틀 · 시계를 썼음. 새 창을 최대화하면 +메인 창이 가려져 브라우저가 그 틀을 멈춤 → 휠은 막히고(preventDefault) 이동은 0. +고침 — 스크롤러가 놓인 창(`ownerDocument.defaultView`)의 틀 · 시계를 씀. + +`helper_230_1_popout_wheel.cjs` 가 메인 창을 「가려짐」(틀 · 시계 멈춤)으로 두고 판정함. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_230_1_popout_wheel.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_새_창_보통_휠_이동_메인_틀_안_씀(r): + p = r["popupNormal"] + assert p["left"] == 360 # 세 칸 × 120 + assert p["prevented"] == [True, True, True] + assert p["popupFrames"] > 0 and p["mainTimers"] == 0 + + +def test_새_창_최대화_뒤_휠_이동(r): + p = r["popupMax"] + assert p["limit"] == 4080 # 6000 − 1920 — 최대화해도 스크롤할 거리 남음 + assert p["lightSyncs"] == 1 # 크기 바뀜 듣개가 돎 + assert p["left"] == 240 # 두 칸 × 120 + assert p["draws"] == 1 # 스크롤 뒤 다시 그리기도 새 창 시계로 + assert p["mainTimersAdded"] == 0 + + +def test_끝에서_멈추고_기본_스크롤로_넘김(r): + p = r["popupEnd"] + assert p["left"] == 280 # 한도 2200 − 1920 + assert p["prevented"][-1] is False and p["after"] is False + + +def test_원래_창_휠_그대로(r): + assert r["mainWindow"] == {"left": 360, "prevented": [True, True, True]} + + +def test_끌기_글자_선택은_새_창에서_풂(r): + d = r["drag"] + assert d["left"] == 100 + assert d["popupCleared"] is True and d["mainCleared"] is False diff --git a/resources/tester/test_230_2_popout_frames.py b/resources/tester/test_230_2_popout_frames.py new file mode 100644 index 000000000..a0f51bd03 --- /dev/null +++ b/resources/tester/test_230_2_popout_frames.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""종단 새 창 최대화 — 같은 꼴 나머지(계획서 230-2) 회귀검증. + +230-1 과 같은 까닭 — 메인 창 전역 화면 틀 · 시계는 새 창 최대화로 메인 창이 가려지면 멈추거나 +늦게 돎. 새 창으로 가는 종단 패널(종단도 · 표 · 유토곡선 · 배수유역도)의 틀 · 시계는 공용 +`viewOf` · `frameOf`(common_util_view_window) 로 그 요소가 놓인 창에서 받음. + +`helper_230_2_popout_frames.cjs` 가 메인 창을 「가려짐」(틀 · 시계 멈춤)으로 두고 실제 함수 본문을 돌림. +옛 코드로는 아래 다시 그리기가 모두 0 번. +""" + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + +# 새 창으로 가는 종단 패널의 화면 코드 +POPOUT_FILES = [ + "A00_Common/design/design_profile.ts", + "A00_Common/design/design_profile_viewport.ts", + "B05_Profile/B05_Profile_UI_Profile_Wheel.ts", + "B05_Profile/B05_Profile_UI_Profile_Heights.ts", + "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts", + "B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts", + "B05_Profile/B05_Profile_UI_Profile_Drag.ts", + "B05_Profile/B05_Profile_UI_Profile_Edit.ts", + "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts", + "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", + "B05_Profile/B05_Profile_UI_Structures_Marks.ts", + "common_util/common_util_mass_haul_view.ts", +] +# 메인 창 전역 틀 · 시계 — `x.requestAnimationFrame` 처럼 창을 짚은 것은 제외 +MAIN_WINDOW_CLOCK = re.compile( + r"(? 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_231_1_pave_reload.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +CARD_120 = {"form": None, "paveLayer": None, "span": None, "card": 120, "sid": None} +PAVE = {"form": "pave", "paveLayer": "pave", "span": "pave", "card": None, "sid": "pave"} + + +def test_범위_안_카드_고름_오감해도_노면공_안_켬_폼_그대로(out) -> None: + assert out["cardBefore"] == CARD_120 + assert out["cardAfter"] == CARD_120 + assert out["cardAfter2"] == CARD_120 # 두 번 오가도 + + +def test_같은_카드_목록_고름_뒤_오감도_노면공_안_켬(out) -> None: + assert out["signPick"]["form"] == "sign" + after = out["signAfter"] + assert after["form"] == "sign" # 목록 고름도 기록 — 오감 뒤 그 폼(231-2) + assert after["paveLayer"] is None + assert after["span"] != "pave" + assert after["card"] == 120 + + +def test_목록_노면공은_오감해도_범위_강조_그대로(out) -> None: + assert out["paveBefore"] == PAVE + assert out["paveAfter"] == PAVE + + +def test_옹벽_범위_안_카드는_그_카드_그대로(out) -> None: + wall = {"form": "wall", "paveLayer": None, "span": None, "card": 120, "sid": None} + assert out["wallCard"] == wall # 기준 카드 100 으로 안 옮김 + assert out["wallAfter"] == wall # 오감 뒤도 · 범위 테두리 없음(137-4) + # 목록 고름은 그대로 기준 카드 · 범위 테두리 · id 기록 + assert out["wallList"] == { + "form": "wall", + "paveLayer": None, + "span": "wall", + "card": 100, + "sid": "wall", + } + + +def test_관_넘김은_가장_가까운_카드(out) -> None: + assert out["pipeHandoff"]["card"] == 80.17 # 이웃 측점 80 아님 + assert out["pipeHandoff"]["form"] == "pipe-80.17" + + +def test_B05_3D_고름은_노면공_id_를_안_적음(out) -> None: + assert out["pick3dWall"] == {"sid": "wall", "form": "wall"} # 범위 안 옹벽 = 옹벽 + assert out["pick3dBare"] == {"sid": None, "form": None} # 노면공만 덮음 = 안 엶 + after = out["pick3dAfter"] + assert after["paveLayer"] is None + assert after["sid"] is None + assert after["form"] is None + + +def test_배선() -> None: + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "section.selectAt({ chainageM: picked, structureId }, coverable);" in panel + assert "if (!openingAtStation) deps.focusChainage(focusOf(structure), pave);" in panel + assert "deps.markStructure?.(id, !openingAtStation);" in panel + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert ( + "markStructure: (structureId, span) => sectionView.selectMark(structureId, span)," in cross + ) + pick = _read("B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts") + assert "structures.resolveAt(selection, coverable)" in pick + assert "structures.selectAt({ ...selection, structureId }, coverable)" in pick + profile = _read("A00_Common", "design", "design_view_profile.ts") + at = profile.index("wireStructurePick(") + assert "coversAtStation(bridge.typeGroup(entry.type_id))" in profile[at : at + 300] + handoff = _read("B06_Section", "B06_Section_UI_Page_Structure_Pick.ts") + assert "nearestWithin(sections, handoff.at, (section) => section.chainage_m)" in handoff diff --git a/resources/tester/test_231_2_list_pick_record.py b/resources/tester/test_231_2_list_pick_record.py new file mode 100644 index 000000000..a1a41b153 --- /dev/null +++ b/resources/tester/test_231_2_list_pick_record.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""231-2 목록 고름도 늘 고름 기록 — 조사 AD 2안. + +까닭: B06 목록 고름은 노면공일 때만 고름 한 칸에 적고 그 밖은 카드 리스너에 맡겼음 → 같은 카드 안 구조물을 +목록으로 고르면 리스너가 안 돌아 기록이 「측점만」 그대로 → 오감 · 새로 고침 뒤 그 폼이 사라짐. +고침: 목록 · 알약 · 형상 고름은 늘 그 구조물 id 를 적음 · 측점 고름(카드 · 다시 세움)이 연 것은 측점만(189-1). +B05 목록은 원래 늘 적음(`rememberPick(…, structure_id)`). 헬퍼 = `helper_231_1_pave_reload.cjs`. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_231_1_pave_reload.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_카드_120_뒤_목록_관_오감_새로고침_관_폼(out) -> None: + for key in ("listPipe", "listPipeTrip", "listPipeF5"): + got = out[key] + assert got["form"] == "pipe-80.17", key + assert got["card"] == 80.17, key + assert got["paveLayer"] is None, key + + +def test_같은_카드_목록_표지는_기록되고_오감_새로고침_뒤_그대로(out) -> None: + sign = {"form": "sign", "paveLayer": None, "span": "sign", "card": 120, "sid": "sign"} + assert out["listSign"] == sign + assert out["listSignTrip"] == sign + assert out["listSignF5"] == sign + + +def test_목록_옹벽은_기준_카드_오감_뒤_그대로(out) -> None: + wall = {"form": "wall", "paveLayer": None, "span": "wall", "card": 100, "sid": "wall"} + assert out["listWall"] == wall + assert out["listWallTrip"] == wall + + +def test_목록_노면공은_새로고침_뒤도_범위_그대로(out) -> None: + assert out["listPaveF5"] == { + "form": "pave", + "paveLayer": "pave", + "span": "pave", + "card": None, + "sid": "pave", + } + + +def test_측점_고름이_연_것은_측점만(out) -> None: + assert out["wallCard"]["sid"] is None # 카드 하나(189-1) + assert out["wallAfter"]["sid"] is None # 다시 세움도 + assert out["cardAfter"]["sid"] is None + + +def test_배선() -> None: + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert 'if (!openingAtStation) selectDesign(deps.projectId, at, { from: "b06" });' in panel + assert "if (pave) selectDesign(" not in panel + profile = _read("A00_Common", "design", "design_view_profile.ts") + # B05 목록은 원래 늘 그 구조물 id 를 적음 + assert "rememberPick(structureAnchorM(structure), structure.structure_id ?? null)" in profile diff --git a/resources/tester/test_232_1_3d_press.py b/resources/tester/test_232_1_3d_press.py new file mode 100644 index 000000000..ea08494ef --- /dev/null +++ b/resources/tester/test_232_1_3d_press.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""232-1 3D 띠 강조 바로 꺼짐(요구 104 · 조사 AF a1 · a2) — 3D 누름 길 통째. + +까닭: 띠 누름을 마커 듣개가 켜고(누를 때) 3D 부재 고름이 뗄 때 「빈 곳 누름」으로 읽어 고름 전체를 지움. +고침: ① 마커 · 띠에 맞은 누름은 부재 고름이 건너뜀 ② 숨은 핀 · 꺼 둔 측점선 · 라벨은 누름을 안 가로챔 +③ 3D 빈 곳 누름 = 고름 안 풂(08-05) · 같은 띠 · 같은 부재 다시 누름 = 풂. +진짜 TS 를 `helper_232_1_3d_press.cjs` 가 three + 가짜 캔버스로 한 누름씩(창 캡처 → 캔버스 듣개 차례) 돌림. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + +RED = "#ef4444" +YELLOW = "#facc15" +AT_120 = {"chainageM": 120, "structureId": None} + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_232_1_3d_press.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) -> None: + for phase in ("down", "up"): + state = out["bar"][phase] + assert state["s120"] == RED + assert state["pin"] is True + assert state["session"] == AT_120 + assert state["graph"] == "s120" + + +def test_빈_곳_누름은_고름_안_풂(out) -> None: + assert out["empty"]["up"]["s120"] == RED + assert out["empty"]["up"]["session"] == AT_120 + assert out["memberEmpty"]["up"]["member"] is True + assert out["memberEmpty"]["up"]["session"]["chainageM"] == 160 + + +def test_같은_띠_다시_누름은_풂(out) -> None: + up = out["again"]["up"] + assert (up["s120"], up["pin"], up["session"], up["graph"]) == (YELLOW, False, None, None) + + +def test_숨은_핀_라벨이_있던_띠_가운데도_켜짐(out) -> None: + assert out["center"]["up"]["s120"] == RED + assert out["center"]["up"]["session"] == AT_120 + + +def test_부재_고름과_같은_부재_다시_누름(out) -> None: + up = out["memberPick"]["up"] + assert up["member"] is True + assert up["session"] == {"chainageM": 160, "structureId": None, "partKey": "inlet"} + again = out["memberAgain"]["up"] + assert again["member"] is False + assert again["session"] is None + + +def test_꺼_둔_측점선은_누름을_안_가로챔(out) -> None: + up = out["hiddenLines"]["up"] + assert up["s100"] == YELLOW + assert up["session"] == AT_120 # 앞 고름 그대로 · 빈 곳 누름이라 안 풂 + assert out["hiddenPressedMarker"] is False + + +def test_배선() -> None: + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Viewer.ts"), encoding="utf-8" + ) as handle: + viewer = handle.read() + assert "pressedMarker: () => markerInput.pressedMarker()" in viewer diff --git a/resources/tester/test_233_1_profile_pick.py b/resources/tester/test_233_1_profile_pick.py new file mode 100644 index 000000000..7ce7a537e --- /dev/null +++ b/resources/tester/test_233_1_profile_pick.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""233-1 고름 한 칸 = 유일 원천 + 바뀜 알림 · 종단도(측점선 · 알약 · 띠)는 그 값으로 그림(조사 AF (나) ① ②). + +까닭: 알림 장치(`subscribeSelection`)는 있었으나 듣는 곳 0 — 화면끼리 손으로 부르는 사슬이라 한 길이 +강조를 끄면(저장 뒤 관 폼 닫힘 → 종단 선 · 알약 끔) 값은 그대로인데 종단만 꺼짐 · 관 알약 id(`pipe-`+측점)가 +옛 관 자리에 묶여 관이 옮겨지면 끊김. +고침: `design_pick_profile` — 종단 보기가 고름 한 칸을 구독해 값 → 종단 강조(`profilePickOf`) · +목록이 바뀐 뒤 · 폼이 닫힌 뒤에도 같은 값으로 다시 얹음(`paint`) · 숨은 보기는 안 그림 · 떠나면 구독 끊음. +진짜 TS 를 `helper_233_1_profile_pick.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_1_profile_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def line(station): + return {"station": station, "structure": None} + + +def pill(structure): + return {"station": None, "structure": structure} + + +def test_측점_값은_그_측점선(out) -> None: + assert out["card"] == line("s120") + assert out["irregularSign"] == line("irregular:sign") # 구조물 측점(관 아님)도 그대로 + assert out["card80"] == line("s80") # 관 80.17 옆 카드 80 은 카드 선 + assert out["pipe80"] == line("irregular:p80") + + +def test_측점선_없는_관은_지금_관_목록으로_알약(out) -> None: + assert out["pipePill"] == pill("pipe-300.40") + assert out["pipeMoved"] == pill("pipe-300.45") # 관이 옮겨져도 끊기지 않음 + + +def test_구조물_값은_그_알약(out) -> None: + assert out["structure"] == pill("wall") + + +def test_다른_길이_꺼도_값대로_다시_얹음(out) -> None: + assert out["clearedByOther"] == pill(None) + assert out["repainted"] == pill("wall") + + +def test_숨은_보기는_안_그리고_보이면_다시_얹음(out) -> None: + assert out["hidden"] == pill("wall") + assert out["shownAgain"] == line("s140") + + +def test_풂과_구독_끊음(out) -> None: + assert out["released"] == line(None) + assert out["disposers"] == 2 # 구독 + Esc 듣기(233-3) + assert out["paintsAfterDispose"] == 0 + + +def test_종단_보기_배선() -> None: + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "bindProfilePick(" in view and "{ profile: profilePanel, viewer," in view + # 폼이 닫히는 길(저장 뒤 · 관 폼으로)은 종단을 끄지 않고 값대로 다시 얹음 + assert "profilePanel.setSelectedStructure(structure?.structure_id ?? null)" not in view + assert view.count("profilePick.paint()") >= 3 + assert len(view.splitlines()) <= 700 diff --git a/resources/tester/test_233_2_view_pick.py b/resources/tester/test_233_2_view_pick.py new file mode 100644 index 000000000..241f5a561 --- /dev/null +++ b/resources/tester/test_233_2_view_pick.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""233-2 3D(띠 · 핀 · 부재) · 횡단 카드(고름 · --span · 형상 · 포장층) · [상세] 선 조각 = 고름 한 칸에서 파생. + +까닭: 3D 는 화면끼리 손으로 부르는 사슬로만 칠해져 길마다 빠짐(구조물 고름 뒤 옛 띠 남음 · 횡단 카드 +고름은 부재를 안 켬) · 횡단 카드를 풀어도 `--span` · 포장층이 남음(a4) · 풀기 길마다 끄는 범위가 다름 · +[상세] 선 조각은 다시 그릴 때(창 크기 · 카드 다시 세움) 고름을 지워 바로 꺼짐. +고침: 종단 보기 구독이 3D 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이) · 횡단 보기 구독(`bindCrossPick`)은 +다른 곳이 바꾼 값을 넘김 적용 길로 다시 세우고 · 풀기는 한 길(`releaseCrossHighlights` — 카드 · 폼 관 · +범위 테두리 · 포장층 · 형상) · [상세] 다시 그림은 같은 자리 조각 고름 유지. +진짜 TS 를 `helper_233_2_view_pick.cjs` 가 Node(three 포함)로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_2_view_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_3D_횡단_카드_고름은_띠_핀_그_측점_부재(out) -> None: + card = out["card120"] + assert (card["red"], card["pin"], card["wall"], card["member"]) == (["s120"], True, True, False) + assert card["profile"]["station"] == "s120" + + +def test_3D_부재키_고름은_그_부재만_띠는_없음(out) -> None: + part = out["part160"] + assert (part["red"], part["pin"], part["member"], part["wall"]) == ([], False, True, False) + + +def test_3D_구조물_고름은_그_자리_구조물_측점_띠(out) -> None: + assert out["sign"]["red"] == ["irregular:sign"] + assert out["sign"]["profile"]["structure"] == "sign" + + +def test_3D_풂과_알림_없음(out) -> None: + released = out["released3d"] + assert (released["red"], released["pin"], released["member"], released["wall"]) == ( + [], + False, + False, + False, + ) + assert out["notified"] == 0 # 칠하기만 — 마커 알림이 사슬을 다시 돌리지 않음 + + +def test_횡단_제_누름은_되받지_않고_다른_곳_값은_다시_세움(out) -> None: + assert out["ownPick"] == [] + assert out["otherPick"] == ["reapply"] + assert out["hidden"] == [] # 숨은 보기는 안 받음 + + +def test_횡단_풀기_한_길은_범위_테두리_형상까지(out) -> None: + for key in ("release", "otherRelease"): + assert out[key] == ["releaseForm", "focus:null", "mark:null", out[key][-1]] + assert out["release"][-1] == "refresh:120" + assert out["revetAfter"] == [] + + +def test_상세_다시_그려도_같은_자리_조각_고름_유지(out) -> None: + assert out["detailPicked"] == 1 + assert out["detailAfterRedraw"] == 1 + assert out["detailAfterChange"] == 0 + + +def test_배선() -> None: + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "bindCrossPick(projectId, host," in cross + assert "crossPick.release()" in cross + # 카드 다시 누름 = 풀기 한 길 + assert "releaseCross(); // 카드 다시 누름" in cross + assert len(cross.splitlines()) <= 685 + profile = _read("A00_Common", "design", "design_view_profile.ts") + assert "{ profile: profilePanel, viewer," in profile + assert len(profile.splitlines()) <= 700 diff --git a/resources/tester/test_233_3_release.py b/resources/tester/test_233_3_release.py new file mode 100644 index 000000000..246703daf --- /dev/null +++ b/resources/tester/test_233_3_release.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""233-3 좌측 목록 · 폼 · 배수유역도 · 남은 바로 꺼짐 자리 · 풀기 = Esc 한 길(조사 AF (나) ⑤ · a3 · a5 · a6). + +까닭: 배수유역도 재계산이 관 마커 · 유역 고름을 지움(지도만 꺼짐) · 지도 · 유역 고름은 고름 한 칸에 안 적혀 +마름모가 안 옮겨짐 · 목록 관 줄은 폭 0.05 · 관이 조금 옮겨지면 줄 강조 빠짐 · 목록 관 고름은 화면마다 손으로 +불러 길마다 빠짐 · 풀기가 화면마다 다름(어디까지 푸는지 제각각). +고침: 종단 보기 구독이 배수유역도(관 마커 · 유역 · 마름모)까지 값으로 칠함(알림 없이 · 재계산 뒤 다시 얹음) · +지도 고름 · 목록 관 고름 · 목록 다시 누름도 고름 한 칸에 적고 칠하기는 구독 몫 · 목록 줄 폭 = `PIPE_MATCH_M` · +옮겨진 관도 고름 따라감 · Esc = 고름 한 칸 비움 → 종단 · 3D · 지도 · 폼 · 횡단 카드 · 범위 · 형상 모두 풂 +(입력 칸 · 창 · 먼저 받은 Esc 는 그쪽 몫). +진짜 TS 를 `helper_233_3_release.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_3_release.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) -> None: + assert out["pipePick"]["map"] == {"pipe": 80.17, "mark": 80.17, "notifies": 0} + assert out["pipePick"]["profile"]["station"] == "irregular:p80" + + +def test_재계산이_지운_지도_고름을_같은_값으로_다시_얹음(out) -> None: + assert out["afterRecompute"]["map"] == {"pipe": 80.17, "mark": 80.17, "notifies": 0} + + +def test_입력칸_창_먼저_받은_Esc는_안_풂(out) -> None: + for key in ("escInInput", "escModal", "escPrevented"): + assert out[key]["session"] == {"chainageM": 80.17, "structureId": None}, key + assert out[key]["form"]["resets"] == 0, key + + +def test_Esc는_값_종단_지도_폼_모두_풂(out) -> None: + esc = out["esc"] + assert esc["session"] is None + assert esc["profile"] == {"station": None, "structure": None} + assert (esc["map"]["pipe"], esc["map"]["mark"]) == (None, None) + assert esc["form"] == {"open": None, "resets": 1} + assert out["crossWhileHidden"] == [] # 숨은 횡단 보기는 안 받음 + + +def test_B05_목록_풀기는_그_길이_폼을_닫고_구독은_폼을_또_안_닫음(out) -> None: + release = out["listRelease"] + assert release["session"] is None + assert release["profile"] == {"station": None, "structure": None} + assert release["form"]["resets"] == 1 # Esc 때 한 번뿐 + + +def test_횡단_보기_Esc는_카드_폼_범위_형상_모두(out) -> None: + assert out["crossEsc"] == ["releaseForm", "focus:null", "mark:null"] + assert out["crossSession"] is None + assert out["listeners"] == 2 # 보기마다 하나 — 보이는 보기만 받음 + + +def test_배선() -> None: + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "onBasinSelected: (chainageM) => pickFromMap(chainageM)" in view + assert "onPipeSelected: (chainageM) => pickFromMap(chainageM)" in view + assert "if (!selectionSyncing) rememberPick(chainageM, null);" in view + assert "onListRelease: () => rememberPick(null, null)" in view + assert "form: panel.structures" in view + assert len(view.splitlines()) <= 700 + panel = _read("B05_Profile", "B05_Profile_UI_Panel.ts") + assert "onListRelease: callbacks.onListRelease" in panel + drainage = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts") + assert "if (notify) callbacks.onPipeSelected?.(pickedChainage);" in drainage + assert "selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);" in drainage + listing = _read("B05_Profile", "B05_Profile_UI_Structures_List.ts") + assert "< PIPE_MATCH_M" in listing and "< 0.05" not in listing + structures = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") + assert "selectedPipeChainage = kept.chainage_m;" in structures + # 저장 · 새로 읽기 뒤 목록 주입이 관 폼을 닫지 않음(닫히며 종단 선까지 꺼졌음) + assert "if (!applyingLive && selectedPipeChainage === null)" in structures + assert len(structures.splitlines()) <= 700 + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "releaseForm: () => structuresPanel.showAtChainage(null)" in cross + assert len(cross.splitlines()) <= 685 diff --git a/resources/tester/test_233_4_empty_click.py b/resources/tester/test_233_4_empty_click.py new file mode 100644 index 000000000..5c224ef55 --- /dev/null +++ b/resources/tester/test_233_4_empty_click.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""233-4 빈 곳 누름 = 어디서도 안 풂 — 3D(232-1)와 같은 한 규칙. + +까닭: 종단 그래프 · 유토곡선 빈 곳 누름(08-04)과 횡단 그리드(카드 사이 · 밖) 빈 곳 누름(08-02)은 고름을 풀었고 +3D 빈 곳 누름(232-1)만 안 풀어 화면마다 「해제」 뜻이 달랐음(조사 AF 5절 5). +고침: 빈 곳 누름 듣개를 뗌 · 풀기는 Esc(`design_pick_escape`) 또는 같은 것 다시 누름 한 길 · +도구([직선화] · [쉬프트] · [자유지정]) 중 빈 곳 누름은 도구 몫 그대로. +진짜 소스의 누름 처리 본문을 `helper_233_4_empty_click.cjs` 가 떼어 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_4_empty_click.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) -> None: + assert out["profileEmpty"] == [] # 옛 코드 = 측점 · 알약 · 폼 풂 + + +def test_종단_도구_중_빈_곳_누름은_도구_몫_그대로(out) -> None: + assert out["profileEmptyTool"] == ["tool:42"] + + +def test_횡단_그리드_빈_곳_누름은_고름_유지(out) -> None: + assert out["gridEmpty"] == [] # 옛 코드 = clearSelection + assert out["gridListeners"] == 1 # 노면공 형상 누름만 남음(189-1) + + +def test_같은_것_다시_누름은_풂(out) -> None: + assert (out["profileFirst"], out["profileAgain"]) == ("s120", None) + assert (out["cardFirst"], out["cardAgain"]) == ("s120", None) + + +def test_Esc는_풂(out) -> None: + assert out["beforeEsc"] == {"chainageM": 120, "structureId": None} + assert out["afterEsc"] is None + + +def test_유토곡선_빈_곳_누름도_안_풂() -> None: + mass = _read("B05_Profile", "B05_Profile_UI_Profile_MassHaul.ts") + render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") + assert "onClearSelection" not in mass and "onClearSelection" not in render diff --git a/resources/tester/test_234_1_structure_station.py b/resources/tester/test_234_1_structure_station.py new file mode 100644 index 000000000..885ec58af --- /dev/null +++ b/resources/tester/test_234_1_structure_station.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +"""234-1 구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안 붙기 · [정수 맞춤] 단추. + +9-29 「저장값 소수 · 표기만 정수」(20장)를 「저장값도 정수 m」로 바꿈 +(사용자 확정 10-10 · 조사 AF 7절 A+B+C). +맞춤 규칙 한 벌 = 파이썬 `common_util_structure_station.py` ↔ +TS `common_util_structure_station.ts`(거울). +화면 쪽 진짜 TS(폼 칸 · 구조물 옮김 · 단추 · 배수유역도 관 · 계획고 편집 따라감)는 +`helper_234_1_structure_station.cjs` 가 Node 로 돌린다. 서버 자동 배치 · 노선 재투영은 파이썬으로. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402 +from common_util.common_util_route_geometry import RouteVertex, StructureCandidate # noqa: E402 +from common_util.common_util_structure_station import ( # noqa: E402 + general_stations, + snap_candidates, + snap_structure_station, +) + +TOTAL = 279.8 +STATIONS = general_stations(TOTAL, 20.0) +# (입력, 기대) — 소수 → 정수 · 일반 측점 1 m 안 붙기 · 끝 측점(소수) 붙기 +CASES = [ + (80.17, 80.0), # 관 80.17 = 카드 80 한 자리 + (80.6, 80.0), # 1 m 안 → 일반 측점 + (80.99, 80.0), + (81.0, 81.0), # 꼭 1 m 는 밖 — 81 에 둘 수 있음(방향키가 갇히지 않음) + (50.5, 51.0), # .5 는 올림(파이썬 · TS 같은 꼴) + (50.4, 50.0), + (263.31, 263.0), + (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 안 + (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 는 끝 측점에 붙어 제자리 → 한 걸음 더 + ((279.8, 1), 279.8), # 노선 밖은 제자리 + ((0.0, -1), 0.0), +] +STRUCTURES = [ + {"structure_id": "sign", "placement": "point", "chainage_m": 127.86}, + { + "structure_id": "wall", + "placement": "interval", + "chainage_m": 80.17, + "start_m": 75.17, + "end_m": 95.67, + }, + {"structure_id": "ok", "placement": "point", "chainage_m": 40.0}, + {"structure_id": "near", "placement": "point", "chainage_m": 20.4}, +] +PIPES = [80.17, 120.0, 120.3, 263.31, 140.0] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s234") / "in.json" + source.write_text( + json.dumps( + { + "stations": STATIONS, + "samples": [value for value, _ in CASES], + "steps": [list(args) for args, _ in STEPS], + "structures": STRUCTURES, + "pipes": PIPES, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_234_1_structure_station.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_general_stations() -> None: + assert STATIONS == [float(index * 20) for index in range(14)] + [279.8] + + +@pytest.mark.parametrize(("value", "expected"), CASES) +def test_snap_python(value: float, expected: float) -> None: + assert snap_structure_station(value, STATIONS) == expected + # 두 번 맞춰도 같음 + assert snap_structure_station(expected, STATIONS) == expected + + +def test_mirror_python_ts(ts) -> None: + assert ts["snap"] == [snap_structure_station(value, STATIONS) for value, _ in CASES] + assert ts["snapTwice"] is True + + +def test_arrow_step_one_metre(ts) -> None: + assert ts["step"] == [expected for _, expected in STEPS] + + +def test_general_registry_skips_irregular(ts) -> None: + assert ts["general"] == STATIONS # 구조물 측점 80.17(irregular)은 일반 측점 아님 + assert ts["defaultSnap"] == 80 + + +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["typedOne"] == 81 + assert ts["untouched"] == 80.17 # 안 고친 옛 값은 그대로(단추 몫) + + +def test_moved_structure_range_ends_integer(ts) -> None: + assert ts["movedPoint"]["chainage_m"] == 128 + wall = ts["movedWall"] + assert (wall["chainage_m"], wall["start_m"], wall["end_m"]) == (80, 75, 96) + drag = ts["movedDrag"] + assert (drag["chainage_m"], drag["start_m"], drag["end_m"]) == (133, 133, 145) + + +def test_snap_button_plan_and_run(ts) -> None: + plan = ts["plan"] + assert [entry["index"] for entry in plan["structures"]] == [0, 1, 3] + assert plan["pipes"] == [[80.17, 80], [263.31, 263]] + assert plan["skipped"] == 1 # 120.3 → 120 자리에 관이 이미 있음 + run = ts["run"] + assert run["count"] == 5 and run["emits"] == 1 and run["closed"] == 1 + assert [s["chainage_m"] for s in run["structures"]] == [128, 80, 40, 20] + assert (run["structures"][1]["start_m"], run["structures"][1]["end_m"]) == (75, 96) + # 관은 하나씩 폼 [수정]과 같은 길 — 시설 속성이 그대로 실림 + 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 "1건은 그대로" in run["toasts"][-1]["message"] + # 두 번째 누름 — 옮길 것 없음(겹친 관만 알림) + assert ts["second"]["count"] == 0 + assert "1건은 맞출 자리에 다른 관" in ts["second"]["toasts"][0]["message"] + + +def test_plan_edit_follows_batch_move(ts) -> None: + # 213-2 규칙만으로는 여럿을 한꺼번에 옮기면 못 따라감(null) → 맞춘 자리로 따라감 + assert ts["follow"] == [{"old": None, "next": 80}, {"old": None, "next": 263}] + + +def test_drainage_map_pipe_integer(ts) -> None: + assert ts["editor"] == [141, 200] # 옮김 141.37 → 141 · 추가 200.6 → 일반 측점 200 + + +def _vertices() -> list[RouteVertex]: + return [RouteVertex(x=0.0, y=0.0, z=0.0, chainage_m=0.0), RouteVertex(TOTAL, 0.0, 0.0, TOTAL)] + + +def test_server_auto_placement_integer() -> None: + vertices = _vertices() + placed = [ + StructureCandidate(chainage_m=80.374, x=80.374, y=0.0, reason="stream"), + StructureCandidate(chainage_m=150.6, x=150.6, y=0.0, reason="spacing"), + StructureCandidate(chainage_m=150.9, x=150.9, y=0.0, reason="spacing"), + ] + snapped = snap_candidates(placed, vertices) + assert [(p.chainage_m, p.x, p.reason) for p in snapped] == [ + (80.0, 80.0, "stream"), + (151.0, 151.0, "spacing"), # 같은 자리로 모인 둘째는 버림 + ] + + +def test_server_reprojection_integer() -> None: + points = [PipePoint(chainage_m=70.0, x=80.374, y=3.0, start_m=75.0, end_m=85.0)] + moved = project_pipe_points(points, _vertices()) + assert moved[0].chainage_m == 80.0 + assert (moved[0].start_m, moved[0].end_m) == (85.0, 95.0) # 구간은 같은 만큼 밀림 + + +def test_wiring_source() -> None: + """받는 곳마다 맞춤이 걸렸는지(짝 머리 · 폼 · 끌기 · 방향키 · 관 · 단추).""" + + def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + assert "common_util_structure_station.py" in read( + "common_util/common_util_structure_station.ts" + ) + assert "common_util_structure_station.ts" in read( + "common_util/common_util_structure_station.py" + ) + 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 "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 + assert "Math.abs(fromChainageM - toChainageM) > 0.005" in b06 + assert "setGeneralStations" in read("B06_Section/B06_Section_Section_Store.ts") + assert "snap_candidates(" in read("common_util/common_util_drainage_detail.py") + for rel in ( + "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", + "B05_Profile/B05_Profile_UI_IrregularStations.ts", + "B05_Profile/B05_Profile_UI_Drainage_Parts.ts", + "B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts", + ): + assert "< 0.51" not in read(rel), rel # 찾는 폭 = PIPE_MATCH_M 한 값 diff --git a/resources/tester/test_235_1_b09_bill_tree.py b/resources/tester/test_235_1_b09_bill_tree.py new file mode 100644 index 000000000..46b549af0 --- /dev/null +++ b/resources/tester/test_235_1_b09_bill_tree.py @@ -0,0 +1,354 @@ +"""235-1 B09 설계내역서 = 접히지 않는 트리 · 더블클릭 입력 · 고른 줄 상자. + +- 폴더 줄(자식 있는 줄 · 제목 · 묶음) = 이름만 고침 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용 · 연결 없음) +- 파일 줄(항목) = 이름 · 규격 · 단위 · 수량 입력 + 「연결 ▾」 +- 트리 안내선(├ is-tee · └ is-end · │ is-bar) · 폴더 · 파일 아이콘(공용 나무 꼴) = 공용 표 level.tree +- 공용 표 editOnDouble = 고칠 칸도 글 · 한 번 누름 = 줄 고름 · 더블클릭 · edit() = 그 칸만 글 상자 · Enter · 초점 떠남 = 끝 +- 레벨 키 = 고른 줄에서 Enter · F2 = 입력 시작(편집 중 · 도구 단추 · 고른 줄 없음 = 안 먹음) +- 고른 줄 = 줄 전체 상자 테두리(테마 토큰 · 배경 대신) +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_182_b09_bill_level import BOOKS, INPUTS, USER # noqa: E402 +from test_184_3_b09_bill_menu import _node, _ts # noqa: E402 +from test_200_2_b09_bill_level_bar import BAR_JS # noqa: E402 +from test_184_3_b09_bill_menu import ROWS as FAKE_ROWS # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) +TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")) +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"] +SUMS = ["sum", "labor", "material", "expense"] +FOLDER_SVG, LEAF_SVG = "M1.5 3.5h4", "M3.5 1.5h6" + + +def _rows(): + return compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"] + + +# --- 설계내역서 칸: 폴더 = 수식 · 파일 = 입력 ------------------------------------------------ + +SPEC_JS = """ +const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser: () => {} }; +const spec = buildBill(ctx); +const keys = ["name", "spec", "unit", "qty", "price_sum", "sum", "labor", "material", "expense", "connect"]; +console.log(JSON.stringify({ + double: spec.editOnDouble, tree: spec.level.tree, + rows: spec.rows.map((r, i) => ({ key: r.key, kind: r.kind, connect: r.connect, + edit: keys.filter((k) => spec.editable(i, k)), + tips: Object.fromEntries(keys.map((k) => [k, spec.tip(i, k) ?? null])) })), +})); +""" + + +def _spec(rows): + return json.loads( + _node(_ts(SRC) + SPEC_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))) + ) + + +def test_폴더_줄_이름만_수량_단가_빈칸_금액_자손_합(): + rows = _rows() + by = {r["key"]: r for r in rows} + folders = [r for r in rows if r["kind"] != "item"] + assert {"sec:1", "sec:1.1", "hand:p", "hand:q"} <= {r["key"] for r in folders} + for f in folders: + assert f.get("qty") is None and all(f.get(f"price_{p}") is None for p in [*SUMS[1:], "sum"]) + kids = [r for r in rows if r["kind"] == "item" and _under(by, r, f["key"])] + for p in SUMS: # 금액 = 자손 항목 합(셈이 냄) + assert f[p] == sum(r[p] or 0 for r in kids), (f["key"], p) + out = _spec(rows) + assert out["double"] is True and out["tree"] is True + for r in out["rows"]: + if r["kind"] == "item": + continue + assert r["edit"] == ["name"], r["key"] # 이름만 + assert r["connect"] == "" # 연결 없음 + for p in SUMS: + assert r["tips"][p] == "수식 = 아래 항목 합(읽기 전용)" + assert r["tips"]["qty"] is None and r["tips"]["name"] is None + + +def _under(by, row, key): + at = by.get(row.get("parent") or "") + while at: + if at["key"] == key: + return True + at = by.get(at.get("parent") or "") + return False + + +def test_파일_줄_입력_연결(): + out = _spec(_rows()) + items = [r for r in out["rows"] if r["kind"] == "item" and r["key"] != "fee"] + assert items + for r in items: + assert r["edit"] == ["name", "spec", "unit", "qty"], r["key"] + assert all(r["tips"][p] is None for p in SUMS) + linked = [r for r in items if not r["key"].startswith("mat:")] + assert linked and all(r["connect"].endswith("▾") for r in linked) + + +# --- 트리 안내선 · 아이콘 --------------------------------------------------------------------- + + +def _guides_py(levels): + """거울 — 아래에 같은 레벨 형제(더 얕은 줄 전까지)가 있으면 ├ · 없으면 └ · 윗대 형제가 있으면 │.""" + + def more(i, d): + for lv in levels[i + 1 :]: + if lv < d: + return False + if lv == d: + return True + return False + + out = [] + for i, lv in enumerate(levels): + g = [("is-bar", d - 2) for d in range(2, lv) if more(i, d)] + if lv >= 2: + g.append(("is-tee" if more(i, lv) else "is-end", lv - 2)) + out.append(g) + return out + + +GUIDE_JS = """ +const parse = (h) => [...h.matchAll(/ui-data-table__guide (is-\\w+)" style="--g:(\\d+)"/g)].map((m) => [m[1], Number(m[2])]); +const levels = __LEVELS__; +const unit = treeGuides(levels).map(parse); +const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser: () => {} }; +const spec = buildBill(ctx); +const html = renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: spec.level, + editable: spec.editable, editOnDouble: true }); +const trs = html.split("")[1].split("")[0].split("").filter(Boolean); +const lvCell = (tr) => (tr.match(/]*>(.*?)<\\/td>/) || [])[1] ?? ""; +console.log(JSON.stringify({ + unit, + bill: trs.map((tr) => parse(lvCell(tr))), + icons: trs.map((tr) => lvCell(tr).includes("__FOLDER__") ? "folder" : lvCell(tr).includes("__LEAF__") ? "leaf" : null), + tree: trs.map((tr) => /has-tree/.test(tr)), + levels: spec.rows.map((r) => r.level), kinds: spec.rows.map((r) => r.kind), + plain: renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: { ...spec.level, tree: false } }) + .includes("ui-data-table__guide"), +})); +""" + + +def _guides(levels, rows): + body = ( + GUIDE_JS.replace("__LEVELS__", json.dumps(levels)) + .replace("__ROWS__", json.dumps(rows, ensure_ascii=False)) + .replace("__FOLDER__", FOLDER_SVG) + .replace("__LEAF__", LEAF_SVG) + ) + return json.loads(_node(_ts(SRC) + TABLE + body)) + + +def test_트리_안내선_한_칸씩(): + levels = [1, 2, 3, 3, 2, 3, 1, 2] + out = _guides(levels, _rows()) + assert out["unit"] == [ + [], + [["is-tee", 0]], + [["is-bar", 0], ["is-tee", 1]], + [["is-bar", 0], ["is-end", 1]], + [["is-end", 0]], + [["is-end", 1]], + [], + [["is-end", 0]], + ] + assert out["unit"] == [[list(g) for g in row] for row in _guides_py(levels)] + + +def test_설계내역서_안내선_아이콘_공용_나무_꼴(): + out = _guides([1], _rows()) + assert out["bill"] == [[list(g) for g in row] for row in _guides_py(out["levels"])] + assert any(out["bill"]) # 실제로 선이 그려짐 + assert out["icons"] == ["leaf" if k == "item" else "folder" for k in out["kinds"]] + assert all(out["tree"]) and out["plain"] is False # 나무 옵션 없으면 안내선 없음 + table = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") + tree = (ROOT / "ui_template" / "ui_template_tree.ts").read_text("utf-8") + assert FOLDER_SVG in table and LEAF_SVG in table # 아이콘 한 벌 — 공용 나무가 가져다 씀 + assert 'import { TREE_ICON } from "./ui_template_data_table";' in tree + assert "TREE_ICON.folder : TREE_ICON.leaf" in tree and FOLDER_SVG not in tree + + +def test_안내선_CSS_테마_토큰(): + css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + block = css.split(".ui-data-table__guide::before {")[1].split("}")[0] + assert "border-left: 1px solid var(--color-border);" in block + assert ".ui-data-table__guide.is-end::before {\n bottom: 50%;" in css + assert ".ui-data-table__guide.is-tee::after,\n.ui-data-table__guide.is-end::after {" in css + assert "left: calc(var(--spacing-8) + var(--g, 0) * var(--dt-indent));" in css + assert ".ui-data-table__table td.has-tree:not(.is-frozen) {\n position: relative;" in css + + +# --- 공용 표 더블클릭 입력 --------------------------------------------------------------------- + +DOM_JS = """ +const doc = { activeElement: null }; +globalThis.document = doc; +globalThis.CSS = { escape: (s) => s }; +class FakeInput { + constructor(owner, i, k) { this.owner = owner; this.dataset = { i, k }; this.value = "v"; } + focus() { doc.activeElement = this; } + select() { this.selected = true; } +} +globalThis.HTMLInputElement = FakeInput; +class Root { + constructor() { this.on = {}; this.html = ""; this.clientHeight = 600; this.scrollTop = 0; this.dataset = {}; } + set innerHTML(h) { this.html = h; } + get innerHTML() { return this.html; } + addEventListener(t, f) { (this.on[t] ??= []).push(f); } + fire(t, ev) { for (const f of this.on[t] ?? []) f(ev); } + contains(x) { return x === this || x?.owner === this; } + focus() { doc.activeElement = this; } + querySelector(sel) { + const m = sel.match(/^input\\[data-i="(\\d+)"\\]\\[data-k="(\\w+)"\\]$/); + if (!m || !this.html.includes(`data-i="${m[1]}" data-k="${m[2]}"`)) return null; + return new FakeInput(this, m[1], m[2]); + } +} +doc.createElement = () => new Root(); +const target = (root, i, c) => { + const td = { dataset: {}, cellIndex: c }; + const tr = { dataset: { i: String(i) }, owner: root }; + return { closest: (s) => (s.startsWith("tr") ? tr : td) }; +}; +const inputs = (root) => [...root.html.matchAll(/]*data-i="(\\d+)" data-k="(\\w+)"/g)].map((m) => `${m[1]}:${m[2]}`); +const columns = [{ key: "name", label: "명칭" }, { key: "qty", label: "수량", kind: "number" }]; +const rows = [{ name: "폴더", qty: null }, { name: "파일", qty: 3 }]; +const log = []; +const t = createDataTable({ columns, rows, editOnDouble: true, editable: (i) => i === 1, + onCellClick: (i, k) => log.push(`click ${i}:${k}`), onEdit: (i, k, v) => log.push(`edit ${i}:${k}=${v}`) }); +const r = t.root; +const out = { start: inputs(r) }; +r.fire("click", { target: target(r, 1, 0) }); +out.click = [inputs(r), [...log]]; +r.fire("dblclick", { target: target(r, 0, 0) }); +out.folderDbl = inputs(r); +r.fire("dblclick", { target: target(r, 1, 1) }); +const input = doc.activeElement; +out.dbl = [inputs(r), input?.dataset ?? null, !!input?.selected]; +let stopped = 0; +const key = (k, more = {}) => r.fire("keydown", { key: k, target: input, isComposing: false, ...more, + preventDefault() {}, stopPropagation() { stopped += 1; } }); +key("Enter", { isComposing: true }); +out.composing = [doc.activeElement === input, stopped]; +key("Enter"); +out.enter = [doc.activeElement === r, stopped]; +r.fire("change", { target: input }); +r.fire("focusout", { target: input }); +setTimeout(() => { + out.after = [inputs(r), log.at(-1)]; + out.editName = [t.edit(1), inputs(r)]; // 마지막 누른 열 = 명칭 + out.editQty = [t.edit(1, "qty"), inputs(r)]; + out.editFolder = t.edit(0); + const plain = createDataTable({ columns, rows, editable: (i) => i === 1 }); + out.plain = [inputs(plain.root), plain.edit(1)]; + console.log(JSON.stringify(out)); +}, 5); +""" + + +def test_한_번_누름_고름_더블클릭_입력_Enter_끝(): + out = json.loads(_node(TABLE + DOM_JS)) + assert out["start"] == [] # 고칠 칸도 처음엔 글 + assert out["click"] == [[], ["click 1:name"]] # 한 번 누름 = 줄 고름(글 상자 안 생김) + assert out["folderDbl"] == [] # 못 고치는 칸 더블클릭 = 그대로 + assert out["dbl"] == [["1:qty"], {"i": "1", "k": "qty"}, True] # 그 칸만 · 초점 · 글 전체 고름 + assert out["composing"] == [True, 0] # 한글 조합 중 Enter = 조합 끝 + assert out["enter"] == [True, 1] # Enter = 표에 초점 · 문서로 안 감 + assert out["after"] == [[], "edit 1:qty=v"] # 초점 떠남 = 입력 끝 · onEdit + assert out["editName"] == [True, ["1:name"]] + assert out["editQty"] == [True, ["1:qty"]] + assert out["editFolder"] is False + assert out["plain"] == [["1:name", "1:qty"], False] # 옵션 없는 표 = 예전대로 바로 글 상자 + + +# --- 레벨 키: Enter · F2 = 입력 시작 -------------------------------------------------------- + +KEYS_JS = """ +const edits = []; +const out = {}; +draw(); +out.none = [key("Enter"), edits.length]; +pick("earth:5"); +root.focus(); +out.enter = [key("Enter"), [...edits]]; +out.f2 = [key("F2"), [...edits]]; +doc.activeElement = tr("earth:5").kids[0].kids[0]; +out.editing = [key("Enter"), edits.length]; +doc.activeElement = bar.tools[0]; +out.tool = [key("Enter"), edits.length]; +root.focus(); +out.composing = [key("Enter", { isComposing: true }), edits.length]; +out.order = (user.bill?.rows ?? []).length; +console.log(JSON.stringify(out)); +""" + + +def test_고른_줄_Enter_F2_입력_시작(): + setup = BAR_JS.split("const out = {};")[0].replace( + "bar = levelBar(root, table);", "bar = levelBar(root, table, (i) => edits.push(i));" + ) + body = (setup + KEYS_JS).replace("__ROWS__", json.dumps(FAKE_ROWS, ensure_ascii=False)) + names = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", + "UI_Tabs_Bill_Drag", "UI_Tabs_Bill_Level"] # fmt: skip + out = json.loads(_node(_ts(names) + body)) + at = [r["key"] for r in FAKE_ROWS].index("earth:5") + assert out["none"] == [False, 0] # 고른 줄 없음 + assert out["enter"] == [True, [at]] + assert out["f2"] == [True, [at, at]] + assert out["editing"] == [False, 2] # 글 상자 안 Enter = 공용 표 몫(입력 끝) + assert out["tool"] == [False, 2] # 도구 단추 Enter = 단추 몫 + assert out["composing"] == [False, 2] + assert out["order"] == 0 # 줄 차례 · 레벨은 그대로 + + +# --- 고른 줄 상자 · 배선 ----------------------------------------------------------------------- + + +def test_고른_줄_상자_테두리_테마_토큰(): + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + sel = '.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td' + top = "inset 0 2px 0 var(--color-accent),\n inset 0 -2px 0 var(--color-accent)" + assert f"{sel} {{\n box-shadow:\n {top};" in css + assert ( + f"{sel}:first-child {{\n box-shadow:\n {top},\n inset 2px 0 0 var(--color-accent);" + in css + ) + assert ( + f"{sel}:last-child {{\n box-shadow:\n {top},\n inset -2px 0 0 var(--color-accent);" + in css + ) + # 배경 대신 — 고른 줄 배경 = 보통 줄 배경(고친 칸 색은 그대로) + bill = '.b09-estimation [data-b09-table="bill"] tbody tr.is-focus' + assert ( + f"{bill}:not(:hover) td:not(.is-edited) {{\n background-color: var(--color-canvas);" in css + ) + assert ( + f"{bill}.is-alt:not(:hover) td:not(.is-edited) {{\n background-color: var(--dt-zebra);" + in css + ) + + +def test_Shell_배선(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "editOnDouble: spec.editOnDouble ?? true," in shell # 243-1 기본 = 더블클릭 + assert "levelBar(table.root, bill, (i) => table.edit(i))" in shell + for name in [ + "ui_template/ui_template_data_table.ts", + "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts", + ]: + assert len((ROOT / name).read_text("utf-8").splitlines()) <= 700, name diff --git a/resources/tester/test_236_1_hint_one_line.py b/resources/tester/test_236_1_hint_one_line.py new file mode 100644 index 000000000..edd8941c1 --- /dev/null +++ b/resources/tester/test_236_1_hint_one_line.py @@ -0,0 +1,160 @@ +"""236-1 B08 · B09 표 아래 안내 글 — 제작 · 셈 설명 지움 · 늘 한 줄 · 페이지 스크롤 0. + +- 안내 글 낱말에 제작 · 셈 설명(ROUND · 「 = 」 · 장 번호 · M01 · 곱셈 나눗셈 기호)이 없음 +- 안내 글 한 조각 70 자 이하(긴 글은 줄임표 + 공용 호버) +- B09 안내 글(lineHint) · 레벨 안내 · B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표) +- 창 크기(1080 · 768 · 600)마다 한 줄 안내 + 표 칸 = 창 끝(199-1 fillViewport 셈) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B08 = ROOT / "B08_Quantity" +B09 = ROOT / "B09_Estimation" + +STRING = re.compile(r'"([^"\\\n]*)"|`([^`\n]*)`') +FORBID = ["ROUND", " = ", "÷", "×", "Σ", "M01", "역전개", "원문"] +CHAPTER = re.compile(r"\(\d{2,3}-\d\)|\b\d{1,3}-\d-\d\b|\b\d{2,3}-\d\b") +MAX_PIECE = 70 + + +def _read(path: Path) -> str: + return path.read_text("utf-8") + + +def _pieces(expr: str) -> list[str]: + out = [] + for m in STRING.finditer(expr): + text = m.group(1) if m.group(1) is not None else m.group(2) + out.append(re.sub(r"\$\{[^}]*\}", "", text).strip()) + return [p for p in out if p] + + +def _b09_hints() -> list[tuple[str, str]]: + found: list[tuple[str, str]] = [] + for path in sorted(B09.glob("B09_Estimation_UI_Tabs_*.ts")): + src = _read(path) + for m in re.finditer(r"\bhint:\s*(.*?),\n\s+\w+[:(]", src, re.S): + found += [(path.name, p) for p in _pieces(m.group(1))] + for m in re.finditer( + r"^const (?:LINK_HINT|BLANK|HINT|EXPENSE_NONE|GUIDE)\s*=\s*(.*?);\n", src, re.S | re.M + ): + found += [(path.name, p) for p in _pieces(m.group(1))] + return found + + +def _b08_hints() -> list[tuple[str, str]]: + found: list[tuple[str, str]] = [] + for name in ( + "B08_Quantity_UI_Earthwork.ts", + "B08_Quantity_UI_Earthwork_Sheets.ts", + "B08_Quantity_UI_Material.ts", + ): + src = _read(B08 / name) + for m in re.finditer(r"footNote\(((?:\s|\+|\"[^\"\n]*\"|`[^`\n]*`)+)\)", src): + found += [(name, p) for p in _pieces(m.group(1))] + for m in re.finditer(r"^const HINTS[^=]*=\s*\{(.*?)\n\};", src, re.S | re.M): + found += [(name, p) for p in _pieces(m.group(1))] + for m in re.finditer(r"^const HIDDEN\s*=\s*(.*?);\n", src, re.S | re.M): + found += [(name, p) for p in _pieces(m.group(1))] + return found + + +def _server_notes() -> list[tuple[str, str]]: + found: list[tuple[str, str]] = [] + for name in ("Router_Summary", "Router_Material"): + src = _read(B08 / f"B08_Quantity_{name}.py") + for m in re.finditer(r"notes\.append\(\s*(.*?)\)\n", src, re.S): + found += [(name, p) for p in _pieces(m.group(1).replace('f"', '"'))] + sangeun = _read(B09 / "B09_Estimation_Sangeun.py") + for m in re.finditer(r'^(?:NO_SOURCE|EMPTY) = "([^"]*)"', sangeun, re.M): + found.append(("Sangeun", m.group(1))) + return found + + +def _all() -> list[tuple[str, str]]: + return _b09_hints() + _b08_hints() + _server_notes() + + +def test_안내_글_모음이_비지_않음(): + assert len(_b09_hints()) >= 20 + assert len(_b08_hints()) >= 4 + assert len(_server_notes()) >= 7 + + +def test_제작_셈_설명_낱말_없음(): + bad = [] + for where, text in _all(): + bad += [(where, token, text) for token in FORBID if token in text] + if CHAPTER.search(text): + bad.append((where, "장 번호", text)) + assert not bad, bad + + +def test_안내_글_조각_짧음(): + long = [(w, t) for w, t in _all() if len(t) > MAX_PIECE] + assert not long, long + + +# ── 늘 한 줄 ─────────────────────────────────────────────────────────────── + + +def _block(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_B09_안내_글_한_줄(): + css = _read(B09 / "B09_Estimation_UI_Page.css") + block = _block(css, ".b09-estimation__line-hint") + assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block + shell = _read(B09 / "B09_Estimation_UI_Shell.ts") + assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 한 줄 + assert "export function lineHint" in _read(B09 / "B09_Estimation_UI_Tabs_Groups.ts") + level = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts") + assert 'guide.className = "ui-general-block__empty b09-estimation__line-hint"' in level + + +def test_B08_안내_줄_한_줄(): + css = _read(B08 / "B08_Quantity_UI_Page.css") + block = _block(css, ".b08-quantity__foot") + assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block + assert "display" not in block # hidden 속성(콘크리트 빈 까닭)을 덮지 않음 + foot = _read(B08 / "B08_Quantity_UI_Foot.ts") + assert "b08-quantity__foot" in foot and "attachHoverInfo" in foot + for name in ("Earthwork", "Earthwork_Sheets", "Material", "Summary"): + assert "footNote" in _read(B08 / f"B08_Quantity_UI_{name}.ts"), name + + +def test_B08_표_아래_안내는_footNote만(): + material = _read(B08 / "B08_Quantity_UI_Material.ts") + assert "note(BASIS)" not in material # 「구조물 수량 기준 …」 제작 설명 줄 없음 + assert "const excel = footNote(" in material + assert "const concreteNote = footNote(" in material + summary = _read(B08 / "B08_Quantity_UI_Summary.ts") + assert 'footNote(summary.notes.join(" · "))' in summary # 서버 알림 = 한 줄로 이어 붙임 + sheets = _read(B08 / "B08_Quantity_UI_Earthwork_Sheets.ts") + assert "tableCard(haulNote(table), grid.root, averages)" in sheets # 제작 설명 줄 없음 + + +# ── 창 끝까지 셈 ──────────────────────────────────────────────────────────── + + +def test_창_크기마다_한_줄_안내_뒤_창_끝(): + proc = subprocess.run( + ["node", str(ROOT / "resources" / "tester" / "helper_236_1_fill_sizes.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + rows = json.loads(proc.stdout) + assert [r["viewport"] for r in rows] == [1080, 768, 600] + for r in rows: + assert r["below"] == 18 + 8 + 12 + 8 # 안내 한 줄 + 줄 사이 + 카드 + 본문 여백 + assert r["bottom"] == r["viewport"], r # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0 diff --git a/resources/tester/test_238_1_save_keeps_structure_stations.py b/resources/tester/test_238_1_save_keeps_structure_stations.py new file mode 100644 index 000000000..d2ae972af --- /dev/null +++ b/resources/tester/test_238_1_save_keeps_structure_stations.py @@ -0,0 +1,163 @@ +"""238-1 B05 [저장]이 구조물 측점을 지우지 않음. + +[저장](경로 확정 길 · `_append_irregular_cross_sections`)은 종단 비정규 측점을 통째로 갈아 끼운다. +그 목록을 화면이 보낸 「관 + A군」으로 쓰면 서버가 심어 둔 소단 · 옹벽 · 흙막이 · 독립 기슭막이 +시작 · 종료 측점이 빠진다 → 서버 측점 규칙(`resolve_extra_stations`)으로 만든 목록을 씀. + +- 사본: 임시 폴더 프로젝트(직선 노선 · 관 하나 + 기슭막이 · 소단 · 옹벽 · 흙막이) +- 앞: B06 [측점 만들기]와 같은 길(서버 목록으로 심음) · 뒤: 화면 목록(관만)을 실은 [저장] +- 판정: 앞뒤 구조물 측점 같음 · 소단 측점(깊이 patch 가 붙는 자리) 남음 · 관 옮김은 반영 +""" + +import asyncio +import json +from pathlib import Path + +import numpy as np + +import B05_Profile.B05_Profile_Engine_Sections as sections +import B05_Profile.B05_Profile_Router_Confirm as confirm +from B05_Profile.B05_Profile_Schema import RouteConfirmRequest +from common_util.common_util_drainage_pipes import route_signature +from common_util.common_util_route_geometry import RouteVertex + +LINE = [[0.0, 0.0, 100.0], [350.0, 0.0, 100.0]] +ROUTE = "B05_Profile/route/route.geojson" +LONG = "B06_Section/longitudinal/longitudinal.json" +PIPE = {"chainage_m": 150.0, "source": "user", "options": {"pipe_diameter_mm": 800}} +REVET = { + "chainage_m": 301.0, + "source": "user", + "facility": "revetment", + "start_m": 296.0, + "end_m": 316.0, +} +STRUCTURES = [ + {"type_id": "soil_guard", "chainage_m": 45.0, "start_m": 41.0, "end_m": 51.0}, + {"type_id": "berm", "chainage_m": 201.0, "start_m": 196.0, "end_m": 206.0}, + {"type_id": "retaining_wall", "chainage_m": 251.0, "start_m": 246.0, "end_m": 256.0}, +] + + +class _Flat: + def sample_xy(self, xy): + count = len(np.asarray(xy, dtype=np.float64)) + return np.full(count, 100.0), np.ones(count, 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 _write_pipes(root: Path, points) -> None: + 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": points}, + ) + + +def _project(root: Path) -> None: + _write(root, ROUTE, {"geometry": {"type": "LineString", "coordinates": LINE}}) + _write_pipes(root, [PIPE, REVET]) + for item in STRUCTURES: + item["placement"] = "interval" + _write(root, "B05_Profile/route/structures.json", {"revision": 1, "structures": STRUCTURES}) + regular = [{"chainage_m": float(c), "kind": "regular"} for c in range(0, 351, 20)] + _write(root, LONG, {"stations": regular}) + + +def _irregular(root: Path) -> list[tuple[str, str]]: + data = json.loads((root / LONG).read_text(encoding="utf-8")) + return [ + (f"{float(s['chainage_m']):.2f}", s.get("structure")) + for s in data["stations"] + if s.get("kind") == "irregular" + ] + + +def _patch(monkeypatch, root: Path) -> None: + async def _value(value, *_a, **_k): + return value + + monkeypatch.setattr(sections, "build_surface_sampler", lambda *_a, **_k: _Flat()) + monkeypatch.setattr(confirm, "get_project_storage_relative_path", lambda *_a: _value("p")) + monkeypatch.setattr(confirm, "resolve_stored_project_path", lambda _p: str(root)) + monkeypatch.setattr(confirm, "get_latest_section_options", lambda *_a: _value(None)) + monkeypatch.setattr(confirm, "get_surface_crs_epsg", lambda *_a: _value(None)) + monkeypatch.setattr( + confirm, + "get_longitudinal_section", + lambda *_a: _value({"longitudinal_file_path": LONG}), + ) + + +def _plant_like_b06(root: Path) -> None: + """B06 [측점 만들기]와 같은 길 — 서버 목록으로 뜨고 종단에 병합.""" + pipes = sections._load_pipe_points(root, LINE) + stations = sections.generate_irregular_sections( + root, + ROUTE, + "csf", + "tin", + False, + extra_stations=sections.resolve_extra_stations(root, pipes), + ) + confirm._merge_irregular_into_longitudinal(root, LONG, stations) + + +def _save(root: Path, screen: list[tuple[float, str]]) -> None: + """B05 [저장] — 화면은 관 · A군 목록만 실어 보냄.""" + request = RouteConfirmRequest( + filter_key="csf", + method="tin", + surface_model_id=1, + irregular_stations=[{"chainage_m": c, "structure": s} for c, s in screen], + ) + assert request.can_regenerate() + asyncio.run( + confirm._append_irregular_cross_sections( + None, "p", {"id": 1, "route_data_path": ROUTE}, request + ) + ) + + +def test_save_keeps_server_planted_stations(tmp_path, monkeypatch): + _project(tmp_path) + _patch(monkeypatch, tmp_path) + _plant_like_b06(tmp_path) + before = _irregular(tmp_path) + labels = {label for _key, label in before} + assert {"소단", "옹벽", "흙막이", "기슭막이"} <= labels + assert [k for k, label in before if label == "기슭막이"] == ["296.00", "301.00", "316.00"] + + _save(tmp_path, [(150.0, "파형강관 D800")]) + + assert _irregular(tmp_path) == before + # 소단 깊이(194 · 측점 patch)가 붙는 자리 = 소단 측점 — 그대로 섬 + assert [k for k, label in _irregular(tmp_path) if label == "소단"] == [ + "196.00", + "201.00", + "206.00", + ] + + +def test_save_follows_moved_pipe(tmp_path, monkeypatch): + _project(tmp_path) + _patch(monkeypatch, tmp_path) + _plant_like_b06(tmp_path) + before = _irregular(tmp_path) + + # 관 150 → 165 (관 초안이 [저장] 차례상 먼저 정본에 들어감 · design_save.ts) + _write_pipes(tmp_path, [{**PIPE, "chainage_m": 165.0}, REVET]) + _save(tmp_path, [(165.0, "파형강관 D800")]) + + after = _irregular(tmp_path) + expected = sorted( + [item for item in before if item[0] != "150.00"] + [("165.00", "파형강관 D800")], + key=lambda item: float(item[0]), + ) + assert after == expected diff --git a/resources/tester/test_239_1_b09_total_bill_summary.py b/resources/tester/test_239_1_b09_total_bill_summary.py new file mode 100644 index 000000000..4e9958014 --- /dev/null +++ b/resources/tester/test_239_1_b09_total_bill_summary.py @@ -0,0 +1,117 @@ +"""239-1 B09 총괄설계내역서 맨 위 = 설계내역서 레벨 1 · 2 집계 · 누름 = 설계내역서 그 줄 빨강. + +- 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도) · 설계내역서 차례 · 레벨 그대로 +- 금액(합계 · 노무 · 재료 · 경비) = 설계내역서 그 줄 금액(Calc_Bill) · 끝 「순공사비 계」 = 총괄 가. 순공사비계 +- 줄 누름 = link {tab: bill, key} → Shell 이 설계내역서 그 줄에 is-pick(빨강 외곽선 · 고른 줄 상자와 따로) +- 아래 원가계산 줄 = 그대로(요율 입력 줄부터 같은 차례) +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_182_b09_bill_level import BOOKS, HAND, INPUTS, USER # noqa: E402 +from test_184_3_b09_bill_menu import _node, _ts # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402 + +SRC = ["Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total"] +#: Shell 이 부르는 줄 class(목록표 → 탭 길 · 181-2) — 묶음 탭 파일에서 그 함수만 +GROUPS = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8") +PICK = re.search(r"export function pickClass\(.*?\n}\n", GROUPS, re.DOTALL).group(0) +PARTS = ["sum", "labor", "material", "expense"] +#: 빈 줄 없는 판(연결 없는 earth:5 · hand:c 뺌) — 총괄 순공사비계가 값을 받음 +FULL_INPUTS = {**INPUTS, "bill": {"items": [i for i in INPUTS["bill"]["items"] if i["ref"]]}} +FULL_USER = {"bill": {"rows": [h for h in HAND if h["key"] != "hand:c"]}} + +JS = """ +const sheets = __SHEETS__; +const ctx = { sheets, payload: {}, user: { estimate: {} }, setUser: () => {} }; +const spec = buildTotal(ctx); +const plain = buildTotal({ ...ctx, sheets: { total: sheets.total } }); +console.log(JSON.stringify({ + rows: spec.rows.map((r, i) => ({ ...r, link: spec.link(i, "name"), cls: spec.rowClass(i), + edit: ["amount", "rate", "hand"].some((k) => spec.editable(i, k)) })), + plain: plain.rows.map((r) => r.key), + pick: [pickClass([3, 3], 2), pickClass([3, 3], 3), pickClass(null, 3)], +})); +""" + + +def _run(inputs, user): + bill = compute_estimation(inputs, user, None, BOOKS)["bill"] + total = compute_total(bill["sums"], {}) + sheets = {"bill": bill, "total": total} + out = json.loads(_node(_ts(SRC) + PICK + JS.replace("__SHEETS__", json.dumps(sheets)))) + return bill, total, out + + +def test_맨_위_줄_레벨_1_2_폴더_차례_금액_같음(): + bill, _, out = _run(INPUTS, USER) + want = [ + r + for r in bill["rows"] + if r["cost"] == "direct" and (r["level"] == 1 or (r["level"] == 2 and r["kind"] != "item")) + ] + top = [r for r in out["rows"] if r["key"].startswith("bill:")] + assert [r["key"] for r in top] == [f"bill:{r['key']}" for r in want] + assert {r["level"] for r in top} == {1, 2} + assert "bill:sec:G" not in {r["key"] for r in top} # 관급 = 순공사비 밖 + assert "bill:haul:1" not in {r["key"] for r in top} # 레벨 2 항목(파일) 빠짐 + for got, src in zip(top, want): + assert got["name"] == f"{src['no']} {src['name']}" + assert [got["amount"], got["labor"], got["material"], got["expense"]] == [ + src[p] for p in PARTS + ] + assert got["cls"] == ("is-title" if src["level"] == 1 else None) + assert got["edit"] is False + # 맨 위부터 내역 줄 → 순공사비 계 → 요율 입력 줄 + keys = [r["key"] for r in out["rows"]] + assert keys[: len(top) + 2] == [r["key"] for r in top] + ["bill_sum", "in_price"] + + +def test_합_순공사비_레벨1_합_총괄_같음(): + bill, total, out = _run(FULL_INPUTS, FULL_USER) + assert bill["totals"]["direct"]["blank"] == 0 + rows = {r["key"]: r for r in out["rows"]} + s = rows["bill_sum"] + lv1 = [r for r in out["rows"] if r["key"].startswith("bill:") and r["level"] == 1] + for col, p in zip(["amount", "labor", "material", "expense"], PARTS): + assert s[col] == bill["totals"]["direct"][p] + assert s[col] == sum(r[col] for r in lv1) + assert s["amount"] == rows["direct"]["amount"] == total["sums"]["direct"] > 0 + assert s["level"] == 1 and s["link"] is None + + +def test_빈_줄_있으면_계_안내(): + bill, _, out = _run(INPUTS, USER) + s = next(r for r in out["rows"] if r["key"] == "bill_sum") + assert bill["totals"]["direct"]["blank"] == 2 + assert s["amount"] == bill["totals"]["direct"]["sum"] and "빈 줄 2" in s["note"] + + +def test_누름_설계내역서_그_줄_빨강(): + bill, _, out = _run(INPUTS, USER) + keys = {r["key"] for r in bill["rows"]} + for r in out["rows"]: + if r["key"].startswith("bill:"): + assert r["link"] == {"tab": "bill", "key": r["key"][5:]} and r["link"]["key"] in keys + else: + assert r["link"] is None # 원가계산 줄 = 이동 없음(그대로) + assert out["pick"] == [None, "is-pick is-pick-top is-pick-end", None] + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "if (bill) jumpTo(bill.rows, at);" in shell # 242-1 범위 = 그 줄 + 자손 + assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell + + +def test_설계내역서_없으면_원가계산_줄_그대로(): + _, _, out = _run(INPUTS, USER) + assert out["plain"][0] == "in_price" + assert out["plain"] == [r["key"] for r in out["rows"] if not r["key"].startswith("bill")] 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 new file mode 100644 index 000000000..eda6e364f --- /dev/null +++ b/resources/tester/test_240_1_b09_bill_folder_font_drag.py @@ -0,0 +1,55 @@ +"""PLAN 240 · 121 · 122 — B09 설계내역서 폴더 줄 글꼴 한 벌 · 들여쓰기 폭 · 끌기 미리 보기 · 데이터 점검 틈.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" + + +def _read(p: Path) -> str: + return p.read_text(encoding="utf-8") + + +def test_폴더_줄은_처음이든_레벨_고쳐_생겼든_같은_줄_꼴(): + src = _read(B09 / "B09_Estimation_UI_Tabs_Bill.ts") + assert 'const title = r.kind === "item" ? "" : "is-title";' in src + assert '"is-group"' not in src + css = _read(B09 / "B09_Estimation_UI_Page.css") + assert "tr.is-group" not in css # 옛 묶음 줄 꼴(색 다름) 없음 + + +def test_들여쓰기_한_단_넓힘_끌기_한_단과_같음(): + css = _read(ROOT / "ui_template" / "ui_template_data_table.css") + assert re.search(r"--dt-indent: 22px;", css) + drag = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Drag.ts") + assert 'getPropertyValue("--dt-indent")' in drag + + +def test_끌기_미리_보기_어디_안_몇_레벨_폴더_테두리_끄는_줄_흐림(): + drag = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Drag.ts") + for part in ( + "b09-estimation__drop-tag", + "is-drop-into", + "is-drag-src", + "못 놓음", + "안", + "맨 위", + ): + assert part in drag, part + css = _read(B09 / "B09_Estimation_UI_Page.css") + for sel in (".b09-estimation__drop-tag", "tr.is-drop-into td", "tr.is-drag-src td"): + assert sel in css, sel + # 끝나면 표시를 모두 걷음 + assert 'into?.classList.remove("is-drop-into")' in drag + assert 'el.classList.remove("is-drag-src")' in drag + + +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))" + 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_241_1_input_cell_border.py b/resources/tester/test_241_1_input_cell_border.py new file mode 100644 index 000000000..4d1b2a9d6 --- /dev/null +++ b/resources/tester/test_241_1_input_cell_border.py @@ -0,0 +1,37 @@ +"""PLAN 241-1 · 123 → 246-1 · 128 — B08 · B09 표 입력 칸 = 테두리 되돌림(241 앞 채움 색).""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CSS = ROOT / "ui_template" / "ui_template_data_table.css" + + +def _rule(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r" \{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_입력_칸_채움_색_테두리_없음(): + body = _rule(CSS.read_text(encoding="utf-8"), ".ui-data-table__input") + # 246-1 — 241 앞 모양(accent 5% 채움) · 안쪽 테두리 없음 + assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body + assert "box-shadow" not in body + assert "#" not in body # 하드코딩 색 없음 — 테마 변수만 + assert "border: 0;" in body + assert "height: var(--dt-row-h);" in body + + +def test_초점_테두리_그대로(): + body = _rule(CSS.read_text(encoding="utf-8"), ".ui-data-table__input:focus") + assert "outline: 2px solid var(--color-focus-ring);" in body + assert "outline-offset: -2px;" in body + + +def test_고친_값_색_칸_그대로(): + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text(encoding="utf-8") + body = _rule(css, ".b09-estimation .ui-data-table__table tbody td.is-edited") + assert "color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas))" in body + ts = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill.ts").read_text(encoding="utf-8") + assert '"is-edited"' in ts diff --git a/resources/tester/test_242_1_b09_bill_jump_range.py b/resources/tester/test_242_1_b09_bill_jump_range.py new file mode 100644 index 000000000..cf65f27a4 --- /dev/null +++ b/resources/tester/test_242_1_b09_bill_jump_range.py @@ -0,0 +1,130 @@ +"""242-1 B09 총괄 → 설계내역서 빨강 = 그 줄 + 하위 전부 · 고름 표시와 한때 하나. + +- 이동해 온 줄(Shell show focus) = jumpTo — 범위 = 그 줄 + 뒤 줄 중 레벨이 더 깊은 연속 줄 · + 줄 class = is-pick(첫 줄 is-pick-top · 끝 줄 is-pick-end) · 바깥 테두리 한 벌(181-2 CSS) +- 오면 고른 줄(is-focus) 풂 · 공용 표 focusRow(at, false) = 스크롤만(공용 강조 안 붙임) +- 설계내역서에서 줄 누름(범위 안 · 밖) · 방향키 · Esc · 끌어 놓음 = 빨강 풂 · 누른 줄만 고름 상자 +- 표 · 문서 = 가짜 DOM(200-2 시험 틀) · 줄 class = Shell 과 같은 곁값(pickedClass ?? pickClass) +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import ROWS, _node, _ts # noqa: E402 +from test_200_2_b09_bill_level_bar import BAR_JS, SRC # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" +GROUPS = (B09 / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8") +PICK = re.search(r"export function pickClass\(.*?\n}\n", GROUPS, re.DOTALL).group(0) + +SETUP = ( + "let focusAt = null;\n" + + BAR_JS.split("const out = {};")[0] + # 가짜 DOM 보강 — class 여럿 지움 · tr. 고르기 + .replace( + "remove: (c) => s.delete(c) }", + "remove: (...c) => c.forEach((x) => s.delete(x)) }", + ) + .replace( + 'querySelectorAll() { return this.all().filter((n) => n.tagName === "TR" && n.cls.has("is-focus")); }', + "querySelectorAll(sel) { const c = sel.match(/tr\\.([\\w-]+)/)[1];" + ' return this.all().filter((n) => n.tagName === "TR" && n.cls.has(c)); }', + ) + # Shell 차례 — jumpTo → 줄 그림(줄 class 곁값) → levelBar + .replace( + ' root = new FakeEl("div");', + " jumpTo(table.rows, focusAt ? table.rows.findIndex((r) => r.key === focusAt) : -1);\n" + ' root = new FakeEl("div");', + ) + .replace( + "td.append(input); tr.append(td); root.append(tr);", + "td.append(input); tr.append(td); root.append(tr);" + " const c = pickedClass(r) ?? pickClass(jumpRange(), i); if (c) tr.className = c;", + ) +) + +for patch in ["remove: (...c)", "querySelectorAll(sel)", "jumpTo(table.rows", "jumpRange(), i)"]: + assert patch in SETUP, patch # 200-2 틀이 바뀌면 여기서 멈춤 + +RUN_JS = """ +const marks = () => Object.fromEntries(root.kids.flatMap((tr, i) => { + const c = [...tr.cls].filter((x) => x.startsWith("is-")).sort().join(" "); + return c ? [[table.rows[i].key, c]] : []; +})); +const out = {}; +const go = (k) => { focusAt = k; user = {}; draw(); focusAt = null; }; +go("sec:1.2"); +out.range = [jumpRange(), marks()]; +out.ends = ["sec:1", "earth:4", "hand:g", "fee"].map((k) => subtreeEnd(table.rows, at(k))); +pick("earth:5"); +out.inside = [jumpRange(), marks()]; +go(null); pick("earth:4"); +out.before = marks(); +go("sec:1"); +out.came = [jumpRange(), marks(), table.rows.map((r) => pickedClass(r)).filter(Boolean)]; +pick("mat:M1"); +out.outside = [jumpRange(), marks()]; +go(null); +out.redraw = marks(); +go("sec:1.2"); root.focus(); +out.arrow = [key("ArrowDown"), jumpRange(), marks(), order()]; +go("sec:4"); root.focus(); +out.esc = [key("Escape"), jumpRange(), marks()]; +go("sec:4"); root.focus(); +out.other = [key("a"), jumpRange()]; +console.log(JSON.stringify(out)); +""" + +PICKED = {"sec:1.2": "is-pick is-pick-top", "earth:4": "is-pick", "earth:5": "is-pick", + "hand:g": "is-pick", "hand:a": "is-pick", "hand:b": "is-pick is-pick-end"} # fmt: skip + + +def _run(): + body = (SETUP + RUN_JS).replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + return json.loads(_node(_ts(SRC) + PICK + body)) + + +def test_범위_그_줄_자손_전부_바깥_테두리_한_벌(): + out = _run() + assert out["range"] == [[1, 6], PICKED] + # sec:1 = 레벨 1 아래 전부 · 항목 = 자기만 · 묶음 = 손 줄까지 · 끝 줄 = 자기 + assert out["ends"] == [6, 2, 5, 11] + + +def test_표시_하나_총괄에서_오면_고름_풂(): + out = _run() + assert out["before"] == {"earth:4": "is-focus"} + rng, marks, picked = out["came"] + assert rng == [0, 6] and picked == [] + assert all("is-focus" not in c for c in marks.values()) + assert marks["sec:1"] == "is-pick is-pick-top" and marks["hand:b"] == "is-pick is-pick-end" + + +def test_다른_줄_누름_빨강_풂_그_줄만_고름(): + out = _run() + assert out["inside"] == [None, {"earth:5": "is-focus"}] # 범위 안 줄 + assert out["outside"] == [None, {"mat:M1": "is-focus"}] # 범위 밖 줄 + assert out["redraw"] == {"mat:M1": "is-focus"} # 다시 그려도 고름만 + + +def test_방향키_Esc_빨강만_풂_다른_키_그대로(): + out = _run() + assert out["arrow"] == [False, None, {}, []] # 줄 안 옮김(고른 줄 없음) + assert out["esc"] == [False, None, {}] + assert out["other"] == [False, [7, 8]] + + +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 + 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 + level = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") + assert 'tr.classList.remove("is-pick", "is-pick-top", "is-pick-end")' in level diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py new file mode 100644 index 000000000..5629a735d --- /dev/null +++ b/resources/tester/test_243_1_double_click_tables.py @@ -0,0 +1,129 @@ +"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 246-1 에서 지움. + +- 공용 표 editOnDouble = 한 번 누름 = 글 상자 안 열림 · 더블클릭 · 칸 누른 뒤 Enter · F2 = 그 칸 글 상자 +- 고칠 수 있는 칸(td) 표시 `is-can-edit` 없음(246-1 · 241 앞 모양) +- B09 = 표 만드는 길 한 곳(Shell) · B08 = 고칠 칸 있는 표(자재 집계표 셋) 모두 editOnDouble +- 칸 누름으로 여는 메뉴 · 고르기 · 이동(onCellClick · choices · link)은 그대로 +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import _node # noqa: E402 +from test_235_1_b09_bill_tree import DOM_JS, TABLE # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +HARNESS = DOM_JS.split("const columns")[0] + +SCENE_JS = """ +const columns = [{ key: "name", label: "명칭" }, { key: "qty", label: "수량", kind: "number" }]; +const rows = [{ name: "폴더", qty: null }, { name: "파일", qty: 3 }]; +const can = (html) => [...html.matchAll(/]*>([^<]*) m[1].includes("is-can-edit")).map((m) => m[2]); +const log = []; +const make = (extra = {}) => createDataTable({ columns, rows, editOnDouble: true, + editable: (i, k) => i === 1 && k === "qty", onCellClick: (i, k) => log.push(`click ${i}:${k}`), + onEdit: (i, k, v) => log.push(`edit ${i}:${k}=${v}`), ...extra }); +const key = (r, k, target, more = {}) => r.fire("keydown", { key: k, target, isComposing: false, ...more, + preventDefault() {}, stopPropagation() {} }); +const out = {}; +const t = make(); +const r = t.root; +out.can = can(r.html); +out.start = inputs(r); +r.fire("click", { target: target(r, 1, 1) }); +out.click = [inputs(r), [...log]]; +key(r, "F2", r); +out.f2 = inputs(r); +const typed = doc.activeElement; +doc.activeElement = r; // 초점 떠남 +r.fire("change", { target: typed }); +r.fire("focusout", { target: typed }); +const enter = () => { + r.fire("click", { target: target(r, 1, 1) }); + key(r, "Enter", r); +}; +setTimeout(() => { + out.afterEnd = inputs(r); + enter(); + out.enter = inputs(r); + const input = doc.activeElement; + key(r, "Enter", input, { isComposing: true }); + out.composing = doc.activeElement === input; + key(r, "Enter", input); + out.ends = doc.activeElement === r; + r.fire("focusout", { target: input }); + setTimeout(() => { + r.fire("click", { target: target(r, 0, 0) }); + key(r, "F2", r); + out.noEditCell = inputs(r); + const off = make({ keyEdit: false }); + off.root.fire("click", { target: target(off.root, 1, 1) }); + key(off.root, "F2", off.root); + out.keyEditOff = inputs(off.root); + off.root.fire("dblclick", { target: target(off.root, 1, 1) }); + out.keyEditOffDbl = inputs(off.root); + const old = createDataTable({ columns, rows, editable: (i, k) => i === 1 && k === "qty" }); + out.old = [inputs(old.root), can(old.root.html)]; + console.log(JSON.stringify(out)); + }, 5); +}, 5); +""" + + +def test_공용_표_더블클릭_Enter_F2(): + out = json.loads(_node(TABLE + HARNESS + SCENE_JS)) + assert out["can"] == [] # 246-1 고칠 칸 td 표시 없음 + assert out["start"] == [] # 처음 = 글 상자 없음 + assert out["click"] == [[], ["click 1:qty"]] # 한 번 누름 = 안 열림 · 누름 알림은 그대로 + assert out["f2"] == ["1:qty"] # F2 = 누른 칸 입력 + assert out["afterEnd"] == [] # 초점 떠남 = 끝 + assert out["enter"] == ["1:qty"] # Enter = 누른 칸 입력 + assert out["composing"] is True # 한글 조합 중 Enter = 조합 끝(입력 유지) + assert out["ends"] is True # 조합 뒤 Enter = 입력 끝 + assert out["noEditCell"] == [] # 못 고치는 칸 = 안 열림 + assert out["keyEditOff"] == [] # keyEdit false = 표가 키를 안 받음 + assert out["keyEditOffDbl"] == ["1:qty"] # 더블클릭은 그대로 + assert out["old"] == [["1:qty"], []] # editOnDouble 없는 표 = 예전대로(테두리 class 없음) + + +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 되돌림 + + +def _sources(*folders: str) -> list[Path]: + return [p for f in folders for p in sorted((ROOT / f).glob("*.ts"))] + + +def test_B09_표_만드는_길_한_곳_더블클릭_기본(): + calls = [ + p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8") + ] + assert calls == ["B09_Estimation_UI_Shell.ts"] + shell = (ROOT / "B09_Estimation" / calls[0]).read_text("utf-8") + assert "editOnDouble: spec.editOnDouble ?? true," in shell + assert "keyEdit: !(spec as BillTable).menu," in shell # 설계내역서 Enter · F2 = 레벨 키 몫 + + +def test_B08_고칠_칸_있는_표_모두_editOnDouble(): + seen = 0 + for p in _sources("B08_Quantity"): + text = p.read_text("utf-8") + for call in re.split(r"createDataTable\(\{", text)[1:]: + block = call.split("\n });")[0] + if "editable:" in block: + seen += 1 + assert "editOnDouble: true" in block, p.name + # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 표(구간 · 표 줄 표는 244-4 에서 모달로) + assert seen == 2 + + +def test_고르기_메뉴_이동_그대로(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));" in shell + assert "spec.choices?.(Number(cell.parentElement?.dataset.i), column.key)" in shell diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py new file mode 100644 index 000000000..b1d8968e3 --- /dev/null +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -0,0 +1,91 @@ +"""244-10 B08 거리이정표 경로 지도 — 테일스케일 주소(http://100.72.32.27:5173 · 보안 문맥 아님). + +① getDisplayMedia 없음 = 지도 그대로 · 캡처 안내 없음([지도 캡처]는 244-11 에서 없앰 · 그림 = 서버 자동) +② SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 · 지도 숨김 +(가짜 kakao.maps · document 로 node 에서 돌림) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" +KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts" +ORIGIN = "http://100.72.32.27:5173" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +STUBS = f""" +const el = (tag: string, o: any = {{}}) => ({{ tag, hidden: false, textContent: o.text ?? "", + className: o.className, dataset: {{}}, children: o.children ?? [] }}); +const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); +const request = async () => ({{ key: "fake-js-key" }}); +const injected: string[] = []; +(globalThis as any).window = globalThis; +(globalThis as any).location = {{ origin: {json.dumps(ORIGIN)} }}; +const liveMilestone = () => ({{ site: {{}}, base: {{ enabled: false }}, + routes: [{{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: false }}] }}); +const siteInfo = () => ({{ start_lonlat: [127.83, 35.64] }}); +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 } }; +""" + + +def _view(setup): + script = ( + STUBS + + setup + + IMPORTS.sub("", _read(KMAP)) + + """ +const view = routeMapView("p1", "r1"); +await new Promise((r) => setTimeout(r, 30)); +const [hint, box] = view.root.children; +console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hidden, + children: view.root.children.length })); +""" + ) + src = IMPORTS.sub("", _read(LAYOUT)) + script + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +NO_MEDIA = """ +Object.defineProperty(globalThis, "navigator", { configurable: true, value: {} }); +(globalThis as any).document = { createElement: () => ({}), head: { append: () => {} } }; +""" + + +def test_보안_문맥_아님_지도_그대로_캡처_안내_없음(): + got = _view(NO_MEDIA + FAKE_KAKAO) + assert not got["boxHidden"] and got["children"] == 2 # 지도는 그대로 · 캡처 단추 없음 + assert got["hint"] == "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" + assert "캡처는 localhost" not in got["hint"] + + +def test_SDK_401_도메인_등록_안내(): + sdk_401 = """ +(globalThis as any).document = { createElement: () => ({}), + head: { append: (s: any) => (injected.push(s.src), setTimeout(() => s.onerror(), 0)) } }; +""" + 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}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + ) + 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 new file mode 100644 index 000000000..25a591302 --- /dev/null +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -0,0 +1,282 @@ +"""244-11 B08 거리이정표 경로 그림 = 서버 자동 캡처(사용자 139번). + +가짜 브라우저(playwright.sync_api 자리에 가짜 모듈)로: +① [길찾기] 길 선 · 표지 → 서버 캡처 → auto/<경로 id>.png · 페이지 주소 = KAKAO_CAPTURE_ORIGIN + + /__route_capture(page.route 로 채움) · 좌표 저장 0 · 키 값 응답 0 +② 사용자 그림 우선 · 「자동 그림으로 되돌리기」(user_only) · 그림 지우기 = 둘 다 · + 경로 지우고 [저장] = 자동 그림도 +③ 실패 까닭 한 줄 — 키 없음 · Playwright 없음 · 브라우저 못 띄움 · 20초 넘김 · 도메인 불일치 +④ 동시 요청 = 하나씩(잠금) · 화면 = 브라우저 캡처 길 없음 · [길찾기] 뒤 자동 · 되돌리기 단추 +""" + +import json +import sys +import threading +import time +import types +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-map" +KEY = "fake-js-key-123" +LINES = [[[35.6866, 127.9095], [35.68, 127.89]], [[35.672, 127.87], [35.66, 127.85]]] +MARKS = [ + {"name": "거창군청", "lon": 127.9095, "lat": 35.6866}, + {"name": "시점", "lon": 127.85, "lat": 35.66}, +] + + +class Box: + """가짜 브라우저 기록 · 동작 모드.""" + + def __init__(self, mode="ok", delay=0.0): + self.mode, self.delay = mode, delay + self.channels, self.routed, self.goto, self.html = [], [], [], [] + self.active = self.peak = 0 + self.lock = threading.Lock() + + +def fake_playwright(box): + class Error(Exception): + pass + + class TimeoutError(Error): # noqa: A001 — playwright 이름 그대로 + pass + + class Page: + def route(self, url, handler): + box.routed.append(url) + self.handler = handler + + def goto(self, url, timeout): + box.goto.append(url) + fulfil = types.SimpleNamespace(fulfill=lambda **kw: box.html.append(kw["body"])) + self.handler(fulfil) + + def wait_for_function(self, expr, timeout): + assert timeout <= cap.TIMEOUT_S * 1000 + if box.mode == "timeout": + raise TimeoutError("Timeout 20000ms exceeded") + + def evaluate(self, expr): + return 1 if box.mode == "sdk_fail" else None + + def wait_for_timeout(self, ms): + pass + + def locator(self, selector): + assert selector == "#m" + return self + + def screenshot(self, timeout): + with box.lock: + box.active += 1 + box.peak = max(box.peak, box.active) + time.sleep(box.delay) + with box.lock: + box.active -= 1 + return PNG + + class Browser: + def new_page(self, viewport): + assert viewport == {"width": 1200, "height": 800} + return Page() + + def close(self): + pass + + class Chromium: + def launch(self, channel, headless, timeout): + box.channels.append(channel) + assert headless + if box.mode == "no_browser" or (box.mode == "no_edge" and channel == "msedge"): + raise Error(f"Chromium distribution '{channel}' is not found") + return Browser() + + class Ctx: + def __enter__(self): + return types.SimpleNamespace(chromium=Chromium()) + + def __exit__(self, *a): + return False + + return types.SimpleNamespace(Error=Error, TimeoutError=TimeoutError, sync_playwright=Ctx) + + +def _setup(tmp_path, monkeypatch, box, origin=None): + monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_playwright(box)) + monkeypatch.setattr( + cap, "spawn", cap.capture + ) # 가짜 브라우저 = 같은 프로세스(249-1 자식은 별도 시험) + monkeypatch.setenv(cap.KAKAO_JS, KEY) + if origin is None: + monkeypatch.delenv(cap.ORIGIN_KEY, raising=False) + else: + monkeypatch.setenv(cap.ORIGIN_KEY, origin) + + async def root(project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + return TestClient(app), f"/api/projects/{uuid4()}/quantity/milestone" + + +def _auto(client, base, route="r1"): + return client.post( + base + "/image/auto", params={"route": route}, json={"lines": LINES, "marks": MARKS} + ) + + +def test_자동_그림_저장_좌표_0(tmp_path, monkeypatch): + box = Box() + client, base = _setup(tmp_path, monkeypatch, box) + res = _auto(client, base) + assert res.json() == {"name": "auto/r1.png", "reason": None} + assert KEY not in res.text + auto = tmp_path / "B08_Quantity/milestone/auto/r1.png" + assert auto.read_bytes() == PNG + # 페이지 = 기본 주소 + /__route_capture 를 page.route 로 채움 · Edge 먼저 + assert box.routed == box.goto == ["http://localhost:5173/__route_capture"] + assert box.channels == ["msedge"] + html = box.html[0] + assert f"appkey={KEY}&autoload=false" in html and 'onerror="window.__fail=1"' in html + assert json.dumps(LINES) in html and "거창군청" in html and "tilesloaded" in html + assert "#E60046" in html and "1200px" in html and "800px" in html + # 좌표 저장 0 — 폴더엔 그림만 + files = sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*") if p.is_file()) + assert files == ["B08_Quantity/milestone/auto/r1.png"] + got = client.get(base).json() + assert got["images"] == {"r1": "auto/r1.png"} and got["auto_images"] == {"r1": "auto/r1.png"} + assert client.get(base + "/image", params={"route": "r1"}).content == PNG + # 나쁜 좌표 · id = 거절 + bad = {"lines": [[[95, 127.9]]], "marks": []} + assert client.post(base + "/image/auto", params={"route": "r1"}, json=bad).status_code == 422 + assert _auto(client, base, "../x").status_code == 400 + + +def test_주소_바꿈_Edge_없으면_Chrome(tmp_path, monkeypatch): + box = Box("no_edge") + client, base = _setup(tmp_path, monkeypatch, box, origin=" http://100.72.32.27:5173/ ") + assert _auto(client, base).json()["name"] == "auto/r1.png" + assert box.channels == ["msedge", "chrome"] + assert box.goto == ["http://100.72.32.27:5173/__route_capture"] + + +def test_사용자_그림_우선_되돌리기(tmp_path, monkeypatch): + client, base = _setup(tmp_path, monkeypatch, Box()) + img = base + "/image" + client.post(img, params={"route": "r1"}, files={"file": ("u.jpg", b"user", "image/jpeg")}) + assert _auto(client, base).json()["name"] == "auto/r1.png" # 자동 그림은 늘 만듦 + got = client.get(base).json() + assert got["images"] == {"r1": "r1.jpg"} and got["auto_images"] == {"r1": "auto/r1.png"} + assert client.get(img, params={"route": "r1"}).content == b"user" # 사용자 그림 우선 + # 자동 그림으로 되돌리기 = 사용자 그림만 지움 + back = client.delete(img, params={"route": "r1", "user_only": "true"}).json() + assert back == {"name": "auto/r1.png"} + assert client.get(img, params={"route": "r1"}).content == PNG + # 그림 지우기 = 둘 다 + assert client.delete(img, params={"route": "r1"}).json() == {"name": None} + assert client.get(img, params={"route": "r1"}).status_code == 404 + assert client.get(base).json()["auto_images"] == {} + # 경로 지우고 [저장] = 자동 그림도 지움 · 남은 경로 것은 그대로 + _auto(client, base, "r1") + _auto(client, base, "r2") + client.post(img, params={"route": "r2"}, files={"file": ("u.png", b"u2", "image/png")}) + keep = {"routes": [{"id": "r2", "name": "김천역"}]} + saved = client.put(base, json=keep).json() + assert saved["images"] == {"r2": "r2.png"} and saved["auto_images"] == {"r2": "auto/r2.png"} + assert not (tmp_path / "B08_Quantity/milestone/auto/r1.png").exists() + + +def test_실패_까닭_한_줄(tmp_path, monkeypatch): + tail = " — 그림 올리기를 쓰세요." + cases = { + "no_browser": "경로 그림 자동 캡처 실패 — 서버에서 브라우저(Edge · Chrome)를 못 띄움", + "timeout": "경로 그림 자동 캡처 실패 — 지도 그림이 20초 안에 안 끝남", + "sdk_fail": "경로 그림 자동 캡처 실패 — 카카오 지도 JS 가 캡처 주소(http://localhost:5173)" + "를 받지 않음(키 도메인 등록 확인)", + } + for mode, reason in cases.items(): + box = Box(mode) + client, base = _setup(tmp_path, monkeypatch, box) + res = _auto(client, base) + assert res.json() == {"name": None, "reason": reason + tail}, mode + assert KEY not in res.text + assert not (tmp_path / "B08_Quantity/milestone/auto").exists() # 실패 = 그림 없음 + # Playwright 없음 + monkeypatch.setitem(sys.modules, "playwright.sync_api", None) + assert _auto(client, base).json()["reason"] == ( + "경로 그림 자동 캡처 실패 — 서버에 Playwright 가 없습니다(pip install playwright)" + tail + ) + # 키 없음 = 브라우저 안 띄움 + box = Box() + client, base = _setup(tmp_path, monkeypatch, box) + monkeypatch.setenv(cap.KAKAO_JS, " ") + assert _auto(client, base).json() == { + "name": None, + "reason": "카카오 지도 JavaScript 키가 없습니다(.env KAKAO_JS_KEY)" + tail, + } + assert box.channels == [] + # 길 선 · 표지 없음 + monkeypatch.setenv(cap.KAKAO_JS, KEY) + empty = client.post(base + "/image/auto", params={"route": "r1"}, json={}).json() + assert empty["reason"] == "길 선 · 표지가 없어 지도 그림을 못 만듦" + tail + + +def test_동시_요청_하나씩(tmp_path, monkeypatch): + box = Box(delay=0.15) + _setup(tmp_path, monkeypatch, box) + out = tmp_path / "auto" + got = [] + + def one(i): + got.append(cap.make_route_image(out, f"r{i}", LINES, MARKS)) + + jobs = [threading.Thread(target=one, args=(i,)) for i in range(3)] + for job in jobs: + job.start() + for job in jobs: + job.join() + assert [g["saved"] for g in got] == [True] * 3 and box.peak == 1 + assert sorted(p.name for p in out.iterdir()) == ["r0.png", "r1.png", "r2.png"] + + +def test_화면_브라우저_캡처_없음_길찾기_뒤_자동(): + def read(name): + return (ROOT / "B08_Quantity" / name).read_text(encoding="utf-8") + + ui = ("UI_Milestone_KakaoMap", "UI_Milestone_Route", "UI_Milestone_Modal", "Milestone_Layout") + for name in (f"B08_Quantity_{n}.ts" for n in ui): + text = read(name) + assert "getDisplayMedia" not in text and "지도 캡처" not in text, name + assert "캡처는 localhost" not in text, name + state = read("B08_Quantity_UI_Milestone_State.ts") + # 244-13 — 연결선 한 구간 [길찾기] · 거래처 쪽 구간만 자동 그림 + assert 'if (image && seg.key === "route" && paths.has(from))' in state + assert "void autoRouteImage(projectId, from, routePaths(from));" in state + 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 + kmap = read("B08_Quantity_UI_Milestone_KakaoMap.ts") + 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 new file mode 100644 index 000000000..2c83b6a93 --- /dev/null +++ b/resources/tester/test_244_12_b08_milestone_base_view.py @@ -0,0 +1,221 @@ +"""244-12 B08 거리이정표 다이어그램(사용자 142~144번) — ① 넉넉한 고정 바탕(공사지만 있어도 +거래처 · 기준점 자리까지 · 넘는 상자가 생길 때만 넓힘) ② 시점 → 중점 선(공사지 바탕 위) · 시점 · +중점 상자 작은 글씨 주소 · 좌표(위도, 경도 소수 5자리) · 중점 주소 = 지금 중점 측점(?mid_m=) +③ 거래처 상자 = 항목 · 이름 · 주소(줄임표 + 호버). +""" + +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 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +LONG = "경상남도 거창군 북상면 병곡리 968-31" + + +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 _route(rid, dir=None, via=True, address="", item=""): + return {"id": rid, "name": rid, "address": address, "via_base": via, "dir": dir, + "lon": None, "lat": None, "legs": [], + "rows": [{"item": item, "dest": "start", "note": ""}]} # fmt: skip + + +def _data(routes, base=False): + return { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": base, "name": "", "address": "", "dir": None, "legs": []}, + "routes": routes, + } + + +def _clipped(note, box_w): + # 줄임표 · 안쪽 여백(양쪽 8) 안 · 앞부분 그대로 + assert note.endswith("…") and LONG.startswith(note[:-1]) + assert _node(f'textW("{note}", 10)') <= box_w - 16 + + +def _lay(data, site=None): + return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)") + + +def test_바탕_고정_범위_넘을_때만_넓힘(): + base_view = _node("BASE_VIEW") + assert _lay(_data([]))["view"] == base_view # 공사지만 있어도 넉넉한 바탕 + vx, vy, vw, vh = base_view + assert vw >= 900 and vh >= 800 + # 공사지 둘레 8방 거래처 · 기준점(서) — 바탕 그대로(다시 줌아웃 없음) + dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + for lay in (_lay(_data([_route(d, d, via=False) for d in dirs])), _lay(_data([], base=True))): + assert lay["view"] == base_view + for b in lay["boxes"]: + assert vx <= b["x"] and b["x"] + b["w"] <= vx + vw + assert vy <= b["y"] and b["y"] + b["h"] <= vy + vh + # 기준점 너머 거래처 = 바탕 넘음 → 그쪽만 넓힘 + lay = _lay(_data([_route("far", "W")], base=True)) + x, y, w, h = lay["view"] + far = next(b for b in lay["boxes"] if b["id"] == "far") + assert x < vx and x <= far["x"] - 48 and x + w == vx + vw and (y, h) == (vy, vh) + + +def test_시점_중점_선_상자_주소_좌표(): + site = {"start_lonlat": [127.812345678, 35.7], "mid_lonlat": [127.9, 35.712341], + "start_address": LONG, "mid_address": None} # fmt: skip + lay = _lay(_data([]), site) + boxes = {b["id"]: b for b in lay["boxes"]} + note, coord = boxes["start"]["notes"] + _clipped(note, boxes["start"]["w"]) + assert coord == "35.70000, 127.81235" + assert boxes["start"]["tip"] == LONG # 호버 = 줄이지 않은 주소 + 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"] + s, m = boxes["start"], boxes["mid"] + assert m["x"] - (s["x"] + s["w"]) >= 100 # 선 글 자리 + for b in (s, m): # 상자 = 공사지 안 + site_box = boxes["site"] + assert site_box["x"] <= b["x"] and b["x"] + b["w"] <= site_box["x"] + site_box["w"] + assert site_box["y"] + 20 < b["y"] and b["y"] + b["h"] <= site_box["y"] + site_box["h"] + + +def test_거래처_상자_항목_이름_주소(): + lay = _lay(_data([_route("g", "N", via=False, address=LONG, item="장비"), + _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"]) + assert boxes["g"]["tip"] == LONG and boxes["k"]["notes"] == ["거창읍"] + assert _node('clip("가나다", 100, 10)') == "가나다" + assert _node(f'clip("{LONG}", 108, 10)') == "경상남도 거창군 북상…" # 한글 10 · 빈칸 6 · … 6 + + +def test_화면_선_순서_글_줄_중점_주소(): + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + # 선 = 공사지 바탕 뒤 · 다른 상자 앞(시점 → 중점 선 보임) + assert 'if (box.kind === "site") shift.append(lineLayer);' in ui + assert '...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"])' in ui + assert 'if (box.tip) g.append(svg("title", {}, box.tip));' in ui + assert "siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null)" in ui + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "site-address${query}" in state and "?mid_m=${midM}" in state + assert ".b08-milestone__note {" in _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_State.ts"): + assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700 + + +def test_중점_주소_지금_측점(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + async def root(_pid): + return tmp_path + + async def project(_pid): + return {"region": ""} + + async def where(lon, lat): + return f"주소 {lon:.5f}" + + monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.setattr(ms, "get_project_by_id", project) + monkeypatch.setattr(ms, "reverse_geocode", where) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + path = tmp_path / "B05_Profile" / "route" / "planned_route.csv" + path.parent.mkdir(parents=True) + path.write_text("sequence,x,y\n0,208000,367000\n1,209000,367000", encoding="utf-8") + url = f"/api/projects/{uuid4()}/quantity/milestone/site-address" + half = client.get(url).json() # 저장본 없음 = 기본(연장 ÷ 2) + near = client.get(url, params={"mid_m": 100}).json() + assert near["start_lonlat"] == half["start_lonlat"] + assert near["start_lonlat"][0] < near["mid_lonlat"][0] < half["mid_lonlat"][0] + assert near["mid_address"] == f"주소 {near['mid_lonlat'][0]:.5f}" + assert client.get(url, params={"mid_m": -1}).status_code == 422 + + +def _hits(a, b, gap=0): + return (a["x"] < b["x"] + b["w"] + gap and b["x"] < a["x"] + a["w"] + gap + and a["y"] < b["y"] + b["h"] + gap and b["y"] < a["y"] + a["h"] + gap) # fmt: skip + + +def _seg_hits(x1, y1, x2, y2, r): + t0, t1, dx, dy = 0.0, 1.0, x2 - x1, y2 - y1 + for p, q in ((-dx, x1 - r["x"]), (dx, r["x"] + r["w"] - x1), + (-dy, y1 - r["y"]), (dy, r["y"] + r["h"] - y1)): # fmt: skip + if p == 0: + if q < 0: + return False + continue + t = q / p + t0, t1 = (max(t0, t), t1) if p < 0 else (t0, min(t1, t)) + if t0 > t1: + return False + return True + + +def test_여유_상자_선_글_겹침_0(): + """④(145번) — 몰린 꼴(기준점 거침 셋 같은 방위 · 방위 없음 둘 · 공사지 동쪽 셋 · + 서쪽 = 기준점 자리).""" + legs = [{"road": "2차포장", "km": 23.8}] + routes = [{**_route(f"n{i}", "N", address=LONG, item="장비"), "legs": legs} for i in range(3)] + routes += [{**_route(f"f{i}"), "legs": legs} for i in range(2)] + routes += [{**_route(f"e{i}", "E", via=False), "legs": legs} for i in range(3)] + routes += [{**_route("w", "W", via=False), "legs": legs}] # 기준점과 같은 방위 = 밀려남 + data = _data(routes, base=True) + data["base"]["legs"] = [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}] + lay = _lay(data) + gap = _node("GAP") + boxes = lay["boxes"] + inside = {("site", "start"), ("site", "mid")} + for i, a in enumerate(boxes): # 상자 사이 여유(공사지 안 시점 · 중점만 품음) + for b in boxes[i + 1 :]: + if (a["id"], b["id"]) not in inside: + assert not _hits(a, b, gap - 0.01), (a["id"], b["id"]) + for ln in lay["lines"]: # 선 끝 = 어느 상자 안도 아님(바깥 → 시점 = 공사지 테두리에서 끊김) + for x, y in ((ln["x1"], ln["y1"]), (ln["x2"], ln["y2"])): + for b in boxes: + if not (b["id"] == "site" and ln["from"] == "start"): + assert not (b["x"] < x < b["x"] + b["w"] and b["y"] < y < b["y"] + b["h"]), ( + ln["from"], + ln["to"], + b["id"], + x, + y, + ) + rects = {ln["from"]: _node(f"labelRect({json.dumps(ln, ensure_ascii=False)})") + for ln in lay["lines"] if ln["labels"]} # fmt: skip + for k, r in rects.items(): # 선 글 ↔ 다른 선 + for o in lay["lines"]: + if o["from"] != k: + assert not _seg_hits(o["x1"], o["y1"], o["x2"], o["y2"], r), (k, o["from"]) + keys = list(rects) + for i, k in enumerate(keys): # 선 글 ↔ 상자 · 다른 선 글 + for b in boxes: + if not (b["kind"] == "site" and k == "start"): + assert not _hits(rects[k], b), (k, b["id"]) + for j in keys[i + 1 :]: + assert not _hits(rects[k], rects[j]), (k, j) diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py new file mode 100644 index 000000000..e409a1606 --- /dev/null +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -0,0 +1,305 @@ +"""244-13 B08 거리이정표 등록 모달 · 연결선 길찾기 · 도로 구분 열(사용자 147번 5 · 8). + +도로 구분 = 기준점 거쳐 시점 들어가는 구간 2차포장 · 1차포장 · 비포장 · 그 밖 포장 · 비포장 +(파이썬 · TS 거울) · 연결선 구간 끝 · 표 열(2차포장 = 기준점 있을 때만) · 소재지 역지오코딩 길 · +고르는 지도(누름 · 끌기) · 연결선 한 구간 [길찾기](거래처 쪽만 자동 그림) — node 로 실제 TS 를 돌림. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +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 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" +CALC = B08 + "Milestone_Calc.ts" +LAYOUT = B08 + "Milestone_Layout.ts" +KMAP = B08 + "UI_Milestone_KakaoMap.ts" +STATE = B08 + "UI_Milestone_State.ts" +BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(rels, script, pre=""): + src = pre + "".join(IMPORTS.sub("", _read(rel)) for rel in rels) + result = subprocess.run( # 원본이 길어 명령 줄 대신 표준 입력 + ["node", "--experimental-strip-types", "--no-warnings", "--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 + return json.loads(result.stdout) + + +def _route(rid, legs, via=True, rows=None, **kw): + return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "legs": legs, + "rows": rows or [{"item": "철근", "dest": "start", "note": ""}], **kw} # fmt: skip + + +NO_BASE = { + "site": {"mid_station_m": 800, "mid_road": "비포장"}, + "base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "routes": [ + _route("g", [{"road": "포장", "km": 10.4}, {"road": "비포장", "km": 0.5}], rows=[ + {"item": "철근", "dest": "start", "note": ""}, + {"item": "혼합골재", "dest": "mid", "note": ""}]), + ], +} # fmt: skip +WITH_BASE = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "address": "", "dir": None, "lon": 127.85, + "lat": 35.66, "legs": [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]}, + "routes": [ + _route("g", [{"road": "2차포장", "km": 23.8}], lon=127.9, lat=35.68), + _route("d", [{"road": "포장", "km": 5}], via=False, lon=127.8, lat=35.6), + ], +} # fmt: skip + + +def test_포장_도로_구분_거울(): + a = compute_milestone(NO_BASE, 1500.0) + assert [(r["pv"], r["un"], r["paved"], r["total"]) for r in a] == [ + (10.4, 0.5, 10.4, 10.9), + (10.4, 1.3, 10.4, 11.7), # 목적지 중점 = 시점 → 중점 0.8 km 비포장 더함 + ] + b = compute_milestone(WITH_BASE, 1500.0) + assert (b[0]["s2"], b[0]["s1"], b[0]["pv"], b[0]["un"]) == (23.8, 0.6, 0, 0.8) + assert (b[0]["paved"], b[0]["total"]) == (24.4, 25.2) # 견본 수식 = 포장 2차 + 1차 + assert (b[1]["s2"], b[1]["pv"], b[1]["paved"], b[1]["total"]) == (0, 5, 5, 5) # 거침 안 함 + ts = _node([CALC], f"console.log(JSON.stringify([computeMilestone({json.dumps(NO_BASE)}, " + f"1500), computeMilestone({json.dumps(WITH_BASE)}, 1500)]));") # fmt: skip + assert ts == [a, b] + assert ms.Leg.model_validate({"road": "포장", "km": 1}).road == "포장" + with pytest.raises(ValidationError): + ms.Leg.model_validate({"road": "3차포장"}) + + +def test_도로_구분_갈래_연결선_구간_끝(): + d = json.dumps(WITH_BASE) + off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}}) + 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), + 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], + "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], + "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"] + + +def test_표_열_기준점_따라(): + got = _node( + [B08 + "UI_Milestone.ts"], + "console.log(JSON.stringify([[false, false], [true, false], [true, true]].map(([b, p]) =>" + " milestoneColumns(b, p).map((c) => `${(c.group ?? [''])[0]}|${c.label}|${c.key}`))));", + ) + assert got[0] == ["|항목|item", "|거래처 이름|company", "|목적지|dest", + "도로 구분 (km)|포장|paved", "도로 구분 (km)|비포장|un", + "계 (km)|합계|total", "|비고|note"] # fmt: skip + roads = [c for c in got[1] if c.startswith("도로 구분")] + assert roads == ["도로 구분 (km)|2차포장|s2", "도로 구분 (km)|1차포장|s1", + "도로 구분 (km)|비포장|un"] # fmt: skip + assert "계 (km)|포장|paved" in got[1] and "계 (km)|비포장|unpaved" in got[1] + assert "도로 구분 (km)|포장|pv" in got[2] # 기준점 있어도 거침 안 한 「포장」 값 = 포장 열 + assert not any("2차포장" in c for c in got[0]) # 2차포장 열 = 기준점 있을 때만 + + +def test_소재지_역지오코딩(monkeypatch): + asked = [] + + async def fake(lon, lat): + asked.append((lon, lat)) + return "경남 거창군 북상면 병곡리 968-31" + + monkeypatch.setattr(ms, "reverse_geocode", fake) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + res = client.get(BASE + "/reverse", params={"lon": 127.85, "lat": 35.66}) + assert res.json() == {"address": "경남 거창군 북상면 병곡리 968-31"} + assert asked == [(127.85, 35.66)] + assert client.get(BASE + "/reverse", params={"lon": 127.85, "lat": 91}).status_code == 422 + + +MAP_STUBS = """ +const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "", + className: o.className, dataset: {}, children: o.children ?? [] }); +const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); +(globalThis as any).window = globalThis; +(globalThis as any).document = { createElement: () => ({}), head: { append: () => undefined } }; +const request = async () => ({ key: "fake-js-key" }); +const LOG: any = { markers: [], handlers: [], lines: 0 }; +class LatLng { lat: number; lng: number; + constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; } + getLat() { return this.lat; } getLng() { return this.lng; } } +const overlay = () => ({ setMap: () => undefined }); +(globalThis as any).kakao = { maps: { + load: (f: () => void) => f(), LatLng, + LatLngBounds: class { extend() {} }, + Map: class { relayout() {} setBounds() { LOG.fit = true; } + setCenter(p: any) { LOG.center = [p.lng, p.lat]; } setLevel(l: number) { LOG.level = l; } }, + Polyline: function () { LOG.lines++; return overlay(); }, + Marker: function (o: any) { const m = { ...overlay(), o, at: o.position, + getPosition: () => m.at }; LOG.markers.push(m); return m; }, + CustomOverlay: function () { return overlay(); }, + event: { addListener: (t: any, type: string, fn: any) => LOG.handlers.push([t, type, fn]) }, +} }; +""" + + +def test_고르는_지도_누름_끌기(): + got = _node( + [KMAP], + """ +const moved: any[] = []; const clicked: any[] = []; +const m = editMap("p1", { hint: "h", onMove: (k, lon, lat) => moved.push([k, lon, lat]), + onClick: (lon, lat) => clicked.push([lon, lat]), center: () => [127.83, 35.64] }); +m.set([], []); +await new Promise((r) => setTimeout(r, 20)); +const empty = { center: LOG.center, level: LOG.level, hidden: m.root.children[1].hidden }; +m.set([{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }, + { key: "start", name: "시점", lon: 127.83, lat: 35.64, movable: false }], + [[[35.68, 127.9], [35.64, 127.83]]]); +await new Promise((r) => setTimeout(r, 20)); +const click = LOG.handlers.find(([, type]) => type === "click"); +click[2]({ latLng: new LatLng(35.7, 127.95) }); +const drags = LOG.handlers.filter(([, type]) => type === "dragend"); +const [r1] = LOG.markers.slice(-2); +r1.at = new LatLng(35.69, 127.91); +drags[0][2](); +console.log(JSON.stringify({ empty, moved, clicked, drags: drags.length, fit: !!LOG.fit, + lines: LOG.lines, draggable: LOG.markers.slice(-2).map((x: any) => !!x.o.draggable), + clicks: LOG.handlers.filter(([, t]) => t === "click").length })); +""", + MAP_STUBS, + ) + assert got["empty"] == { + "center": [127.83, 35.64], + "level": 9, + "hidden": False, + } # 점 없음 = 시점 + assert got["draggable"] == [True, False] and got["drags"] == 1 # 시점 = 고정 + assert got["moved"] == [["r1", 127.91, 35.69]] and got["clicked"] == [[127.95, 35.7]] + assert got["fit"] and got["lines"] == 1 and got["clicks"] == 1 # 지도 누름 사건 한 번만 + + +STATE_STUBS = """ +const API_BASE_URL = ""; +const showToast = () => undefined; +const store = new Map(); +const readState = (k: string, p: string) => store.get(k + p) ?? null; +const writeState = (k: string, v: any, p: string) => (store.set(k + p, v), WRITES.n++); +const clearState = (k: string, p: string) => store.delete(k + p); +const WRITES = { n: 0 }; +const CALLS: any[] = []; +const request = async (url: string, init: any = {}) => { + CALLS.push([url, init.body ? JSON.parse(init.body) : null]); + if (url.endsWith("/milestone")) return { data: DATA, site: { start_lonlat: [127.83, 35.64], + mid_default_m: 1500 }, images: {}, auto_images: {} }; + if (url.includes("/directions")) return { km: 10.5, reason: null, + path: [[35.68, 127.9], [35.66, 127.85]] }; + return { name: "auto/g.png", reason: null }; +}; +""" + + +def test_연결선_한_구간_길찾기_거래처_쪽만_그림(): + script = f"""const DATA = {json.dumps(WITH_BASE)}; +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 writes = WRITES.n; +await findSegmentKm("p1", "base"); // 같은 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"), + 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" + (url, body), (img, payload), (url2, body2) = got["calls"][:3] + assert url.endswith("/directions") and body == { + "origin": {"lon": 127.9, "lat": 35.68}, + "destination": {"lon": 127.85, "lat": 35.66}, + } + assert img.endswith("/image/auto?route=g") # 거래처 쪽 구간 = 자동 그림 + assert payload["lines"] == [[[35.68, 127.9], [35.66, 127.85]]] + assert [m["name"] for m in payload["marks"]] == ["g", "병곡리"] # 두 끝만(시점 없음) + assert body2["origin"] == {"lon": 127.85, "lat": 35.66} + assert body2["destination"] == {"lon": 127.83, "lat": 35.64} + assert not any( + "/image/auto" in c[0] for c in got["calls"][got["afterRoute"] :] + ) # 기준점 = 그림 없음 + assert got["route"][0]["km"] == 23.8 and got["route"][0]["auto_km"] == 10.5 # 사용자 값 그대로 + assert got["base"][0]["auto_km"] == 10.5 and got["again"] == got["writes"] + assert got["routeLines"] == [got["path"]] and got["baseLines"] == [ + got["path"] + ] # 구간마다 길 선 + assert got["start"] == "" # 시점 → 중점 = 길찾기 없음 + + +def test_화면_등록_모달_연결선_모달(): + modal = _read(B08 + "UI_Milestone_Modal.ts") + place = _read(B08 + "UI_Milestone_Place.ts") + leg = _read(B08 + "UI_Milestone_LegModal.ts") + diagram = _read(B08 + "UI_Milestone_Diagram.ts") + css = _read(B08 + "UI_Milestone.css") + # ① 큰 창 · 2열 · 분류 · 항목 목록 · 소재지 · 직접 입력 없음 · 다음 지도 + assert 'dialogClass: "b08-milestone__modal"' in modal + assert ".ui-modal.b08-milestone__modal {\n width: min(960px, 94vw);" in css + assert "grid-template-columns: 1fr 1fr;" in css + assert "children: [sort.root, first.item.root, name.root, first.dest.root]" in modal + assert ( + '{ value: "base", text: "기준위치" }' in modal + and '{ value: "route", text: "거래처" }' in modal + ) + assert 'route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"]' in modal + assert "소재지" in modal and "거래처 위치" not in modal + for text in (modal, place): + assert 'label: "직접 입력"' not in text and "buildAddressField" not in text + 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 'label: "기준점 +"' not in diagram and "addBase" not in diagram + # ③ 연결선 누름 = 연결선 모달 · 시점 → 중점 = 손 입력만 + assert "openLegInfo(projectId, ln.from);" in diagram and "b08-milestone__line-hit" in diagram + assert "clearTimeout(wait);" in diagram # 선 글 더블클릭 = 바로 고침 그대로 + mid = leg[leg.index("function openMidLeg") : leg.index("export function openLegInfo")] + 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 + 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", + "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip + assert len(_read(rel).splitlines()) <= 700, rel diff --git a/resources/tester/test_244_14_b08_milestone_item_input.py b/resources/tester/test_244_14_b08_milestone_item_input.py new file mode 100644 index 000000000..f13a81e1a --- /dev/null +++ b/resources/tester/test_244_14_b08_milestone_item_input.py @@ -0,0 +1,83 @@ +"""244-14 B08 거리이정표 [+] 모달 「항목」 = 손 입력 + 제안 목록(사용자 120-답 ③ 「항목: 수기 입력」). + +거래처 제안 = 견본 다섯 · 기준위치 제안 = 기준점 · 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜(쓴 글 그대로) +— node 로 실제 rowField 를 돌림(가짜 DOM). +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MODAL = ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts" +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +STUBS = """ +type N = any; +const el = (tag: string, o: any = {}): N => { + const n: N = { tag, children: [] as N[], attrs: { ...(o.attrs ?? {}) }, dataset: {}, style: {}, + value: "", placeholder: "", + get id() { return n.attrs.id; }, + setAttribute(k: string, v: string) { n.attrs[k] = v; }, + append(...c: N[]) { n.children.push(...c); }, + replaceChildren(...c: N[]) { n.children = c; } }; + return n; +}; +const createInputField = (o: any) => { + const input = el("input"); + return { root: el("div", { children: [] }), input, label: o.label, setError: () => undefined }; +}; +const createSelectField = (o: any) => { + const select = el("select"); + return { root: el("div"), select, setOptions: () => undefined }; +}; +""" + +SCRIPT = """ +const sug = (f: any) => f.item.root.children.find((c: N) => c.tag === "datalist") + .children.map((c: N) => c.attrs.value); +const a = rowField({ item: "", dest: "start", note: "" }, "route"); +const empty = { value: a.item.input.value, list: sug(a), ph: a.item.input.placeholder, + linked: a.item.input.attrs.list === a.item.root.children[0].id }; +a.item.input.value = " 레미콘(25-21-150) "; // 목록 밖 손 글 +a.setKind("base"); +const base = { value: a.item.input.value, list: sug(a), ph: a.item.input.placeholder }; +a.setKind("route"); +const old = rowField({ item: "옛 항목", dest: "mid", note: "" }, "route"); +const b = rowField({ item: "", dest: "start", note: "" }, "route"); +console.log(JSON.stringify({ empty, base, back: a.item.input.value, list: sug(a), + old: [old.item.input.value, old.dest.select.value, sug(old)], + ids: new Set([a, old, b].map((f) => f.item.input.attrs.list)).size, + tag: a.item.input.dataset.b08MilestoneAdd })); +""" + + +def test_항목_손_입력_제안_분류_바꿔도_글_유지(): + src = STUBS + IMPORTS.sub("", MODAL.read_text(encoding="utf-8")) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--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 + got = json.loads(result.stdout) + five = ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"] + # 빈 줄 = 빈 손 입력 칸 + 거래처 제안 다섯(datalist 이어짐) + assert got["empty"] == {"value": "", "list": five, "ph": "예 장비(20톤)", "linked": True} + # 분류 → 기준위치 = 제안만 기준점 · 쓴 글 그대로 + assert got["base"] == { + "value": " 레미콘(25-21-150) ", + "list": ["기준점"], + "ph": "예 기준점", + } + assert got["back"] == " 레미콘(25-21-150) " and got["list"] == five + # 옛 항목 글 = 그대로 칸에 · 목적지 그대로 + assert got["old"] == ["옛 항목", "mid", five] + assert got["ids"] == 3 and got["tag"] == "item" # 줄마다 제안 목록 따로 + + +def test_저장_값_손_글_그대로(): + modal = MODAL.read_text(encoding="utf-8") + assert "item: r.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 new file mode 100644 index 000000000..ef1e7b574 --- /dev/null +++ b/resources/tester/test_244_1_b08_milestone.py @@ -0,0 +1,190 @@ +"""244-1 B08 거리이정표 — 표 합 거울(파이썬 · TS) · 저장 왕복(임시 storage) · 그림 · +하위 탭 추가 · 삭제 · 중점 기본. + +견본 = 실무 「거리이정표」 시트(거창군청 → 병곡리 968-31 → 시점 · 김천역 → 임도중점) +장비 = 2차 23.8 + 1차 0.6 + 비포장 0.8 → 포장 24.4 · 비포장 0.8 · 합계 25.2. +""" + +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_Milestone_Calc import compute_milestone # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _leg(road, km): + return {"road": road, "km": km} + + +SAMPLE = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리 968-31", "address": "", "dir": "E", + "legs": [_leg("1차포장", 0.6), _leg("비포장", 0.8)]}, + "routes": [ + {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": "W", + "legs": [_leg("2차포장", 23.8)], + "rows": [{"item": "장비(20톤)", "dest": "start", "note": ""}, + {"item": "장비(8톤)", "dest": "start", "note": "같은 길"}]}, + {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": "N", + "legs": [_leg("2차포장", 61.2), _leg("1차포장", 2.05), _leg("비포장", None)], + "rows": [{"item": "철근", "dest": "mid", "note": ""}]}, + ], +} # fmt: skip + + +def _run_ts(data, mid_default): + calc = ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Calc.ts" + body = ( + f"\nconst d = {json.dumps(data, ensure_ascii=False)};" + f"\nconsole.log(JSON.stringify(computeMilestone(d, {json.dumps(mid_default)})));" + ) + script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + 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_견본_표_합(): + rows = compute_milestone(SAMPLE, 1500.0) + a, b, c = rows + 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) + # 사용자 중점 측점 · 기준점 없음 = 공통 구간 안 셈 + 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) + + +def test_거울_TS(): + for mid in (1500.0, None): + assert _run_ts(SAMPLE, mid) == compute_milestone(SAMPLE, mid) + odd = {**SAMPLE, "site": {"mid_station_m": 1234.5678, "mid_road": "비포장"}} + assert _run_ts(odd, 10.0) == compute_milestone(odd, 10.0) + + +def _route_csv(root: Path, points): + path = root / "B05_Profile" / "route" / "planned_route.csv" + path.parent.mkdir(parents=True) + lines = ["sequence,x,y"] + [f"{i},{x},{y}" for i, (x, y) in enumerate(points)] + path.write_text("\n".join(lines), encoding="utf-8") + + +def test_중점_기본_노선_절반(tmp_path): + assert ms.site_info(tmp_path, None)["mid_default_m"] is None # 노선 전 = 빈칸 + _route_csv(tmp_path, [(208000, 367000), (209000, 367000), (209000, 367500)]) + site = ms.site_info(tmp_path, None) + assert site["length_m"] == 1500 and site["mid_default_m"] == 750 + lon, lat = site["start_lonlat"] + assert 124 < lon < 132 and 33 < lat < 39 # 작업 좌표계(폴백 5186) → 경위도 + assert site["mid_lonlat"] != site["start_lonlat"] + + +def _client(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + async def root(_project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + return TestClient(app), f"/api/projects/{uuid4()}/quantity/milestone" + + +def test_저장_왕복_그림(tmp_path, monkeypatch): + client, base = _client(tmp_path, monkeypatch) + empty = client.get(base).json() + assert empty["data"]["routes"] == [] and empty["table"] == [] and empty["images"] == {} + _route_csv(tmp_path, [(0, 0), (3000, 0)]) + saved = client.put(base, json=SAMPLE).json() + assert saved["data"] == ms.Milestone.model_validate(SAMPLE).model_dump() + assert saved["table"] == compute_milestone(SAMPLE, 1500.0) # 서버가 다시 셈 + stored = json.loads((tmp_path / "B08_Quantity/milestone/milestone.json").read_text("utf-8")) + assert stored["routes"][0]["name"] == "거창군청" and "table" not in stored + assert client.get(base).json() == saved + # 그림 — 경로 하나 = 그림 하나(교체) · 내려받기 · 지우기 + img = base + "/image" + up = client.post(img, params={"route": "r1"}, files={"file": ("a.PNG", b"png1", "image/png")}) + assert up.json() == {"name": "r1.png"} + client.post(img, params={"route": "r1"}, files={"file": ("b.jpg", b"jpg2", "image/jpeg")}) + assert client.get(base).json()["images"] == {"r1": "r1.jpg"} + assert client.get(img, params={"route": "r1"}).content == b"jpg2" + assert ( + client.post(img, params={"route": "r1"}, files={"file": ("x.pdf", b"1")}).status_code == 400 + ) + assert client.get(img, params={"route": "../x"}).status_code == 400 + client.post(img, params={"route": "r2"}, files={"file": ("c.png", b"png3", "image/png")}) + client.delete(img, params={"route": "r2"}) + assert client.get(img, params={"route": "r2"}).status_code == 404 + # 경로 삭제 뒤 [저장] = 그 경로 그림도 지움 + client.post(img, params={"route": "r2"}, files={"file": ("c.png", b"png3", "image/png")}) + one = {**SAMPLE, "routes": SAMPLE["routes"][:1]} + assert client.put(base, json=one).json()["images"] == {"r1": "r1.jpg"} + # 겹친 id · 나쁜 값 = 거절 + twice = {**SAMPLE, "routes": [SAMPLE["routes"][0]] * 2} + assert client.put(base, json=twice).status_code == 422 + bad = {**SAMPLE, "site": {"mid_station_m": -1, "mid_road": "비포장"}} + assert client.put(base, json=bad).status_code == 422 + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_메인_탭_하위_탭_추가_삭제(): + page = _read("B08_Quantity/B08_Quantity_UI_Page.ts") + assert '{ id: "milestone", label: "B08_Quantity_Tab_Milestone" }' in page + assert 'if (tab === "milestone" && projectId) return renderMilestone(view, projectId);' in page + assert 'B08_Quantity_Tab_Milestone: ["거리이정표"' in _read( + "ui_template/ui_template_locale_b4.ts" + ) + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui + # 244-4 — 하위 탭 줄 [+ 추가] · [삭제] 없음(탭은 [+] 등록 · 항목 정보 [삭제]로만) + assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert "fresh.routes = fresh.routes.filter((r) => r.id !== route!.id);" in modal + assert "editOnDouble: true" in ui and "computeMilestone(data, siteInfo()" in ui + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route + # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state + assert state.count('method: "PUT"') == 1 and "setTimeout" not in state + options = _read("B08_Quantity/B08_Quantity_UI_Options.ts") + assert 'card("milestone"' not in options and "milestoneCardBody" not in options # 244-6 + assert "await saveMilestone(projectId);" in options + registry = _read("A00_Common/b_page_state.ts") + assert '"b08-milestone": { bucket: "draft", scope: "project" }' in registry + main = _read("main.py") + assert "app.include_router(b08_milestone_router, dependencies=protected_with_company)" in main + # 짝 표기 + assert "짝 = `B08_Quantity_Milestone_Calc.ts`" in _read( + "B08_Quantity/B08_Quantity_Milestone_Calc.py" + ) + assert "짝 = `B08_Quantity_Milestone_Calc.py`" in _read( + "B08_Quantity/B08_Quantity_Milestone_Calc.ts" + ) + for name in ("UI_Milestone.ts", "UI_Milestone_Route.ts", "UI_Milestone_State.ts", + "Router_Milestone.py"): # fmt: skip + rel = f"B08_Quantity/B08_Quantity_{name}" + assert len(_read(rel).splitlines()) <= 700 diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py new file mode 100644 index 000000000..a8dc4adb0 --- /dev/null +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -0,0 +1,160 @@ +"""244-2 B08 거리이정표 다이어그램 — 방위 자리(8방 · 경위도 방위각 · 빈 방위 차례 · +같은 방위 나란히) · 선 글(도로 구분 km) · 상자 누름 · [+] 항목 추가 길. + +자리 계산 = `B08_Quantity_Milestone_Layout.ts` 를 node 로 돌림(화면 = 북 위 · SVG y 아래 = 남). +""" + +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)) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _layout(data, site=None, mid_km=0.75): + src = (ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Layout.ts").read_text(encoding="utf-8") + body = ( + f"\nconst d = {json.dumps(data, ensure_ascii=False)};" + f"\nconsole.log(JSON.stringify(layoutMilestone(d, {json.dumps(site)}, {mid_km})));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", IMPORTS.sub("", src) + 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 _route(rid, name, dir=None, via=True, legs=None, lon=None, lat=None): + return {"id": rid, "name": name, "address": "", "via_base": via, "dir": dir, "lon": lon, + "lat": lat, "legs": legs or [], + "rows": [{"item": "", "dest": "start", "note": ""}]} # fmt: skip + + +def _data(routes, base=None): + return { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": base or {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "routes": routes, + } + + +def _center(layout, bid): + box = next(b for b in layout["boxes"] if b["id"] == bid) + return box["x"] + box["w"] / 2, box["y"] + box["h"] / 2 + + +def test_공사지_컨테이너_시점_중점_선(): + lay = _layout(_data([])) + kinds = {b["id"]: b["kind"] for b in lay["boxes"]} + assert kinds == {"site": "site", "start": "start", "mid": "mid"} + sx, _ = _center(lay, "start") + mx, _ = _center(lay, "mid") + assert sx < mx # 시점 왼 · 중점 오른 · 한 컨테이너 안 + site = next(b for b in lay["boxes"] if b["id"] == "site") + for bid in ("start", "mid"): + 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"]} + ] + + +def test_8방_자리_기준점_없음_공사지_기준(): + lay = _layout( + _data([_route("e", "동쪽", "E"), _route("n", "북쪽", "N"), _route("sw", "남서", "SW")]) + ) + cx, cy = _center(lay, "site") + ex, ey = _center(lay, "e") + nx, ny = _center(lay, "n") + wx, wy = _center(lay, "sw") + assert ex > cx + 200 and abs(ey - cy) < 1 + assert ny < cy - 200 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" # 기준점 없으면 시점으로 + + +def test_기준점_기준_방위_선_글(): + base = {"enabled": True, "name": "병곡리 968-31", "address": "", "dir": "W", + "legs": [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]} # fmt: skip + lay = _layout( + _data( + [ + _route("g", "거창군청", "N", legs=[{"road": "2차포장", "km": 23.8}]), + _route("k", "김천역", "E", via=False), + ], + base, + ), # fmt: skip + ) + cx, cy = _center(lay, "site") + bx, by = _center(lay, "base") + 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[("k", "start")] == [] + assert next(b for b in lay["boxes"] if b["id"] == "base")["label"] == "병곡리 968-31" + + +def test_경위도_있으면_방위각이_8방보다_앞섬(): + site = {"start_lonlat": [127.8, 35.7], "mid_lonlat": None} + lay = _layout(_data([_route("e", "동쪽 좌표", "N", lon=127.9, lat=35.7)]), site) + cx, cy = _center(lay, "site") + ex, ey = _center(lay, "e") + assert ex > cx + 200 and abs(ey - cy) < 1 # 고른 방위 N 보다 좌표(동) 먼저 + + +def test_빈_방위_차례_같은_방위_나란히(): + lay = _layout( + _data( + [_route("a", "가", "E"), _route("b", "나", "E"), _route("c", "다"), _route("d", "라")] + ) + ) + (ax, ay), (bx, by) = _center(lay, "a"), _center(lay, "b") + assert abs(ax - bx) < 1 and abs(ay - by) >= 60 # 같은 방위 = 옆으로 나란히 + 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 (dx, dy) != (cx, cy) and dx < sx # 다음 빈 방위(북서) + boxes = [b for b in lay["boxes"] if b["kind"] == "route"] + for i, p in enumerate(boxes): # 업체 상자끼리 안 겹침 + for q in boxes[i + 1 :]: + assert ( + p["x"] + p["w"] <= q["x"] or q["x"] + q["w"] <= p["x"] + or p["y"] + p["h"] <= q["y"] or q["y"] + q["h"] <= p["y"] + ) # fmt: skip + + +def test_화면_상자_누름_더하기_창(): + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8") + # 244-4 — 상자 누름 = 모달(탭 이동 아님) + assert ( + "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 + 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 # 같은 업체 이름 = 그 경로에 표 줄 + 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 + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"): + assert ( + len((ROOT / f"B08_Quantity/B08_Quantity_{name}").read_text("utf-8").splitlines()) <= 700 + ) diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py new file mode 100644 index 000000000..8d533d060 --- /dev/null +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -0,0 +1,216 @@ +"""244-4 B08 거리이정표 고침(사용자 130번) — [+] 모달(항목 · 업체 이름 · 목적지 + +주소 검색 재활용) · 방위 = 좌표 자동 · 시점 모달(등록 주소 · VWorld 주소 · 지도) · +다이어그램 팬 · 줌 공용 · +경로 하위 탭 = 그림만(다음 지도 길찾기 새 창 · 화면 캡처) · 상자 누름 = 항목 정보 모달 · +선 글 바로 고침(⑥). +""" + +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 B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +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 _route(rid, lon=None, lat=None, via=True): + row = {"item": "", "dest": "start", "note": ""} + return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "lon": lon, + "lat": lat, "legs": [], "rows": [row]} # fmt: skip + + +def _center(lay, bid): + box = next(b for b in lay["boxes"] if b["id"] == bid) + return box["x"] + box["w"] / 2, box["y"] + box["h"] / 2 + + +def test_방위_좌표_자동(): + site = {"start_lonlat": [127.8, 35.7], "mid_lonlat": None} + base = {"enabled": True, "name": "기준", "address": "", "dir": None, "legs": []} + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": base, + # 기준점 좌표 없음 → 시점 좌표 기준 · 업체 = 시점 남쪽 · 기준점 안 거침 업체 = 동쪽 + "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") + sx, sy = _center(lay, "s") + assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래 + 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") + nx, ny = _center(lay, "n") + assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표) + assert abs(nx - bx) < 1 and ny < by - 200 + + +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" + ) + + +def test_VWorld_좌표_주소(monkeypatch): + body = {"response": {"status": "OK", "result": [ + {"type": "parcel", "text": "경남 거창군 북상면 병곡리 968-31"}, + {"type": "road", "text": "경남 거창군 북상면 덕유월성로 1"}]}} # fmt: skip + seen = [] + + def fetch(url, timeout=5): + seen.append(url) + return json.dumps(body).encode() + + monkeypatch.setattr(vmap, "_fetch", fetch) + assert vmap._reverse_sync(127.75, 35.78) == "경남 거창군 북상면 덕유월성로 1" + assert "request=getAddress" in seen[0] and "point=127.75%2C35.78" in seen[0] + body["response"]["result"] = body["response"]["result"][:1] + assert vmap._reverse_sync(127.75, 35.78) == "경남 거창군 북상면 병곡리 968-31" + monkeypatch.setattr(vmap, "_fetch", lambda url, timeout=5: b"not json") + assert vmap._reverse_sync(127.75, 35.78) is None + + +def test_시점_모달_주소_길(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + async def root(_pid): + return tmp_path + + async def project(_pid): + return {"region": "경남 거창군 북상면"} + + async def where(lon, lat): + return f"주소 {lon:.3f}" + + monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.setattr(ms, "get_project_by_id", project) + monkeypatch.setattr(ms, "reverse_geocode", where) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + url = f"/api/projects/{uuid4()}/quantity/milestone/site-address" + empty = client.get(url).json() + assert empty["region"] == "경남 거창군 북상면" and empty["start_address"] is None + path = tmp_path / "B05_Profile" / "route" / "planned_route.csv" + path.parent.mkdir(parents=True) + path.write_text("sequence,x,y\n0,208000,367000\n1,209000,367000", encoding="utf-8") + got = client.get(url).json() + lon, lat = got["start_lonlat"] + assert 124 < lon < 132 and 33 < lat < 39 + assert got["start_address"] == f"주소 {lon:.3f}" and got["mid_address"].startswith("주소 ") + + +def test_화면_모달_팬줌_그림탭(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + # ① [+] 모달 — 항목 · 거래처 이름 · 목적지 + 소재지(244-13 · 주소 찾기 = B01 후보 검색 재활용 · + # 지도 = 다음 지도) · 방위 고르기 없음 · [등록] + add = modal[ + modal.index("export function openPlaceModal") : modal.index("export interface SiteAddress") + ] + for text in ('label: "항목"', 'label: "목적지"'): # 항목 · 목적지 줄 = rowField + assert text in modal + for text in ('label: "거래처 이름"', '"등록"', "placePicker(", "place.value()"): + assert text in add + assert "방위" not in add.replace("방위는 이 자리로 자동", "") and "dirOptions" not in modal + 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 + # ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달 + 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 + 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 + # ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 그림 올리기 길(화면 캡처는 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 + 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", + "B08_Quantity/B08_Quantity_UI_Milestone_Route.ts", + "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts", + "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip + assert len(_read(rel).splitlines()) <= 700 + + +def test_선_글_바로_고침_모달과_한_벌(): + from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone + + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "기준", "address": "", "dir": None, + "legs": [{"road": "1차포장", "km": 0.6}]}, + "routes": [{**_route("g"), "legs": [{"road": "2차포장", "km": None}]}], + } # fmt: skip + d = json.dumps(data) + 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}})]" + ) + 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["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"} + # 표 셈 그대로 — 고친 값 = 모달 값과 같은 칸 + (row,) = compute_milestone(after, 1500.0) + assert (row["s2"], row["s1"], row["un"], row["total"]) == (0, 23.8, 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 + assert "anchor.getBoundingClientRect()" in ui # 팬 · 줌 뒤 글 화면 자리 + assert "putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch))" 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 new file mode 100644 index 000000000..0b1b161e1 --- /dev/null +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -0,0 +1,65 @@ +"""244-5 B08 거리이정표 기준점 방위 칸 없앰 — 기준점 자리 = 주소 좌표 → 공사지 기준 방위각 · +좌표 없으면 공사지 서쪽(견본) · 옛 저장 값의 방위(dir)는 무시. +""" + +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 _layout(base, site): + data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, "base": base, "routes": []} + src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")) + expr = f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)" + 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 + 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"] + + +BASE = {"enabled": True, "name": "기준", "address": "", "legs": []} +SITE = {"start_lonlat": [127.8, 35.7], "mid_lonlat": None} + + +def test_좌표_없음_서쪽_옛_방위_무시(): + for site in (SITE, None): + (cx, cy), (bx, by) = _layout({**BASE, "dir": "E"}, site) + assert bx < cx - 200 and abs(by - cy) < 1 # 옛 「동」 무시 · 서(왼) + + +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 # 북 = 위(옛 「서」 무시) + (cx, cy), (bx, by) = _layout({**BASE, "dir": None, "lon": 127.9, "lat": 35.7}, SITE) + assert bx > cx + 200 and abs(by - cy) < 1 # 동 = 오른 + # 시점 좌표 모름 = 서쪽 기본 + (cx, cy), (bx, by) = _layout({**BASE, "lon": 127.9, "lat": 35.7}, None) + assert bx < cx - 200 + + +def test_좌측_패널_방위_칸_없음_모달_안내(): + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "공사지에서 본 방위" not in state and "dirOptions" not in state + assert "d.base.dir" not in state # 244-6 — 기준점 칸은 다이어그램 기준점 모달로 + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + 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 + ) diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py new file mode 100644 index 000000000..9b5248ef8 --- /dev/null +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -0,0 +1,140 @@ +"""244-6 B08 거리이정표 추가 고침(사용자 132 · 133번) — 표 폭 = 다이어그램 폭 · +거래처 상자 항목 글 · 좌측 패널 카드 없음(공사지 · 기준점 = 다이어그램 모달 · [기준점 +]) · +팬(가운데 · 빈 곳 왼쪽 끌기 · 원배율에서도) · 화면 글 「업체」 → 「거래처」. + +팬은 공용 `A00_Common/b_svg_zoom_pan.ts` + `…_Milestone_Layout.ts`(panStart · panContent)를 +node 로 돌려 pointerdown · pointermove · pointerup 사건을 실제로 보내 +그림 묶음 transform 이 바뀌는지 잼. +""" + +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 = ("UI_Milestone.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_Modal.ts", + "UI_Milestone_Route.ts", "UI_Milestone_State.ts", "Milestone_Layout.ts") # fmt: skip + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(script): + src = "".join( + IMPORTS.sub("", _read(rel)) + for rel in ("A00_Common/b_svg_zoom_pan.ts", "B08_Quantity/B08_Quantity_Milestone_Layout.ts") + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + "\n" + script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +PAN = """ +const W = 600, H = 400; +class Svg extends EventTarget { + classList = { add() {}, remove() {} }; + setPointerCapture() {} + releasePointerCapture() {} + getBoundingClientRect() { return { left: 0, top: 0, width: W, height: H }; } +} +const shape = { closest: (s: string) => (s.includes(".b08-milestone__box") ? {} : null) }; +const empty = { closest: () => null }; +function fire(svg: EventTarget, type: string, button: number, x: number, y: number, + target: unknown) { + const ev = new Event(type, { cancelable: true }); + for (const [k, v] of Object.entries({ button, clientX: x, clientY: y, pointerId: 1, target })) + Object.defineProperty(ev, k, { value: v }); + svg.dispatchEvent(ev); +} +function drag(button: number, target: unknown) { + const svg = new Svg(); + const layer = { t: "", setAttribute(_k: string, v: string) { this.t = v; } }; + attachSvgZoomPan({ svg: svg as any, layer: layer as any, widthPx: W, heightPx: H, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, wheelZoom: true, + content: panContent(W, H), panStart }); + fire(svg, "pointerdown", button, 100, 100, target); + fire(svg, "pointermove", button, 160, 130, target); + fire(svg, "pointerup", button, 160, 130, target); + return layer.t; +} +console.log(JSON.stringify({ + leftEmpty: drag(0, empty), leftShape: drag(0, shape), + middleShape: drag(1, shape), right: drag(2, empty), +})); +""" + + +def test_실제_포인터_사건으로_팬(): + got = _node(PAN) + # 원배율(1)에서도 빈 곳 왼쪽 끌기 · 가운데 버튼 끌기 = 그림 묶음이 끈 만큼 옮김 + assert got["leftEmpty"] == "translate(60 30) scale(1)" + assert got["middleShape"] == "translate(60 30) scale(1)" + assert got["leftShape"] == "" and got["right"] == "" # 상자 위 왼쪽 = 그 화면 고르기 몫 + + +def test_거래처_상자_항목_글(): + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "routes": [ + {"id": "g", "name": "거창군청", "address": "", "via_base": True, "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, + "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]}, + ], + } # fmt: skip + lay = _node(f"console.log(JSON.stringify(layoutMilestone({json.dumps(data)}, null, 0)));") + boxes = {b["id"]: b for b in lay["boxes"]} + assert boxes["g"]["items"] == "장비 · 골재" and boxes["g"]["label"] == "거창군청" + assert boxes["k"]["items"] == "" and boxes["k"]["label"] == "거래처" + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + # 244-12 — 글 줄 = 항목(윗줄) · 이름 · 작은 글씨 + assert '[box.items, "b08-milestone__items"]' in ui and '[box.label, ""]' in ui + + +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 + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert "root.style.width = `min(100%, calc(48vh * ${vw / vh}))`;" in ui # 다이어그램 = 틀 너비 + page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert "children: [diagram.root, none, table.root]" in page + # 좌측 패널 카드 없음 · 공사지 · 기준점은 모달 + options = _read("B08_Quantity/B08_Quantity_UI_Options.ts") + assert "milestoneCardBody" not in options and "await saveMilestone(projectId);" in options + 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 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음 + 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 '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 + + +def test_화면_글_거래처(): + for name in UI: + text = _read(f"B08_Quantity/B08_Quantity_{name}") + assert "업체" not in text, name + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert 'label: "거래처 이름"' in modal + page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert '{ key: "company", label: "거래처 이름"' in page and "`거래처 ${" in page + for name in UI: + assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700 diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py new file mode 100644 index 000000000..0392f8230 --- /dev/null +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -0,0 +1,243 @@ +"""244-7 B08 거리이정표 — 카카오 길찾기 REST 로 km 자동. + +서버 길 = 가짜 응답으로 km 셈(10463 m → 10.5 km) · 키 없음 안내 · 키 값이 응답 · 로그에 안 나옴 · +구간 채움(거래처 → 기준점 · 기준점 → 시점 · 기준점 없으면 거래처 → 시점) · 사용자 km 우선 · +도로 구분 안 고름 = pick(합에 안 듦) · 실제 카카오 한 번(AISLO_KAKAO_LIVE=1 일 때만). +""" + +import io +import json +import logging +import os +import re +import subprocess +import sys +import urllib.error +from pathlib import Path + +import pytest +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 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +FAKE_KEY = "fake-kakao-key-0123456789" +GEOCHANG = {"lon": 127.9095, "lat": 35.6866} # 거창군청 +BYEONGGOK = {"lon": 127.85, "lat": 35.66} # 병곡리 +OK_BODY = { + "trans_id": "t", + "routes": [ + { + "result_code": 0, + "result_msg": "길찾기 성공", + "summary": {"distance": 10463, "duration": 902}, + "sections": [{"roads": [{"vertexes": [127.9, 35.6]}]}], + } + ], +} + + +def _node(src_rel, expr): + src = (ROOT / src_rel).read_text(encoding="utf-8") + 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 _client(): + app = FastAPI() + app.include_router(ms.router) + return TestClient(app) + + +def _post(client, origin=GEOCHANG, destination=BYEONGGOK): + return client.post( + "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone/directions", + json={"origin": origin, "destination": destination}, + ) + + +def test_가짜_응답_km_소수_한자리(monkeypatch): + monkeypatch.setenv(ms.KAKAO_KEY, FAKE_KEY) + seen = {} + + def fake(request): + seen["url"] = request.full_url + seen["auth"] = request.get_header("Authorization") + return json.dumps(OK_BODY).encode() + + monkeypatch.setattr(ms, "_kakao_open", fake) + res = _post(_client()) + assert res.status_code == 200 + assert res.json() == {"km": 10.5, "reason": None, "path": [[35.6, 127.9]]} # 244-9 + # 문서 꼴 — origin · destination = 경도,위도 · 머리 KakaoAK + assert seen["url"].startswith(ms.KAKAO_URL + "?") + assert "origin=127.9095%2C35.6866" in seen["url"] + assert "destination=127.85%2C35.66" in seen["url"] and "priority=RECOMMEND" in seen["url"] + assert seen["auth"] == f"KakaoAK {FAKE_KEY}" + # 응답 원문은 안 돌려줌(km · 경로 좌표 [위도, 경도] 만 · 244-9) + assert "vertexes" not in res.text and "sections" not in res.text + # 끝수 = 소수 1자리 반올림 + for meters, km in ((10450, 10.5), (10449, 10.4), (950, 1.0), (0, 0.0)): + body = {"routes": [{"result_code": 0, "summary": {"distance": meters}}]} + monkeypatch.setattr(ms, "_kakao_open", lambda r, b=body: json.dumps(b).encode()) + assert ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK))["km"] == km + + +def test_키_없음_안내(monkeypatch): + monkeypatch.delenv(ms.KAKAO_KEY, raising=False) + called = [] + monkeypatch.setattr(ms, "_kakao_open", lambda r: called.append(r)) + body = _post(_client()).json() + assert body["km"] is None and "키가 없습니다" in body["reason"] and "손으로" in body["reason"] + assert not called # 키 없으면 카카오를 부르지 않음 + monkeypatch.setenv(ms.KAKAO_KEY, " ") + assert "키가 없습니다" in _post(_client()).json()["reason"] + + +def test_키_값_응답_로그에_안_나옴(monkeypatch, caplog): + monkeypatch.setenv(ms.KAKAO_KEY, FAKE_KEY) + caplog.set_level(logging.DEBUG) + client = _client() + + def http(code): + def fake(request): + raise urllib.error.HTTPError( + request.full_url, code, f"bad {FAKE_KEY}", {}, io.BytesIO(FAKE_KEY.encode()) + ) + + return fake + + def boom(request): + raise OSError(f"끊김 Authorization: KakaoAK {FAKE_KEY}") + + cases = [ + (http(401), "키를 받지 않았습니다"), + (http(429), "요청 수를 넘었습니다"), + (http(500), "HTTP 500"), + (boom, "닿지 못했습니다"), + (lambda r: b"{not json " + FAKE_KEY.encode(), "닿지 못했습니다"), + (lambda r: json.dumps({"routes": [{"result_code": 104, "result_msg": + "출발지와 도착지가 5 m 이내"}]}).encode(), "5 m 이내"), + ] # fmt: skip + for fake, want in cases: + monkeypatch.setattr(ms, "_kakao_open", fake) + res = _post(client) + assert res.status_code == 200 and res.json()["km"] is None + assert want in res.json()["reason"], res.json() + assert FAKE_KEY not in res.text + assert FAKE_KEY not in caplog.text + # 잘못된 좌표 = 422(카카오 안 부름) + assert _post(client, origin={"lon": 999, "lat": 0}).status_code == 422 + assert FAKE_KEY not in ms.__doc__ and "KakaoAK" not in (ms.__doc__ or "") + + +def _data(base_enabled=True, base_ll=(127.85, 35.66)): + return { + "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": []}, + "routes": [ + {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "lon": 127.9095, "lat": 35.6866, "legs": [{"road": "2차포장", "km": 23.8}], + "rows": [{"item": "장비", "dest": "mid", "note": ""}]}, + {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None, + "lon": None, "lat": None, "legs": [{"road": None, "km": None}], + "rows": [{"item": "철근", "dest": "start", "note": ""}]}, + ], + } # fmt: skip + + +LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" +START = [127.81, 35.75] + + +def test_구간_채움(): + d = json.dumps(_data()) + seg = _node(LAYOUT, f"kmSegments({d}, 'r1', {json.dumps(START)})") + assert [(s["key"], s["text"]) for s in seg] == [ + ("route", "거창군청 → 병곡리 968-31"), + ("base", "병곡리 968-31 → 시점"), + ] + assert seg[0]["from"] == [127.9095, 35.6866] and seg[0]["to"] == [127.85, 35.66] + assert seg[1]["from"] == [127.85, 35.66] and seg[1]["to"] == START + # 기준점 없음 = 거래처 → 시점 하나 · 기준점 안 거침 · 거래처 좌표 없음 = from null + off = json.dumps(_data(base_enabled=False)) + one = _node(LAYOUT, f"kmSegments({off}, 'r1', {json.dumps(START)})") + assert [(s["key"], s["text"], s["to"]) for s in one] == [("route", "거창군청 → 시점", START)] + 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)") + 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}] + # 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값 + 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"] # 시점 → 중점 = 노선 측점 거리 그대로 + + +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}] + 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 + a2, _ = compute_milestone(data, 1500.0) + assert (a2["s1"], a2["total"], a2["pick"]) == (0, 12.0, True) + # TS 짝 거울 + for d in (data, _data()): + ts = _node("B08_Quantity/B08_Quantity_Milestone_Calc.ts", + f"computeMilestone({json.dumps(d)}, 1500)") # fmt: skip + assert ts == compute_milestone(d, 1500.0) + # 저장 꼴 — 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 ms.Leg.model_validate({"km": 1}).road == "2차포장" + + +def test_화면_길찾기_단추_키_화면에_없음(): + read = lambda rel: (ROOT / rel).read_text(encoding="utf-8") # noqa: E731 + modal = read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + state = read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + table = read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + # 244-13 — [길찾기] = 연결선 모달 단추 · [등록] 뒤 그 구간 한 번 + 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 "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름 + # 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음 + for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): + text = rel.read_text(encoding="utf-8") + assert "KAKAO_REST_API_KEY" not in text and "KakaoAK" not in text, rel + + +@pytest.mark.skipif( + os.environ.get("AISLO_KAKAO_LIVE") != "1", reason="실제 카카오 = 한 번만 손으로" +) +def test_실제_카카오_한번(): + from dotenv import load_dotenv + + load_dotenv(ROOT / ".env") + got = ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK)) + assert got["reason"] is None and isinstance(got["km"], float) and 5 < got["km"] < 20 + print(f"\n실제 카카오 거창군청 → 병곡리 = {got['km']} km") diff --git a/resources/tester/test_244_8_b08_milestone_no_scroll.py b/resources/tester/test_244_8_b08_milestone_no_scroll.py new file mode 100644 index 000000000..cbf8855aa --- /dev/null +++ b/resources/tester/test_244_8_b08_milestone_no_scroll.py @@ -0,0 +1,85 @@ +"""244-8 B08 거리이정표 다이어그램 영역 스크롤 없앰(사용자 135번). + +틀 = overflow hidden · 그림 = 틀 안 맞춤(높이 48vh 넘으면 좁힘) · 휠 = 줌만. +휠은 공용 `A00_Common/b_svg_zoom_pan.ts` 와 다이어그램 틀 듣개를 node 로 돌려 +진짜 wheel 사건(cancelable)을 보내 기본동작(스크롤)이 막히는지 · 배율이 바뀌는지 잼. +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +DIAGRAM = "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(script): + src = IMPORTS.sub("", _read("A00_Common/b_svg_zoom_pan.ts")) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + "\n" + script], + 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_틀_스크롤_없음_그림_맞춤(): + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + block = css[css.index("\n.b08-milestone__diagram {") :] + block = block[: block.index("}")] + 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 + + +def test_휠_진짜_사건_줌_스크롤_막음(): + ui = _read(DIAGRAM) + line = next(ln.strip() for ln in ui.splitlines() if 'host.addEventListener("wheel"' in ln) + got = _node( + """ +const W = 600, H = 400; +class Svg extends EventTarget { + classList = { add() {}, remove() {} }; + getBoundingClientRect() { return { left: 0, top: 0, width: W, height: H }; } +} +function wheel(target: EventTarget, deltaY: number) { + const ev = new Event("wheel", { cancelable: true, bubbles: true }); + for (const [k, v] of Object.entries({ deltaY, clientX: 300, clientY: 200 })) + Object.defineProperty(ev, k, { value: v }); + target.dispatchEvent(ev); + return ev.defaultPrevented; +} +const svg = new Svg(); +const layer = { t: "", setAttribute(_k: string, v: string) { this.t = v; } }; +attachSvgZoomPan({ svg: svg as any, layer: layer as any, widthPx: W, heightPx: H, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, wheelZoom: true }); +const svgIn = wheel(svg, 100); +const zoomIn = layer.t; +const svgOut = wheel(svg, -100); +const zoomOut = layer.t; +// 틀 듣개(그림 밖 여백 · 단추 줄) — 소스 줄 그대로 돌림 · passive 면 preventDefault 무시됨 +const host = new EventTarget(); +let opts: unknown = null; +const add = host.addEventListener.bind(host); +host.addEventListener = (t: string, f: any, o: any) => { opts = o; add(t, f, o); }; +LINE +const hostBlocked = wheel(host, 100); +console.log(JSON.stringify({ svgIn, zoomIn, svgOut, zoomOut, hostBlocked, opts })); +""".replace("LINE", line) + ) + # 그림 위 휠 = 줌(커서 자리 붙잡음) · 기본동작(스크롤) 막힘 = 스크롤 위치 안 바뀜 + assert got["svgIn"] is True and got["zoomIn"].endswith(" scale(1.15)") + assert got["zoomIn"].startswith("translate(-45 -29.99") # 커서(300, 200) 제자리 + assert got["svgOut"] is True and got["zoomOut"] != got["zoomIn"] # 밀면 축소 + # 그림 밖 틀 위 휠도 페이지 스크롤로 안 샘 + assert got["hostBlocked"] is True and got["opts"] == {"passive": False} diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py new file mode 100644 index 000000000..1d25f62a8 --- /dev/null +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -0,0 +1,230 @@ +"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로(사용자 138번 · [지도 캡처]는 244-11 에서 없앰). + +서버 = 길찾기 응답에 경로 좌표 [위도, 경도](vertexes = 경도 · 위도 번갈아) · 저장 안 함 · JS 키 길 · +화면 = 표지(거래처 · 기준점 · 시점) · 키 없음 = 안내 한 줄 · 키 있음 = 길 선 · +표지 · 경로 전체 맞춤(가짜 kakao.maps 로 node 에서 돌림). +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +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 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" +LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" +KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts" +GEOCHANG = {"lon": 127.9095, "lat": 35.6866} +BYEONGGOK = {"lon": 127.85, "lat": 35.66} +BODY = { + "routes": [ + { + "result_code": 0, + "summary": {"distance": 10463}, + "sections": [ + {"roads": [{"vertexes": [127.9095, 35.6866, 127.89, 35.68]}]}, + {"roads": [{"vertexes": [127.87, 35.672, 127.85, 35.66]}]}, + ], + } + ] +} + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(rels, script): + src = "".join(IMPORTS.sub("", _read(rel)) for rel in rels) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + "\n" + script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _client(monkeypatch, body=BODY): + monkeypatch.setenv(ms.KAKAO_KEY, "fake-rest-key") + monkeypatch.setattr(ms, "_kakao_open", lambda r: json.dumps(body).encode()) + app = FastAPI() + app.include_router(ms.router) + return TestClient(app) + + +def test_길찾기_경로_좌표_위도_경도(monkeypatch): + res = _client(monkeypatch).post( + BASE + "/directions", json={"origin": GEOCHANG, "destination": BYEONGGOK} + ) + assert res.json() == { + "km": 10.5, + "reason": None, + "path": [[35.6866, 127.9095], [35.68, 127.89], [35.672, 127.87], [35.66, 127.85]], + } + assert "vertexes" not in res.text and "sections" not in res.text # 원문 안 돌려줌 + + +def test_깨진_좌표_km_그대로_길_선만_없음(monkeypatch): + monkeypatch.delenv(ms.KAKAO_KEY, raising=False) + got = ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK)) # 키 없음 = 길 선 없음 + assert got["km"] is None and "path" not in got + broken = json.loads(json.dumps(BODY)) + broken["routes"][0]["sections"][1]["roads"][0]["vertexes"] = ["x", None] + body = ( + _client(monkeypatch, broken) + .post(BASE + "/directions", json={"origin": GEOCHANG, "destination": BYEONGGOK}) + .json() + ) + assert body["km"] == 10.5 and body["path"] == [] + + +def test_좌표_저장_안_함(tmp_path): + route = {"id": "r1", "name": "거창군청", "legs": [{"road": None, "auto_km": 10.5}]} + route["path"] = [[35.6866, 127.9095], [35.66, 127.85]] # 화면이 잘못 실어 보내도 + ms.save_data(tmp_path, ms.Milestone.model_validate({"routes": [route]})) + files = [p for p in tmp_path.rglob("*") if p.is_file()] + assert [p.name for p in files] == ["milestone.json"] + text = files[0].read_text(encoding="utf-8") + assert '"path"' not in text and "35.6866" not in text and '"auto_km": 10.5' in text + + +def test_JS_키_길(monkeypatch): + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + monkeypatch.delenv(ms.KAKAO_JS, raising=False) + assert client.get(BASE + "/map-key").json() == {"key": None} + monkeypatch.setenv(ms.KAKAO_JS, " ") + assert client.get(BASE + "/map-key").json() == {"key": None} + monkeypatch.setenv(ms.KAKAO_JS, "fake-js-key") + assert client.get(BASE + "/map-key").json() == {"key": "fake-js-key"} + + +def test_표지_셋(): + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "lon": 127.85, "lat": 35.66, "legs": []}, + "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) + 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), +]));""", + ) + assert got[0] == [ + {"name": "거창군청", "lon": 127.9, "lat": 35.68}, + {"name": "병곡리", "lon": 127.85, "lat": 35.66}, + {"name": "시점", "lon": 127.83, "lat": 35.64}, + ] + assert got[1] == [{"name": "시점", "lon": 127.83, "lat": 35.64}] # 좌표 없는 거래처 = 표지 없음 + assert got[2] == [{"name": "거창군청", "lon": 127.9, "lat": 35.68}] + + +STUBS = """ +const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "", + className: o.className, dataset: {}, children: o.children ?? [] }); +const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); +const injected: string[] = []; +(globalThis as any).document = { createElement: () => ({}), + head: { append: (s: any) => injected.push(s.src) } }; +(globalThis as any).window = globalThis; +const liveMilestone = () => ({ site: {}, base: { enabled: true, name: "병곡리", lon: 127.85, + lat: 35.66 }, routes: [{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: true }] }); +const siteInfo = () => ({ start_lonlat: [127.83, 35.64] }); +const routePaths = () => PATHS; +""" + + +def _map(key, paths, extra=""): + script = f""" +const KEY = {json.dumps(key)}; +const PATHS = {json.dumps(paths)}; +const asked: string[] = []; +const request = async (url: string) => (asked.push(url), {{ key: KEY }}); +{extra} +const view = routeMapView("p1", "r1"); +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, + log: (globalThis as any).LOG ?? null }})); +""" + return _node([LAYOUT], STUBS + IMPORTS.sub("", _read(KMAP)) + script) + + +def test_키_없음_안내_한_줄(): + got = _map(None, []) + assert got["asked"] == ["/projects/p1/quantity/milestone/map-key"] + assert got["injected"] == [] # SDK 안 부름 + assert got["hint"] == ( + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + ) + assert got["boxHidden"] + + +FAKE_KAKAO = """ +const LOG: any = { overlays: [], bounds: [] }; +(globalThis as any).LOG = LOG; +class LatLng { lat: number; lng: number; + constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; } } +const on = (kind: string, o: any) => ({ setMap: (m: any) => m && LOG.overlays.push([kind, o]) }); +(globalThis as any).kakao = { maps: { + load: (f: () => void) => f(), + LatLng, + LatLngBounds: class { extend(p: any) { LOG.bounds.push([p.lat, p.lng]); } }, + Map: class { constructor() { LOG.maps = (LOG.maps ?? 0) + 1; } + relayout() {} setBounds() { LOG.fit = true; } }, + Polyline: function (o: any) { return on("line", o); }, + Marker: function (o: any) { return on("marker", o); }, + CustomOverlay: function (o: any) { return on("label", o); }, +} }; +""" + + +def test_키_있음_길_선_표지_전체_맞춤(): + paths = [[[35.68, 127.9], [35.67, 127.87], [35.66, 127.85]], [[35.66, 127.85], [35.64, 127.83]]] + got = _map("fake-js-key", paths, FAKE_KAKAO) + log = got["log"] + assert not got["boxHidden"] and log["maps"] == 1 and log["fit"] + lines = [o for kind, o in log["overlays"] if kind == "line"] + assert [[[p["lat"], p["lng"]] for p in o["path"]] for o in lines] == paths + assert lines[0]["strokeColor"] == "#E60046" and lines[0]["strokeWeight"] >= 5 + titles = [o["title"] for kind, o in log["overlays"] if kind == "marker"] + assert titles == ["거창군청", "병곡리", "시점"] + assert len(log["bounds"]) == 3 + 5 # 표지 셋 + 길 선 점 다섯 = 경로 전체 맞춤 + assert got["hint"] == "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" + + +def test_화면_자리_캡처_키_서버만(): + kmap = _read(KMAP) + 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 + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "routeMapView(projectId, routeId)" in route + assert "mapView.draw();" in route + 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"): # 키 이름은 서버만 + text = rel.read_text(encoding="utf-8") + assert "KAKAO_JS_KEY" not in text and "KAKAO_REST_API_KEY" not in text, rel + for rel in (KMAP, "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", LAYOUT, + "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip + assert len(_read(rel).splitlines()) <= 700 diff --git a/resources/tester/test_245_1_merged_hover.py b/resources/tester/test_245_1_merged_hover.py new file mode 100644 index 000000000..bf19478f8 --- /dev/null +++ b/resources/tester/test_245_1_merged_hover.py @@ -0,0 +1,43 @@ +"""245-1 병합 표 호버 정리 — 병합 칸(is-merged)은 줄 강조(호버 · 이동해 온 줄 · 줄무늬) 밖. + +규칙 (가) = 병합 칸은 칠하지 않고 그 줄 나머지 칸만. 같은 세기 선택자라 병합 칸 규칙이 뒤에 와야 이김. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CSS = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") +MERGED = ".ui-data-table__table tbody tr td.is-merged {" + + +def _at(sel: str) -> int: + i = CSS.find(sel) + assert i >= 0, sel + return i + + +def test_병합_칸_줄_강조_밖(): + body = CSS[_at(MERGED) :].split("}", 1)[0] + assert "background: var(--color-canvas);" in body + assert "box-shadow: none;" in body + + +def test_호버_고른줄_줄무늬_뒤에_옴(): + at = _at(MERGED) + for sel in ( + ".ui-data-table__table tbody tr:hover td {", + ".ui-data-table__table tbody tr.is-focus td {", + ".ui-data-table__table tbody tr.is-alt td {", + ): + assert _at(sel) < at, sel + + +def test_병합_칸_글_위_정렬_그대로(): + assert re.search(r"td\.is-merged \{\s*vertical-align: top;", CSS) + + +def test_B09_빨강_범위는_병합_칸에도_남음(): + page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + # 빨강(is-pick)은 .b09-estimation 이 붙어 더 셈 — 병합 칸 box-shadow: none 에 안 덮임 + assert ".b09-estimation .ui-data-table__table tbody tr.is-pick td {" in page diff --git a/resources/tester/test_246_1_b09_bill_menu_simple.py b/resources/tester/test_246_1_b09_bill_menu_simple.py new file mode 100644 index 000000000..7a76c6545 --- /dev/null +++ b/resources/tester/test_246_1_b09_bill_menu_simple.py @@ -0,0 +1,82 @@ +"""246-1 · 128 — B09 설계내역서 우클릭 메뉴 줄임 · 입력 칸 테두리 되돌림. + +- 입력 칸 = 241 앞 모양(글 상자 accent 5% 채움 · td 표시 없음) +- 더함 = 「항목 추가」 하나 — 항목 = 바로 아래 같은 부모 · 폴더 = 그 폴더 첫 하위 +- 맨 위 1단(새 공종) = ← 내어쓰기 — 직접비 · 손 줄은 묶음 · 관급자재대 앞 · 자재 줄은 못 냄 +- 연결 이름 = 「구조물 조합」 · 「일위대가 사전조합」 · 「단가산출」 · 지움 = 「줄 삭제」 +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import DG, ROWS, _menu, _order, _row # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +OLD_ADDS = ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가", "새 공종 추가(1단)"] + + +def test_입력_칸_테두리_없음(): + 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 + + +def test_더함_하나_줄_삭제_연결_이름_셋(): + keys = ["sec:1", "earth:4", "hand:g", "hand:a", "fee", "sec:G"] + out = _menu({}, labels=keys)["labels"] + for key in keys: + assert [x for x in out[key] if "추가" in x] == ["항목 추가"], key + 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] + for name in ["일위대가 호표", "단가산출 호표", "구조물 조합", "일위대가 사전조합", "단가산출"]: + assert f'["{name}",' in picks + for word in ["서고", "마스터", "회사", "개인", "M01", "새 산근", "로직"]: + assert word not in picks + 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") + assert "항목 추가 · 줄 삭제" in bill # 표 아래 안내 글 + + +#: 관급자재대 안 손 묶음(hand:s · 자재 없음) · 손 항목(hand:t) +SUPPLY = [*ROWS[:-1], _row("hand:s", "sec:G", 2, "group", cost="supply"), + _row("hand:t", "hand:s", 3, cost="supply", qty=1, ref=DG), ROWS[-1]] # fmt: skip + + +def test_자리_규칙(): + runs = [("hand:g", "항목 추가"), ("hand:a", "항목 추가"), ("hand:b", "항목 추가"), + ("hand:a", "내어쓰기 ←"), ("hand:g", "내어쓰기 ←"), ("hand:s", "내어쓰기 ←"), + ("hand:t", "내어쓰기 ←"), ("mat:M1", "내어쓰기 ←")] # fmt: skip + out = _menu({}, runs=runs, rows=SUPPLY)["runs"] + new = lambda b: [(e.get("parent"), e.get("name", e["key"])) for e in b["rows"]] # noqa: E731 + # 폴더 = 그 폴더 첫 하위 · 항목 = 바로 아래 같은 부모 + got = new(out["hand:g|항목 추가"]) + assert got[got.index(("sec:1.2", "hand:g")) + 1] == ("hand:g", "새 줄") + got = [e["key"] for e in out["hand:a|항목 추가"]["rows"]] + row = out["hand:a|항목 추가"]["rows"][got.index("hand:a") + 1] + assert (row["name"], row["parent"]) == ("새 줄", "hand:g") + got = out["hand:b|항목 추가"]["rows"] + row = got[[e["key"] for e in got].index("hand:b") + 1] + assert (row["name"], row["parent"], "kind" in row) == ("새 줄", "sec:1.2", False) + # ← = 한 단씩 — 레벨 4 → 3(sec:1.2 아래) · 레벨 3 → 2(sec:1 아래) + assert ("hand:a", "sec:1.2") in _order(out["hand:a|내어쓰기 ←"]) + assert ("hand:g", "sec:1") in _order(out["hand:g|내어쓰기 ←"]) + # 관급 안 손 묶음 ← = 새 공종(직접비) · 관급자재대 앞 + b = out["hand:s|내어쓰기 ←"] + tops = [k for k, p in _order(b) if p is None] + assert tops == ["sec:1", "sec:4", "hand:s", "sec:G"] + s = {e["key"]: e for e in b["rows"]}["hand:s"] + assert (s["kind"], s["cost"]) == ("group", "direct") + # 손 항목 ← 관급 묶음 밖(sec:G 아래) · 자재 줄 = 1단 안 됨 + assert ("hand:t", "sec:G") in _order(out["hand:t|내어쓰기 ←"]) + assert out["mat:M1|내어쓰기 ←"] is None + # 1단 손 항목 → 묶음(수량 · 연결 지움) — 2단 손 항목을 두 번 냄 + one = _menu({"bill": out["hand:t|내어쓰기 ←"]}, runs=[("hand:t", "내어쓰기 ←")], + rows=SUPPLY)["runs"]["hand:t|내어쓰기 ←"] # fmt: skip + t = {e["key"]: e for e in one["rows"]}["hand:t"] + assert ("parent" in t, t["kind"], t["qty"], t["ref"], t["cost"]) == ( + False, "group", None, None, "direct") # fmt: skip diff --git a/resources/tester/test_246_2_context_submenu_side.py b/resources/tester/test_246_2_context_submenu_side.py new file mode 100644 index 000000000..3c09cc09a --- /dev/null +++ b/resources/tester/test_246_2_context_submenu_side.py @@ -0,0 +1,168 @@ +"""246-2 · 129 — 공용 우클릭 메뉴 하위 메뉴(children) = 「글 ▸」 한 줄 + 오른쪽 옆 새 판. + +- 올림 · 누름 · → = 옆 판(머리 줄 높이 · 메뉴 오른쪽) · 다른 줄 올림 · ← = 닫힘(← 는 머리로 초점) +- 화면 오른쪽 끝이면 메뉴 왼쪽 · 옆 판 고름 = 메뉴 닫고 동작 · 바깥 누름 · Esc = 모두 닫힘 +- 옛 아코디언(같은 판 안 펼침 · ▾) 없음 · B09 「연결」 = children 그대로(옆 판으로 감) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) + +DOM_JS = """ +const docOn = {}; +class El { + constructor(tag) { Object.assign(this, { tag, kids: [], parent: null, on: {}, style: {}, + className: "", hidden: false, textContent: "", attrs: {} }); } + get classList() { + const el = this; + const list = () => el.className.split(" ").filter(Boolean); + return { add: (c) => { if (!list().includes(c)) el.className = [...list(), c].join(" "); }, + remove: (c) => (el.className = list().filter((x) => x !== c).join(" ")), + contains: (c) => list().includes(c) }; + } + get ownerDocument() { return { defaultView: globalThis.view }; } + get firstElementChild() { return this.kids[0] ?? null; } + get lastElementChild() { return this.kids.at(-1) ?? null; } + setAttribute(k, v) { this.attrs[k] = v; } + append(...xs) { for (const x of xs) { x.parent = this; this.kids.push(x); } } + replaceChildren() { this.kids = []; } + remove() { if (this.parent) this.parent.kids = this.parent.kids.filter((x) => x !== this); } + contains(x) { for (let at = x; at; at = at.parent) if (at === this) return true; return false; } + addEventListener(t, f) { (this.on[t] ??= []).push(f); } + fire(t, ev = {}) { for (const f of this.on[t] ?? []) f({ target: this, preventDefault() {}, + stopPropagation() {}, ...ev }); } + focus() { doc.activeElement = this; } + all() { return this.kids.flatMap((k) => [k, ...k.all()]); } + match(sel) { return sel.startsWith(".") ? sel.slice(1).split(".").every((c) => this.classList.contains(c)) + : this.tag === sel; } + querySelectorAll(sel) { + let found = [this]; + for (const part of sel.split(" ")) found = found.flatMap((el) => el.all().filter((k) => k.match(part))); + return found; + } + querySelector(sel) { return this.querySelectorAll(sel)[0] ?? null; } + getBoundingClientRect() { + if (this.style.left !== undefined) { + const side = this.classList.contains("ui-ctx-menu__side"); + const left = parseFloat(this.style.left), top = parseFloat(this.style.top); + const width = side ? 150 : 160; + const height = 8 + 30 * this.kids.filter((k) => k.tag === "button").length; + return { left, top, width, height, right: left + width, bottom: top + height }; + } + const box = this.parent.getBoundingClientRect(); + const top = box.top + 4 + 30 * this.parent.kids.filter((k) => k.tag === "button").indexOf(this); + return { left: box.left + 4, top, width: box.width - 8, height: 30, right: box.right - 4, bottom: top + 30 }; + } +} +const doc = { activeElement: null, createElement: (t) => new El(t), + addEventListener: (t, f) => (docOn[t] ??= []).push(f) }; +globalThis.document = doc; +globalThis.Node = El; +globalThis.view = { innerWidth: 1000, innerHeight: 800 }; +globalThis.window = globalThis.view; +""" + +SCENE_JS = """ +const log = []; +const menu = createMapContextMenu("b09-estimation"); +const items = [["A", () => log.push("A")], + { label: "연결", children: [["x", () => log.push("x")], ["y", () => log.push("y")]] }, "-", + ["B", () => log.push("B")]]; +const el = menu.element; +const btns = () => el.kids.filter((k) => k.tag === "button"); +const side = () => el.querySelector(".ui-ctx-menu__side"); +const sideText = () => side()?.kids.map((k) => k.textContent) ?? null; +const at = () => [parseFloat(side().style.left), parseFloat(side().style.top), side().style.position]; +const out = {}; +menu.open(100, 100, items); +const head = btns()[1]; +out.open = [el.kids.map((k) => k.textContent || k.className), sideText(), head.attrs["aria-haspopup"]]; +head.fire("pointerenter"); +out.hover = [sideText(), at(), head.classList.contains("is-open"), side().className]; +out.firstRow = side().kids[0].getBoundingClientRect().top === head.getBoundingClientRect().top; +out.contains = menu.contains(side().kids[0]); +btns()[0].fire("pointerenter"); +out.leave = [sideText(), head.classList.contains("is-open")]; +head.fire("click"); +out.click = [sideText(), doc.activeElement?.textContent]; +side().fire("keydown", { key: "ArrowLeft" }); +out.left = [sideText(), doc.activeElement === head]; +head.fire("keydown", { key: "ArrowRight" }); +out.right = [sideText(), doc.activeElement?.textContent]; +side().kids[1].fire("click"); +out.pick = [el.hidden, el.kids.length, [...log]]; +// 화면 오른쪽 끝 — 메뉴는 안쪽으로 당김 · 옆 판 = 메뉴 왼쪽 +view.innerWidth = 400; +menu.open(300, 100, items); +btns()[1].fire("pointerenter"); +out.edge = [parseFloat(el.style.left), at()[0]]; +// 아래 끝 — 옆 판 올림 +view.innerWidth = 1000; view.innerHeight = 100; +menu.open(100, 10, items); +btns()[1].fire("pointerenter"); +out.bottom = at()[1]; +view.innerHeight = 800; +// Esc · 바깥 누름 = 모두 닫힘 +menu.open(100, 100, items); +btns()[1].fire("pointerenter"); +for (const f of docOn.keydown) f({ key: "Escape", stopPropagation() {}, preventDefault() {} }); +out.esc = [el.hidden, side()]; +menu.open(100, 100, items); +btns()[1].fire("pointerenter"); +const inside = side().kids[0]; +for (const f of docOn.pointerdown) f({ target: inside }); +out.insideDown = el.hidden; +for (const f of docOn.pointerdown) f({ target: new El("div") }); +out.outsideDown = el.hidden; +console.log(JSON.stringify(out)); +""" + + +def _node(source: str) -> str: + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=source, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return result.stdout + + +def _src() -> str: + return (ROOT / "ui_template" / "ui_template_context_menu.ts").read_text("utf-8") + + +def test_옆_판_열기_닫기_자리(): + out = json.loads(_node(DOM_JS + IMPORTS.sub("", _src()) + SCENE_JS)) + # 「연결 ▸」 한 줄 · 처음엔 옆 판 없음(같은 판 안 펼침 없음) + assert out["open"] == [["A", "연결 ▸", "ui-ctx-menu__sep", "B"], None, "menu"] + # 올림 = 옆 판 다섯 자리 — 메뉴 오른쪽(100 + 160) · 첫 줄 = 머리 줄 높이(134 - 4) · fixed + names = "b09-estimation__context-menu-sub ui-ctx-menu ui-ctx-menu__side" + assert out["hover"] == [["x", "y"], [260, 130, "fixed"], True, names] + assert out["firstRow"] is True and out["contains"] is True + assert out["leave"] == [None, False] # 다른 줄 올림 = 닫힘 + assert out["click"] == [["x", "y"], "x"] # 누름 = 열고 첫 줄 초점 + assert out["left"] == [None, True] # ← = 닫고 머리 줄로 + assert out["right"] == [["x", "y"], "x"] # → = 열기 + assert out["pick"] == [True, 0, ["y"]] # 옆 판 고름 = 메뉴 닫고 동작 + # 오른쪽 끝 — 메뉴 = 400 - 4 - 160 · 옆 판 = 메뉴 왼쪽(236 - 150) + assert out["edge"] == [236, 86] + # 아래 끝(높이 100) — 옆 판(68) 바닥 = 100 - 4 + assert out["bottom"] == 100 - 4 - 68 + assert out["esc"] == [True, None] + assert out["insideDown"] is False and out["outsideDown"] is True + + +def test_옛_아코디언_없음_공용_꼴(): + src = _src() + assert "ui-ctx-menu__sub" not in src and "▾" not in src and "is-sub" not in src + css = (ROOT / "ui_template" / "ui_template_context_menu.css").read_text("utf-8") + 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 diff --git a/resources/tester/test_247_1_b08_summary_green_lamp.py b/resources/tester/test_247_1_b08_summary_green_lamp.py new file mode 100644 index 000000000..a490adb49 --- /dev/null +++ b/resources/tester/test_247_1_b08_summary_green_lamp.py @@ -0,0 +1,107 @@ +"""247-1 B08 구조물 집계표 — 시설이 모두 조합에 이은 측점 줄 = 초록 램프. + +- 칸 class: 화면 소스의 `missing` · `linked` · `cellClass` 를 떼어 공용 표로 그림(Node) + 모두 연결 = 초록 · 하나라도 빈 칸 = 노랑 · 시설 없음 · 합계 줄 = 램프 없음 +- 호버 글: 초록 = 「모두 연결됨」 +- CSS: 초록 = 성공 토큰(밝음 · 어두움) +""" + +import json +import re + +from test_198_1_b08_summary_lamp import PRELUDE, ROOT, UI, _lamp_class, _node + +DATA = (ROOT / "B08_Quantity" / "B08_Quantity_Summary_Data.ts").read_text(encoding="utf-8") + + +def _fac(fid: str, at: float, *linked: bool) -> dict: + slots = [ + {"copy": f"{fid}{i}", "group": "칸", "item": None, "linked": v} + for i, v in enumerate(linked) + ] + return { + "id": fid, + "type_id": "box", + "name": "BOX", + "facility": None, + "chainage_m": at, + "slots": slots, + } + + +def test_모두_연결_초록_빈_칸_노랑_없음_없음(): + grab = lambda pat: re.search(pat, UI, re.S).group(1) # noqa: E731 + missing = grab(r"(const missing = stations\.map\(.*?\);\n)") + linked = grab(r"(const linked = stations\.map\(.*?\);\n)") + cell = grab(r"cellClass: (\(i, key\) =>.*?),\n\s*frozen") + line = re.search(r"(const LINKED_CLASS = .*?;)", UI).group(1) + facilities = [_fac("a", 0, True, True), _fac("b", 20, True, False), _fac("c", 20, True)] + unlinked = [f for f in facilities if not all(s["linked"] for s in f["slots"])] + table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts")) + # 두 함수만 떼어 씀(모듈 import 없이) + data = json.dumps( + "".join( + re.search(rf"export (const {name} = .*?;\n)\n", DATA, re.S).group(1) + for name in ("unlinkedAt", "unassignedText") + ) + ) + got = _node( + PRELUDE + + f""" +const load = (src) => {{ + const box = {{ exports: {{}} }}; + new Function("exports", "module", "require", js(src))( + box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p))); + return box.exports; +}}; +const t = load(fs.readFileSync({table}, "utf8")); +const {{ unlinkedAt, unassignedText }} = new Function( + js({data}) + "; return {{ unlinkedAt, unassignedText }};")(); +const L = (k) => k; +const LAMP_CLASS = {json.dumps(_lamp_class())}; +{line} +const stations = [0, 20, 40]; +const unlinked = {json.dumps(unlinked, ensure_ascii=False)}; +const pipe = {{ facilities: {json.dumps(facilities, ensure_ascii=False)} }}; +{missing}{linked}const cellClass = {cell}; +const html = t.renderDataTableHtml({{ + columns: [{{ key: "station", label: "측점" }}, {{ key: "c1", label: "조합" }}], + rows: stations.map((s) => ({{ station: String(s) }})), + total: {{ station: "계", c1: 1 }}, + frozen: 1, empty: "", cellClass }}); +const rows = html.split("")[1].split(""); +console.log(JSON.stringify({{ rows: rows.slice(0, 3), tail: html.split("")[1] ?? "", lamps: html.split(LAMP_CLASS).length - 1 }})); +""" + ) + lamp = _lamp_class() + assert f'class="is-frozen is-edge {lamp} is-linked"' in got["rows"][0] # 0 = 모두 연결 + assert f'class="is-frozen is-edge {lamp}"' in got["rows"][1] # 20 = 빈 칸 하나 = 노랑 + assert lamp not in got["rows"][2] # 40 = 시설 없음 + assert got["lamps"] == 2 # 합계 줄 없음 + + +def test_초록_호버_모두_연결됨(): + hover = re.search( + r"attachHoverInfo\(\n\s*table\.root,\n\s*(\(cell\) => \{.*?\n\s*\}),\n", UI, re.S + ) + got = _node( + PRELUDE + + f""" +const L = (k) => ({{ B08_Quantity_Summary_LampTip: "구조물 조합 미연결", B08_Quantity_Summary_LampOk: "모두 연결됨" }})[k]; +const missing = ["", "BOX: 칸"]; +const text = eval(js("(" + {json.dumps(hover.group(1))} + ")")); +const cell = (i) => ({{ closest: () => ({{ dataset: {{ i: String(i) }} }}) }}); +console.log(JSON.stringify([text(cell(0)), text(cell(1))])); +""" + ) + assert got == ["모두 연결됨", "구조물 조합 미연결 — BOX: 칸"] + locale = (ROOT / "ui_template" / "ui_template_locale_b4.ts").read_text(encoding="utf-8") + assert 'B08_Quantity_Summary_LampOk: ["모두 연결됨",' in locale + + +def test_초록_성공_토큰(): + css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text(encoding="utf-8") + m = re.search(r"td\.b08-summary__lamp\.is-linked::before\s*\{([^}]*)\}", css) + assert m and re.search(r"background:\s*var\(--color-success\)", m.group(1)) + theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8") + assert len(re.findall(r"--color-success:\s*#", theme)) >= 2 # 밝음 · 어두움 diff --git a/resources/tester/test_248_1_b09_group_jump_unpick.py b/resources/tester/test_248_1_b09_group_jump_unpick.py new file mode 100644 index 000000000..0ebb03c45 --- /dev/null +++ b/resources/tester/test_248_1_b09_group_jump_unpick.py @@ -0,0 +1,90 @@ +"""248-1 B09 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) 다른 탭 이동 빨강 = 설계내역서(242-1) 규칙. + +- 이동해 온 호표 = 빨강 하나 — 카드 = 박스 is-pick 만(줄 is-focus 안 붙음) · 표 = 구간 is-pick 만 · + 공용 focusRow(at, false) = 스크롤만 +- 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · 누른 줄만 고름(is-focus) · Esc = 고름도 풂 · 다시 그려도 그대로 +- 글 칸 안 키 · 다른 키 = 그대로 +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 +from resources.tester.test_171_3_b09_group_tabs import DG # noqa: E402 +from resources.tester.test_181_2_b09_box_pick import ROWS # noqa: E402 + +LABOR = "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts" +INPUT = "{ closest: (s) => (s.includes('input') ? {} : null) }" # 글 칸 안 키 + + +def test_카드_빨강_하나_누름_키_풂(): + out = run( + [COMMON, TABS, LABOR, GROUPS], + f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" + "\nconst o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: () => {}, redraw: () => {}," + " setTools: () => {}, makeTable: () => null };" + "\nconst marks = (v) => [v.all('.is-pick').length, v.all('.is-focus').map((e) => e.dataset.at ?? 'box')];" + "\nconst out = {};" + "\nlet [v] = renderGroupPanel(o); out.came = marks(v);" + "\nv.querySelector('[data-at=\"2\"]').click(); out.line = marks(v);" + "\n[v] = renderGroupPanel(o); v.querySelector('.ui-group-card').click(); out.box = marks(v);" + "\n[v] = renderGroupPanel(o); key('a'); key('ArrowDown', " + + INPUT + + "); out.kept = marks(v);" + "\nkey('ArrowDown'); out.arrow = marks(v);" + "\n[v] = renderGroupPanel(o); v.querySelector('[data-at=\"2\"]').click(); key('Escape'); out.esc = marks(v);" + "\nconsole.log(JSON.stringify(out));", + RAW, + ) + assert out["came"] == [1, []] # 박스 빨강만 · 줄 강조 없음 + assert out["line"] == [0, ["2"]] # 누른 줄만 고름 + assert out["box"] == [0, []] # 카드 빈 곳 누름 = 빨강만 풂 + assert out["kept"] == [1, []] # 다른 키 · 글 칸 안 = 그대로 + assert out["arrow"] == [0, []] + assert out["esc"] == [0, []] # Esc = 고름도 풂 + + +def test_표_빨강_하나_누름_키_풂(): + out = run( + [COMMON, TABS, GROUPS], + f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};" + "\nlet extra = null; let root = null; const scrolled = [];" + "\nconst makeTable = (e) => { extra = e; root = document.createElement('table');" + " spec.rows.forEach((_, i) => { const tr = document.createElement('tr'); tr.dataset.i = String(i);" + " tr.className = e(i) ?? ''; tr.append(document.createElement('td')); root.append(tr); });" + " return { root, focusRow: (i, mark) => scrolled.push([i, mark]) }; };" + "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}," + " setTools: () => {}, makeTable };" + "\nchosen.set('dg', 'table');" + "\nconst marks = () => [root.children.map((tr) => tr.className || null)," + " spec.rows.map((_, i) => extra(i))];" + "\nconst out = {};" + "\nrenderGroupPanel(o); out.came = marks(); out.scrolled = [...scrolled];" + "\nroot.children[1].children[0].click(); out.click = marks();" + "\nrenderGroupPanel(o); key('Enter'); key('ArrowUp', " + INPUT + "); out.kept = marks()[0];" + "\nkey('ArrowUp'); out.arrow = marks();" + "\nrenderGroupPanel(o); root.children[5].click(); key('Escape'); out.esc = marks();" + "\nconsole.log(JSON.stringify(out));", + ) + red = [None] * 3 + ["is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end", None] + none = [None] * 8 + assert out["came"] == [red, red] # 구간 빨강만 + assert out["scrolled"] == [[4, False]] # 스크롤만(공용 강조 안 붙임) + focus1 = [None, "is-focus"] + [None] * 6 + assert out["click"] == [focus1, focus1] # 누른 줄만 · 다시 그려도 + assert out["kept"] == red + assert out["arrow"] == [none, none] + assert out["esc"] == [none, none] + + +def test_배선(): + groups = (ROOT / GROUPS).read_text("utf-8") + assert "table.focusRow(at, false)" in groups + common = (ROOT / COMMON).read_text("utf-8") + assert 'el?.classList.add("is-focus")' not in common + assert "unpickCards(grid, true);" in common diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py new file mode 100644 index 000000000..d567527cc --- /dev/null +++ b/resources/tester/test_249_1_b08_milestone_capture_child.py @@ -0,0 +1,97 @@ +"""249-1 B08 거리이정표 자동 그림 = 자식 프로세스 캡처(사용자 148번). + +서버(uvicorn · Windows) = WindowsSelectorEventLoopPolicy → 스레드 안 sync_playwright 가 +드라이버 하위 프로세스를 못 만듦. 같은 정책 · 스레드에서: +① 옛 길(같은 프로세스 shoot) = 「브라우저 못 띄움」 재현 +② make_route_image(자식 파이썬 프로세스) = 실제 Edge 로 PNG 생김 · 키 값 응답 0 +③ 자식 시간 넘김 · 자식 죽음 = 까닭 한 줄 +카카오 SDK 대신 단색 페이지(이 파일을 자식 스크립트로 · page_html 만 바꿈) — 네트워크 없음. +""" + +import asyncio +import json +import sys +import threading +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 import B08_Quantity_Milestone_Capture as cap # noqa: E402 + +HTML = ( + '
' +) +KEY = "fake-js-key-249" +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 + print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read())))) + sys.exit(0) + +import pytest # noqa: E402 + +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows Selector 루프 재현") + + +def in_server_thread(fn): + """uvicorn 처럼 Selector 정책을 건 채 스레드(asyncio.to_thread 자리)에서 fn().""" + got = {} + + def run(): + try: + got["value"] = fn() + except BaseException as error: # noqa: BLE001 — 시험에서 그대로 봄 + got["error"] = error + + before = asyncio.get_event_loop_policy() + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + try: + job = threading.Thread(target=run) + job.start() + job.join(60) + finally: + asyncio.set_event_loop_policy(before) + return got + + +def test_옛_길_같은_프로세스는_못_띄움(): + pytest.importorskip("playwright.sync_api") + got = in_server_thread(lambda: cap.shoot(HTML, cap.DEFAULT_ORIGIN)) + assert isinstance(got.get("error"), cap.CaptureError) + assert str(got["error"]) == cap.NO_BROWSER + + +def test_자식_프로세스로_PNG_생김(tmp_path, monkeypatch): + pytest.importorskip("playwright.sync_api") + monkeypatch.setattr(cap, "CHILD", __file__) + monkeypatch.setenv(cap.KAKAO_JS, KEY) + monkeypatch.delenv(cap.ORIGIN_KEY, raising=False) + out = tmp_path / "auto" + got = in_server_thread(lambda: cap.make_route_image(out, "r1", LINES, MARKS)) + assert got.get("value") == {"saved": True, "reason": None}, got + png = (out / "r1.png").read_bytes() + assert png[:8] == b"\x89PNG\r\n\x1a\n" and len(png) > 1000 + assert sorted(p.name for p in out.iterdir()) == ["r1.png"] # .new 남지 않음 + + +def test_자식_시간_넘김_죽음_까닭(tmp_path, monkeypatch): + monkeypatch.setenv(cap.KAKAO_JS, KEY) + tail = " — 그림 올리기를 쓰세요." + head = "경로 그림 자동 캡처 실패 — " + hang = tmp_path / "hang.py" + hang.write_text("import time\ntime.sleep(30)\n", encoding="utf-8") + monkeypatch.setattr(cap, "CHILD", str(hang)) + monkeypatch.setattr(cap, "CHILD_TIMEOUT_S", 1) + got = cap.make_route_image(tmp_path / "auto", "r1", LINES, MARKS) + assert got == {"saved": False, "reason": head + "지도 그림이 20초 안에 안 끝남" + tail} + dead = tmp_path / "dead.py" + dead.write_text(f"raise SystemExit('boom {KEY}')\n", encoding="utf-8") + monkeypatch.setattr(cap, "CHILD", str(dead)) + got = cap.make_route_image(tmp_path / "auto", "r1", LINES, MARKS) + assert got == {"saved": False, "reason": head + cap.NO_BROWSER + tail} + assert not (tmp_path / "auto").exists() diff --git a/resources/tester/test_b05_station_roundtrip.py b/resources/tester/test_b05_station_roundtrip.py index 22d113943..6c8ad7142 100644 --- a/resources/tester/test_b05_station_roundtrip.py +++ b/resources/tester/test_b05_station_roundtrip.py @@ -42,7 +42,8 @@ def test_화면_표기는_거리를_정수로(results): def test_칸을_고치면_친_값(results): - assert results["edited"] == pytest.approx(125.2) + # 친 값 125.2 → 정수 m 125(234-1 · 저장값도 정수 m — 9-29 「소수 그대로」를 바꿈) + assert results["edited"] == 125 assert results["normalized"] == ["2", "10.0", 50] assert results["cleared"] is None diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index 22ad774a1..3fadfc00f 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -36,11 +36,15 @@ def test_visible_false_만_숨김(out): def test_visible_false_옵션은_폼에_안_그림_정의는_그대로(out): + # 숨긴 길이는 칸을 안 그리고 값만 쥠(54-26 · 범위 계산 · 저장이 옛 값을 이어 씀) · 상세는 B05 에 없음 assert out["drawB05"] == { - "keys": ["height_m"], + "keys": ["height_m", "length_m"], + "drawn": ["height_m"], "hidden": False, - } # 길이 숨김 · 상세는 B05 에 없음 - assert out["drawB06"]["keys"] == ["height_m", "stone_kind"] # B06 은 상세까지 · 숨김은 여기도 + } + # B06 은 상세까지 · 숨김은 여기도 칸 없음 + assert out["drawB06"]["drawn"] == ["height_m", "stone_kind"] + assert out["drawB06"]["keys"] == ["height_m", "stone_kind", "length_m"] assert out["definitionKept"] == ["height_m", "length_m", "stone_kind", "back_len_cm"] diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index 91320af14..33eda500d 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -124,7 +124,9 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀(): assert "mountDrawingTemplate(" not in text and "createSpreadsheet(" not in text, name assert cards.count("mountDrawingCard(") == 1 # 도면 미리보기 = 카드 파일에서 뗀 부품 하나(31-4) drawing_card = (M02 / "M02_MasterTemplete_DrawingCard.ts").read_text(encoding="utf-8") - assert drawing_card.count("mountDrawingTemplate(") == 1 + # 카드 도면 = 여러 장 탭 부품 한 번(105-3 · 36fc3d6c3 부터 mountDrawingTemplate 대신 · 234-2) + assert drawing_card.count("mountStructureSheets(") == 1 + assert "mountDrawingTemplate(" not in drawing_card def test_넣을_값_카드는_sub2_파일_한_벌(out): diff --git a/resources/tester/test_m02_area_table.py b/resources/tester/test_m02_area_table.py index 8a699f868..5826398ac 100644 --- a/resources/tester/test_m02_area_table.py +++ b/resources/tester/test_m02_area_table.py @@ -33,6 +33,7 @@ SHEET = { "A2": {"값": "합"}, "B2": {"식": "B1*2", "서식": 1}, "A3": {"값": "숨김행"}, + "F1": {"값": "왼쪽", "서식": 3}, "C1": {"값": "x"}, }, "병합": ["A1:A2", "C1:E5"], @@ -43,7 +44,7 @@ BOOK = { "종류": "통합문서", "판": 1, "열": "rv", - "서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}], + "서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}, {"가로": "left"}], "시트": [SHEET], "활성": "s1", } @@ -56,7 +57,12 @@ def _one(**ask) -> dict: def test_표시_글_형식_굵게_정렬_칸_key(): got = _one(시트="면적산출", 범위="A1:B2") cells = got["표"]["shapeData"]["cells"] - assert cells[0][1] == {"text": "1,234.50", "align": "right", "bold": True, "key": "s1!B1"} + assert cells[0][1] == { + "text": "1,234.50", + "align": "center", + "bold": True, + "key": "s1!B1", + } # 안 준 수 칸 = 가운데(237-1) assert cells[1][1]["text"] == "2,469.00" # 식 칸 = 풀이 값에 형식 assert cells[0][0]["align"] == "center" # 가로 서식 assert (got["시트"], got["범위"]) == ("s1", "A1:B2") @@ -64,6 +70,14 @@ def test_표시_글_형식_굵게_정렬_칸_key(): assert got["표"]["shapeData"]["style"] == {"fontSize": 2.5, "padding": 1, "연결": ""} +def test_맞춤_안_준_칸은_가운데_준_칸은_그대로_237_1(): + cells = _one(시트="s1", 범위="A1:F1")["표"]["shapeData"]["cells"][0] + by_key = {c["key"]: c["align"] for c in cells if c} + assert by_key["s1!A1"] == "center" # 가로 center 를 준 칸 + assert by_key["s1!B1"] == "center" # 안 준 수 칸 (옛: right) + assert by_key["s1!F1"] == "left" # 가로 left 를 준 칸 그대로 + + def test_병합_잘라냄_숨긴_행열_뺌_빈_칸(): shape = _one(시트="s1", 범위="A1:D4", 글자크기=5)["표"]["shapeData"] cells = shape["cells"] @@ -71,7 +85,11 @@ def test_병합_잘라냄_숨긴_행열_뺌_빈_칸(): assert cells[0][0]["rowSpan"] == 2 and cells[1][0] is None assert cells[0][2]["rowSpan"] == 3 and "colSpan" not in cells[0][2] # C1:E5 → C1:C4(D 숨김) assert cells[1][2] is None and cells[2][2] is None - assert cells[2][0] == {"text": "", "align": "left", "key": "s1!A4"} # 빈 칸 + assert cells[2][0] == { + "text": "", + "align": "center", + "key": "s1!A4", + } # 빈 칸 · 안 준 칸 = 가운데 assert shape["style"]["fontSize"] == 5 assert shape["columnWidths"][1] == pytest.approx(12 * 5 * 0.7) # 열 폭 비율 assert shape["rowHeights"] == [pytest.approx(5 * 1.8)] * 3 diff --git a/resources/tester/test_spreadsheet_align_default.py b/resources/tester/test_spreadsheet_align_default.py new file mode 100644 index 000000000..e32979c5e --- /dev/null +++ b/resources/tester/test_spreadsheet_align_default.py @@ -0,0 +1,57 @@ +"""맞춤 기본 = 가운데 · 가운데(237-1) — 안 준 칸 · 준 칸 · 도구줄 기본 표시 · 내보내기(복사 HTML). + +`spreadsheet_align_default_entry.ts` 를 vite `--ssr` 로 번들해 Node 로 돌림. 영역 표 맞춤은 +`test_m02_area_table.py`. 화면(도구줄 · 칸 · 편집창)은 사용자가 확인. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_align_default_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_맞춤_기본_가운데_237_1(): + with tempfile.TemporaryDirectory(prefix="spreadsheet_align_default_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + entry_rel, + "--outDir", + outdir, + ], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "spreadsheet_align_default_entry.js")], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), run.stderr + result = json.loads(run.stdout) + assert result["fail"] == 0, result["failures"] + assert result["pass"] == 14 diff --git a/ui_template/ui_template_context_menu.css b/ui_template/ui_template_context_menu.css index 7b11ab018..c726c6c66 100644 --- a/ui_template/ui_template_context_menu.css +++ b/ui_template/ui_template_context_menu.css @@ -47,22 +47,13 @@ cursor: not-allowed; } -/* 하위 메뉴(아코디언) — 머리글 굵게 · 종류는 들여쓰기 */ +/* 하위 메뉴(246-2) — 머리 줄 굵게 · 연 동안 머리 줄 칠함 · 옆 판 = 같은 메뉴 꼴(자리 = fixed · 스크립트) */ .ui-ctx-menu__item.is-group { font-weight: var(--font-weight-medium); } -.ui-ctx-menu__sub { - display: flex; - flex-direction: column; -} - -.ui-ctx-menu__sub[hidden] { - display: none; -} - -.ui-ctx-menu__item.is-sub { - padding-left: var(--spacing-24); +.ui-ctx-menu__item.is-open { + background: var(--color-surface-sunken, var(--color-mist-violet)); } /* 구분선(`"-"` 항목) */ diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 4edc8545b..514413a6c 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -13,8 +13,8 @@ import "./ui_template_context_menu.css"; -/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로 - * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). +/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 「글 ▸」 한 줄 · 올리거나 누르면 + * 오른쪽 옆 새 판(하위 메뉴 · 화면 오른쪽 끝이면 왼쪽) · → 열기 · ← 닫기(PLAN 246-2). * 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */ export type MapContextMenuItem = | [label: string, action: () => void] @@ -104,7 +104,7 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { const entry = document.createElement("button"); entry.type = "button"; entry.className = `${blockClass}__context-menu-item ui-ctx-menu__item`; - if (sub) entry.classList.add("is-sub"); + if (!sub) entry.addEventListener("pointerenter", closeSide); // 다른 줄로 옮기면 옆 판 닫힘 entry.textContent = label; entry.addEventListener("click", () => { close(); @@ -113,7 +113,72 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { return entry; } - /** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 · 하위 메뉴를 펼칠 때 */ + /** 옆 판(하위 메뉴) 닫음 — 머리 줄 표시도 풂 */ + function closeSide(): void { + element.querySelector(".ui-ctx-menu__side")?.remove(); + element + .querySelectorAll(".ui-ctx-menu__item.is-open") + .forEach((head) => head.classList.remove("is-open")); + } + + /** 옆 판 자리 — 머리 줄 오른쪽(넘치면 메뉴 왼쪽) · 아래 끝은 올림 · fixed(메뉴 overflow 에 안 잘림) · + * 변환된 그릇 안이면 실제 자리와 어긋난 만큼 바로잡음 */ + function placeSide(side: HTMLElement, head: HTMLElement): void { + const view = element.ownerDocument.defaultView ?? window; + const menu = element.getBoundingClientRect(); + let x = menu.right; + let y = head.getBoundingClientRect().top; + side.style.left = `${x}px`; + side.style.top = `${y}px`; + const box = side.getBoundingClientRect(); + const [ox, oy] = [box.left - x, box.top - y]; + y -= (side.firstElementChild?.getBoundingClientRect().top ?? box.top) - box.top; // 첫 줄 = 머리 줄 높이 + if (x + box.width > view.innerWidth - 4) x = Math.max(4, menu.left - box.width); + if (y + box.height > view.innerHeight - 4) y = Math.max(4, view.innerHeight - 4 - box.height); + side.style.left = `${x - ox}px`; + side.style.top = `${y - oy}px`; + } + + /** 하위 메뉴 머리 줄 — 올림 · 누름 · → = 옆 판 · 옆 판 ← = 닫고 머리로 */ + function buildGroup(label: string, children: Array<[string, () => void]>): HTMLButtonElement { + const head = document.createElement("button"); + head.type = "button"; + head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`; + head.setAttribute("aria-haspopup", "menu"); + head.textContent = `${label} ▸`; + const show = (focus: boolean): void => { + if (!head.classList.contains("is-open")) { + closeSide(); + const side = document.createElement("div"); + side.className = `${blockClass}__context-menu-sub ui-ctx-menu ui-ctx-menu__side`; + side.style.position = "fixed"; + side.setAttribute("role", "menu"); + children.forEach(([text, action]) => side.append(buildLeaf(text, action, true))); + side.addEventListener("keydown", (event) => { + if (event.key !== "ArrowLeft") return; + event.preventDefault(); + event.stopPropagation(); + closeSide(); + head.focus(); + }); + element.append(side); + head.classList.add("is-open"); + placeSide(side, head); + } + if (focus) element.querySelector(".ui-ctx-menu__side button")?.focus(); + }; + head.addEventListener("pointerenter", () => show(false)); + head.addEventListener("click", () => show(true)); + head.addEventListener("keydown", (event) => { + if (event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + show(true); + }); + return head; + } + + /** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 */ function fit(): void { const x = parseFloat(element.style.left); const y = parseFloat(element.style.top); @@ -144,29 +209,8 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { element.append(buildLeaf(item[0], item[1])); return; } - // 하위 메뉴(아코디언) — 머리글을 누르면 종류 목록이 바로 아래로 펼쳐진다. - // 한 번에 한 군만 연다(다른 군을 열면 앞의 군은 접힘). - const head = document.createElement("button"); - head.type = "button"; - head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`; - head.textContent = `${item.label} ▸`; - const body = document.createElement("div"); - body.className = `${blockClass}__context-menu-sub ui-ctx-menu__sub`; - body.hidden = true; - item.children.forEach(([label, action]) => body.append(buildLeaf(label, action, true))); - head.addEventListener("click", () => { - const willOpen = body.hidden; - element - .querySelectorAll(`.${blockClass}__context-menu-sub`) - .forEach((other) => (other.hidden = true)); - element - .querySelectorAll(`.${blockClass}__context-menu-item.is-group`) - .forEach((other) => (other.textContent = other.textContent!.replace("▾", "▸"))); - body.hidden = !willOpen; - head.textContent = `${item.label} ${willOpen ? "▾" : "▸"}`; - fit(); // 펼친 하위가 화면 아래로 넘치면 메뉴를 올림 - }); - element.append(head, body); + // 하위 메뉴 = 머리 줄 하나 · 옆 판(한 번에 하나) + element.append(buildGroup(item.label, item.children)); }); if (element.lastElementChild?.classList.contains("ui-ctx-menu__sep")) element.lastElementChild.remove(); diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 787d9e90f..f17dc400a 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -10,6 +10,13 @@ --dt-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface)); --dt-line: var(--color-border); --dt-edge: color-mix(in srgb, var(--color-text-secondary) 45%, var(--color-border)); + /* 들여쓰기 한 단(레벨 글자 크기와 무관 · 끌기 한 단과 같음 · 121 넓힘) */ + --dt-indent: 22px; + /* 레벨 글자 크기(PLAN 228-1) — 줄 높이 28px 안 · Lv3 = 표 기본(14px) */ + --dt-lv1-size: var(--text-body); + --dt-lv2-size: 15px; + --dt-lv4-size: 13px; + --dt-lv5-size: var(--text-caption); width: fit-content; /* 표가 좁으면 틀도 표만큼 */ max-width: 100%; max-height: calc(100vh - 260px); @@ -173,9 +180,9 @@ outline-offset: -2px; } -/* 레벨(PLAN 201-1 · level 옵션 · 보기 전용) — 레벨 열 왼쪽 맞춤 · 레벨마다 1em 들여쓰기(글 상자도) */ +/* 레벨(PLAN 201-1 · level 옵션 · 보기 전용) — 레벨 열 왼쪽 맞춤 · 레벨마다 1em(표 기본) 들여쓰기(글 상자도) */ .ui-data-table__table td.ui-data-table__lv { - padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em); + padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * var(--dt-indent)); text-align: left; } @@ -184,12 +191,93 @@ } .ui-data-table__table td.ui-data-table__lv .ui-data-table__input { - padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em); + padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * var(--dt-indent)); text-align: left; } +/* 나무(PLAN 235-1 · level.tree) — 접히지 않는 나무 안내선 · 폴더 · 파일 아이콘(공용 나무 ui_template_tree 꼴) + * 안내선 칸 한 단 = 들여쓰기 한 칸 · 선 = 칸 가운데(윗대 아이콘 가운데) · 고정 열 칸은 sticky 가 자리 기준 */ +.ui-data-table__table td.has-tree:not(.is-frozen) { + position: relative; +} + +.ui-data-table__guide { + position: absolute; + z-index: 1; + top: 0; + bottom: 0; + left: calc(var(--spacing-8) + var(--g, 0) * var(--dt-indent)); + width: var(--dt-indent); + pointer-events: none; +} + +.ui-data-table__guide::before, +.ui-data-table__guide::after { + content: ""; + position: absolute; + left: 50%; +} + +/* │ 윗대 형제가 아래에 더 있음 · ├ 형제가 아래에 더 있음 · └ 끝 형제 */ +.ui-data-table__guide::before { + top: 0; + bottom: 0; + border-left: 1px solid var(--color-border); +} + +.ui-data-table__guide.is-end::before { + bottom: 50%; +} + +.ui-data-table__guide.is-tee::after, +.ui-data-table__guide.is-end::after { + top: 50%; + width: 50%; + border-top: 1px solid var(--color-border); +} + +.ui-data-table__icon { + display: inline-flex; + margin-right: var(--spacing-4); + color: var(--color-text-secondary); + vertical-align: -2px; +} + +.ui-data-table__table td.has-tree .ui-data-table__input { + padding-left: calc( + var(--spacing-8) + var(--dt-lv, 0) * var(--dt-indent) + 14px + var(--spacing-4) + ); +} + +/* 레벨 글꼴(PLAN 228-1) — 묶음 줄(is-node) 굵게 · 크기는 레벨 · 항목 줄은 14px 보다 크지 않게(Lv1 · 2 항목 = 기본) */ +.ui-data-table__table td.ui-data-table__lv.is-node { + font-weight: var(--font-weight-bold); +} + +.ui-data-table__table td.ui-data-table__lv.is-lv1.is-node { + font-size: var(--dt-lv1-size); +} + +.ui-data-table__table td.ui-data-table__lv.is-lv2.is-node { + font-size: var(--dt-lv2-size); +} + +.ui-data-table__table td.ui-data-table__lv.is-lv4 { + font-size: var(--dt-lv4-size); +} + +.ui-data-table__table td.ui-data-table__lv.is-lv5 { + font-size: var(--dt-lv5-size); +} + /* 같은 값 세로 병합(PLAN 201-1 · mergeSame) — 한 칸 글은 위에 */ .ui-data-table__table td.is-merged { vertical-align: top; padding-top: calc((var(--dt-row-h) - 1.3em) / 2); } + +/* 245-1 병합 칸 = 줄 강조 밖 — 올린 줄 · 이동해 온 줄 · 줄무늬가 병합 칸을 칠하지 않음 · 그 줄 나머지 칸만 */ +.ui-data-table__table tbody tr td.is-merged { + background: var(--color-canvas); + box-shadow: none; +} diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 7f0b93d82..afc3bc564 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -12,6 +12,12 @@ * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 * 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0) * 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도) + * 글꼴(PLAN 228-1) = 칸 class is-lv1~5 · 묶음(group · 안 주면 바로 아래 줄이 더 깊은 줄) = is-node 굵게 + * 크기 Lv1 16 · Lv2 15 · Lv3 14 · Lv4 13 · Lv5 12(px) · 항목 줄은 14 보다 크지 않게 + * 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴) + * 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 · + * Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기 + * 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(PLAN 243-1 · keyEdit false = 표 밖이 맡음) * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) * ========================================================================== */ @@ -68,8 +74,21 @@ export interface DataTableOptions { cellClass?: (index: number, key: string) => string | null | undefined; /** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */ fillViewport?: boolean; - /** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) */ - level?: { key: string; of: (index: number) => number | null | undefined }; + /** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) · + * group = 묶음 줄인지(안 주면 바로 아래 줄 레벨이 더 큰 줄) — 레벨 · 묶음 따라 글꼴(228-1) */ + level?: { + key: string; + of: (index: number) => number | null | undefined; + group?: (index: number) => boolean; + /** 접히지 않는 나무 안내선 · 폴더(묶음) · 파일 아이콘(235-1) */ + tree?: boolean; + }; + /** 참이면 고칠 칸 = 글 · 더블클릭 · edit() 때만 글 상자(235-1) */ + editOnDouble?: boolean; + /** 칸 누른 뒤 표에서 Enter · F2 = 그 칸 입력 시작(editOnDouble · 기본 참 · 거짓 = 표 밖이 키를 맡음) */ + keyEdit?: boolean; + /** 지금 글 상자인 칸(editOnDouble · 표가 정함) */ + editing?: { index: number; key: string } | null; /** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */ mergeSame?: string[]; } @@ -77,10 +96,19 @@ export interface DataTableOptions { export interface DataTable { root: HTMLElement; setRows: (rows: DataRow[], total?: DataRow | null) => void; - /** 그 줄로 스크롤하고 강조 */ - focusRow: (index: number) => void; + /** 그 줄로 스크롤하고 강조(mark = false → 스크롤만 · 강조 풂) */ + focusRow: (index: number, mark?: boolean) => void; + /** 그 줄 칸 입력 시작(editOnDouble) — key 없거나 못 고치면 마지막 누른 열 · 첫 고칠 칸 · 못 하면 false */ + edit: (index: number, key?: string) => boolean; } +/** 나무 아이콘 — 폴더 · 항목(글꼴색을 따름) · 공용 나무(`ui_template_tree` 기본 줄)와 같은 꼴 */ +export const TREE_ICON = { + folder: + '', + leaf: '', +}; + const EMPTY = "-"; const ESC: Record = { "&": "&", "<": "<", ">": ">", '"': """ }; const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]); @@ -160,13 +188,18 @@ function rowHtml( const key = cols[j].key; const span = spans?.[j] ?? 1; if (!span) continue; // 위 칸에 합쳐짐 - const lvc = lv?.key === key ? " ui-data-table__lv" : ""; + const lvc = + lv?.key === key + ? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}${lv.tree === undefined ? "" : " has-tree"}` + : ""; + // 나무(235-1) = 안내선 + 폴더 · 파일 아이콘 — 글 상자 칸은 안내선만 + const guide = lv?.key === key ? (lv.tree ?? "") : ""; const c = spans ? ` data-c="${j}"` : ""; if (edit !== null && editable?.(edit, key)) { const v = r[key]; const raw = v === null || v === undefined ? "" : String(v); const tip = esc(cols[j].title ?? cols[j].label); - tr += ``; + tr += `${guide}`; continue; } const [txt, empty] = formatCell(r[key], fmts[j], blank); @@ -187,7 +220,11 @@ function rowHtml( (fmts[j] ? ' data-num=""' : "") + (span > 1 ? ` rowspan="${span}"` : "") + c; - tr += `${esc(txt)}`; + const icon = + lv?.key === key && lv.tree !== undefined + ? `${lv.node ? TREE_ICON.folder : TREE_ICON.leaf}` + : ""; + tr += `${guide}${icon}${esc(txt)}`; } const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra] .filter(Boolean) @@ -199,9 +236,10 @@ function rowHtml( : `${tr}`; } -/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */ +/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · 나무 안내선 HTML(나무 아니면 없음) · + * spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */ interface RowMore { - lv?: { key: string; n: number }; + lv?: { key: string; n: number; node: boolean; tree?: string }; spans?: number[]; } @@ -238,6 +276,25 @@ export function mergeSpans(o: DataTableOptions, from: number, to: number): numbe return out; } +/** 나무 안내선(235-1) — 줄마다 칸(한 단 = 들여쓰기 한 칸 · 레벨 2 부터) span HTML. + * 자기 칸 = 아래에 같은 레벨 형제가 더 있으면 ├(is-tee) · 끝이면 └(is-end) · 윗대 칸 = 그 윗대 형제가 아래에 더 있으면 │(is-bar) */ +export function treeGuides(levels: number[]): string[] { + const out: string[] = new Array(levels.length); + const more: boolean[] = []; // 레벨 d 형제가 아래에 더 있음(더 얕은 줄 전까지) + const span = (cls: string, slot: number) => + ``; + for (let i = levels.length - 1; i >= 0; i--) { + const lv = Math.max(1, levels[i]); + let html = ""; + for (let d = 2; d < lv; d++) if (more[d]) html += span("is-bar", d - 2); + if (lv >= 2) html += span(more[lv] ? "is-tee" : "is-end", lv - 2); + out[i] = html; + more[lv] = true; + more.length = lv + 1; // 더 깊은 레벨 = 이 줄에서 끊김 + } + return out; +} + /** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */ export interface RowWindow { from: number; @@ -274,8 +331,15 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin const blank = o.empty ?? EMPTY; const pick = o.onRowClick !== undefined || o.onCellClick !== undefined; const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null; + const tree = o.level?.tree ? treeGuides(o.rows.map((_r, i) => o.level!.of(i) ?? 1)) : null; + // 더블클릭 입력(235-1) — 글 상자 = 지금 고치는 칸만 + const inputAt = o.editOnDouble + ? (i: number, key: string) => + o.editing?.index === i && o.editing.key === key && !!o.editable?.(i, key) + : o.editable; for (let i = from; i < to; i++) { const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0; + const node = o.level ? isNode(o.level, i, n) : false; body += rowHtml( cols, fmts, @@ -285,12 +349,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin blank, pick ? i : null, i, - o.editable, + inputAt, o.focus === i, o.rowClass?.(i), o.cellClass ? (key) => o.cellClass?.(i, key) : undefined, { - ...(o.level ? { lv: { key: o.level.key, n: lvN } } : {}), + ...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}), ...(spans ? { spans: spans[i - from] } : {}), }, ); @@ -304,6 +368,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin ); } +/** 묶음 줄(228-1) — group 이 주면 그 값 · 안 주면 바로 아래 줄 레벨이 더 큼 */ +function isNode(lv: NonNullable, i: number, n: number): boolean { + if (lv.group) return lv.group(i); + return i + 1 < n && (lv.of(i + 1) ?? 1) > (lv.of(i) ?? 1); +} + /** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */ const OVER = 40; @@ -402,14 +472,73 @@ export function createDataTable(opts: DataTableOptions): DataTable { const key = input.dataset.k; setTimeout(() => state.onEdit?.(index, key, input.value)); }); - root.addEventListener("click", (event) => { - const row = (event.target as Element).closest("tr[data-i]"); - if (!row || !root.contains(row)) return; - const cell = (event.target as Element).closest("td"); + /** 누른 칸 [줄, 열 키] — 본문 줄 밖 = null */ + const cellAt = (target: EventTarget | null): [number, string] | null => { + const row = (target as Element).closest("tr[data-i]"); + if (!row || !root.contains(row)) return null; + const cell = (target as Element).closest("td"); const column = cell ? state.columns[cellColumn(cell)] : undefined; - if (column) state.onCellClick?.(Number(row.dataset.i), column.key); - state.onRowClick?.(Number(row.dataset.i)); + return [Number(row.dataset.i), column?.key ?? ""]; + }; + let lastKey = ""; + let lastRow: number | null = null; // 마지막 누른 줄 — Enter · F2 입력 시작 자리 + root.addEventListener("click", (event) => { + const at = cellAt(event.target); + if (!at) return; + lastKey = at[1]; + lastRow = at[0]; + if (at[1]) state.onCellClick?.(at[0], at[1]); + state.onRowClick?.(at[0]); }); + const edit = (index: number, key?: string): boolean => { + if (!state.editOnDouble || !state.editable) return false; + const can = state.columns.map((c) => c.key).filter((k) => state.editable!(index, k)); + const k = [key, lastKey, can[0]].find((x) => x !== undefined && can.includes(x)); + if (k === undefined) return false; + state.editing = { index, key: k }; + draw(true); + const input = root.querySelector( + `input[data-i="${index}"][data-k="${CSS.escape(k)}"]`, + ); + input?.focus(); + input?.select(); + return true; + }; + if (opts.editOnDouble) { + root.addEventListener("dblclick", (event) => { + const at = cellAt(event.target); + if (at && state.editable?.(at[0], at[1])) edit(at[0], at[1]); + }); + // Enter = 입력 끝(한글 조합 중 Enter 는 조합 끝) — 표에 초점 · 키가 문서까지 가지 않게(다시 입력 시작 방지) + // 표에 초점이 있을 때 Enter · F2 = 누른 칸 입력 시작(keyEdit) + root.addEventListener("keydown", (event) => { + if ((event.key !== "Enter" && event.key !== "F2") || event.isComposing) return; + if (!(event.target instanceof HTMLInputElement)) { + if (opts.keyEdit === false || event.target !== root || lastRow === null) return; + if (state.editable?.(lastRow, lastKey)) { + event.preventDefault(); + edit(lastRow, lastKey); + } + return; + } + if (event.key !== "Enter") return; + event.preventDefault(); + event.stopPropagation(); + root.focus(); + }); + // 초점 떠남 = 입력 끝 — 다시 그린 같은 칸으로 초점이 돌아왔으면 그대로 + root.addEventListener("focusout", (event) => { + const input = event.target as HTMLInputElement; + if (!state.editing || !input.dataset?.k) return; + setTimeout(() => { + const now = document.activeElement as HTMLElement | null; + const back = now && root.contains(now) && now.dataset.k === input.dataset.k; + if (back && now.dataset.i === input.dataset.i) return; + state.editing = null; + draw(true); + }); + }); + } draw(true); if (opts.fillViewport) fillViewport(root, () => draw(false)); return { @@ -419,10 +548,11 @@ export function createDataTable(opts: DataTableOptions): DataTable { if (total !== undefined) state.total = total; draw(true); }, - focusRow(index) { - state.focus = index; + focusRow(index, mark = true) { + state.focus = mark ? index : null; root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3); draw(true); }, + edit, }; } diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 976100317..3a7fb9b02 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -7,7 +7,7 @@ * 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽) * 접기 없음 — 묶음 모두 펼친 박스 · 같은 가로 줄 카드 = 높이 같음(200-1 · 합계 줄은 아래 붙음) * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선 - * (`is-pick`) + 스크롤 + 그 줄 강조(`is-focus`) + * (`is-pick`) + 스크롤만(248-1 빨강 하나) · 누름 = 빨강 풂 · 누른 줄만 고름(`is-focus`) * ========================================================================== */ import "./ui_template_group_cards.css"; @@ -224,7 +224,9 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { grid.addEventListener("click", (event) => { const el = (event.target as Element).closest("[data-at]"); + unpickCards(grid, true); if (!el || !grid.contains(el)) return; + el.classList.add("is-focus"); opts.onPick?.(Number(el.dataset.at)); }); @@ -232,8 +234,13 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { const el = grid.querySelector(`[data-at="${opts.focus}"]`); const box = el?.closest(".ui-group-card"); box?.classList.add("is-pick"); - el?.classList.add("is-focus"); requestAnimationFrame(() => (box ?? el)?.scrollIntoView({ block: "center" })); } return root; } + +/** 248-1 박스 빨강(`is-pick`) 풂 · lines = 고른 줄(`is-focus`)도 */ +export function unpickCards(root: ParentNode, lines = false): void { + for (const el of root.querySelectorAll(lines ? ".is-pick, .is-focus" : ".is-pick")) + el.classList.remove("is-pick", "is-focus"); +} diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 4dd32bf14..4c0e3ff0e 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -18,6 +18,7 @@ export const ui_locales_b4 = { "Insert suggested value — KDS standard soil 0.5 m", ], B08_Quantity_Tab_Hydrology: ["수리집수면적표", "Hydraulic Catchment Table"], + B08_Quantity_Tab_Milestone: ["거리이정표", "Distance Milestones"], B08_Quantity_Tab_Pending: ["차례대로 만들 탭입니다.", "This tab comes later, in order."], B08_Quantity_Tab_Materials: ["자재", "Materials"], B08_Quantity_Tab_MaterialTotal: ["총괄자재수량집계표", "Total Material Quantity Table"], @@ -64,6 +65,7 @@ export const ui_locales_b4 = { B08_Quantity_Summary_Total: ["계", "Total"], B08_Quantity_Summary_Unlinked: ["미연결", "Unlinked"], B08_Quantity_Summary_LampTip: ["구조물 조합 미연결", "Structure combination unlinked"], + B08_Quantity_Summary_LampOk: ["모두 연결됨", "All linked"], B08_Quantity_Link_Title: ["조합 연결", "Link combinations"], B08_Quantity_Link_Body: ["본체", "Body"], B08_Quantity_Link_Linked: ["연결됨", "Linked"], diff --git a/ui_template/ui_template_tree.ts b/ui_template/ui_template_tree.ts index 0da561078..a2aa5041a 100644 --- a/ui_template/ui_template_tree.ts +++ b/ui_template/ui_template_tree.ts @@ -8,6 +8,7 @@ import "./ui_template_tree.css"; import { enterMenu, leaveMenu } from "./ui_template_context_menu"; +import { TREE_ICON } from "./ui_template_data_table"; import { el } from "./ui_template_elements"; export interface TreeNode { @@ -28,13 +29,6 @@ export interface TreeNode { folder?: boolean; } -/** 기본 줄 아이콘 — 폴더 · 항목(글꼴색을 따름) */ -const ICON = { - folder: - '', - leaf: '', -}; - export interface TreeMenuItem { label: string; run: () => void; @@ -130,7 +124,7 @@ export function createTreeRow( ): HTMLButtonElement { const branch = !!node.children?.length || !!node.folder; const icon = el("span", { className: "ui-tree__icon" }); - icon.innerHTML = branch ? ICON.folder : ICON.leaf; // 고정 SVG 글만 + icon.innerHTML = branch ? TREE_ICON.folder : TREE_ICON.leaf; // 고정 SVG 글만 const button = el("button", { className: `ui-tree__row ui-sidebar-row${branch ? " is-branch" : ""}`, attrs: { type: "button" },