Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-10-11 14:10:05 +09:00
60 changed files with 3061 additions and 489 deletions
+2
View File
@@ -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 [저장]이 내보낸다. */
+2
View File
@@ -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 {
+11 -1
View File
@@ -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>[];
}
+2
View File
@@ -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) — 기하 입력이라 재계산에도 넘김
+2
View File
@@ -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`)
+12 -1
View File
@@ -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
+21 -32
View File
@@ -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),
}
+35 -1
View File
@@ -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
+102
View File
@@ -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
+93
View File
@@ -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;
}
+59 -12
View File
@@ -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"]
+112 -35
View File
@@ -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({
+33 -13
View File
@@ -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()))))
+198 -96
View File
@@ -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;
}
+136 -17
View File
@@ -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
+5 -4
View File
@@ -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);
+3 -3
View File
@@ -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: "경로 — 다음(카카오) 지도" });
+74 -61
View File
@@ -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"),
);
},
});
+45 -22
View File
@@ -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,
],
+79 -16
View File
@@ -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))
+24 -3
View File
@@ -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")),
]
}
+24 -12
View File
@@ -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)
+16 -1
View File
@@ -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 자동 줄 연결 덮개
+9 -3
View File
@@ -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));
+111
View File
@@ -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_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화():
+60
View File
@@ -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}
+114
View File
@@ -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
+115
View File
@@ -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)
+157
View File
@@ -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"]
+1 -1
View File
@@ -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"]
+8
View File
@@ -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;
+7 -2
View File
@@ -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>`
);
}