diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 0399b7ad2..48ef93fe1 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -110,6 +110,8 @@ export const STATE_REGISTRY = { /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ "structure-notes": { bucket: "draft", scope: "route" }, + /** B06 횡단 [상세] 모달 표 위 CAD 칸(260-1) — 측점키(0.01 m) → CAD 엔티티 묶음. [저장]이 내보냄. */ + "structure-note-cads": { bucket: "draft", scope: "route" }, /** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */ "detail-table": { bucket: "draft", scope: "route" }, /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index bb293e404..951564e82 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -148,6 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { if (event.button === 1) event.preventDefault(); }); svg.addEventListener("pointerdown", (event) => { + // 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1) + moved = false; // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. if (!(options.panStart ? options.panStart(event) : event.button === 1)) return; event.preventDefault(); diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 846d3623c..c3f163646 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -453,10 +453,10 @@ background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface)); } -/* 미연결 — 연한 노랑 배경 + 노랑 테두리(경고 · 밝음 · 어두움 같은 토큰). */ +/* 미연결 — 옅은 회색 배경 + 회색 테두리(구조물 리스트 램프 색 · 밝음 · 어두움 같은 토큰 · 258-3). */ .b05-structure__summary-name:not(.is-assigned) { - border-color: var(--color-warning); - background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + border-color: var(--color-text-muted); + background: color-mix(in srgb, var(--color-text-muted) 12%, var(--color-surface)); } .b05-structure__summary .b05-route__irregular-btn { diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 7c09a92ed..acbf38e0a 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -474,6 +474,8 @@ export interface CrossDesign { pavement_suggested?: boolean; /** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */ structure_note?: string; + /** 표 위 CAD 칸(260-1) — CAD 저장 꼴 · 있으면 B07 이 글 대신 이 엔티티를 표 위에 놓음. */ + structure_note_cad?: CrossNoteCad; /** [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 서버가 다시 셈). */ detail_table?: CrossDetailTable; /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ @@ -625,6 +627,8 @@ export interface CrossSectionPatch { cut_rock_area_m2?: number; /** 측점 구조물 글(138-4) — 빈 글 = 지움. */ structure_note?: string; + /** 표 위 CAD 칸(260-1) — 세션 초안 그대로. */ + structure_note_cad?: CrossNoteCad; /** [상세] 표 손질(179-4) — 세션 초안 그대로. */ detail_table?: CrossDetailTable; } @@ -638,7 +642,7 @@ export type CrossCellSource = /** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */ export interface CrossPickPiece { - line: "design" | "ground" | "spoil" | "structure"; + line: "design" | "ground" | "spoil" | "structure" | "rock"; start: [number, number]; end: [number, number]; } @@ -649,3 +653,9 @@ export interface CrossDetailTable { rows: Array<{ id: string; after: string | null; grouped: boolean; labels: string[] }>; columns: Array<{ id: string; label: string }>; } + +/** [상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(`cad_host` · 엔티티 · 층) 그대로. */ +export interface CrossNoteCad { + entities: Record[]; + layers?: Record[]; +} diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 02bfa8a79..987e4cfdc 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -94,6 +94,8 @@ export const USER_TOUCHED_KEYS = [ "cut_slope_ratio_user", // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + // 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님. + "structure_note_cad", // [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) "detail_table", // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index adab8a3c5..294045754 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -155,6 +155,8 @@ USER_TOUCHED_KEYS = ( "cut_slope_ratio_user", # 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + # 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님. + "structure_note_cad", # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) "detail_table", # 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index fae03161c..c0434106b 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -138,7 +138,7 @@ class BoxAdjustPatch(BaseModel): class CrossPickPiece(BaseModel): """[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고).""" - line: Literal["design", "ground", "spoil", "structure"] + line: Literal["design", "ground", "spoil", "structure", "rock"] start: list[float] = Field(..., min_length=2, max_length=2) end: list[float] = Field(..., min_length=2, max_length=2) @@ -179,6 +179,15 @@ class CrossDetailTable(BaseModel): columns: list[CrossDetailColumn] = Field(default_factory=list, max_length=4) +class CrossNoteCad(BaseModel): + """[상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(엔티티 · 층) 그대로 · B07 이 표 위에 놓음.""" + + model_config = ConfigDict(extra="allow") + + entities: list[dict[str, Any]] = Field(default_factory=list, max_length=2000) + layers: list[dict[str, Any]] | None = None + + class CrossSectionPatch(BaseModel): """확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" @@ -246,6 +255,8 @@ class CrossSectionPatch(BaseModel): ford_pipe_length_m: float | None = Field(default=None, ge=0) # 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움. structure_note: str | None = Field(default=None, max_length=2000) + # 표 위 CAD 칸(260-1) — 엔티티가 빈 칸 = 비움(옛 글 대신 아무것도 안 놓음) + structure_note_cad: CrossNoteCad | None = None # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈 detail_table: CrossDetailTable | None = None diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 260405b9b..a475ba9ed 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -4,8 +4,8 @@ * * 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 · * 칸 크기가 바뀌면 다시 그림) · - * 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 · - * B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) · + * 가운데 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 → + * [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) · * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) · * 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). @@ -16,7 +16,7 @@ import "./B06_Section_UI_Style_Cross_Detail.css"; import { openModal } from "@ui/ui_template_modal"; -import { readState, writeState } from "../A00_Common/b_page_state"; +import { readState } from "../A00_Common/b_page_state"; import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch"; import type { CrossQuantitySheet, @@ -33,6 +33,7 @@ import { import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table"; import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; +import { mountNoteCad, stationNoLabel } from "./B06_Section_UI_Cross_Detail_Cad"; /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ export interface CrossDetailHost { @@ -52,9 +53,7 @@ export function setCrossDetailHost(next: CrossDetailHost): void { host = next; } -/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */ - -const noteKey = (chainageM: number): string => chainageM.toFixed(2); +/* ── 옛 구조물 글 세션 초안(측점키 0.01 m → 글 · 새 글은 CAD 칸 260) ─────────────── */ /** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */ export function readStructureNotes(projectId: string, routeId: number): Map { @@ -62,26 +61,6 @@ export function readStructureNotes(projectId: string, routeId: number): Map [Number(key), String(text)])); } -function noteOf(section: CrossSection): string { - const routeId = host?.routeId(); - if (host && routeId !== null && routeId !== undefined) { - const draft = readState>("structure-notes", host.projectId, routeId); - const text = draft?.[noteKey(section.chainage_m)]; - if (text !== undefined) return text; - } - return section.design?.structure_note ?? ""; -} - -function writeNote(section: CrossSection, text: string): void { - // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 - if (section.design) section.design.structure_note = text; - const routeId = host?.routeId(); - if (!host || routeId === null || routeId === undefined) return; - const all = readState>("structure-notes", host.projectId, routeId) ?? {}; - all[noteKey(section.chainage_m)] = text; - writeState("structure-notes", all, host.projectId, routeId); -} - /* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */ function cell( @@ -277,12 +256,21 @@ export function openCrossDetail(section: CrossSection): void { dialogClass: "b06-detail", wide: true, mount(body, close) { - const note = document.createElement("textarea"); - note.className = "ui-input b06-detail__note"; - note.rows = 3; - note.placeholder = "도면 표 위 글 — B07 횡단도 수량표 바로 위에 줄마다 찍힘"; - note.value = noteOf(section); - note.addEventListener("input", () => writeNote(state.section, note.value)); + // 표 위 빈 CAD(260-2) — 높이 고정 · 한 번만 만듦 · 닫으면 destroy + const note = document.createElement("div"); + note.className = "b06-detail__note"; + note.style.height = "180px"; + const routeId = host?.routeId(); + const stations = host?.detail()?.longitudinal.stations ?? []; + const noteCad = + host && routeId !== null && routeId !== undefined + ? mountNoteCad(note, section, () => state.section, { + projectId: host.projectId, + routeId, + station: stationNoLabel(section.chainage_m, inferStationInterval(stations)), + }) + : null; + note.querySelector(".cad-host")?.style.setProperty("min-height", "0"); const cad = document.createElement("div"); cad.className = "b06-detail__cad"; const tableBox = document.createElement("div"); @@ -369,6 +357,7 @@ export function openCrossDetail(section: CrossSection): void { stop: () => { resize.disconnect(); tablePick.detach(); + noteCad?.destroy(); }, }; // 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음 diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts new file mode 100644 index 000000000..6633cfba7 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -0,0 +1,286 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Cad.ts + * 횡단 [상세] 모달 가운데 = 표 위 CAD 칸(260 · 요구 162-21). + * + * 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` · + * 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음 + * (`_Engine_Cad_Table.cross_note_cad_entities`). + * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수 + * 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면. + * 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. + * ========================================================================== */ + +import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import type { CrossNoteCad, CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch"; + +const noteKey = (chainageM: number): string => chainageM.toFixed(2); + +/** [저장]이 patch 로 싣는 CAD 칸 — 측점(m) → 엔티티 묶음. */ +export function readStructureNoteCads( + projectId: string, + routeId: number, +): Map { + const raw = readState>("structure-note-cads", projectId, routeId); + return new Map(Object.entries(raw ?? {}).map(([key, cad]) => [Number(key), cad])); +} + +function writeNoteCad(section: CrossSection, cad: CrossNoteCad, at: NoteCadPlace): void { + // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 + if (section.design) section.design.structure_note_cad = cad; + const all = readState>( + "structure-note-cads", + at.projectId, + at.routeId, + ); + const next = { ...all, [noteKey(section.chainage_m)]: cad }; + writeState("structure-note-cads", next, at.projectId, at.routeId); +} + +/** 세션 초안 → 저장분 → 없음(null). */ +function noteCadOf(section: CrossSection, at: NoteCadPlace): CrossNoteCad | null { + const draft = readState>( + "structure-note-cads", + at.projectId, + at.routeId, + ); + return draft?.[noteKey(section.chainage_m)] ?? section.design?.structure_note_cad ?? null; +} + +/* ── 엔티티(CAD 저장 꼴 · B07 `_text_entity` · `_circle_entity` 와 같은 꼴) ───────── */ + +const LAYER_ID = "기본"; +const BLANK_LAYER = { id: LAYER_ID, name: "기본", isVisible: true, isLocked: false }; +const FONT = 2.0; +const LINE = 3.0; +/** 옛 글 색 = B07 표 글(`TABLE_LABEL_COLOR`) · 미리 넣음 = 그림처럼 붉은 계열. */ +const NOTE_COLOR = "#e8edf4"; +const CULVERT_COLOR = "#ff7f7f"; + +const newId = (): string => crypto.randomUUID(); + +function textEntity( + label: string, + x: number, + y: number, + color: string, + align: "left" | "center" = "left", +): Record { + return { + id: newId(), + type: "Text", + lineColor: color, + lineWidth: 1, + layerId: LAYER_ID, + shapeData: { + label, + basePoint: { x, y }, + options: { + textDirection: { x: 1, y: 0 }, + textAlign: align, + textColor: color, + fontSize: FONT, + fontFamily: "sans-serif", + }, + }, + }; +} + +/** 옛 글 → 줄마다 Text(B07 `cross_note_lines` 와 같은 줄 나눔 · 빈 줄은 자리만). */ +export function legacyNoteEntities(note: string | undefined): Record[] { + const lines = (note ?? "") + .trim() + .split(/\r?\n/) + .map((line) => line.trimEnd()); + if (!lines.some(Boolean)) return []; + return lines.flatMap((line, index) => + line ? [textEntity(line, 0, -LINE * (index + 0.5), NOTE_COLOR)] : [], + ); +} + +/* ── 횡단배수 미리 넣음(주인 측점 · 관만) ──────────────────────────────────── */ + +const one = (value: number | null | undefined): string => + typeof value === "number" && Number.isFinite(value) ? value.toFixed(1) : ""; +const metres = (value: number | null | undefined): string => (one(value) ? `${one(value)}m` : ""); +const trim = (value: number): string => String(Math.round(value * 100) / 100); +/** 돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로. */ +const bondOf = (form: string | null | undefined): string => + /찰/.test(form ?? "") ? "찰" : /메/.test(form ?? "") ? "메" : (form ?? ""); +const BASIN_FORMS: Record = { I: "I형", L: "L형", U: "ㄷ형" }; + +/** 관보호공 한 줄 — 구조를 안 고른 쪽 = 없음. */ +function sideLine(side: CulvertSideSpec | undefined, title: string): string | null { + if (!side?.structure) return null; + const basin = side.structure === "집수정"; + const form = basin ? (BASIN_FORMS[side.basin_form ?? ""] ?? "") : bondOf(side.revet_form); + const height = basin ? side.basin_height_m : side.revet_height_m; + const length = basin ? side.basin_length_m : side.revet_length_m; + const spec = [form, `H=${one(height)}`].filter(Boolean).join(","); + return `관보호공(${title}) ${side.structure}(${spec}) L=${metres(length)}`; +} + +/** 도수로/돌붙임 첫 상자(`chute_add_{n}_*`) — 쪽 칸이 없어 유출부로(사용자가 고침). */ +function chuteLine(options: Record, fallback?: string): string | null { + const picked = Object.keys(options) + .map((key) => /^chute_add_(\d+)_structure$/.exec(key)) + .filter((hit): hit is RegExpExecArray => !!hit && !!String(options[hit[0]] ?? "").trim()) + .sort((a, b) => Number(a[1]) - Number(b[1]))[0]; + const head = picked ? `chute_add_${picked[1]}_` : null; + const structure = head ? String(options[`${head}structure`]).trim() : fallback; + if (!structure) return null; + const bond = head ? bondOf(String(options[`${head}bond`] ?? "")) : ""; + const area = head ? Number(options[`${head}area`]) : NaN; + const spec = [structure, bond].filter(Boolean).join(","); + return `관유출부보강(${spec}) ${area > 0 ? trim(area) : ""}m2`; +} + +/** 측점 이름(B07 `station_no_label` 꼴 · 대문자 NO · 거리는 정수 = 화면 표기). */ +export function stationNoLabel(chainageM: number, intervalM: number): string { + const safe = intervalM > 0 ? intervalM : 1; + let number = Math.floor((chainageM + 1e-6) / safe); + let rest = chainageM - number * safe; + if (rest >= safe - 0.05) { + number += 1; + rest = 0; + } + return Math.abs(rest) < 0.05 ? `NO.${number}` : `NO.${number}+${Math.round(rest)}`; +} + +function shape(type: string, shapeData: Record): Record { + return { + id: newId(), + type, + lineColor: CULVERT_COLOR, + lineWidth: 1, + layerId: LAYER_ID, + shapeData, + }; +} + +/** + * 횡단배수 미리 넣음 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관 + * (독립 기슭막이) · 이웃 측점 = 빈 목록. `options` = 그 관의 `pipe_points` 옵션(돌붙임 형태 · 면적). + * 왼쪽 원 안 세 줄(배수관 / Φ관경 / L=관 길이 · 없으면 빈칸) · 오른쪽 = 측점 · 관보호공(유입 · 유출) · + * 관유출부보강. + */ +export function culvertNoteEntities( + section: CrossSection, + station: string, + options: Record = {}, +): Record[] { + const culvert = section.culvert; + if (!culvert || culvert.type !== "pipe" || culvert.body || culvert.hidden_pipe) return []; + if (Math.abs((culvert.chainage_m ?? section.chainage_m) - section.chainage_m) >= PIPE_MATCH_M) + return []; + const [cx, cy, radius] = [6.5, -9.0, 6.5]; + const inside = [ + "배수관", + `Φ${Math.round(culvert.diameter_m * 1000)}m/m`, + `L=${metres(section.design?.pipe_length_m)}`, + ]; + const right = [ + station, + sideLine(culvert.inlet, "유입"), + sideLine(culvert.outlet, "유출"), + chuteLine(options, culvert.chute?.structure), + ].filter((line): line is string => !!line); + return [ + shape("Circle", { center: { x: cx, y: cy }, radius }), + shape("Line", { + startPoint: { x: cx, y: cy - radius }, + endPoint: { x: cx, y: cy - radius - LINE }, + }), + ...inside.map((line, index) => + textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"), + ), + ...right.map((line, index) => textEntity(line, 15, -LINE * (index + 0.5), CULVERT_COLOR)), + ]; +} + +/** 관 옵션 — 세션 초안(관 목록 주인 = 설계 가게) → 없으면 정본 `pipe_points.json`. */ +async function pipeOptionsAt( + projectId: string, + chainageM: number, +): Promise> { + const pipes = readPendingPipes(projectId) ?? (await fetchDetailPipePoints(projectId)).pipe_points; + const pipe = pipes.find((point) => Math.abs(point.chainage_m - chainageM) < PIPE_MATCH_M); + return pipe?.options ?? {}; +} + +/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 횡단배수 미리 넣음 → 빈 도면. 옮기거나 넣었으면 초안에 씀. */ +async function firstNoteCad(section: CrossSection, at: NoteCadPlace): Promise { + const stored = noteCadOf(section, at); + if (stored) return stored; + let entities = legacyNoteEntities(section.design?.structure_note); + if (!entities.length && section.culvert) { + const owner = section.culvert.chainage_m ?? section.chainage_m; + const options = await pipeOptionsAt(at.projectId, owner).catch(() => ({})); + entities = culvertNoteEntities(section, at.station, options); + } + const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] }; + if (entities.length) writeNoteCad(section, cad, at); + return cad; +} + +/* ── 모달 칸(빈 CAD · 바탕만) ───────────────────────────────────────────── */ + +export interface NoteCadPlace { + projectId: string; + routeId: number; + /** 미리 넣음 첫 줄(`stationNoLabel`). */ + station: string; +} + +/** + * [상세] 모달 가운데 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 · + * 모달을 닫으면 `destroy`. 칸 크기가 바뀌어도 다시 만들지 않음(iframe 이 스스로 맞춤). + */ +export function mountNoteCad( + box: HTMLElement, + first: CrossSection, + section: () => CrossSection, + at: NoteCadPlace, +): { destroy: () => void } { + let closed = false; + const cad = createCadHost({ + title: "표 위 CAD", + onChanged: (dirty) => { + if (!dirty || closed) return; + cad + .requestSave() + .then(({ drawing }) => { + if (!closed) + writeNoteCad(section(), { entities: drawing.entities, layers: drawing.layers }, at); + }) + .catch(() => undefined); + }, + }); + box.append(cad.element); + cad.beginLoading(); + const scope = `b06-detail-note:${at.projectId}:${at.routeId}:${noteKey(first.chainage_m)}`; + void firstNoteCad(first, at).then((drawing) => { + if (closed) return; + const layers = drawing.layers?.length ? drawing.layers : [BLANK_LAYER]; + cad.load( + { entities: drawing.entities, layers }, + null, + false, + {}, + { + recoveryScope: scope, + hostTitle: "표 위 CAD", + bare: true, + }, + ); + }); + return { + destroy: () => { + closed = true; + cad.destroy(); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts index f137535dc..3f15d056d 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts @@ -44,6 +44,16 @@ export function groundRuns(samples: ReadonlyArray | undefined): P const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) => (raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite)); +/** 암 경계선 — 원지반선 + 오프셋(암 프리셋만 · 카드 `appendRockBoundaryOverlay` · 서버 `rock_runs`). */ +export function rockRuns( + design: CrossDesign | undefined, + samples: ReadonlyArray | undefined, +): Pt[][] { + const offset = design?.rock_boundary_offset_m; + if (design?.geometry_preset !== "rock" || typeof offset !== "number") return []; + return groundRuns(samples).map((run) => run.map(([x, y]): Pt => [x, y + offset])); +} + /** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */ export function pickLines( design: CrossDesign | undefined, @@ -53,6 +63,7 @@ export function pickLines( design: [polyline(design?.design_line)], ground: groundRuns(samples), spoil: [polyline(design?.spoil_fill_line)], + rock: rockRuns(design, samples), }; } diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index 2a5aca824..b3d8442c4 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B06_Section_UI_Cross_Detail_Select.ts - * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로 + * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로 * 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 · * Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴): * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」). @@ -79,6 +79,8 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli else if (el.classList.contains("b06-chart__cross-profile")) others.push({ line: "ground", points }); else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points }); + else if (el.classList.contains("b06-chart__rock-boundary")) + others.push({ line: "rock", points }); } const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({ line: piece.structure ? "structure" : "design", @@ -102,6 +104,7 @@ const LINE_NAME: Record = { structure: "구조물 선", ground: "원지반선", spoil: "사토장선", + rock: "암 경계선", }; /** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */ @@ -111,7 +114,11 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie const [piece] = pieces; const from = piece.points[0]; const to = piece.points[piece.points.length - 1]; - if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil") + if ( + design && + piece.points.length === 2 && + (piece.line === "design" || piece.line === "structure") + ) return pieceLabel(design, { from: { offset: from[0], elevation: from[1] }, to: { offset: to[0], elevation: to[1] }, diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 0d99b5e12..db7fb66a3 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -35,6 +35,7 @@ import { readShoulderExtends, } from "./B06_Section_Cross_Design_Session"; import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; +import { readStructureNoteCads } from "./B06_Section_UI_Cross_Detail_Cad"; import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; @@ -413,6 +414,11 @@ export function collectSectionEdits(ctx: SectionPersistContext): { readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => { patchFor(chainage).structure_note = note; }); + // 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 세션 초안 그대로(빈 엔티티 = 비움) + if (ctx.projectId && routeId !== null) + readStructureNoteCads(ctx.projectId, routeId).forEach((cad, chainage) => { + patchFor(chainage).structure_note_cad = cad; + }); // [상세] 표 손질(179-4) — 세션 초안 그대로 · 값은 서버가 그릴 때 다시 셈 if (ctx.projectId && routeId !== null) readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py index d522ab3f9..3cc85c50f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py @@ -181,12 +181,21 @@ def ring_area(paths: list[list[Point]]) -> float | None: return area if area > 1e-9 else None +def rock_runs(design: dict[str, Any], samples: Any = None) -> list[list[Point]]: + """암 경계선 — 원지반선 + `rock_boundary_offset_m`(암 프리셋만 · 카드 그리기와 같은 규칙).""" + offset = _num(design.get("rock_boundary_offset_m")) + if design.get("geometry_preset") != "rock" or offset is None: + return [] + return [[(x, y + offset) for x, y in run] for run in ground_runs(samples)] + + def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]: - """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선).""" + """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선 · 암 경계선).""" return { "design": [_design_line(design)], "ground": ground_runs(samples), "spoil": [_polyline(design.get("spoil_fill_line"))], + "rock": rock_runs(design, samples), } diff --git a/B08_Quantity/B08_Quantity_Haul.py b/B08_Quantity/B08_Quantity_Haul.py index 93a01af50..5a628cb43 100644 --- a/B08_Quantity/B08_Quantity_Haul.py +++ b/B08_Quantity/B08_Quantity_Haul.py @@ -118,7 +118,7 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict "id": f"h-{g['key']}"[:40], "name": "", "address": "", - "via_base": True, + "to": "site", "linked": False, "dir": None, "haul": g["key"], @@ -127,3 +127,37 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict } ) return {**data, "routes": routes} + + +def route_bill_key(route: dict[str, Any], index: int = 0) -> str: + """거리이정표 행 → B09 운반공 줄 키(253-1) — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}` · + 한 상자 둘째 행부터 `haul:r-{id}.{n}`(경로 id 에 「.」 없음 — 안 겹침).""" + if route.get("haul"): + return bill_key(str(route["haul"])) + return f"haul:r-{route.get('id')}" + (f".{index}" if index else "") + + +def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[str, Any]]: + """거리이정표 표 행 하나 = 운반공 줄 하나(253-1) — [{key, name, ref, user, L1, L2}] · + L1 = 포장 계(2차 + 1차 + 포장) · L2 = 비포장(`compute_milestone` 결과) · + user = 사용자 상자(자동 상자는 총괄자재 묶음 줄이 이미 있음 · 둘째 행은 안 씀).""" + routes = {r.get("id"): r for r in data.get("routes") or []} + out, seen = [], set() + for row in table: + route = routes.get(row.get("route")) or {} + key = route_bill_key(route, int(row.get("index") or 0)) + if key in seen: + continue + seen.add(key) + item = str(row.get("item") or "").strip() + out.append( + { + "key": key, + "name": f"{item}운반" if item else "운반", + "ref": route.get("ref"), + "user": not route.get("haul"), + "L1": row.get("paved"), + "L2": row.get("unpaved"), + } + ) + return out diff --git a/B08_Quantity/B08_Quantity_Haul_Cycle.py b/B08_Quantity/B08_Quantity_Haul_Cycle.py new file mode 100644 index 000000000..02557fc77 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul_Cycle.py @@ -0,0 +1,102 @@ +"""B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력. + +⚠ 짝 = `B08_Quantity_Haul_Cycle.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_253_2_haul_cycle.py`. + 조작 중은 TS 짝(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 이 파일로 다시 채움(정본). + +식(거창 25 설계서 단가산출 1308~1347행 꼴 · 끝수 = 소수 둘째 자리 반올림) + q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) + t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + t2 + t3 + t4 + t5 + t6 + L1 = 거리이정표 그 행 포장 계(2차 + 1차 + 포장) · L2 = 비포장 · 속도 0 = 그 항 0. +로직 입력 채움(이름으로) — `Cm` · `사이클시간` = Cm · `t2` = t2 · `운반거리` = L1 + L2 · + `own` = 사용자가 덮은 칸(L1 · L2 · t1 · 로직 입력 이름) — 그 값이 이김. +기본값 = 거창 설계서(혼합골재 덤프 15톤) · 속도 · t 표 = master_data CC000054~058(화면이 고름). +""" + +from __future__ import annotations + +import math +from typing import Any + +DEFAULTS: dict[str, float] = { + "L1": 0.0, + "L2": 0.0, + "V1": 30.0, + "V2": 35.0, + "V3": 10.0, + "V4": 15.0, + "T": 15.0, + "rt": 1.7, + "f": 1.0, + "qs": 1.72, + "k": 1.0, + "Es": 0.6, + "Cms": 37.4, + "t3": 0.8, + "t4": 0.42, + "t5": 0.5, + "t6": 1.5, +} +#: 로직 입력 이름 → 셈 결과 칸 +FILL = {"Cm": "cm", "사이클시간": "cm", "t2": "t2", "운반거리": "L"} + + +def r2(value: float) -> float: + """소수 둘째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음).""" + return math.floor(value * 100 + 0.5) / 100 + + +def _div(a: float, b: float) -> float: + return a / b if b else 0.0 + + +def _num(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) if math.isfinite(value) else None + + +def cycle_time(inputs: dict[str, Any] | None) -> dict[str, float]: + """사이클 입력(빈 칸 = 기본값 · `t1` 있으면 그 값) → {q, n, t1, t2, cm, L}.""" + given = {k: _num(v) for k, v in (inputs or {}).items()} + v = {k: given[k] if given.get(k) is not None else d for k, d in DEFAULTS.items()} + q = r2(_div(v["T"], v["rt"]) * v["f"]) + n = r2(_div(q, v["qs"] * v["k"])) + t1 = given.get("t1") + t1 = r2(_div(v["Cms"] * n, 60 * v["Es"])) if t1 is None else t1 + l1, l2 = v["L1"], v["L2"] + t2 = r2((_div(l1, v["V1"]) + _div(l1, v["V2"]) + _div(l2, v["V3"]) + _div(l2, v["V4"])) * 60) + cm = r2(t1 + t2 + v["t3"] + v["t4"] + v["t5"] + v["t6"]) + return { + "q": q, + "n": n, + "t1": t1, + "t2": t2, + "cm": cm, + "L": math.floor((l1 + l2) * 1000 + 0.5) / 1000, + } + + +def cycle_inputs(ref: dict[str, Any] | None, dist: dict[str, Any] | None) -> dict[str, Any]: + """연결 `ref.cycle` + 거리이정표 거리(`dist{L1, L2}`) — 덮은 L1 · L2(`ref.own`)는 그대로.""" + cycle = dict((ref or {}).get("cycle") or {}) + own = set((ref or {}).get("own") or []) + for name in ("L1", "L2"): + if name not in own and _num((dist or {}).get(name)) is not None: + cycle[name] = dist[name] + if "t1" not in own: + cycle.pop("t1", None) + return cycle + + +def fill_given( + ref: dict[str, Any], names: list[str], dist: dict[str, Any] | None +) -> dict[str, Any]: + """로직 입력값 다시 채움 — `names` = 그 로직 입력 이름 · 덮은 칸(`ref.own`)은 그대로.""" + own = set(ref.get("own") or []) + got = cycle_time(cycle_inputs(ref, dist)) + given = dict(ref.get("given") or {}) + for name, slot in FILL.items(): + if name in names and name not in own: + given[name] = got[slot] + return given diff --git a/B08_Quantity/B08_Quantity_Haul_Cycle.ts b/B08_Quantity/B08_Quantity_Haul_Cycle.ts new file mode 100644 index 000000000..7d64079d4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Haul_Cycle.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B08_Quantity_Haul_Cycle.ts + * B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력. + * + * ⚠ 짝 = `B08_Quantity_Haul_Cycle.py`(같은 식) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_253_2_haul_cycle.py`. + * 조작 중은 이 파일(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 짝으로 다시 채움(정본). + * + * 식 = q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) · + * t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + … + t6 · 끝수 = 소수 둘째 자리 · + * 속도 0 = 그 항 0 · 로직 입력 채움 = Cm · 사이클시간 = Cm · t2 · 운반거리 = L1 + L2 · + * `own` = 사용자가 덮은 칸(그 값이 이김). + * ========================================================================== */ + +export const CYCLE_DEFAULTS: Record = { + L1: 0, + L2: 0, + V1: 30, + V2: 35, + V3: 10, + V4: 15, + T: 15, + rt: 1.7, + f: 1, + qs: 1.72, + k: 1, + Es: 0.6, + Cms: 37.4, + t3: 0.8, + t4: 0.42, + t5: 0.5, + t6: 1.5, +}; +/** 로직 입력 이름 → 셈 결과 칸 */ +export const CYCLE_FILL: Record = { + Cm: "cm", + 사이클시간: "cm", + t2: "t2", + 운반거리: "L", +}; + +export interface CycleResult { + q: number; + n: number; + t1: number; + t2: number; + cm: number; + L: number; +} +export interface CycleRef { + given?: Record | null; + cycle?: Record | null; + own?: string[] | null; +} +export type Dist = { L1?: number | null; L2?: number | null } | null | undefined; + +/** 소수 둘째 자리 반올림(0.5 올림 · 파이썬 짝 `r2`) */ +export const r2 = (value: number): number => Math.floor(value * 100 + 0.5) / 100; +const div = (a: number, b: number): number => (b ? a / b : 0); +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 사이클 입력(빈 칸 = 기본값 · t1 있으면 그 값) → {q, n, t1, t2, cm, L} */ +export function cycleTime(inputs: Record | null | undefined): CycleResult { + const v: Record = {}; + for (const [k, d] of Object.entries(CYCLE_DEFAULTS)) v[k] = num(inputs?.[k]) ?? d; + const q = r2(div(v.T, v.rt) * v.f); + const n = r2(div(q, v.qs * v.k)); + const t1 = num(inputs?.t1) ?? r2(div(v.Cms * n, 60 * v.Es)); + const t2 = r2((div(v.L1, v.V1) + div(v.L1, v.V2) + div(v.L2, v.V3) + div(v.L2, v.V4)) * 60); + const cm = r2(t1 + t2 + v.t3 + v.t4 + v.t5 + v.t6); + return { q, n, t1, t2, cm, L: Math.floor((v.L1 + v.L2) * 1000 + 0.5) / 1000 }; +} + +/** 연결 `ref.cycle` + 거리이정표 거리 — 덮은 L1 · L2(`ref.own`)는 그대로 */ +export function cycleInputs(ref: CycleRef | null | undefined, dist: Dist): Record { + const cycle: Record = { ...(ref?.cycle || {}) }; + const own = new Set(ref?.own || []); + for (const name of ["L1", "L2"] as const) + if (!own.has(name) && num(dist?.[name]) !== null) cycle[name] = dist?.[name]; + if (!own.has("t1")) delete cycle.t1; + return cycle; +} + +/** 로직 입력값 다시 채움 — names = 그 로직 입력 이름 · 덮은 칸은 그대로 */ +export function fillGiven(ref: CycleRef, names: string[], dist: Dist): Record { + const own = new Set(ref.own || []); + const got = cycleTime(cycleInputs(ref, dist)); + const given: Record = { ...(ref.given || {}) }; + for (const [name, slot] of Object.entries(CYCLE_FILL)) + if (names.includes(name) && !own.has(name)) given[name] = got[slot]; + return given; +} diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index 006196a3d..2f2195984 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -4,14 +4,16 @@ 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. 조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈. -표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 +표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 · +`base_chain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면 시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · 포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · 끝수 = 소수 셋째 자리(반올림). 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7). -도로 구분(152) = 포장 종류는 선 자리로 정함(`road_of`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = -1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 · -포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리). +도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`road_of`) — 시점에 닿는 기준점 구간 = 1차포장 · +그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 +구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리). +옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrate_milestone` 로 새 꼴(`bases[]` · `to`). """ from __future__ import annotations @@ -21,8 +23,10 @@ from typing import Any ROADS = ("2차포장", "1차포장", "포장", "비포장") KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"} -# 구간 자리 → 포장 종류(152) — via = 거래처 → 기준점 · base = 기준점 → 시점 · direct = 거래처 → 시점 · mid = 시점 → 중점 +# 구간 자리 → 포장 종류(152 · 254) — via = → 기준점 · base = 기준점 → 시점 · +# direct = 거래처 → 시점 · mid = 시점 → 중점 PAVED = {"via": "2차포장", "base": "1차포장", "direct": "포장", "mid": "포장"} +SITE = "site" # 이음 대상 = 시점(그 밖 = 기준점 id) def road_of(road: str | None, kind: str) -> str: @@ -55,21 +59,64 @@ def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: return float(station or 0) / 1000 +def base_chain(data: dict[str, Any], to: str) -> list[dict[str, Any]]: + """기준점 사슬(254) — to 부터 이은 기준점을 따라 시점까지 · 방문 집합(돌기 끊음) · + 안 이은 · 없는 기준점에서 멈춤.""" + by_id = {b.get("id"): b for b in data.get("bases") or []} + out: list[dict[str, Any]] = [] + seen: set[str] = set() + while to != SITE and to not in seen: + seen.add(to) + base = by_id.get(to) + if base is None or base.get("linked", True) is False: + break + out.append(base) + to = base.get("to") or SITE + return out + + +def migrate_milestone(data: dict[str, Any]) -> dict[str, Any]: + """옛 꼴 → 새 꼴(254) — 옛 `base`(켜짐) = bases[0] id "b1" · + 경로 `via_base` = to "b1" | "site" · 새 꼴은 그대로(사본).""" + rest = {k: v for k, v in data.items() if k != "base"} + old = data.get("base") or {} + fresh = isinstance(rest.get("bases"), list) + on = not fresh and bool(old.get("enabled")) + if fresh: + bases = rest["bases"] + elif on: + keep = ("name", "address", "dir", "lon", "lat", "pos", "manual", "legs") + bases = [{**{k: old[k] for k in keep if k in old}, "id": "b1", "to": SITE, "linked": True}] + else: + bases = [] + routes = [] + for route in rest.get("routes") or []: + if not isinstance(route.get("to"), str): + via = on and route.get("via_base", True) is not False + route = {k: v for k, v in route.items() if k != "via_base"} + route["to"] = "b1" if via else SITE + routes.append(route) + return {**rest, "bases": bases, "routes": routes} + + +def _kind(owner: dict[str, Any], to_site: str, to_base: str) -> str: + return to_site if (owner.get("to") or SITE) == SITE else to_base + + def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list[dict[str, Any]]: """표 줄 — 경로 차례 · 경로 안 줄 차례.""" + data = migrate_milestone(data) site = data.get("site") or {} - base = data.get("base") or {} mid = {"road": site.get("mid_road") or "비포장", "km": mid_km(data, mid_default_m)} out: list[dict[str, Any]] = [] for route in data.get("routes") or []: for index, row in enumerate(route.get("rows") or []): sums = {"s2": 0.0, "s1": 0.0, "pv": 0.0, "un": 0.0} - linked = route.get("linked", True) is not False # 안 이은 거래처 = 구간 km 없음(155) - via = linked and bool(base.get("enabled")) and route.get("via_base", True) is not False - if linked: - add_legs(sums, route.get("legs"), "via" if via else "direct") - if via: - add_legs(sums, base.get("legs"), "base") + # 안 이은 거래처 = 구간 km 없음(155) · 이음 = 거래처 구간 + 기준점 사슬(254) + if route.get("linked", True) is not False: + add_legs(sums, route.get("legs"), _kind(route, "direct", "via")) + for base in base_chain(data, route.get("to") or SITE): + add_legs(sums, base.get("legs"), _kind(base, "base", "via")) if row.get("dest") == "mid": add_legs(sums, [mid], "mid") paved = sums["s2"] + sums["s1"] + sums["pv"] diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 5c1bac853..ece7882e5 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -6,20 +6,23 @@ * 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. * 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈. * - * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 + * 표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 · + * `baseChain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면 * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · * 끝수 = 소수 셋째 자리(반올림) * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) - * 도로 구분(152) = 포장 종류는 선 자리로 정함(`roadOf`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = - * 1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 · - * 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리) + * 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`roadOf`) — 시점에 닿는 기준점 구간 = 1차포장 · + * 그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 + * 구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리) + * 옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrateMilestone` 로 새 꼴(`bases[]` · `to`) * ========================================================================== */ export const ROADS = ["2차포장", "1차포장", "포장", "비포장"] as const; export type Road = (typeof ROADS)[number]; -/** 구간 자리(152) — 거래처 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · 시점 → 중점(mid) */ +/** 구간 자리(152 · 254) — 거래처 · 기준점 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · + * 시점 → 중점(mid) */ export type SegKind = "via" | "base" | "direct" | "mid"; -/** 포장 종류 = 선 자리로 정함(152) — 기준점이 생기면 거래처 → 기준점 = 2차 · 기준점 → 시점 = 1차 · 없으면 포장 */ +/** 포장 종류 = 선 자리로 정함(152) — → 기준점 = 2차 · 기준점 → 시점 = 1차 · 거래처 → 시점 = 포장 */ export const PAVED: Record = { via: "2차포장", base: "1차포장", @@ -36,20 +39,81 @@ export function withRoads(data: MilestoneData): MilestoneData { return { ...data, site: { ...data.site, mid_road: roadOf(data.site.mid_road, "mid") }, - base: { ...data.base, legs: fix(data.base.legs, "base") }, - routes: data.routes.map((r) => ({ - ...r, - legs: fix(r.legs, data.base.enabled && r.via_base !== false ? "via" : "direct"), - })), + bases: data.bases.map((b) => ({ ...b, legs: fix(b.legs, baseKind(b)) })), + routes: data.routes.map((r) => ({ ...r, legs: fix(r.legs, routeKind(r)) })), }; } -/** 선 자리 — from = 거래처 id · "base" · "start"(시점 → 중점) */ +/** 이음 대상 = 시점(254 · 그 밖 = 기준점 id) */ +export const SITE_ID = "site"; +const baseKind = (b: { to: string }): SegKind => (b.to === SITE_ID ? "base" : "via"); +const routeKind = (r: { to: string }): SegKind => (r.to === SITE_ID ? "direct" : "via"); + +/** 선 자리 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */ export function segKind(data: MilestoneData, from: string): SegKind { if (from === "start") return "mid"; - if (from === "base") return "base"; + const base = data.bases.find((b) => b.id === from); + if (base) return baseKind(base); const route = data.routes.find((r) => r.id === from); - return data.base.enabled && route?.via_base !== false ? "via" : "direct"; + return route ? routeKind(route) : "direct"; +} + +/** 기준점 사슬(254) — to 부터 이은 기준점을 따라 시점까지 · 방문 집합(돌기 끊음) · 안 이은 · 없는 기준점에서 멈춤 */ +export function baseChain(data: MilestoneData, to: string): MilestoneBase[] { + const out: MilestoneBase[] = []; + const seen = new Set(); + let id = to; + while (id !== SITE_ID && !seen.has(id)) { + seen.add(id); + const base = data.bases.find((b) => b.id === id); + if (!base || base.linked === false) break; + out.push(base); + id = base.to; + } + return out; +} + +type OldBase = Partial & { enabled?: boolean }; +type OldData = Partial> & { + base?: OldBase | null; + routes?: Array & { via_base?: boolean }>; +}; + +/** 옛 꼴 → 새 꼴(254) — 옛 `base`(켜짐) = bases[0] id "b1" · 경로 `via_base` = to "b1" | "site" · + * 새 꼴은 그대로(사본) */ +export function migrateMilestone(data: OldData | MilestoneData): MilestoneData { + const { base: old, ...rest } = data as OldData; + const fresh = Array.isArray(rest.bases); + const on = !fresh && !!old?.enabled; + const bases: MilestoneBase[] = fresh + ? rest.bases! + : on + ? [ + { + id: "b1", + name: old!.name ?? "", + address: old!.address ?? "", + dir: old!.dir ?? null, + lon: old!.lon ?? null, + lat: old!.lat ?? null, + pos: old!.pos ?? null, + manual: !!old!.manual, + legs: old!.legs ?? [], + to: SITE_ID, + linked: true, + }, + ] + : []; + const routes = (rest.routes ?? []).map((r) => { + if (typeof r.to === "string") return r as MilestoneRoute; + const { via_base, ...keep } = r; + return { ...keep, to: on && via_base !== false ? "b1" : SITE_ID } as MilestoneRoute; + }); + return { + site: rest.site ?? { mid_station_m: null, mid_road: "비포장" }, + bases, + routes, + }; } /** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; @@ -76,8 +140,8 @@ export interface MilestoneRoute { /** 업체 이름 = 하위 탭 이름 */ name: string; address: string; - /** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */ - via_base: boolean; + /** 잇는 곳(254) — "site"(시점으로 바로) · 기준점 id */ + to: string; dir: Dir | null; lon?: number | null; lat?: number | null; @@ -91,25 +155,37 @@ export interface MilestoneRoute { haul?: string | null; /** 단가연결(251 · dg · sg · logic 꼴 · 정본) */ ref?: Record | null; - /** 업체 → 기준점(거침) · 시점 구간 */ + /** 업체 → to(기준점 · 시점) 구간 */ legs: Leg[]; rows: MilestoneRow[]; } +/** 기준점(254 · 여럿) — 거래처가 거쳐 가는 자리 · to = 시점 또는 다른 기준점 */ +export interface MilestoneBase { + id: string; + name: string; + address: string; + dir: Dir | null; + lon?: number | null; + lat?: number | null; + pos?: [number, number] | null; + manual?: boolean; + /** 이 기준점 → to 구간 */ + legs: Leg[]; + /** 잇는 곳 — "site"(시점) · 다른 기준점 id */ + to: string; + /** false = 아직 선 안 이음(요소 추가 뒤) · 없음 = 이음 */ + linked?: boolean; +} + export interface MilestoneData { - site: { mid_station_m: number | null; mid_road: Road }; - base: { - enabled: boolean; - name: string; - address: string; - dir: Dir | null; - lon?: number | null; - lat?: number | null; - pos?: [number, number] | null; - manual?: boolean; - /** 기준점 → 시점 공통 구간 */ - legs: Leg[]; + site: { + mid_station_m: number | null; + mid_road: Road; + /** 길찾기용 시점 덮개(255-2 · 경로 탭 시점 끌기 · 노선은 그대로) — 없음 = 노선 첫 점 */ + start_pin?: { lon: number; lat: number } | null; }; + bases: MilestoneBase[]; routes: MilestoneRoute[]; } @@ -153,19 +229,20 @@ export function midKm(data: MilestoneData, midDefaultM: number | null): number { /** 표 줄 — 경로 차례 · 경로 안 줄 차례 */ export function computeMilestone( - data: MilestoneData, + raw: MilestoneData, midDefaultM: number | null, ): MilestoneTableRow[] { - const base = data.base; + const data = migrateMilestone(raw); const mid: Leg = { road: data.site?.mid_road ?? "비포장", km: midKm(data, midDefaultM) }; const out: MilestoneTableRow[] = []; for (const route of data.routes ?? []) { (route.rows ?? []).forEach((row, index) => { const sums = { s2: 0, s1: 0, pv: 0, un: 0 }; - const linked = route.linked !== false; // 안 이은 거래처 = 구간 km 없음(155) - const via = linked && !!base?.enabled && route.via_base !== false; - if (linked) addLegs(sums, route.legs, via ? "via" : "direct"); - if (via) addLegs(sums, base.legs, "base"); + if (route.linked !== false) { + // 안 이은 거래처 = 구간 km 없음(155) · 이음 = 거래처 구간 + 기준점 사슬(254) + addLegs(sums, route.legs, routeKind(route)); + for (const b of baseChain(data, route.to)) addLegs(sums, b.legs, baseKind(b)); + } if (row.dest === "mid") addLegs(sums, [mid], "mid"); const paved = sums.s2 + sums.s1 + sums.pv; out.push({ diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py index 7a3b50975..0ae895ca1 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Capture.py +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -11,6 +11,7 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · v 캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 · 키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가 드라이버 하위 프로세스를 못 만듦(249-1). +장소 그림(256-1) = 같은 길 · 작은 지도(480×320 · 표지 하나 · 주소 둘레 줌) → `place/<상자 id>.png`. """ from __future__ import annotations @@ -37,6 +38,7 @@ NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 TAIL = " — [그림 변경]으로 넣으세요." +PLACE_VIEW = {"w": 480, "h": 320, "level": 4} # 장소 그림(256-1) — 표지 가운데 · 고정 줌 _lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초) @@ -45,17 +47,22 @@ class CaptureError(Exception): """까닭 한 줄(키 · 예외 원문 없음).""" -def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str: - """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.""" +def page_html( + key: str, lines: list[Any], marks: list[dict[str, Any]], view: dict[str, int] | None = None +) -> str: + """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail. + view = 크기 · 줌 고정(장소 그림 · 첫 표지 가운데 · 경로 맞춤 안 함).""" + w, h = (view["w"], view["h"]) if view else (WIDTH, HEIGHT) + level = view["level"] if view else 7 data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c") sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false" return f""" -
+
""" @@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]: """자식 프로세스 몸통 — 그림 찍어 `/.png` 저장 → {saved, reason}.""" key = os.environ.get(KAKAO_JS, "").strip() try: - png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"]) + png = shoot(page_html(key, job["lines"], job["marks"], job.get("view")), job["origin"]) except CaptureError as error: return {"saved": False, "reason": str(error)} out_dir = Path(job["out"]) @@ -160,25 +169,36 @@ def spawn(job: dict[str, Any]) -> dict[str, Any]: def make_route_image( - out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]] + out_dir: Path, + route_id: str, + lines: list[Any], + marks: list[dict[str, Any]], + view: dict[str, int] | None = None, + what: str = "경로 그림", + tail: str = TAIL, ) -> dict[str, Any]: """그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림.""" key = os.environ.get(KAKAO_JS, "").strip() if not key: return { "saved": False, - "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}", + "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){tail}", } if not any(lines) and not marks: - return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"} + return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{tail}"} origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/") - job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks} + job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks, "view": view} with _lock: got = spawn({**job, "origin": origin}) if not got.get("saved"): - return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"} + return {"saved": False, "reason": f"{what} 자동 캡처 실패 — {got.get('reason')}{tail}"} return {"saved": True, "reason": None} +def make_place_image(out_dir: Path, place_id: str, mark: dict[str, Any]) -> dict[str, Any]: + """장소 그림(256-1) — 작은 지도 · 표지 하나 → `/<상자 id>.png`.""" + return make_route_image(out_dir, place_id, [], [mark], PLACE_VIEW, "장소 그림", "") + + if __name__ == "__main__": print(json.dumps(capture(json.loads(sys.stdin.buffer.read())))) diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index d3f7016da..2c99a0062 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -6,8 +6,12 @@ * 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면 * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · * 같은 방위 여럿 = 옆으로 나란히 - * 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5) - * 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 + * 기준점 자리 = 기준점 주소 좌표 → 잇는 곳(시점 · 다른 기준점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — + * 옛 8방 무시(244-5) · 여럿(254) = 잇는 곳이 먼저 놓이게 사슬 차례 + * 선 = 거래처 → to(기준점 · 시점) · 기준점 → to · 시점 → 중점 — 글 = 구간 줄마다 두 줄(1줄 도로 구분 · + * 2줄 km · 254 · `legText` 가 줄바꿈 글자로 나눔) + * 처음 자리 선 길이(254) = 그 구간 km 비례(`lineLen` · 로그 · 최소 · 최대) — 잇는 곳 테두리 → 상자 테두리 + * 상자 높이 = 내용(254 · 항목 · 이름 · 주소 줄바꿈 `wrap` 최대 3줄 · 좌표) · 기준점 상자도 주소 * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 @@ -18,7 +22,7 @@ * 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표 * ========================================================================== */ -import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; +import type { Dir, Leg, MilestoneBase, MilestoneData } from "./B08_Quantity_Milestone_Calc"; export interface DiagramBox { id: string; @@ -77,11 +81,9 @@ const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"]; const SITE = { w: 380, h: 106 }; const NODE = { w: 124, h: 58 }; const BOX = { w: 148, h: 58 }; -const R_BASE = 390; -const R_ROUTE = 280; const PAD = 48; -/** 바탕 반폭 · 반높이 — 기준점 · 공사지 둘레 거래처(같은 거리)가 어느 방위로 와도 담김 */ -const REACH = R_BASE; +/** 바탕 반폭 · 반높이 — 공사지 둘레 상자(짧은 구간 · 선 길이 최소쯤)가 어느 방위로 와도 담김 */ +const REACH = 390; export const BASE_VIEW: [number, number, number, number] = [ -(REACH + BOX.w / 2 + PAD), -(REACH + BOX.h / 2 + PAD), @@ -93,9 +95,17 @@ export const BASE_VIEW: [number, number, number, number] = [ export const GAP = 12; const LINE_GAP = 4; const INSET = 8; -/** 글 크기(px) — 화면 CSS 와 같음(이름 13 · 항목 · 선 글 12 · 작은 글씨 10) */ -const FONT = { label: 13, items: 12, km: 12, note: 10 }; +/** 글 크기(px) — 화면 CSS 와 같음(이름 · 항목 13(254 · 거래처 글과 같음) · 선 글 12 · 작은 글씨 10) */ +const FONT = { label: 13, items: 13, km: 12, note: 10 }; const KM_LINE = 16; +/** 상자 안 글 줄 간격 · 위아래 여백(254 상자 높이 = 줄 × ROW + ROW_PAD · 최소 BOX.h) */ +export const ROW = 15; +const ROW_PAD = 13; +const NOTE_LINES = 3; + +/** 선 길이(254) — 구간 km 비례(로그) · 최소 · 최대(너무 멀어지지 않게) · 잇는 곳 테두리 → 상자 테두리 */ +export const lineLen = (km: number): number => + Math.min(320, Math.max(100, 100 + 90 * Math.log10(1 + Math.max(0, km)))); export interface Rect { x: number; @@ -123,6 +133,36 @@ export function clip(text: string, maxW: number, size: number): string { return `${out}…`; } +/** 여러 줄(254) — 폭 넘으면 낱말(빈칸) 사이에서 · 긴 낱말은 글자에서 줄바꿈 · 최대 줄 넘으면 끝 줄 줄임표 */ +export function wrap(text: string, maxW: number, size: number, maxLines = NOTE_LINES): string[] { + const out: string[] = []; + let line = ""; + for (const word of text.trim().split(/\s+/).filter(Boolean)) { + const next = line ? `${line} ${word}` : word; + if (textW(next, size) <= maxW) { + line = next; + continue; + } + if (line) out.push(line); + line = word; + while (textW(line, size) > maxW) { + let cut = ""; + for (const ch of line) { + if (textW(cut + ch, size) > maxW) break; + cut += ch; + } + out.push(cut); + line = line.slice(cut.length); + } + } + if (line) out.push(line); + if (out.length <= maxLines) return out; + return [...out.slice(0, maxLines - 1), clip(out.slice(maxLines - 1).join(" "), maxW, size)]; +} + +/** 상자 높이(254) = 글 줄(항목 · 이름 · 작은 글씨) · 최소 BOX.h */ +const boxH = (rows: number): number => Math.max(BOX.h, rows * ROW + ROW_PAD); + /** 상자 작은 글씨 — 주소(줄임) · 좌표(위도, 경도 소수 5자리) */ const notesOf = (w: number, address: string | null | undefined, lonlat?: LonLat): string[] => [ ...(address ? [clip(address, w - 2 * INSET, FONT.note)] : []), @@ -162,7 +202,7 @@ export const segHits = (x1: number, y1: number, x2: number, y2: number, r: Rect) segEnter(x1, y1, x2, y2, r) !== null; /** 상자 가운데 → 테두리까지 = 선 방향(dx, dy)의 몇 배 */ -const toEdge = (b: Rect, dx: number, dy: number): number => +const toEdge = (b: { w: number; h: number }, dx: number, dy: number): number => Math.min(dx ? b.w / 2 / Math.abs(dx) : Infinity, dy ? b.h / 2 / Math.abs(dy) : Infinity); /** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */ @@ -181,12 +221,19 @@ export function placeDeg( return own.dir ? DIR_DEG[own.dir] : null; } +/** 구간 km(Calc `legKm` 과 같음 · 형 import 만) */ +const kmOf = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; + +/** 선 글 — 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km · 줄바꿈 글자로 나눔) */ export const legText = (legs: Leg[]): string[] => legs.map((leg) => { - const km = leg.km ?? leg.auto_km ?? null; // Calc `legKm` 과 같음(형 import 만) - return `${leg.road ?? "도로 구분 고르기"} ${km == null ? "-" : String(km)}km`; + const km = kmOf(leg); + return `${leg.road ?? "도로 구분 고르기"}\n${km == null ? "-" : String(km)}km`; }); +/** 구간 km 합(선 길이 · 254) */ +const legsKm = (legs: Leg[]): number => legs.reduce((sum, leg) => sum + Number(kmOf(leg) ?? 0), 0); + /** 자동 값 호버 — 사용자 km 가 자동 값과 다를 때만 */ export const legTip = (leg: Leg): string => leg.auto_km != null && leg.km != null && leg.km !== leg.auto_km @@ -201,7 +248,7 @@ export function setAutoKm(legs: Leg[], km: number): Leg[] { } export interface KmSegment { - /** route = 거래처 구간 · base = 기준점 → 시점 공통 구간 */ + /** route = 거래처 구간 · base = 기준점 구간(→ 시점 · 다른 기준점) */ key: "route" | "base"; text: string; from: [number, number] | null; @@ -211,40 +258,64 @@ export interface KmSegment { const pointOf = (p: { lon?: number | null; lat?: number | null }): [number, number] | null => p.lon != null && p.lat != null ? [p.lon, p.lat] : null; -/** 구간 한 줄(244-13 연결선) — from = 거래처 id(거래처 → 기준점(거침) · 시점) · "base"(기준점 → 시점) · - * 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함) */ +/** 이음 대상 = 시점(Calc `SITE_ID` 와 같음 · 형 import 만) */ +const TO_SITE = "site"; +const baseOf = (data: MilestoneData, id: string): MilestoneBase | undefined => + data.bases.find((b) => b.id === id); +/** 구간 주인 — 기준점 · 거래처(from id) */ +export const ownerOf = (data: MilestoneData, from: string) => + baseOf(data, from) ?? data.routes.find((r) => r.id === from); + +/** 기준점 사슬(254 · Calc `baseChain` 과 같음) — to 부터 시점까지 · 돌기 · 안 이음 · 없음에서 멈춤 */ +export function chainOf(data: MilestoneData, to: string): MilestoneBase[] { + const out: MilestoneBase[] = []; + const seen = new Set(); + for (let id = to; id !== TO_SITE && !seen.has(id);) { + seen.add(id); + const base = baseOf(data, id); + if (!base || base.linked === false) break; + out.push(base); + id = base.to; + } + return out; +} + +/** 돌기(254) — 기준점 from 을 to 에 이으면 to 사슬이 from 으로 돌아옴(A → B → A · 이음 여부 무관) */ +export function makesLoop(data: MilestoneData, from: string, to: string): boolean { + const seen = new Set(); + for (let id = to; id !== TO_SITE && !seen.has(id); id = baseOf(data, id)?.to ?? TO_SITE) { + if (id === from) return true; + seen.add(id); + } + return false; +} + +/** 구간 한 줄(244-13 연결선) — from = 거래처 id · 기준점 id(→ to = 시점 · 기준점) · + * 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함 · 안 이음) */ export function segmentEnds(data: MilestoneData, from: string, start: LonLat): KmSegment | null { - const baseName = data.base.name || "기준점"; - if (from === "base") - return data.base.enabled - ? { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null } - : null; - const route = data.routes.find((r) => r.id === from); - if (!route || route.linked === false) return null; // 155 안 이은 거래처 = 구간 없음 - const viaBase = data.base.enabled && route.via_base; + const base = baseOf(data, from); + const owner = base ?? data.routes.find((r) => r.id === from); + if (!owner || owner.linked === false) return null; // 155 안 이은 상자 = 구간 없음 + const target = owner.to === TO_SITE ? undefined : baseOf(data, owner.to); + if (owner.to !== TO_SITE && !target) return null; return { - key: "route", - text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, - from: pointOf(route), - to: viaBase ? pointOf(data.base) : (start ?? null), + key: base ? "base" : "route", + text: `${owner.name || (base ? "기준점" : "거래처")} → ${target ? target.name || "기준점" : "시점"}`, + from: pointOf(owner), + to: target ? pointOf(target) : (start ?? null), }; } -/** 기준점 거쳐 시점 들어가는 구간(244-13 · 도로 구분 2차 · 1차 · 비포장) — 기준점 → 시점 · 거침 거래처 */ -export function chainVia(data: MilestoneData, from: string): boolean { - if (!data.base.enabled || from === "start") return false; - return from === "base" || !!data.routes.find((r) => r.id === from)?.via_base; -} - -/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점 +/** [길찾기] 구간 — 거래처 → to · 그 뒤 기준점 사슬 구간들(254) * (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */ export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] { const first = segmentEnds(data, routeId, start); - if (!first) return []; - return chainVia(data, routeId) ? [first, segmentEnds(data, "base", start)!] : [first]; + const route = data.routes.find((r) => r.id === routeId); + if (!first || !route) return []; + return [first, ...chainOf(data, route.to).map((b) => segmentEnds(data, b.id, start)!)]; } -/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */ +/** 카카오 지도 표지(244-9) — 거래처 · 기준점 사슬(좌표 있을 때) · 시점 */ export function mapMarks( data: MilestoneData, routeId: string, @@ -257,19 +328,15 @@ export function mapMarks( if (p) out.push({ name, lon: p[0], lat: p[1] }); }; add(route.name || "거래처", pointOf(route)); - if (data.base.enabled && route.via_base) add(data.base.name || "기준점", pointOf(data.base)); + if (route.linked !== false) + for (const b of chainOf(data, route.to)) add(b.name || "기준점", pointOf(b)); add("시점", start ?? null); return out; } -/** 자동 km 를 그 구간에(사용자 km 는 그대로) */ -export function putAutoKm( - data: MilestoneData, - routeId: string, - key: KmSegment["key"], - km: number, -): MilestoneData { - const legs = key === "base" ? data.base.legs : data.routes.find((r) => r.id === routeId)?.legs; +/** 자동 km 를 그 구간에(from = 거래처 · 기준점 id · 사용자 km 는 그대로) */ +export function putAutoKm(data: MilestoneData, from: string, km: number): MilestoneData { + const legs = ownerOf(data, from)?.legs; if (legs) setAutoKm(legs, km); return data; } @@ -289,6 +356,7 @@ function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): Dia const t1 = Math.min(toEdge(a, dx, dy) + LINE_GAP / len, 0.5); const t2 = Math.max((segEnter(ax, ay, bx, by, end) ?? 1) - LINE_GAP / len, t1); const labels = legText(legs); + const rows = labels.flatMap((l) => l.split("\n")); return { from: a.id, to: b.id, @@ -300,8 +368,8 @@ function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): Dia tips: legs.map(legTip), lx: (ax + bx) / 2, ly: (ay + by) / 2, - lw: labels.length ? Math.max(...labels.map((l) => textW(l, FONT.km))) + 8 : 0, - lh: labels.length * KM_LINE + 2, + lw: rows.length ? Math.max(...rows.map((l) => textW(l, FONT.km))) + 8 : 0, + lh: rows.length ? rows.length * KM_LINE + 2 : 0, }; } @@ -363,15 +431,18 @@ export function layoutMilestone( }; /** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */ // 153 사용자가 끌어 놓은 자리(왼 위 · 그림 좌표) — 있으면 방위 자리 · 밀어 냄 대신 그 자리 - const posOf = (id: string): [number, number] | null | undefined => - id === "base" ? data.base.pos : data.routes.find((r) => r.id === id)?.pos; + const posOf = (id: string): [number, number] | null | undefined => ownerOf(data, id)?.pos; const put = (box: DiagramBox, out?: [number, number]): void => { const inner = box.w - 2 * INSET; box.label = clip(box.label, inner, FONT.label); if (box.items) box.items = clip(box.items, inner, FONT.items); const pos = posOf(box.id); if (pos) [box.x, box.y] = pos; - for (let i = 0; !pos && out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) { + // 254 선 길이가 짧아져 앞서 놓인 선 위에 앉지 않게 — 놓인 상자 · 놓인 선(가운데 ↔ 가운데) 둘 다 피함 + const blocked = (): boolean => + boxes.some((b) => hits(box, b, GAP)) || + pending.some(([f, t]) => segHits(...center(f), ...center(t), box)); + for (let i = 0; !pos && out && i < 400 && blocked(); i++) { box.x += out[0] * 4; box.y += out[1] * 4; } @@ -411,31 +482,68 @@ export function layoutMilestone( line("start", "mid", [{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]); const startLL = site?.start_lonlat ?? null; - const useBase = data.base.enabled; - const baseLL: LonLat = - data.base.lon != null && data.base.lat != null ? [data.base.lon, data.base.lat] : null; - let baseDeg = 270; - if (useBase) { - baseDeg = placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270; - const [ux, uy] = unit(baseDeg); - const cx = ux * R_BASE; - const cy = uy * R_BASE; + const llOf = (p?: { lon?: number | null; lat?: number | null }): LonLat => + p && p.lon != null && p.lat != null ? [p.lon, p.lat] : null; + const used = new Set(); + const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; + /** 상자 id → 잇는 곳에서 본 방위(기준점 둘레 빈 방위 = 그 방위에서 돌림 · 되돌아가는 쪽 뺌) */ + const degOf = new Map(); + let auto = 0; + /** 방위 안 정한 상자 — 빈 8방 차례(기준점 둘레 = 잇는 곳 반대편부터 · 잇는 곳으로 향하는 쪽 뺌) */ + const free = (anchor: string): number => { + const turn = anchor === "site" ? 0 : (degOf.get(anchor) ?? 0); + const blocked = anchor === "site" ? -1 : turn + 180; + const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter( + (c) => keyOf(anchor, c) !== keyOf(anchor, blocked), + ); + return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; + }; + /** 잇는 곳 가운데 → 상자 가운데(254) = 잇는 곳 테두리 + 상자 테두리 + 선 길이(구간 km 비례) */ + const reachOf = (anchor: string, h: number, ux: number, uy: number, legs: Leg[]): number => + toEdge(byId.get(anchor)!, ux, uy) + toEdge({ w: BOX.w, h }, ux, uy) + lineLen(legsKm(legs)); + /** 잇는 곳 상자 — 놓인 기준점이면 그 id · 아니면 공사지 */ + const anchorOf = (to: string): string => (to !== "site" && byId.has(to) ? to : "site"); + + // 기준점(254 · 여럿) — 잇는 곳(시점 · 다른 기준점) 기준 방위(좌표 → 방위각 · 없으면 서부터 빈 방위) · + // 잇는 곳이 먼저 놓이게 사슬 차례 · 돌기 = 공사지 둘레 + const waiting = [...data.bases]; + while (waiting.length) { + let i = waiting.findIndex( + (b) => b.linked === false || b.to === "site" || byId.has(b.to) || !ownerOf(data, b.to), + ); + if (i < 0) i = 0; + const [b] = waiting.splice(i, 1); + const anchor = b.linked === false ? "site" : anchorOf(b.to); + const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL); + const deg = + placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ?? + (anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor)); + used.add(keyOf(anchor, deg)); + degOf.set(b.id, deg); + const [ax, ay] = center(anchor); + const [ux, uy] = unit(deg); + const notes = b.address ? wrap(b.address, BOX.w - 2 * INSET, FONT.note) : []; + const h = boxH(1 + notes.length); + const reach = reachOf(anchor, h, ux, uy, b.legs); put( { - id: "base", + id: b.id, kind: "base", - x: cx - BOX.w / 2, - y: cy - BOX.h / 2, + x: ax + ux * reach - BOX.w / 2, + y: ay + uy * reach - h / 2, w: BOX.w, - h: BOX.h, - label: data.base.name || "기준점", + h, + label: b.name || "기준점", + notes, + tip: b.address || undefined, }, [ux, uy], ); - line("base", "start", data.base.legs); + if (b.linked !== false && (b.to === "site" || byId.has(b.to))) + line(b.id, b.to === "site" ? "start" : b.to, b.legs); } - // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 + // 거래처 — 잇는 곳(기준점 · 공사지) 기준 방위 · 같은 방위끼리 옆으로 나란히 const groups = new Map< string, Array<{ @@ -446,31 +554,24 @@ export function layoutMilestone( anchor: string; deg: number; legs: Leg[]; + linked: boolean; + notes: string[]; + h: number; }> >(); - const used = new Set(); - const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; const items = data.routes.map((r) => { - const anchor = useBase && r.via_base ? "base" : "site"; - return { r, anchor, deg: placeDeg(r, anchor === "base" ? (baseLL ?? startLL) : startLL) }; + const anchor = anchorOf(r.to); + const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL); + return { r, anchor, deg: placeDeg(r, anchorLL) }; }); for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg)); - let auto = 0; - /** 방위 안 고른 거래처 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */ - const free = (anchor: string): number => { - const turn = anchor === "base" ? baseDeg : 0; - const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1; - const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter( - (c) => keyOf(anchor, c) !== keyOf(anchor, blocked), - ); - return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; - }; for (const { r, anchor, deg } of items) { const d = deg ?? free(anchor); used.add(keyOf(anchor, d)); const key = keyOf(anchor, d); if (!groups.has(key)) groups.set(key, []); const items = [...new Set(r.rows.map((row) => row.item.trim()).filter(Boolean))].join(" · "); + const notes = r.address ? wrap(r.address, BOX.w - 2 * INSET, FONT.note) : []; groups.get(key)!.push({ id: r.id, label: r.name || "거래처", @@ -479,16 +580,21 @@ export function layoutMilestone( anchor, deg: d, legs: r.legs, + // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음 + linked: r.linked !== false && (r.to === "site" || anchor === r.to), + notes, + h: boxH((items ? 1 : 0) + 1 + notes.length), }); } for (const group of groups.values()) { group.forEach((g, k) => { - const [ax, ay] = center(g.anchor === "base" ? "base" : "site"); + const [ax, ay] = center(g.anchor); const [ux, uy] = unit(g.deg); - // 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④) - const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP; + // 나란히 = 방위에 직각 · 상자 직각 폭(무리에서 가장 높은 상자) + 여유만큼(④) + const tall = Math.max(...group.map((x) => x.h)); + const spread = Math.abs(ux) * tall + Math.abs(uy) * BOX.w + GAP; const off = (k - (group.length - 1) / 2) * spread; - const reach = g.anchor === "site" ? R_BASE : R_ROUTE; // 공사지 둘레 = 기준점과 같은 거리(선 글 자리) + const reach = reachOf(g.anchor, g.h, ux, uy, g.legs); const cx = ax + ux * reach - uy * off; const cy = ay + uy * reach + ux * off; put( @@ -496,19 +602,17 @@ export function layoutMilestone( id: g.id, kind: "route", x: cx - BOX.w / 2, - y: cy - BOX.h / 2, + y: cy - g.h / 2, w: BOX.w, - h: BOX.h, + h: g.h, label: g.label, items: g.items, - notes: notesOf(BOX.w, g.address), + notes: g.notes, tip: g.address, }, [ux, uy], ); - // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) - if (data.routes.find((r) => r.id === g.id)?.linked !== false) - line(g.id, g.anchor === "base" ? "base" : "start", g.legs); + if (g.linked) line(g.id, g.anchor === "site" ? "start" : g.anchor, g.legs); }); } @@ -530,11 +634,10 @@ export function layoutMilestone( }; } -/** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ +/** 선 글 한 줄 → 그 구간 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */ export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null { if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }; - const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; - return legs?.[i] ?? null; + return ownerOf(data, from)?.legs[i] ?? null; } /** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */ @@ -550,8 +653,7 @@ export function setLeg( data.site.mid_station_m = patch.km == null ? null : Math.round(patch.km * 1000 * 1000) / 1000; return data; } - const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; - const leg = legs?.[i]; + const leg = ownerOf(data, from)?.legs[i]; if (leg) Object.assign(leg, patch); return data; } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index e68cccdbc..657e482c9 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -9,6 +9,10 @@ POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 · 표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` · 좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄 + POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 · + 이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름 + GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/.png` + · `auto/.png` · `.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음) GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램 지금 중점 측점(244-12 · 상자 작은 글씨) @@ -24,17 +28,25 @@ 그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자 그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님). 그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움. +장소 그림(256-1) = `place/<상자 id>.png` · [저장] 뒤 서버가 좌표가 바뀐(또는 그림 없는) 상자만 +다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 · +GET 답 `place_images` {상자 id: "place/.png"} · `place_times` {상자 id: 바뀐 시각 ms} +(캐시 깨기). 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). 표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). +기준점 = `bases[]`(254 · 여럿 · to = 시점 또는 다른 기준점) · 경로 `to` — 옛 `base` · `via_base` 는 +읽을 때 · 받을 때 새 꼴로 옮김(`migrate_milestone` · 저장 = 새 꼴). """ from __future__ import annotations import asyncio +import hashlib import json import math import os +import threading import urllib.error import urllib.parse import urllib.request @@ -42,7 +54,7 @@ from pathlib import Path from typing import Annotated, Any, Literal from uuid import UUID -from fastapi import APIRouter, File, HTTPException, Query, UploadFile +from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field, ValidationError, model_validator @@ -50,8 +62,8 @@ 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_Haul import haul_groups, with_haul_routes -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_Milestone_Calc import SITE, compute_milestone, migrate_milestone, r3 +from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets from common_util.common_util_initial_snapshot import design_route_csv_path @@ -69,8 +81,11 @@ PATH = "/{project_id}/quantity/milestone" FOLDER = Path("B08_Quantity") / "milestone" DATA_FILE = "milestone.json" AUTO = "auto" # 자동 그림 하위 폴더(244-11) +PLACE = "place" # 장소 그림 하위 폴더(256-1) +PRINTS = ".prints.json" # 그림 지문 {"place/": 해시} — 바뀐 것만 다시 캡처 IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" +BOX_IDS = {SITE, "start", "mid"} # 다이어그램 공사지 상자 id — 기준점 · 경로 id 로 못 씀 Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13) Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] @@ -102,7 +117,7 @@ class Place(BaseModel): class Route(Place): id: str = Field(pattern=ROUTE_ID) - via_base: bool = True + to: str = Field(default=SITE, pattern=ROUTE_ID) # 254 잇는 곳 = "site"(시점) · 기준점 id # 251 운반 묶음 키(총괄자재 · B09 운반 줄과 이음) · 단가연결(dg · sg · logic 꼴 · 정본) haul: str | None = Field(default=None, max_length=60, pattern=r"^[A-Za-z0-9_-]+$") ref: dict[str, Any] | None = None @@ -111,24 +126,42 @@ class Route(Place): class Base(Place): - enabled: bool = False + """기준점(254 · 여럿) — to = 시점 또는 다른 기준점 id(돌기는 화면이 막고 셈이 끊음).""" + + id: str = Field(pattern=ROUTE_ID) + to: str = Field(default=SITE, pattern=ROUTE_ID) + linked: bool = True # False = 아직 선 안 이음(요소 추가 뒤) + + +class LonLat(BaseModel): + lon: float = Field(ge=-180, le=180) + lat: float = Field(ge=-90, le=90) class Site(BaseModel): mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000) mid_road: Road = "비포장" + start_pin: LonLat | None = None # 255-2 길찾기용 시점 덮개(노선은 그대로) class Milestone(BaseModel): site: Site = Field(default_factory=Site) - base: Base = Field(default_factory=Base) + bases: list[Base] = Field(default_factory=list, max_length=20) routes: list[Route] = Field(default_factory=list, max_length=100) + @model_validator(mode="before") + @classmethod + def _migrate(cls, data: Any) -> Any: + """옛 꼴(단일 base · via_base) → 새 꼴(254).""" + return migrate_milestone(data) if isinstance(data, dict) else data + @model_validator(mode="after") def _unique_ids(self) -> Milestone: - ids = [route.id for route in self.routes] + ids = [b.id for b in self.bases] + [route.id for route in self.routes] if len(ids) != len(set(ids)): - raise ValueError("경로 id 가 겹칩니다.") + raise ValueError("경로 · 기준점 id 가 겹칩니다.") + if BOX_IDS & set(ids): + raise ValueError("site · start · mid 는 id 로 못 씁니다.") return self @@ -212,15 +245,29 @@ def images(root: str | Path, sub: str = "") -> dict[str, str]: } +def places(data: dict[str, Any]) -> list[tuple[str, str, float, float]]: + """좌표가 정해진 상자 → [(상자 id, 이름, 경도, 위도)] — 거래처 = 경로 id · 기준점 = bases id.""" + boxes = [ + (b["id"], b.get("name") or "", b.get("lon"), b.get("lat")) + for b in [*data.get("routes", []), *data.get("bases", [])] + ] + return [(i, n, lon, lat) for i, n, lon, lat in boxes if lon is not None and lat is not None] + + def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]: site = site_info(root, data["site"]["mid_station_m"]) auto = images(root, AUTO) + place = images(root, PLACE) return { "data": data, "site": site, "table": compute_milestone(data, site["mid_default_m"]), "images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선 "auto_images": auto, + "place_images": place, + "place_times": { + stem: int((folder(root) / name).stat().st_mtime * 1000) for stem, name in place.items() + }, } @@ -230,13 +277,60 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]: path.mkdir(parents=True, exist_ok=True) atomic_write_json(path / DATA_FILE, data) keep = {route["id"] for route in data["routes"]} - for sub in ("", AUTO): + for sub, ids in (("", keep), (AUTO, keep), (PLACE, {p[0] for p in places(data)})): for stem, name in images(root, sub).items(): - if stem not in keep: + if stem not in ids: (path / name).unlink(missing_ok=True) return payload(root, data) +def _print(*parts: Any) -> str: + """지문 — 좌표 · 이름 해시(좌표 원문은 안 남김).""" + return hashlib.sha1(json.dumps(parts, ensure_ascii=False).encode()).hexdigest()[:16] + + +def _read_prints(root: str | Path) -> dict[str, str]: + try: + return json.loads((folder(root) / PRINTS).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def _set_print(root: str | Path, key: str, value: str | None) -> None: + """지문 한 칸 적음 · None = 지움(다음 [저장]에 다시 캡처).""" + with _prints_lock: + prints = _read_prints(root) + if prints.get(key) == value: + return + if value is None: + prints.pop(key, None) + else: + prints[key] = value + atomic_write_json(folder(root) / PRINTS, prints) + + +_prints_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 지문 파일 읽고 쓰기 한 번에 하나 + + +def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: float) -> dict: + """장소 그림 한 장 캡처 → 지문 적음 → {saved, reason}.""" + got = make_place_image(folder(root) / PLACE, place_id, {"name": name, "lon": lon, "lat": lat}) + if got["saved"]: + _set_print(root, f"{PLACE}/{place_id}", _print(name, lon, lat)) + return got + + +def refresh_images(root: str | Path) -> None: + """[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 · + 실패 = 다음 [저장]에 다시.""" + data = read_data(root) + prints = _read_prints(root) + have = images(root, PLACE) + for place_id, name, lon, lat in places(data): + if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have: + place_image(root, place_id, name, lon, lat) + + def _route_id(route: str) -> str: try: return Route(id=route).id @@ -260,9 +354,13 @@ async def milestone_read(project_id: UUID) -> dict[str, Any]: @router.put(PATH) -async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: +async def milestone_save( + project_id: UUID, body: Milestone, tasks: BackgroundTasks +) -> dict[str, Any]: root = await _project_root(project_id) - return await asyncio.to_thread(save_data, root, body) + out = await asyncio.to_thread(save_data, root, body) + tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임 + return out @router.get(PATH + "/site-address") @@ -375,15 +473,36 @@ async def milestone_image_auto( return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]} +FILE_NAME = r"^(?:(?:auto|place)/)?[A-Za-z0-9_-]{1,40}\.(?:png|jpe?g|gif|webp|bmp)$" + + +@router.post(PATH + "/place-image") +async def milestone_place_image(project_id: UUID, body: Mark, id: str = Query(...)) -> dict: + """장소 그림(256-1) — 등록 · 수정 · 끌기 뒤 화면이 부름 → place/.png · 좌표는 저장 안 함.""" + place_id = _route_id(id) + root = await _project_root(project_id) + got = await asyncio.to_thread(place_image, root, place_id, body.name, body.lon, body.lat) + if not got["saved"]: + return {"name": None, "time": None, "reason": got["reason"]} + name = f"{PLACE}/{place_id}.png" + time_ms = int((folder(root) / name).stat().st_mtime * 1000) + return {"name": name, "time": time_ms, "reason": None} + + +@router.get(PATH + "/file") +async def milestone_file(project_id: UUID, name: str = Query(..., pattern=FILE_NAME)): + """그림 파일(256-1) — 하위 폴더 auto · place 만(패턴이 경로 넘기 막음).""" + root = await _project_root(project_id) + path = folder(root) / name + if not path.is_file(): + raise HTTPException(status_code=404, detail="그림이 없습니다.") + return FileResponse(path) + + KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" KAKAO_KEY = "KAKAO_REST_API_KEY" -class LonLat(BaseModel): - lon: float = Field(ge=-180, le=180) - lat: float = Field(ge=-90, le=90) - - class Directions(BaseModel): origin: LonLat destination: LonLat diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index fe859ca05..2c7509eae 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -118,7 +118,8 @@ stroke: var(--color-success); } -.b08-milestone__box.is-route { +.b08-milestone__box.is-route, +.b08-milestone__box.is-base { cursor: pointer; } @@ -170,10 +171,10 @@ width: 80px; } -/* 거래처 상자 윗줄 = 항목(244-6) */ +/* 거래처 상자 윗줄 = 항목(244-6) · 글 크기 = 거래처 이름(254) */ .b08-milestone__items { fill: var(--color-text-secondary); - font-size: 12px; + font-size: 13px; } /* 상자 작은 글씨 = 주소 · 좌표(244-12) */ @@ -224,7 +225,7 @@ cursor: pointer; } -/* 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 · 공사지에 놓으면 연결 */ +/* 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254) · 끌어 기준점 · 공사지에 놓으면 연결 */ .b08-milestone__connect { fill: var(--color-success); stroke: var(--color-surface); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 148f27c34..3de113da2 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -87,7 +87,7 @@ export function addRoute(projectId: string, init: Partial = {}): id, name: `거래처 ${data.routes.length + 1}`, address: "", - via_base: true, + to: "site", dir: null, legs: [{ road: null, km: null }], // 도로 구분 = 사용자 고름(244-7) rows: [{ item: "", dest: "start", note: "" }], @@ -168,12 +168,12 @@ function tableView(projectId: string): MilestoneView { const data = liveMilestone(projectId); const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null); // 기준점 생김 · 없앰 = 열 바뀜 → 표 새로 - const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`; + const next = `${data.bases.length > 0}:${rows.some((r) => r.pv > 0)}`; if (next !== mode) { mode = next; const fresh = makeTable( milestoneColumns( - data.base.enabled, + data.bases.length > 0, rows.some((r) => r.pv > 0), ), ); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index e7595d8a1..548c2b7f4 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -10,12 +10,14 @@ * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 수정 · 삭제) · 선 · 선 글 누름 = 연결선 모달 * (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) - * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) + * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) · + * 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km) + * 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소) * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ -import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, @@ -28,6 +30,8 @@ import { import { layoutMilestone, legAt, + makesLoop, + ownerOf, panContent, panStart, setLeg, @@ -158,7 +162,7 @@ export function createDiagram( site: () => openSiteInfo(projectId), start: () => openSiteInfo(projectId), mid: () => openSiteInfo(projectId), - base: () => openPlaceModal(projectId, { add: false, id: "base" }), + base: (id) => openPlaceModal(projectId, { add: false, id }), }; /** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버) @@ -189,7 +193,7 @@ export function createDiagram( Math.round(box.y + (e.clientY - down.clientY) / k), ]; const fresh = liveMilestone(projectId); - const target = box.id === "base" ? fresh.base : fresh.routes.find((r) => r.id === box.id); + const target = ownerOf(fresh, box.id); if (!target) return; target.pos = pos; userZoomed = true; // 옮긴 뒤 배율 그대로 @@ -200,12 +204,13 @@ export function createDiagram( }); }; - /** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 기준점 거침 · 공사지(시점 · 중점) = 시점으로 바로 · - * 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) + 길찾기 한 번(km · 경로 그림) */ + /** 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254 · 테두리 안) · 끌어 기준점 상자 = 그 기준점으로 + * (to = 기준점 id) · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 자기 · 같은 연결 = 취소 · 기준점 돌기 + * (A → B → A) = 취소 + 알림 · 바뀌면 그 구간 새로(km 비움) + 길찾기 한 번(km · 거래처 = 경로 그림) */ const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => { - const cx = box.x + box.w; - const cy = box.y + box.h / 2; - const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 7 }); + const cx = box.x + 10; + const cy = box.y + 10; + const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 6 }); dot.append(svg("title", {}, "끌어서 기준점 · 공사지에 놓으면 연결")); dot.addEventListener("click", (e) => e.stopPropagation()); // 상자 모달 안 뜸 dot.addEventListener("pointerdown", (down) => { @@ -221,12 +226,14 @@ export function createDiagram( const wire = svg("line", { class: "b08-milestone__wire", x1: cx, y1: cy, x2: cx, y2: cy }); shift.append(wire); let over: Element | null = null; - const targetOf = (e: PointerEvent): "base" | "site" | null => { + /** 놓을 곳 — 기준점 id · "site"(공사지 · 시점 · 중점) · 없음 */ + const targetOf = (e: PointerEvent): string | null => { const hit = document.elementFromPoint(e.clientX, e.clientY); const g = hit?.closest("[data-b08-milestone-box]"); const id = g?.dataset.b08MilestoneBox; - if (id === "base") return "base"; - return id === "site" || id === "start" || id === "mid" ? "site" : null; + if (id === "site" || id === "start" || id === "mid") return "site"; + if (id === box.id) return null; // 자기 = 취소 + return id && liveMilestone(projectId).bases.some((b) => b.id === id) ? id : null; }; const move = (e: PointerEvent): void => { const [x, y] = toLocal(e); @@ -247,12 +254,17 @@ export function createDiagram( over?.classList.remove("is-drop-target"); const t = targetOf(e); const fresh = liveMilestone(projectId); - const route = fresh.routes.find((r) => r.id === box.id); + const route = ownerOf(fresh, box.id); if (!t || !route) return; - const via = t === "base"; - const now = route.linked === false ? null : fresh.base.enabled && route.via_base !== false; - if (via === now) return; // 같은 연결 = 그대로 - route.via_base = via; + if (route.linked !== false && route.to === t) return; // 같은 연결 = 그대로 + if (box.kind === "base" && makesLoop(fresh, box.id, t)) { + showToast( + "연결이 돌아 제자리로 옵니다(기준점 A → B → A) — 연결하지 않았습니다.", + "warning", + ); + return; + } + route.to = t; route.linked = true; // 155 요소 추가 뒤 첫 이음 route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기 putMilestone(projectId, fresh); @@ -270,7 +282,7 @@ export function createDiagram( variant: "ghost", onClick: () => { const fresh = liveMilestone(projectId); - fresh.base.pos = null; + fresh.bases.forEach((b) => (b.pos = null)); fresh.routes.forEach((r) => (r.pos = null)); userZoomed = false; putMilestone(projectId, fresh); @@ -370,15 +382,16 @@ export function createDiagram( }); g.addEventListener("keydown", (e) => e.key === "Enter" && info()); if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift); - if (box.kind === "route") g.append(connectDot(box, shift)); + if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift)); shift.append(g); if (box.kind === "site") shift.append(lineLayer); } - // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄 + // 선 글은 상자 위에(가리지 않게 맨 끝) — 구간 줄마다 두 줄(254 · 도로 구분 / km) for (const ln of layout.lines) { // 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④) const x = ln.lx; - const y = ln.ly - ((ln.labels.length - 1) * 16) / 2; + const count = ln.labels.reduce((n, l) => n + l.split("\n").length, 0); + const y = ln.ly - ((count - 1) * 16) / 2; const text = svg("text", { class: "b08-milestone__km", x, @@ -386,23 +399,27 @@ export function createDiagram( "text-anchor": "middle", "dominant-baseline": "central", }); + let first = true; ln.labels.forEach((label, i) => { - const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); - span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; - if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); - // 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침 let wait = 0; - span.addEventListener("click", (e) => { - e.stopPropagation(); - clearTimeout(wait); - wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250); - }); - span.addEventListener("dblclick", (e) => { - e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 - clearTimeout(wait); - editLegAt(projectId, span, ln.from, i, mk); - }); - text.append(span); + for (const row of label.split("\n")) { + const span = svg("tspan", { x, dy: first ? 0 : 16 }, row); + first = false; + span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; + if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); + // 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침 + span.addEventListener("click", (e) => { + e.stopPropagation(); + clearTimeout(wait); + wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250); + }); + span.addEventListener("dblclick", (e) => { + e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 + clearTimeout(wait); + editLegAt(projectId, span, ln.from, i, mk); + }); + text.append(span); + } }); shift.append(text); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index 988a48bf3..089b7c485 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -3,8 +3,8 @@ * 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름. * * 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7) - * 도로 구분(152) = 포장 종류는 선 자리로 정함 — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 · - * 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`) + * 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함 — → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 · + * 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`) * 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음) * 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정) * 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음) @@ -22,7 +22,7 @@ import { type Road, type SegKind, } from "./B08_Quantity_Milestone_Calc"; -import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; +import { ownerOf, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { toastKm } from "./B08_Quantity_UI_Milestone_Modal"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; @@ -33,6 +33,7 @@ import { roadOptions, segmentPath, siteInfo, + startPoint, } from "./B08_Quantity_UI_Milestone_State"; const km = (text: string): number | null => { @@ -144,10 +145,10 @@ function openMidLeg(projectId: string): void { }); } -/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "start"(시점 → 중점) */ +/** 연결선 누름 — from = 거래처 · 기준점 id(→ 기준점 · 시점 · 254) · "start"(시점 → 중점) */ export function openLegInfo(projectId: string, from: string): void { if (from === "start") return openMidLeg(projectId); - const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; + const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); // 255-2 덮개 const seg = segmentEnds(liveMilestone(projectId), from, start()); if (!seg) return; openModal({ @@ -155,11 +156,8 @@ export function openLegInfo(projectId: string, from: string): void { closeLabel: "닫기", dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { - const legsOf = (): Leg[] => { - const d = liveMilestone(projectId); - return (from === "base" ? d.base.legs : d.routes.find((r) => r.id === from)?.legs) ?? []; - }; - const via = chainVia(liveMilestone(projectId), from); + const legsOf = (): Leg[] => ownerOf(liveMilestone(projectId), from)?.legs ?? []; + const isBase = liveMilestone(projectId).bases.some((b) => b.id === from); const kind = segKind(liveMilestone(projectId), from); let legs = legsEditor(legsOf(), kind); const status = hint(""); @@ -167,7 +165,7 @@ export function openLegInfo(projectId: string, from: string): void { /** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */ const apply = (): void => { const fresh = liveMilestone(projectId); - const target = from === "base" ? fresh.base : fresh.routes.find((r) => r.id === from); + const target = ownerOf(fresh, from); if (!target) return; target.legs = legs.value(); putMilestone(projectId, fresh); @@ -177,18 +175,20 @@ export function openLegInfo(projectId: string, from: string): void { legs.root.replaceWith(next.root); legs = next; }; + /** 표지 = 그 구간 두 끝(주인 · to = 기준점 또는 시점) */ const pins = (): MapPin[] => { const d = liveMilestone(projectId); - const route = d.routes.find((r) => r.id === from); + const owner = ownerOf(d, from); const out: MapPin[] = []; const add = (key: string, name: string, lon?: number | null, lat?: number | null): void => { if (lon != null && lat != null) out.push({ key, name, lon, lat, movable: key !== "start" }); }; - if (route) add(route.id, route.name || "거래처", route.lon, route.lat); - if (from === "base" || via) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); + if (!owner) return out; + add(owner.id, owner.name || (isBase ? "기준점" : "거래처"), owner.lon, owner.lat); + const target = d.bases.find((b) => b.id === owner.to); + if (target) add(target.id, target.name || "기준점", target.lon, target.lat); else add("start", "시점", start()?.[0], start()?.[1]); - if (from === "base") add("start", "시점", start()?.[0], start()?.[1]); return out; }; const drawMap = (): void => { @@ -214,7 +214,7 @@ export function openLegInfo(projectId: string, from: string): void { apply(); void reverseAddress(projectId, lon, lat).then((address) => { const fresh = liveMilestone(projectId); - const target = key === "base" ? fresh.base : fresh.routes.find((r) => r.id === key); + const target = ownerOf(fresh, key); if (!target) return; Object.assign(target, { lon, lat, address }); putMilestone(projectId, fresh); @@ -242,14 +242,13 @@ export function openLegInfo(projectId: string, from: string): void { save.dataset.b08MilestoneLegSave = ""; actions.append(find, save); // 155 [수기 입력] — 카카오 길찾기 연결 끊음(자동 값 지움 · 길찾기 · 지도 숨김 · km 손 입력) · 풀면 다시 길찾기 - const owner = (d = liveMilestone(projectId)) => - from === "base" ? d.base : d.routes.find((r) => r.id === from); + const owner = (d = liveMilestone(projectId)) => ownerOf(d, from); const manual = createCheckbox("수기 입력 — 카카오 길찾기 안 함(거리 손 입력)"); manual.input.checked = !!owner()?.manual; manual.input.dataset.b08MilestoneManual = ""; const auto = hint( - from === "base" - ? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" + isBase + ? "기준점 구간 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" : "빈칸 = 길찾기 자동 값 · 넣은 값이 우선", ); const mapHead = el("h4", { text: "경로 — 다음(카카오) 지도" }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 1942871d0..476ede05d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -8,9 +8,10 @@ * 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달 * 연결(기준점 거침 · 시점으로 바로) = 다이어그램 연결 점 ● 끌기(154 · 155 · 등록 = 상자만 · 선 없음) * 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰) - * 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리 + * 분류 기준위치 = 기준점(254 · 여럿 · 새 `bases` 원소 · 항목 · 목적지 없음 · 등록 = 상자만 · 연결 점으로 이음) · + * 방위 = 소재지 좌표로 자동 자리 · 기준점 [삭제] = 그 기준점에 이은 거래처 · 기준점은 안 이음으로 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 - * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 + * 시점(공사지) 상자 = 중점 측점 km(저장 m · 255-1) · 아래 카카오 지도(시점 · 중점 표지) * 조작 = 캐시 · [저장] 때 서버 * ========================================================================== */ @@ -24,7 +25,6 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; import { roadOf, type Dest, @@ -32,7 +32,9 @@ import { type MilestoneRow, type Road, } from "./B08_Quantity_Milestone_Calc"; +import { setLeg } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; +import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { findSegmentKm, @@ -87,20 +89,21 @@ function rowField(r: MilestoneRow, kind: "route" | "base") { export type PlaceTarget = { add: true } | { add: false; id: string }; -/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */ +/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id = 거래처 · 기준점 id */ export function openPlaceModal( projectId: string, target: PlaceTarget, addRoute?: (init: Partial) => string, ): void { const data = liveMilestone(projectId); - const editBase = !target.add && target.id === "base"; + const base = target.add ? undefined : data.bases.find((b) => b.id === target.id); + const editBase = !!base; const route = !target.add && !editBase ? data.routes.find((r) => r.id === target.id) : undefined; if (!target.add && !editBase && !route) return; const title = target.add ? "요소 추가 — 기준점 · 거래처" - : editBase - ? `기준점 — ${data.base.name || "기준점"}` + : base + ? `기준점 — ${base.name || "기준점"}` : `항목 정보 — ${route!.name || "거래처"}`; openModal({ title, @@ -118,20 +121,14 @@ export function openPlaceModal( }); sort.select.value = kind; sort.select.disabled = !target.add; - // 기준점 = 하나 — 이미 있으면 [+]에서 기준위치 못 고름(기준점 상자에서 고침) - const baseOption = sort.select.querySelector('option[value="base"]'); - if (target.add && data.base.enabled && baseOption) { - baseOption.disabled = true; - baseOption.textContent = "기준위치(있음 — 기준점 상자에서 수정)"; - } sort.select.dataset.b08MilestoneAdd = "sort"; const name = createInputField({ label: "거래처 이름", compact: true }); - name.input.value = editBase ? data.base.name : (route?.name ?? ""); + name.input.value = base ? base.name : (route?.name ?? ""); name.input.dataset.b08MilestoneAdd = "company"; const nameLabel = name.root.querySelector(".ui-field__label"); // 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠 const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind); - const old = editBase ? data.base : route; + const old = base ?? route; const place = placePicker( projectId, { address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null }, @@ -163,11 +160,24 @@ export function openPlaceModal( 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 }]; + if (kind === "base" && base) { + const b = fresh.bases.find((x) => x.id === base.id); + if (!b) return close(); + Object.assign(b, where, { name: text, dir: null }); + putMilestone(projectId, fresh); + if (moved(where)) segment = b.id; + } else if (kind === "base") { + // 254 새 기준점 = 상자만 · 선은 연결 점 ● 로 이음(이을 때 길찾기) + fresh.bases.push({ + id: `b${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`, + name: text, + dir: null, + legs: [{ road: null, km: null }], + to: "site", + linked: false, + ...where, + }); putMilestone(projectId, fresh); - if (moved(where)) segment = "base"; } else if (route) { const r = fresh.routes.find((x) => x.id === route.id); if (!r) return close(); @@ -180,9 +190,9 @@ export function openPlaceModal( } else { if (!text) return name.setError("거래처 이름을 넣으세요."); // 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기) - const id = addRoute?.({ + addRoute?.({ name: text, - via_base: true, + to: "site", linked: false, dir: null, rows: rowValues(), @@ -198,12 +208,17 @@ export function openPlaceModal( label: "삭제", variant: "danger", onClick: async () => { - const label = editBase ? data.base.name || "기준점" : route?.name || "거래처"; + const label = base ? base.name || "기준점" : route?.name || "거래처"; if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제"))) return; const fresh = liveMilestone(projectId); - if (editBase) fresh.base = { enabled: false, name: "", address: "", dir: null, legs: [] }; - else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 + if (base) { + fresh.bases = fresh.bases.filter((b) => b.id !== base.id); + // 254 그 기준점에 이은 상자 = 안 이음(옛 구간 km = 다른 길 · 연결 점으로 다시 이음) + for (const o of [...fresh.bases, ...fresh.routes]) + if (o.to === base.id) + Object.assign(o, { to: "site", linked: false, legs: [{ road: null, km: null }] }); + } else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 putMilestone(projectId, fresh); close(); }, @@ -231,27 +246,26 @@ export interface SiteAddress { mid_address: string | null; } -/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */ +/** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */ export function openSiteInfo(projectId: string): void { openModal({ title: "공사지 — 시점 · 중점", closeLabel: "닫기", + dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { - const text = (label: string, value: string): HTMLElement => - el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); - // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) + // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) · 보이고 받는 것만 km(255-1) const data = liveMilestone(projectId); const site = siteInfo(); - const km = (m: number | null | undefined): string => - m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); const mid = createInputField({ - label: "중점 측점 (m)", + label: "중점 측점 (km)", type: "number", min: 0, compact: true, }); - mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; - mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); + mid.input.step = "0.001"; + mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m / 1000) : ""; + mid.input.value = + data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000); mid.input.dataset.b08Milestone = "mid_station_m"; const road = createSelectField({ label: "시점 → 중점 도로", @@ -263,40 +277,39 @@ export function openSiteInfo(projectId: string): void { label: "수정", variant: "filled", onClick: () => { + const text = mid.input.value; + const value = Number(text); + const km = text.trim() && Number.isFinite(value) && value >= 0 ? value : null; const fresh = liveMilestone(projectId); - const value = Number(mid.input.value); - fresh.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; - fresh.site.mid_road = road.select.value as Road; - putMilestone(projectId, fresh); + putMilestone( + projectId, + setLeg(fresh, "start", 0, { road: road.select.value as Road, km }), + ); close(); }, }); save.dataset.b08MilestoneSite = "save"; actions.append(save); - const map = buildAddressMap(); - const info = el("div", { children: [text("주소", "불러오는 중…")] }); - body.append( - line(mid.root, road.root), - text( - "시점 = 노선 첫 점(측점 0)", - `노선 ${km(site?.length_m)} km · 중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`, - ), - info, - map.root, - ); - request(`/projects/${projectId}/quantity/milestone/site-address`).then( - (s) => { - const ll = (p: [number, number] | null): string => - p ? ` (${p[1].toFixed(6)}, ${p[0].toFixed(6)})` : ""; - info.replaceChildren( - text("프로젝트 등록 주소(사업지역)", s.region || "없음"), - text("시점 브이월드 주소", (s.start_address ?? "못 찾음") + ll(s.start_lonlat)), - text("중점 브이월드 주소", (s.mid_address ?? "못 찾음") + ll(s.mid_lonlat)), - ); - if (s.start_lonlat) map.showPoint(s.start_lonlat[1], s.start_lonlat[0]); - else if (s.region) void map.show(s.region); - }, - (error: unknown) => showToast(`공사지 주소를 못 읽었습니다 — ${String(error)}`, "error"), + const map = editMap(projectId, { hint: "", onMove: () => undefined }); + const pins = (s: { + start_lonlat: [number, number] | null; + mid_lonlat: [number, number] | null; + }): MapPin[] => + ( + [ + ["start", "시점", s.start_lonlat], + ["mid", "중점", s.mid_lonlat], + ] as const + ).flatMap(([key, name, p]) => + p ? [{ key, name, lon: p[0], lat: p[1], movable: false }] : [], + ); + if (site) map.set(pins(site), []); + body.append(line(mid.root, road.root), map.root); + // 지금 중점 측점(캐시)의 중점 좌표 — 오면 표지 다시 + const query = data.site.mid_station_m == null ? "" : `?mid_m=${data.site.mid_station_m}`; + request(`/projects/${projectId}/quantity/milestone/site-address${query}`).then( + (s) => map.set(pins(s), []), + (error: unknown) => showToast(`공사지 좌표를 못 읽었습니다 — ${String(error)}`, "error"), ); }, }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index d4b9c85b6..8c632231c 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -5,6 +5,8 @@ * * [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버) * 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157) + * 255-2 시점도 끌기 = 길찾기용 덮개(`site.start_pin`) · 표지 옮기면 그 자리에 닿는 구간 모두 다시 길찾기 · + * 열 때 선 없는 사슬 구간 모두 길찾기 · 실패 = 안내 한 줄 · 끌기는 그림 캡처 안 함 * ========================================================================== */ import { @@ -14,6 +16,7 @@ import { } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { @@ -25,7 +28,8 @@ import { putMilestone, routeImageKey, segmentPath, - siteInfo, + startPoint, + touchingSegments, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; @@ -119,48 +123,66 @@ export function routeView(projectId: string, routeId: string): MilestoneView { }); reset.dataset.b08MilestoneRevert = ""; - const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; const pins = (): MapPin[] => { const d = liveMilestone(projectId); + const start = startPoint(d); const r = d.routes.find((x) => x.id === routeId); const out: MapPin[] = []; const add = (key: string, label: string, lon?: number | null, lat?: number | null): void => { - if (lon != null && lat != null) - out.push({ key, name: label, lon, lat, movable: key !== "start" }); + if (lon != null && lat != null) out.push({ key, name: label, lon, lat, movable: true }); // 255-2 시점도 끌기(길찾기 덮개) }; if (!r) return out; add(r.id, r.name || "거래처", r.lon, r.lat); if (r.linked === false) return out; - if (d.base.enabled && r.via_base) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); - add("start", "시점", start()?.[0], start()?.[1]); + for (const b of chainOf(d, r.to)) add(b.id, b.name || "기준점", b.lon, b.lat); // 254 기준점 사슬 + add("start", "시점", start?.[0], start?.[1]); return out; }; const lines = (): Array> => { const d = liveMilestone(projectId); const r = d.routes.find((x) => x.id === routeId); - const via = !!r && r.linked !== false && d.base.enabled && r.via_base; - return [segmentPath(routeId), ...(via ? [segmentPath("base")] : [])].filter( - (p) => p.length >= 2, - ); + const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : []; + return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2); + }; + // 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」) + const status = el("p", { className: "b08-options__hint" }); + status.dataset.b08MilestoneRouteStatus = ""; + status.hidden = true; + /** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */ + const refind = async (ids: string[]): Promise => { + const texts = await Promise.all(ids.map((id) => findSegmentKm(projectId, id, false))); + const bad = texts.filter((t) => t.includes(" — ")); + status.textContent = bad.join(" · "); + status.hidden = !bad.length; + drawMap(); + }; + /** 표지 끌기 — 시점 = 덮개(site.start_pin · 노선 그대로) · 거래처 · 기준점 = 소재지 · 닿는 선 모두 다시 */ + const moveTo = async (key: string, lon: number, lat: number): Promise => { + const address = key === "start" ? "" : await reverseAddress(projectId, lon, lat); + const fresh = liveMilestone(projectId); + if (key === "start") fresh.site.start_pin = { lon, lat }; + else { + const target = ownerOf(fresh, key); + if (!target) return; + Object.assign(target, { lon, lat, address }); + } + putMilestone(projectId, fresh); + await refind(touchingSegments(fresh, key)); }; const map = editMap(projectId, { hint: "", - onMove: (key, lon, lat) => - void reverseAddress(projectId, lon, lat).then((addr) => { - const fresh = liveMilestone(projectId); - const target = key === "base" ? fresh.base : fresh.routes.find((x) => x.id === key); - if (!target) return; - Object.assign(target, { lon, lat, address: addr }); - putMilestone(projectId, fresh); - void findSegmentKm(projectId, key === "base" ? "base" : routeId).then(() => drawMap()); - }), + onMove: (key, lon, lat) => void moveTo(key, lon, lat), }); const drawMap = (): void => map.set(pins(), lines()); - let asked = false; // 길 선이 아직 없으면 열 때 한 번 길찾기(그림 · 알림 없음) + let asked = false; // 길 선이 없으면 열 때 한 번 길찾기(사슬 선 모두 · 그림 · 알림 없음) const ensurePath = (): void => { - if (asked || segmentPath(routeId).length) return; + if (asked) return; asked = true; - void findSegmentKm(projectId, routeId, false).then(() => drawMap()); + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : []; + const missing = [routeId, ...chain].filter((id) => !segmentPath(id).length); + if (missing.length) void refind(missing); }; const root = el("section", { @@ -174,6 +196,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { el("div", { className: "b08-milestone__bar", children: [change, reset, file] }), ], }), + status, img, map.root, ], diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 456a44b57..6d87b31cc 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -11,16 +11,25 @@ * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) * 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림 * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 - * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) · + * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) · * 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점) + * 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` · + * [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl` + * 기준점 여럿(254) = `bases[]` · 길 선 표 키 = 거래처 · 기준점 id · 옛 꼴 캐시 = 읽을 때 새 꼴(`migrateMilestone`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; -import { PAVED, type MilestoneData, type SegKind } from "./B08_Quantity_Milestone_Calc"; -import { mapMarks, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; +import { + PAVED, + SITE_ID, + migrateMilestone, + type MilestoneData, + type SegKind, +} from "./B08_Quantity_Milestone_Calc"; +import { mapMarks, ownerOf, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; export interface SiteInfo { @@ -35,11 +44,13 @@ interface Payload { site: SiteInfo; images: Record; auto_images: Record; + place_images: Record; + place_times: Record; } export const EMPTY: MilestoneData = { site: { mid_station_m: null, mid_road: "비포장" }, - base: { enabled: false, name: "", address: "", dir: null, legs: [] }, + bases: [], routes: [], }; @@ -61,6 +72,19 @@ export async function loadMilestone(projectId: string, force = false): Promise

saved?.site ?? null; +/** 길찾기 시점 = 경로 탭에서 끌어 놓은 덮개(255-2 · site.start_pin) · 없으면 노선 첫 점 */ +export const startPoint = (data: MilestoneData): [number, number] | null => { + const pin = data.site.start_pin; + return pin ? [pin.lon, pin.lat] : (siteInfo()?.start_lonlat ?? null); +}; + +/** 그 자리(시점 "start" · 기준점 · 거래처 id)에 닿는 구간(255-2) — 나가는 구간(자기) + 들어오는 구간(to = 그 자리) */ +export function touchingSegments(data: MilestoneData, key: string): string[] { + const to = key === "start" ? SITE_ID : key; + const incoming = [...data.bases, ...data.routes].filter((o) => o.to === to && o.linked !== false); + return [...(key === "start" ? [] : [key]), ...incoming.map((o) => o.id)]; +} + /** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 · * 오면 다시 그림) · 아직 · 실패 = null(좌표만) */ const siteAddresses = new Map(); @@ -97,9 +121,11 @@ export const routeImageKey = (routeId: string): string | null => { export const hasMilestoneDraft = (projectId: string): boolean => readState("b08-milestone", projectId) !== null; -/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */ +/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본 · 옛 꼴 캐시 = 새 꼴 · 254) */ export function liveMilestone(projectId: string): MilestoneData { - return clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY); + return migrateMilestone( + clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY), + ); } /** 고친 한 벌을 캐시에 · 화면에 알림 */ @@ -180,10 +206,11 @@ export async function autoRouteImage( lines: Array>, ): Promise { // 표지 = 거래처 쪽 구간 두 끝(mapMarks 차례 = 거래처 · 기준점(거침) · 시점 → 앞 둘) - const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null).slice( - 0, - 2, - ); + const marks = mapMarks( + liveMilestone(projectId), + routeId, + startPoint(liveMilestone(projectId)), + ).slice(0, 2); let reason: string | null; try { const res = await request<{ name: string | null; reason: string | null }>( @@ -204,6 +231,43 @@ export async function autoRouteImage( changes.dispatchEvent(new Event("change")); } +/** 장소 그림(256-1) 다시 읽을 표 — 이름 + 바뀐 시각 · 없으면 null */ +export const placeImageKey = (placeId: string): string | null => { + const name = saved?.place_images?.[placeId]; + return name ? `${name}#${saved?.place_times?.[placeId] ?? 0}` : null; +}; + +/** 장소 그림(256-1) — 상자 좌표 · 이름 → 서버 캡처 → 목록 고쳐 알림(뒤에서 · 실패 = 알림 한 줄) */ +export async function capturePlace( + projectId: string, + placeId: string, + mark: { name: string; lon: number; lat: number }, +): Promise { + let reason: string | null; + try { + const res = await request<{ name: string | null; time: number | null; reason: string | null }>( + `/projects/${projectId}/quantity/milestone/place-image?id=${encodeURIComponent(placeId)}`, + { method: "POST", body: JSON.stringify(mark) }, + ); + reason = res.reason; + if (res.name && saved) { + saved.place_images = { ...saved.place_images, [placeId]: res.name }; + saved.place_times = { ...saved.place_times, [placeId]: res.time ?? Date.now() }; + } + } catch (error) { + reason = `장소 그림 캡처 요청 실패(${String(error)})`; + } + if (reason) showToast(reason, "warning"); + changes.dispatchEvent(new Event("change")); +} + +/** 장소 그림 → 화면 주소(blob · 쿠키 포함) */ +export async function placeImageBlobUrl(projectId: string, placeId: string): Promise { + const name = saved?.place_images?.[placeId] ?? `place/${placeId}.png`; + const url = `${API_BASE_URL}/projects/${projectId}/quantity/milestone/file?name=${encodeURIComponent(name)}`; + return URL.createObjectURL(await (await call(url)).blob()); +} + /** 그림 → 화면 주소(blob · 쿠키 포함) */ export async function imageBlobUrl(projectId: string, routeId: string): Promise { return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); @@ -214,7 +278,7 @@ export function roadOptions(kind: SegKind): Array<{ value: string; text: string return [PAVED[kind], "비포장"].map((r) => ({ value: r, text: r })); } -/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */ +/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 · 기준점 id) → [위도, 경도] 목록(메모리만 · 저장 안 함) */ const paths = new Map>(); export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? []; /** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */ @@ -229,9 +293,9 @@ export async function findSegmentKm( image = true, ): Promise { const data = liveMilestone(projectId); - const owner = from === "base" ? data.base : data.routes.find((r) => r.id === from); + const owner = ownerOf(data, from); if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함 - const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null); + const seg = segmentEnds(data, from, startPoint(data)); if (!seg) return ""; if (!seg.from || !seg.to) { const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; @@ -253,9 +317,8 @@ export async function findSegmentKm( if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!); else paths.delete(from); const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 - const legs = from === "base" ? fresh.base.legs : fresh.routes.find((r) => r.id === from)?.legs; - if (legs?.[0]?.auto_km !== res.km) { - putAutoKm(fresh, from, seg.key, res.km); + if (ownerOf(fresh, from)?.legs[0]?.auto_km !== res.km) { + putAutoKm(fresh, from, res.km); putMilestone(projectId, fresh); } else changes.dispatchEvent(new Event("change")); if (image && seg.key === "route" && paths.has(from)) diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index ec581bf7c..d8a80c3df 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -106,9 +106,12 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]: def haul_items( - materials: dict[str, Any] | None, refs: dict[str, Any] | None = None + materials: dict[str, Any] | None, + refs: dict[str, Any] | None = None, + routes: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: - """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) · + """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) + + 거리이정표 사용자 상자 행(253-1 · `B08_Quantity_Haul.haul_rows` · 수량 빈칸) · 연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김).""" out = [] for g in haul_groups(materials): @@ -128,6 +131,24 @@ def haul_items( "why": WHY_LOGIC if logic else WHY_HAUL, } ) + for r in routes or []: + if not r.get("user"): + continue + ref = (refs or {}).get(r["key"]) + logic = (ref or {}).get("tab") == "logic" + out.append( + { + "key": r["key"], + "section": "3", + "name": r["name"], + "spec": "", + "unit": "", + "qty": None, + "note": "B08 거리이정표", + "ref": ref, + "why": WHY_LOGIC if logic else WHY_HAUL, + } + ) return out @@ -136,7 +157,7 @@ def bill_items(b08: dict[str, Any]) -> dict[str, Any]: "items": [ *earth_items(b08.get("earthwork")), *structure_items(b08.get("summary")), - *haul_items(b08.get("materials"), b08.get("haul_refs")), + *haul_items(b08.get("materials"), b08.get("haul_refs"), b08.get("haul_routes")), ] } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 05f8d0c3b..d23353b28 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -27,11 +27,13 @@ from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException from pydantic import BaseModel -from B08_Quantity.B08_Quantity_Haul import bill_key +from B08_Quantity.B08_Quantity_Haul import haul_rows +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone +from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Sangeun as sangeun @@ -110,18 +112,27 @@ def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> t return sheets, compute_summary(sheets, hopyo_lines(sheets)) -def haul_refs(root: Path) -> dict[str, Any]: - """거리이정표 운반 상자 단가연결(251-5) → `{설계내역서 줄 키: ref}` — 못 읽으면 빈 묶음.""" +def haul_routes(root: Path) -> list[dict[str, Any]]: + """거리이정표 행 → 운반공 줄(253-1 · `haul_rows` · 거리 L1 · L2 포함) — 못 읽으면 빈 목록.""" try: - routes = read_milestone(root).get("routes") or [] - except Exception: # noqa: BLE001 — 거리이정표 없음 = 연결 없음 - return {} - return {bill_key(r["haul"]): r["ref"] for r in routes if r.get("haul") and r.get("ref")} + data = read_milestone(root) + mid = site_info(root, data["site"]["mid_station_m"])["mid_default_m"] + return haul_rows(data, compute_milestone(data, mid)) + except Exception: # noqa: BLE001 — 거리이정표 없음 = 운반 연결 없음 + return [] + + +def haul_user(rows: list[dict[str, Any]]) -> dict[str, Any]: + """셈 덧값 — `haul_refs{줄 키: ref}`(251-5) · `haul_dist{줄 키: {L1, L2}}`(253-2 거리 채움).""" + return { + "haul_refs": {r["key"]: r["ref"] for r in rows if r.get("ref")}, + "haul_dist": {r["key"]: {"L1": r["L1"], "L2": r["L2"]} for r in rows}, + } def calc_user(root: Path) -> dict[str, Any]: - """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결(화면엔 안 보냄).""" - return {**store.read_user(root), "haul_refs": haul_refs(root)} + """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결 · 거리(화면엔 안 보냄).""" + return {**store.read_user(root), **haul_user(haul_routes(root))} async def _demand(root: Path) -> list[str]: @@ -143,11 +154,12 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: ) book = await asyncio.to_thread(store.read_book, root) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) - inputs["bill"] = bill_items({**b08, "haul_refs": await asyncio.to_thread(haul_refs, root)}) + hauls = await asyncio.to_thread(haul_routes, root) + inputs["bill"] = bill_items({**b08, **haul_user(hauls), "haul_routes": hauls}) inputs["hopyo"] = hopyo_prices(root) inputs["daega"] = await asyncio.to_thread(daega_inputs, root) user = await asyncio.to_thread(store.read_user, root) - calc = {**user, "haul_refs": await asyncio.to_thread(haul_refs, root)} + calc = {**user, **haul_user(hauls)} sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc) if schedule_draft: schedule_draft.attach(inputs, sheets, user) @@ -176,7 +188,7 @@ async def estimation(project_id: UUID) -> dict[str, Any]: async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: root = await _project_root(project_id) user = await asyncio.to_thread(store.write_user, root, body.get("user")) - calc = {**user, "haul_refs": await asyncio.to_thread(haul_refs, root)} + calc = {**user, **haul_user(await asyncio.to_thread(haul_routes, root))} await asyncio.to_thread(library.prune, root, calc) # 204-2 줄이 안 잇는 서고 사본 keys = await _demand(root) book = await asyncio.to_thread(store.read_book, root) diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 3b7f74dd8..297f9de63 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -37,6 +37,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total from B09_Estimation.B09_Estimation_Store import ref_rows from M01_MasterData import M01_MasterData_Store as m01 @@ -475,13 +476,27 @@ class Solver: LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses") +def _input_names(code: str) -> list[str]: + try: + return [str(i.get("이름")) for i in m01.loaded()[1].logic(code).get("입력") or []] + except mf.FormulaError: + return [] + + def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, Any]]]: - """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)].""" + """설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)] · + 운반공 줄(253-2 · `haul_dist{줄 키: {L1, L2}}`) = 거리이정표 거리로 로직 입력 다시 채움(정본).""" out = [] + dist = (user or {}).get("haul_dist") or {} for row in ref_rows(user): # 251-5 거리이정표 운반 연결 포함 ref = row.get("ref") or {} if ref.get("tab") != "logic" or not ref.get("code"): continue + if row["key"] in dist: + ref = { + **ref, + "given": fill_given(ref, _input_names(str(ref["code"])), dist[row["key"]]), + } src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")), "rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip where = "수기 줄" if row["key"].startswith("hand:") else "내역 줄" # 184 자동 줄 연결 덮개 diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 07496606d..1016fcac6 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -126,9 +126,15 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None: if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]: given = ref.get("given") if isinstance(ref.get("given"), dict) else {} picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None - return {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")), - "given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))}, - "rate": _num(ref.get("rate")), "picks": picks} # fmt: skip + out = {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")), + "given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))}, + "rate": _num(ref.get("rate")), "picks": picks} # fmt: skip + cycle = ref.get("cycle") # 253-2 운반 사이클 입력 · 덮은 칸 + if isinstance(cycle, dict): + out["cycle"] = {str(k): _num(v) for k, v in cycle.items() if _num(v) is not None} + own = ref.get("own") if isinstance(ref.get("own"), list) else [] + out["own"] = [str(x)[:40] for x in own if isinstance(x, str)][:40] + return out return None diff --git a/resources/tester/helper_259_detail_region.cjs b/resources/tester/helper_259_detail_region.cjs new file mode 100644 index 000000000..61139b6dd --- /dev/null +++ b/resources/tester/helper_259_detail_region.cjs @@ -0,0 +1,133 @@ +/* PLAN 259 단위검증 헬퍼 — [상세] 공용 팬 누름 풂 · 암 경계선 나눔 · 닫힌 영역 · 표 칸 → 그래프 고름. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes, rock} · + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_259_detail_region.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const Z = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); +const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts")); + +const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; + +/* ① 공용 팬 — 가운데 단추 팬(click 없음) 뒤 왼쪽 누름 click 이 안 삼켜짐 */ +function panCase(panStart) { + const listeners = {}; + const svg = { + addEventListener: (name, fn) => (listeners[name] ??= []).push(fn), + setAttribute: () => {}, + classList: { add: () => {}, remove: () => {} }, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 600, height: 300 }), + }; + const layer = { setAttribute: () => {} }; + Z.attachSvgZoomPan({ + svg, + layer, + widthPx: 600, + heightPx: 300, + pad: { left: 10, right: 10, top: 10, bottom: 10 }, + content: { x: -600, y: -300, width: 1800, height: 900 }, + panStart, + }); + const fire = (name, init = {}) => { + let stopped = false; + const event = { + button: 0, + clientX: 0, + clientY: 0, + pointerId: 1, + preventDefault: () => {}, + stopPropagation: () => (stopped = true), + ...init, + }; + (listeners[name] ?? []).forEach((fn) => fn(event)); + return stopped; + }; + const drag = (button) => { + fire("pointerdown", { button, clientX: 100, clientY: 100 }); + fire("pointermove", { button, clientX: 140, clientY: 120 }); + fire("pointerup", { button, clientX: 140, clientY: 120 }); + }; + const click = (x = 50) => { + fire("pointerdown", { clientX: x, clientY: 50 }); + fire("pointerup", { clientX: x, clientY: 50 }); + return fire("click", { clientX: x, clientY: 50 }); + }; + return { drag, click, fire }; +} +{ + const middle = panCase(); + middle.drag(1); // 가운데 단추 팬 — 브라우저는 click 을 안 보냄(auxclick) + out.panMiddle = { firstClickStopped: middle.click(), secondClickStopped: middle.click() }; + // 거리이정표 꼴 — 빈 곳 왼쪽 끌기 팬(끌기로 끝난 click 은 막고 · 다음 누름은 통과) + const left = panCase((event) => event.button === 1 || event.button === 0); + left.drag(0); + // 끌기 끝 click(왼쪽은 브라우저가 보냄) — 막혀야 함 + out.panLeft = { afterDragStopped: left.fire("click"), nextClickStopped: left.click() }; +} + +/* ② 암 경계선 — 그림 요소 → 조각(설계선과 만나는 점에서 나눔) */ +const px = ([offset, elevation]) => + [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ].join(","); +const el = (classes, points, attrs = {}) => ({ + tagName: "polyline", + classList: { contains: (name) => classes.includes(name) }, + hasAttribute: (name) => name in attrs, + getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)), +}); +const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]); +const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]); +const rockPts = G.rockRuns(design, samples)[0]; +const elements = [ + el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }), + el(["b06-chart__cross-profile"], groundPts), + el(["b06-chart__rock-boundary"], rockPts), +]; +const pieces = S.collectPieces(elements, axes); +out.pieces = pieces; +const rockPieces = pieces.filter((p) => p.line === "rock"); +out.rockLabel = S.selectionLabel(design, [rockPieces[1]]); +out.rockSource = S.selectionSource("length", [rockPieces[1]]); +out.rockLocal = T.localCellValue(out.rockSource, design, samples); +out.rockLength = G.pathLength(rockPieces[1].points); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_260_note_cad.cjs b/resources/tester/helper_260_note_cad.cjs new file mode 100644 index 000000000..372e2a599 --- /dev/null +++ b/resources/tester/helper_260_note_cad.cjs @@ -0,0 +1,111 @@ +/* PLAN 260 단위검증 헬퍼 — [상세] 표 위 CAD 칸 · 옛 글 옮김 · 횡단배수 미리 넣음. + * `B06_Section_UI_Cross_Detail_Cad.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다(CAD · 세션 · 서버 = 흉내). + * 결과는 표준출력에 JSON 한 벌. 확장자 `.cjs` 까닭은 `helper_108_drag_box_hit.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { + "@ui/cad_host/cad_host": { createCadHost: () => ({}) }, + "../A00_Common/b_page_state": { readState: () => null, writeState: () => undefined }, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({}) }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null }, +}; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + throw new Error(`흉내 없는 모듈: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts")); + +const wall = (role, form, height, length) => ({ + role, + structure: "기슭막이", + revet_form: form, + revet_height_m: height, + revet_length_m: length, +}); +const culvert = (extra = {}) => ({ + type: "pipe", + chainage_m: 1433, + pipe_kind: null, + diameter_m: 1.2, + min_cover_m: 0.5, + inlet: wall("inlet", "돌쌓기(찰)", 2, 10), + outlet: wall("outlet", "돌쌓기(메)", 2, 10), + ...extra, +}); +const section = (spec, design = { pipe_length_m: 22 }, chainage = 1433) => ({ + chainage_m: chainage, + station_id: "s", + samples: [], + culvert: spec, + design, +}); +const CHUTE = { chute_add_1_structure: "돌붙임", chute_add_1_bond: "찰붙임", chute_add_1_area: 10 }; + +/** 엔티티 → 비교하기 쉬운 꼴(종류 · 글 · 자리 · 색). */ +const brief = (entities) => + entities.map((entity) => ({ + type: entity.type, + label: entity.shapeData.label ?? null, + align: entity.shapeData.options?.textAlign ?? null, + x: entity.shapeData.basePoint?.x ?? entity.shapeData.center?.x ?? null, + y: entity.shapeData.basePoint?.y ?? entity.shapeData.center?.y ?? null, + color: entity.lineColor, + layer: entity.layerId, + })); +const run = (sec, options) => brief(cad.culvertNoteEntities(sec, "NO.71+13", options)); + +const out = { + full: run(section(culvert()), CHUTE), + noLength: run(section(culvert(), {}), {}), + basin: run( + section( + culvert({ + inlet: { + role: "inlet", + structure: "집수정", + basin_form: "L", + basin_height_m: 1.7, + basin_length_m: 2, + }, + }), + ), + {}, + ), + chuteFallback: run(section(culvert({ chute: { structure: "도수로", thickness_m: 0.45 } })), {}), + noSides: run( + section( + culvert({ + inlet: { role: "inlet", structure: "" }, + outlet: { role: "outlet", structure: "" }, + }), + ), + {}, + ), + none: run(section(undefined), CHUTE), + ford: run(section(culvert({ body: "ford" })), CHUTE), + hidden: run(section(culvert({ hidden_pipe: true })), CHUTE), + neighbour: run(section(culvert(), { pipe_length_m: 22 }, 1440), CHUTE), + legacy: brief(cad.legacyNoteEntities("\nL형 측구 \n\n집수정 보강\n")), + legacyEmpty: cad.legacyNoteEntities(" ").length, + station: [ + cad.stationNoLabel(1433, 20), + cad.stationNoLabel(1420, 20), + cad.stationNoLabel(1439.97, 20), + ], +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_177_1_summary_unassigned_bg.py b/resources/tester/test_177_1_summary_unassigned_bg.py index bafa3cf4f..7af55cb66 100644 --- a/resources/tester/test_177_1_summary_unassigned_bg.py +++ b/resources/tester/test_177_1_summary_unassigned_bg.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""PLAN 177-1 — 구조물 조합 미연결 라벨 = 연한 노랑(경고) 배경 + 테두리(소스 시험).""" +"""PLAN 177-1 → 258-3 — 구조물 조합 미연결 라벨 = 옅은 회색(구조물 리스트 램프 색) 배경 + 테두리(소스 시험).""" import os import re @@ -15,10 +15,10 @@ def _rule(sel: str) -> str: return m.group(1) -def test_미연결_배경_테두리_경고색_글자색_없음(): +def test_미연결_배경_테두리_회색_글자색_없음(): body = _rule(".b05-structure__summary-name:not(.is-assigned)") - assert re.search(r"border-color:\s*var\(--color-warning\)", body) - assert re.search(r"background:\s*color-mix\([^;]*--color-warning[^;]*--color-surface", body) + assert re.search(r"border-color:\s*var\(--color-text-muted\)", body) + assert re.search(r"background:\s*color-mix\([^;]*--color-text-muted[^;]*--color-surface", body) assert not re.search(r"(^|[;\s])color:", body) diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 5629a735d..a261089b1 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -1,7 +1,7 @@ -"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 246-1 에서 지움. +"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 258-2 에서 옅게 되살림. - 공용 표 editOnDouble = 한 번 누름 = 글 상자 안 열림 · 더블클릭 · 칸 누른 뒤 Enter · F2 = 그 칸 글 상자 -- 고칠 수 있는 칸(td) 표시 `is-can-edit` 없음(246-1 · 241 앞 모양) +- 고칠 수 있는 칸(td) = 글일 때도 `is-can-edit`(옅은 outline · 258-2) · 못 고치는 칸 · 옛 방식 표 = 없음 - B09 = 표 만드는 길 한 곳(Shell) · B08 = 고칠 칸 있는 표(자재 집계표 셋) 모두 editOnDouble - 칸 누름으로 여는 메뉴 · 고르기 · 이동(onCellClick · choices · link)은 그대로 """ @@ -76,7 +76,7 @@ setTimeout(() => { def test_공용_표_더블클릭_Enter_F2(): out = json.loads(_node(TABLE + HARNESS + SCENE_JS)) - assert out["can"] == [] # 246-1 고칠 칸 td 표시 없음 + assert out["can"] == ["3"] # 고칠 수 있는 칸만 · 글일 때도 표시(258-2) assert out["start"] == [] # 처음 = 글 상자 없음 assert out["click"] == [[], ["click 1:qty"]] # 한 번 누름 = 안 열림 · 누름 알림은 그대로 assert out["f2"] == ["1:qty"] # F2 = 누른 칸 입력 @@ -90,10 +90,18 @@ def test_공용_표_더블클릭_Enter_F2(): assert out["old"] == [["1:qty"], []] # editOnDouble 없는 표 = 예전대로(테두리 class 없음) -def test_고칠_칸_테두리_CSS_없음(): +def test_고칠_칸_테두리_CSS_옅게_테마_변수만(): css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") - ts = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") - assert "is-can-edit" not in css and "is-can-edit" not in ts # 246-1 되돌림 + m = re.search(r"\.ui-data-table__table td\.is-can-edit \{([^}]*)\}", css) + assert m + body = m.group(1) + assert ( + "outline: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-canvas));" + in body + ) + assert "outline-offset: -1px;" in body # 칸 크기 불변 + assert "box-shadow" not in body # 빨강 이동 상자(box-shadow)와 안 겹침 + assert "#" not in body # 하드코딩 색 없음 def _sources(*folders: str) -> list[Path]: diff --git a/resources/tester/test_244_12_b08_milestone_base_view.py b/resources/tester/test_244_12_b08_milestone_base_view.py index 2c83b6a93..a5365f1c2 100644 --- a/resources/tester/test_244_12_b08_milestone_base_view.py +++ b/resources/tester/test_244_12_b08_milestone_base_view.py @@ -16,6 +16,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) LONG = "경상남도 거창군 북상면 병곡리 968-31" @@ -58,6 +59,7 @@ def _clipped(note, box_w): def _lay(data, site=None): + data = migrate_milestone(data) # 254 옛 꼴 → bases · to(옛 기준점 = b1) return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)") @@ -92,7 +94,7 @@ def test_시점_중점_선_상자_주소_좌표(): assert boxes["mid"]["notes"] == ["35.71234, 127.90000"] # 주소 없음 = 좌표만 assert _lay(_data([]))["boxes"][1]["notes"] == [] # 좌표도 없음 = 빈 줄 line = next(ln for ln in lay["lines"] if ln["from"] == "start") - assert line["to"] == "mid" and line["labels"] == ["비포장 1km"] + assert line["to"] == "mid" and line["labels"] == ["비포장\n1km"] # 254 두 줄 s, m = boxes["start"], boxes["mid"] assert m["x"] - (s["x"] + s["w"]) >= 100 # 선 글 자리 for b in (s, m): # 상자 = 공사지 안 @@ -106,7 +108,9 @@ def test_거래처_상자_항목_이름_주소(): _route("k", "E", via=False, address="거창읍")])) # fmt: skip boxes = {b["id"]: b for b in lay["boxes"]} assert (boxes["g"]["items"], boxes["g"]["label"]) == ("장비", "g") - _clipped(boxes["g"]["notes"][0], boxes["g"]["w"]) + # 254 긴 주소 = 줄바꿈(낱말 사이) + 상자 세로 늘림(줄 × 15 + 13 · 최소 58) + assert boxes["g"]["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"] + assert boxes["g"]["h"] == 4 * 15 + 13 and boxes["k"]["h"] == 58 assert boxes["g"]["tip"] == LONG and boxes["k"]["notes"] == ["거창읍"] assert _node('clip("가나다", 100, 10)') == "가나다" assert _node(f'clip("{LONG}", 108, 10)') == "경상남도 거창군 북상…" # 한글 10 · 빈칸 6 · … 6 diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index 956d4631d..e462cbf04 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -21,7 +21,10 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) B08 = "B08_Quantity/B08_Quantity_" @@ -90,29 +93,29 @@ def test_포장_도로_구분_거울(): def test_도로_구분_갈래_연결선_구간_끝(): - d = json.dumps(WITH_BASE) - off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}}) + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + d = json.dumps(migrate_milestone(WITH_BASE)) + off = {**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}} + off = json.dumps(migrate_milestone(off)) got = _node( [CALC, LAYOUT], f"""const D = {d}; const O = {off}; const S = [127.83, 35.64]; -console.log(JSON.stringify([["g", "d", "base", "start"].map((f) => PAVED[segKind(D, f)]), - ["g", "base"].map((f) => segKind(O, f)), - ["g", "d", "base", "start"].map((f) => chainVia(D, f)), ["g", "base"].map((f) => chainVia(O, f)), - segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "base", S), - segmentEnds(D, "start", S), segmentEnds(O, "base", S), segmentEnds(O, "g", S), +console.log(JSON.stringify([["g", "d", "b1", "start"].map((f) => PAVED[segKind(D, f)]), + ["g", "b1"].map((f) => segKind(O, f)), + segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "b1", S), + segmentEnds(D, "start", S), segmentEnds(O, "b1", S), segmentEnds(O, "g", S), kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""", ) # 152 포장 종류 = 선 자리 — 기준점 거침 거래처 2차 · 안 거침 포장 · 기준점 → 시점 1차 · 시점 → 중점 포장 - assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "base"] - assert got[2] == [True, False, True, False] and got[3] == [False, False] - assert got[4] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], + assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "direct"] + assert got[2] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], "to": [127.85, 35.66]} # fmt: skip - assert got[5]["text"] == "d → 시점" and got[5]["to"] == [127.83, 35.64] - assert got[6] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66], + assert got[3]["text"] == "d → 시점" and got[3]["to"] == [127.83, 35.64] + assert got[4] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66], "to": [127.83, 35.64]} # fmt: skip - assert got[7] is None and got[8] is None # 시점 → 중점 = 손 입력 · 기준점 없음 - assert got[9]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로 - assert got[10] == ["route", "base"] and got[11] == ["route"] + assert got[5] is None and got[6] is None # 시점 → 중점 = 손 입력 · 기준점 없음 + assert got[7]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로 + assert got[8] == ["route", "base"] and got[9] == ["route"] def test_표_열_기준점_따라(): @@ -237,13 +240,13 @@ await loadMilestone("p1"); const a = await findSegmentKm("p1", "g"); await new Promise((r) => setTimeout(r, 10)); const afterRoute = CALLS.length; -const b = await findSegmentKm("p1", "base"); +const b = await findSegmentKm("p1", "b1"); // 254 옛 꼴 캐시 → 기준점 id b1 const writes = WRITES.n; -await findSegmentKm("p1", "base"); // 같은 km = 캐시 안 씀 +await findSegmentKm("p1", "b1"); // 같은 km = 캐시 안 씀 const live = liveMilestone("p1"); console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, again: WRITES.n, - route: live.routes[0].legs, base: live.base.legs, path: segmentPath("g"), - routeLines: routePaths("g"), baseLines: routePaths("base"), + route: live.routes[0].legs, base: live.bases[0].legs, path: segmentPath("g"), + routeLines: routePaths("g"), baseLines: routePaths("b1"), start: await findSegmentKm("p1", "start") }}));""" got = _node([CALC, LAYOUT, STATE], script, STATE_STUBS) assert got["a"] == "g → 병곡리 10.5 km" and got["b"] == "병곡리 → 시점 10.5 km" @@ -290,7 +293,7 @@ def test_화면_등록_모달_연결선_모달(): assert "editMap(projectId" in place and "onClick: (lon, lat) => void moveTo(lon, lat)" in place assert "/quantity/milestone/reverse?lon=" in place # ② 상자 누름 = 같은 등록 모달 · [기준점 +] 없음 - assert 'base: () => openPlaceModal(projectId, { add: false, id: "base" }),' in diagram + assert "base: (id) => openPlaceModal(projectId, { add: false, id })," in diagram assert 'label: "기준점 +"' not in diagram and "addBase" not in diagram # ③ 연결선 누름 = 연결선 모달 · 시점 → 중점 = 손 입력만 assert "openLegInfo(projectId, ln.from);" in diagram and "b08-milestone__line-hit" in diagram diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index 203e3c163..ac4b3f24a 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -14,10 +14,13 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 + IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) def _layout(data, site=None, mid_km=0.75): + data = migrate_milestone(data) # 254 옛 꼴(base · via_base) → bases · to(옛 기준점 = b1) src = (ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Layout.ts").read_text(encoding="utf-8") body = ( f"\nconst d = {json.dumps(data, ensure_ascii=False)};" @@ -63,7 +66,7 @@ def test_공사지_컨테이너_시점_중점_선(): b = next(x for x in lay["boxes"] if x["id"] == bid) assert site["x"] <= b["x"] and b["x"] + b["w"] <= site["x"] + site["w"] assert lay["lines"] == [ - {**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장 0.75km"]} + {**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장\n0.75km"]} # 254 두 줄 ] @@ -75,8 +78,9 @@ def test_8방_자리_기준점_없음_공사지_기준(): ex, ey = _center(lay, "e") nx, ny = _center(lay, "n") wx, wy = _center(lay, "sw") + # 254 선 길이 = km 비례(빈 km = 최소 100) — 공사지 테두리(가로 190 · 세로 53)에서 잼 assert ex > cx + 200 and abs(ey - cy) < 1 - assert ny < cy - 200 and abs(nx - cx) < 1 + assert ny < cy - 150 and abs(nx - cx) < 1 assert wx < cx and wy > cy line = next(ln for ln in lay["lines"] if ln["from"] == "e") assert line["to"] == "start" # 기준점 없으면 시점으로 @@ -95,17 +99,17 @@ def test_기준점_기준_방위_선_글(): ), # fmt: skip ) cx, cy = _center(lay, "site") - bx, by = _center(lay, "base") + bx, by = _center(lay, "b1") assert bx < cx - 200 and abs(by - cy) < 1 # 기준점 = 공사지 서쪽 gx, gy = _center(lay, "g") assert abs(gx - bx) < 1 and gy < by - 200 # 거창군청 = 기준점 북쪽 kx, _ = _center(lay, "k") assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽 labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]} - assert labels[("g", "base")] == ["2차포장 23.8km"] - assert labels[("base", "start")] == ["1차포장 0.6km", "비포장 0.8km"] + assert labels[("g", "b1")] == ["2차포장\n23.8km"] # 254 두 줄(도로 구분 / km) + assert labels[("b1", "start")] == ["1차포장\n0.6km", "비포장\n0.8km"] assert labels[("k", "start")] == [] - assert next(b for b in lay["boxes"] if b["id"] == "base")["label"] == "병곡리 968-31" + assert next(b for b in lay["boxes"] if b["id"] == "b1")["label"] == "병곡리 968-31" def test_경위도_있으면_방위각이_8방보다_앞섬(): @@ -127,7 +131,7 @@ def test_빈_방위_차례_같은_방위_나란히(): cx, cy = _center(lay, "c") dx, dy = _center(lay, "d") sx, sy = _center(lay, "site") - assert cy < sy - 200 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북) + assert cy < sy - 150 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북) assert (dx, dy) != (cx, cy) and dx < sx # 다음 빈 방위(북서) boxes = [b for b in lay["boxes"] if b["kind"] == "route"] for i, p in enumerate(boxes): # 업체 상자끼리 안 겹침 diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 613e5d68d..31cdf6dd5 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -18,6 +18,7 @@ if str(ROOT) not in sys.path: from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402 from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -58,21 +59,23 @@ def test_방위_좌표_자동(): # 기준점 좌표 없음 → 시점 좌표 기준 · 업체 = 시점 남쪽 · 기준점 안 거침 업체 = 동쪽 "routes": [_route("s", 127.8, 35.6), _route("e", 127.9, 35.7, via=False)], } - lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") - bx, by = _center(lay, "base") + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "b1") sx, sy = _center(lay, "s") - assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래 + assert abs(sx - bx) < 1 and sy > by + 150 # 남 = 아래(254 빈 km = 최소 선 길이) cx, cy = _center(lay, "site") ex, ey = _center(lay, "e") assert ex > cx + 200 and abs(ey - cy) < 1 # 기준점 좌표 있음 → 기준점 기준(업체가 기준점 북쪽) data["base"] = {**base, "lon": 127.7, "lat": 35.7} data["routes"] = [_route("n", 127.7, 35.8)] - lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") - bx, by = _center(lay, "base") + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "b1") nx, ny = _center(lay, "n") assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표) - assert abs(nx - bx) < 1 and ny < by - 200 + assert abs(nx - bx) < 1 and ny < by - 150 def test_다음_지도_길찾기_주소(): @@ -147,10 +150,8 @@ def test_화면_모달_팬줌_그림탭(): place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts") assert 'from "../B01_Dashboard/B01_Dashboard_Api_Fetch"' in place assert "searchAddress(text)" in place - assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal # 공사지 모달 지도 - # ② 시점 모달 — 등록 주소 · VWorld 주소 · 지도 - assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal - assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal + # ② 시점 모달 — 지도 = 공용 카카오 지도(255-1 · 주소 줄 지움) + assert "/quantity/milestone/site-address" in modal and "editMap(projectId" in modal # ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달 for text in ('"수정"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): assert text in add @@ -188,17 +189,17 @@ def test_선_글_바로_고침_모달과_한_벌(): "legs": [{"road": "1차포장", "km": 0.6}]}, "routes": [{**_route("g"), "legs": [{"road": "2차포장", "km": None}]}], } # fmt: skip - d = json.dumps(data) + d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1) got = _node( f"[legAt({d}, 'g', 0, 0.75), legAt({d}, 'start', 0, 0.75), legAt({d}, 'x', 0, 0)," f" setLeg(setLeg(setLeg({d}, 'g', 0, {{road: '1차포장', km: 23.8}})," - f" 'base', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]" + f" 'b1', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]" ) assert got[0] == {"road": "2차포장", "km": None} assert got[1] == {"road": "비포장", "km": 0.75} and got[2] is None after = got[3] assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}] - assert after["base"]["legs"] == [{"road": "1차포장", "km": None}] + assert after["bases"][0]["legs"] == [{"road": "1차포장", "km": None}] assert after["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"} # 표 셈 — 포장 종류 = 선 자리(152 · 기준점 거침 거래처 = 2차포장 · 저장 값 1차포장 이어도) (row,) = compute_milestone(after, 1500.0) diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index 0b1b161e1..53c593e4f 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -5,9 +5,15 @@ import json import re import subprocess +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 + IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -17,6 +23,7 @@ def _read(rel): def _layout(base, site): data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, "base": base, "routes": []} + data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1) src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")) expr = f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)" result = subprocess.run( @@ -27,7 +34,7 @@ def _layout(base, site): assert result.returncode == 0, result.stderr lay = json.loads(result.stdout) center = {b["id"]: (b["x"] + b["w"] / 2, b["y"] + b["h"] / 2) for b in lay["boxes"]} - return center["site"], center["base"] + return center["site"], center["b1"] BASE = {"enabled": True, "name": "기준", "address": "", "legs": []} @@ -42,7 +49,7 @@ def test_좌표_없음_서쪽_옛_방위_무시(): def test_좌표_있음_공사지_기준_방위각(): (cx, cy), (bx, by) = _layout({**BASE, "dir": "W", "lon": 127.8, "lat": 35.8}, SITE) - assert abs(bx - cx) < 1 and by < cy - 200 # 북 = 위(옛 「서」 무시) + assert abs(bx - cx) < 1 and by < cy - 150 # 북 = 위(옛 「서」 무시 · 254 최소 선 길이) (cx, cy), (bx, by) = _layout({**BASE, "dir": None, "lon": 127.9, "lat": 35.7}, SITE) assert bx > cx + 200 and abs(by - cy) < 1 # 동 = 오른 # 시점 좌표 모름 = 서쪽 기본 @@ -60,6 +67,5 @@ def test_좌측_패널_방위_칸_없음_모달_안내(): ] 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 - ) + assert "placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ??" in layout + assert '(anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor))' in layout diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 9e609a8ef..93228518e 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -82,13 +82,13 @@ def test_실제_포인터_사건으로_팬(): def test_거래처_상자_항목_글(): data = { "site": {"mid_station_m": None, "mid_road": "비포장"}, - "base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "bases": [], "routes": [ - {"id": "g", "name": "거창군청", "address": "", "via_base": True, "dir": None, + {"id": "g", "name": "거창군청", "address": "", "to": "site", "dir": None, "legs": [], "rows": [{"item": "장비", "dest": "start", "note": ""}, {"item": " 장비 ", "dest": "mid", "note": ""}, {"item": "골재", "dest": "mid", "note": ""}]}, - {"id": "k", "name": "", "address": "", "via_base": True, "dir": None, + {"id": "k", "name": "", "address": "", "to": "site", "dir": None, "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]}, ], } # fmt: skip @@ -116,14 +116,14 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음(): assert "milestoneCardBody" not in _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") site = modal[modal.index("export function openSiteInfo") :] - assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site - assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site - # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음 + assert 'label: "중점 측점 (km)"' in site and 'label: "시점 → 중점 도로"' in site + assert 'setLeg(fresh, "start", 0, {' in site and "putMilestone(" in site + # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 254 여럿 · 삭제 = 그 기준점만 base = modal[ modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") ] - assert "enabled: true," in base and 'label: "삭제"' in base - assert "fresh.base = { enabled: false," in base + assert "fresh.bases.push({" in base and 'label: "삭제"' in base + assert "fresh.bases = fresh.bases.filter((b) => b.id !== base.id);" in base assert 'label: "기준점 +"' not in ui and "openBaseInfo" not in ui and "addBase" not in ui assert "content: panContent(vw, vh)" in ui and "panStart," in ui diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 30f715237..9523bf302 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -24,7 +24,10 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) FAKE_KEY = "fake-kakao-key-0123456789" @@ -144,7 +147,8 @@ def test_키_값_응답_로그에_안_나옴(monkeypatch, caplog): def _data(base_enabled=True, base_ll=(127.85, 35.66)): - return { + """254 옛 꼴로 적고 새 꼴(bases · to · 옛 기준점 = b1)로 옮겨 씀.""" + return migrate_milestone({ "site": {"mid_station_m": None, "mid_road": "비포장"}, "base": {"enabled": base_enabled, "name": "병곡리 968-31", "address": "", "dir": None, "lon": base_ll[0], "lat": base_ll[1], "legs": []}, @@ -156,7 +160,7 @@ def _data(base_enabled=True, base_ll=(127.85, 35.66)): "lon": None, "lat": None, "legs": [{"road": None, "km": None}], "rows": [{"item": "철근", "dest": "start", "note": ""}]}, ], - } # fmt: skip + }) # fmt: skip LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" @@ -179,27 +183,28 @@ def test_구간_채움(): two = _node(LAYOUT, f"kmSegments({d}, 'r2', null)") assert two == [{"key": "route", "text": "김천역 → 시점", "from": None, "to": None}] # 자동 km 넣기 — 빈 구간 = 안 고른 줄 하나 · 사용자 km 는 그대로 - put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 'route', 10.5), 'r1', 'base', 1.4)") + put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 10.5), 'b1', 1.4)") assert put["routes"][0]["legs"] == [{"road": "2차포장", "km": 23.8, "auto_km": 10.5}] - assert put["base"]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}] + assert put["bases"][0]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}] # 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값 lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)") line = {ln["from"]: ln for ln in lay["lines"]} - assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] - assert line["base"]["labels"] == ["도로 구분 고르기 1.4km"] and line["base"]["tips"] == [""] - assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 + # 254 선 글 두 줄(도로 구분 / km) + assert line["r1"]["labels"] == ["2차포장\n23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] + assert line["b1"]["labels"] == ["도로 구분 고르기\n1.4km"] and line["b1"]["tips"] == [""] + assert line["start"]["labels"] == ["비포장\n0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 def test_사용자_값_우선_고르기_표시(): data = _data() data["routes"][0]["legs"] = [{"road": "2차포장", "km": None, "auto_km": 10.5}] - data["base"]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}] + data["bases"][0]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}] a, b = compute_milestone(data, 1500.0) # 거래처 = 자동 10.5 · 기준점 = 사용자 0.6(자동 1.4 무시) · 중점 1.5 비포장 assert (a["s2"], a["s1"], a["un"], a["total"], a["pick"]) == (10.5, 0.6, 1.5, 12.6, False) # 152 도로 구분 안 고름 = 선 자리 포장 종류로 셈(pick 늘 거짓) — 빈 km = 0 assert (b["total"], b["pick"]) == (0, False) - data["base"]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리) + data["bases"][0]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리) a2, _ = compute_milestone(data, 1500.0) assert (a2["s1"], a2["total"], a2["pick"]) == (0.6, 12.6, False) # TS 짝 거울 @@ -210,7 +215,7 @@ def test_사용자_값_우선_고르기_표시(): # 저장 꼴 — auto_km · road null 받음 · 빠진 road = 옛 기본 2차포장 saved = ms.Milestone.model_validate(data).model_dump() assert saved["routes"][0]["legs"][0]["auto_km"] == 10.5 - assert saved["base"]["legs"][0]["road"] is None + assert saved["bases"][0]["legs"][0]["road"] is None assert ms.Leg.model_validate({"km": 1}).road == "2차포장" diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 8f0393f93..df41ff5d7 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -19,6 +19,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" @@ -46,9 +47,10 @@ def _read(rel): def _node(rels, script): src = "".join(IMPORTS.sub("", _read(rel)) for rel in rels) + # 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", src + "\n" + script], + "--input-type=module-typescript", "-"], input=src + "\n" + script, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr @@ -118,14 +120,14 @@ def test_표지_셋(): "routes": [{"id": "r1", "name": "거창군청", "lon": 127.9, "lat": 35.68, "via_base": True}, {"id": "r2", "name": "", "lon": None, "lat": None, "via_base": False}], } # fmt: skip - d = json.dumps(data) + d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1) got = _node( [LAYOUT], f"""const D = {d}; console.log(JSON.stringify([ mapMarks(D, "r1", [127.83, 35.64]), mapMarks(D, "r2", [127.83, 35.64]), - mapMarks({{ ...D, base: {{ ...D.base, enabled: false }} }}, "r1", null), + mapMarks({{ ...D, bases: [] }}, "r1", null), ]));""", ) assert got[0] == [ diff --git a/resources/tester/test_246_1_b09_bill_menu_simple.py b/resources/tester/test_246_1_b09_bill_menu_simple.py index 450d2dd66..4df6d88a6 100644 --- a/resources/tester/test_246_1_b09_bill_menu_simple.py +++ b/resources/tester/test_246_1_b09_bill_menu_simple.py @@ -16,11 +16,12 @@ ROOT = Path(__file__).resolve().parents[2] OLD_ADDS = ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가", "새 공종 추가(1단)"] -def test_입력_칸_테두리_없음(): +def test_입력_칸_채움_색_td_테두리_옅게(): css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") body = css.split(".ui-data-table__input {")[1].split("}")[0] assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body - assert "box-shadow" not in body and "is-can-edit" not in css + assert "box-shadow" not in body + assert "td.is-can-edit {" in css # 258-2 — 고칠 칸 옅은 outline 되살림 def test_더함_하나_줄_삭제_연결_이름_셋(): diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py index 2e1948356..3f303ed2f 100644 --- a/resources/tester/test_249_1_b08_milestone_capture_child.py +++ b/resources/tester/test_249_1_b08_milestone_capture_child.py @@ -29,7 +29,7 @@ LINES = [[[35.6866, 127.9095], [35.66, 127.85]]] MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}] if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로 - cap.page_html = lambda key, lines, marks: HTML + cap.page_html = lambda key, lines, marks, view=None: HTML print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read())))) sys.exit(0) diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index d444804b8..c965c4822 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -9,7 +9,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, road_of # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, + road_of, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -87,7 +91,9 @@ def test_153_지도_보기_유지_상자_색_끌어_옮김(): assert var in block[: block.index("}")], kind ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert 'if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift);' in ui - assert 'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui # 159 이름 + assert ( + 'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui + ) # 159 이름 def test_153_끌어_놓은_자리_레이아웃(): @@ -96,23 +102,27 @@ def test_153_끌어_놓은_자리_레이아웃(): "base": {"enabled": True, "name": "기준", "address": "", "dir": "W", "legs": [], "pos": [500, -40]}, "routes": [{"id": "r", "name": "a", "address": "", "via_base": False, "dir": "N", "legs": [], "rows": [], "pos": [-700, 300]}]} # fmt: skip - expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'base' || b.id === 'r').map((b) => [b.id, b.x, b.y])" + data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1) + expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'b1' || b.id === 'r').map((b) => [b.id, b.x, b.y])" out = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert out.returncode == 0, out.stderr - assert sorted(json.loads(out.stdout)) == [["base", 500, -40], ["r", -700, 300]] + assert sorted(json.loads(out.stdout)) == [["b1", 500, -40], ["r", -700, 300]] def test_154_연결_점_끌어_잇기(): ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert 'if (box.kind === "route") g.append(connectDot(box, shift));' in ui - assert 'const via = t === "base";' in ui and "route.via_base = via;" in ui + # 254 기준점 상자에도 연결 점 + assert ( + 'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui + ) + assert "route.to = t;" in ui # 254 놓은 기준점 id · 공사지 = "site" assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움 assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번 - assert "if (via === now) return;" in ui # 같은 연결 = 그대로 + assert "if (route.linked !== false && route.to === t) return;" in ui # 같은 연결 = 그대로 modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert ( 'createCheckbox("기준점 거침")' not in modal and "via_base: via.input.checked" not in modal @@ -145,7 +155,7 @@ def test_155_요소_추가_안_이은_거래처_수기_입력(): state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "if (owner?.manual) return" in state # 수기 입력 = 카카오 길찾기 안 함 layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") - assert "if (!route || route.linked === false) return null;" in layout + assert "if (!owner || owner.linked === false) return null;" in layout # 254 거래처 · 기준점 def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화(): diff --git a/resources/tester/test_253_1_haul_rows.py b/resources/tester/test_253_1_haul_rows.py new file mode 100644 index 000000000..514dcf8b4 --- /dev/null +++ b/resources/tester/test_253_1_haul_rows.py @@ -0,0 +1,60 @@ +"""253-1 거리이정표 행 1:1 → B09 운반공 줄 — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}`.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from B08_Quantity.B08_Quantity_Haul import haul_rows, route_bill_key # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 +from B09_Estimation.B09_Estimation_Router import haul_user # noqa: E402 + +LOGIC = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {}} +DATA = { + "site": {"mid_station_m": 2000, "mid_road": "비포장"}, + "base": {"enabled": False, "legs": []}, + "routes": [ + {"id": "h-sand", "name": "골재상", "haul": "sand", "via_base": False, + "legs": [{"road": "포장", "km": 25.1}], "rows": [{"item": "모래", "dest": "start"}]}, + {"id": "u1", "name": "석산", "ref": LOGIC, "via_base": False, + "legs": [{"road": "포장", "km": 10}, {"road": "비포장", "km": 0.8}], + "rows": [{"item": "혼합골재", "dest": "start"}, {"item": "깬돌", "dest": "mid"}]}, + ], +} # fmt: skip + + +def test_keys(): + assert route_bill_key({"id": "h-sand", "haul": "sand"}) == "haul:3" + assert route_bill_key({"id": "h-x", "haul": "remicon"}) == "haul:remicon" + assert route_bill_key({"id": "u1"}) == "haul:r-u1" + assert route_bill_key({"id": "u1"}, 1) == "haul:r-u1.1" + + +def test_rows_one_per_table_row(): + rows = haul_rows(DATA, compute_milestone(DATA, None)) + got = {r["key"]: r for r in rows} + assert list(got) == ["haul:3", "haul:r-u1", "haul:r-u1.1"] + assert (got["haul:3"]["L1"], got["haul:3"]["L2"], got["haul:3"]["user"]) == (25.1, 0, False) + assert (got["haul:r-u1"]["name"], got["haul:r-u1"]["L1"], got["haul:r-u1"]["L2"]) == ( + "혼합골재운반", 10, 0.8) # fmt: skip + assert got["haul:r-u1.1"]["L2"] == 2.8 # 중점 = 시점 → 중점 2 km 비포장 더함 + assert got["haul:r-u1"]["ref"] == LOGIC + + +def test_bill_items_user_rows(): + rows = haul_rows(DATA, compute_milestone(DATA, None)) + extra = haul_user(rows) + items = {i["key"]: i for i in haul_items(None, extra["haul_refs"], rows)} + assert ( + "haul:3" in items and items["haul:3"]["name"] == "모래운반" + ) # 자동 = 총괄자재 묶음 그대로 + user = items["haul:r-u1"] + assert (user["section"], user["name"], user["qty"], user["ref"]) == ( + "3", + "혼합골재운반", + None, + LOGIC, + ) + assert items["haul:r-u1.1"]["name"] == "깬돌운반" and items["haul:r-u1.1"]["ref"] == LOGIC + assert extra["haul_dist"]["haul:r-u1"] == {"L1": 10, "L2": 0.8} diff --git a/resources/tester/test_253_2_haul_cycle.py b/resources/tester/test_253_2_haul_cycle.py new file mode 100644 index 000000000..59bac4dcb --- /dev/null +++ b/resources/tester/test_253_2_haul_cycle.py @@ -0,0 +1,114 @@ +"""253-2 운반 사이클시간 짝 — 거창 25 설계서 단가산출 1308~1347행 재현 · Py ↔ TS 거울 · +로직 입력 이름 채움 · B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time, fill_given # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import hand_sources # noqa: E402 +from B09_Estimation.B09_Estimation_Store import ref_rows # noqa: E402 + +XLSX = next( + (ROOT / "resources/knowledge/original/실무문서").glob( + "01.산불거창*/01.내역서/00.25년_산불_거창*내역+수량.xlsx" + ), + None, +) +#: 칸 → 입력 이름(단가산출 시트 · 32.혼합골재 운반(덤프15톤)) +CELLS = {"S1318": "L1", "AR1318": "L2", "R1320": "V1", "R1321": "V2", "AQ1320": "V3", + "AQ1321": "V4", "K1323": "T", "AB1323": "rt", "Q1325": "f", "L1327": "qs", + "Z1327": "k", "AL1327": "Es", "BA1327": "Cms", "L1337": "t3", "X1337": "t4", + "AK1337": "t5", "AW1337": "t6"} # fmt: skip + + +@pytest.mark.skipif(XLSX is None, reason="거창 설계서 없음") +def test_거창_혼합골재_운반_재현(): + import openpyxl + + ws = openpyxl.load_workbook(XLSX, read_only=False)["단가산출"] + inputs = {} + for cell, name in CELLS.items(): + text = str(ws[cell].value) # =TEXT(25.1,"#,##0.#######") + inputs[name] = float(re.match(r"=TEXT\(([\d.]+),", text).group(1)) + # 식 줄이 같은 꼴인지(t2 · Cm) + assert ( + "(S1318)/(R1320)+(S1318)/(R1321)+(AR1318)/(AQ1320)+(AR1318)/(AQ1321)" in ws["BX1334"].value + ) + assert "BQ1331+BX1334+L1337+X1337+AK1337+AW1337" in ws["CE1339"].value + assert (inputs["L1"], inputs["L2"]) == (25.1, 0.8) + got = cycle_time(inputs) + assert (got["q"], got["n"], got["t1"]) == (8.82, 5.13, 5.33) + assert (got["t2"], got["cm"]) == (101.23, 109.78) + assert cycle_time({"L1": 25.1, "L2": 0.8}) == got # 기본값 = 거창 값 + + +CASES = [ + {"L1": 25.1, "L2": 0.8}, + {"L1": 3.333, "L2": 1.257, "V1": 20, "V2": 25, "V3": 7, "V4": 8, "t5": 3.77}, + {"L1": 0, "L2": 0, "t1": 2.5, "V3": 0}, + {"L1": 12.5, "L2": 4.05, "T": 24, "rt": 1.8, "f": 0.8, "qs": 1.2, "k": 0.9, "Es": 0.75}, +] + + +def test_거울_ts(): + calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts" + body = ( + f"\nconst cases = {json.dumps(CASES)};" + "\nconsole.log(JSON.stringify(cases.map((c) => cycleTime(c))));" + "\nconst ref = {given: {qt: 1, Cm: 9}, cycle: {L1: 99, t1: 7}, own: ['L1']};" + "\nconst got = fillGiven(ref, ['Cm', 't2', '운반거리'], {L1: 25.1, L2: 0.8});" + "\nconsole.log(JSON.stringify(got));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + ts, ts_fill = (json.loads(line) for line in result.stdout.strip().splitlines()) + assert ts == [cycle_time(c) for c in CASES] + ref = {"given": {"qt": 1, "Cm": 9}, "cycle": {"L1": 99, "t1": 7}, "own": ["L1"]} + assert ts_fill == fill_given(ref, ["Cm", "t2", "운반거리"], {"L1": 25.1, "L2": 0.8}) + + +def test_입력_이름_채움(): + ref = {"given": {"qt": 15}, "cycle": {}} + got = fill_given(ref, ["qt", "Cm", "지역"], {"L1": 25.1, "L2": 0.8}) + assert got == {"qt": 15, "Cm": 109.78} + assert fill_given(ref, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"qt": 15, "운반거리": 25.9} + assert fill_given(ref, ["사이클시간", "t2"], {"L1": 25.1, "L2": 0.8}) == { + "qt": 15, "사이클시간": 109.78, "t2": 101.23} # fmt: skip + # 덮은 칸 = 그 값 · 덮은 L1 = 거리 대신 사이클 값 + own = {"given": {"Cm": 50}, "own": ["Cm"]} + assert fill_given(own, ["Cm"], {"L1": 25.1, "L2": 0.8}) == {"Cm": 50} + mine = {"given": {}, "cycle": {"L1": 10}, "own": ["L1"]} + assert fill_given(mine, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"운반거리": 10.8} + + +def test_서버_셈_거리로_다시_채움(): + ref = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {"Cm": 1}, "cycle": {}} + user = {"haul_refs": {"haul:r-u1": ref}, "haul_dist": {"haul:r-u1": {"L1": 25.1, "L2": 0.8}}} + ((_key, _use, src),) = hand_sources(user) + assert src["given"]["Cm"] == 109.78 + user["haul_dist"]["haul:r-u1"] = {"L1": 10, "L2": 0} # 거리 바뀌면 따라감 + ((_key, _use, src),) = hand_sources(user) + assert src["given"]["Cm"] == 45.69 and "운반거리" not in src["given"] # 그 로직 입력만 + + +def test_덮개_ref_사이클_남김(): + from B09_Estimation.B09_Estimation_Store import _clean_ref + + ref = {"tab": "logic", "code": "GF000134", "given": {"Cm": 3}, "cycle": {"V1": 20, "x": "a"}, + "own": ["Cm", 3]} # fmt: skip + got = _clean_ref(ref) + assert got["cycle"] == {"V1": 20} and got["own"] == ["Cm"] + rows = ref_rows({"bill": {"rows": [{"key": "haul:r-u1", "ref": got}]}}) + assert rows[0]["ref"]["cycle"] == {"V1": 20} diff --git a/resources/tester/test_254_1_b08_milestone_bases.py b/resources/tester/test_254_1_b08_milestone_bases.py new file mode 100644 index 000000000..5cccd62e7 --- /dev/null +++ b/resources/tester/test_254_1_b08_milestone_bases.py @@ -0,0 +1,213 @@ +"""254-1 B08 거리이정표 여러 기준점(사용자 162-10) — `bases[]` · 경로 `to` · 옛 꼴 옮김 · +기준점 사슬 셈(파이썬 · TS 거울) · 돌기 막음 · +도로 구분(시점에 닿는 기준점 구간 = 1차 · 그 밖 = 2차). + +견본 사슬 = 거창군청 → 기준점 A(병곡리) → 기준점 B(신원) → 시점 · 김천역 → B → 시점 · +철근 상회 → 시점(직접). +""" + +import json +import re +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Haul import with_haul_routes # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + base_chain, + compute_milestone, + migrate_milestone, +) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(files, expr): + src = "\n".join(IMPORTS.sub("", _read(f)) for f in files) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _leg(road, km): + return {"road": road, "km": km} + + +def _base(bid, to, legs, linked=True): + return {"id": bid, "name": bid, "address": "", "dir": None, "legs": legs, "to": to, + "linked": linked} # fmt: skip + + +def _route(rid, to, legs, dest="start"): + return {"id": rid, "name": rid, "address": "", "dir": None, "to": to, "legs": legs, + "rows": [{"item": "장비", "dest": dest, "note": ""}]} # fmt: skip + + +CHAIN = { + "site": {"mid_station_m": 800, "mid_road": "비포장"}, + "bases": [ + _base("A", "B", [_leg(None, 4.5), _leg("비포장", 0.3)]), + _base("B", "site", [_leg(None, 0.6)]), + ], + "routes": [ + _route("g", "A", [_leg(None, 23.8)]), + _route("k", "B", [_leg(None, 61.2)], dest="mid"), + _route("c", "site", [_leg(None, 2.0)]), + ], +} + +OLD = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "address": "주소", "dir": "E", "lon": 127.85, + "lat": 35.66, "pos": [10, 20], "legs": [_leg("1차포장", 0.6)]}, + "routes": [ + {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "legs": [_leg("2차포장", 23.8)], "rows": [{"item": "장비", "dest": "start", "note": ""}]}, + {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None, + "legs": [_leg(None, 5)], "rows": [{"item": "철근", "dest": "start", "note": ""}]}, + ], +} # fmt: skip + + +def test_옛_꼴_옮김_거울(): + new = migrate_milestone(OLD) + assert "base" not in new and [b["id"] for b in new["bases"]] == ["b1"] + b1 = new["bases"][0] + assert (b1["to"], b1["linked"], b1["name"], b1["pos"]) == ("site", True, "병곡리", [10, 20]) + assert b1["legs"] == [_leg("1차포장", 0.6)] and b1["lon"] == 127.85 + assert [(r["id"], r["to"], "via_base" in r) for r in new["routes"]] == [ + ("r1", "b1", False), + ("r2", "site", False), + ] + # 꺼진 기준점 = bases 없음 · 거침 경로도 시점으로 + off = migrate_milestone({**OLD, "base": {**OLD["base"], "enabled": False}}) + assert off["bases"] == [] and [r["to"] for r in off["routes"]] == ["site", "site"] + assert migrate_milestone(new) == new # 새 꼴 = 그대로 + # TS 짝 — 옮긴 꼴 · 표 합이 같음 + calc = [B08 + "Milestone_Calc.ts"] + ts = _node(calc, f"migrateMilestone({json.dumps(OLD)})") + assert ts["bases"][0]["id"] == "b1" and [r["to"] for r in ts["routes"]] == ["b1", "site"] + assert ts["bases"][0]["legs"] == b1["legs"] and ts["bases"][0]["pos"] == [10, 20] + assert _node(calc, f"computeMilestone({json.dumps(OLD)}, 1500)") == compute_milestone( + OLD, 1500.0 + ) + # 옛 표 합 그대로 — 2차 23.8 + 1차 0.6 + (a, b) = compute_milestone(OLD, 1500.0) + assert (a["s2"], a["s1"], a["total"]) == (23.8, 0.6, 24.4) and (b["pv"], b["total"]) == (5, 5) + + +def test_서버_읽기_받기_새_꼴(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + # 옛 파일 읽기 = 새 꼴(지우지 않음 · 저장 때 새 꼴) + folder = tmp_path / "B08_Quantity" / "milestone" + folder.mkdir(parents=True) + (folder / "milestone.json").write_text(json.dumps(OLD, ensure_ascii=False), "utf-8") + data = ms.read_data(tmp_path) + assert [b["id"] for b in data["bases"]] == ["b1"] and data["routes"][0]["to"] == "b1" + assert "base" not in data and "via_base" not in data["routes"][0] + + async def root(_pid): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + url = f"/api/projects/{uuid4()}/quantity/milestone" + saved = client.put(url, json=OLD).json() # 옛 꼴 받기 = 새 꼴로 저장 + stored = json.loads((folder / "milestone.json").read_text("utf-8")) + assert "base" not in stored and stored["bases"][0]["id"] == "b1" + assert stored["routes"][0]["to"] == "b1" and "via_base" not in stored["routes"][0] + assert saved["table"] == compute_milestone(OLD, None) + # 새 꼴 사슬 왕복 · 겹친 id · 공사지 상자 id = 거절 + assert client.put(url, json=CHAIN).json()["data"]["bases"][0]["to"] == "B" + twice = {**CHAIN, "bases": [*CHAIN["bases"], _base("g", "site", [])]} + assert client.put(url, json=twice).status_code == 422 + box = {**CHAIN, "bases": [_base("site", "site", [])]} + assert client.put(url, json=box).status_code == 422 + # 운반 자동 상자 = 시점으로(안 이음) + (auto,) = with_haul_routes({"routes": []}, [{"key": "1", "name": "골재"}])["routes"] + assert auto["to"] == "site" and auto["linked"] is False and "via_base" not in auto + + +def test_기준점_둘_사슬_셈_거울(): + g, k, c = compute_milestone(CHAIN, None) + # 거래처 → A = 2차 · A → B(기준점 → 기준점) = 2차 · B → 시점 = 1차 · 비포장은 사용자 값 + assert (g["s2"], g["s1"], g["pv"], g["un"]) == (28.3, 0.6, 0, 0.3) + assert (g["paved"], g["unpaved"], g["total"]) == (28.9, 0.3, 29.2) + # 김천역 → B = 2차 · B → 시점 = 1차 · 목적지 중점 = 시점 → 중점 0.8 비포장 + assert (k["s2"], k["s1"], k["un"], k["total"]) == (61.2, 0.6, 0.8, 62.6) + # 시점으로 바로 = 포장 + assert (c["s2"], c["s1"], c["pv"], c["total"]) == (0, 0, 2.0, 2.0) + calc = [B08 + "Milestone_Calc.ts"] + assert _node(calc, f"computeMilestone({json.dumps(CHAIN)}, null)") == [g, k, c] + # 안 이은 기준점에서 사슬 멈춤(그 구간 · 뒤 구간 안 셈) + cut = {**CHAIN, "bases": [CHAIN["bases"][0], {**CHAIN["bases"][1], "linked": False}]} + g2, k2, _ = compute_milestone(cut, None) + assert (g2["s2"], g2["s1"], g2["un"], g2["total"]) == (28.3, 0, 0.3, 28.6) + assert (k2["s2"], k2["total"]) == (61.2, 62.0) + assert _node(calc, f"computeMilestone({json.dumps(cut)}, null)") == compute_milestone(cut, None) + # 선 자리 — 시점에 닿는 기준점 = base(1차) · 기준점 → 기준점 · 거래처 → 기준점 = via(2차) + kinds = _node( + calc, f"['g', 'A', 'B', 'c', 'start'].map((f) => segKind({json.dumps(CHAIN)}, f))" + ) + assert kinds == ["via", "via", "base", "direct", "mid"] + + +def test_돌기_막음(): + loop = {**CHAIN, "bases": [_base("A", "B", [_leg(None, 1)]), _base("B", "A", [_leg(None, 2)])]} + # 셈 = 방문 집합으로 끊음(A → B → A 멈춤 · 끝없이 안 돎) + assert [b["id"] for b in base_chain(loop, "A")] == ["A", "B"] + (g, *_) = compute_milestone(loop, None) + assert g["total"] == 26.8 + calc = [B08 + "Milestone_Calc.ts"] + assert _node(calc, f"computeMilestone({json.dumps(loop)}, null)") == compute_milestone( + loop, None + ) + self_loop = {**CHAIN, "bases": [_base("A", "A", [_leg(None, 1)])]} + assert [b["id"] for b in base_chain(self_loop, "A")] == ["A"] + # 연결 놓을 때 = 돌기면 취소(makesLoop) — A → B 사슬에서 B 를 A 에 이으면 돎 + layout = [B08 + "Milestone_Layout.ts"] + d = json.dumps(CHAIN) + got = _node( + layout, + f"[makesLoop({d}, 'B', 'A'), makesLoop({d}, 'A', 'B'), makesLoop({d}, 'B', 'site')," + f" makesLoop({d}, 'A', 'A'), chainOf({d}, 'A').map((b) => b.id)," + f" kmSegments({d}, 'g', [127.8, 35.6]).map((s) => s.text)]", + ) + assert got[:4] == [True, False, False, True] + assert got[4] == ["A", "B"] and got[5] == ["g → A", "A → B", "B → 시점"] + + +def test_화면_기준점_여럿_등록_삭제(): + modal = _read(B08 + "UI_Milestone_Modal.ts") + assert "fresh.bases.push({" in modal and 'to: "site",' in modal + assert "기준위치(있음" not in modal # 254 기준점 여럿 — 고르기 막음 없음 + # 지운 기준점에 이은 상자 = 안 이음 + assert "if (o.to === base.id)" in modal + state = _read(B08 + "UI_Milestone_State.ts") + assert "migrateMilestone(" in state and "bases: []," in state + page = _read(B08 + "UI_Milestone.ts") + assert "data.bases.length > 0" in page and 'to: "site",' in page + for name in ("Milestone_Calc.ts", "Milestone_Layout.ts", "UI_Milestone_Diagram.ts", + "UI_Milestone_Modal.ts", "UI_Milestone_State.ts", "UI_Milestone_LegModal.ts", + "UI_Milestone_Route.ts", "UI_Milestone.ts"): # fmt: skip + text = _read(B08 + name) + assert "data.base." not in text and ".via_base" not in text and ".base.legs" not in text diff --git a/resources/tester/test_254_2_b08_milestone_diagram.py b/resources/tester/test_254_2_b08_milestone_diagram.py new file mode 100644 index 000000000..73f092158 --- /dev/null +++ b/resources/tester/test_254_2_b08_milestone_diagram.py @@ -0,0 +1,129 @@ +"""254-2 B08 거리이정표 다이어그램(사용자 162-4 · 5 · 6 · 10) — 기준점 상자 연결 점 · 주소 · +연결 점 왼쪽 위 안쪽 · 항목 글 = 거래처 글 · 주소 줄바꿈 + 상자 높이 · 선 글 두 줄 · +선 길이 = 거리 비례(로그 · 최소 · 최대). +""" + +import json +import math +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" +LONG = "경상남도 거창군 북상면 병곡리 968-31" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = IMPORTS.sub("", _read(B08 + "Milestone_Layout.ts")) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-"], + input=src + f"\nconsole.log(JSON.stringify({expr}));", + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _leg(km): + return {"road": None, "km": km} + + +def _route(rid, dir, km, to="site", address=""): + return {"id": rid, "name": rid, "address": address, "dir": dir, "to": to, + "legs": [_leg(km)] if km is not None else [], + "rows": [{"item": "장비", "dest": "start", "note": ""}]} # fmt: skip + + +def _base(bid, to, km, address="", lon=None, lat=None): + return {"id": bid, "name": bid, "address": address, "dir": None, "to": to, "lon": lon, + "lat": lat, "legs": [_leg(km)], "linked": True} # fmt: skip + + +def _data(bases, routes): + return {"site": {"mid_station_m": None, "mid_road": "비포장"}, "bases": bases, "routes": routes} + + +def _lay(data): + return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, null, 0.5)") + + +def _boxes(lay): + return {b["id"]: b for b in lay["boxes"]} + + +def test_선_길이_거리_비례_최소_최대(): + kms = [0, 1, 10, 23.8, 100, 1e6, -3] + got = _node(f"{json.dumps(kms)}.map(lineLen)") + want = [min(320, max(100, 100 + 90 * math.log10(1 + max(0, k)))) for k in kms] + assert all(abs(a - b) < 1e-9 for a, b in zip(got, want)) + assert got[0] == 100 and got[5] == 320 and got[6] == 100 # 최소 · 최대(너무 멀지 않게) + assert got[0] < got[1] < got[2] < got[3] < got[4] + # 동쪽 거래처 셋 — 공사지 테두리 → 상자 테두리 = 그 구간 선 길이 + lay = _lay(_data([], [_route("a", "E", 1), _route("b", "S", 60), _route("c", "W", None)])) + boxes = _boxes(lay) + site = boxes["site"] + a, b, c = boxes["a"], boxes["b"], boxes["c"] + assert abs((a["x"]) - (site["x"] + site["w"]) - got[1]) < 1e-6 + assert abs(b["y"] - (site["y"] + site["h"]) - _node("lineLen(60)")) < 1e-6 + assert abs(site["x"] - (c["x"] + c["w"]) - 100) < 1e-6 # 빈 km = 최소 + + +def test_기준점_사슬_자리_주소_높이(): + bases = [_base("A", "B", 4.5, address=LONG), _base("B", "site", 0.6, address="거창읍")] + lay = _lay(_data(bases, [_route("g", "N", 23.8, to="A")])) + boxes = _boxes(lay) + a, b, g = boxes["A"], boxes["B"], boxes["g"] + assert (a["kind"], b["kind"]) == ("base", "base") + # 기준점 상자 = 주소(줄바꿈) · 높이 = 이름 + 주소 줄 + assert a["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"] and a["tip"] == LONG + assert a["h"] == 3 * 15 + 13 and b["notes"] == ["거창읍"] and b["h"] == 58 + # B = 공사지 서쪽(좌표 없음) · A = B 둘레(B 에 이음) · g = A 북쪽 + site = boxes["site"] + assert b["x"] + b["w"] < site["x"] and a["x"] + a["w"] < b["x"] + b["w"] + assert g["y"] + g["h"] < a["y"] + lines = {(ln["from"], ln["to"]) for ln in lay["lines"]} + assert {("g", "A"), ("A", "B"), ("B", "start"), ("start", "mid")} == lines + # 아주 긴 주소 = 최대 3줄 · 끝 줄 줄임표 + many = " ".join([LONG] * 4) + rows = _node(f'wrap("{many}", 132, 10)') + assert len(rows) == 3 and rows[-1].endswith("…") + assert all(_node(f'textW("{r}", 10)') <= 132 for r in rows) + + +def test_선_글_두_줄(): + lay = _lay(_data([_base("B", "site", 0.6)], [_route("g", "N", 23.8, to="B")])) + line = next(ln for ln in lay["lines"] if ln["from"] == "B") + assert line["labels"] == ["1차포장\n0.6km"] or line["labels"] == ["도로 구분 고르기\n0.6km"] + assert line["lh"] == 2 * 16 + 2 # 두 줄 높이 + two = _node("legText([{road: '1차포장', km: 0.6}, {road: '비포장', km: null}])") + assert two == ["1차포장\n0.6km", "비포장\n-km"] + ui = _read(B08 + "UI_Milestone_Diagram.ts") + assert 'for (const row of label.split("\\n"))' in ui # 줄마다 tspan + assert "span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;" in ui # 두 줄 다 그 구간 + + +def test_연결_점_왼쪽_위_안쪽_기준점도_돌기_취소(): + ui = _read(B08 + "UI_Milestone_Diagram.ts") + dot = ui[ui.index("const connectDot") :] + dot = dot[: dot.index("return dot;")] + # 왼쪽 위 안쪽 — 가운데 (10, 10) · 반지름 6 + 테두리 2 = 상자 안 + assert "const cx = box.x + 10;" in dot and "const cy = box.y + 10;" in dot and "r: 6" in dot + assert ( + 'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui + ) + assert 'if (box.kind === "base" && makesLoop(fresh, box.id, t))' in dot + assert "if (id === box.id) return null;" in dot # 자기 = 취소 + assert "route.to = t;" in dot and "route.linked = true;" in dot + css = _read(B08 + "UI_Milestone.css") + items = css[css.index(".b08-milestone__items {") :] + assert "font-size: 13px;" in items[: items.index("}")] # 항목 = 거래처 글 + assert "items: 13" in _read(B08 + "Milestone_Layout.ts") + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"): + assert len(_read(B08 + name).splitlines()) <= 700, name diff --git a/resources/tester/test_255_1_b08_milestone_site_modal.py b/resources/tester/test_255_1_b08_milestone_site_modal.py new file mode 100644 index 000000000..e944a6d05 --- /dev/null +++ b/resources/tester/test_255_1_b08_milestone_site_modal.py @@ -0,0 +1,51 @@ +"""255-1 거리이정표 시점 모달 — 중점 측점 km 로 보이고 받음(저장은 m) · 설명 줄 지움 · +지도 = 공용 카카오 지도(시점 · 중점 표지).""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _site(km): + data = {"site": {"mid_station_m": 999, "mid_road": "비포장"}, "base": {}, "routes": []} + return _node(f'setLeg({json.dumps(data)}, "start", 0, {{ km: {json.dumps(km)} }}).site') + + +def test_중점_km_저장_m(): + assert _site(1.2345)["mid_station_m"] == 1234.5 + assert _site(0.0005)["mid_station_m"] == 0.5 + assert _site(None)["mid_station_m"] is None # 빈칸 = 노선 ÷ 2 + + +def test_시점_모달_km_카카오_지도(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + site = modal[modal.index("export function openSiteInfo") :] + assert 'label: "중점 측점 (km)"' in site + assert "String(data.site.mid_station_m / 1000)" in site # 보임 = km + assert 'setLeg(fresh, "start", 0, {' in site # 받음 km → 저장 m + # 설명 줄 · 등록 주소 · 브이월드 주소 줄 · B01 타일 지도 없음 + for gone in ("시점 = 노선 첫 점", "프로젝트 등록 주소", "브이월드 주소", "buildAddressMap"): + assert gone not in modal + assert "editMap(projectId" in site + assert '["start", "시점", s.start_lonlat]' in site and '["mid", "중점", s.mid_lonlat]' in site + assert 'dialogClass: "b08-milestone__modal"' in site # 다른 모달과 같은 창 · 지도 높이 diff --git a/resources/tester/test_255_2_b08_milestone_start_pin.py b/resources/tester/test_255_2_b08_milestone_start_pin.py new file mode 100644 index 000000000..bf149c7e4 --- /dev/null +++ b/resources/tester/test_255_2_b08_milestone_start_pin.py @@ -0,0 +1,119 @@ +"""255-2 거리이정표 경로 탭 — 시점 끌기 = 길찾기용 덮개(site.start_pin · 노선 그대로) · +표지 옮기면 그 자리에 닿는 구간(나가는 · 들어오는) 모두 다시 길찾기 · 열 때 사슬 구간 모두 선 · +실패 안내 · 끌기는 그림 캡처 안 부름.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from pydantic import ValidationError + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +STUBS = """ +let cache = null; +const sent = []; +const API_BASE_URL = ""; +const showToast = () => undefined; +const readState = () => cache; +const writeState = (_key, d) => { cache = d; }; +const clearState = () => { cache = null; }; +const request = async (url, opts) => { + if (url.endsWith("/quantity/milestone")) + return { data: DATA, site: { length_m: 1000, mid_default_m: 500, start_lonlat: [127, 35], + mid_lonlat: null }, images: {}, auto_images: {} }; + sent.push(JSON.parse(opts.body)); + return { km: 1.5, reason: null, path: [] }; +}; +""" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _place(pid, to, lon, lat, **more): + return {"id": pid, "name": pid, "address": "", "dir": None, "lon": lon, "lat": lat, + "legs": [], "to": to, **more} # fmt: skip + + +DATA = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "bases": [_place("b1", "site", 127.5, 35.5), _place("b2", "b1", 127.6, 35.6)], + "routes": [ + _place("r1", "site", 127.2, 35.2, rows=[]), + _place("r2", "b1", 127.3, 35.3, rows=[]), + _place("r3", "b2", 127.4, 35.4, rows=[]), + _place("r4", "b1", 127.1, 35.1, rows=[], linked=False), + ], +} + + +def _run(script): + src = "".join( + IMPORTS.sub("", _read(f"B08_Quantity/B08_Quantity_{n}.ts")) + for n in ("Milestone_Calc", "Milestone_Layout", "UI_Milestone_State") + ) + code = f"const DATA = {json.dumps(DATA)};\n{STUBS}\n{src}\n{script}" + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=code, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_시점_덮개_길찾기(): + got = _run(""" +await loadMilestone("p"); +await findSegmentKm("p", "r1", false); +const d = liveMilestone("p"); +d.site.start_pin = { lon: 128.25, lat: 36.5 }; +putMilestone("p", d); +await findSegmentKm("p", "r1", false); +await findSegmentKm("p", "b1", false); +console.log(JSON.stringify({ sent, start: startPoint(liveMilestone("p")) })); +""") + dest = [s["destination"] for s in got["sent"]] + assert dest[0] == {"lon": 127, "lat": 35} # 덮개 없음 = 노선 첫 점 + assert dest[1] == dest[2] == {"lon": 128.25, "lat": 36.5} # 거래처 · 기준점 → 시점 = 덮개 + assert got["start"] == [128.25, 36.5] + + +def test_닿는_구간_모두(): + got = _run(""" +console.log(JSON.stringify(["start", "b1", "b2", "r1", "r3"].map((k) => touchingSegments(DATA, k)))); +""") + start, b1, b2, r1, r3 = got + assert start == ["b1", "r1"] # 시점에 들어오는 구간 + assert b1 == ["b1", "b2", "r2"] # 나가는 구간 + 들어오는 경로들(안 이은 r4 빠짐) + assert b2 == ["b2", "r3"] + assert r1 == ["r1"] and r3 == ["r3"] + + +def test_서버_모델_시점_덮개(): + got = ms.Milestone.model_validate({"site": {"start_pin": {"lon": 127.5, "lat": 35.5}}}) + assert got.model_dump()["site"]["start_pin"] == {"lon": 127.5, "lat": 35.5} + assert ms.Milestone().model_dump()["site"]["start_pin"] is None + with pytest.raises(ValidationError): + ms.Milestone.model_validate({"site": {"start_pin": {"lon": 500, "lat": 35}}}) + + +def test_경로_탭_끌기_다시_길찾기(): + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "movable: true" in route and 'movable: key !== "start"' not in route + assert "fresh.site.start_pin = { lon, lat };" in route + assert "await refind(touchingSegments(fresh, key));" in route + assert "findSegmentKm(projectId, id, false)" in route # 끌기 · 열기 = 그림 캡처 없음 + assert "[routeId, ...chain].filter((id) => !segmentPath(id).length)" in route # 열 때 모든 구간 + assert "status.hidden = !bad.length;" in route # 실패 안내 + calc = _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts") + assert "start_pin?: { lon: number; lat: number } | null;" in calc diff --git a/resources/tester/test_256_1_b08_milestone_place_image.py b/resources/tester/test_256_1_b08_milestone_place_image.py new file mode 100644 index 000000000..316947fd8 --- /dev/null +++ b/resources/tester/test_256_1_b08_milestone_place_image.py @@ -0,0 +1,160 @@ +"""256-1 B08 거리이정표 장소 그림 영구 저장(사용자 162-6 · 7). + +캡처는 가짜(make_place_image 자리)로: +① 장소 그림 페이지 = 480×320 · 고정 줌 · 경로 맞춤 안 함 +② [저장] 뒤 좌표 있는 상자(거래처 = 경로 id · 기준점 = b1)만 캡처 → place/.png · + GET 답 place_images · place_times +③ 다시 [저장] = 바뀐 상자 · 그림 없는 상자만 · 실패 = 다음에 다시 · 지문에 좌표 원문 없음 +④ 지운 상자 그림 = [저장] 때 지움 · 화면 캡처 길(place-image) · 그림 파일 길(file · 경로 넘기 막음) +""" + +import sys +from pathlib import Path +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +PNG = b"\x89PNG\r\n\x1a\nfake-place" +DATA = { + "base": {"enabled": True, "name": "거창IC", "lon": 127.91, "lat": 35.68}, + "routes": [ + {"id": "r1", "name": "거창레미콘", "lon": 127.9095, "lat": 35.6866}, + {"id": "r2", "name": "좌표 없음"}, + ], +} + + +class Fake: + """가짜 캡처 — 부른 상자 기록 · ok=False 면 실패.""" + + def __init__(self): + self.calls = [] + self.ok = True + + def __call__(self, out_dir, place_id, mark): + self.calls.append((place_id, mark["name"], mark["lon"], mark["lat"])) + if not self.ok: + return {"saved": False, "reason": "장소 그림 자동 캡처 실패 — 시험"} + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / f"{place_id}.png").write_bytes(PNG) + return {"saved": True, "reason": None} + + +def _save(root, data): + return ms.save_data(root, ms.Milestone.model_validate(data)) + + +def _fake(monkeypatch): + fake = Fake() + monkeypatch.setattr(ms, "make_place_image", fake) + return fake + + +def test_장소_그림_페이지_작은_지도(): + html = cap.page_html( + "k", [], [{"name": "거창레미콘", "lon": 127.9, "lat": 35.6}], cap.PLACE_VIEW + ) + assert "width:480px;height:320px" in html and "level: 4" in html + assert "if (false)" in html # 경로 맞춤 안 함 — 표지 가운데 · 고정 줌 + assert "if (true)" in cap.page_html("k", [], []) # 경로 그림은 그대로 + + +def test_저장_뒤_좌표_있는_상자만_캡처(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + assert sorted(c[0] for c in fake.calls) == ["b1", "r1"] + got = ms.payload(tmp_path, ms.read_data(tmp_path)) + assert got["place_images"] == {"b1": "place/b1.png", "r1": "place/r1.png"} + assert set(got["place_times"]) == {"b1", "r1"} + assert all(isinstance(t, int) and t > 0 for t in got["place_times"].values()) + assert (tmp_path / "B08_Quantity/milestone/place/r1.png").read_bytes() == PNG + prints = (tmp_path / "B08_Quantity/milestone/.prints.json").read_text(encoding="utf-8") + assert "127.9095" not in prints and "35.6866" not in prints # 해시만 + + +def test_바뀐_상자_그림_없는_상자만_다시(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + fake.calls.clear() + ms.refresh_images(tmp_path) + assert fake.calls == [] # 안 바뀜 = 캡처 0 + moved = {**DATA, "routes": [{**DATA["routes"][0], "lat": 35.7}, DATA["routes"][1]]} + _save(tmp_path, moved) + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["r1"] + fake.calls.clear() + (tmp_path / "B08_Quantity/milestone/place/b1.png").unlink() + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["b1"] + # 실패 = 지문 안 적음 → 다음에 다시 + fake.calls.clear() + fake.ok = False + _save(tmp_path, {**moved, "base": {**DATA["base"], "lon": 127.95}}) + ms.refresh_images(tmp_path) + ms.refresh_images(tmp_path) + assert [c[0] for c in fake.calls] == ["b1", "b1"] + + +def test_지운_상자_그림은_저장_때_지움(tmp_path, monkeypatch): + _fake(monkeypatch) + _save(tmp_path, DATA) + ms.refresh_images(tmp_path) + got = _save(tmp_path, {"routes": [DATA["routes"][1]]}) # r1 · 기준점 좌표 지움 + assert got["place_images"] == {} and got["place_times"] == {} + assert not list((tmp_path / "B08_Quantity/milestone/place").iterdir()) + + +def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + + async def root(project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + base = f"/api/projects/{uuid4()}/quantity/milestone" + # [저장] = 답 뒤 캡처(BackgroundTasks — TestClient 는 답 전에 끝냄) + assert client.put(base, json=DATA).status_code == 200 + assert sorted(c[0] for c in fake.calls) == ["b1", "r1"] + assert client.get(base).json()["place_images"]["r1"] == "place/r1.png" + # 등록 · 끌기 뒤 화면이 부름 + fake.calls.clear() + mark = {"name": "새 거래처", "lon": 127.8, "lat": 35.5} + res = client.post(base + "/place-image", params={"id": "r9"}, json=mark).json() + assert res["name"] == "place/r9.png" and res["reason"] is None and res["time"] > 0 + assert fake.calls == [("r9", "새 거래처", 127.8, 35.5)] + fake.ok = False + bad = client.post(base + "/place-image", params={"id": "r8"}, json=mark).json() + assert bad == {"name": None, "time": None, "reason": "장소 그림 자동 캡처 실패 — 시험"} + assert client.post(base + "/place-image", params={"id": "../x"}, json=mark).status_code == 400 + # 그림 파일 길 — 하위 폴더 둘 · 경로 넘기 막음 + assert client.get(base + "/file", params={"name": "place/r1.png"}).content == PNG + assert client.get(base + "/file", params={"name": "place/zz.png"}).status_code == 404 + for name in ("../milestone.json", "place/../x.png", "x/r1.png", "place/r1.json", "/etc/x.png"): + assert client.get(base + "/file", params={"name": name}).status_code == 422, name + + +def test_여러_기준점_상자_id(tmp_path, monkeypatch): + fake = _fake(monkeypatch) + bases = [ + {"id": "b1", "name": "거창IC", "lon": 127.91, "lat": 35.68, "to": "b2"}, + {"id": "b2", "name": "함양JC", "lon": 127.72, "lat": 35.52}, + {"id": "b3", "name": "좌표 없음"}, + ] + _save(tmp_path, {"bases": bases, "routes": [{**DATA["routes"][0], "to": "b1"}]}) + ms.refresh_images(tmp_path) + assert sorted(c[0] for c in fake.calls) == ["b1", "b2", "r1"] + got = _save(tmp_path, {"bases": bases[1:], "routes": []}) # b1 · r1 지움 + assert got["place_images"] == {"b2": "place/b2.png"} diff --git a/resources/tester/test_258_1_table_width_var.py b/resources/tester/test_258_1_table_width_var.py new file mode 100644 index 000000000..7831b90d7 --- /dev/null +++ b/resources/tester/test_258_1_table_width_var.py @@ -0,0 +1,52 @@ +"""258-1 · 258-2 · 258-3 — 표 폭 CSS 변수 · 입력 칸 테두리 옅게 · 구조물 조합 미지정 회색(소스 시험). + +- 템플릿 표 인라인 = `--dt-w`(열 합) · css `width: var(--dt-w)` → 페이지 css 가 width 로 덮음 +- 거리이정표 표 = 틀 폭 100% +- 고칠 칸 td.is-can-edit = accent 28% outline · box-shadow 아님 +- B05 미지정 = 회색(text-muted) +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DT_CSS = ROOT / "ui_template" / "ui_template_data_table.css" +DT_TS = ROOT / "ui_template" / "ui_template_data_table.ts" + + +def _rule(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r" \{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_표_폭_인라인_변수_css_width_변수(): + ts = DT_TS.read_text("utf-8") + assert '' in ts + assert 'style="width:${x}px"' not in ts # 인라인 width 가 페이지 css 를 이기지 않음 + body = _rule(DT_CSS.read_text("utf-8"), ".ui-data-table__table") + assert "width: var(--dt-w);" in body + assert "table-layout: fixed;" in body + + +def test_거리이정표_표_틀_폭_전체(): + css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone.css").read_text("utf-8") + body = _rule(css, ".b08-milestone > .ui-data-table .ui-data-table__table") + assert "width: 100%;" in body # 변수 규칙(0,1,0)보다 선택자가 구체적 + + +def test_입력_칸_테두리_옅게_outline(): + css = DT_CSS.read_text("utf-8") + body = _rule(css, ".ui-data-table__table td.is-can-edit") + m = re.search(r"var\(--color-accent\) (\d+)%", body) + assert m and 25 <= int(m.group(1)) <= 30 # 예전 60% → 옅게 + assert "outline: 1px solid" in body and "box-shadow" not in body + assert '"is-can-edit"' in DT_TS.read_text("utf-8") + + +def test_구조물_조합_미지정_회색(): + css = (ROOT / "B05_Profile" / "B05_Profile_UI_Style_Structures.css").read_text("utf-8") + body = _rule(css, ".b05-structure__summary-name:not(.is-assigned)") + assert "border-color: var(--color-text-muted);" in body + assert "--color-text-muted" in body.split("background:")[1] + assert "--color-warning" not in body diff --git a/resources/tester/test_259_detail_region.py b/resources/tester/test_259_detail_region.py new file mode 100644 index 000000000..4a0c3b66e --- /dev/null +++ b/resources/tester/test_259_detail_region.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""PLAN 259 — B06 [상세] 영역 · 길이 · 표 강조. + +못박는 것 + - 259-1 공용 팬(`A00_Common/b_svg_zoom_pan.ts`) — 가운데 단추 팬 뒤 왼쪽 누름 click 이 안 삼켜짐 · + 거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음. + - 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 · + 암 프리셋 아니면 다시 고름. + +./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Schema import CrossSectionPatch +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value, pick_lines + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + +LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)] +DESIGN = { + "design_elevation_m": 98.0, + "geometry_preset": "rock", + "rock_boundary_offset_m": -1.0, + "road_edges": { + "left": {"offset_m": 2.0, "elevation_m": 98.0}, + "right": {"offset_m": -2.0, "elevation_m": 98.0}, + }, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE], +} +SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] +AXES = { + "widthPx": 600, + "heightPx": 300, + "pixelsPerMeter": 10, + "maxOffset": 14, + "displayMax": 110, + "elevationMid": 100, + "exaggeration": 2, +} +ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_259_detail_region.cjs")], + input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 259-1 공용 팬 ──────────────────────────────────────────────────────── + + +def test_가운데_단추_팬_뒤_왼쪽_누름_통과(out): + assert out["panMiddle"] == {"firstClickStopped": False, "secondClickStopped": False} + + +def test_거리이정표_왼쪽_끌기_팬은_끌기_click_만_막음(out): + assert out["panLeft"] == {"afterDragStopped": True, "nextClickStopped": False} + + +# ── 259-1 암 경계선 나눔 · 고름 ─────────────────────────────────────────── + + +def test_암_경계선이_설계선과_만나는_점에서_나뉨(out): + rock = [p for p in out["pieces"] if p["line"] == "rock"] + assert len(rock) == 3 + ends = sorted(round(p["points"][0][0], 4) for p in rock) + assert ends == pytest.approx([-10.0, -ROCK_X, ROCK_X], abs=1e-3) + # 설계 경사도 암 경계선에서 잘림(경사 끝 · 암 교점 · 원지반 교점) + design_ends = { + round(q[0], 3) + for p in out["pieces"] + if p["line"] == "design" + for q in (p["points"][0], p["points"][-1]) + } + assert round(ROCK_X, 3) in design_ends + + +def test_암_경계선_고름_값_저장_꼴(out): + length = 2 * ROCK_X + assert out["rockLength"] == pytest.approx(length, abs=1e-3) + assert out["rockLabel"] == f"암 경계선 길이 {length:.2f}m\n수평 {length:.2f}m · 높이 0.00m" + source = out["rockSource"] + assert source["src"] == "length" and source["pieces"][0]["line"] == "rock" + patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}}) + assert patch.model_dump()["detail_table"]["cells"]["fence"]["pieces"][0]["line"] == "rock" + + +def test_암_경계선_서버_거울(out): + number, _text, again = cell_value(out["rockSource"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(out["rockLocal"], abs=1e-6) + assert pick_lines(DESIGN, SAMPLES)["rock"] == [[(x, 99.0) for x in (-10, -5, 0, 5, 10)]] + # 토사 프리셋이면 암 경계선 없음 → 다시 고름 + soil = {**DESIGN, "geometry_preset": "soil"} + assert pick_lines(soil, SAMPLES)["rock"] == [] + assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True) diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py new file mode 100644 index 000000000..774dbc8d8 --- /dev/null +++ b/resources/tester/test_260_note_cad.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +"""PLAN 260 — B06 [상세] 표 위 CAD 칸(`structure_note_cad`) → B07 횡단도 표 위. + +- 260-1 칸: 스키마 · 재계산 남김 · 세션 초안 → [저장] patch. +- 260-2 미리 넣음: 횡단배수 주인 측점만 · 관 · 유입 · 유출 · 도수로 · 빈 구조물 · 옛 글 옮김 + (`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림). + +./venv/Scripts/python.exe -m pytest resources/tester/test_260_note_cad.py -q +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402 +from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402 + +HERE = Path(__file__).resolve().parent +TEXT = { + "id": "t1", + "type": "Text", + "lineColor": "#ff7f7f", + "lineWidth": 1, + "layerId": "기본", + "shapeData": {"label": "NO.71+13", "basePoint": {"x": 15, "y": -1.5}, "options": {}}, +} +CAD = {"entities": [TEXT], "layers": [{"id": "기본", "name": "기본"}]} + +# ── 260-1 칸 ──────────────────────────────────────────────────────────── + + +def test_CAD_칸은_patch_스키마를_지나_그대로_남는다(): + dumped = CrossSectionPatch(chainage_m=40.0, structure_note_cad=CAD).model_dump() + merged = {k: v for k, v in dumped.items() if k != "chainage_m" and v is not None} + assert merged == {"structure_note_cad": CAD} + # 빈 엔티티 = 비움 — 걸러지지 않고 실림(옛 글 대신 아무것도 안 놓음) + empty = CrossSectionPatch(chainage_m=40.0, structure_note_cad={"entities": []}).model_dump() + assert empty["structure_note_cad"] == {"entities": [], "layers": None} + # 안 보냄 = 안 바꿈 + assert CrossSectionPatch(chainage_m=40.0).model_dump()["structure_note_cad"] is None + + +def test_CAD_칸은_엔티티_수_상한(): + with pytest.raises(ValueError): + CrossSectionPatch(chainage_m=40.0, structure_note_cad={"entities": [TEXT] * 2001}) + + +def test_CAD_칸은_재계산에서도_남는다(monkeypatch): + assert "structure_note_cad" in design_mod.USER_TOUCHED_KEYS + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 100.0)]) + monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) + monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) + monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + monkeypatch.setattr( + design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + ) + sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note_cad": CAD}}] + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 + assert sections[0]["design"]["structure_note_cad"] == CAD + + +def test_브라우저가_CAD_칸을_저장_patch_에_싣는다(): + read = lambda rel: (PROJECT_ROOT / rel).read_text(encoding="utf-8") # noqa: E731 + persist = read("B06_Section/B06_Section_UI_Page_Persist.ts") + assert "readStructureNoteCads(" in persist and ".structure_note_cad = cad" in persist + state = read("A00_Common/b_page_state.ts") + assert '"structure-note-cads": { bucket: "draft", scope: "route" }' in state + # 캐시 설계 → B05 [저장] 길(`crossPatchesFromCache`)도 같은 목록 + assert '"structure_note_cad",' in read("B06_Section/B06_Section_Cross_Refresh.ts") + # 모달 = 빈 CAD(캔버스만) · 글 상자 없음 + detail = read("B06_Section/B06_Section_UI_Cross_Detail.ts") + assert "textarea" not in detail and "mountNoteCad(" in detail and "noteCad?.destroy()" in detail + assert "bare: true" in read("B06_Section/B06_Section_UI_Cross_Detail_Cad.ts") + + +# ── 260-2 미리 넣음 ───────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_260_note_cad.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _labels(entities: list[dict]) -> list[str]: + return [entity["label"] for entity in entities if entity["type"] == "Text"] + + +def test_관_유입_유출_도수로_한_벌(out): + """그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 관보호공 둘 · 관유출부보강.""" + full = out["full"] + assert [entity["type"] for entity in full[:2]] == ["Circle", "Line"] + assert _labels(full) == [ + "배수관", + "Φ1200m/m", + "L=22.0m", + "NO.71+13", + "관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m", + "관보호공(유출) 기슭막이(메,H=2.0) L=10.0m", + "관유출부보강(돌붙임,찰) 10m2", + ] + # 원 안 = 가운데 맞춤 · 원 중심 x · 오른쪽 = 왼쪽 맞춤 · 줄 3.0 간격 · 붉은 계열 · 기본 층 + inside, right = full[2:5], full[5:] + assert {(e["align"], e["x"]) for e in inside} == {("center", full[0]["x"])} + assert [e["y"] for e in right] == [-1.5, -4.5, -7.5, -10.5] + assert {e["align"] for e in right} == {"left"} + assert {e["color"] for e in full} == {"#ff7f7f"} and {e["layer"] for e in full} == {"기본"} + + +def test_관_길이_없으면_L_뒤_빈칸(out): + assert _labels(out["noLength"])[2] == "L=" + # 도수로 상자가 없으면 관유출부보강 줄 없음 + assert not any(label.startswith("관유출부보강") for label in _labels(out["noLength"])) + + +def test_유입_집수정_은_형태_높이_길이(out): + assert "관보호공(유입) 집수정(L형,H=1.7) L=2.0m" in _labels(out["basin"]) + + +def test_도수로_옵션이_없으면_세트_구조만(out): + assert _labels(out["chuteFallback"])[-1] == "관유출부보강(도수로) m2" + + +def test_보호공을_안_고르면_관과_측점만(out): + assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22.0m", "NO.71+13"] + + +@pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"]) +def test_횡단배수_주인_측점이_아니면_빈값(out, case): + """구조물 없음 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) · 이웃 측점 = 빈 도면.""" + assert out[case] == [] + + +def test_옛_글은_줄마다_Text_로_옮김(out): + legacy = out["legacy"] + assert _labels(legacy) == ["L형 측구", "집수정 보강"] + # 빈 줄은 자리만 — B07 옛 글 줄과 같은 높이(줄 3.0 · 가운데) + assert [e["y"] for e in legacy] == [-1.5, -7.5] and {e["x"] for e in legacy} == {0} + assert out["legacyEmpty"] == 0 + + +def test_측점_이름은_B07_꼴(out): + assert out["station"] == ["NO.71+13", "NO.71", "NO.72"] diff --git a/resources/tester/test_ui_data_table.py b/resources/tester/test_ui_data_table.py index 70ab16487..6b61be801 100644 --- a/resources/tester/test_ui_data_table.py +++ b/resources/tester/test_ui_data_table.py @@ -108,7 +108,7 @@ def test_155_1_글자_14px_열_폭_같은_비율(r): assert abs(r["scale"] - 14 / 12) < 1e-9 w = r["widths"] assert '' in w - assert 'style="width:350px"' in w # 표 폭 = 112 + 98 + 140 + assert 'style="--dt-w:350px"' in w # 표 폭 = 112 + 98 + 140 (258-1 · css 변수) assert 'class="is-frozen is-edge" style="left:112px"' in w # 둘째 고정 열 left 도 같이 assert "font-size: var(--text-body-sm);" in r["css"] assert "--dt-row-h: 28px;" in r["css"] diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index f17dc400a..ed9f05f32 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -33,6 +33,8 @@ } .ui-data-table__table { + /* 258-1 — 표 폭 = 열 합(--dt-w · 인라인) · 페이지 css 가 width 로 덮음 */ + width: var(--dt-w); table-layout: fixed; border-collapse: separate; border-spacing: 0; @@ -175,6 +177,12 @@ text-align: right; } +/* 고칠 수 있는 칸(PLAN 258-2 · editOnDouble) — 글일 때도 옅은 입력 칸 테두리(outline 안쪽 · 줄 고름 box-shadow 와 안 겹침) */ +.ui-data-table__table td.is-can-edit { + outline: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-canvas)); + outline-offset: -1px; +} + .ui-data-table__input:focus { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index afc3bc564..44c2e69d8 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -17,6 +17,7 @@ * 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴) * 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 · * Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기 + * 고칠 수 있는 칸 = 글일 때도 칸에 입력 칸 테두리 class `is-can-edit`(PLAN 258-2 · 옅은 outline) · * 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(PLAN 243-1 · keyEdit false = 표 밖이 맡음) * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) @@ -183,7 +184,7 @@ function rowHtml( more: RowMore = {}, ): string { let tr = ""; - const { lv, spans } = more; + const { lv, spans, can } = more; for (let j = 0; j < cols.length; j++) { const key = cols[j].key; const span = spans?.[j] ?? 1; @@ -209,6 +210,7 @@ function rowHtml( frz && left[j + 1] == null && "is-edge", empty && "is-empty", cellClass?.(key), + can?.(key) && "is-can-edit", lvc.trim(), span > 1 && "is-merged", ] @@ -241,6 +243,8 @@ function rowHtml( interface RowMore { lv?: { key: string; n: number; node: boolean; tree?: string }; spans?: number[]; + /** 고칠 수 있는 칸인지(editOnDouble · 글로 그린 칸에 `is-can-edit` 표시 · 258-2) */ + can?: (key: string) => boolean; } /** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */ @@ -356,6 +360,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin { ...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}), ...(spans ? { spans: spans[i - from] } : {}), + ...(o.editOnDouble && o.editable ? { can: (key: string) => o.editable!(i, key) } : {}), }, ); } @@ -363,7 +368,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; const cap = o.caption ? `` : ""; return ( - `
${esc(o.caption)}
${cap}${colgroup}` + + `
${cap}${colgroup}` + `${headHtml(cols, left)}${body}${foot}
` ); }