Merge remote-tracking branch 'origin/dev' into sub_laptop_3
This commit is contained in:
@@ -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 [저장]이 내보낸다. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>[];
|
||||
layers?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
@@ -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) — 기하 입력이라 재계산에도 넘김
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<number, string> {
|
||||
@@ -62,26 +61,6 @@ export function readStructureNotes(projectId: string, routeId: number): Map<numb
|
||||
return new Map(Object.entries(raw).map(([key, text]) => [Number(key), String(text)]));
|
||||
}
|
||||
|
||||
function noteOf(section: CrossSection): string {
|
||||
const routeId = host?.routeId();
|
||||
if (host && routeId !== null && routeId !== undefined) {
|
||||
const draft = readState<Record<string, string>>("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<Record<string, string>>("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<HTMLElement>(".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) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음
|
||||
|
||||
@@ -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<number, CrossNoteCad> {
|
||||
const raw = readState<Record<string, CrossNoteCad>>("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<Record<string, CrossNoteCad>>(
|
||||
"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<Record<string, CrossNoteCad>>(
|
||||
"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<string, unknown> {
|
||||
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<string, unknown>[] {
|
||||
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<string, string> = { 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<string, unknown>, 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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown> = {},
|
||||
): Record<string, unknown>[] {
|
||||
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<Record<string, unknown>> {
|
||||
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<CrossNoteCad> {
|
||||
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<CadHostDrawing>({
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -44,6 +44,16 @@ export function groundRuns(samples: ReadonlyArray<SectionSample> | 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<SectionSample> | 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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SplitPiece["line"], string> = {
|
||||
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] },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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<string, number> = {
|
||||
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<string, "cm" | "t2" | "L"> = {
|
||||
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<string, unknown> | null;
|
||||
cycle?: Record<string, unknown> | 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<string, unknown> | null | undefined): CycleResult {
|
||||
const v: Record<string, number> = {};
|
||||
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<string, unknown> {
|
||||
const cycle: Record<string, unknown> = { ...(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<string, unknown> {
|
||||
const own = new Set(ref.own || []);
|
||||
const got = cycleTime(cycleInputs(ref, dist));
|
||||
const given: Record<string, unknown> = { ...(ref.given || {}) };
|
||||
for (const [name, slot] of Object.entries(CYCLE_FILL))
|
||||
if (names.includes(name) && !own.has(name)) given[name] = got[slot];
|
||||
return given;
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<SegKind, Road> = {
|
||||
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<string>();
|
||||
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<MilestoneBase> & { enabled?: boolean };
|
||||
type OldData = Partial<Omit<MilestoneData, "routes">> & {
|
||||
base?: OldBase | null;
|
||||
routes?: Array<Partial<MilestoneRoute> & { 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<string, unknown> | 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({
|
||||
|
||||
@@ -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"""<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0">
|
||||
<div id="m" style="width:{WIDTH}px;height:{HEIGHT}px"></div>
|
||||
<div id="m" style="width:{w}px;height:{h}px"></div>
|
||||
<script src="{sdk}" onerror="window.__fail=1"></script>
|
||||
<script>window.kakao && kakao.maps.load(() => {{
|
||||
const D = {data}, K = kakao.maps, at = (p) => new K.LatLng(p[0], p[1]);
|
||||
const pts = [...D.marks.map((m) => [m.lat, m.lon]), ...D.lines.flat()].map(at);
|
||||
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: 7 }});
|
||||
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: {level} }});
|
||||
D.lines.forEach((l) => new K.Polyline({{ map, path: l.map(at), strokeWeight: 6,
|
||||
strokeColor: "{LINE_COLOR}", strokeOpacity: 0.9 }}));
|
||||
D.marks.forEach((m) => {{
|
||||
@@ -68,9 +75,11 @@ def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
|
||||
new K.CustomOverlay({{ map, position: p, content: s, yAnchor: 2.6 }});
|
||||
}});
|
||||
K.event.addListener(map, "tilesloaded", () => (window.__done = true));
|
||||
const b = new K.LatLngBounds();
|
||||
pts.forEach((p) => b.extend(p));
|
||||
map.setBounds(b, 60, 60, 60, 60);
|
||||
if ({"false" if view else "true"}) {{
|
||||
const b = new K.LatLngBounds();
|
||||
pts.forEach((p) => b.extend(p));
|
||||
map.setBounds(b, 60, 60, 60, 60);
|
||||
}}
|
||||
}});</script></body></html>"""
|
||||
|
||||
|
||||
@@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""자식 프로세스 몸통 — 그림 찍어 `<out>/<route_id>.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) — 작은 지도 · 표지 하나 → `<out_dir>/<상자 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()))))
|
||||
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
|
||||
/** 상자 id → 잇는 곳에서 본 방위(기준점 둘레 빈 방위 = 그 방위에서 돌림 · 되돌아가는 쪽 뺌) */
|
||||
const degOf = new Map<string, number>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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/<id>.png`
|
||||
· `auto/<id>.png` · `<id>.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음)
|
||||
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/<id>.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/<id>": 해시} — 바뀐 것만 다시 캡처
|
||||
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/<id>.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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -87,7 +87,7 @@ export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}):
|
||||
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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<SVGGElement>("[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);
|
||||
}
|
||||
|
||||
@@ -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: "경로 — 다음(카카오) 지도" });
|
||||
|
||||
@@ -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<MilestoneRoute>) => 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<HTMLOptionElement>('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<SiteAddress>(`/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<SiteAddress>(`/projects/${projectId}/quantity/milestone/site-address${query}`).then(
|
||||
(s) => map.set(pins(s), []),
|
||||
(error: unknown) => showToast(`공사지 좌표를 못 읽었습니다 — ${String(error)}`, "error"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<Array<[number, number]>> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
],
|
||||
|
||||
@@ -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<string, string>;
|
||||
auto_images: Record<string, string>;
|
||||
place_images: Record<string, string>;
|
||||
place_times: Record<string, number>;
|
||||
}
|
||||
|
||||
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<P
|
||||
|
||||
export const siteInfo = (): SiteInfo | null => 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<string, SiteAddress | null>();
|
||||
@@ -97,9 +121,11 @@ export const routeImageKey = (routeId: string): string | null => {
|
||||
export const hasMilestoneDraft = (projectId: string): boolean =>
|
||||
readState<MilestoneData>("b08-milestone", projectId) !== null;
|
||||
|
||||
/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */
|
||||
/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본 · 옛 꼴 캐시 = 새 꼴 · 254) */
|
||||
export function liveMilestone(projectId: string): MilestoneData {
|
||||
return clone(readState<MilestoneData>("b08-milestone", projectId) ?? saved?.data ?? EMPTY);
|
||||
return migrateMilestone(
|
||||
clone(readState<MilestoneData>("b08-milestone", projectId) ?? saved?.data ?? EMPTY),
|
||||
);
|
||||
}
|
||||
|
||||
/** 고친 한 벌을 캐시에 · 화면에 알림 */
|
||||
@@ -180,10 +206,11 @@ export async function autoRouteImage(
|
||||
lines: Array<Array<[number, number]>>,
|
||||
): Promise<void> {
|
||||
// 표지 = 거래처 쪽 구간 두 끝(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<void> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string, Array<[number, number]>>();
|
||||
export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? [];
|
||||
/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */
|
||||
@@ -229,9 +293,9 @@ export async function findSegmentKm(
|
||||
image = true,
|
||||
): Promise<string> {
|
||||
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))
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 자동 줄 연결 덮개
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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): # 업체 상자끼리 안 겹침
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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차포장"
|
||||
|
||||
|
||||
|
||||
@@ -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] == [
|
||||
|
||||
@@ -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_더함_하나_줄_삭제_연결_이름_셋():
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화():
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 # 다른 모달과 같은 창 · 지도 높이
|
||||
@@ -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
|
||||
@@ -0,0 +1,160 @@
|
||||
"""256-1 B08 거리이정표 장소 그림 영구 저장(사용자 162-6 · 7).
|
||||
|
||||
캡처는 가짜(make_place_image 자리)로:
|
||||
① 장소 그림 페이지 = 480×320 · 고정 줌 · 경로 맞춤 안 함
|
||||
② [저장] 뒤 좌표 있는 상자(거래처 = 경로 id · 기준점 = b1)만 캡처 → place/<id>.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"}
|
||||
@@ -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 '<table class="ui-data-table__table" style="--dt-w:${x}px">' 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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -108,7 +108,7 @@ def test_155_1_글자_14px_열_폭_같은_비율(r):
|
||||
assert abs(r["scale"] - 14 / 12) < 1e-9
|
||||
w = r["widths"]
|
||||
assert '<col style="width:112px"><col style="width:98px"><col style="width:140px">' 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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
|
||||
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
|
||||
return (
|
||||
`<table class="ui-data-table__table" style="width:${x}px">${cap}<colgroup>${colgroup}</colgroup>` +
|
||||
`<table class="ui-data-table__table" style="--dt-w:${x}px">${cap}<colgroup>${colgroup}</colgroup>` +
|
||||
`<thead>${headHtml(cols, left)}</thead><tbody>${body}</tbody>${foot}</table>`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user