Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -61,6 +61,8 @@ export interface SvgZoomPanOptions {
|
||||
maxScale?: number;
|
||||
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
|
||||
initial?: ZoomPanState;
|
||||
/** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */
|
||||
homeScale?: number;
|
||||
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
|
||||
onChange?: (state: ZoomPanState) => void;
|
||||
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||
@@ -85,9 +87,15 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
height: Math.max(heightPx - pad.top - pad.bottom, 1),
|
||||
};
|
||||
const maxScale = options.maxScale ?? 8;
|
||||
let scale = initial?.scale ?? 1;
|
||||
let tx = initial?.tx ?? 0;
|
||||
let ty = initial?.ty ?? 0;
|
||||
const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale);
|
||||
const home: ZoomPanState = {
|
||||
scale: homeScale,
|
||||
tx: (plot.x + plot.width / 2) * (1 - homeScale),
|
||||
ty: (plot.y + plot.height / 2) * (1 - homeScale),
|
||||
};
|
||||
let scale = initial?.scale ?? home.scale;
|
||||
let tx = initial?.tx ?? home.tx;
|
||||
let ty = initial?.ty ?? home.ty;
|
||||
const applyTransform = (): void => {
|
||||
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||||
onChange?.({ scale, tx, ty });
|
||||
@@ -182,7 +190,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
/* 이미 해제됨 */
|
||||
}
|
||||
};
|
||||
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
|
||||
if (scale !== 1 || tx !== 0 || ty !== 0) {
|
||||
clampPan();
|
||||
applyTransform();
|
||||
}
|
||||
@@ -209,9 +217,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
}
|
||||
const currentScale = (): number => scale;
|
||||
const reset = (): void => {
|
||||
scale = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
({ scale, tx, ty } = home);
|
||||
applyTransform();
|
||||
};
|
||||
// 더블클릭 원복.
|
||||
|
||||
@@ -45,12 +45,12 @@ export interface CrossDetailHost {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션 + 처음 배율(173-4 · 179-3 · 266-1 · 뷰 `detailChart`). */
|
||||
chart: (
|
||||
stationId: string,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;
|
||||
}
|
||||
|
||||
let host: CrossDetailHost | null = null;
|
||||
@@ -187,12 +187,14 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */
|
||||
|
||||
/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 ·
|
||||
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */
|
||||
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) ·
|
||||
* 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */
|
||||
export function attachDetailZoom(
|
||||
clone: SVGSVGElement,
|
||||
axes: CrossAxesOptions | null,
|
||||
initial: ZoomPanState | undefined,
|
||||
onChange: (state: ZoomPanState) => void,
|
||||
homeScale = 1,
|
||||
): ZoomPanHandle | null {
|
||||
const layer = clone.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
if (!layer) return null;
|
||||
@@ -223,6 +225,7 @@ export function attachDetailZoom(
|
||||
content: { x: -width, y: -height, width: 3 * width, height: 3 * height },
|
||||
wheelZoom: true,
|
||||
initial,
|
||||
homeScale,
|
||||
onChange: (state) => {
|
||||
follow(state);
|
||||
onChange(state);
|
||||
@@ -251,7 +254,7 @@ function drawChart(
|
||||
section: CrossSection,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null {
|
||||
): { clone: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null {
|
||||
drawing = true;
|
||||
try {
|
||||
const drawn = host?.chart(section.station_id, widthPx, heightPx);
|
||||
@@ -264,7 +267,7 @@ function drawChart(
|
||||
.querySelectorAll(`[clip-path="url(#${id})"]`)
|
||||
.forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`));
|
||||
});
|
||||
return { clone, axes: drawn.axes };
|
||||
return { clone, axes: drawn.axes, homeScale: drawn.homeScale };
|
||||
} finally {
|
||||
drawing = false;
|
||||
}
|
||||
@@ -359,7 +362,13 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
else {
|
||||
const { clone } = drawn;
|
||||
axes = drawn.axes;
|
||||
const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next));
|
||||
const handle = attachDetailZoom(
|
||||
clone,
|
||||
axes,
|
||||
zoom,
|
||||
(next) => (zoom = next),
|
||||
drawn.homeScale,
|
||||
);
|
||||
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
|
||||
tablePick.redraw(); // 선 조각 고르기(209-1)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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";
|
||||
import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
|
||||
|
||||
@@ -109,6 +110,9 @@ export function legacyNoteEntities(note: string | undefined): Record<string, unk
|
||||
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` : "");
|
||||
/** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */
|
||||
const wholeMetres = (value: number | null | undefined): string =>
|
||||
typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : "";
|
||||
const trim = (value: number): string => String(Math.round(value * 100) / 100);
|
||||
/** 돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로. */
|
||||
const bondOf = (form: string | null | undefined): string =>
|
||||
@@ -183,7 +187,7 @@ export function culvertNoteEntities(
|
||||
const inside = [
|
||||
"배수관",
|
||||
`Φ${Math.round(culvert.diameter_m * 1000)}m/m`,
|
||||
`L=${metres(section.design?.pipe_length_m)}`,
|
||||
`L=${wholeMetres(section.design?.pipe_length_m)}`,
|
||||
];
|
||||
const right = [
|
||||
station,
|
||||
|
||||
@@ -579,7 +579,9 @@ export function createSectionView(
|
||||
elevationMid: plot.elevationMid,
|
||||
exaggeration: Math.max(currentExaggeration, 0.1),
|
||||
};
|
||||
return { svg, axes };
|
||||
// 칸 맞춤이 넓힌 가로 범위를 처음 배율로 되좁힘 = 카드 반폭(266-1)
|
||||
const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;
|
||||
return { svg, axes, homeScale };
|
||||
},
|
||||
focusStation(stationId) {
|
||||
if (stationId === null) clearSelection();
|
||||
|
||||
@@ -39,12 +39,12 @@ export interface SectionViewController {
|
||||
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
|
||||
refreshCards: (chainages: ReadonlyArray<number>) => void;
|
||||
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null)
|
||||
* · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */
|
||||
* · 축 다시 그리기 옵션(179-3 · 기하 없으면 null) · 처음 배율(266-1 · 가로 = 카드 반폭). */
|
||||
detailChart: (
|
||||
stationId: string,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
|
||||
focusStation: (stationId: string | null) => void;
|
||||
|
||||
@@ -360,6 +360,11 @@
|
||||
fill: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 관 정보 글(관매설 · 세월교 · 267-1) — 노폭 라벨(`.b06-chart__carriageway-label`)과 같은 글 크기 */
|
||||
.b06-chart__culvert-label.b06-chart__pipe-info {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 유입 보호공 공간 확보 절토선 — 말단에서 표준단면 절토경사로 지반까지 */
|
||||
.b06-chart__culvert-cut {
|
||||
stroke: var(--color-warning);
|
||||
|
||||
@@ -40,6 +40,11 @@ def _metres(value: Any) -> str:
|
||||
return f"{_one(value)}m" if _one(value) else ""
|
||||
|
||||
|
||||
def _whole_metres(value: Any) -> str:
|
||||
"""관 길이 = 사사오입 정수(265-3 · TS `pipeLengthWhole` · B08 「관매설」과 같은 값)."""
|
||||
return f"{_js_round(value)}m" if _one(value) else ""
|
||||
|
||||
|
||||
def _trim(value: float) -> str:
|
||||
rounded = _js_round(value * 100) / 100
|
||||
return str(int(rounded)) if rounded == int(rounded) else str(rounded)
|
||||
@@ -159,7 +164,7 @@ def culvert_note_entities(
|
||||
inside = [
|
||||
"배수관",
|
||||
f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m",
|
||||
f"L={_metres(length)}",
|
||||
f"L={_whole_metres(length)}",
|
||||
]
|
||||
chute = culvert.get("chute") if isinstance(culvert.get("chute"), dict) else {}
|
||||
right = [
|
||||
|
||||
@@ -204,6 +204,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 262-2 고정 시점 표지 — 둥근 붉은 알약(고르는 표지 = 기본 핀 + 흰 이름표와 구별) */
|
||||
.b08-milestone__map-start {
|
||||
padding: 3px 10px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 999px;
|
||||
background: #e60046;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */
|
||||
.ui-modal.b08-milestone__modal {
|
||||
width: min(960px, 94vw);
|
||||
|
||||
@@ -20,7 +20,6 @@ import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base";
|
||||
import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal";
|
||||
import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link";
|
||||
import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
|
||||
import {
|
||||
@@ -163,7 +162,6 @@ function tableView(projectId: string): MilestoneView {
|
||||
dist: at && { L1: at.L1, L2: at.L2 },
|
||||
name: route.rows[at?.index ?? 0]?.item || route.name || "운반",
|
||||
apply: link,
|
||||
other: () => void openPriceLink({ apply: link }),
|
||||
}),
|
||||
],
|
||||
...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []),
|
||||
|
||||
@@ -98,6 +98,8 @@ export interface MapPin {
|
||||
lon: number;
|
||||
lat: number;
|
||||
movable: boolean;
|
||||
/** 262-2 고정 시점 표지 — 끌기 · 맞춤 · 보기 이동에 안 쓰임 · 고르는 표지와 모양이 다름 */
|
||||
fixed?: boolean;
|
||||
}
|
||||
|
||||
export interface EditMap {
|
||||
@@ -127,8 +129,9 @@ export function editMap(
|
||||
loadKakaoMaps(projectId).then(
|
||||
(maps) =>
|
||||
requestAnimationFrame(() => {
|
||||
const moving = pins.filter((p) => !p.fixed);
|
||||
const points = [
|
||||
...pins.map((p) => new maps.LatLng(p.lat, p.lon)),
|
||||
...moving.map((p) => new maps.LatLng(p.lat, p.lon)),
|
||||
...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)),
|
||||
];
|
||||
const c = opts.center?.();
|
||||
@@ -156,6 +159,17 @@ export function editMap(
|
||||
),
|
||||
...pins.flatMap((p) => {
|
||||
const at = new maps.LatLng(p.lat, p.lon);
|
||||
if (p.fixed)
|
||||
return [
|
||||
new maps.CustomOverlay({
|
||||
position: at,
|
||||
content: el("span", {
|
||||
className: "b08-milestone__map-start",
|
||||
text: p.name,
|
||||
}),
|
||||
yAnchor: 0.5,
|
||||
}),
|
||||
];
|
||||
const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable });
|
||||
if (p.movable)
|
||||
maps.event.addListener(marker, "dragend", () => {
|
||||
@@ -176,7 +190,7 @@ export function editMap(
|
||||
if (fitted) {
|
||||
// 보기 밖 표지만 그쪽으로 옮김(줌 그대로)
|
||||
const view = map.getBounds();
|
||||
const out = pins.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon)));
|
||||
const out = moving.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon)));
|
||||
if (out) map.panTo(new maps.LatLng(out.lat, out.lon));
|
||||
} else if (points.length > 1) {
|
||||
const bounds = new maps.LatLngBounds();
|
||||
|
||||
@@ -106,8 +106,8 @@ export function openPlaceModal(
|
||||
const title = target.add
|
||||
? "요소 추가 — 기준점 · 거래처"
|
||||
: base
|
||||
? `기준점 — ${base.name || "기준점"}`
|
||||
: `거래처 — ${route!.name || "거래처"}`; // 254-3 표 머리 글(항목 정보) 없음
|
||||
? base.name || "기준점"
|
||||
: route!.name || "거래처"; // 262-1 「—」 앞 글자 없음
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: "닫기",
|
||||
@@ -259,7 +259,7 @@ export interface SiteAddress {
|
||||
/** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */
|
||||
export function openSiteInfo(projectId: string): void {
|
||||
openModal({
|
||||
title: "공사지 — 시점 · 중점",
|
||||
title: "시점 · 중점",
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "b08-milestone__modal",
|
||||
mount: (body, close, actions) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e
|
||||
import { searchAddress, type AddressCandidate } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import { request } from "./B08_Quantity_UI_Hydro";
|
||||
import { editMap } from "./B08_Quantity_UI_Milestone_KakaoMap";
|
||||
import { siteInfo } from "./B08_Quantity_UI_Milestone_State";
|
||||
import { liveMilestone, startPoint } from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
export interface Place {
|
||||
address: string;
|
||||
@@ -52,27 +52,35 @@ export function placePicker(projectId: string, initial: Place, name: () => strin
|
||||
list.hidden = true;
|
||||
const where = el("p", { className: "b08-milestone__place" });
|
||||
where.dataset.b08MilestonePlace = "value";
|
||||
// 262-2 시점 = 거리이정표가 쓰는 시점(끌어 옮긴 덮개 · 없으면 노선 시점)
|
||||
const start = (): [number, number] | null => startPoint(liveMilestone(projectId));
|
||||
const map = editMap(projectId, {
|
||||
hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김",
|
||||
onClick: (lon, lat) => void moveTo(lon, lat),
|
||||
onMove: (_key, lon, lat) => void moveTo(lon, lat),
|
||||
center: () => siteInfo()?.start_lonlat ?? null,
|
||||
center: start,
|
||||
});
|
||||
|
||||
const draw = (): void => {
|
||||
where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`;
|
||||
const s = start();
|
||||
map.set(
|
||||
place.lon != null && place.lat != null
|
||||
? [
|
||||
{
|
||||
key: "place",
|
||||
name: name() || "소재지",
|
||||
lon: place.lon,
|
||||
lat: place.lat,
|
||||
movable: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[
|
||||
...(s
|
||||
? [{ key: "start", name: "시점", lon: s[0], lat: s[1], movable: false, fixed: true }]
|
||||
: []),
|
||||
...(place.lon != null && place.lat != null
|
||||
? [
|
||||
{
|
||||
key: "place",
|
||||
name: name() || "소재지",
|
||||
lon: place.lon,
|
||||
lat: place.lat,
|
||||
movable: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Haul_Modal.ts
|
||||
* 「운반 단가」 모달(PLAN 253-3 · 요구 162-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결.
|
||||
* 위 = 단가산출 이름 · [변경](`openLogicPicker`) · [다른 종류](옛 단가연결 창) ·
|
||||
* 왼 = 옵션 값 — 로직 입력(`fetchLogicInputs`) + 사이클 입력(L1 · L2 · V1~V4 · t1 · t3~t6 · t1 셈 값) ·
|
||||
* 오른 = 계산 절차 — 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle`) + 로직 산출 줄
|
||||
* (서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만).
|
||||
* 로직 입력 Cm · 사이클시간 · t2 · 운반거리 = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) ·
|
||||
* 빈칸으로 지우면 다시 거리에서.
|
||||
* 연결 없는 줄도 같은 모달(단가산출 빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본).
|
||||
* 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결.
|
||||
* 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) ·
|
||||
* 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음) ·
|
||||
* 오른 = 계산 절차 — 거리 줄 · 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle` · 8-2-8 기본값 · 거리 칸 있을 때만) +
|
||||
* 로직 산출 줄(서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만).
|
||||
* 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) ·
|
||||
* 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값).
|
||||
* 연결 없는 줄도 같은 모달(빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본).
|
||||
* 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -22,65 +22,28 @@ import {
|
||||
fillGiven,
|
||||
type Dist,
|
||||
} from "../B08_Quantity/B08_Quantity_Haul_Cycle";
|
||||
import { loadTable, type TableRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Note";
|
||||
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
|
||||
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
importLib,
|
||||
openPriceLink,
|
||||
type Picked,
|
||||
type PriceLinkOpts,
|
||||
} from "./B09_Estimation_UI_Tabs_Bill_Link";
|
||||
|
||||
type Apply = (ref: Row | null, from?: Row) => void;
|
||||
export interface HaulModalOpts {
|
||||
export interface HaulModalOpts extends PriceLinkOpts {
|
||||
/** 지금 연결(없으면 null) */
|
||||
ref: Row | null;
|
||||
/** 그 행 거리 — L1 포장 계 · L2 비포장(km) */
|
||||
dist: Dist;
|
||||
/** 줄 이름(제목) */
|
||||
name: string;
|
||||
apply: Apply;
|
||||
/** 옛 단가연결 창(일위대가 · 구조물 조합 등 다른 종류) */
|
||||
other?: () => void;
|
||||
}
|
||||
|
||||
/** 사이클 입력 칸 — [이름, 글, 단위] · 무리 = 화면 묶음 */
|
||||
const CYCLE_FIELDS: Array<[string, string, string][]> = [
|
||||
[
|
||||
["L1", "포장 운반거리 L1", "km"],
|
||||
["L2", "비포장 운반거리 L2", "km"],
|
||||
],
|
||||
[
|
||||
["V1", "포장 적재 V1", "km/hr"],
|
||||
["V2", "포장 공차 V2", "km/hr"],
|
||||
["V3", "비포장 적재 V3", "km/hr"],
|
||||
["V4", "비포장 공차 V4", "km/hr"],
|
||||
],
|
||||
[
|
||||
["t1", "적재 t1", "분"],
|
||||
["T", "적재량 T", "ton"],
|
||||
["rt", "단위중량 rt", "t/㎥"],
|
||||
["f", "체적환산 f", ""],
|
||||
["qs", "버킷용량 qs", "㎥"],
|
||||
["k", "버킷계수 k", ""],
|
||||
["Es", "작업효율 Es", ""],
|
||||
["Cms", "적재기계 Cms", "초"],
|
||||
],
|
||||
[
|
||||
["t3", "적하 t3", "분"],
|
||||
["t4", "대기 t4", "분"],
|
||||
["t5", "덮개 t5", "분"],
|
||||
["t6", "세륜 t6", "분"],
|
||||
],
|
||||
];
|
||||
/** 표 고르기(건설품셈 8-2-8 · master_data 읽기만) — [표 키, 채울 칸(값칸 → 입력), 조건 칸들] */
|
||||
const PICKS: Array<[string, Record<string, string>, string]> = [
|
||||
["CC000054", { 적재: "V1", 공차: "V2" }, "포장 도로상태"],
|
||||
["CC000054", { 적재: "V3", 공차: "V4" }, "비포장 도로상태"],
|
||||
["CC000055", { 시간: "t3" }, "적하(토질 · 작업조건)"],
|
||||
["CC000056", { 시간: "t4" }, "대기(적재장소)"],
|
||||
["CC000057", { 시간: "t5" }, "덮개"],
|
||||
["CC000058", { 시간: "t6" }, "세륜"],
|
||||
];
|
||||
const OWNABLE = new Set(["L1", "L2", "t1"]);
|
||||
/** 옛 사이클 손 칸(253-3) — 새 모달은 받지 않음 */
|
||||
const OLD_OWN = new Set(["L1", "L2", "t1"]);
|
||||
const FROM_DIST = "거리에서";
|
||||
|
||||
const fmt = (v: number | null | undefined): string =>
|
||||
@@ -91,15 +54,11 @@ export function cycleLines(input: Record<string, unknown>): string[] {
|
||||
const v: Record<string, number> = { ...CYCLE_DEFAULTS };
|
||||
for (const k of Object.keys(v)) if (typeof input[k] === "number") v[k] = input[k] as number;
|
||||
const r = cycleTime(input);
|
||||
const t1 =
|
||||
typeof input.t1 === "number"
|
||||
? `t1 = ${fmt(r.t1)} 분(손 입력)`
|
||||
: `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`;
|
||||
return [
|
||||
"사이클시간 — 건설품셈 8-2-8 덤프트럭",
|
||||
`q = T / rt × f = ${v.T} / ${v.rt} × ${v.f} = ${r.q} ㎥`,
|
||||
`n = q / (qs × k) = ${r.q} / (${v.qs} × ${v.k}) = ${r.n} 회`,
|
||||
t1,
|
||||
`t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`,
|
||||
`t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 = (${v.L1}/${v.V1} + ${v.L1}/${v.V2} + ${v.L2}/${v.V3} + ${v.L2}/${v.V4}) × 60 = ${r.t2} 분`,
|
||||
`Cm = t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6} = ${r.cm} 분`,
|
||||
`운반거리 = L1 + L2 = ${r.L} km`,
|
||||
@@ -127,21 +86,33 @@ async function preview(code: string, given: Row, rate: unknown, picks: unknown):
|
||||
}
|
||||
|
||||
const label = (text: string, unit: string): string => (unit ? `${text} (${unit})` : text);
|
||||
const KIND_TEXT: Record<string, string> = {
|
||||
logic: "단가산출",
|
||||
precombo: "일위대가 사전조합",
|
||||
combo: "구조물 조합",
|
||||
dg: "일위대가 호표",
|
||||
sg: "단가산출 호표",
|
||||
};
|
||||
|
||||
/** 「운반 단가」 모달 */
|
||||
export function openHaulModal(o: HaulModalOpts): void {
|
||||
const logicRef = o.ref?.tab === "logic" ? o.ref : null;
|
||||
const old = (logicRef?.cycle as Row) || {};
|
||||
const st = {
|
||||
/** 고른 것 — 단가산출 아닌 것(조합 · 호표) */
|
||||
other: (o.ref && !logicRef
|
||||
? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } }
|
||||
: null) as Picked | null,
|
||||
code: String(logicRef?.code ?? ""),
|
||||
name: String(logicRef?.name ?? ""),
|
||||
given: { ...((logicRef?.given as Row) || {}) } as Row,
|
||||
cycle: { ...((logicRef?.cycle as Row) || {}) } as Row,
|
||||
own: new Set<string>((logicRef?.own as string[]) || []),
|
||||
own: new Set<string>(((logicRef?.own as string[]) || []).filter((n) => !OLD_OWN.has(n))),
|
||||
rate: logicRef?.rate ?? null,
|
||||
picks: logicRef?.picks ?? null,
|
||||
inputs: [] as ItemInput[],
|
||||
unit: "",
|
||||
};
|
||||
const oldCycle = Object.keys(old).length > 0;
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
openModal({
|
||||
@@ -159,65 +130,46 @@ export function openHaulModal(o: HaulModalOpts): void {
|
||||
attrs: { style: "margin:0;white-space:pre-wrap;font-size:12px;line-height:1.6" },
|
||||
});
|
||||
const right = el("div", { attrs: { style: "overflow:auto;min-width:0" }, children: [steps] });
|
||||
const ref = (): Row => ({ own: [...st.own], cycle: st.cycle, given: st.given });
|
||||
// 사이클 = 8-2-8 기본값 + 거리(옛 손 값 안 씀 · [적용]하면 지움)
|
||||
const ref = (): Row => ({ own: [...st.own], cycle: {}, given: st.given });
|
||||
const names = (): string[] => st.inputs.map((i) => i.이름);
|
||||
const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row;
|
||||
|
||||
const redraw = (): void => {
|
||||
const fills = st.code !== "" && names().some((n) => n in CYCLE_FILL);
|
||||
const input = cycleInputs(ref(), o.dist);
|
||||
const head2 = cycleLines(input);
|
||||
steps.textContent = [...head2, "", st.code ? "단가산출 — 셈 중…" : "단가산출 — 없음"].join(
|
||||
"\n",
|
||||
);
|
||||
const head2 = fills
|
||||
? [
|
||||
`거리 — 포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km`,
|
||||
...cycleLines(input),
|
||||
"",
|
||||
]
|
||||
: [];
|
||||
window.clearTimeout(timer);
|
||||
if (!st.code) return;
|
||||
const my = ++seq;
|
||||
if (!st.code) {
|
||||
const what = st.other
|
||||
? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결`
|
||||
: "없음";
|
||||
steps.textContent = `연결 — ${what}`;
|
||||
return;
|
||||
}
|
||||
steps.textContent = [...head2, "단가산출 — 셈 중…"].join("\n");
|
||||
timer = window.setTimeout(() => {
|
||||
preview(st.code, filled(), st.rate, st.picks).then(
|
||||
(got) => {
|
||||
if (my !== seq) return; // 최신 응답만
|
||||
st.unit = got.unit;
|
||||
const price = got.price == null ? `빈칸 — ${got.why ?? ""}` : `${got.price} 원`;
|
||||
steps.textContent = [...head2, "", ...got.lines, "", `단가 = ${price}`].join("\n");
|
||||
steps.textContent = [...head2, ...got.lines, "", `단가 = ${price}`].join("\n");
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (my === seq)
|
||||
steps.textContent = [...head2, "", `못 셈 — ${String(error)}`].join("\n");
|
||||
if (my === seq) steps.textContent = [...head2, `못 셈 — ${String(error)}`].join("\n");
|
||||
},
|
||||
);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const numberField = (name: string, text: string, unit: string): HTMLElement => {
|
||||
const input = el("input", {
|
||||
className: "ui-input m02-inputval__control",
|
||||
attrs: { type: "number", step: "any", "data-haul-cycle": name },
|
||||
}) as HTMLInputElement;
|
||||
const tag = el("span", { className: "m02-labin__muted" });
|
||||
const show = (): void => {
|
||||
const now = cycleInputs(ref(), o.dist);
|
||||
const auto =
|
||||
name === "t1" ? cycleTime(now).t1 : ((now[name] as number) ?? CYCLE_DEFAULTS[name]);
|
||||
const mine = OWNABLE.has(name) ? st.own.has(name) : name in st.cycle;
|
||||
if (document.activeElement !== input)
|
||||
input.value = mine ? String(st.cycle[name] ?? "") : "";
|
||||
input.placeholder = String(auto ?? "");
|
||||
tag.textContent = (name === "L1" || name === "L2") && !mine && o.dist ? FROM_DIST : "";
|
||||
};
|
||||
input.addEventListener("change", () => {
|
||||
const v = input.value.trim() === "" ? null : Number(input.value);
|
||||
if (v === null || !Number.isFinite(v)) {
|
||||
delete st.cycle[name];
|
||||
st.own.delete(name);
|
||||
} else {
|
||||
st.cycle[name] = v;
|
||||
if (OWNABLE.has(name)) st.own.add(name);
|
||||
}
|
||||
update();
|
||||
});
|
||||
shows.push(show);
|
||||
return row(label(text, unit), [input, tag]);
|
||||
};
|
||||
const row = (text: string, cells: HTMLElement[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
@@ -228,33 +180,6 @@ export function openHaulModal(o: HaulModalOpts): void {
|
||||
});
|
||||
const shows: Array<() => void> = [];
|
||||
|
||||
const tablePick = (key: string, map: Record<string, string>, text: string): HTMLElement => {
|
||||
const select = el("select", {
|
||||
className: "ui-select ui-select--sm m02-inputval__control",
|
||||
attrs: { "data-haul-pick": Object.values(map).join(",") },
|
||||
}) as HTMLSelectElement;
|
||||
select.append(el("option", { text: "표에서 고르기", attrs: { value: "" } }));
|
||||
void loadTable(key).then((t: TableRow | null) => {
|
||||
const conds = Object.keys(t?.조건 || {});
|
||||
(t?.줄 || []).forEach((line, i) => {
|
||||
const what = conds.map((c) => String(line[c] ?? "")).join(" · ");
|
||||
const vals = Object.keys(map)
|
||||
.map((col) => `${map[col]} ${String(line[col] ?? "")}`)
|
||||
.join(" · ");
|
||||
select.append(el("option", { text: `${what} — ${vals}`, attrs: { value: String(i) } }));
|
||||
});
|
||||
select.onchange = () => {
|
||||
const line = t?.줄?.[Number(select.value)];
|
||||
if (!line) return;
|
||||
for (const [col, name] of Object.entries(map))
|
||||
if (typeof line[col] === "number") st.cycle[name] = line[col] as number;
|
||||
select.value = "";
|
||||
update();
|
||||
};
|
||||
});
|
||||
return row(text, [select]);
|
||||
};
|
||||
|
||||
const logicRows = (): HTMLElement[] =>
|
||||
st.inputs.map((input) => {
|
||||
const fill = input.이름 in CYCLE_FILL && !st.own.has(input.이름);
|
||||
@@ -291,29 +216,36 @@ export function openHaulModal(o: HaulModalOpts): void {
|
||||
shows.forEach((show) => show());
|
||||
redraw();
|
||||
};
|
||||
/** 로직 바뀜 — 왼쪽 칸을 새로 */
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m02-labin__muted", text });
|
||||
/** 고른 것 바뀜 — 왼쪽 칸을 새로 */
|
||||
const refresh = (): void => {
|
||||
shows.splice(0);
|
||||
const sec = (text: string): HTMLElement =>
|
||||
el("h4", { text, attrs: { style: "margin:12px 0 4px" } });
|
||||
left.replaceChildren(
|
||||
sec("단가산출 옵션 값"),
|
||||
...(st.code
|
||||
const lines = st.code
|
||||
? st.inputs.length
|
||||
? logicRows()
|
||||
: [el("p", { className: "m02-labin__muted", text: "단가산출 없음 — [고르기]" })]),
|
||||
sec("사이클시간 입력"),
|
||||
...CYCLE_FIELDS.flatMap((group) => group.map(([n, t, u]) => numberField(n, t, u))),
|
||||
sec("표에서 고르기(건설품셈 8-2-8)"),
|
||||
...PICKS.map(([key, map, text]) => tablePick(key, map, text)),
|
||||
: [muted("입력 없음")]
|
||||
: [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")];
|
||||
left.replaceChildren(
|
||||
el("h4", { text: "옵션 값", attrs: { style: "margin:12px 0 4px" } }),
|
||||
...lines,
|
||||
...(oldCycle && st.code
|
||||
? [muted("옛 사이클 손 값 — 셈은 8-2-8 기본값 · [적용]하면 지움")]
|
||||
: []),
|
||||
);
|
||||
title.textContent = st.code ? `단가산출 ${st.code} ${st.name}` : "단가산출 — 없음";
|
||||
change.querySelector(".ui-btn__label")!.textContent = st.code ? "변경" : "고르기";
|
||||
const p = st.other;
|
||||
title.textContent = st.code
|
||||
? `단가산출 ${st.code} ${st.name}`
|
||||
: p
|
||||
? `${KIND_TEXT[p.kind] ?? p.kind} ${String("from" in p ? (p.from.name ?? "") : "name" in p ? p.name : "")}`
|
||||
: "연결 — 없음";
|
||||
change.querySelector(".ui-btn__label")!.textContent = st.code || p ? "변경" : "고르기";
|
||||
update();
|
||||
};
|
||||
|
||||
const useLogic = (code: string, name: string): void => {
|
||||
fetchLogicInputs([code]).then(
|
||||
(got) => {
|
||||
st.other = null;
|
||||
st.code = code;
|
||||
st.name = name;
|
||||
st.inputs = got.로직[0]?.입력 ?? [];
|
||||
@@ -324,41 +256,40 @@ export function openHaulModal(o: HaulModalOpts): void {
|
||||
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
|
||||
);
|
||||
};
|
||||
const usePicked = (p: Picked): void => {
|
||||
if (p.kind === "logic") return useLogic(p.code, p.name);
|
||||
st.other = p;
|
||||
st.code = "";
|
||||
st.inputs = [];
|
||||
refresh();
|
||||
};
|
||||
const change = createButton({
|
||||
label: "변경",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => openLogicPicker((logic) => useLogic(logic.키, logic.이름)),
|
||||
onClick: () => void openPriceLink({ ...o, onPick: usePicked }),
|
||||
});
|
||||
linkBox.append(title, change);
|
||||
if (o.other)
|
||||
linkBox.append(
|
||||
createButton({
|
||||
label: "다른 종류",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
o.other?.();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const ok = createButton({
|
||||
label: "적용",
|
||||
onClick: () => {
|
||||
if (!st.code) return showToast("단가산출을 먼저 고르세요", "error");
|
||||
const next: Row = {
|
||||
tab: "logic",
|
||||
code: st.code,
|
||||
name: st.name,
|
||||
given: filled(),
|
||||
rate: st.rate,
|
||||
picks: st.picks,
|
||||
cycle: st.cycle,
|
||||
own: [...st.own],
|
||||
};
|
||||
o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") });
|
||||
showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success");
|
||||
const p = st.other;
|
||||
if (!st.code && !p) return showToast("연결할 것을 먼저 고르세요", "error");
|
||||
if (p && (p.kind === "combo" || p.kind === "precombo")) void importLib(o, p);
|
||||
else if (p && "ref" in p) o.apply(p.ref, p.from);
|
||||
else {
|
||||
const next: Row = {
|
||||
tab: "logic",
|
||||
code: st.code,
|
||||
name: st.name,
|
||||
given: filled(),
|
||||
rate: st.rate,
|
||||
picks: st.picks,
|
||||
own: [...st.own],
|
||||
};
|
||||
o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") });
|
||||
showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success");
|
||||
}
|
||||
close();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,13 +61,26 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row
|
||||
);
|
||||
}
|
||||
|
||||
/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) */
|
||||
export type LibPick = {
|
||||
kind: "combo" | "precombo";
|
||||
layer: string;
|
||||
owner: string | null;
|
||||
key: string;
|
||||
};
|
||||
export type Picked =
|
||||
| { kind: "logic"; code: string; name: string }
|
||||
| (LibPick & { name: string; unit: string })
|
||||
| { kind: "dg" | "sg"; ref: Row; from: Row };
|
||||
|
||||
/** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만
|
||||
* 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */
|
||||
async function importLib(
|
||||
export async function importLib(
|
||||
o: { apply: ApplyRef; onPayload?: (payload: Row) => void },
|
||||
pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string },
|
||||
lib: LibPick,
|
||||
): Promise<void> {
|
||||
const apply = o.apply;
|
||||
const pick = { kind: lib.kind, layer: lib.layer, owner: lib.owner, key: lib.key };
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) return;
|
||||
showToast("가져오는 중…", "info");
|
||||
@@ -98,7 +111,7 @@ async function importLib(
|
||||
}
|
||||
|
||||
/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 ·
|
||||
* haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 「운반 단가」 모달(옛 창은 [다른 종류]) */
|
||||
* haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 「운반 단가」 모달([변경] = 같은 목록 · 263-1) */
|
||||
export function linkItems(
|
||||
ctx: TabContext,
|
||||
ref: Row | null,
|
||||
@@ -106,14 +119,14 @@ export function linkItems(
|
||||
haul?: { dist: Dist; name: string },
|
||||
): [Leaf[], Leaf[]] {
|
||||
const more: Leaf[] = [];
|
||||
const priceLink = (): void =>
|
||||
void openPriceLink({
|
||||
apply,
|
||||
hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] },
|
||||
onPayload: (p) => Object.assign(ctx.payload, p),
|
||||
});
|
||||
const link: PriceLinkOpts = {
|
||||
apply,
|
||||
hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] },
|
||||
onPayload: (p) => Object.assign(ctx.payload, p),
|
||||
};
|
||||
const priceLink = (): void => void openPriceLink(link);
|
||||
const haulModal = (): void =>
|
||||
openHaulModal({ ref, dist: haul?.dist, name: haul?.name || "", apply, other: priceLink });
|
||||
openHaulModal({ ...link, ref, dist: haul?.dist, name: haul?.name || "" });
|
||||
if (ref?.tab === "logic" && !haul)
|
||||
more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]);
|
||||
if (ref) more.push(["단가연결 풀기", () => apply(null)]);
|
||||
@@ -129,9 +142,18 @@ export interface PriceLinkOpts {
|
||||
hopyo?: { dg: Row[]; sg: Row[] };
|
||||
/** 서고 사본 들여온 뒤 받은 원가 자료(B09 = 화면 자료에 끼움) */
|
||||
onPayload?: (payload: Row) => void;
|
||||
/** 고른 것 받기(263-1 「운반 단가」 모달) — 없으면 단가산출 = 옵션 값 창 · 조합 = 사본 들여옴 · 호표 = 연결 */
|
||||
onPick?: (picked: Picked) => void;
|
||||
}
|
||||
|
||||
export async function openPriceLink(o: PriceLinkOpts): Promise<void> {
|
||||
const pick =
|
||||
o.onPick ??
|
||||
((p: Picked): void => {
|
||||
if (p.kind === "logic") askInputs(o.apply, p.code, p.name);
|
||||
else if ("ref" in p) o.apply(p.ref, p.from);
|
||||
else void importLib(o, p);
|
||||
});
|
||||
const hop = (tab: "dg" | "sg") =>
|
||||
((tab === "dg" ? o.hopyo?.dg : o.hopyo?.sg) ?? []).map((r) => ({
|
||||
kind: tab,
|
||||
@@ -140,7 +162,7 @@ export async function openPriceLink(o: PriceLinkOpts): Promise<void> {
|
||||
name: String(r.name || ""),
|
||||
sub: String(r.spec || ""),
|
||||
unit: String(r.unit || ""),
|
||||
onPick: () => o.apply({ tab, key: r.key }, r),
|
||||
onPick: () => pick({ kind: tab, ref: { tab, key: r.key }, from: r }),
|
||||
}));
|
||||
// 구조물 조합 = 마스터 · 회사 · 개인 한 목록(B08 [선택] 나무와 같은 원천)
|
||||
const combos = (await loadRows().catch(() => [])).map((r) => ({
|
||||
@@ -151,14 +173,16 @@ export async function openPriceLink(o: PriceLinkOpts): Promise<void> {
|
||||
sub: `${LAYER_TEXT[r.layer ?? "system"]} · ${r.key}`,
|
||||
unit: String(r.단위 || ""),
|
||||
onPick: () =>
|
||||
void importLib(o, {
|
||||
pick({
|
||||
kind: "combo",
|
||||
layer: r.layer ?? "system",
|
||||
owner: r.owner ?? null,
|
||||
key: r.key,
|
||||
name: itemName(r),
|
||||
unit: String(r.단위 || ""),
|
||||
}),
|
||||
}));
|
||||
openLogicPicker((logic) => askInputs(o.apply, logic.키, logic.이름), {
|
||||
openLogicPicker((logic) => pick({ kind: "logic", code: logic.키, name: logic.이름 }), {
|
||||
layer: "personal",
|
||||
owner: null,
|
||||
title: "단가연결",
|
||||
@@ -174,7 +198,7 @@ export async function openPriceLink(o: PriceLinkOpts): Promise<void> {
|
||||
]
|
||||
: []),
|
||||
],
|
||||
pick: (ref) =>
|
||||
void importLib(o, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }),
|
||||
pick: (ref, name) =>
|
||||
pick({ kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키, name, unit: "" }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ function loadTs(file) {
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
// 관 길이 정수(265-3) = 실제 `pipeLengthWhole`(타입 import 만 있는 파일)
|
||||
STUBS["./B06_Section_UI_Cross_Culvert_Const"] = loadTs(
|
||||
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Const.ts"),
|
||||
);
|
||||
const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts"));
|
||||
|
||||
const wall = (role, form, height, length) => ({
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* PLAN 266-1 단위검증 헬퍼 — [상세] 처음 가로 범위 = 카드 반폭.
|
||||
* 실제 TS(`_Cross_View_Metrics` 칸 맞춤 · `b_svg_zoom_pan` homeScale)를 Node 로 돌림.
|
||||
* 처음 배율 = 뷰 `detailChart` 와 같은 식(base.ppm / fitted.ppm · 짝 시험이 원본 줄을 확인).
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_266_detail_home.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B06 = path.join(ROOT, "B06_Section");
|
||||
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
|
||||
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
||||
const CROSS_HEIGHT = Number(/export const CROSS_HEIGHT = (\d+)/.exec(common)[1]);
|
||||
const STUBS = {
|
||||
"./B06_Section_UI_Section_Common": {
|
||||
CROSS_PAD,
|
||||
CROSS_HEIGHT,
|
||||
validElevation: (s) => s.valid !== false && Number.isFinite(s.elevation_m),
|
||||
},
|
||||
};
|
||||
function loadTs(full) {
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => STUBS[request] ?? {};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts"));
|
||||
const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts"));
|
||||
|
||||
function fakeSvg(width, height) {
|
||||
const listeners = {};
|
||||
const attrs = {};
|
||||
return {
|
||||
attrs,
|
||||
setAttribute: (name, value) => (attrs[name] = String(value)),
|
||||
getAttribute: (name) => attrs[name] ?? null,
|
||||
classList: { add() {}, remove() {} },
|
||||
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
|
||||
fire: (type, event) =>
|
||||
(listeners[type] ?? []).forEach((fn) =>
|
||||
fn({ preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }),
|
||||
),
|
||||
setPointerCapture() {},
|
||||
releasePointerCapture() {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
|
||||
};
|
||||
}
|
||||
|
||||
// 가파른 비탈 — ±30 m 샘플 · 1 m 마다 · 1 m 에 0.6 m 오름(칸 맞춤이 가로를 넓히게)
|
||||
const section = {
|
||||
samples: Array.from({ length: 61 }, (_, i) => ({
|
||||
offset_m: i - 30,
|
||||
elevation_m: 100 + 0.6 * (i - 30),
|
||||
})),
|
||||
};
|
||||
|
||||
/** 반폭 · 칸 크기로 상세 창을 연 셈 → 처음 · 줌 · 팬 · 원복 뒤 보이는 가로 범위(m). */
|
||||
function open(halfWidth, W, H) {
|
||||
const base = metrics.crossPlotBase(section, 1, W, halfWidth, 100);
|
||||
const fitted = metrics.fitPlotBase(base, H);
|
||||
const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;
|
||||
const svg = fakeSvg(W, H);
|
||||
const layer = fakeSvg(W, H);
|
||||
let state = null;
|
||||
const handle = attachSvgZoomPan({
|
||||
svg,
|
||||
layer,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
pad: CROSS_PAD,
|
||||
maxScale: 16,
|
||||
content: { x: -W, y: -H, width: 3 * W, height: 3 * H },
|
||||
wheelZoom: true,
|
||||
homeScale,
|
||||
onChange: (next) => (state = next),
|
||||
});
|
||||
// 보이는 플롯 가로 끝(svg x) → 도형 층 x → 측점 거리(m)
|
||||
const range = () => {
|
||||
const s = state ?? { scale: 1, tx: 0, ty: 0 };
|
||||
const toM = (x) =>
|
||||
fitted.minOffset + ((x - s.tx) / s.scale - CROSS_PAD.left) / fitted.pixelsPerMeter;
|
||||
return [toM(CROSS_PAD.left), toM(W - CROSS_PAD.right)].map((v) => Math.round(v * 1000) / 1000);
|
||||
};
|
||||
const out = { homeScale, widened: fitted !== base, first: range() };
|
||||
svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: 100 });
|
||||
out.wheelScale = state.scale / homeScale;
|
||||
svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 });
|
||||
svg.fire("pointermove", { clientX: 160, clientY: 100 });
|
||||
svg.fire("pointerup", {});
|
||||
out.panned = range()[0] !== out.first[0];
|
||||
handle.reset(); // [⤢]
|
||||
out.reset = range();
|
||||
for (let i = 0; i < 40; i++) svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: -100 });
|
||||
out.minScale = state.scale; // 줄이면 칸 맞춤 전체(1배)까지
|
||||
svg.fire("dblclick", {});
|
||||
out.dblclick = range();
|
||||
return out;
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
global15: open(15, 1200, 300),
|
||||
station10: open(10, 1200, 300),
|
||||
tall15: open(15, 1200, 900),
|
||||
}),
|
||||
);
|
||||
@@ -84,13 +84,16 @@ def test_모달_배선_단추_칸_비율():
|
||||
css = _read("B06_Section_UI_Style_Cross_Detail.css")
|
||||
assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src
|
||||
assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src
|
||||
assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src
|
||||
flat = " ".join(src.split())
|
||||
assert (
|
||||
"attachDetailZoom( clone, axes, zoom, (next) => (zoom = next), drawn.homeScale, )" in flat
|
||||
)
|
||||
assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3)
|
||||
assert 'clone.querySelector<SVGGElement>("g.b06-chart__plot-layer")' in src
|
||||
assert "createCrossAxes(axisLayer, axes)" in src
|
||||
view = _read("B06_Section_UI_Section_View.ts")
|
||||
assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view
|
||||
assert "return { svg, axes };" in view
|
||||
assert "return { svg, axes, homeScale };" in view
|
||||
assert "position: relative;" in css
|
||||
# 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득
|
||||
assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css
|
||||
|
||||
@@ -100,7 +100,10 @@ def test_상세_칸_크기로_새로_그림_배선():
|
||||
assert "cardZoomStates.delete(stationId);" in view
|
||||
types = _read("B06_Section_UI_Section_View_Types.ts")
|
||||
# 179-3 — svg + 축 옵션
|
||||
assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types
|
||||
assert (
|
||||
" ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;"
|
||||
in types
|
||||
)
|
||||
assert (
|
||||
" detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number,"
|
||||
in types
|
||||
|
||||
@@ -139,6 +139,6 @@ console.log(JSON.stringify([refText(null),
|
||||
]
|
||||
# 램프 = 항목 칸 · 우클릭 = [단가연결] · 연결 있으면 [단가연결 풀기] · 창 = B09 와 같은 것
|
||||
assert 'key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP' in src
|
||||
assert "openPriceLink({ apply: link })" in src and '"단가연결 풀기"' in src
|
||||
assert "openHaulModal({" in src and '"단가연결 풀기"' in src # 263-1 = 운반 단가 모달 하나
|
||||
diagram = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone_Diagram.ts").read_text("utf-8")
|
||||
assert "Math.abs(h - lastH) > 1" in diagram # 틀 높이 바뀜 = 다시 그림(아래 잘림 없음)
|
||||
|
||||
@@ -48,8 +48,8 @@ def test_내역_줄_거리():
|
||||
|
||||
def test_모달_여는_곳():
|
||||
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
|
||||
for want in ("/estimation/logic-preview", "openLogicPicker", "fetchLogicInputs", "fillGiven",
|
||||
"cycleInputs", "my !== seq", "CC000054", "거리에서", "[고르기]"): # fmt: skip
|
||||
for want in ("/estimation/logic-preview", "openPriceLink", "fetchLogicInputs", "fillGiven",
|
||||
"cycleInputs", "my !== seq", "거리에서", "[고르기]"): # fmt: skip
|
||||
assert want in modal, want
|
||||
milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts")
|
||||
assert "openHaulModal({" in milestone and "L1: paved" in milestone
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""254-3 B08 거리이정표 상자 모달 제목 · 장소 그림(사용자 162-6 · 8).
|
||||
|
||||
① 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 「항목 정보」 없음)
|
||||
① 상자 누름 모달 제목 = 이름만(262-1 · 「—」 앞 글자 없음 · 표 머리 글 「항목 정보」 없음)
|
||||
② 장소 그림(256-1 `place_images`) = 상자 안 글 아래 작게 · 그림 없으면 자리 없음(상자 높이 그대로)
|
||||
"""
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_장소_그림_상자_안_글_아래():
|
||||
|
||||
def test_모달_제목_화면_그림():
|
||||
modal = _read(B08 + "UI_Milestone_Modal.ts")
|
||||
assert "`거래처 — ${route!.name" in modal and "`기준점 — ${base.name" in modal
|
||||
assert 'route!.name || "거래처"' in modal and 'base.name || "기준점"' in modal # 262-1
|
||||
assert "항목 정보 —" not in modal
|
||||
ui = _read(B08 + "UI_Milestone_Diagram.ts")
|
||||
assert "placeImageBlobUrl(projectId, id)" in ui and "placeImageKey(id)" in ui
|
||||
|
||||
@@ -107,7 +107,7 @@ def test_관_유입_유출_도수로_한_벌(out):
|
||||
assert _labels(full) == [
|
||||
"배수관",
|
||||
"Φ1200m/m",
|
||||
"L=22.0m",
|
||||
"L=22m",
|
||||
"NO.71+13",
|
||||
"관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m",
|
||||
"관보호공(유출) 기슭막이(메,H=2.0) L=10.0m",
|
||||
@@ -136,7 +136,7 @@ def test_도수로_옵션이_없으면_세트_구조만(out):
|
||||
|
||||
|
||||
def test_보호공을_안_고르면_관과_측점만(out):
|
||||
assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22.0m", "NO.71+13"]
|
||||
assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"])
|
||||
@@ -425,7 +425,7 @@ def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_
|
||||
assert labels[2:] == [
|
||||
"배수관",
|
||||
"Φ1200m/m",
|
||||
"L=22.0m",
|
||||
"L=22m",
|
||||
"NO.71+13",
|
||||
"관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m",
|
||||
"관보호공(유출) 기슭막이(메,H=2.0) L=10.0m",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""262 B08 거리이정표 상자 창 제목 · 소재지 지도 시점 표지(사용자 164-1).
|
||||
|
||||
262-1 거래처 · 기준점 창 제목 = 이름만(빈 이름 = 「거래처」 · 「기준점」) · 시점 창 = 「시점 · 중점」 · 요소 추가 창 그대로
|
||||
262-2 소재지 지도 = 시점 표지 고정(끌기 없음 · 맞춤에서 빠짐) · 시점 없으면 표지 없음 · 소재지 없으면 시점 중심
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B08 = "B08_Quantity/B08_Quantity_UI_Milestone"
|
||||
|
||||
|
||||
def _read(name):
|
||||
return (ROOT / f"{B08}{name}").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_제목_이름만():
|
||||
modal = _read("_Modal.ts")
|
||||
title = modal[modal.index("const title = target.add") : modal.index("openModal({")]
|
||||
assert '? base.name || "기준점"' in title and ': route!.name || "거래처"' in title
|
||||
assert '"요소 추가 — 기준점 · 거래처"' in title
|
||||
assert 'title: "시점 · 중점",' in modal and "공사지 —" not in modal
|
||||
assert re.search(r"title: `?[^\n]*(거래처|기준점|공사지) —", modal) is None
|
||||
|
||||
|
||||
def test_시점_표지_고정():
|
||||
place = _read("_Place.ts")
|
||||
assert "startPoint(liveMilestone(projectId))" in place # 거리이정표가 쓰는 시점
|
||||
assert "center: start" in place # 소재지 없음 = 시점 중심
|
||||
assert "movable: false, fixed: true" in place and 'name: "시점"' in place
|
||||
assert "...(s" in place and " : [])," in place # 시점 없음 = 표지 없음
|
||||
kakao = _read("_KakaoMap.ts")
|
||||
assert "fixed?: boolean;" in kakao
|
||||
assert "const moving = pins.filter((p) => !p.fixed);" in kakao
|
||||
assert "...moving.map(" in kakao # 처음 보기 = 소재지 중심(시점은 맞춤에서 빠짐)
|
||||
assert "moving.find(" in kakao # 보기 이동에서도 빠짐
|
||||
assert "if (p.fixed)" in kakao and "b08-milestone__map-start" in kakao
|
||||
css = _read(".css")
|
||||
assert ".b08-milestone__map-start {" in css
|
||||
@@ -0,0 +1,89 @@
|
||||
"""263-1 「운반 단가」 모달 = 고른 항목 입력만 + 거리 자동 — 사이클 손 칸 · 8-2-8 표 고르기 없음 ·
|
||||
[변경] = 단가연결 한 목록 · 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 · 손 값 이김 ·
|
||||
옛 `cycle` = 서버 읽기 그대로(모달 [적용]하면 지움)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Haul_Cycle import FILL, cycle_time, fill_given # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Sangeun import _input_names, hand_sources # noqa: E402
|
||||
|
||||
DIST = {"L1": 23.8, "L2": 10.5}
|
||||
|
||||
|
||||
def _src(name: str) -> str:
|
||||
return (ROOT / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_왼쪽_칸_고른_로직_입력만():
|
||||
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
|
||||
for gone in ("data-haul-cycle", "CC000054", "사이클시간 입력", "표에서 고르기", "다른 종류",
|
||||
"numberField", "loadTable"): # fmt: skip
|
||||
assert gone not in modal, gone
|
||||
# 칸 = 로직 입력 하나에 한 줄(createValueControl) · 사이클 줄 = 거리 칸 있을 때만
|
||||
assert "st.inputs.map((input) =>" in modal and "createValueControl(" in modal
|
||||
assert "names().some((n) => n in CYCLE_FILL)" in modal
|
||||
assert "포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km" in modal
|
||||
# 거리 받는 마스터 로직 = 거리 칸 하나씩
|
||||
for code, want in (("GF000284", "운반거리"), ("GF000134", "Cm"), ("GF000283", "사이클시간")):
|
||||
names = _input_names(code)
|
||||
assert [n for n in names if n in FILL] == [want], (code, names)
|
||||
|
||||
|
||||
def test_변경_한_목록():
|
||||
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
|
||||
assert "openPriceLink({ ...o, onPick: usePicked })" in modal and "importLib(o, p)" in modal
|
||||
link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts")
|
||||
assert "o.onPick ??" in link and "openHaulModal({ ...link, ref" in link
|
||||
milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts")
|
||||
assert "other:" not in milestone and "openPriceLink" not in milestone
|
||||
|
||||
|
||||
def test_거리_칸_자동_손_값_이김():
|
||||
ref = {"given": {"재료": "토사"}, "cycle": {}, "own": []}
|
||||
got = fill_given(ref, _input_names("GF000284"), DIST)
|
||||
assert got["운반거리"] == 34.3 # L1 + L2
|
||||
cm = cycle_time(DIST)["cm"]
|
||||
assert fill_given(ref, ["Cm"], DIST)["Cm"] == cm
|
||||
assert fill_given(ref, ["사이클시간", "t2"], DIST) == {
|
||||
"재료": "토사", "사이클시간": cm, "t2": cycle_time(DIST)["t2"]} # fmt: skip
|
||||
mine = {"given": {"운반거리": 5}, "own": ["운반거리"]}
|
||||
assert fill_given(mine, ["운반거리"], DIST)["운반거리"] == 5
|
||||
|
||||
|
||||
def test_ts_짝_사이클_비움():
|
||||
"""모달 = cycle {} · 옛 own L1 · L2 · t1 뺌 → TS fillGiven = 파이썬과 같음."""
|
||||
calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts"
|
||||
ref = {"given": {"qt": 15}, "cycle": {}, "own": []}
|
||||
body = (
|
||||
f"\nconsole.log(JSON.stringify(fillGiven({json.dumps(ref)}, ['Cm', '운반거리'],"
|
||||
f" {json.dumps(DIST)})));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == fill_given(ref, ["Cm", "운반거리"], DIST)
|
||||
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
|
||||
assert (
|
||||
"cycle: {}, given: st.given" in modal and 'OLD_OWN = new Set(["L1", "L2", "t1"])' in modal
|
||||
)
|
||||
assert "cycle: st.cycle" not in modal # [적용] 새 연결에 cycle 없음
|
||||
|
||||
|
||||
def test_옛_cycle_서버_읽기_그대로():
|
||||
old = {"tab": "logic", "code": "GF000134", "given": {"Cm": 1}, "cycle": {"V1": 20}}
|
||||
user = {"haul_refs": {"haul:r-u1": old}, "haul_dist": {"haul:r-u1": DIST}}
|
||||
((_k, _u, src),) = hand_sources(user)
|
||||
assert src["given"]["Cm"] == cycle_time({**DIST, "V1": 20})["cm"]
|
||||
new = {**old, "cycle": None}
|
||||
user["haul_refs"]["haul:r-u1"] = new
|
||||
((_k, _u, src),) = hand_sources(user)
|
||||
assert src["given"]["Cm"] == cycle_time(DIST)["cm"]
|
||||
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 265 — B06 [상세] 표 위 CAD 칸 · 접기 · 차례 · 구조물 글 · 관 길이 정수.
|
||||
|
||||
- 265-3 원형 마크 L = 사사오입 정수(TS `pipeLengthWhole` = 파이썬 짝 같은 값).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_265_note_cad.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import _culvert_set # noqa: E402
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402
|
||||
culvert_note_entities,
|
||||
)
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
_PIPE = {"pipe_diameter_mm": 800}
|
||||
|
||||
|
||||
def _mirror(cases: list[dict]) -> list[dict]:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"],
|
||||
input=json.dumps(cases, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _labels(entities: list[dict]) -> list[str]:
|
||||
return [e["shapeData"]["label"] for e in entities if e["type"] == "Text"]
|
||||
|
||||
|
||||
# ── 265-3 관 길이 정수 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("length", "label"),
|
||||
[(8.5, "L=9m"), (8.4, "L=8m"), (8.45, "L=8m"), (7.0, "L=7m"), (None, "L=")],
|
||||
)
|
||||
def test_원형_마크_L_은_사사오입_정수_TS_파이썬_같음(length, label):
|
||||
design = {} if length is None else {"pipe_length_m": length}
|
||||
culvert = {**_culvert_set(_PIPE), "chainage_m": 40.0}
|
||||
case = {
|
||||
"section": {"chainage_m": 40.0, "culvert": culvert, "design": design},
|
||||
"station": "NO.2",
|
||||
"options": _PIPE,
|
||||
"chainage": 40.0,
|
||||
"interval": 20.0,
|
||||
}
|
||||
[ts] = _mirror([case])
|
||||
py = culvert_note_entities(40.0, culvert, design, "NO.2", _PIPE)
|
||||
assert _labels(ts["entities"])[2] == label
|
||||
assert _labels(py)[2] == label
|
||||
@@ -0,0 +1,85 @@
|
||||
"""PLAN 266-1 — B06 [상세] 처음 가로 범위 = 좌측 「횡단 보기 설정」 반폭(개별 표시 반폭이면 그것).
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Section_View.ts`(`detailChart` 처음 배율) ·
|
||||
`B06_Section_UI_Cross_Detail.ts`(`attachDetailZoom` homeScale) · `A00_Common/b_svg_zoom_pan.ts`.
|
||||
실제 TS 를 `helper_266_detail_home.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
B06 = os.path.join(ROOT, "B06_Section")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
with open(os.path.join(B06, name), encoding="utf-8") as f:
|
||||
return f.read().replace(chr(13), "")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_266_detail_home.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_처음_가로_반폭_15(out):
|
||||
# 칸 맞춤이 가로를 넓혀도 처음 배율로 되좁힘 = −15 ~ +15
|
||||
case = out["global15"]
|
||||
assert case["widened"] is True and case["homeScale"] > 1
|
||||
assert case["first"] == [-15, 15]
|
||||
|
||||
|
||||
def test_개별_반폭_측점(out):
|
||||
assert out["station10"]["first"] == [-10, 10]
|
||||
|
||||
|
||||
def test_안_넓힌_칸_그대로(out):
|
||||
case = out["tall15"]
|
||||
assert case["widened"] is False and case["homeScale"] == 1
|
||||
assert case["first"] == [-15, 15]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["global15", "station10", "tall15"])
|
||||
def test_처음으로_같은_범위_줌_이동_그대로(out, name):
|
||||
case = out[name]
|
||||
assert case["reset"] == case["first"] # [⤢]
|
||||
assert case["dblclick"] == case["first"] # 더블클릭
|
||||
assert case["wheelScale"] == pytest.approx(1.15) # 휠 줌
|
||||
assert case["panned"] is True # 가운데 단추 끌기
|
||||
assert case["minScale"] == 1 # 줄이면 칸 맞춤 전체까지
|
||||
|
||||
|
||||
def test_배선():
|
||||
view = _read("B06_Section_UI_Section_View.ts")
|
||||
assert (
|
||||
"const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;"
|
||||
in view
|
||||
)
|
||||
assert "return { svg, axes, homeScale };" in view
|
||||
# 반폭 = 개별 표시 반폭 › 좌측 「횡단 보기 설정」(카드와 같은 길)
|
||||
assert (
|
||||
"effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth)"
|
||||
in view
|
||||
)
|
||||
src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split())
|
||||
assert "drawn.homeScale" in src and "homeScale, onChange" in src
|
||||
assert "homeScale: drawn.homeScale" in src
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 267-1 — B06 횡단 카드 「관매설 …」 글 크기 = 「노폭 …」 글 크기 · 줄 겹침 없음.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_267_1_pipe_info_font.py -q -p no:cacheprovider --basetemp=tmp/pt
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CSS = (ROOT / "B06_Section" / "B06_Section_UI_Style_Cross_Areas.css").read_text(encoding="utf-8")
|
||||
TYPES = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Types.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _font_px(selector: str) -> float:
|
||||
"""셀렉터 블록의 마지막 font-size(px) — 뒤에 오는 규칙이 이긴다."""
|
||||
blocks = re.findall(re.escape(selector) + r"\s*\{([^}]*)\}", CSS)
|
||||
sizes = [float(m) for b in blocks for m in re.findall(r"font-size:\s*([\d.]+)px", b)]
|
||||
assert sizes, selector
|
||||
return sizes[-1]
|
||||
|
||||
|
||||
def test_관_정보_글_크기가_노폭_라벨과_같다() -> None:
|
||||
road = _font_px(".b06-chart__carriageway-label")
|
||||
info = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info")
|
||||
assert road == info == 11.0
|
||||
# 다른 culvert-label(집수정 · 박스 이름)은 그대로 9px
|
||||
assert _font_px(".b06-chart__culvert-label") == 9.0
|
||||
|
||||
|
||||
def test_관_정보_글_줄이_노폭_라벨과_겹치지_않는다() -> None:
|
||||
step = float(re.search(r"- (\d+) \* \(stacked \+ 1\)", TYPES).group(1))
|
||||
font = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info")
|
||||
road_font = _font_px(".b06-chart__carriageway-label")
|
||||
asc, desc = 0.8, 0.2 # 글자 상자 근사 — 기준선 위 80% · 아래 20%
|
||||
road_top = 0 - asc * road_font # 노폭 라벨 기준선 y=0
|
||||
for n in (1, 2, 3): # 관 정보 글이 쌓인 줄 수
|
||||
info_y = -step * n
|
||||
info_bottom = info_y + desc * font
|
||||
assert info_bottom < road_top, (n, info_bottom, road_top)
|
||||
if n > 1: # 윗줄 바닥 < 아랫줄 윗면
|
||||
upper_bottom = -step * n + desc * font
|
||||
lower_top = -step * (n - 1) - asc * font
|
||||
assert upper_bottom < lower_top, n
|
||||
Reference in New Issue
Block a user