Merge remote-tracking branch 'origin/dev' into sub_desktop_1
This commit is contained in:
@@ -71,6 +71,8 @@ config/formula_node/
|
||||
config/spreadsheet_node/
|
||||
# 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물.
|
||||
config/b07_extent_node/
|
||||
# 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1).
|
||||
config/b07_note_node/
|
||||
|
||||
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
|
||||
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
|
||||
|
||||
@@ -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 [저장]이 내보낸다. */
|
||||
|
||||
@@ -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 });
|
||||
@@ -148,6 +156,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();
|
||||
@@ -180,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();
|
||||
}
|
||||
@@ -207,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();
|
||||
};
|
||||
// 더블클릭 원복.
|
||||
|
||||
@@ -82,15 +82,16 @@ export function createSectionFrame(
|
||||
heading.textContent = title;
|
||||
if (view === "profile") {
|
||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
|
||||
// ui-panel-form: 좌측 패널 컨테이너 공용 양식(269 · 「구조물 배치」 기준 칸 · 라벨 · 단추) — 두 보기 모두.
|
||||
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
|
||||
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "ui-collapsible__title";
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route__panel-body";
|
||||
root.append(heading, body);
|
||||
return { root, body };
|
||||
}
|
||||
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
root.className = `b06-profile__group ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "b06-profile__group-legend ui-collapsible__title";
|
||||
root.append(heading);
|
||||
return { root, body: root };
|
||||
|
||||
@@ -46,7 +46,8 @@ const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6;
|
||||
|
||||
/**
|
||||
* 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1)
|
||||
* ② 고른 측점 그대로(일반 카드 → 구조물 측점) ③ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1).
|
||||
* ② 그 자리 구조물 측점(같은 m 면 측점 하나 · 구조물이 위 · 272 — 일반 카드 id 는 종단도가 숨긴 측점)
|
||||
* ③ 고른 측점 그대로(일반 카드) ④ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1).
|
||||
*/
|
||||
export function profilePickOf(
|
||||
selection: DesignSelection | null,
|
||||
@@ -55,16 +56,16 @@ export function profilePickOf(
|
||||
if (!selection || selection.chainageM === null) return { stationId: null };
|
||||
if (selection.structureId) return { structureId: selection.structureId };
|
||||
const at = selection.chainageM;
|
||||
const irregular = source.irregular();
|
||||
const exact = irregular.find((entry) => sameAt(entry.chainage_m, at));
|
||||
if (exact) return { stationId: irregularStationId(exact.id) };
|
||||
const card = source.sections().find((entry) => sameAt(entry.chainage_m, at));
|
||||
if (card) return { stationId: card.station_id };
|
||||
const irregular = source.irregular();
|
||||
const own =
|
||||
irregular.find((entry) => sameAt(entry.chainage_m, at)) ??
|
||||
nearestWithin(
|
||||
irregular.filter((entry) => entry.origin === "pipe"),
|
||||
at,
|
||||
(entry) => entry.chainage_m,
|
||||
);
|
||||
const own = nearestWithin(
|
||||
irregular.filter((entry) => entry.origin === "pipe"),
|
||||
at,
|
||||
(entry) => entry.chainage_m,
|
||||
);
|
||||
if (own) return { stationId: irregularStationId(own.id) };
|
||||
const target = graphTargetAt(null, at, source.pipes(), source.sections());
|
||||
return "pipeChainageM" in target
|
||||
|
||||
@@ -42,7 +42,6 @@ import type {
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
emptyEdits,
|
||||
toAlignmentBase,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
@@ -70,18 +69,19 @@ import {
|
||||
createElevationHold,
|
||||
createProfileDrag,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
|
||||
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
||||
import {
|
||||
planCurveWarning,
|
||||
sampleAt,
|
||||
snapToMasterStations,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
||||
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
import { frameOf } from "@util/common_util_view_window";
|
||||
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
|
||||
import { noticeDroppedEdits } from "./design_save";
|
||||
import { createProfileOverlays } from "./design_profile_overlays";
|
||||
import { bindProfileViewport } from "./design_profile_viewport";
|
||||
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
|
||||
import "../../B06_Section/B06_Section_UI_Style.css";
|
||||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||||
@@ -219,6 +219,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
|
||||
let stationDisplay = { station: 0, cumulative: 0 };
|
||||
let base: AlignmentBase | null = null;
|
||||
/** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */
|
||||
let storedBase: AlignmentBase | null = null;
|
||||
let alignment: ProfileAlignment | null = null;
|
||||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||||
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
||||
@@ -330,6 +332,33 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
history.record();
|
||||
}
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리
|
||||
* 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물
|
||||
* 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */
|
||||
function listed(): AlignmentBase | null {
|
||||
if (!storedBase || !detail) return storedBase;
|
||||
const server = detail.longitudinal.stations;
|
||||
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
||||
const list = oneStationList(server, planted);
|
||||
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
||||
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
||||
const gone = server
|
||||
.filter((station) => station.kind === "irregular" && !station.grid)
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
||||
const born = list
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
||||
* 실제 렌더링하도록 모은다.
|
||||
@@ -432,6 +461,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 다른 영역에서 고른 자리가 화면 밖이면 가운데로(칠한 뒤 · `design_profile_viewport`). */
|
||||
const reveal = createSelectionReveal(body);
|
||||
const revealPick = (): void =>
|
||||
reveal(
|
||||
(selectedStationId ?? selectedStructureId)
|
||||
? `${selectedStationId}|${selectedStructureId}`
|
||||
: null,
|
||||
);
|
||||
// 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계).
|
||||
const unbindViewport = bindProfileViewport({
|
||||
body,
|
||||
@@ -472,7 +509,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
savedEdits = stored?.edits ?? emptyEdits();
|
||||
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||||
}
|
||||
base = stored ? toAlignmentBase(stored) : null;
|
||||
storedBase = stored ? toAlignmentBase(stored) : null;
|
||||
base = listed();
|
||||
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
||||
if (carried) store.replace(carried);
|
||||
alignment = base ? buildAlignment(base, store.edits()) : null;
|
||||
@@ -521,6 +559,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
|
||||
selectedStructureId = structureIdAtStation(stationId);
|
||||
draw();
|
||||
revealPick();
|
||||
},
|
||||
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
|
||||
setEnforceMinCover(enforce: boolean) {
|
||||
@@ -535,9 +574,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
|
||||
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
|
||||
setGradeLimit(maxGradePct: number) {
|
||||
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
||||
if (!base || !storedBase || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
||||
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
|
||||
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } };
|
||||
const policy = { ...base.policy, max_grade_pct: maxGradePct };
|
||||
storedBase = { ...storedBase, policy };
|
||||
base = { ...base, policy };
|
||||
alignment = buildAlignment(base, store.edits());
|
||||
draw();
|
||||
},
|
||||
@@ -547,7 +588,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
},
|
||||
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
||||
setIrregularStations(stations: IrregularStation[]) {
|
||||
irregularStations = snapToMasterStations(alignment, stations);
|
||||
irregularStations = stations;
|
||||
base = listed();
|
||||
if (base) alignment = buildAlignment(base, store.edits());
|
||||
draw();
|
||||
},
|
||||
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
|
||||
@@ -572,6 +615,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
|
||||
selectedStationId = keep ? selectedStationId : own;
|
||||
draw();
|
||||
revealPick();
|
||||
},
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||||
setStationDisplay(next: { station: number; cumulative: number }) {
|
||||
@@ -583,10 +627,12 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
|
||||
*/
|
||||
resetStationEdit(chainageM: number, moveTo?: number) {
|
||||
const key = chainageKey(chainageM);
|
||||
// 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272)
|
||||
const edits = store.edits();
|
||||
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
|
||||
store.resetStation(chainageM, moveTo);
|
||||
const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find(
|
||||
(entry) => Math.abs(Number(entry) - chainageM) < 0.006,
|
||||
);
|
||||
if (key !== undefined) store.resetStation(Number(key), moveTo);
|
||||
},
|
||||
isDirty: () => store.dirty(),
|
||||
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
||||
@@ -596,8 +642,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
noticeDroppedEdits(saved);
|
||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||
if (next?.base_pvi?.length) {
|
||||
base = toAlignmentBase(next);
|
||||
alignment = next;
|
||||
storedBase = toAlignmentBase(next);
|
||||
base = listed();
|
||||
alignment = buildAlignment(base!, store.edits());
|
||||
if (detail) detail.longitudinal.profile_alignment = next;
|
||||
}
|
||||
store.markSaved();
|
||||
@@ -606,6 +653,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
clear() {
|
||||
detail = null;
|
||||
base = null;
|
||||
storedBase = null;
|
||||
alignment = null;
|
||||
selectedStationId = null;
|
||||
balanceBar.replaceChildren();
|
||||
|
||||
@@ -68,3 +68,33 @@ export function bindProfileViewport(deps: ProfileViewportDeps): () => void {
|
||||
resizeObserver.observe(body);
|
||||
return () => resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
/** 고른 것이 보이는 칸 가장자리에서 이만큼(폭 비율) 안쪽이면 그대로 둔다. */
|
||||
const REVEAL_EDGE = 0.1;
|
||||
|
||||
/**
|
||||
* 고른 측점선(없으면 고른 구조물 알약)이 종단도 보이는 칸 밖이면 가운데로 민다 — 다른 영역(횡단 카드 ·
|
||||
* 3D · 배수유역도 · 좌측 목록)에서 고른 자리도 종단에서 바로 보이게. 칠한 결과(강조된 선 · 알약)를
|
||||
* 보고 옮긴다 · 같은 고름을 다시 칠할 때는 안 밂(사용자가 옮겨 본 자리를 지킴) · 그래프 안에서
|
||||
* 누른 것은 이미 보이는 자리라 안 움직임. `key` = 고름 열쇠(풀면 null).
|
||||
*/
|
||||
export function createSelectionReveal(body: HTMLElement): (key: string | null) => void {
|
||||
let shown: string | null = null;
|
||||
return (key) => {
|
||||
if (key === null || key === shown) {
|
||||
shown = key;
|
||||
return;
|
||||
}
|
||||
const mark =
|
||||
body.querySelector(".b06-chart__station--selected") ??
|
||||
body.querySelector(".b05-structure__mark.is-selected");
|
||||
if (!mark || body.clientWidth <= 0) return; // 아직 안 그려짐 — 다음 칠하기에서
|
||||
shown = key;
|
||||
const box = mark.getBoundingClientRect();
|
||||
const view = body.getBoundingClientRect();
|
||||
const at = box.left + box.width / 2 - view.left;
|
||||
if (at >= view.width * REVEAL_EDGE && at <= view.width * (1 - REVEAL_EDGE)) return;
|
||||
// 바로 옮김 — 고른 뒤 잇따르는 다시 그리기가 부드러운 이동을 끊음(다시 그리기는 지금 자리를 지킴)
|
||||
body.scrollLeft += at - view.width / 2;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
|
||||
import { paintMapPick } from "./design_pick_profile";
|
||||
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
|
||||
import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
|
||||
@@ -342,15 +343,14 @@ export async function mountCrossView(
|
||||
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
|
||||
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
|
||||
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
|
||||
removeStructure: (structureId) => {
|
||||
structuresPanel.removeStructureById(structureId);
|
||||
},
|
||||
removeStructure: (structureId) => void structuresPanel.removeStructureById(structureId),
|
||||
movePipe: (fromChainageM, toChainageM) =>
|
||||
structuresPanel.movePipeTo(fromChainageM, toChainageM),
|
||||
moveStructure: (structureId, toChainageM) => {
|
||||
structuresPanel.moveStructureTo(structureId, toChainageM);
|
||||
},
|
||||
selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
|
||||
pipesChanged: () => void refreshDetailForStructures(),
|
||||
});
|
||||
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
|
||||
const pipeOptionsContext: PipeOptionsContext = {
|
||||
@@ -505,6 +505,7 @@ export async function mountCrossView(
|
||||
appliedPick = null;
|
||||
restoreStructurePick();
|
||||
},
|
||||
paintMap: (selection) => shared.profile?.side && paintMapPick(shared.profile.side, selection),
|
||||
});
|
||||
/** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */
|
||||
function releaseCross(): void {
|
||||
|
||||
@@ -122,6 +122,7 @@ export async function mountProfileView(
|
||||
bridge.setPipes(pipes);
|
||||
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
|
||||
profilePanel.setMinCoverPipes(pipes);
|
||||
snapOld();
|
||||
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
|
||||
};
|
||||
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
|
||||
@@ -245,6 +246,14 @@ export async function mountProfileView(
|
||||
selectStationOfPipe(chainageM);
|
||||
};
|
||||
|
||||
let oldSnapped = false;
|
||||
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */
|
||||
function snapOld(): void {
|
||||
if (oldSnapped || restoring || !lastPipes) return;
|
||||
oldSnapped = true;
|
||||
panel.structures.snapOld();
|
||||
}
|
||||
|
||||
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
|
||||
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
|
||||
function restorePick(): void {
|
||||
@@ -653,6 +662,7 @@ export async function mountProfileView(
|
||||
} finally {
|
||||
progress.remove();
|
||||
restoring = false;
|
||||
snapOld();
|
||||
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
|
||||
}
|
||||
|
||||
|
||||
@@ -58,11 +58,17 @@ export function buildKnowledgeChat(): HTMLElement {
|
||||
input.rows = 2;
|
||||
input.required = true;
|
||||
input.maxLength = 500;
|
||||
input.placeholder = "메시지를 입력하세요";
|
||||
input.placeholder = "메시지를 입력하세요 (Shift+Enter 줄바꿈)";
|
||||
input.setAttribute("aria-label", "질문");
|
||||
const submit = document.createElement("button");
|
||||
submit.type = "submit";
|
||||
submit.textContent = "보내기";
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
submit.click();
|
||||
}
|
||||
});
|
||||
form.append(input, submit);
|
||||
panel.append(header, messages, form);
|
||||
wrap.append(toggle, panel);
|
||||
|
||||
@@ -536,8 +536,8 @@
|
||||
.b01-knowledge-chat__toggle,
|
||||
.b01-knowledge-chat__form button {
|
||||
border: 0;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -565,7 +565,7 @@
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
box-shadow: 0 8px 28px #0003;
|
||||
color: var(--color-text-primary, #222);
|
||||
color: var(--color-text-body);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
@@ -588,8 +588,8 @@
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar svg {
|
||||
@@ -631,14 +631,15 @@
|
||||
.b01-knowledge-chat__user {
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply {
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom-left-radius: 4px;
|
||||
background: var(--color-background, #f4f4f4);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply details {
|
||||
@@ -722,7 +723,7 @@
|
||||
.b01-knowledge-source-modal__content code {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: var(--color-background, #f4f4f4);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content .ui-markdown-table {
|
||||
@@ -746,7 +747,7 @@
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content th {
|
||||
background: var(--color-background, #f4f4f4);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form {
|
||||
@@ -765,11 +766,17 @@
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form textarea::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form button {
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -272,6 +272,8 @@ def resolve_extra_stations(
|
||||
|
||||
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
|
||||
되짚기 때문이다(B05_Profile_Structures_Migration).
|
||||
TS 짝 = `common_util_structure_station.ts` `plantedChainages`(화면 측점 한 벌 · 272) ·
|
||||
거울 시험 = `resources/tester/test_272_1_station_list.py`.
|
||||
"""
|
||||
types = structure_type_map()
|
||||
derived: list[tuple[float, str]] = []
|
||||
@@ -549,7 +551,7 @@ def run_section_generation(
|
||||
}
|
||||
|
||||
|
||||
def generate_irregular_sections(
|
||||
def sample_structure_sections(
|
||||
project_root: Path,
|
||||
route_data_path: str,
|
||||
filter_key: str,
|
||||
@@ -559,16 +561,15 @@ def generate_irregular_sections(
|
||||
extra_stations: tuple[tuple[float, str], ...],
|
||||
options: SectionGenerationOptions | None = None,
|
||||
crs: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""비정규 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다.
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀.
|
||||
|
||||
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다. 격자 측점과 똑같은 접선·오프셋
|
||||
샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각
|
||||
`cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
|
||||
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
|
||||
격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점
|
||||
(`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기
|
||||
(`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2).
|
||||
"""
|
||||
if not extra_stations:
|
||||
return []
|
||||
return [], []
|
||||
base = options or SectionGenerationOptions()
|
||||
merged_options = SectionGenerationOptions(
|
||||
station_interval_m=base.station_interval_m,
|
||||
@@ -589,18 +590,48 @@ def generate_irregular_sections(
|
||||
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
|
||||
design_curves=_load_design_curves(project_root),
|
||||
)
|
||||
irregular_stations = [
|
||||
station
|
||||
for station in result["longitudinal"]["stations"]
|
||||
if station.get("kind") == "irregular"
|
||||
]
|
||||
|
||||
def structural(entry: dict[str, Any]) -> bool:
|
||||
return entry.get("kind") == "irregular" or bool(entry.get("structure"))
|
||||
|
||||
return (
|
||||
[station for station in result["longitudinal"]["stations"] if structural(station)],
|
||||
[cross for cross in result["cross_sections"] if structural(cross)],
|
||||
)
|
||||
|
||||
|
||||
def generate_irregular_sections(
|
||||
project_root: Path,
|
||||
route_data_path: str,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
smooth: bool,
|
||||
*,
|
||||
extra_stations: tuple[tuple[float, str], ...],
|
||||
options: SectionGenerationOptions | None = None,
|
||||
crs: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음).
|
||||
|
||||
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272).
|
||||
각각 `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
|
||||
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
|
||||
"""
|
||||
irregular_stations, crosses = sample_structure_sections(
|
||||
project_root,
|
||||
route_data_path,
|
||||
filter_key,
|
||||
method,
|
||||
smooth,
|
||||
extra_stations=extra_stations,
|
||||
options=options,
|
||||
crs=crs,
|
||||
)
|
||||
if not irregular_stations:
|
||||
return []
|
||||
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
|
||||
cross_dir.mkdir(parents=True, exist_ok=True)
|
||||
for cross_section in result["cross_sections"]:
|
||||
if cross_section.get("kind") != "irregular":
|
||||
continue
|
||||
for cross_section in crosses:
|
||||
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
|
||||
atomic_write_json(cross_file, cross_section)
|
||||
return irregular_stations
|
||||
|
||||
@@ -415,8 +415,10 @@ def generate_sections(
|
||||
kind = "bp"
|
||||
elif abs(float(value) - total) <= 1e-6:
|
||||
kind = "ep"
|
||||
elif rounded in extras and rounded not in regular_rounded:
|
||||
# 격자와 겹치지 않는 비정규 측점만 irregular. 규칙 격자와 겹치면 규칙 측점 우선.
|
||||
elif rounded in extras:
|
||||
# 구조물 측점이 위(272 · 요구 167) — 격자와 같은 m 여도 구조물 측점(irregular) 하나 ·
|
||||
# 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림
|
||||
# (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`).
|
||||
kind = "irregular"
|
||||
else:
|
||||
kind = "regular"
|
||||
@@ -466,9 +468,10 @@ def generate_sections(
|
||||
"uphill_side": uphill_side,
|
||||
"frame": frame,
|
||||
}
|
||||
# 구조물은 kind와 무관하게 부착한다(규칙 격자와 겹쳐 regular가 돼도 구조물 정보는 유지).
|
||||
if rounded in extras:
|
||||
station["structure"] = extras[rounded]
|
||||
if rounded in regular_rounded:
|
||||
station["grid"] = True # 숨은 일반 측점 자리(272)
|
||||
stations.append(station)
|
||||
|
||||
samples = []
|
||||
|
||||
@@ -241,6 +241,45 @@ async def update_longitudinal_grade_summary(
|
||||
)
|
||||
|
||||
|
||||
async def sync_cross_section_rows(
|
||||
connection: aiomysql.Connection, route_id: int, chainages: list[float]
|
||||
) -> int:
|
||||
"""종단 정본 측점에 횡단 행을 맞춘다 — 정본에 없는 측점의 행(옮긴 · 지운 구조물 측점의 옛 자리).
|
||||
|
||||
0.5 m 안에 행 없는 새 측점이 있으면 그리로 옮긴다(옛 소수 → 정수 m · 사용자 설계 값 그대로 ·
|
||||
면적은 저장 뒤 서버 재계산) · 나머지는 지운다 — 남기면 수량(B08 토적)이 옛 자리 측점을 그대로
|
||||
셈(272). 같은 측점 = 0.01 m 안(`_rowless_station_chainages` 와 같은 폭). 측점 목록이 비면
|
||||
아무것도 안 건드린다. 옮기거나 지운 행 수.
|
||||
"""
|
||||
if not chainages:
|
||||
return 0
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT id, chainage_m FROM cross_sections WHERE route_id = %s", (route_id,)
|
||||
)
|
||||
rows = [(int(row[0]), float(row[1])) for row in await cursor.fetchall()]
|
||||
orphans = [
|
||||
(row_id, at) for row_id, at in rows if all(abs(at - c) >= 0.01 for c in chainages)
|
||||
]
|
||||
free = [c for c in chainages if all(abs(c - at) >= 0.01 for _, at in rows)]
|
||||
pairs = sorted(
|
||||
(abs(c - at), row_id, c) for row_id, at in orphans for c in free if abs(c - at) <= 0.5
|
||||
)
|
||||
moved: dict[int, float] = {}
|
||||
for _gap, row_id, chainage in pairs:
|
||||
if row_id not in moved and chainage not in moved.values():
|
||||
moved[row_id] = chainage
|
||||
for row_id, chainage in moved.items():
|
||||
await cursor.execute(
|
||||
"UPDATE cross_sections SET chainage_m = %s WHERE id = %s", (chainage, row_id)
|
||||
)
|
||||
dropped = [row_id for row_id, _ in orphans if row_id not in moved]
|
||||
if dropped:
|
||||
marks = ", ".join(["%s"] * len(dropped))
|
||||
await cursor.execute(f"DELETE FROM cross_sections WHERE id IN ({marks})", dropped)
|
||||
return len(orphans)
|
||||
|
||||
|
||||
async def get_surface_crs_epsg(
|
||||
connection: aiomysql.Connection, project_id: UUID, surface_model_id: int
|
||||
) -> int | None:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -18,11 +19,16 @@ from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profi
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
_load_pipe_points,
|
||||
_load_route_polyline,
|
||||
find_cross_file,
|
||||
generate_irregular_sections,
|
||||
resolve_extra_stations,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
|
||||
from B05_Profile.B05_Profile_Repository import (
|
||||
get_surface_crs_epsg,
|
||||
sync_cross_section_rows,
|
||||
update_longitudinal_grade_summary,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_latest_section_options,
|
||||
@@ -193,12 +199,16 @@ async def sync_uphill_overrides_into_designs(
|
||||
_SAME_STATION_M = 0.006
|
||||
|
||||
|
||||
def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[str, Any] | None:
|
||||
def _follow_moved_stations(
|
||||
data: dict[str, Any], before: list[float], previous: list[float] | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다.
|
||||
|
||||
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
|
||||
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안)만 옮기고 나머지는 지운다.
|
||||
바뀌었으면 새 등급 요약(DB 몫)을, 아니면 `None`.
|
||||
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤
|
||||
자리(272)만 옮기고 나머지는 지운다.
|
||||
측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을,
|
||||
아니면 `None`.
|
||||
"""
|
||||
stored = data.get("profile_alignment")
|
||||
if not isinstance(stored, dict) or not stored.get("base_pvi"):
|
||||
@@ -218,6 +228,11 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
|
||||
),
|
||||
None,
|
||||
)
|
||||
# 옛 소수 측점 → 정수 m 맞춤(272 · 요구 167) — 맞춘 자리가 새 측점이면 꼭짓점 · 편집도
|
||||
# 그리로(지우면 계획선이 그 자리에서 바뀜 · 화면 `followMovedNodes` 와 짝)
|
||||
snapped = chainage_key(math.floor(old + 0.5))
|
||||
if near is None and snapped in after_keys and snapped not in before_keys:
|
||||
near = float(snapped)
|
||||
moves[chainage_key(old)] = None if near is None else chainage_key(near)
|
||||
|
||||
def moved(value: float) -> tuple[bool, str | None]:
|
||||
@@ -254,7 +269,15 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
|
||||
changed = True
|
||||
if new_key is not None:
|
||||
base.append({**node, "chainage_m": float(new_key)})
|
||||
if not changed:
|
||||
# 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
|
||||
# 줄이 없는 판은 맞추기 전 측점(`previous`)과 견줌
|
||||
rows = stored.get("stations")
|
||||
listed = (
|
||||
[round(float(row.get("chainage_m", 0.0)), 6) for row in rows]
|
||||
if isinstance(rows, list)
|
||||
else previous
|
||||
)
|
||||
if not changed and listed in (None, [round(value, 6) for value in after]):
|
||||
return None
|
||||
stored["base_pvi"] = base
|
||||
alignment, profile = rebuild_alignment_profile(data, next_edits)
|
||||
@@ -270,12 +293,17 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
|
||||
def _merge_irregular_into_longitudinal(
|
||||
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
|
||||
) -> dict[str, Any] | None:
|
||||
"""종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬).
|
||||
"""종단 정본 stations 의 구조물 측점을 **지금 목록 전체로 다시 맞춘다**(272 · 요구 167).
|
||||
|
||||
B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과
|
||||
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 규칙 측점·표고 샘플은
|
||||
손대지 않는다. 계획선은 사라진 비정규 측점 자리만 고친다(`_follow_moved_stations` —
|
||||
바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 재확정해도 중복이 생기지 않는다.
|
||||
`irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은
|
||||
`grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도),
|
||||
구조물이 떠난 격자 자리(`grid`)는 일반 측점으로 되돌리고, 새 목록의 격자 자리 측점은 그
|
||||
격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 표고 샘플은 손대지 않는다.
|
||||
B06 상세는 종단 파일의 stations 에 맞는 cross 파일만 노출하고 남은 파일을 지운다
|
||||
(`prune_stale_cross_files`). 계획선은 사라진 구조물 측점 자리만 고친다(`_follow_moved_stations`
|
||||
— 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 다시 맞춰도 중복이 생기지 않는다.
|
||||
TS 짝 = `common_util_structure_station.ts` `oneStationList`(화면 측점 한 벌) · 거울 시험 =
|
||||
`resources/tester/test_272_1_station_list.py`.
|
||||
"""
|
||||
root = project_root.resolve()
|
||||
path = (root / longitudinal_file_path).resolve()
|
||||
@@ -290,15 +318,53 @@ def _merge_irregular_into_longitudinal(
|
||||
for station in stations
|
||||
if station.get("kind") == "irregular"
|
||||
]
|
||||
regular = [station for station in stations if station.get("kind") != "irregular"]
|
||||
merged = regular + list(irregular_stations)
|
||||
merged.sort(key=lambda station: float(station.get("chainage_m", 0.0)))
|
||||
data["stations"] = merged
|
||||
summary = _follow_moved_stations(data, before)
|
||||
fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
|
||||
kept: dict[float, dict[str, Any]] = {}
|
||||
left: list[dict[str, Any]] = []
|
||||
for station in stations:
|
||||
if station.get("kind") == "irregular" and not station.get("grid"):
|
||||
continue # 옛 구조물 측점 — 새 목록이 갈음
|
||||
key = round(float(station.get("chainage_m", 0.0)), 6)
|
||||
if key not in fresh and ("structure" in station or station.get("grid")):
|
||||
# 구조물이 떠난 격자 · 시종점 자리 — 일반 측점으로(옛 판 「regular + 글」 포함)
|
||||
station = {k: v for k, v in station.items() if k not in ("grid", "structure")}
|
||||
if station.get("kind") == "irregular":
|
||||
station["kind"] = "regular"
|
||||
left.append(station)
|
||||
kept[key] = station
|
||||
kept.update(fresh)
|
||||
data["stations"] = sorted(kept.values(), key=lambda s: float(s.get("chainage_m", 0.0)))
|
||||
previous = [round(float(station.get("chainage_m", 0.0)), 6) for station in stations]
|
||||
summary = _follow_moved_stations(data, before, previous)
|
||||
atomic_write_json(path, data)
|
||||
_relabel_cross_files(path.parent.parent / "cross_sections", left)
|
||||
return summary
|
||||
|
||||
|
||||
async def drop_rows_off_stations(
|
||||
connection: aiomysql.Connection, route_id: int, project_root: Path, longitudinal_path: str
|
||||
) -> None:
|
||||
"""측점을 다시 맞춘 뒤 — 종단 정본에 없는 측점의 횡단 행을 새 측점으로 옮기거나 지운다(272)."""
|
||||
path = (project_root.resolve() / longitudinal_path).resolve()
|
||||
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
|
||||
await sync_cross_section_rows(
|
||||
connection, route_id, [float(station["chainage_m"]) for station in stations]
|
||||
)
|
||||
|
||||
|
||||
def _relabel_cross_files(cross_dir: Path, stations: list[dict[str, Any]]) -> None:
|
||||
"""일반 측점으로 돌아온 자리의 횡단 파일 글(종류 · 구조물)도 맞춘다 — 카드 제목(272)."""
|
||||
for station in stations:
|
||||
cross_path = find_cross_file(cross_dir, float(station.get("chainage_m", 0.0)))
|
||||
if not cross_path.is_file():
|
||||
continue
|
||||
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
||||
cross.pop("structure", None)
|
||||
cross.pop("grid", None)
|
||||
cross["kind"] = station.get("kind")
|
||||
atomic_write_json(cross_path, cross)
|
||||
|
||||
|
||||
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
|
||||
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
|
||||
SAMPLING_SNAPSHOT_NAME = "sampling.json"
|
||||
@@ -373,14 +439,20 @@ async def _append_irregular_cross_sections(
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
|
||||
# 빈 목록이어도 맞춤 — 옛 구조물 측점 · 떠난 격자 자리 글을 지워야 함(272)
|
||||
irregular_stations = await asyncio.to_thread(_generate)
|
||||
if not irregular_stations:
|
||||
return
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
|
||||
if longitudinal:
|
||||
await asyncio.to_thread(
|
||||
summary = await asyncio.to_thread(
|
||||
_merge_irregular_into_longitudinal,
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
irregular_stations,
|
||||
)
|
||||
if summary is not None: # 계획선을 다시 셈 — DB 등급 요약도 같이(B06 측점 만들기와 같은 길)
|
||||
await update_longitudinal_grade_summary(
|
||||
connection, route_id=route["id"], grade_summary=summary
|
||||
)
|
||||
await drop_rows_off_stations(
|
||||
connection, route["id"], project_root, str(longitudinal["longitudinal_file_path"])
|
||||
)
|
||||
|
||||
@@ -268,7 +268,8 @@ class RouteConfirmRequest(BaseModel):
|
||||
"""경로 확정 요청.
|
||||
|
||||
확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값
|
||||
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 비정규 측점이 없으면 재생성 없이 확정만 한다.
|
||||
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 셋이 다 오면 구조물 측점을 서버 정본 목록
|
||||
전체로 다시 맞춘다(빈 목록이어도 · 272).
|
||||
모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다.
|
||||
"""
|
||||
|
||||
@@ -286,12 +287,8 @@ class RouteConfirmRequest(BaseModel):
|
||||
return tuple((item.chainage_m, item.structure) for item in self.irregular_stations)
|
||||
|
||||
def can_regenerate(self) -> bool:
|
||||
return bool(
|
||||
self.irregular_stations
|
||||
and self.filter_key
|
||||
and self.method
|
||||
and self.surface_model_id is not None
|
||||
)
|
||||
# 화면 목록이 비어도 맞춤 — 측점 목록은 서버 정본에서 뜨고, 옛 구조물 측점을 지워야 함(272)
|
||||
return bool(self.filter_key and self.method and self.surface_model_id is not None)
|
||||
|
||||
|
||||
class RouteConfirmResponse(BaseModel):
|
||||
|
||||
@@ -154,7 +154,7 @@ export function renderBasinRows(
|
||||
.replace("{relief}", basin.relief_m.toFixed(1))
|
||||
.replace("{flow}", String(Math.round(basin.flow_length_m)))
|
||||
.replace("{pipe}", pipe);
|
||||
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(1));
|
||||
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(0)); // 정수 m(167)
|
||||
// 산출 근거(도달시간·강우강도·설계유량)는 툴팁 둘째 줄로 붙인다 — 행이 길어지지 않게.
|
||||
if (basin.tc_minutes != null && basin.design_flow_m3s != null) {
|
||||
row.title +=
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 종단 보기 본체(`design_view_profile`)가 700줄을 넘어 여기로 뗐다(계획서 24-3b).
|
||||
* ========================================================================== */
|
||||
|
||||
import { dropStationsNear } from "./B05_Profile_Util_Station";
|
||||
import { oneStationList } from "../common_util/common_util_structure_station";
|
||||
import { interpolateIrregularStations } from "./B05_Profile_UI_Page_Helpers";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
@@ -24,7 +24,7 @@ export function stationLinesOf(input: StationLinesInput) {
|
||||
// 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고
|
||||
// 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다
|
||||
// (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다.
|
||||
const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular");
|
||||
const regular = oneStationList(detail.longitudinal.stations, []);
|
||||
const injected = interpolateIrregularStations(
|
||||
regular,
|
||||
input.irregular,
|
||||
@@ -48,9 +48,8 @@ export function stationLinesOf(input: StationLinesInput) {
|
||||
return best.elevation_m;
|
||||
};
|
||||
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
|
||||
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와
|
||||
// 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06).
|
||||
return [...dropStationsNear(regular, injected), ...injected].map((station) => {
|
||||
// 측점 한 벌(272) — 같은 m 면 구조물 측점 하나 · 종단 그래프 · 표와 같은 목록(`oneStationList`).
|
||||
return oneStationList(detail.longitudinal.stations, injected).map((station) => {
|
||||
const design = designAt(station.chainage_m);
|
||||
return {
|
||||
...station,
|
||||
|
||||
@@ -34,6 +34,12 @@ import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
||||
import { followedStation } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { followSnapped } from "./B05_Profile_UI_Structures_Snap";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
SAME_STATION_M,
|
||||
structureStationMarks,
|
||||
type StationMark,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
|
||||
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
||||
export interface PipeProjection {
|
||||
@@ -41,6 +47,9 @@ export interface PipeProjection {
|
||||
effective_diameter_mm: number | null;
|
||||
facility: PipeFacility;
|
||||
options?: Record<string, string | number>;
|
||||
/** 독립 기슭막이 구간 — 시작 · 끝도 측점을 심는다(272 · 서버와 같은 규칙). */
|
||||
start_m?: number | null;
|
||||
end_m?: number | null;
|
||||
}
|
||||
|
||||
export interface StructuresBridgeDeps {
|
||||
@@ -58,17 +67,28 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
/** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */
|
||||
let irregularStations: IrregularStation[] = [];
|
||||
|
||||
function pipesToStations(
|
||||
pipes: Array<{
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
facility: PipeFacility;
|
||||
}>,
|
||||
): IrregularStation[] {
|
||||
/** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */
|
||||
function stationAt(
|
||||
chainage: number,
|
||||
): Pick<IrregularStation, "station" | "remainder" | "chainage_m"> {
|
||||
const interval = deps.panel().values().stationInterval || 20;
|
||||
return pipes.map((pipe) => {
|
||||
const station = Math.floor(pipe.chainage_m / interval);
|
||||
const remainder = pipe.chainage_m - station * interval;
|
||||
const station = Math.floor(chainage / interval);
|
||||
return { station, remainder: chainage - station * interval, chainage_m: chainage };
|
||||
}
|
||||
|
||||
/** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서
|
||||
* 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */
|
||||
const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] =>
|
||||
marks
|
||||
.filter((mark) => mark.role !== "anchor")
|
||||
.map((mark) => ({
|
||||
id: `${id}:${mark.role}`,
|
||||
...stationAt(mark.chainage_m),
|
||||
structure: label,
|
||||
}));
|
||||
|
||||
function pipesToStations(pipes: ReadonlyArray<PipeProjection>): IrregularStation[] {
|
||||
return pipes.flatMap((pipe) => {
|
||||
const label =
|
||||
pipe.facility === "pipe"
|
||||
? structureLabel({
|
||||
@@ -77,15 +97,17 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
|
||||
})
|
||||
: FACILITY_NAMES[pipe.facility];
|
||||
return {
|
||||
id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
||||
station,
|
||||
remainder,
|
||||
chainage_m: pipe.chainage_m,
|
||||
structure: label,
|
||||
structureType: "배관",
|
||||
origin: "pipe",
|
||||
};
|
||||
const id = `pipe-${pipe.chainage_m.toFixed(2)}`;
|
||||
return [
|
||||
{
|
||||
id,
|
||||
...stationAt(pipe.chainage_m),
|
||||
structure: label,
|
||||
structureType: "배관" as const,
|
||||
origin: "pipe" as const,
|
||||
},
|
||||
...spanStations(id, label, pipeStationMarks(pipe)),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,7 +137,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
);
|
||||
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
|
||||
gone.forEach((station) => {
|
||||
// 없으면 맞춘 자리의 새 측점 — [정수 맞춤]이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1)
|
||||
// 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272)
|
||||
const same = followedStation(station, gone, born) ?? followSnapped(station, born);
|
||||
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
||||
});
|
||||
@@ -143,29 +165,45 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
|
||||
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
|
||||
let pipeStations: IrregularStation[] = [];
|
||||
let structureTypeMap = new Map<string, { group: string; name: string }>();
|
||||
let structureTypeMap = new Map<
|
||||
string,
|
||||
{ type_id: string; group: string; name: string; placement: string; managed_by: string | null }
|
||||
>();
|
||||
|
||||
/** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
||||
/** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations`
|
||||
* 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
||||
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
|
||||
const interval = deps.panel().values().stationInterval || 20;
|
||||
return structures
|
||||
.filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A")
|
||||
.map((structure) => {
|
||||
const chainage = structure.chainage_m ?? structure.start_m ?? 0;
|
||||
const station = Math.floor(chainage / interval);
|
||||
return {
|
||||
id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`,
|
||||
station,
|
||||
remainder: chainage - station * interval,
|
||||
chainage_m: chainage,
|
||||
structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id,
|
||||
};
|
||||
});
|
||||
return structures.flatMap((structure) => {
|
||||
const type = structureTypeMap.get(structure.type_id);
|
||||
const marks = structureStationMarks(structure, type);
|
||||
const anchor = marks.find((mark) => mark.role === "anchor");
|
||||
if (!anchor) return [];
|
||||
const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`;
|
||||
const label = type?.name ?? structure.type_id;
|
||||
return [
|
||||
{ id, ...stationAt(anchor.chainage_m), structure: label },
|
||||
...spanStations(id, label, marks),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */
|
||||
/** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이
|
||||
* 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을
|
||||
* 지우지 않게) · 물넘이포장은 측점을 안 심어 견주지 않음. */
|
||||
function syncCrossDrainStations(): void {
|
||||
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]);
|
||||
const planting = (station: IrregularStation) =>
|
||||
station.structure !== FACILITY_NAMES.ford_pavement;
|
||||
const kept: IrregularStation[] = [];
|
||||
for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) {
|
||||
const taken =
|
||||
station.origin !== "pipe" &&
|
||||
kept.some(
|
||||
(other) =>
|
||||
planting(other) && Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M,
|
||||
);
|
||||
if (!taken) kept.push(station);
|
||||
}
|
||||
applyIrregularStations(kept);
|
||||
}
|
||||
|
||||
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
|
||||
@@ -326,7 +364,16 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
deps.profilePanel().setStructureTypes(allTypes);
|
||||
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
||||
structureTypeMap = new Map(
|
||||
allTypes.map((type) => [type.type_id, { group: type.group, name: type.name }]),
|
||||
allTypes.map((type) => [
|
||||
type.type_id,
|
||||
{
|
||||
type_id: type.type_id,
|
||||
group: type.group,
|
||||
name: type.name,
|
||||
placement: type.placement,
|
||||
managed_by: type.managed_by,
|
||||
},
|
||||
]),
|
||||
);
|
||||
structureRevision = stored.revision;
|
||||
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Guard.ts
|
||||
* 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고,
|
||||
* 구조물 측점을 종단 정본 측점으로 맞추기.
|
||||
* 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고
|
||||
* (구조물 측점 맞추기는 측점 한 벌 `oneStationList` 로 갈음 · 272).
|
||||
*
|
||||
* 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다
|
||||
* (계획서 24-2c). 본문 로직과 수치는 그대로이고 화면 상태를 인자로 받는다.
|
||||
@@ -9,11 +9,6 @@
|
||||
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
|
||||
/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는
|
||||
* mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */
|
||||
const STATION_SNAP_TOLERANCE_M = 0.1;
|
||||
|
||||
/** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */
|
||||
export function sampleAt(
|
||||
@@ -64,36 +59,3 @@ export function planCurveWarning(
|
||||
});
|
||||
return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04).
|
||||
*
|
||||
* 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`,
|
||||
* 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든
|
||||
* 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이
|
||||
* 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
|
||||
*
|
||||
* 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다.
|
||||
* 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다.
|
||||
*/
|
||||
export function snapToMasterStations(
|
||||
alignment: ProfileAlignment | null,
|
||||
stations: IrregularStation[],
|
||||
): IrregularStation[] {
|
||||
const master = alignment?.stations;
|
||||
if (!master?.length) return stations;
|
||||
return stations.map((station) => {
|
||||
let best: number | null = null;
|
||||
let gap = STATION_SNAP_TOLERANCE_M;
|
||||
for (const node of master) {
|
||||
const distance = Math.abs(node.chainage_m - station.chainage_m);
|
||||
if (distance <= gap) {
|
||||
gap = distance;
|
||||
best = node.chainage_m;
|
||||
}
|
||||
}
|
||||
return best === null || best === station.chainage_m
|
||||
? station
|
||||
: { ...station, chainage_m: best };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { dropStationsNear } from "./B05_Profile_Util_Station";
|
||||
import { oneStationList } from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
@@ -225,18 +225,12 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
: (longitudinal.design_profiles ?? []);
|
||||
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
||||
const graphData = normalizedLongitudinal(longitudinal);
|
||||
// 종단 정본(확정 시 병합분)에 들어 있는 비정규 측점은 걷어낸다 — 화면의 정본은 사이드바
|
||||
// 목록이고, 관을 옮기면 그 목록만 따라온다. 둘을 겹쳐 그리면 옮기기 전 자리의 세로선이
|
||||
// 그대로 남는다(2026-08-02 사용자 보고).
|
||||
const regular = graphData.stations.filter((station) => station.kind !== "irregular");
|
||||
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
|
||||
// 일반 측점은 숨김(구조물이 위) · 표 · ▲▼ · 3D 와 같은 목록(`oneStationList`).
|
||||
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
|
||||
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 세로선·라벨이 두 겹으로
|
||||
// 겹쳐 읽히지 않는다(2026-09-06). 남는 쪽은 구조물 측점이다.
|
||||
const graphLongitudinal = {
|
||||
...graphData,
|
||||
stations: [...dropStationsNear(regular, injected), ...injected].sort(
|
||||
(a, b) => a.chainage_m - b.chainage_m,
|
||||
),
|
||||
stations: oneStationList(graphData.stations, injected),
|
||||
};
|
||||
// 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04
|
||||
// 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다.
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
* 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`)
|
||||
* · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀)
|
||||
* · 값 `auto` = 「(자동)」 · 빈 값은 건너뜀(기본값으로 안 바꿈 · 기본값은 새 구조물 칸에 실제 값)
|
||||
* · `structureOptionLines`(166-1) = 같은 컨테이너의 **보이는 칸 전부** 「제목: 이름 값 · …」 — B06 [상세] CAD 칸
|
||||
* 미리 넣음 · B07 짝(서버 Node `B07_DesignDetail_Note_Options_Node`)이 같이 씀
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
|
||||
import { shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box";
|
||||
import { withLegacyGuards } from "../A00_Common/design/design_structure_ford";
|
||||
@@ -18,6 +21,8 @@ import {
|
||||
branchOf,
|
||||
choiceLabel,
|
||||
choicesOf,
|
||||
containerTitle,
|
||||
itemLabel,
|
||||
readState,
|
||||
type Instance,
|
||||
type SpecContainer,
|
||||
@@ -46,6 +51,14 @@ export function balloonLines(
|
||||
return values.length ? [first, values.join(SEP)] : [first];
|
||||
}
|
||||
|
||||
/** 명세 이름 바꿈 표 — 배관은 정의 파일의 옛 이름 「흙막이 추가」보다 명세 이름 「흙막이」가 앞섬(54-1). */
|
||||
function specNames(structure: StructureInstance, type: StructureType | undefined) {
|
||||
const names = type?.spec_names ?? {};
|
||||
if (structure.type_id !== "pipe") return names;
|
||||
const { soil_add: _old, ...pipeNames } = names;
|
||||
return pipeNames;
|
||||
}
|
||||
|
||||
/** 명세 컨테이너 값 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름(빈 값 = ""). */
|
||||
function specLines(
|
||||
spec: StructureSpec,
|
||||
@@ -53,12 +66,7 @@ function specLines(
|
||||
type: StructureType | undefined,
|
||||
count?: ContainerCount,
|
||||
): string[] {
|
||||
let names = type?.spec_names ?? {};
|
||||
if (structure.type_id === "pipe") {
|
||||
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
|
||||
const { soil_add: _old, ...pipeNames } = names;
|
||||
names = pipeNames;
|
||||
}
|
||||
const names = specNames(structure, type);
|
||||
const state = formState(spec, structure.type_id, structure.options ?? {});
|
||||
return spec.containers.flatMap((c) =>
|
||||
instancesOf(c, state[c.id] ?? [], count).map((values) => {
|
||||
@@ -129,3 +137,76 @@ function groupLines(
|
||||
return raw !== undefined && raw !== "" ? `${raw}${option.unit ?? ""}` : "";
|
||||
});
|
||||
}
|
||||
|
||||
/** 「이름 (단위)」 칸 이름 + 값 → 「이름 값단위」(예 높이 (m) + 1.3 = 높이 1.3m) · 단위 없는 이름은 그대로 띄움. */
|
||||
function nameValue(label: string, shown: string): string {
|
||||
const hit = /^(.*?)\s*\(([^()]+)\)$/.exec(label);
|
||||
return hit && shown !== AUTO_MARK ? `${hit[1]} ${shown}${hit[2]}` : `${label} ${shown}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CAD 칸 미리 넣음 옵션 줄(166-1) — 좌측 패널 폼과 같은 컨테이너 · 이름 · 보이는 칸 전부 · 빈 칸 뺌.
|
||||
* 명세 종류 = 컨테이너(선택 컨테이너는 저장된 인스턴스마다 · 뒤에 번호)마다 「제목: 이름 값 · 이름 값」 ·
|
||||
* 그 밖 종류 = 정의 그룹마다(그룹 형상 칸 포함) + 그룹 밖 칸은 종류 이름 줄 · 값 하나 없는 상자는 줄 없음.
|
||||
*/
|
||||
export function structureOptionLines(
|
||||
structure: Pick<StructureInstance, "type_id" | "options">,
|
||||
type: StructureType | undefined,
|
||||
): string[] {
|
||||
const options = structure.options ?? {};
|
||||
const line = (title: string, parts: string[]): string[] => {
|
||||
const kept = parts.filter(Boolean);
|
||||
return kept.length ? [`${title}: ${kept.join(SEP)}`] : [];
|
||||
};
|
||||
const spec = STRUCTURE_SPECS[structure.type_id];
|
||||
if (spec) {
|
||||
const names = specNames(structure as StructureInstance, type);
|
||||
const state = formState(spec, structure.type_id, options);
|
||||
return spec.containers.flatMap((c) =>
|
||||
(state[c.id] ?? []).flatMap((values, index) => {
|
||||
const group = type?.groups?.find((g) => g.id === c.link)?.name;
|
||||
const title = containerTitle(c, names, group) + (c.required ? "" : ` ${index + 1}`);
|
||||
const parts = c.items
|
||||
.filter((item) => item.kind !== "auto" && (!item.by || branchOf(item, values)))
|
||||
.map((item) => {
|
||||
// 필수 상자 빈 칸 = 폼이 채워 보이는 기본값(177-2 · 예 동물이동형 유) · 선택 상자는 안내뿐이라 뺌
|
||||
const empty = !values[item.id]?.trim() && c.required && item.default;
|
||||
const filled = empty ? { ...values, [item.id]: item.default ?? "" } : values;
|
||||
const shown = specValue(c, item, filled, names);
|
||||
return shown ? nameValue(itemLabel(c, item, names), shown) : "";
|
||||
});
|
||||
return line(title, parts);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const byKey = new Map((type?.options ?? []).map((option) => [option.key, option]));
|
||||
const valueOf = (key: string, unit?: string | null): string => {
|
||||
const raw = options[key];
|
||||
if (raw === undefined || raw === null || raw === "") return "";
|
||||
return raw === "auto" ? AUTO_MARK : `${raw}${unit ?? ""}`;
|
||||
};
|
||||
const optionParts = (keys: string[]): string[] =>
|
||||
keys.map((key) => {
|
||||
const option = byKey.get(key);
|
||||
if (!option || option.visible === false || option.enabled === false) return "";
|
||||
const shown = valueOf(key, option.unit);
|
||||
return shown ? `${option.label} ${shown}` : "";
|
||||
});
|
||||
const groups = type?.groups ?? [];
|
||||
const grouped = new Set(groups.flatMap((group) => group.options));
|
||||
return [
|
||||
...groups.flatMap((group) =>
|
||||
line(group.name, [
|
||||
...optionParts(group.options),
|
||||
...shapeFields(group.shape).map((field) => {
|
||||
const shown = valueOf(shapeKey(group.id, field.suffix));
|
||||
return shown ? nameValue(field.label, shown) : "";
|
||||
}),
|
||||
]),
|
||||
),
|
||||
...line(
|
||||
type?.name ?? structure.type_id,
|
||||
optionParts((type?.options ?? []).map((o) => o.key).filter((key) => !grouped.has(key))),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -168,13 +168,13 @@ export function stationFields(
|
||||
if (rest < intervalM && Number.isInteger(stationNo)) return;
|
||||
const parts = chainageToStation(stationToChainage(stationNo, rest, intervalM), intervalM);
|
||||
station.value = String(parts.station);
|
||||
remainder.value = parts.remainder.toFixed(1);
|
||||
remainder.value = parts.remainder.toFixed(0); // 측점 + 거리 = 정수 m(167)
|
||||
};
|
||||
// 다른 change 리스너(옵션 잠금·임시 배치)보다 먼저 등록돼 정규화된 값이 읽힌다.
|
||||
[station, remainder].forEach((input) => input.addEventListener("change", normalize));
|
||||
}
|
||||
// 칸은 잔여를 소수 1자리로 보임 — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌
|
||||
// (표시값을 다시 읽으면 0.1m 로 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨)
|
||||
// 칸은 잔여를 정수로 보임(167) — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌
|
||||
// (표시값을 다시 읽으면 옛 소수 값이 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨)
|
||||
let written: { chainageM: number; station: string; remainder: string } | null = null;
|
||||
return {
|
||||
wrap,
|
||||
@@ -196,7 +196,7 @@ export function stationFields(
|
||||
const valid =
|
||||
Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0;
|
||||
markInvalid(!valid);
|
||||
// 친 값은 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 안 고친 칸은 위에서 옛 값 그대로
|
||||
// 친 값은 정수 m(반올림 · 167) — 안 고친 칸은 위에서 옛 값 그대로
|
||||
return valid ? snapStructureStation(stationNo * intervalM + rest) : null;
|
||||
},
|
||||
write(chainageM, intervalM) {
|
||||
@@ -208,7 +208,7 @@ export function stationFields(
|
||||
}
|
||||
const parts = chainageToStation(chainageM, intervalM);
|
||||
station.value = String(parts.station);
|
||||
remainder.value = parts.remainder.toFixed(1);
|
||||
remainder.value = parts.remainder.toFixed(0); // 측점 + 거리 = 정수 m(167)
|
||||
written = { chainageM, station: station.value, remainder: remainder.value };
|
||||
},
|
||||
clearInvalid: () => markInvalid(false),
|
||||
|
||||
@@ -47,7 +47,11 @@ import { pickable, showsAnchor } from "./B05_Profile_UI_Structures_Define";
|
||||
import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
|
||||
import { movedStructure, snapDock, snapStructureStation } from "./B05_Profile_UI_Structures_Snap";
|
||||
import {
|
||||
movedStructure,
|
||||
snapOldStations,
|
||||
snapStructureStation,
|
||||
} from "./B05_Profile_UI_Structures_Snap";
|
||||
|
||||
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
|
||||
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
|
||||
@@ -542,13 +546,15 @@ export function createStructuresSection(
|
||||
return {
|
||||
root,
|
||||
facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15)
|
||||
listRoot: snapDock(list, {
|
||||
structures,
|
||||
pipes: () => pipeFacilities,
|
||||
emit,
|
||||
movePipe: (from, to, attributes) => callbacks.onPipeUpdate(from, to, attributes),
|
||||
closeForm: () => resetForm(),
|
||||
}),
|
||||
listRoot: list,
|
||||
snapOld: () =>
|
||||
snapOldStations({
|
||||
structures,
|
||||
pipes: () => pipeFacilities,
|
||||
emit,
|
||||
movePipe: (from, to, attributes) => callbacks.onPipeUpdate(from, to, attributes),
|
||||
closeForm: () => resetForm(),
|
||||
}),
|
||||
hasSelection: () => editingId !== null || selectedPipeChainage !== null,
|
||||
pickGroups: () => pickGroups(types, canPick), // 우클릭 메뉴 = 이 드롭다운 목록(129-1)
|
||||
setTypes(next, pick = () => true) {
|
||||
|
||||
@@ -22,10 +22,7 @@ import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures
|
||||
import { asRoad } from "./B05_Profile_UI_Structures_Define";
|
||||
import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import {
|
||||
isSnappedStation,
|
||||
snapStructureStation,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
readGroupItems,
|
||||
readSummaryItem,
|
||||
@@ -309,11 +306,9 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
beforeInput?.focus();
|
||||
return;
|
||||
}
|
||||
// 시작 · 끝도 정수 m(234-1) — 안 고친 옛 소수 기준이면 옛 셈 그대로([정수 맞춤] 몫)
|
||||
const fit = (value: number): number =>
|
||||
isSnappedStation(anchor as number) ? snapStructureStation(value) : value;
|
||||
start = fit(Math.max(anchor - before, 0));
|
||||
end = Math.max(fit(start + length), start + Math.min(length, 1));
|
||||
// 시작 · 끝도 정수 m · 끝은 1 m 이상(234-1 · 167)
|
||||
start = snapStructureStation(Math.max(anchor - before, 0));
|
||||
end = Math.max(snapStructureStation(start + length), start + 1);
|
||||
} else if (placement === "interval") {
|
||||
start = ctx.startFields.read(step, true);
|
||||
end = ctx.endFields.read(step, true);
|
||||
|
||||
@@ -154,6 +154,8 @@ export interface StructuresSection {
|
||||
/** 배치된 구조물 목록(<ul>) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다
|
||||
* (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */
|
||||
listRoot: HTMLElement;
|
||||
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 옮긴다(종단 보기를 열 때 한 번 · 167) — 옮긴 수. */
|
||||
snapOld: () => number;
|
||||
/** 서버에서 받은 타입 목록을 채운다(최초 1회). 이름·폼은 넘긴 종류 전부로 풀고,
|
||||
* 고르기 목록엔 `pick` 이 고른 종류만 보임(빼면 전부). */
|
||||
setTypes: (types: StructureType[], pick?: (type: StructureType) => boolean) => void;
|
||||
|
||||
@@ -63,8 +63,10 @@ export async function splitPavementRange(
|
||||
const segments: Span[] = [];
|
||||
let cursor = start;
|
||||
for (const ford of overlaps) {
|
||||
if (ford.start - cursor > EPS) segments.push({ start: cursor, end: ford.start });
|
||||
cursor = ford.end;
|
||||
// 경계도 정수 m(167) — 물넘이를 안 덮게 앞 구간 끝은 내림 · 뒤 구간 시작은 올림
|
||||
const before = Math.floor(ford.start + EPS);
|
||||
if (before - cursor > EPS) segments.push({ start: cursor, end: before });
|
||||
cursor = Math.ceil(ford.end - EPS);
|
||||
}
|
||||
if (end - cursor > EPS) segments.push({ start: cursor, end });
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Snap.ts
|
||||
* 구조물 기준 측점 정수 맞춤(234-1) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과
|
||||
* 기존 소수 자료를 옮기는 [정수 맞춤] 단추(좌측 구조물 목록 머리 · B05 · B06 한 벌).
|
||||
* 구조물 기준 측점 정수 맞춤(234-1 · 272) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과
|
||||
* 옛 소수 자료를 정수 m 로 옮기는 자동 맞춤(종단 보기를 열 때 한 번 · 단추 없음 · 요구 167).
|
||||
*
|
||||
* 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 단추는 확인 창 뒤
|
||||
* 구조물은 한 번에, 관은 하나씩 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집
|
||||
* 따라가기(213-2) · 시설 속성 이어받기가 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다.
|
||||
* 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 구조물은 한 번에, 관은 하나씩
|
||||
* 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집 따라가기(213-2) · 시설 속성 이어받기가
|
||||
* 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다(알림 한 줄).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
isSnappedStation,
|
||||
snapStructureStation,
|
||||
@@ -26,8 +26,7 @@ export function movedStructure(target: StructureInstance, toChainageM: number):
|
||||
if (target.placement !== "interval") return { ...target, chainage_m: anchor };
|
||||
const length = (target.end_m ?? 0) - (target.start_m ?? 0);
|
||||
const start = snapStructureStation(anchor - (structureAnchorM(target) - (target.start_m ?? 0)));
|
||||
const snappedEnd = snapStructureStation(start + length);
|
||||
const end = snappedEnd > start ? snappedEnd : start + length;
|
||||
const end = Math.max(snapStructureStation(start + length), start + 1); // 끝도 정수 · 1 m 이상(167)
|
||||
return {
|
||||
...target,
|
||||
chainage_m: Math.min(Math.max(anchor, start), end),
|
||||
@@ -55,7 +54,8 @@ export const structureOffGrid = (structure: StructureInstance): boolean =>
|
||||
export interface SnapPlan {
|
||||
/** 목록 자리 · 옮긴 모양. */
|
||||
structures: Array<{ index: number; next: StructureInstance }>;
|
||||
pipes: Array<{ from: number; to: number; pipe: PipeFacilityItem }>;
|
||||
/** 옛 자리 → 새 자리 · 시작 · 끝도 맞춘 속성(독립 기슭막이 구간). */
|
||||
pipes: Array<{ from: number; to: number; attributes: FacilityAttributes }>;
|
||||
/** 맞춘 자리에 다른 관이 있어 그대로 둔 관 수. */
|
||||
skipped: number;
|
||||
}
|
||||
@@ -73,16 +73,20 @@ export function snapPlan(
|
||||
const taken = pipes.filter((pipe) => !offGrid(pipe.chainage_m)).map((pipe) => pipe.chainage_m);
|
||||
const plan: SnapPlan = { structures: moved, pipes: [], skipped: 0 };
|
||||
[...pipes]
|
||||
.filter((pipe) => offGrid(pipe.chainage_m))
|
||||
.filter((pipe) => offGrid(pipe.chainage_m) || offGrid(pipe.start_m) || offGrid(pipe.end_m))
|
||||
.sort((left, right) => left.chainage_m - right.chainage_m)
|
||||
.forEach((pipe) => {
|
||||
const to = snapStructureStation(pipe.chainage_m);
|
||||
if (taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) {
|
||||
if (to !== pipe.chainage_m && taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) {
|
||||
plan.skipped += 1;
|
||||
return;
|
||||
}
|
||||
taken.push(to);
|
||||
plan.pipes.push({ from: pipe.chainage_m, to, pipe });
|
||||
const attributes = pipeAttributes(pipe);
|
||||
if (attributes.start_m !== undefined)
|
||||
attributes.start_m = snapStructureStation(attributes.start_m);
|
||||
if (attributes.end_m !== undefined) attributes.end_m = snapStructureStation(attributes.end_m);
|
||||
plan.pipes.push({ from: pipe.chainage_m, to, attributes });
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
@@ -97,7 +101,7 @@ export const pipeAttributes = (pipe: PipeFacilityItem): FacilityAttributes => ({
|
||||
group_items: pipe.group_items ?? null,
|
||||
});
|
||||
|
||||
export interface SnapDockContext {
|
||||
export interface SnapContext {
|
||||
/** 패널이 쥔 구조물 배열(참조) — 옮긴 것을 제자리에 갈아 끼움. */
|
||||
structures: StructureInstance[];
|
||||
pipes: () => ReadonlyArray<PipeFacilityItem>;
|
||||
@@ -109,52 +113,23 @@ export interface SnapDockContext {
|
||||
closeForm: () => void;
|
||||
}
|
||||
|
||||
/** [정수 맞춤] 단추를 얹은 목록 뿌리 — 아래 고정 영역에 목록 대신 놓인다. */
|
||||
export function snapDock(list: HTMLElement, ctx: SnapDockContext): HTMLElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b05-route__irregular-btn b05-structure__snap";
|
||||
button.textContent = "정수 맞춤";
|
||||
button.title = "소수 기준 측점(관 · 구조물)을 정수 m 로 — 일반 측점 1 m 안이면 그 측점";
|
||||
button.addEventListener("click", () => void runSnap(ctx));
|
||||
const head = document.createElement("div");
|
||||
head.className = "b05-structure__snap-row";
|
||||
head.append(button);
|
||||
const dock = document.createElement("div");
|
||||
dock.className = "b05-structure__list-dock";
|
||||
dock.append(head, list);
|
||||
return dock;
|
||||
}
|
||||
|
||||
export async function runSnap(ctx: SnapDockContext): Promise<number> {
|
||||
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 옮긴다(요구 167 · 확인 창 없이) — 옮긴 수. */
|
||||
export function snapOldStations(ctx: SnapContext): number {
|
||||
const plan = snapPlan(ctx.structures, ctx.pipes());
|
||||
const count = plan.structures.length + plan.pipes.length;
|
||||
if (!count) {
|
||||
if (count) {
|
||||
ctx.closeForm();
|
||||
if (plan.structures.length) {
|
||||
plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next));
|
||||
ctx.emit();
|
||||
}
|
||||
plan.pipes.forEach(({ from, to, attributes }) => ctx.movePipe(from, to, attributes));
|
||||
}
|
||||
if (count || plan.skipped)
|
||||
showToast(
|
||||
plan.skipped
|
||||
? `소수 측점 관 ${plan.skipped}건은 맞출 자리에 다른 관이 있어 그대로 둡니다.`
|
||||
: "소수 기준 측점이 없습니다.",
|
||||
"info",
|
||||
(count ? `옛 소수 측점 ${count}건을 정수 m 로 옮겼습니다 — [저장]하면 남습니다.` : "") +
|
||||
(plan.skipped ? ` 맞출 자리에 다른 관이 있는 관 ${plan.skipped}건은 그대로 둡니다.` : ""),
|
||||
plan.skipped ? "info" : "success",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const ok = await showConfirmDialog(
|
||||
`소수 기준 측점 ${count}건(관 ${plan.pipes.length} · 구조물 ${plan.structures.length})을 ` +
|
||||
"정수 m(일반 측점 1 m 안이면 그 측점)로 옮깁니다. 계획고 편집도 따라가며, [저장]을 눌러야 남습니다.",
|
||||
"정수 맞춤",
|
||||
);
|
||||
if (!ok) return 0;
|
||||
ctx.closeForm();
|
||||
if (plan.structures.length) {
|
||||
plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next));
|
||||
ctx.emit();
|
||||
}
|
||||
plan.pipes.forEach(({ from, to, pipe }) => ctx.movePipe(from, to, pipeAttributes(pipe)));
|
||||
showToast(
|
||||
`정수 맞춤 ${count}건` +
|
||||
(plan.skipped ? ` · 자리 겹친 관 ${plan.skipped}건은 그대로` : "") +
|
||||
" — [저장]을 눌러 남기세요.",
|
||||
"success",
|
||||
);
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -357,11 +357,11 @@
|
||||
|
||||
/* ── 조작 컨트롤 높이 템플릿(2026-08-29 사용자 지시) ──────────────────────
|
||||
* 「구조물 배치」 섹션 안의 입력·셀렉트·스텝 묶음·이식 행이 **한 높이**를 쓴다.
|
||||
* 값을 바꾸려면 이 변수 하나만 고치면 된다. 기준은 조정창에서 옮겨 온 행의
|
||||
* 값은 theme `--panel-control-h` 한 곳(좌측 패널 공용 양식이 함께 씀 · 269). 기준은 조정창에서 옮겨 온 행의
|
||||
* 컨트롤 높이(26px)다 — 사용자가 그 크기를 기준으로 정했다. */
|
||||
.b05-structure-section,
|
||||
.b05-drainage__facility {
|
||||
--b05-control-h: 26px;
|
||||
--b05-control-h: var(--panel-control-h);
|
||||
}
|
||||
|
||||
.b05-structure-section input:not([type="checkbox"]),
|
||||
@@ -409,7 +409,7 @@
|
||||
/* 제목(라벨)과 항목 사이 간격도 한 변수로 묶는다(2026-08-29 사용자 지시) — 기준은
|
||||
조정창에서 옮겨 온 행(2px·작은 글자). 측점 행은 가로 배치라 열 간격을 그대로 둔다. */
|
||||
.b05-structure-section {
|
||||
--b05-label-gap: 2px;
|
||||
--b05-label-gap: var(--panel-label-gap);
|
||||
}
|
||||
|
||||
.b05-structure-section .b05-route__field {
|
||||
@@ -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 {
|
||||
@@ -650,15 +650,3 @@
|
||||
.b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* [정수 맞춤] 단추 줄 — 구조물 목록 머리(234-1) */
|
||||
.b05-structure__snap-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b05-structure__snap {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CrossDesign,
|
||||
CrossDesignRequest,
|
||||
CrossDesignResponse,
|
||||
CrossSection,
|
||||
CrossSectionPatch,
|
||||
SectionConfirmResponse,
|
||||
SectionContextResponse,
|
||||
@@ -120,6 +121,21 @@ export async function getSections(
|
||||
}
|
||||
|
||||
/** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */
|
||||
/**
|
||||
* 초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 미리 떠 받는다 — 서버는 아무것도 안 씀 · 캐시에만 얹음
|
||||
* ([저장] 때 서버가 같은 계산으로 정본에 씀 · 273-2).
|
||||
*/
|
||||
export async function previewStructureSections(
|
||||
projectId: string,
|
||||
chainages: number[],
|
||||
): Promise<CrossSection[]> {
|
||||
const result = await requestJson<{ cross_sections?: CrossSection[] }>(
|
||||
`/projects/${projectId}/section/preview-stations`,
|
||||
{ method: "POST", body: JSON.stringify({ chainages }) },
|
||||
);
|
||||
return result.cross_sections ?? [];
|
||||
}
|
||||
|
||||
export async function fetchSectionDetail(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
|
||||
@@ -118,6 +118,8 @@ export interface SectionStation {
|
||||
kind: "bp" | "ep" | "regular" | "irregular";
|
||||
/** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */
|
||||
structure?: string;
|
||||
/** 구조물 측점이 얹힌 격자 자리(같은 m 면 측점 하나 · 구조물이 위 · 272) — 구조물이 떠나면 일반 측점. */
|
||||
grid?: boolean;
|
||||
center_z: number | null;
|
||||
azimuth_deg: number | null;
|
||||
/** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */
|
||||
@@ -316,6 +318,8 @@ export interface BoxSet {
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
samples: SectionSample[];
|
||||
/** 초안 자리를 서버가 미리 떠 준 횡단 — 캐시에만 있음 · [저장]하면 서버가 정본으로 씀(273-2). */
|
||||
preview?: boolean;
|
||||
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
|
||||
design?: CrossDesign;
|
||||
/** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */
|
||||
@@ -474,6 +478,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 +631,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 +646,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 +657,9 @@ export interface CrossDetailTable {
|
||||
rows: Array<{ id: string; after: string | null; grouped: boolean; labels: string[] }>;
|
||||
columns: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
/** [상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(`cad_host` · 엔티티 · 층) 그대로. */
|
||||
export interface CrossNoteCad {
|
||||
entities: Record<string, unknown>[];
|
||||
layers?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ export const USER_TOUCHED_KEYS = [
|
||||
"cut_slope_ratio_user",
|
||||
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
|
||||
"structure_note",
|
||||
// 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님.
|
||||
"structure_note_cad",
|
||||
// [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
|
||||
"detail_table",
|
||||
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
|
||||
|
||||
@@ -89,20 +89,20 @@ def _rowless_station_chainages(
|
||||
return result
|
||||
|
||||
|
||||
async def _stand_patch_stations(
|
||||
project_id: UUID, request: SectionConfirmRequest | None, known: list[float]
|
||||
) -> None:
|
||||
"""행 없는 측점의 횡단 수정 — 버리지 않고 그 측점부터 세운다(B05·B06 한 페이지처럼).
|
||||
async def _stand_patch_stations(project_id: UUID) -> None:
|
||||
"""저장 · 확정 앞에 구조물 측점을 지금 관 · 구조물 목록으로 맞춘다(B05·B06 한 페이지처럼).
|
||||
|
||||
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없다. 여기서
|
||||
[측점 만들기]와 같은 조건으로 세우면 아래 `_rowless_station_chainages` 가 행을 만들고
|
||||
수정이 그 행에 얹힌다.
|
||||
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없고, 옛 자리 측점은
|
||||
남아 있다 — 행 없는 측점의 수정이 버려지고 옛 측점이 카드 · 수량에 남음. [측점 만들기]와 같은
|
||||
조건으로 세우고 낡은 것을 지우면(맞출 것이 없으면 그냥 돌아옴 · 272) 아래
|
||||
`_rowless_station_chainages` 가 행을 만들고 수정이 그 행에 얹힌다.
|
||||
"""
|
||||
patches = (request.cross_patches or []) if request else []
|
||||
if any(all(abs(p.chainage_m - k) >= 0.01 for k in known) for p in patches):
|
||||
from B06_Section.B06_Section_Router_Stations import ensure_structure_stations
|
||||
from B06_Section.B06_Section_Router_Stations import ensure_structure_stations
|
||||
|
||||
try:
|
||||
await ensure_structure_stations(project_id)
|
||||
except Exception: # 맞춤이 안 돼도 저장은 진행 — 옛 측점이 남을 뿐(다음 저장에서 다시)
|
||||
logger.exception("B06 저장: 구조물 측점 맞춤 실패(저장은 진행): project_id=%s", project_id)
|
||||
|
||||
|
||||
async def _apply_section_edits(
|
||||
@@ -230,6 +230,9 @@ async def save_sections(
|
||||
if stale:
|
||||
return stale
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
# 측점부터 맞춤 — 아래 행 조회가 맞춘(옮기고 지운) 뒤 행을 봄(272)
|
||||
await _stand_patch_stations(project_id)
|
||||
async with pool.acquire() as connection:
|
||||
known = await get_cross_section_chainages(connection, route_id)
|
||||
# 행은 있는데 **설계가 빈** 측점도 채운다(2026-09-09). 「빈 설계」란 `design` 이
|
||||
# 없거나 **`ground_type` 이 없는 것**이다 — 재생성이 행을 다시 쓰면 면적 4키만
|
||||
@@ -238,7 +241,6 @@ async def save_sections(
|
||||
# ⚠ **값이 있는 행은 건드리지 않는다** — 사용자 조작값·이월분이 거기 있다.
|
||||
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
|
||||
|
||||
await _stand_patch_stations(project_id, request, known)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
rowless = missing + await asyncio.to_thread(
|
||||
_rowless_station_chainages,
|
||||
@@ -311,10 +313,12 @@ async def confirm_sections(
|
||||
if stale:
|
||||
return stale
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
await _stand_patch_stations(
|
||||
project_id
|
||||
) # 측점부터 맞춤 — 아래 행 조회가 맞춘 뒤 행을 봄(272)
|
||||
async with pool.acquire() as connection:
|
||||
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
|
||||
known = await get_cross_section_chainages(connection, route_id)
|
||||
|
||||
await _stand_patch_stations(project_id, request, known)
|
||||
marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter()))
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
# 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면
|
||||
|
||||
@@ -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`)
|
||||
@@ -580,9 +582,13 @@ def compute_default_designs(
|
||||
chainages: list[float],
|
||||
standard: dict[str, Any] | None = None,
|
||||
carried: dict[float, dict[str, Any]] | None = None,
|
||||
crosses: dict[float, dict[str, Any]] | None = None,
|
||||
) -> list[tuple[float, dict[str, Any]]]:
|
||||
"""미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다.
|
||||
|
||||
`crosses`(누가거리 round 3 → 횡단 dict)가 오면 파일 대신 그 횡단으로 계산한다 — 아직 정본에
|
||||
없는 B06 미리 뜨기 자리(273-2) · 상단측도 그 횡단 값(종단 정본에 측점이 없음).
|
||||
|
||||
standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다.
|
||||
`carried`(누가거리 round 3 → `CARRY_INPUT_KEYS` 값)가 오면 그 측점은 기본값 대신 그 입력으로
|
||||
계산한다 — 노선 [확인] 체인의 옛 설계 이월(PLAN 74-3). 결과 칸은 늘 새 계획고로 다시 낸다.
|
||||
@@ -600,11 +606,13 @@ def compute_default_designs(
|
||||
ford_drops = ford_surface_drops(root)
|
||||
results: list[tuple[float, dict[str, Any]]] = []
|
||||
for chainage_m in chainages:
|
||||
cross = (crosses or {}).get(round(chainage_m, 3))
|
||||
cross_path = find_cross_file(cross_dir, chainage_m)
|
||||
if not cross_path.is_file():
|
||||
if cross is None and not cross_path.is_file():
|
||||
continue
|
||||
try:
|
||||
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
||||
if cross is None:
|
||||
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
||||
samples = cross.get("samples")
|
||||
if not isinstance(samples, list):
|
||||
continue
|
||||
@@ -615,7 +623,12 @@ def compute_default_designs(
|
||||
samples,
|
||||
design_elevation_from_longitudinal(longitudinal, chainage_m),
|
||||
ground_type=str(user.get("ground_type") or "ripping_rock"),
|
||||
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
|
||||
section_mode=default_modes.get(
|
||||
round(chainage_m, 3),
|
||||
f"{cross['uphill_side']}_cut"
|
||||
if cross.get("uphill_side") in ("left", "right")
|
||||
else "left_cut",
|
||||
),
|
||||
ditch_type=str(user.get("ditch_type") or "standard"),
|
||||
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
|
||||
paved=paved_at(chainage_m, paved_ranges),
|
||||
|
||||
@@ -27,6 +27,7 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import SAME_STATION_SNAP_M
|
||||
@@ -83,8 +84,8 @@ async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str
|
||||
}
|
||||
|
||||
|
||||
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
|
||||
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
|
||||
def _planted(project_root: Path, route_data_path: str) -> list[tuple[float, str]]:
|
||||
"""관 · 구조물 정본이 심는 구조물 측점 가운데 **노선 안**(세울 수 있는 것)만."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
_load_pipe_points,
|
||||
_load_route_polyline,
|
||||
@@ -92,23 +93,28 @@ def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, A
|
||||
)
|
||||
|
||||
polyline = _load_route_polyline(project_root, route_data_path)
|
||||
pipes = _load_pipe_points(project_root, polyline)
|
||||
extras = resolve_extra_stations(project_root, pipes)
|
||||
if not extras:
|
||||
return []
|
||||
existing = _station_chainages(project_root)
|
||||
extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
|
||||
# 노선 밖(0 미만 · 끝 넘음) = 측점을 못 세움 — 판정에서 뺌(219-1 · 길이 모르면 끝은 안 봄)
|
||||
end_m = sum(math.dist(a[:2], b[:2]) for a, b in zip(polyline, polyline[1:]))
|
||||
missing = []
|
||||
for chainage, label in extras:
|
||||
value = float(chainage)
|
||||
if value < -STATION_MATCH_TOLERANCE_M or (
|
||||
end_m > 0 and value > end_m + STATION_MATCH_TOLERANCE_M
|
||||
):
|
||||
continue
|
||||
if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing):
|
||||
continue
|
||||
missing.append({"chainage_m": round(value, 3), "label": label})
|
||||
return [
|
||||
(float(chainage), label)
|
||||
for chainage, label in extras
|
||||
if float(chainage) >= -STATION_MATCH_TOLERANCE_M
|
||||
and (end_m <= 0 or float(chainage) <= end_m + STATION_MATCH_TOLERANCE_M)
|
||||
]
|
||||
|
||||
|
||||
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
|
||||
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
|
||||
planted = _planted(project_root, route_data_path)
|
||||
if not planted:
|
||||
return []
|
||||
existing = _station_chainages(project_root)
|
||||
missing = [
|
||||
{"chainage_m": round(value, 3), "label": label}
|
||||
for value, label in planted
|
||||
if all(abs(value - other) > STATION_MATCH_TOLERANCE_M for other in existing)
|
||||
]
|
||||
return sorted(missing, key=lambda item: item["chainage_m"])
|
||||
|
||||
|
||||
@@ -130,6 +136,24 @@ def _station_chainages(project_root: Path) -> list[float]:
|
||||
return values
|
||||
|
||||
|
||||
def _stale_marks(project_root: Path, route_data_path: str) -> int:
|
||||
"""종단 정본의 구조물 측점과 **지금 관 · 구조물**이 어긋난 수 — 옮긴 관의 옛 자리 · 구조물이
|
||||
떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272). 0 이면 맞음."""
|
||||
import json
|
||||
|
||||
planted = {round(value, 2) for value, _label in _planted(project_root, route_data_path)}
|
||||
standing: set[float] = set()
|
||||
for path in sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for station in data.get("stations") or []:
|
||||
if station.get("kind") == "irregular" or station.get("structure"):
|
||||
standing.add(round(float(station.get("chainage_m") or 0.0), 2))
|
||||
return len(standing ^ planted)
|
||||
|
||||
|
||||
async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None:
|
||||
async def _load(connection):
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
@@ -142,6 +166,26 @@ async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None
|
||||
return Path(resolve_stored_project_path(stored)), route
|
||||
|
||||
|
||||
async def _generation_inputs(
|
||||
project_id: UUID, route: dict[str, Any], snapshot: dict[str, Any]
|
||||
) -> tuple[Any, int | None, dict[str, Any] | None]:
|
||||
"""측점을 뜰 때 쓰는 저장분 — (횡단 옵션, 좌표계 EPSG, 종단 행) · 만들기 · 미리 뜨기 한 벌."""
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_latest_section_options,
|
||||
get_longitudinal_section,
|
||||
)
|
||||
|
||||
async def _load(connection):
|
||||
options = await get_latest_section_options(connection, project_id)
|
||||
crs_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, snapshot.get("surface_model_id")
|
||||
)
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
|
||||
return options, crs_epsg, longitudinal
|
||||
|
||||
return await run_with_connection(_load)
|
||||
|
||||
|
||||
async def _regenerate_structure_stations(
|
||||
project_id: UUID, project_root: Path, route: dict[str, Any], snapshot: dict[str, Any]
|
||||
) -> int:
|
||||
@@ -158,21 +202,10 @@ async def _regenerate_structure_stations(
|
||||
from B05_Profile.B05_Profile_Router_Confirm import (
|
||||
_merge_irregular_into_longitudinal,
|
||||
_section_options_from_stored,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_latest_section_options,
|
||||
get_longitudinal_section,
|
||||
drop_rows_off_stations,
|
||||
)
|
||||
|
||||
async def _load(connection):
|
||||
options = await get_latest_section_options(connection, project_id)
|
||||
crs_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, snapshot.get("surface_model_id")
|
||||
)
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
|
||||
return options, crs_epsg, longitudinal
|
||||
|
||||
stored_options, crs_epsg, longitudinal = await run_with_connection(_load)
|
||||
stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot)
|
||||
|
||||
def _regenerate() -> tuple[int, dict[str, Any] | None]:
|
||||
polyline = _load_route_polyline(project_root, str(route["route_data_path"]))
|
||||
@@ -189,13 +222,20 @@ async def _regenerate_structure_stations(
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
summary = None
|
||||
if stations and longitudinal:
|
||||
if longitudinal: # 빈 목록이어도 맞춤 — 옛 구조물 측점을 지움(272)
|
||||
summary = _merge_irregular_into_longitudinal(
|
||||
project_root, str(longitudinal["longitudinal_file_path"]), stations
|
||||
)
|
||||
return len(stations), summary
|
||||
|
||||
made, summary = await asyncio.to_thread(_regenerate)
|
||||
if longitudinal:
|
||||
await run_with_connection(
|
||||
drop_rows_off_stations,
|
||||
route["id"],
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
)
|
||||
if summary is not None:
|
||||
# 옮긴 관 자리의 계획선이 다시 셈됨 — DB 등급 요약도 같이(계획선 저장과 같은 길).
|
||||
await run_with_connection(
|
||||
@@ -207,7 +247,7 @@ async def _regenerate_structure_stations(
|
||||
|
||||
|
||||
async def ensure_structure_stations(project_id: UUID) -> int:
|
||||
"""빠진 구조물 측점을 세운다 — 횡단 저장이 **행 없는 측점의 수정**을 받을 때 부름.
|
||||
"""빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎).
|
||||
|
||||
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리
|
||||
횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다.
|
||||
@@ -218,14 +258,18 @@ async def ensure_structure_stations(project_id: UUID) -> int:
|
||||
return 0
|
||||
project_root, route = paths
|
||||
missing = await asyncio.to_thread(_missing_marks, project_root, str(route["route_data_path"]))
|
||||
if not missing:
|
||||
# 빠진 것만이 아니라 낡은 것(옮긴 관 옛 자리 · 떠난 격자 자리)도 맞춤 — 두 벌로 남지 않게(272)
|
||||
stale = await asyncio.to_thread(_stale_marks, project_root, str(route["route_data_path"]))
|
||||
if not missing and not stale:
|
||||
return 0
|
||||
snapshot = await _sampling_conditions(project_id, project_root)
|
||||
if snapshot is None:
|
||||
logger.warning("B06 저장: 빠진 구조물 측점 %d곳 — 샘플링 조건이 없어 못 세움", len(missing))
|
||||
logger.warning(
|
||||
"B06 저장: 구조물 측점 %d곳을 못 맞춤 — 샘플링 조건 없음", len(missing) or stale
|
||||
)
|
||||
return 0
|
||||
await _regenerate_structure_stations(project_id, project_root, route, snapshot)
|
||||
return len(missing)
|
||||
return len(missing) or stale
|
||||
|
||||
|
||||
@router.get("/{project_id}/section/missing-stations")
|
||||
@@ -299,3 +343,88 @@ async def create_missing_stations(project_id: UUID) -> JSONResponse:
|
||||
"missing": missing,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
#: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게.
|
||||
PREVIEW_MAX_STATIONS = 60
|
||||
|
||||
|
||||
class PreviewStationsRequest(BaseModel):
|
||||
"""미리 뜰 자리(누가거리 m) — 노선 밖 · 숫자 아닌 값은 서버가 버림."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
chainages: list[float] = Field(default_factory=list, max_length=PREVIEW_MAX_STATIONS)
|
||||
|
||||
|
||||
@router.post("/{project_id}/section/preview-stations")
|
||||
async def preview_structure_sections(
|
||||
project_id: UUID, request: PreviewStationsRequest
|
||||
) -> JSONResponse:
|
||||
"""초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 **미리 떠서 돌려만 준다**(273-2 · 요구 168).
|
||||
|
||||
파일 · DB 에 아무것도 안 쓴다 — 화면이 캐시에 얹어 B06 카드를 [저장] 전에 보이고, [저장] 때
|
||||
서버가 같은 길(`sample_structure_sections` · `compute_default_designs`)로 다시 만들어 정본에 씀.
|
||||
샘플링 조건은 측점 만들기와 같다(없으면 지어내지 않고 409).
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Engine_Sections import sample_structure_sections
|
||||
from B05_Profile.B05_Profile_Router_Confirm import _section_options_from_stored
|
||||
from B06_Section.B06_Section_Router_Design import compute_default_designs
|
||||
|
||||
chainages = sorted({round(c, 3) for c in request.chainages if math.isfinite(c) and c >= 0})
|
||||
if not chainages:
|
||||
return JSONResponse(content={"status": "success", "cross_sections": []})
|
||||
try:
|
||||
paths = await _project_paths(project_id)
|
||||
if paths is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정된 노선이 없습니다."},
|
||||
)
|
||||
project_root, route = paths
|
||||
snapshot = await _sampling_conditions(project_id, project_root)
|
||||
if snapshot is None:
|
||||
return JSONResponse(
|
||||
status_code=409, content={"status": "error", "message": SNAPSHOT_MISSING}
|
||||
)
|
||||
stored_options, crs_epsg, longitudinal = await _generation_inputs(
|
||||
project_id, route, snapshot
|
||||
)
|
||||
if not longitudinal:
|
||||
return JSONResponse(content={"status": "success", "cross_sections": []})
|
||||
|
||||
def _preview() -> list[dict[str, Any]]:
|
||||
_stations, crosses = sample_structure_sections(
|
||||
project_root,
|
||||
str(route["route_data_path"]),
|
||||
str(snapshot["filter_key"]),
|
||||
str(snapshot["method"]),
|
||||
bool(snapshot.get("smooth")),
|
||||
extra_stations=tuple((chainage, "") for chainage in chainages),
|
||||
options=_section_options_from_stored(stored_options),
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
wanted = set(chainages)
|
||||
picked = {
|
||||
round(float(cross["chainage_m"]), 3): cross
|
||||
for cross in crosses
|
||||
if round(float(cross["chainage_m"]), 3) in wanted
|
||||
}
|
||||
designs = compute_default_designs(
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
list(picked),
|
||||
crosses=picked,
|
||||
)
|
||||
for chainage, design in designs:
|
||||
picked[round(chainage, 3)]["design"] = design
|
||||
return list(picked.values())
|
||||
|
||||
sections = await asyncio.to_thread(_preview)
|
||||
except Exception:
|
||||
logger.exception("B06 미리 뜨기 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "횡단을 미리 뜨지 못했습니다."},
|
||||
)
|
||||
return JSONResponse(content={"status": "success", "cross_sections": sections})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_Section_Drafts.ts
|
||||
* 초안 측점 한 벌(272 · 273-2) — 관 · 구조물을 놓거나 옮긴 초안을 공유 상세(횡단 카드 · 유토곡선)에 맞춘다.
|
||||
*
|
||||
* · 옛 자리 구조물 측점(서버가 [저장] 때 다시 맞추기 전) = 숨김 — 쥐고 있다가 초안이 다시 그 자리를
|
||||
* 심거나 초안이 없어지면 돌려 놓음.
|
||||
* · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB
|
||||
* 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음.
|
||||
*
|
||||
* 심는 자리 규칙 = `plantedChainages`(서버 `resolve_extra_stations` 짝) · 카드 이름 = 서버 측점 이름 규칙
|
||||
* (`pipe_station_label` · 종류 이름) · 관 그림 = 짝 산식 `common_util_culvert_sets`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
fetchStructures,
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
plantedChainages,
|
||||
SAME_STATION_M,
|
||||
structureStationMarks,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
previewStructureSections,
|
||||
type CrossSection,
|
||||
type SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
|
||||
type Pipes = PendingPipes;
|
||||
|
||||
const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_STATION_M;
|
||||
const byChainage = (a: { chainage_m: number }, b: { chainage_m: number }) =>
|
||||
a.chainage_m - b.chainage_m;
|
||||
|
||||
/** 저장된 관 · 구조물 — 한쪽 초안만 있을 때 심는 자리 셈에 씀 · 저장 뒤(`forgetDraftStations`) 다시 받음. */
|
||||
const saved = new Map<string, Promise<[Pipes, StructureInstance[]]>>();
|
||||
/** 미리 뜬 횡단(`프로젝트:노선:누가거리`) — 못 뜬 자리는 null(다시 안 물음) · 저장 뒤 비움. */
|
||||
const previews = new Map<string, CrossSection | null>();
|
||||
/** 초안이 빼 둔 옛 자리 측점. */
|
||||
const parked = new WeakMap<SectionDetailResponse, CrossSection[]>();
|
||||
/** 상세마다 마지막 맞춤 번호 — 늦게 끝난 옛 맞춤이 새 맞춤을 덮지 않게. */
|
||||
const turns = new WeakMap<SectionDetailResponse, number>();
|
||||
|
||||
/** 저장 · 노선 바뀜 뒤 — 쥔 저장분 · 미리 뜬 횡단을 버림. */
|
||||
export function forgetDraftStations(projectId: string): void {
|
||||
saved.delete(projectId);
|
||||
for (const key of [...previews.keys()]) if (key.startsWith(`${projectId}:`)) previews.delete(key);
|
||||
}
|
||||
|
||||
function savedOf(projectId: string): Promise<[Pipes, StructureInstance[]]> {
|
||||
let hit = saved.get(projectId);
|
||||
if (!hit) {
|
||||
hit = Promise.all([
|
||||
fetchDetailPipePoints(projectId).then((res) => res.pipe_points as Pipes),
|
||||
fetchStructures(projectId).then((res) => res.structures),
|
||||
]);
|
||||
hit.catch(() => saved.delete(projectId));
|
||||
saved.set(projectId, hit);
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
async function previewOf(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
chainages: number[],
|
||||
): Promise<CrossSection[]> {
|
||||
const key = (at: number) => `${projectId}:${routeId}:${at.toFixed(3)}`;
|
||||
const ask = chainages.filter((at) => !previews.has(key(at)));
|
||||
if (ask.length) {
|
||||
const got = await previewStructureSections(projectId, ask);
|
||||
ask.forEach((at) => previews.set(key(at), got.find((s) => near(s.chainage_m, at)) ?? null));
|
||||
}
|
||||
return chainages.flatMap((at) => previews.get(key(at)) ?? []);
|
||||
}
|
||||
|
||||
/** 미리 뜬 횡단에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */
|
||||
function decorate(
|
||||
fresh: CrossSection[],
|
||||
pipes: Pipes,
|
||||
structures: StructureInstance[],
|
||||
types: StructureType[],
|
||||
): void {
|
||||
const registry = registryDefaults(types);
|
||||
const byId = new Map(types.map((type) => [type.type_id, type]));
|
||||
const pipeLabel = (pipe: Pipes[number]): string | null => {
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
if (facility === "ford_pavement") return null;
|
||||
if (facility !== "pipe") return byId.get(facility)?.name ?? facility;
|
||||
const values = { ...registry.pipe, ...pipe.options };
|
||||
return `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`;
|
||||
};
|
||||
const labels: Array<[number, string]> = [
|
||||
...pipes.flatMap((pipe) => {
|
||||
const label = pipeLabel(pipe);
|
||||
return label
|
||||
? pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label])
|
||||
: [];
|
||||
}),
|
||||
...structures.flatMap((structure) => {
|
||||
const type = byId.get(structure.type_id);
|
||||
const label = type?.name ?? structure.type_id;
|
||||
return structureStationMarks(structure, type).map((m): [number, string] => [
|
||||
m.chainage_m,
|
||||
label,
|
||||
]);
|
||||
}),
|
||||
];
|
||||
for (const section of fresh) {
|
||||
section.preview = true;
|
||||
const hit = labels.find(([at]) => near(at, section.chainage_m));
|
||||
if (hit) section.structure = hit[1];
|
||||
}
|
||||
const own = pipes.filter((pipe) =>
|
||||
fresh.some((section) => near(section.chainage_m, pipe.chainage_m)),
|
||||
);
|
||||
attachCulvertSets(
|
||||
fresh as unknown as Array<Record<string, unknown>>,
|
||||
buildCulvertSets(own, registry),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 초안에 맞춰 상세의 횡단 목록을 고친다 — 옛 자리 숨김 · 새 자리 미리 뜬 횡단 얹음. 초안이 없으면
|
||||
* 서버 목록 그대로(빼 둔 것 돌려 놓음). 미리 뜨기를 못 하면(샘플링 조건 없음 · 끊김) 새 자리는
|
||||
* 종전처럼 [저장] 뒤에 섬.
|
||||
*/
|
||||
export async function withDraftStations(
|
||||
detail: SectionDetailResponse,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Promise<void> {
|
||||
const turn = (turns.get(detail) ?? 0) + 1;
|
||||
turns.set(detail, turn);
|
||||
const pipesDraft = readPendingPipes(projectId);
|
||||
const structuresDraft = readPendingStructures(projectId);
|
||||
const all = [
|
||||
...detail.cross_sections.filter((section) => !section.preview),
|
||||
...(parked.get(detail) ?? []),
|
||||
].sort(byChainage);
|
||||
let live = all;
|
||||
let stale: CrossSection[] = [];
|
||||
let fresh: CrossSection[] = [];
|
||||
if (pipesDraft || structuresDraft) {
|
||||
const inputs = await Promise.all([
|
||||
pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes),
|
||||
structuresDraft ?? savedOf(projectId).then(([, structures]) => structures),
|
||||
fetchStructureTypes(true),
|
||||
]).catch(() => null);
|
||||
if (inputs) {
|
||||
const [pipes, structures, types] = inputs;
|
||||
const planted = plantedChainages(pipes, structures, types);
|
||||
const isStale = (section: CrossSection): boolean =>
|
||||
section.kind === "irregular" &&
|
||||
!section.grid &&
|
||||
planted.every((at) => !near(at, section.chainage_m));
|
||||
stale = all.filter(isStale);
|
||||
live = all.filter((section) => !isStale(section));
|
||||
const missing = planted.filter(
|
||||
(at, index) =>
|
||||
planted.findIndex((other) => near(other, at)) === index &&
|
||||
live.every((section) => !near(section.chainage_m, at)),
|
||||
);
|
||||
fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : [];
|
||||
if (fresh.length) decorate(fresh, pipes, structures, types);
|
||||
}
|
||||
}
|
||||
if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음
|
||||
detail.cross_sections.splice(
|
||||
0,
|
||||
detail.cross_sections.length,
|
||||
...[...live, ...fresh].sort(byChainage),
|
||||
);
|
||||
parked.set(detail, stale);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type PavementStructureInput,
|
||||
} from "@util/common_util_structure_pavement";
|
||||
import { setGeneralStations } from "../common_util/common_util_structure_station";
|
||||
import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
|
||||
@@ -63,8 +64,10 @@ function keyOf(projectId: string, routeId: number): string {
|
||||
async function withDraftWalls(
|
||||
detail: SectionDetailResponse,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Promise<SectionDetailResponse> {
|
||||
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
|
||||
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
|
||||
const drafts = readPendingStructures(projectId);
|
||||
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
|
||||
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
|
||||
@@ -185,21 +188,21 @@ export async function loadSectionDetail(
|
||||
const key = keyOf(projectId, routeId);
|
||||
if (!options?.force) {
|
||||
const cached = cache.get(key);
|
||||
if (cached) return withDraftWalls(cached, projectId);
|
||||
if (cached) return withDraftWalls(cached, projectId, routeId);
|
||||
const inFlight = pending.get(key);
|
||||
if (inFlight) return inFlight;
|
||||
// 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다.
|
||||
const stored = readState<SectionDetailResponse>("section-detail", projectId, routeId);
|
||||
if (stored) {
|
||||
cache.set(key, stored);
|
||||
return withDraftWalls(stored, projectId);
|
||||
return withDraftWalls(stored, projectId, routeId);
|
||||
}
|
||||
}
|
||||
const request = fetchSectionDetail(projectId, routeId)
|
||||
.then(async (detail) => {
|
||||
cache.set(key, detail);
|
||||
writeState("section-detail", detail, projectId, routeId);
|
||||
return withDraftWalls(detail, projectId);
|
||||
return withDraftWalls(detail, projectId, routeId);
|
||||
})
|
||||
.finally(() => {
|
||||
pending.delete(key);
|
||||
@@ -224,6 +227,7 @@ export function replaceSectionDetail(
|
||||
* 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다.
|
||||
*/
|
||||
export function invalidateSectionDetail(projectId: string, routeId?: number): void {
|
||||
forgetDraftStations(projectId);
|
||||
if (routeId !== undefined) {
|
||||
cache.delete(keyOf(projectId, routeId));
|
||||
clearState("section-detail", projectId, routeId);
|
||||
|
||||
@@ -33,7 +33,7 @@ const HIT_CLASS = "b06-chart__design-hit";
|
||||
/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */
|
||||
export const DESIGN_LINE_ATTR = "data-design-line";
|
||||
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
|
||||
const OCCLUDER_CLASSES = [
|
||||
export const OCCLUDER_CLASSES = [
|
||||
"b06-chart__culvert-revet",
|
||||
"b06-chart__culvert-basin",
|
||||
"b06-chart__ford-slab",
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*
|
||||
* 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 ·
|
||||
* 칸 크기가 바뀌면 다시 그림) ·
|
||||
* 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 ·
|
||||
* B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) ·
|
||||
* 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) ·
|
||||
* 가운데 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) ·
|
||||
* 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`).
|
||||
* 아래 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 →
|
||||
* [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) ·
|
||||
* 머리 한 줄 접기(265-1 · 구조물 측점 폄 · 일반 측점 접힘).
|
||||
* 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2).
|
||||
* 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
|
||||
* 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3).
|
||||
@@ -16,7 +17,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,
|
||||
@@ -24,7 +25,12 @@ import type {
|
||||
SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { CROSS_PAD, inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common";
|
||||
import { DETAIL_TARGETS, highlightDetail, presentKeys } from "./B06_Section_UI_Cross_Detail_Pick";
|
||||
import {
|
||||
DETAIL_TARGETS,
|
||||
highlightDetail,
|
||||
outlinePicked,
|
||||
presentKeys,
|
||||
} from "./B06_Section_UI_Cross_Detail_Pick";
|
||||
import {
|
||||
attachSvgZoomPan,
|
||||
type ZoomPanHandle,
|
||||
@@ -33,18 +39,24 @@ 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 {
|
||||
loadNoteThings,
|
||||
mountNoteCad,
|
||||
stationNoLabel,
|
||||
thingsAt,
|
||||
} from "./B06_Section_UI_Cross_Detail_Cad";
|
||||
|
||||
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
|
||||
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;
|
||||
@@ -52,9 +64,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 +72,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(
|
||||
@@ -132,18 +122,25 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
if (repick.has(key)) td.dataset.repick = "";
|
||||
return mark(td, { cell: key, row, col }, isAdded(key));
|
||||
};
|
||||
// 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2)
|
||||
// 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) · 합친 묶음 = 덮는 줄 키 모두(259-3)
|
||||
const nameCell = (
|
||||
label: string | null,
|
||||
span: { col?: number; row?: number } | undefined,
|
||||
key: string | null,
|
||||
row: string,
|
||||
slot: number,
|
||||
keys?: Array<string | null>,
|
||||
): HTMLElement => {
|
||||
const added = row.startsWith("r");
|
||||
const data = { row, label: added ? `${row}:${slot}` : undefined };
|
||||
const data = {
|
||||
row,
|
||||
label: added ? `${row}:${slot}` : undefined,
|
||||
keys: keys && keys.length > 1 ? keys.filter(Boolean).join(" ") : undefined,
|
||||
};
|
||||
return mark(cell("th", label ?? "", span, key), data, added);
|
||||
};
|
||||
const groupKeys = (slots: typeof layout.left, index: number) =>
|
||||
slots.slice(index, index + groupSpan(slots, index)).map((slot) => slot[2]);
|
||||
const table = document.createElement("table");
|
||||
table.className = "b06-detail__qtable";
|
||||
const titleRow = table.insertRow();
|
||||
@@ -158,13 +155,31 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
const row = table.insertRow();
|
||||
// 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) · 묶음 = 하위 줄만큼(179-4)
|
||||
if (groupL !== null && subL !== null)
|
||||
row.append(nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0));
|
||||
row.append(
|
||||
nameCell(
|
||||
groupL,
|
||||
{ row: groupSpan(layout.left, index) },
|
||||
keyL,
|
||||
id,
|
||||
0,
|
||||
groupKeys(layout.left, index),
|
||||
),
|
||||
);
|
||||
else if (groupL !== null) row.append(nameCell(groupL, { col: 2 }, keyL, id, 0));
|
||||
if (subL !== null) row.append(nameCell(subL, undefined, keyL, id, 0));
|
||||
row.append(valueCell(keyL, id));
|
||||
const [groupM, subM, keyM] = layout.middle[index];
|
||||
if (groupM !== null && subM !== null)
|
||||
row.append(nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1));
|
||||
row.append(
|
||||
nameCell(
|
||||
groupM,
|
||||
{ row: groupSpan(layout.middle, index) },
|
||||
keyM,
|
||||
id,
|
||||
1,
|
||||
groupKeys(layout.middle, index),
|
||||
),
|
||||
);
|
||||
else if (groupM !== null) row.append(nameCell(groupM, { col: 2 }, keyM, id, 1));
|
||||
if (subM !== null) row.append(nameCell(subM, undefined, keyM, id, 1));
|
||||
row.append(valueCell(keyM, id));
|
||||
@@ -178,12 +193,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;
|
||||
@@ -214,6 +231,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);
|
||||
@@ -242,7 +260,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);
|
||||
@@ -255,7 +273,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;
|
||||
}
|
||||
@@ -277,36 +295,80 @@ 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 · 머리 한 줄 접기(265-1 · 공용 접기 꼴) ·
|
||||
// 열 때 = 구조물 측점 폄 · 일반 측점 접힘(그 창에서만) · 처음 펼 때 한 번 만듦(접힌 동안 칸 ·
|
||||
// 초안 · [저장] 그대로 — 안 연 칸은 B07 이 같은 짝으로 그림)
|
||||
const noteBox = document.createElement("section");
|
||||
noteBox.className = "b06-detail__notebox ui-collapsible";
|
||||
const noteHead = document.createElement("div");
|
||||
noteHead.className = "b06-detail__notehead ui-collapsible__title";
|
||||
noteHead.textContent = "표 위 CAD";
|
||||
const note = document.createElement("div");
|
||||
note.className = "b06-detail__note";
|
||||
note.style.height = "180px";
|
||||
noteBox.append(noteHead, note);
|
||||
const routeId = host?.routeId();
|
||||
const stations = host?.detail()?.longitudinal.stations ?? [];
|
||||
let noteCad: { destroy: () => void } | null = null;
|
||||
const openNote = (): void => {
|
||||
if (noteCad || !host || routeId === null || routeId === undefined) return;
|
||||
noteCad = mountNoteCad(note, section, () => state.section, {
|
||||
projectId: host.projectId,
|
||||
routeId,
|
||||
station: stationNoLabel(section.chainage_m, inferStationInterval(stations)),
|
||||
things,
|
||||
});
|
||||
note.querySelector<HTMLElement>(".cad-host")?.style.setProperty("min-height", "0");
|
||||
};
|
||||
let touched = false;
|
||||
noteHead.addEventListener("click", () => {
|
||||
touched = true;
|
||||
if (!noteBox.classList.toggle("is-collapsed")) openNote();
|
||||
});
|
||||
// 접힘으로 시작 → 그 측점에 놓인 구조물이 있으면 폄(사용자가 먼저 누르면 그대로)
|
||||
noteBox.classList.add("is-collapsed");
|
||||
const things = host ? loadNoteThings(host.projectId) : Promise.resolve([]);
|
||||
void things.then((list) => {
|
||||
if (touched || current !== state || !thingsAt(list, section.chainage_m).length) return;
|
||||
noteBox.classList.remove("is-collapsed");
|
||||
openNote();
|
||||
});
|
||||
const cad = document.createElement("div");
|
||||
cad.className = "b06-detail__cad";
|
||||
const tableBox = document.createElement("div");
|
||||
tableBox.className = "b06-detail__table";
|
||||
body.append(cad, note, tableBox);
|
||||
// 차례 = 그림 → 표 → CAD 칸(265-1)
|
||||
body.append(cad, tableBox, noteBox);
|
||||
|
||||
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2)
|
||||
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) ·
|
||||
// 저장된 조각 · 띠 칸 = 그래프에서 고름 + 값 글(259-2) · 고른 칸 묶음 = 바깥 한 줄(259-3)
|
||||
let picked: string | null = null;
|
||||
const showPicked = (): void => {
|
||||
const svg = cad.querySelector("svg");
|
||||
const present = svg ? presentKeys(svg, state.section.design) : new Set<string>();
|
||||
if (picked && !present.has(picked)) picked = null;
|
||||
tableBox.querySelectorAll<HTMLElement>("[data-key]").forEach((el) => {
|
||||
el.classList.toggle("is-picked", el.dataset.key === picked);
|
||||
el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""));
|
||||
const graph = picked ? tablePick.graph(picked) : null;
|
||||
if (picked && !graph && !present.has(picked)) picked = null;
|
||||
tableBox.querySelectorAll<HTMLElement>("[data-key], [data-cell]").forEach((el) => {
|
||||
const keys = (el.dataset.keys ?? el.dataset.key ?? el.dataset.cell ?? "").split(" ");
|
||||
el.classList.toggle("is-picked", picked !== null && keys.includes(picked));
|
||||
if (el.dataset.key)
|
||||
el.toggleAttribute(
|
||||
"data-empty",
|
||||
!present.has(el.dataset.key) && !tablePick.graph(el.dataset.key),
|
||||
);
|
||||
});
|
||||
if (svg) highlightDetail(svg, state.section.design, picked);
|
||||
outlinePicked(tableBox);
|
||||
const band = graph && "band" in graph ? graph.band : graph ? null : picked;
|
||||
if (svg) highlightDetail(svg, state.section.design, band);
|
||||
};
|
||||
tableBox.addEventListener("click", (event) => {
|
||||
const el = (event.target as HTMLElement).closest<HTMLElement>("[data-key]");
|
||||
const key = el?.dataset.key;
|
||||
if (!key || el.hasAttribute("data-empty")) return;
|
||||
const el = (event.target as HTMLElement).closest<HTMLElement>("[data-key], [data-cell]");
|
||||
const key = el?.dataset.key ?? el?.dataset.cell;
|
||||
if (!el || !key) return;
|
||||
if (!tablePick.graph(key) && (!el.dataset.key || el.hasAttribute("data-empty"))) return;
|
||||
picked = picked === key ? null : key;
|
||||
showPicked();
|
||||
tablePick.showCell(picked);
|
||||
});
|
||||
|
||||
// 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4)
|
||||
@@ -330,7 +392,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)
|
||||
}
|
||||
@@ -369,6 +437,7 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
stop: () => {
|
||||
resize.disconnect();
|
||||
tablePick.detach();
|
||||
noteCad?.destroy();
|
||||
},
|
||||
};
|
||||
// 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail_Cad.ts
|
||||
* 횡단 [상세] 모달 아래 = 표 위 CAD 칸(260 · 요구 162-21 · 표 아래 접기 265-1).
|
||||
*
|
||||
* 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` ·
|
||||
* 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음
|
||||
* (`_Engine_Cad_Table.cross_note_cad_entities`).
|
||||
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음
|
||||
* (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 =
|
||||
* 「이름 조합 미지정」 · 이어서 좌측 패널 옵션 줄 = `structureOptionLines` · 166-1) · 그 밖 = 빈 도면.
|
||||
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` ·
|
||||
* `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연
|
||||
* 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`.
|
||||
* 좌표 = 도면 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 {
|
||||
fetchStructures,
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon";
|
||||
import {
|
||||
readGroupItems,
|
||||
readSummaryItem,
|
||||
summaryLabel,
|
||||
type SummaryItem,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import type { CrossNoteCad, CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
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)] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 미리 넣음(265-2) = 원형 마크(관 주인 측점) + 측점 · 구조물 조합 줄 ─────────────── */
|
||||
|
||||
/** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */
|
||||
const wholeMetres = (value: number | null | undefined): string =>
|
||||
typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : "";
|
||||
|
||||
/** 측점 이름(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)}`;
|
||||
}
|
||||
|
||||
/** 측점 글 줄 재료 한 건(관 지점 · 구조물 배치) — 이름 = 구조물정의 종류 이름. */
|
||||
export interface NoteThing {
|
||||
name: string;
|
||||
/** 배관(`facility` pipe) — 원형 마크가 관을 그려 관 본체 조합 줄은 안 씀. */
|
||||
pipe: boolean;
|
||||
/** 놓인 누가거리(관 = 관 자리 · 구조물 = 기준 측점 · 없으면 시작). */
|
||||
at: number;
|
||||
summary_item?: unknown;
|
||||
group_items?: unknown;
|
||||
/** 좌측 패널 옵션 줄(166-1 · `structureOptionLines` · B07 짝은 서버 Node 가 같은 함수로 채움). */
|
||||
optionLines?: string[];
|
||||
}
|
||||
|
||||
/** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */
|
||||
const PIPE_BODY_LINK = "grv6lny";
|
||||
|
||||
/** 그 측점에 놓인 구조물(관 · 구조물 배치 · 기준 측점이 찾는 거리 안) — 구조물 측점 판정 · 글 줄 재료. */
|
||||
export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteThing[] =>
|
||||
things.filter((thing) => Math.abs(thing.at - chainageM) < PIPE_MATCH_M);
|
||||
|
||||
/**
|
||||
* 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 +
|
||||
* 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 ·
|
||||
* 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만 · 이어서 그 구조물 좌측 패널 옵션 줄(166-1).
|
||||
*/
|
||||
export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] {
|
||||
return thingsAt(things, chainageM).flatMap((thing) => {
|
||||
const own = readSummaryItem(thing.summary_item);
|
||||
const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items));
|
||||
if (own) slots.unshift(["", own]);
|
||||
const names = slots
|
||||
.filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK)))
|
||||
.map(([, item]) => summaryLabel(item));
|
||||
const combos = slots.length
|
||||
? [...new Set(names)]
|
||||
: thing.pipe
|
||||
? []
|
||||
: [`${thing.name} 조합 미지정`];
|
||||
return [...combos, ...(thing.optionLines ?? [])];
|
||||
});
|
||||
}
|
||||
|
||||
function shape(type: string, shapeData: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
id: newId(),
|
||||
type,
|
||||
lineColor: CULVERT_COLOR,
|
||||
lineWidth: 1,
|
||||
layerId: LAYER_ID,
|
||||
shapeData,
|
||||
};
|
||||
}
|
||||
|
||||
/** 원형 마크 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) ·
|
||||
* 이웃 측점 = 없음 · 원 안 세 줄(배수관 / Φ관경 / L=관 길이 정수 · 없으면 빈칸) · 지시선 없음(169). */
|
||||
function pipeMark(section: CrossSection): 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=${wholeMetres(section.design?.pipe_length_m)}`,
|
||||
];
|
||||
return [
|
||||
shape("Circle", { center: { x: cx, y: cy }, radius }), // 아래 수직 지시선 없음(169)
|
||||
...inside.map((line, index) =>
|
||||
textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리 넣음 — 원형 마크(있으면) + 오른쪽 측점 · 구조물 조합 줄(`lines` = `structureNoteLines`) ·
|
||||
* 마크 없으면 글이 왼쪽 끝부터 · 마크도 줄도 없으면 빈 목록.
|
||||
*/
|
||||
export function noteEntities(
|
||||
section: CrossSection,
|
||||
station: string,
|
||||
lines: readonly string[],
|
||||
): Record<string, unknown>[] {
|
||||
const mark = pipeMark(section);
|
||||
if (!mark.length && !lines.length) return [];
|
||||
const x = mark.length ? 15 : 0;
|
||||
return [
|
||||
...mark,
|
||||
...[station, ...lines].map((line, index) =>
|
||||
textEntity(line, x, -LINE * (index + 0.5), CULVERT_COLOR),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** 글 줄 재료 — 관(세션 초안 → 정본 `pipe_points.json`) · 구조물(세션 초안 → 정본 `structures.json`) ·
|
||||
* 이름 = 구조물정의 종류 이름(꺼진 종류 포함) · 못 받으면 빈 목록. */
|
||||
export async function loadNoteThings(projectId: string): Promise<NoteThing[]> {
|
||||
try {
|
||||
const [pipes, structures, types] = await Promise.all([
|
||||
readPendingPipes(projectId) ??
|
||||
fetchDetailPipePoints(projectId).then((res) => res.pipe_points),
|
||||
readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures),
|
||||
fetchStructureTypes(true),
|
||||
]);
|
||||
const byId = new Map(types.map((type) => [type.type_id, type]));
|
||||
const nameOf = (kind: string): string => byId.get(kind)?.name ?? kind;
|
||||
const optionLines = (type_id: string, options?: Record<string, string | number>): string[] =>
|
||||
structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id));
|
||||
const piped = pipes.map((point): NoteThing => {
|
||||
const kind = point.facility ?? "pipe";
|
||||
const { summary_item, group_items } = point;
|
||||
return {
|
||||
name: nameOf(kind),
|
||||
pipe: kind === "pipe",
|
||||
at: point.chainage_m,
|
||||
summary_item,
|
||||
group_items,
|
||||
optionLines: optionLines(kind, point.options as Record<string, string | number>),
|
||||
};
|
||||
});
|
||||
const placed = structures.flatMap((item): NoteThing[] => {
|
||||
const at = item.chainage_m ?? item.start_m;
|
||||
if (typeof at !== "number" || !Number.isFinite(at)) return [];
|
||||
const { summary_item, group_items } = item;
|
||||
return [
|
||||
{
|
||||
name: nameOf(item.type_id),
|
||||
pipe: false,
|
||||
at,
|
||||
summary_item,
|
||||
group_items,
|
||||
optionLines: optionLines(item.type_id, item.options),
|
||||
},
|
||||
];
|
||||
});
|
||||
return [...piped, ...placed];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 미리 넣음(원형 마크 · 구조물 조합 줄) → 빈 도면.
|
||||
* 옮기거나 넣었으면 초안에 씀. */
|
||||
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) {
|
||||
const lines = structureNoteLines(await at.things, section.chainage_m);
|
||||
entities = noteEntities(section, at.station, lines);
|
||||
}
|
||||
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;
|
||||
/** 글 줄 재료(`loadNoteThings` · 모달이 한 번 받음). */
|
||||
things: Promise<NoteThing[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* [상세] 모달 아래 칸(`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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py`
|
||||
* (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) ·
|
||||
* 거울 시험 `resources/tester/test_209_1_detail_select.py`.
|
||||
* 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
|
||||
* 나눔(`splitAtCrossings`) · 닫힌 영역(`findFaces` · 259-2)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,6 +227,89 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 닫힌 영역(259-2 · 화면 몫) ─────────────────────────────────────────── */
|
||||
|
||||
/** 나눈 조각으로 둘러싸인 면 하나 — 둘레 조각 번호 · 고리(m) · 넓이(㎡). */
|
||||
export interface Face {
|
||||
edges: number[];
|
||||
ring: Pt[];
|
||||
area: number;
|
||||
}
|
||||
|
||||
/** 꺾은선이 그 끝에서 떠나는 방향(라디안) — 아주 짧은 첫 토막은 건너뜀. */
|
||||
function leaving(points: Pt[]): number {
|
||||
const [x0, y0] = points[0];
|
||||
const next = points.find((p) => dist(p, points[0]) > 1e-6) ?? points[points.length - 1];
|
||||
return Math.atan2(next[1] - y0, next[0] - x0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조각(변)으로 된 평면 그래프의 면 — 끝이 `TOUCH_M` 안이면 같은 마디 · 열린 끝 조각(마디 차수 1)은
|
||||
* 거듭 지움(열린 영역 제외) · 각 반변에서 도착 마디의 시계 방향 다음 변으로 돌아 면을 셈 ·
|
||||
* 넓이가 양(반시계)인 것만 = 바깥 면 뺌.
|
||||
* ponytail: 안에 떠 있는 섬(다리 없음)은 바깥 면의 구멍으로 안 뺌 — 필요하면 포함 판정 더함.
|
||||
*/
|
||||
export function findFaces(pieces: SplitPiece[]): Face[] {
|
||||
const nodes: Pt[] = [];
|
||||
const nodeOf = (p: Pt): number => {
|
||||
const found = nodes.findIndex((q) => dist(p, q) <= TOUCH_M);
|
||||
return found >= 0 ? found : nodes.push(p) - 1;
|
||||
};
|
||||
const ends = pieces.map((piece) => [
|
||||
nodeOf(piece.points[0]),
|
||||
nodeOf(piece.points[piece.points.length - 1]),
|
||||
]);
|
||||
const alive = ends.map(([a, b]) => a !== b);
|
||||
for (let changed = true; changed;) {
|
||||
const degree = new Array<number>(nodes.length).fill(0);
|
||||
ends.forEach(([a, b], i) => alive[i] && (degree[a]++, degree[b]++));
|
||||
changed = false;
|
||||
ends.forEach(([a, b], i) => {
|
||||
if (alive[i] && (degree[a] < 2 || degree[b] < 2)) [alive[i], changed] = [false, true];
|
||||
});
|
||||
}
|
||||
// 반변 h = 2i(앞 → 뒤) · 2i+1(뒤 → 앞)
|
||||
const path = (h: number): Pt[] => {
|
||||
const points = pieces[h >> 1].points;
|
||||
return h & 1 ? [...points].reverse() : points;
|
||||
};
|
||||
const head = (h: number): number => ends[h >> 1][h & 1 ? 0 : 1];
|
||||
const out = nodes.map(() => [] as Array<[number, number]>);
|
||||
ends.forEach(([a, b], i) => {
|
||||
if (!alive[i]) return;
|
||||
out[a].push([leaving(path(2 * i)), 2 * i]);
|
||||
out[b].push([leaving(path(2 * i + 1)), 2 * i + 1]);
|
||||
});
|
||||
out.forEach((list) => list.sort((p, q) => p[0] - q[0]));
|
||||
const next = (h: number): number => {
|
||||
const list = out[head(h)];
|
||||
const at = list.findIndex(([, e]) => e === (h ^ 1));
|
||||
return list[(at - 1 + list.length) % list.length][1];
|
||||
};
|
||||
const faces: Face[] = [];
|
||||
const seen = new Set<number>();
|
||||
ends.forEach((_, i) => {
|
||||
if (!alive[i]) return;
|
||||
for (const start of [2 * i, 2 * i + 1]) {
|
||||
if (seen.has(start)) continue;
|
||||
const cycle: number[] = [];
|
||||
for (let h = start; !seen.has(h) && cycle.length <= 2 * pieces.length; h = next(h)) {
|
||||
seen.add(h);
|
||||
cycle.push(h);
|
||||
}
|
||||
const ring = cycle.flatMap((h) => path(h).slice(1));
|
||||
let twice = 0;
|
||||
ring.forEach((p, k) => {
|
||||
const q = ring[(k + 1) % ring.length];
|
||||
twice += p[0] * q[1] - q[0] * p[1];
|
||||
});
|
||||
if (twice / 2 > 1e-9)
|
||||
faces.push({ edges: [...new Set(cycle.map((h) => h >> 1))], ring, area: twice / 2 });
|
||||
}
|
||||
});
|
||||
return faces;
|
||||
}
|
||||
|
||||
/** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */
|
||||
export function pieceSource(piece: SplitPiece): CrossPickPiece {
|
||||
const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4];
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려
|
||||
* `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리.
|
||||
* 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖.
|
||||
* 고른 칸 묶음 = 바깥 한 줄(`outlinePicked` · 259-3).
|
||||
* ponytail: 판정 선의 구조물 트림 여부는 복제에서 모름 → 자리로만 가름(트림 선은 놓인 구역을 따름).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -75,3 +76,48 @@ export function presentKeys(svg: Element, design: CrossDesign | undefined): Set<
|
||||
Object.keys(DETAIL_TARGETS).filter((key) => targetsOf(svg, design, key).length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 표 강조 바깥 한 줄(259-3) ──────────────────────────────────────────── */
|
||||
|
||||
/** 직사각형들(좌 · 위 · 우 · 아래)의 합친 모양 바깥 줄 — 격자로 나눠 덮인 칸과 안 덮인 칸 사이 변만(SVG path). */
|
||||
export function unionOutline(rects: number[][]): string {
|
||||
const xs = [...new Set(rects.flatMap((r) => [r[0], r[2]]))].sort((a, b) => a - b);
|
||||
const ys = [...new Set(rects.flatMap((r) => [r[1], r[3]]))].sort((a, b) => a - b);
|
||||
const covered = (i: number, j: number): boolean =>
|
||||
i >= 0 &&
|
||||
j >= 0 &&
|
||||
i + 1 < xs.length &&
|
||||
j + 1 < ys.length &&
|
||||
rects.some((r) => r[0] <= xs[i] && xs[i + 1] <= r[2] && r[1] <= ys[j] && ys[j + 1] <= r[3]);
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i + 1 < xs.length; i++)
|
||||
for (let j = 0; j + 1 < ys.length; j++) {
|
||||
if (!covered(i, j)) continue;
|
||||
const [x0, x1, y0, y1] = [xs[i], xs[i + 1], ys[j], ys[j + 1]];
|
||||
if (!covered(i, j - 1)) parts.push(`M${x0} ${y0}H${x1}`);
|
||||
if (!covered(i, j + 1)) parts.push(`M${x0} ${y1}H${x1}`);
|
||||
if (!covered(i - 1, j)) parts.push(`M${x0} ${y0}V${y1}`);
|
||||
if (!covered(i + 1, j)) parts.push(`M${x1} ${y0}V${y1}`);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/** 고른 칸(`.is-picked`) 묶음에 바깥 한 줄 — 칸마다 테두리를 그리면 붙은 칸에 겹선이 생김. */
|
||||
export function outlinePicked(box: HTMLElement): void {
|
||||
box.querySelector(".b06-detail__pick-outline")?.remove();
|
||||
const cells = [...box.querySelectorAll(".is-picked")];
|
||||
if (!cells.length) return;
|
||||
const base = box.getBoundingClientRect();
|
||||
const rects = cells.map((cell) => {
|
||||
const r = cell.getBoundingClientRect();
|
||||
return [r.left - base.left, r.top - base.top, r.right - base.left, r.bottom - base.top].map(
|
||||
(v) => Math.round(v),
|
||||
);
|
||||
});
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("class", "b06-detail__pick-outline");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", unionOutline(rects));
|
||||
svg.append(path);
|
||||
box.append(svg);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail_Select.ts
|
||||
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로
|
||||
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로
|
||||
* 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 ·
|
||||
* Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴):
|
||||
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」).
|
||||
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」).
|
||||
* 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각)
|
||||
* 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움.
|
||||
* 누름 판정(261-3) = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 보통 배율 얇은 띠도 영역 ·
|
||||
* 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘).
|
||||
* 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`).
|
||||
* 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀).
|
||||
* ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때.
|
||||
* ========================================================================== */
|
||||
@@ -14,19 +19,25 @@ import { CROSS_PAD } from "./B06_Section_UI_Section_Common";
|
||||
import {
|
||||
DESIGN_LINE_ATTR,
|
||||
mergeStraightPieces,
|
||||
OCCLUDER_CLASSES,
|
||||
pieceLabel,
|
||||
} from "./B06_Section_UI_Cross_Design_Pick";
|
||||
import {
|
||||
findFaces,
|
||||
LINE_TOLERANCE_M,
|
||||
pathLength,
|
||||
pieceSource,
|
||||
ringArea,
|
||||
splitAtCrossings,
|
||||
type Face,
|
||||
type Pt,
|
||||
type SplitPiece,
|
||||
} from "./B06_Section_UI_Cross_Detail_Geom";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
export const PIECE_HIT_CLASS = "b06-detail__piece";
|
||||
/** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */
|
||||
export const FACE_HIT_CLASS = "b06-detail__face";
|
||||
|
||||
/** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */
|
||||
export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt {
|
||||
@@ -39,12 +50,13 @@ export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt {
|
||||
};
|
||||
}
|
||||
|
||||
function mToPx(axes: CrossAxesOptions): (p: Pt) => string {
|
||||
/** m → 그림 px(`join` 하면 `x,y`). */
|
||||
function mToPx(axes: CrossAxesOptions): (p: Pt) => Pt {
|
||||
return ([offset, elevation]) => {
|
||||
const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration;
|
||||
const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter;
|
||||
const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter;
|
||||
return `${x},${y}`;
|
||||
return [x, y];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,9 +88,17 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli
|
||||
points: points.map(([offset, elevation]) => ({ offset, elevation })),
|
||||
structure: !el.hasAttribute(DESIGN_LINE_ATTR),
|
||||
});
|
||||
else if (OCCLUDER_CLASSES.some((name) => el.classList.contains(name)))
|
||||
// 벽 · 집수정 · 본체 둘레(261-3) — 설계선 끝 · 비탈 밑을 잇는 구조물 선(닫힌 고리)
|
||||
runs.push({
|
||||
points: [...points, points[0]].map(([offset, elevation]) => ({ offset, elevation })),
|
||||
structure: true,
|
||||
});
|
||||
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 +122,7 @@ const LINE_NAME: Record<SplitPiece["line"], string> = {
|
||||
structure: "구조물 선",
|
||||
ground: "원지반선",
|
||||
spoil: "사토장선",
|
||||
rock: "암 경계선",
|
||||
};
|
||||
|
||||
/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */
|
||||
@@ -111,22 +132,55 @@ 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] },
|
||||
structure: piece.line === "structure",
|
||||
});
|
||||
const horizontal = Math.abs(to[0] - from[0]);
|
||||
const height = Math.abs(to[1] - from[1]);
|
||||
const grade = horizontal > 0 ? `${((height / horizontal) * 100).toFixed(1)}%` : "수직";
|
||||
return (
|
||||
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` +
|
||||
`수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m`
|
||||
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m · 경사비율 ${grade}\n` +
|
||||
`수평 ${horizontal.toFixed(2)}m · 높이 ${height.toFixed(2)}m`
|
||||
);
|
||||
}
|
||||
const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
|
||||
const area = ringArea(pieces.map((piece) => piece.points));
|
||||
return (
|
||||
`${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` +
|
||||
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`)
|
||||
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`)
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 영역 값 두 줄(259-2) — 넓이 합 / 둘레(고른 둘레 조각 길이 합 · 붙은 영역은 맞닿은 변 뺌). */
|
||||
export function regionLabel(faces: Face[], pieces: SplitPiece[]): string {
|
||||
const area = faces.reduce((sum, face) => sum + face.area, 0);
|
||||
const around = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
|
||||
const count = faces.length > 1 ? `${faces.length}개 · ` : "";
|
||||
return `영역 ${count}면적 ${area.toFixed(2)}㎡\n둘레 ${around.toFixed(2)}m`;
|
||||
}
|
||||
|
||||
/** 영역 여럿 → 둘레 조각 — 둘 이상이 맞닿은 변은 짝수 번 나와 빠짐. */
|
||||
export function regionEdges(faces: Face[]): number[] {
|
||||
const count = new Map<number, number>();
|
||||
for (const face of faces)
|
||||
for (const edge of face.edges) count.set(edge, (count.get(edge) ?? 0) + 1);
|
||||
return [...count].filter(([, n]) => n % 2).map(([edge]) => edge);
|
||||
}
|
||||
|
||||
/** 그림 조각이 저장된 조각(두 끝 · 어느 방향이든)과 같은 자리인가(259-2 표 칸 → 그래프). */
|
||||
export function samePiece(piece: SplitPiece, stored: CrossPickPiece): boolean {
|
||||
if (piece.line !== stored.line) return false;
|
||||
const near = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M;
|
||||
const [a, b] = [piece.points[0], piece.points[piece.points.length - 1]];
|
||||
return (
|
||||
(near(a, stored.start) && near(b, stored.end)) || (near(a, stored.end) && near(b, stored.start))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,9 +203,74 @@ export interface DetailSelect {
|
||||
setMode: (kind: "area" | "length" | null) => void;
|
||||
source: (kind: "area" | "length") => ReturnType<typeof selectionSource>;
|
||||
clear: () => void;
|
||||
/** 저장된 조각을 그림에서 고름(259-2 표 칸 누름) · null = 비움. */
|
||||
choose: (stored: CrossPickPiece[] | null) => void;
|
||||
/** 고름 없이 값 글만(띠 · 259-2) — 다음 누름에 지워짐. */
|
||||
note: (text: string | null) => void;
|
||||
detach: () => void;
|
||||
}
|
||||
|
||||
/** 선 누름 거리(화면 px) — 이 안이면 선 · 영역 안이면 맞은편 선까지의 ⅓ 안이어야 선(261-3). */
|
||||
export const LINE_PICK_PX = 4;
|
||||
|
||||
const segmentGap = (p: Pt, a: Pt, b: Pt): number => {
|
||||
const d = [b[0] - a[0], b[1] - a[1]];
|
||||
const size2 = d[0] ** 2 + d[1] ** 2;
|
||||
const t = size2
|
||||
? Math.max(0, Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / size2))
|
||||
: 0;
|
||||
return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]);
|
||||
};
|
||||
const pathGap = (p: Pt, path: Pt[]): number =>
|
||||
Math.min(...path.slice(1).map((q, i) => segmentGap(p, path[i], q)));
|
||||
const inside = (p: Pt, ring: Pt[]): boolean =>
|
||||
ring.reduce((inn, a, i) => {
|
||||
const b = ring[(i + 1) % ring.length];
|
||||
return a[1] > p[1] !== b[1] > p[1] &&
|
||||
p[0] < a[0] + ((p[1] - a[1]) * (b[0] - a[0])) / (b[1] - a[1])
|
||||
? !inn
|
||||
: inn;
|
||||
}, false);
|
||||
|
||||
/**
|
||||
* 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이.
|
||||
* 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다
|
||||
* 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역.
|
||||
*/
|
||||
export function pickAt(
|
||||
point: Pt,
|
||||
scale: number,
|
||||
paths: Pt[][],
|
||||
rings: Pt[][],
|
||||
areas: number[],
|
||||
pieces: SplitPiece[],
|
||||
): { piece: number } | { face: number } | null {
|
||||
const gaps = paths.map((path) => pathGap(point, path) * scale);
|
||||
const nearest = gaps.reduce((best, gap, i) => (gap < gaps[best] ? i : best), 0);
|
||||
const ends = (piece: SplitPiece): Pt[] => [
|
||||
piece.points[0],
|
||||
piece.points[piece.points.length - 1],
|
||||
];
|
||||
const touches = (a: SplitPiece, b: SplitPiece): boolean =>
|
||||
ends(a).some((p) =>
|
||||
ends(b).some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= LINE_TOLERANCE_M),
|
||||
);
|
||||
const across = Math.min(
|
||||
Infinity,
|
||||
...gaps.filter((_, i) => i !== nearest && !touches(pieces[i], pieces[nearest])),
|
||||
);
|
||||
const face = rings
|
||||
.flatMap((ring, i) => (inside(point, ring) ? [i] : []))
|
||||
.sort((a, b) => areas[a] - areas[b])[0];
|
||||
if (
|
||||
paths.length &&
|
||||
gaps[nearest] <= LINE_PICK_PX &&
|
||||
(face === undefined || gaps[nearest] * 2 <= across)
|
||||
)
|
||||
return { piece: nearest };
|
||||
return face === undefined ? null : { face };
|
||||
}
|
||||
|
||||
/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */
|
||||
const pieceKey = (piece: SplitPiece | undefined): string =>
|
||||
piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : "";
|
||||
@@ -164,11 +283,22 @@ export function attachDetailSelect(input: {
|
||||
picked: (additive: boolean) => void;
|
||||
/** [칸에 넣기] 단추. */
|
||||
commit: () => void;
|
||||
/** 면적 고르기 중 그 자리에 면적 띠가 있나 — 있으면 영역 대신 띠(표가 맡음). */
|
||||
band?: (event: MouseEvent) => boolean;
|
||||
}): DetailSelect {
|
||||
const { cad } = input;
|
||||
let pieces: SplitPiece[] = [];
|
||||
let hits: SVGPolylineElement[] = [];
|
||||
let faces: Face[] = [];
|
||||
let faceEls: SVGPolygonElement[] = [];
|
||||
/** 그림 층 · 조각 · 영역 고리(그림 px) — 누름 판정. */
|
||||
let layer: SVGGElement | null = null;
|
||||
let paths: Pt[][] = [];
|
||||
let rings: Pt[][] = [];
|
||||
let selected: number[] = [];
|
||||
/** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */
|
||||
let chosenFaces: number[] = [];
|
||||
let noteText: string | null = null;
|
||||
let mode: "area" | "length" | null = null;
|
||||
|
||||
const label = document.createElement("span");
|
||||
@@ -186,26 +316,60 @@ export function attachDetailSelect(input: {
|
||||
const show = (): void => {
|
||||
const chosen = selected.map((i) => pieces[i]);
|
||||
hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i)));
|
||||
label.textContent = selectionLabel(input.design(), chosen);
|
||||
label.hidden = !chosen.length;
|
||||
const same = (edges: number[]) =>
|
||||
edges.length === selected.length && edges.every((edge) => selected.includes(edge));
|
||||
const filled = chosenFaces.length
|
||||
? chosenFaces
|
||||
: faces.flatMap((face, i) => (same(face.edges) ? [i] : []));
|
||||
faceEls.forEach((el, i) => el.classList.toggle("is-selected", filled.includes(i)));
|
||||
label.textContent =
|
||||
noteText ??
|
||||
(filled.length
|
||||
? regionLabel(
|
||||
filled.map((i) => faces[i]),
|
||||
chosen,
|
||||
)
|
||||
: selectionLabel(input.design(), chosen));
|
||||
label.hidden = !label.textContent;
|
||||
put.hidden = !mode || !selectionSource(mode, chosen);
|
||||
// [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김
|
||||
const bar = cad.querySelector(".b06-cross-card__zoom");
|
||||
if (bar && label.parentElement !== bar) bar.prepend(label, put);
|
||||
};
|
||||
|
||||
/** 누른 자리(그림 안만) → 선 · 영역 — 판정 선 굵기 대신 거리로(261-3). */
|
||||
const pickOf = (event: MouseEvent): ReturnType<typeof pickAt> => {
|
||||
const ctm = layer?.getScreenCTM();
|
||||
if (!ctm || !layer?.ownerSVGElement?.contains(event.target as Node)) return null;
|
||||
const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(ctm.inverse());
|
||||
const areas = faces.map((face) => face.area);
|
||||
return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces);
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`);
|
||||
const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area";
|
||||
if (!hit) {
|
||||
if (!additive && !mode && selected.length) {
|
||||
selected = [];
|
||||
const pick = pickOf(event);
|
||||
const shift = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||
const additive = shift || mode === "area";
|
||||
noteText = null;
|
||||
if (!pick || !("piece" in pick)) {
|
||||
if (pick && !(mode === "area" && input.band?.(event))) {
|
||||
// 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter)
|
||||
event.stopPropagation();
|
||||
const index = pick.face;
|
||||
chosenFaces = toggleSelection(chosenFaces, index, shift);
|
||||
selected = regionEdges(chosenFaces.map((i) => faces[i]));
|
||||
show();
|
||||
input.picked(true);
|
||||
return;
|
||||
}
|
||||
if (!additive && !mode) [selected, chosenFaces] = [[], []];
|
||||
show();
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive);
|
||||
// 선이 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬
|
||||
event.stopImmediatePropagation();
|
||||
chosenFaces = [];
|
||||
selected = toggleSelection(selected, pick.piece, additive);
|
||||
show();
|
||||
input.picked(additive);
|
||||
};
|
||||
@@ -216,19 +380,29 @@ export function attachDetailSelect(input: {
|
||||
// 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2)
|
||||
const keep = new Set(selected.map((i) => pieceKey(pieces[i])));
|
||||
const axes = input.axes();
|
||||
const layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
|
||||
selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : []));
|
||||
chosenFaces = [];
|
||||
faces = findFaces(pieces);
|
||||
const toPx = axes ? mToPx(axes) : null;
|
||||
hits = toPx
|
||||
? pieces.map((piece) => {
|
||||
const hit = document.createElementNS(SVG_NS, "polyline");
|
||||
hit.setAttribute("points", piece.points.map(toPx).join(" "));
|
||||
hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`);
|
||||
layer?.append(hit);
|
||||
return hit;
|
||||
})
|
||||
: [];
|
||||
paths = toPx ? pieces.map((piece) => piece.points.map(toPx)) : [];
|
||||
rings = toPx ? faces.map((face) => face.ring.map(toPx)) : [];
|
||||
// 영역 판정 면 먼저(밑) → 조각 판정 선(위)
|
||||
faceEls = rings.map((ring) => {
|
||||
const el = document.createElementNS(SVG_NS, "polygon");
|
||||
el.setAttribute("points", ring.join(" "));
|
||||
el.setAttribute("class", FACE_HIT_CLASS);
|
||||
layer?.append(el);
|
||||
return el;
|
||||
});
|
||||
hits = paths.map((path, i) => {
|
||||
const hit = document.createElementNS(SVG_NS, "polyline");
|
||||
hit.setAttribute("points", path.join(" "));
|
||||
hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${pieces[i].line}`);
|
||||
layer?.append(hit);
|
||||
return hit;
|
||||
});
|
||||
show();
|
||||
},
|
||||
setMode(kind) {
|
||||
@@ -241,7 +415,19 @@ export function attachDetailSelect(input: {
|
||||
selected.map((i) => pieces[i]),
|
||||
),
|
||||
clear() {
|
||||
selected = [];
|
||||
[selected, chosenFaces] = [[], []];
|
||||
show();
|
||||
},
|
||||
choose(stored) {
|
||||
chosenFaces = [];
|
||||
noteText = null;
|
||||
selected = stored
|
||||
? pieces.flatMap((piece, i) => (stored.some((s) => samePiece(piece, s)) ? [i] : []))
|
||||
: [];
|
||||
show();
|
||||
},
|
||||
note(text) {
|
||||
noteText = text;
|
||||
show();
|
||||
},
|
||||
detach() {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) —
|
||||
* 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록.
|
||||
* 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기].
|
||||
* 닫힌 영역 안 누름 = 그 영역 둘레 조각(259-2) · 면적 고르기 중 띠 자리는 띠가 앞.
|
||||
* 표 칸 누름(259-2) = 저장된 조각을 그림에서 다시 고름 · 띠 칸 = 값 글(`cellGraph` · `bandLabel`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
@@ -19,10 +21,10 @@ import type {
|
||||
CrossDetailTable,
|
||||
CrossSection,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { SectionSample } from "./B06_Section_Api_Types";
|
||||
import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
|
||||
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom";
|
||||
import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select";
|
||||
import { attachDetailSelect } from "./B06_Section_UI_Cross_Detail_Select";
|
||||
|
||||
/** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */
|
||||
export const MAX_ROWS = 12;
|
||||
@@ -173,6 +175,32 @@ export function areaSourceOf(target: Element): CrossCellSource | null {
|
||||
return band ? { src: "area", key: band[1] } : null;
|
||||
}
|
||||
|
||||
/** 그 칸이 그래프에서 무엇을 가리키나(259-2) — 저장된 조각 · 띠 키(저장 띠 출처 → 칸 키) · 없음. */
|
||||
export function cellGraph(
|
||||
table: CrossDetailTable,
|
||||
key: string,
|
||||
): { pieces: CrossPickPiece[] } | { band: string } | null {
|
||||
const source = table.cells[key];
|
||||
if (source && "pieces" in source) return { pieces: source.pieces };
|
||||
if (source && source.src === "area" && "key" in source) return { band: source.key };
|
||||
return null;
|
||||
}
|
||||
|
||||
const BAND_NAME: Readonly<Record<string, string>> = {
|
||||
cut_soil: "깍기(토사)",
|
||||
cut_rock: "깍기(암석)",
|
||||
ditch_soil: "측구(토사)",
|
||||
ditch_rock: "측구(암석)",
|
||||
embankment: "쌓기",
|
||||
spoil_fill: "사토장",
|
||||
};
|
||||
|
||||
/** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */
|
||||
export function bandLabel(key: string, design: CrossDesign | undefined): string | null {
|
||||
const value = BAND_NAME[key] ? localCellValue({ src: "area", key }, design) : null;
|
||||
return value === null ? null : `${BAND_NAME[key]} 면적 ${value.toFixed(2)}㎡`;
|
||||
}
|
||||
|
||||
/* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */
|
||||
|
||||
const AREA_DESIGN: Readonly<Record<string, string>> = {
|
||||
@@ -280,7 +308,7 @@ export interface DetailTableHost {
|
||||
let menu: ReturnType<typeof createMapContextMenu> | null = null;
|
||||
|
||||
const PICK_HINT = {
|
||||
area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소",
|
||||
area: "면적 띠를 누름 · 또는 닫힌 영역 안 · 선들을 누름 → Enter · Esc 취소",
|
||||
length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소",
|
||||
} as const;
|
||||
|
||||
@@ -289,6 +317,10 @@ const PICK_HINT = {
|
||||
export function attachDetailTable(host: DetailTableHost): {
|
||||
detach: () => void;
|
||||
redraw: () => void;
|
||||
/** 칸 그래프 짝(259-2) — 저장된 조각 · 띠 키 · 없음. */
|
||||
graph: (key: string) => ReturnType<typeof cellGraph>;
|
||||
/** 표 칸 누름(259-2) — 저장된 조각을 그림에서 고름 · 띠면 값 글 · null = 비움. */
|
||||
showCell: (key: string | null) => void;
|
||||
} {
|
||||
menu ??= createMapContextMenu("b06-detail");
|
||||
const ctxMenu = menu;
|
||||
@@ -323,6 +355,7 @@ export function attachDetailTable(host: DetailTableHost): {
|
||||
const shown = localCellValue(source, section.design, section.samples);
|
||||
const td = tableBox.querySelector<HTMLElement>(`td[data-cell="${CSS.escape(key)}"]`);
|
||||
if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2);
|
||||
if ("key" in source) select.note(bandLabel(source.key, section.design)); // 띠도 값 글(259-2)
|
||||
edit((table) => setCell(table, key, source));
|
||||
};
|
||||
const commitSelection = (): void => {
|
||||
@@ -354,14 +387,16 @@ export function attachDetailTable(host: DetailTableHost): {
|
||||
if (picking?.kind === "length" && !additive) commitSelection();
|
||||
},
|
||||
commit: commitSelection,
|
||||
band: (event) => !!bandAt(event),
|
||||
});
|
||||
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음
|
||||
const onPick = (event: MouseEvent): void => {
|
||||
if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`))
|
||||
return;
|
||||
const hit = document
|
||||
const bandAt = (event: MouseEvent): Element | undefined =>
|
||||
document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
|
||||
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 누름은 위가 막음(261-3 거리 판정)
|
||||
const onPick = (event: MouseEvent): void => {
|
||||
if (picking?.kind !== "area") return;
|
||||
const hit = bandAt(event);
|
||||
const source = hit ? areaSourceOf(hit) : null;
|
||||
if (!source) return;
|
||||
event.stopPropagation();
|
||||
@@ -426,5 +461,15 @@ export function attachDetailTable(host: DetailTableHost): {
|
||||
tableBox.removeEventListener("keydown", onEnter);
|
||||
},
|
||||
redraw: () => select.redraw(),
|
||||
graph: (key) => cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key),
|
||||
showCell: (key) => {
|
||||
const target = key
|
||||
? cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key)
|
||||
: null;
|
||||
select.choose(target && "pieces" in target ? target.pieces : null);
|
||||
// 저장 조각 = 조각 값 글 · 저장 띠 = 그 띠 · 저장 없음 = 칸 키 띠
|
||||
const band = !target ? key : "band" in target ? target.band : null;
|
||||
select.note(band ? bandLabel(band, host.section().design) : null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
* 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음.
|
||||
* 풀기(Esc · 카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 · 범위 테두리 ·
|
||||
* 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4).
|
||||
* 한 벌 배수유역도(관 마커 · 유역 · 측점 마름모)는 낸 쪽과 무관하게 늘 값대로 칠함 — 횡단 보기 안
|
||||
* 누름(카드 · 목록)도 지도에 이어짐(270 · 종단 보기 `paintMapPick` 과 같은 규칙).
|
||||
* ========================================================================== */
|
||||
|
||||
import { subscribeSelection } from "../A00_Common/design/design_selection";
|
||||
import { subscribeSelection, type DesignSelection } from "../A00_Common/design/design_selection";
|
||||
import { bindEscapeRelease } from "../A00_Common/design/design_pick_escape";
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
@@ -27,6 +29,8 @@ export interface CrossPickDeps {
|
||||
};
|
||||
/** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */
|
||||
reapply: () => void;
|
||||
/** 배수유역도를 값으로 칠함(알림 없이 · 270). */
|
||||
paintMap?: (selection: DesignSelection | null) => void;
|
||||
}
|
||||
|
||||
let releasing = false;
|
||||
@@ -65,7 +69,8 @@ function releaseAll(deps: CrossPickDeps): void {
|
||||
}
|
||||
|
||||
/** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음.
|
||||
* `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */
|
||||
* `release` = 강조 모두 끔(값은 부르는 쪽이 비움). 지도는 보일 때 모든 값을 칠함 — 보기 전환 때는
|
||||
* 종단 보기가 같은 값으로 칠해 둠 · 재계산 뒤는 횡단 보기 종단도 알림(`onPipesChanged`)이 다시 얹음. */
|
||||
export function bindCrossPick(
|
||||
projectId: string | null,
|
||||
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
|
||||
@@ -73,7 +78,9 @@ export function bindCrossPick(
|
||||
): { release: () => void } {
|
||||
host.onDispose(
|
||||
subscribeSelection(projectId, (selection, from) => {
|
||||
if (!host.visible() || from === "b06") return;
|
||||
if (!host.visible()) return;
|
||||
deps.paintMap?.(selection);
|
||||
if (from === "b06") return;
|
||||
if (selection.chainageM === null) releaseCrossHighlights(deps);
|
||||
else deps.reapply();
|
||||
}),
|
||||
|
||||
@@ -62,13 +62,13 @@ const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를
|
||||
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
|
||||
/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */
|
||||
const PIPE_ADD_NOTICE =
|
||||
"계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
"계곡 통과 시설을 넣었습니다 — 횡단도는 바로 보이고, 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
const PIPE_REMOVE_NOTICE =
|
||||
"계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
"계곡 통과 시설을 뺐습니다 — 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
/** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */
|
||||
const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다.";
|
||||
const PIPE_MOVE_NOTICE =
|
||||
"기준 측점을 옮겼습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
"기준 측점을 옮겼습니다 — 횡단도는 바로 보이고, 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
|
||||
export interface B06StructuresPanelDeps {
|
||||
projectId: string | null;
|
||||
@@ -493,6 +493,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2)
|
||||
showToast(PIPE_MOVE_NOTICE, "success");
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface SectionStructureEdit {
|
||||
moveStructure: (structureId: string, toChainageM: number) => void;
|
||||
/** 구조물(관 아님) 하나를 폼에 올리고 그 카드로 간다 — 같은 측점 관보다 먼저(52-15). */
|
||||
selectStructure: (structureId: string) => void;
|
||||
/** 배수 재계산이 관 종류 · 관경을 바꿈 — 카드(미리 뜬 새 관 그림 포함)를 다시 얹음(273-2). */
|
||||
pipesChanged?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,14 @@ import {
|
||||
type DesignPageShared,
|
||||
} from "../A00_Common/design/design_shared";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { coversChainage, PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import {
|
||||
coversChainage,
|
||||
NO_SELECTION,
|
||||
PIPE_MATCH_M,
|
||||
readSelection,
|
||||
selectDesign,
|
||||
} from "../A00_Common/design/design_selection";
|
||||
import { paintMapPick } from "../A00_Common/design/design_pick_profile";
|
||||
import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import type {
|
||||
CrossSection,
|
||||
@@ -139,6 +146,8 @@ export function createSectionProfile(
|
||||
let types: ReadonlyArray<StructureType> = [];
|
||||
let stations: IrregularStation[] = [];
|
||||
let intervalM = 20;
|
||||
/** 마지막으로 받은 관 목록 지문(자리 · 종류 · 관경) — 바뀔 때만 카드를 다시 얹음. */
|
||||
let pipeSignature: string | null = null;
|
||||
|
||||
/** 가까운 카드 — 관 측점 · 구조물 자리에서 카드를 고를 때. */
|
||||
const nearestCard = (chainageM: number): CrossSection | null => {
|
||||
@@ -158,6 +167,15 @@ export function createSectionProfile(
|
||||
return stationId;
|
||||
};
|
||||
|
||||
/** 배수유역도에서 고른 관 자리 → 고름 한 칸(null = 풂) — 횡단 보기 구독이 카드 · 폼 · 알약 · 표 열을
|
||||
* 그 값으로 세움(종단 보기 `pickFromMap` 과 같은 몫 · 270). */
|
||||
const pickFromMap = (chainageM: number | null): void =>
|
||||
selectDesign(
|
||||
shared.projectId,
|
||||
chainageM === null ? NO_SELECTION : { chainageM, structureId: null },
|
||||
{ from: "map" },
|
||||
);
|
||||
|
||||
const syncMarks = (): void => {
|
||||
if (!profile) return;
|
||||
profile.setStructureTypes([...types]);
|
||||
@@ -196,6 +214,20 @@ export function createSectionProfile(
|
||||
onStructureTypeAdd: (chainage, typeId) => host.edit()?.addStructureType(chainage, typeId),
|
||||
onIrregularSelect: (station) =>
|
||||
host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null),
|
||||
onBasinSelected: (chainageM) => pickFromMap(chainageM),
|
||||
onPipeSelected: (chainageM) => pickFromMap(chainageM),
|
||||
// 배수 재계산이 지운 지도 강조를 값대로 다시 얹음(종단 보기 restorePick 과 같은 몫 · 270)
|
||||
onPipesChanged: (pipes) => {
|
||||
if (shared.view() === "cross" && shared.profile?.side)
|
||||
paintMapPick(shared.profile.side, readSelection(shared.projectId));
|
||||
// 관 종류 · 관경이 바뀜(새 관 관경은 배수 재계산이 정함) — 카드 관 그림도 다시(273-2)
|
||||
const signature = JSON.stringify(
|
||||
pipes.map((p) => [p.chainage_m, p.facility, p.options?.pipe_diameter_mm ?? null]),
|
||||
);
|
||||
const changed = pipeSignature !== null && signature !== pipeSignature;
|
||||
pipeSignature = signature;
|
||||
if (changed && shared.view() === "cross") host.edit()?.pipesChanged?.();
|
||||
},
|
||||
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
|
||||
// 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15).
|
||||
onStructureSelect: (structureId) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -51,8 +51,6 @@
|
||||
문제 제거(2026-08-06 사용자 보고). */
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b06-profile__group-legend {
|
||||
@@ -64,7 +62,7 @@
|
||||
|
||||
/* 라벨·입력·버튼을 **한 줄**로 세운다(횡단 보기 설정, 2026-08-23 사용자 지시).
|
||||
ui-field는 기본이 세로(라벨 위·입력 아래·그 아래 오류 슬롯)라 그대로 두면 버튼이
|
||||
오류 슬롯 높이만큼 밀려 수평이 안 맞는다 — 이 줄에서만 가로로 눕히고 오류 슬롯을 접는다. */
|
||||
오류 슬롯 높이만큼 밀려 수평이 안 맞는다 — 이 줄에서만 가로로 눕힘(오류 슬롯 접기 = 공용 양식 `.ui-panel-form`). */
|
||||
.b06-profile__field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -91,10 +89,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b06-profile__field-row .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 창이 좁아 좌측 패널이 237px 까지 물러난 자리 — 라벨이 안 줄어 2px 이 새어 나왔다.
|
||||
그 폭에서만 라벨을 윗줄로 접는다. 넓은 폭 배치는 손대지 않는다(2026-09-08 실측).
|
||||
⚠ 폭 제한 없이 `flex-wrap` 을 주면 넓은 폭에서도 라벨이 윗줄로 올라간다 — 재 보고 되돌렸다. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Style_Cross_Detail.css
|
||||
* 횡단 카드 [상세] 단추 · 모달(138-4 · 150-2) — 공용 큰 창 · 그림(남은 높이) → 글 칸 → B07 수량표.
|
||||
* 횡단 카드 [상세] 단추 · 모달(138-4 · 150-2) — 공용 큰 창 · 그림(남은 높이) → B07 수량표 → CAD 칸(265-1).
|
||||
* ========================================================================== */
|
||||
|
||||
/* 측구 줄 맨 오른쪽 — ⋯ 가 보이면 그 옆, 숨으면 혼자 오른쪽 끝 */
|
||||
@@ -18,6 +18,20 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 표 위 CAD 칸(265-1) — 표 아래 · 머리 한 줄 접기(공용 `ui-collapsible` · 접히면 그림이 남은 높이) */
|
||||
.b06-detail__notebox {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 4px);
|
||||
}
|
||||
|
||||
.b06-detail__notehead {
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b06-detail__note {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
@@ -28,6 +42,8 @@
|
||||
|
||||
.b06-detail__table {
|
||||
flex: none;
|
||||
/* 고른 칸 바깥 줄(259-3) 자리 기준 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 그림 칸 = 테마 색(173-4 · 카드와 같은 바탕) · 그림은 칸 크기로 새로 그려 가득 */
|
||||
@@ -81,7 +97,7 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 · 줄 붉게 */
|
||||
/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 붉게 · 테두리는 묶음 바깥 한 줄(259-3) */
|
||||
.b06-detail__qtable [data-key] {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -92,12 +108,23 @@
|
||||
|
||||
.b06-detail__qtable .is-picked {
|
||||
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
|
||||
box-shadow: inset 0 0 0 1px var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.b06-detail__qtable tr:has(.is-picked) {
|
||||
background: color-mix(in srgb, var(--color-danger) 5%, transparent);
|
||||
.b06-detail__pick-outline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b06-detail__pick-outline path {
|
||||
fill: none;
|
||||
stroke: var(--color-danger);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: square;
|
||||
}
|
||||
|
||||
/* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */
|
||||
@@ -172,7 +199,8 @@
|
||||
.b06-detail__piece {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 10;
|
||||
/* 누름 판정은 거리(LINE_PICK_PX 4) — 굵기는 걸침 표시만 */
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: stroke;
|
||||
@@ -188,6 +216,22 @@
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
/* 닫힌 영역 판정 면(259-2) — 투명 채움으로 누름 · 고른 영역 = 붉은 채움 */
|
||||
.b06-detail__face {
|
||||
fill: transparent;
|
||||
stroke: none;
|
||||
pointer-events: fill;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-detail__face:hover {
|
||||
fill: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.b06-detail__face.is-selected {
|
||||
fill: color-mix(in srgb, var(--color-danger) 22%, transparent);
|
||||
}
|
||||
|
||||
/* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */
|
||||
.b06-detail__commit {
|
||||
width: auto;
|
||||
|
||||
@@ -29,6 +29,8 @@ const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */
|
||||
const STRUCTURE_LAYER_ID = "b08-structure";
|
||||
const STRUCTURE_COLOR = "#f6d55c";
|
||||
/** 배수관 · 박스암거 색 — 구조물(노랑) · 설계선(보라) · 지반선(흰/검정)과 또렷이 다른 청록(268-2). */
|
||||
const PIPE_COLOR = "#22d3ee";
|
||||
/** 설계선 도면층·색 — 서버 도면(`B07_DesignDetail_Engine_Cad`)이 쓰는 그 값이다. */
|
||||
export const DESIGN_LAYER_ID = "b08-design";
|
||||
const DESIGN_COLOR = "#b794f6";
|
||||
@@ -49,6 +51,7 @@ interface Style {
|
||||
}
|
||||
|
||||
const STRUCTURE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: STRUCTURE_COLOR };
|
||||
const PIPE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: PIPE_COLOR };
|
||||
const DESIGN_STYLE: Style = { layerId: DESIGN_LAYER_ID, color: DESIGN_COLOR };
|
||||
|
||||
function baseEntity(style: Style, type: string, shapeData: unknown): Entity {
|
||||
@@ -494,9 +497,10 @@ function drawDesign(
|
||||
);
|
||||
}
|
||||
|
||||
/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다. */
|
||||
/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다 — 박스암거 · 배수관은 pipeSvg 로 따로(268-2). */
|
||||
function drawStructures(
|
||||
svg: SVGElement,
|
||||
pipeSvg: SVGElement,
|
||||
section: CrossSection,
|
||||
layouts: Layouts,
|
||||
x: (offset: number) => number,
|
||||
@@ -504,12 +508,12 @@ function drawStructures(
|
||||
): void {
|
||||
const { link, culvert, box, ford, own } = layouts;
|
||||
if (own) appendRevetmentOverlay(svg, own, x, y);
|
||||
if (box) appendBoxOverlay(svg, box, x, y);
|
||||
if (box) appendBoxOverlay(pipeSvg, box, x, y);
|
||||
if (ford) appendFordOverlay(svg, ford, x, y);
|
||||
if (culvert) {
|
||||
const linked = !section.culvert && !!link;
|
||||
appendCulvertOverlay(
|
||||
svg,
|
||||
pipeSvg,
|
||||
culvert,
|
||||
x,
|
||||
y,
|
||||
@@ -536,9 +540,11 @@ export function overlayEntities(
|
||||
const designSvg = document.createElementNS(SVG_NS, "svg");
|
||||
drawDesign(designSvg, section, layouts, x, y);
|
||||
const structureSvg = document.createElementNS(SVG_NS, "svg");
|
||||
drawStructures(structureSvg, section, layouts, x, y);
|
||||
const pipeSvg = document.createElementNS(SVG_NS, "svg");
|
||||
drawStructures(structureSvg, pipeSvg, section, layouts, x, y);
|
||||
return [
|
||||
...harvest(designSvg, DESIGN_STYLE, frame),
|
||||
...harvest(structureSvg, STRUCTURE_STYLE, frame),
|
||||
...harvest(pipeSvg, PIPE_STYLE, frame),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -524,10 +524,9 @@ def build_cross_drawing(
|
||||
|
||||
# 표 모듈은 이 모듈의 직렬화 헬퍼를 쓰므로 순환을 피해 함수 안에서 들여온다.
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
_cross_note_entities,
|
||||
_cross_table_entities,
|
||||
cross_note_height,
|
||||
cross_note_lines,
|
||||
cross_note_block_entities,
|
||||
cross_note_block_height,
|
||||
cross_table_height,
|
||||
cross_table_width,
|
||||
)
|
||||
@@ -550,7 +549,7 @@ def build_cross_drawing(
|
||||
if quantity_table is not None:
|
||||
stack = (
|
||||
cross_table_height(design, table_scale)
|
||||
+ cross_note_height(cross_note_lines(design), table_scale)
|
||||
+ cross_note_block_height(design, table_scale)
|
||||
+ 8.0
|
||||
)
|
||||
oy = table_bottom + stack + own_half_height * CROSS_MM
|
||||
@@ -580,12 +579,11 @@ def build_cross_drawing(
|
||||
table_bottom = oy - half_height
|
||||
if quantity_table is not None:
|
||||
table_top = oy - half_height - 8.0
|
||||
# 표 위 글(150-3) — 줄 수만큼 표를 내림
|
||||
note_lines = cross_note_lines(design)
|
||||
# 표 위 글(150-3) · CAD 칸(260-3) — 그 높이만큼 표를 내림
|
||||
entities.extend(
|
||||
_cross_note_entities(drawing_id, note_lines, table_top, center_x, table_scale)
|
||||
cross_note_block_entities(drawing_id, design, table_top, center_x, table_scale)
|
||||
)
|
||||
table_top -= cross_note_height(note_lines, table_scale)
|
||||
table_top -= cross_note_block_height(design, table_scale)
|
||||
entities.extend(
|
||||
_cross_table_entities(
|
||||
drawing_id, quantity_table, table_top, title_label, center_x, design, table_scale
|
||||
@@ -630,6 +628,12 @@ def build_cross_drawing(
|
||||
# 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울
|
||||
# 서버 설계선을 이 안에서만 골라내는 데 쓴다.
|
||||
"frame": [frame_left, frame_bottom, frame_right, frame_top],
|
||||
# 화면이 다시 그려 갈아 끼울 서버 선 — 설계선 + 측구 구간 구조물 선(268-1)
|
||||
"design_ids": [
|
||||
e["id"]
|
||||
for e in entities
|
||||
if e["layerId"] in (DESIGN_LAYER_ID, STRUCTURE_LAYER_ID)
|
||||
],
|
||||
}
|
||||
],
|
||||
"layers": [
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""B07 횡단도 표 위 CAD 칸 — 미리 넣음(260-4 · 265-2 · 요구 162-21 · 164-5).
|
||||
|
||||
⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `noteEntities` · `structureNoteLines` ·
|
||||
`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py` ·
|
||||
`test_265_note_cad.py`.
|
||||
B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 그림을 놓음 · 사용자가 지운 칸
|
||||
(빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저
|
||||
(모달이 처음 열 때 옮기는 차례와 같음).
|
||||
그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된
|
||||
구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」) + 그 구조물 좌측 패널 옵션 줄(166-1).
|
||||
옵션 줄은 파이썬으로 다시 짜지 않음 — 화면과 같은 TS(`structureOptionLines`)를 Node
|
||||
진입점(`B07_DesignDetail_Note_Options_Node.ts` · CLAUDE.md 5장 ②)으로 돌려 받음 ·
|
||||
실패는 비치명(조합 줄만).
|
||||
좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_structure_types
|
||||
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file
|
||||
from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
_OPTIONS_BUNDLE = ROOT / "config" / "b07_note_node" / "B07_DesignDetail_Note_Options_Node.js"
|
||||
_OPTIONS_NPM = "build:b07-note"
|
||||
|
||||
# TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭
|
||||
PIPE_MATCH_M = 0.51
|
||||
_LAYER_ID = "기본"
|
||||
_FONT = 2.0
|
||||
_LINE = 3.0
|
||||
_COLOR = "#ff7f7f"
|
||||
# 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리
|
||||
_PIPE_BODY_LINK = "grv6lny"
|
||||
_LAYERS = ("system", "company", "personal")
|
||||
_GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
|
||||
|
||||
#: (관 누가거리, 세트, 옵션) — 원형 마크 재료
|
||||
Pipe = tuple[float, dict[str, Any], dict[str, Any]]
|
||||
#: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items, type_id, options, option_lines}`
|
||||
#: — TS `NoteThing`(option_lines = `optionLines`)
|
||||
Thing = dict[str, Any]
|
||||
|
||||
|
||||
def _js_round(value: float) -> int:
|
||||
"""JS `Math.round` — .5 는 위로."""
|
||||
return math.floor(value + 0.5)
|
||||
|
||||
|
||||
def _number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||
|
||||
|
||||
def _whole_metres(value: Any) -> str:
|
||||
"""관 길이 = 사사오입 정수(265-3 · TS `pipeLengthWhole` · B08 「관매설」과 같은 값)."""
|
||||
return f"{_js_round(value)}m" if _number(value) else ""
|
||||
|
||||
|
||||
def station_no_label(chainage_m: float, interval_m: float) -> str:
|
||||
"""측점 이름(대문자 NO · 거리 정수) — TS `stationNoLabel` 짝."""
|
||||
safe = interval_m if interval_m > 0 else 1.0
|
||||
number = math.floor((chainage_m + 1e-6) / safe)
|
||||
rest = chainage_m - number * safe
|
||||
if rest >= safe - 0.05:
|
||||
number += 1
|
||||
rest = 0.0
|
||||
return f"NO.{number}" if abs(rest) < 0.05 else f"NO.{number}+{_js_round(rest)}"
|
||||
|
||||
|
||||
def _summary_item(value: Any) -> dict[str, Any] | None:
|
||||
"""TS `readSummaryItem` — 모양이 맞으면 그대로 · 틀리면 None."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
owner, key = value.get("owner"), value.get("key")
|
||||
ok = (
|
||||
value.get("layer") in _LAYERS
|
||||
and (owner is None or isinstance(owner, str))
|
||||
and isinstance(key, str)
|
||||
and key != ""
|
||||
and isinstance(value.get("name"), str)
|
||||
)
|
||||
return value if ok else None
|
||||
|
||||
|
||||
def _group_items(value: Any) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""TS `readGroupItems` — 그룹 id · 항목 모양이 맞는 것만(차례 그대로)."""
|
||||
if not isinstance(value, dict):
|
||||
return []
|
||||
return [
|
||||
(gid, item)
|
||||
for gid, raw in value.items()
|
||||
if _GROUP_ID.match(str(gid)) and (item := _summary_item(raw))
|
||||
]
|
||||
|
||||
|
||||
def things_at(things: list[Thing], chainage_m: float) -> list[Thing]:
|
||||
"""그 측점에 놓인 구조물 — TS `thingsAt`."""
|
||||
return [thing for thing in things if abs(thing["at"] - chainage_m) < PIPE_MATCH_M]
|
||||
|
||||
|
||||
def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]:
|
||||
"""구조물 조합 줄 — TS `structureNoteLines` 짝.
|
||||
|
||||
본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌 ·
|
||||
이어서 그 구조물 좌측 패널 옵션 줄(`option_lines` · 166-1).
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for thing in things_at(things, chainage_m):
|
||||
own = _summary_item(thing.get("summary_item"))
|
||||
slots = ([("", own)] if own else []) + _group_items(thing.get("group_items"))
|
||||
names = [
|
||||
item["name"] or item["key"]
|
||||
for gid, item in slots
|
||||
if not (thing["pipe"] and gid in ("", _PIPE_BODY_LINK))
|
||||
]
|
||||
if slots:
|
||||
lines += list(dict.fromkeys(names))
|
||||
elif not thing["pipe"]:
|
||||
lines.append(f"{thing['name']} 조합 미지정")
|
||||
lines += [str(line) for line in thing.get("option_lines") or []]
|
||||
return lines
|
||||
|
||||
|
||||
def _shape(index: int, kind: str, shape_data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": f"culvert-note:{index}",
|
||||
"type": kind,
|
||||
"lineColor": _COLOR,
|
||||
"lineWidth": 1,
|
||||
"layerId": _LAYER_ID,
|
||||
"shapeData": shape_data,
|
||||
}
|
||||
|
||||
|
||||
def _text(index: int, label: str, x: float, y: float, align: str = "left") -> dict[str, Any]:
|
||||
return _shape(
|
||||
index,
|
||||
"Text",
|
||||
{
|
||||
"label": label,
|
||||
"basePoint": {"x": x, "y": y},
|
||||
"options": {
|
||||
"textDirection": {"x": 1, "y": 0},
|
||||
"textAlign": align,
|
||||
"textColor": _COLOR,
|
||||
"fontSize": _FONT,
|
||||
"fontFamily": "sans-serif",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _pipe_mark(
|
||||
chainage_m: float, culvert: dict[str, Any] | None, design: dict[str, Any] | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""원형 마크 — TS `pipeMark`(주인 배수관만 · 상자형 · 숨은 관 · 이웃 = 없음)."""
|
||||
if not isinstance(culvert, dict) or culvert.get("type") != "pipe":
|
||||
return []
|
||||
if culvert.get("body") or culvert.get("hidden_pipe"):
|
||||
return []
|
||||
owner = culvert.get("chainage_m")
|
||||
if abs((owner if isinstance(owner, (int, float)) else chainage_m) - chainage_m) >= PIPE_MATCH_M:
|
||||
return []
|
||||
cx, cy, radius = 6.5, -9.0, 6.5
|
||||
length = design.get("pipe_length_m") if isinstance(design, dict) else None
|
||||
inside = [
|
||||
"배수관",
|
||||
f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m",
|
||||
f"L={_whole_metres(length)}",
|
||||
]
|
||||
# 원 + 원 안 세 줄 — 아래 수직 지시선 없음(요구 169)
|
||||
return [_shape(0, "Circle", {"center": {"x": cx, "y": cy}, "radius": radius})] + [
|
||||
_text(1 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside)
|
||||
]
|
||||
|
||||
|
||||
def note_entities(
|
||||
chainage_m: float,
|
||||
culvert: dict[str, Any] | None,
|
||||
design: dict[str, Any] | None,
|
||||
station: str,
|
||||
lines: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""미리 넣음 — TS `noteEntities` 짝(원형 마크 + 측점 · 조합 줄 · 마크 없으면 왼쪽 끝부터).
|
||||
|
||||
`culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `lines` = `structure_note_lines`.
|
||||
"""
|
||||
mark = _pipe_mark(chainage_m, culvert, design)
|
||||
if not mark and not lines:
|
||||
return []
|
||||
x = 15 if mark else 0
|
||||
start = len(mark)
|
||||
return mark + [
|
||||
_text(start + i, line, x, -_LINE * (i + 0.5)) for i, line in enumerate([station, *lines])
|
||||
]
|
||||
|
||||
|
||||
def _things(points: list[Any], structures: list[Any], names: dict[str, str]) -> list[Thing]:
|
||||
"""글 줄 재료 — 관 지점 → 구조물 배치 차례(TS `loadNoteThings` 와 같은 차례)."""
|
||||
things: list[Thing] = [
|
||||
{
|
||||
"name": names.get(point.facility, point.facility),
|
||||
"pipe": point.facility == PIPE_FACILITY_PIPE,
|
||||
"at": float(point.chainage_m),
|
||||
"summary_item": point.summary_item,
|
||||
"group_items": point.group_items,
|
||||
"type_id": point.facility,
|
||||
"options": dict(point.options or {}),
|
||||
}
|
||||
for point in points
|
||||
]
|
||||
for item in structures:
|
||||
at = item.chainage_m if item.chainage_m is not None else item.start_m
|
||||
if not _number(at):
|
||||
continue
|
||||
things.append(
|
||||
{
|
||||
"name": names.get(item.type_id, item.type_id),
|
||||
"pipe": False,
|
||||
"at": float(at),
|
||||
"summary_item": item.summary_item,
|
||||
"group_items": item.group_items,
|
||||
"type_id": item.type_id,
|
||||
"options": dict(item.options or {}),
|
||||
}
|
||||
)
|
||||
return things
|
||||
|
||||
|
||||
def with_option_lines(things: list[Thing]) -> list[Thing]:
|
||||
"""구조물마다 좌측 패널 옵션 줄(166-1) — 화면 TS 를 Node 로 한 번에 · 실패면 그대로."""
|
||||
if not things:
|
||||
return things
|
||||
try:
|
||||
# 지연 import — M02 정의 저장소가 B07 엔진 import 차례에 끼지 않게
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible
|
||||
|
||||
payload = {
|
||||
"things": [
|
||||
{"type_id": t["type_id"], "options": t.get("options") or {}} for t in things
|
||||
],
|
||||
"types": with_visible().model_dump(mode="json")["types"],
|
||||
"definition": load_definition(),
|
||||
}
|
||||
output = run_bundle_json(_OPTIONS_BUNDLE, _OPTIONS_NPM, payload)
|
||||
except Exception:
|
||||
logger.warning("B07 CAD 칸 옵션 줄 계산 실패 — 조합 줄만 넣음", exc_info=True)
|
||||
return things
|
||||
rows = output.get("lines") if isinstance(output, dict) else None
|
||||
if not isinstance(rows, list) or len(rows) != len(things):
|
||||
return things
|
||||
return [
|
||||
{
|
||||
**thing,
|
||||
"option_lines": [line for line in row if isinstance(line, str)]
|
||||
if isinstance(row, list)
|
||||
else [],
|
||||
}
|
||||
for thing, row in zip(things, rows)
|
||||
]
|
||||
|
||||
|
||||
def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]:
|
||||
"""정본 재료 — (원형 마크 배수관 · 관경 안 고름 = 없음, 글 줄 재료 = 관 지점 + 구조물 배치)."""
|
||||
try:
|
||||
document = json.loads(pipe_points_file(project_root).read_text(encoding="utf-8"))
|
||||
points = parse_pipe_points(document.get("points"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
points = []
|
||||
pipes: list[Pipe] = []
|
||||
for point in points:
|
||||
options = dict(point.options or {})
|
||||
if point.facility != PIPE_FACILITY_PIPE or options.get("pipe_diameter_mm") in (None, ""):
|
||||
continue
|
||||
spec = {**_culvert_set(options), "chainage_m": float(point.chainage_m)}
|
||||
pipes.append((float(point.chainage_m), spec, options))
|
||||
_revision, structures = load_structures(str(project_root))
|
||||
names = {kind.type_id: kind.name for kind in load_structure_types()}
|
||||
return pipes, with_option_lines(_things(points, structures, names))
|
||||
|
||||
|
||||
def with_structure_note(
|
||||
design: dict[str, Any] | None,
|
||||
chainage_m: float,
|
||||
sources: tuple[list[Pipe], list[Thing]],
|
||||
station: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""CAD 칸이 없고 옛 글도 없는 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로.
|
||||
|
||||
설계가 없는 측점(dict 아님)은 그대로 — 표 · 계획선 셈이 설계 없음으로 가는 길을 안 바꿈.
|
||||
"""
|
||||
if not isinstance(design, dict) or design.get("structure_note_cad") is not None:
|
||||
return design
|
||||
if str(design.get("structure_note") or "").strip():
|
||||
return design
|
||||
pipes, things = sources
|
||||
near = [pipe for pipe in pipes if abs(pipe[0] - chainage_m) < PIPE_MATCH_M]
|
||||
spec = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))[1] if near else None
|
||||
lines = structure_note_lines(things, chainage_m)
|
||||
entities = note_entities(chainage_m, spec, design, station, lines)
|
||||
if not entities:
|
||||
return design
|
||||
return {**design, "structure_note_cad": {"entities": entities}}
|
||||
@@ -34,8 +34,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
polyline_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
cross_note_height,
|
||||
cross_note_lines,
|
||||
cross_note_block_height,
|
||||
cross_table_height,
|
||||
cross_table_width,
|
||||
)
|
||||
@@ -106,7 +105,7 @@ def section_block_size(
|
||||
top = half_height + _BORDER_PAD_MM
|
||||
below = half_height + _BORDER_PAD_MM
|
||||
if with_table:
|
||||
note_h = cross_note_height(cross_note_lines(design_spec), table_scale)
|
||||
note_h = cross_note_block_height(design_spec, table_scale)
|
||||
below += 8.0 + note_h + cross_table_height(design_spec, table_scale)
|
||||
return (width, top + below)
|
||||
|
||||
@@ -241,7 +240,9 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
layer_id = f"{STATION_LAYER_PREFIX}{section['chainage']}"
|
||||
title = section.get("title") or f"{section['chainage']}"
|
||||
station_layers.append(_layer(layer_id, f"횡단 {title}"))
|
||||
design_ids = [e["id"] for e in placed["entities"] if e["layerId"] == DESIGN_LAYER_ID]
|
||||
# 측구 구간 구조물 선(b08-structure)도 갈아 끼울 대상 — 화면 구조물 덧그림이 같은 자리를 그림(268-1)
|
||||
replaceable = (DESIGN_LAYER_ID, STRUCTURE_LAYER_ID)
|
||||
design_ids = [e["id"] for e in placed["entities"] if e["layerId"] in replaceable]
|
||||
for entity in placed["entities"]:
|
||||
_move_to_layer(entity, layer_id)
|
||||
for placement in placed.get("cross_placements") or []:
|
||||
|
||||
@@ -401,6 +401,96 @@ def _cross_note_entities(
|
||||
]
|
||||
|
||||
|
||||
# 표 위 CAD 칸(260-3) — B06 [상세] 빈 CAD(`design.structure_note_cad`) 엔티티를 표 왼쪽 위에 맞춤.
|
||||
# 칸이 있으면(엔티티가 비었어도) 옛 글 대신 이것 · 없으면 옛 글 줄 그대로.
|
||||
|
||||
|
||||
def cross_note_cad(design: dict[str, Any] | None) -> list[dict[str, Any]] | None:
|
||||
"""설계의 표 위 CAD 엔티티 — 칸이 없으면 None(옛 글 줄로)."""
|
||||
cad = design.get("structure_note_cad") if isinstance(design, dict) else None
|
||||
if not isinstance(cad, dict):
|
||||
return None
|
||||
entities = cad.get("entities")
|
||||
return [e for e in entities if isinstance(e, dict)] if isinstance(entities, list) else []
|
||||
|
||||
|
||||
def _text_width(label: str, font: float) -> float:
|
||||
"""글 폭 어림(mm) — 한글 · 한자 = 글자 크기 · 그 밖 = 0.6 배."""
|
||||
return sum(font if ord(char) >= 0x2E80 else 0.6 * font for char in label)
|
||||
|
||||
|
||||
def note_cad_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None:
|
||||
"""CAD 칸 범위 (min_x, min_y, max_x, max_y) — 글은 기준점이 세로 가운데(CAD `textBaseline`)라
|
||||
글 높이 · 폭을 맞춤 쪽으로 더함 · 그 밖은 `entities_bbox`."""
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox
|
||||
|
||||
boxes = []
|
||||
for entity in entities:
|
||||
shape = entity.get("shapeData") or {}
|
||||
base = shape.get("basePoint")
|
||||
if entity.get("type") == "Text" and isinstance(base, dict):
|
||||
options = shape.get("options") or {}
|
||||
font = float(options.get("fontSize") or 0.0)
|
||||
width = _text_width(str(shape.get("label") or ""), font)
|
||||
x, y = float(base["x"]), float(base["y"])
|
||||
left = {"center": x - width / 2.0, "right": x - width}.get(options.get("textAlign"), x)
|
||||
boxes.append((left, y - font / 2.0, left + width, y + font / 2.0))
|
||||
elif (box := entities_bbox([entity])) is not None:
|
||||
boxes.append(box)
|
||||
if not boxes:
|
||||
return None
|
||||
return (
|
||||
min(b[0] for b in boxes),
|
||||
min(b[1] for b in boxes),
|
||||
max(b[2] for b in boxes),
|
||||
max(b[3] for b in boxes),
|
||||
)
|
||||
|
||||
|
||||
def _on_layer(entity: dict[str, Any], layer_id: str) -> dict[str, Any]:
|
||||
entity["layerId"] = layer_id
|
||||
for child in entity.get("children") or []:
|
||||
_on_layer(child, layer_id)
|
||||
return entity
|
||||
|
||||
|
||||
def cross_note_block_height(design: dict[str, Any] | None, scale: float = 1.0) -> float:
|
||||
"""표 위 칸 높이(mm) — CAD 칸 = 범위 높이 × 배율 · 없으면 옛 글 줄 높이."""
|
||||
cad = cross_note_cad(design)
|
||||
if cad is None:
|
||||
return cross_note_height(cross_note_lines(design), scale)
|
||||
box = note_cad_bbox(cad)
|
||||
return (box[3] - box[1]) * scale if box else 0.0
|
||||
|
||||
|
||||
def cross_note_block_entities(
|
||||
drawing_id: str,
|
||||
design: dict[str, Any] | None,
|
||||
top: float,
|
||||
center_x: float = 0.0,
|
||||
scale: float = 1.0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""표 위 칸 엔티티 — CAD 칸 = 범위 왼쪽 위를 표 왼쪽 끝 · `top` 에 맞춰 옮김(× 배율 · 표 층 ·
|
||||
id `{도면}:notecad:{n}`) · 없으면 옛 글 줄(`_cross_note_entities`)."""
|
||||
cad = cross_note_cad(design)
|
||||
if cad is None:
|
||||
return _cross_note_entities(drawing_id, cross_note_lines(design), top, center_x, scale)
|
||||
box = note_cad_bbox(cad)
|
||||
if box is None:
|
||||
return []
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import _transform_entity
|
||||
|
||||
left = center_x - _CROSS_TABLE_WIDTH * scale / 2.0
|
||||
dx, dy = left - box[0] * scale, top - box[3] * scale
|
||||
return [
|
||||
_on_layer(
|
||||
_transform_entity(entity, f"{drawing_id}:notecad:{index}", scale, dx, dy),
|
||||
CROSS_TABLE_LAYER_ID,
|
||||
)
|
||||
for index, entity in enumerate(cad)
|
||||
]
|
||||
|
||||
|
||||
def cross_table_width(scale: float = 1.0) -> float:
|
||||
"""수량 산출표 가로 폭(mm)."""
|
||||
return _CROSS_TABLE_WIDTH * scale
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_Note_Options_Node.ts
|
||||
* B07 횡단도 표 위 CAD 칸 미리 넣음의 **좌측 패널 옵션 줄**을 서버가 내는 Node 진입점(166-1 ·
|
||||
* CLAUDE.md 5장 「계산 자리」 ②).
|
||||
*
|
||||
* 화면(B06 [상세] 모달 `loadNoteThings`)과 같은 함수(`structureOptionLines` · 정의 덧칸 `withDefinition`)를
|
||||
* 그대로 돌린다 — 파이썬 짝(`B07_DesignDetail_Engine_Cad_CulvertNote`)은 받은 줄을 구조물마다 조합 줄 뒤에 붙인다.
|
||||
*
|
||||
* 실행: node <번들> <입력.json> <출력.json>
|
||||
* 입력 { things: [{ type_id, options }], types: GET /projects/structure-types 의 types,
|
||||
* definition: 구조물 정의(GET /m02/structure-definition 과 같은 꼴) }
|
||||
* 출력 { lines: string[][] } — things 차례대로
|
||||
* 끝 코드: 0 성공 / 2 인자 오류
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import type { StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon";
|
||||
import {
|
||||
withDefinition,
|
||||
type StructureDefinition,
|
||||
} from "../B05_Profile/B05_Profile_UI_Structures_Define";
|
||||
|
||||
interface Input {
|
||||
things?: Array<{ type_id: string; options?: Record<string, string | number> }>;
|
||||
types?: StructureType[];
|
||||
definition?: StructureDefinition | null;
|
||||
}
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용: node <번들> <입력.json> <출력.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as Input;
|
||||
const types = new Map(
|
||||
withDefinition(input.types ?? [], input.definition ?? null).map((type) => [type.type_id, type]),
|
||||
);
|
||||
const lines = (input.things ?? []).map((thing) =>
|
||||
structureOptionLines(
|
||||
{ type_id: thing.type_id, options: thing.options ?? {} },
|
||||
types.get(thing.type_id),
|
||||
),
|
||||
);
|
||||
writeFileSync(outputPath, JSON.stringify({ lines }));
|
||||
@@ -25,6 +25,15 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
|
||||
Pipe,
|
||||
Thing,
|
||||
load_note_sources,
|
||||
with_structure_note,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
|
||||
station_no_label as station_no_label_upper,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
@@ -383,17 +392,25 @@ def _cross_section_input(
|
||||
chainage: int | float,
|
||||
design: dict[str, Any] | None,
|
||||
overlay: tuple[float, float, float, float] | None = None,
|
||||
pipes: tuple[list[Pipe], list[Thing]] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
|
||||
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다.
|
||||
|
||||
`pipes` = 정본 재료(`load_note_sources` · 배수관 · 구조물) — CAD 칸 없는 측점에
|
||||
미리 넣음(260-4 · 265-2)."""
|
||||
path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage)
|
||||
if not path.is_file():
|
||||
return None
|
||||
source = _read_json(path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
at = float(source.get("chainage_m", chainage))
|
||||
return {
|
||||
"chainage": chainage,
|
||||
"source": source,
|
||||
"design": design,
|
||||
# 표 · 계획선은 저장 설계 그대로 · 그림만 미리 넣음 칸을 얹은 사본(260-4)
|
||||
"design": with_structure_note(
|
||||
design, at, pipes or ([], []), station_no_label_upper(at, interval)
|
||||
),
|
||||
"design_line": _cross_design_line(longitudinal_path, source, design),
|
||||
"quantity_table": _quantity_table(source, design),
|
||||
"title": station_no_label(float(source.get("chainage_m", chainage)), interval),
|
||||
@@ -416,6 +433,7 @@ def _read_cross_sheet(
|
||||
if sheet is None:
|
||||
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
pipes = load_note_sources(project_root)
|
||||
sections = [
|
||||
section
|
||||
for section in (
|
||||
@@ -425,6 +443,7 @@ def _read_cross_sheet(
|
||||
chainage,
|
||||
designs.get(chainage),
|
||||
overlays.get(chainage),
|
||||
pipes,
|
||||
)
|
||||
for chainage in sheet["chainages"]
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
|
||||
@@ -79,7 +79,7 @@ export function buildDrawingSidePanel(
|
||||
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b07-drawing-button";
|
||||
button.className = "b07-drawing-button ui-panel-btn";
|
||||
button.dataset.drawingId = drawing.id;
|
||||
button.dataset.confirmed = String(drawing.confirmed);
|
||||
const name = document.createElement("span");
|
||||
@@ -114,7 +114,7 @@ export function buildDrawingSidePanel(
|
||||
if (!drawing) {
|
||||
const placeholder = document.createElement("button");
|
||||
placeholder.type = "button";
|
||||
placeholder.className = "b07-drawing-button";
|
||||
placeholder.className = "b07-drawing-button ui-panel-btn";
|
||||
placeholder.disabled = true;
|
||||
placeholder.title = "준비 중";
|
||||
placeholder.dataset.pending = "true";
|
||||
@@ -132,14 +132,10 @@ export function buildDrawingSidePanel(
|
||||
panel.append(button);
|
||||
continue;
|
||||
}
|
||||
const section = document.createElement("section");
|
||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시).
|
||||
section.className = "b07-drawing-group ui-collapsible ui-sidebar-section";
|
||||
// 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식).
|
||||
const section = createPanelCard(`${group.label} ${items.length}`);
|
||||
section.classList.add("b07-drawing-group");
|
||||
if (group.kind) section.dataset.kind = group.kind;
|
||||
const sectionTitle = document.createElement("h3");
|
||||
sectionTitle.className = "ui-collapsible__title";
|
||||
sectionTitle.textContent = `${group.label} ${items.length}`;
|
||||
section.append(sectionTitle);
|
||||
if (cross) {
|
||||
// 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만.
|
||||
for (const standard of drawings.filter((item) => item.kind === "cross_standard"))
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { createSelectField, showToast } from "@ui/ui_template_elements";
|
||||
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
import {
|
||||
@@ -257,9 +258,10 @@ export function buildPlanSection(
|
||||
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
||||
onManage?: () => void,
|
||||
): HTMLElement {
|
||||
const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section");
|
||||
// 컨테이너 = 좌측 패널 공용 카드(269)
|
||||
const section = createPanelCard(`평면도 ${items.length}`);
|
||||
section.classList.add("b07-drawing-group", "b07-plan");
|
||||
section.dataset.kind = "plan";
|
||||
section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`));
|
||||
|
||||
for (const { kindId, name, sheets } of groupPlanSheets(items)) {
|
||||
const block = el("div", "b07-plan-kind");
|
||||
@@ -273,7 +275,7 @@ export function buildPlanSection(
|
||||
|
||||
if (onManage) {
|
||||
// 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1).
|
||||
const manage = el("button", "b07-plan-tools__button b07-plan-manage-open", "평면도 관리");
|
||||
const manage = el("button", "ui-panel-btn b07-plan-manage-open", "평면도 관리");
|
||||
manage.type = "button";
|
||||
manage.addEventListener("click", onManage);
|
||||
section.append(manage);
|
||||
|
||||
@@ -40,15 +40,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b07-drawing-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
/* 테두리 색·두께는 공통 ui-sidebar-section이 준다 — 여기선 형태(라운드·안쪽 여백)만. */
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 횡단도는 2열 배치 — 단, **칸이 좁아지면 1열로 접는다**(2026-09-08).
|
||||
좌측 패널이 320 고정이던 동안은 2열이 늘 128.8px 라 문제가 없었으나, 공용
|
||||
오버레이가 좁은 폭에서 패널을 280·227·182 로 물리면서 칸이 108.8 → 82.4 → 60px
|
||||
@@ -62,13 +53,6 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b07-drawing-group h3 {
|
||||
margin: var(--spacing-8) 0 var(--spacing-4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 아직 내용이 없는 도면 — 눌리기는 하고 도각만 나온다(2026-09-01). 옅게 두되
|
||||
못 누르는 것처럼 보이지 않게 커서는 그대로 둔다. */
|
||||
.b07-drawing-button[data-pending="true"] {
|
||||
@@ -94,22 +78,13 @@
|
||||
/* 격자 칸의 기본 최소폭은 글자 길이라, 이름이 길면 칸이 패널을 밀어내 단추가
|
||||
오른쪽으로 튀어나갔다(2026-09-04 실측 14px). 0으로 풀면 이름이 `…`로 잘린다. */
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
/* 경계가 뚜렷하도록 배경색과 유사하지만 조금 짙은 톤 + 은은한 테두리 */
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent);
|
||||
/* 확정 여부를 나타내는 좌측 색 띠 (미확정: 투명) */
|
||||
/* 크기 · 테두리 · 바탕 · 글자 = 공용 `.ui-panel-btn`(269) · 여기선 확정 여부 좌측 색 띠(미확정: 투명)만 */
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: var(--radius-buttons);
|
||||
background: color-mix(in srgb, var(--color-surface) 84%, #000);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b07-drawing-button__name {
|
||||
overflow: hidden;
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -227,7 +202,7 @@
|
||||
|
||||
/* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일:
|
||||
배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기].
|
||||
단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비 · 오류 자리는 접음. */
|
||||
단추 · 숫자 칸 크기 = 좌측 패널 공용 양식(`.ui-panel-form` · 269) · 한 줄 안 같은 너비. */
|
||||
.b07-cross-layout-slot {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -260,17 +235,8 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-btn,
|
||||
.b07-cross-layout .ui-field .ui-input {
|
||||
height: auto;
|
||||
padding: var(--btn-bar-padding);
|
||||
font-size: var(--btn-bar-font-size);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-field__label {
|
||||
margin: 0;
|
||||
font-size: var(--btn-bar-font-size);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -279,10 +245,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
|
||||
.b07-drawing-list__bypass {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
@@ -315,10 +277,6 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-plan-manage-open {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* [평면도 관리] 모달(130-1) — 왼쪽 종류 · 자리 · 장 목록 / 오른쪽 미리보기 · 공용 모달에 자기 class 로 큰 창 */
|
||||
.ui-modal.b07-plan-manage {
|
||||
width: min(1320px, 96vw);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""B08 운반 행(PLAN 251-1 · 사용자 159번) — 총괄자재표 → 운반 묶음.
|
||||
|
||||
거리이정표 행 · B09 운반공 줄의 한 원천.
|
||||
묶음 = 총괄 줄 중 수량 있는 것 · 같은 묶음(이형철근 · 레미콘 · 깬돌 · 파형강관 ·
|
||||
커플링밴드)은 한 행 · 골재(모래 · 자갈 · 막자갈 · 혼합석) · 그 밖 줄은 따로 ·
|
||||
채집석 공제 뺌 · 장비 두 행(중기 · 기계) 늘.
|
||||
옛 B09 운반 6줄 키(`haul:1~6` · 중기 · 기계 · 모래 · 자갈 · 시멘트 · 철근)는 그 묶음에 그대로 —
|
||||
사용자 설계내역서 덮개가 고아가 되지 않게 · 그 여섯은 수량이 없어도 늘 있음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Material_Calc import DEDUCT_KEY
|
||||
|
||||
#: 장비 — (묶음 키, 이름, 단위, 옛 번호)
|
||||
EQUIP: list[tuple[str, str, str, int]] = [
|
||||
("equip_heavy", "중기", "대", 1),
|
||||
("equip_machine", "기계", "대", 2),
|
||||
]
|
||||
#: 옛 B09 운반 줄 번호 — 묶음 키 → n(`haul:n`)
|
||||
LEGACY = {
|
||||
"equip_heavy": 1,
|
||||
"equip_machine": 2,
|
||||
"sand": 3,
|
||||
"gravel": 4,
|
||||
"cement": 5,
|
||||
"rebar": 6,
|
||||
}
|
||||
#: 총괄 줄 키 머리 → (묶음 키, 이름) — 여러 규격 = 한 행
|
||||
PREFIX = [
|
||||
("rebar_", "rebar", "철근"),
|
||||
("remicon_", "remicon", "레미콘"),
|
||||
("stone_buy_", "stone", "깬돌"),
|
||||
("pipe_", "pipe", "파형강관"),
|
||||
("coupling_", "coupling", "커플링밴드"),
|
||||
]
|
||||
|
||||
|
||||
#: 총괄 고정 줄 이름 · 단위(줄에 이름이 없어도 같은 이름 · 옛 여섯 늘 행)
|
||||
NAMES = {
|
||||
"sand": ("모래", "㎥"),
|
||||
"gravel": ("자갈", "㎥"),
|
||||
"rubble": ("막자갈", "㎥"),
|
||||
"mixed_aggregate": ("혼합석", "㎥"),
|
||||
"cement": ("시멘트", "대"),
|
||||
"rebar": ("철근", "ton"),
|
||||
"wire_mesh": ("와이어메쉬", "㎡"),
|
||||
"sod": ("떼", "㎡"),
|
||||
"revet_block": ("호안블럭", "㎡"),
|
||||
"flume_400c": ("수로관", "본"),
|
||||
"steel_grating": ("스틸그레이팅", "EA"),
|
||||
}
|
||||
|
||||
|
||||
def _group(row: dict[str, Any]) -> tuple[str, str]:
|
||||
key = str(row.get("key") or "")
|
||||
for head, gkey, name in PREFIX:
|
||||
if key.startswith(head):
|
||||
return gkey, name
|
||||
if key in NAMES:
|
||||
return key, NAMES[key][0]
|
||||
return key, str(row.get("group") or row.get("spec") or key) # 맞춤 없는 사본 자재 = 자재 이름
|
||||
|
||||
|
||||
def bill_key(gkey: str) -> str:
|
||||
"""B09 운반 줄 키 — 옛 여섯은 `haul:n` 그대로 · 새 묶음 = `haul:<묶음 키>`."""
|
||||
return f"haul:{LEGACY[gkey]}" if gkey in LEGACY else f"haul:{gkey}"
|
||||
|
||||
|
||||
def haul_groups(materials: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""총괄자재표(`material.rows`) → 운반 행 [{key, name, unit, qty}].
|
||||
|
||||
장비 둘 + 묶음(차례 = 총괄 줄 차례)."""
|
||||
rows = ((materials or {}).get("material") or {}).get("rows") or []
|
||||
out: dict[str, dict[str, Any]] = {
|
||||
g: {"key": g, "name": n, "unit": u, "qty": None} for g, n, u, _ in EQUIP
|
||||
}
|
||||
for row in rows:
|
||||
if row.get("key") == DEDUCT_KEY: # 채집석 공제(뺄 수량)
|
||||
continue
|
||||
gkey, name = _group(row)
|
||||
amount = row.get("amount")
|
||||
has = isinstance(amount, (int, float)) and amount > 0
|
||||
slot = out.get(gkey)
|
||||
if slot is None:
|
||||
if not has and gkey not in LEGACY:
|
||||
continue # 수량 없는 새 묶음 = 행 없음
|
||||
slot = out[gkey] = {
|
||||
"key": gkey,
|
||||
"name": name,
|
||||
"unit": row.get("unit") or "",
|
||||
"qty": None,
|
||||
}
|
||||
if has and (slot["unit"] in ("", row.get("unit"))):
|
||||
slot["unit"] = row.get("unit") or slot["unit"]
|
||||
slot["qty"] = round((slot["qty"] or 0) + float(amount), 6)
|
||||
for gkey in LEGACY: # 옛 여섯 = 늘
|
||||
if gkey not in out:
|
||||
name, unit = NAMES[gkey]
|
||||
out[gkey] = {"key": gkey, "name": name, "unit": unit, "qty": None}
|
||||
return list(out.values())
|
||||
|
||||
|
||||
def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""거리이정표 자료에 운반 행 상자 자동(251-2) — 묶음마다 거래처 상자가 없으면 넣음.
|
||||
|
||||
거래처 · 주소 빈칸 · 선 없음(linked false) · 항목 = 자재 이름 ·
|
||||
저장은 [저장] 때(읽기만 합침)."""
|
||||
have = {r.get("haul") for r in data.get("routes") or []}
|
||||
routes = list(data.get("routes") or [])
|
||||
for g in groups:
|
||||
if g["key"] in have:
|
||||
continue
|
||||
routes.append(
|
||||
{
|
||||
"id": f"h-{g['key']}"[:40],
|
||||
"name": "",
|
||||
"address": "",
|
||||
"to": "site",
|
||||
"linked": False,
|
||||
"dir": None,
|
||||
"haul": g["key"],
|
||||
"legs": [{"road": None, "km": None}],
|
||||
"rows": [{"item": g["name"], "dest": "start", "note": ""}],
|
||||
}
|
||||
)
|
||||
return {**data, "routes": routes}
|
||||
|
||||
|
||||
def route_bill_key(route: dict[str, Any], index: int = 0) -> str:
|
||||
"""거리이정표 행 → B09 운반공 줄 키(253-1) — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}` ·
|
||||
한 상자 둘째 행부터 `.{n}`(261-4 자동 상자도 · 경로 id · 묶음 키에 「.」 없음 — 안 겹침)."""
|
||||
tail = f".{index}" if index else ""
|
||||
if route.get("haul"):
|
||||
return bill_key(str(route["haul"])) + tail
|
||||
return f"haul:r-{route.get('id')}" + tail
|
||||
|
||||
|
||||
def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""거리이정표 표 행 하나 = 운반공 줄 하나(253-1 · 261-4 단가연결 무관) —
|
||||
[{key, name, ref, user, haul, L1, L2}] · name = 행 항목명 그대로(없으면 거래처 · 그것도 없으면 빈칸) ·
|
||||
L1 = 포장 계(2차 + 1차 + 포장) · L2 = 비포장(`compute_milestone` 결과) ·
|
||||
user = 사용자 상자 · haul = 자동 상자 묶음 키(첫 행은 총괄자재 묶음 줄 이름 · 거리)."""
|
||||
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)
|
||||
out.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": str(row.get("item") or "").strip() or str(route.get("name") or "").strip(),
|
||||
"ref": route.get("ref"),
|
||||
"user": not route.get("haul"),
|
||||
"haul": route.get("haul"),
|
||||
"L1": row.get("paved"),
|
||||
"L2": row.get("unpaved"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,102 @@
|
||||
"""B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력.
|
||||
|
||||
⚠ 짝 = `B08_Quantity_Haul_Cycle.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_253_2_haul_cycle.py`.
|
||||
조작 중은 TS 짝(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 이 파일로 다시 채움(정본).
|
||||
|
||||
식(거창 25 설계서 단가산출 1308~1347행 꼴 · 끝수 = 소수 둘째 자리 반올림)
|
||||
q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es)
|
||||
t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + t2 + t3 + t4 + t5 + t6
|
||||
L1 = 거리이정표 그 행 포장 계(2차 + 1차 + 포장) · L2 = 비포장 · 속도 0 = 그 항 0.
|
||||
로직 입력 채움(이름으로) — `Cm` · `사이클시간` = Cm · `t2` = t2 · `운반거리` = L1 + L2 ·
|
||||
`own` = 사용자가 덮은 칸(L1 · L2 · t1 · 로직 입력 이름) — 그 값이 이김.
|
||||
기본값 = 거창 설계서(혼합골재 덤프 15톤) · 속도 · t 표 = master_data CC000054~058(화면이 고름).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
DEFAULTS: dict[str, float] = {
|
||||
"L1": 0.0,
|
||||
"L2": 0.0,
|
||||
"V1": 30.0,
|
||||
"V2": 35.0,
|
||||
"V3": 10.0,
|
||||
"V4": 15.0,
|
||||
"T": 15.0,
|
||||
"rt": 1.7,
|
||||
"f": 1.0,
|
||||
"qs": 1.72,
|
||||
"k": 1.0,
|
||||
"Es": 0.6,
|
||||
"Cms": 37.4,
|
||||
"t3": 0.8,
|
||||
"t4": 0.42,
|
||||
"t5": 0.5,
|
||||
"t6": 1.5,
|
||||
}
|
||||
#: 로직 입력 이름 → 셈 결과 칸
|
||||
FILL = {"Cm": "cm", "사이클시간": "cm", "t2": "t2", "운반거리": "L"}
|
||||
|
||||
|
||||
def r2(value: float) -> float:
|
||||
"""소수 둘째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음)."""
|
||||
return math.floor(value * 100 + 0.5) / 100
|
||||
|
||||
|
||||
def _div(a: float, b: float) -> float:
|
||||
return a / b if b else 0.0
|
||||
|
||||
|
||||
def _num(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return float(value) if math.isfinite(value) else None
|
||||
|
||||
|
||||
def cycle_time(inputs: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""사이클 입력(빈 칸 = 기본값 · `t1` 있으면 그 값) → {q, n, t1, t2, cm, L}."""
|
||||
given = {k: _num(v) for k, v in (inputs or {}).items()}
|
||||
v = {k: given[k] if given.get(k) is not None else d for k, d in DEFAULTS.items()}
|
||||
q = r2(_div(v["T"], v["rt"]) * v["f"])
|
||||
n = r2(_div(q, v["qs"] * v["k"]))
|
||||
t1 = given.get("t1")
|
||||
t1 = r2(_div(v["Cms"] * n, 60 * v["Es"])) if t1 is None else t1
|
||||
l1, l2 = v["L1"], v["L2"]
|
||||
t2 = r2((_div(l1, v["V1"]) + _div(l1, v["V2"]) + _div(l2, v["V3"]) + _div(l2, v["V4"])) * 60)
|
||||
cm = r2(t1 + t2 + v["t3"] + v["t4"] + v["t5"] + v["t6"])
|
||||
return {
|
||||
"q": q,
|
||||
"n": n,
|
||||
"t1": t1,
|
||||
"t2": t2,
|
||||
"cm": cm,
|
||||
"L": math.floor((l1 + l2) * 1000 + 0.5) / 1000,
|
||||
}
|
||||
|
||||
|
||||
def cycle_inputs(ref: dict[str, Any] | None, dist: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""연결 `ref.cycle` + 거리이정표 거리(`dist{L1, L2}`) — 덮은 L1 · L2(`ref.own`)는 그대로."""
|
||||
cycle = dict((ref or {}).get("cycle") or {})
|
||||
own = set((ref or {}).get("own") or [])
|
||||
for name in ("L1", "L2"):
|
||||
if name not in own and _num((dist or {}).get(name)) is not None:
|
||||
cycle[name] = dist[name]
|
||||
if "t1" not in own:
|
||||
cycle.pop("t1", None)
|
||||
return cycle
|
||||
|
||||
|
||||
def fill_given(
|
||||
ref: dict[str, Any], names: list[str], dist: dict[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
"""로직 입력값 다시 채움 — `names` = 그 로직 입력 이름 · 덮은 칸(`ref.own`)은 그대로."""
|
||||
own = set(ref.get("own") or [])
|
||||
got = cycle_time(cycle_inputs(ref, dist))
|
||||
given = dict(ref.get("given") or {})
|
||||
for name, slot in FILL.items():
|
||||
if name in names and name not in own:
|
||||
given[name] = got[slot]
|
||||
return given
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Haul_Cycle.ts
|
||||
* B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력.
|
||||
*
|
||||
* ⚠ 짝 = `B08_Quantity_Haul_Cycle.py`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_253_2_haul_cycle.py`.
|
||||
* 조작 중은 이 파일(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 짝으로 다시 채움(정본).
|
||||
*
|
||||
* 식 = q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) ·
|
||||
* t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + … + t6 · 끝수 = 소수 둘째 자리 ·
|
||||
* 속도 0 = 그 항 0 · 로직 입력 채움 = Cm · 사이클시간 = Cm · t2 · 운반거리 = L1 + L2 ·
|
||||
* `own` = 사용자가 덮은 칸(그 값이 이김).
|
||||
* ========================================================================== */
|
||||
|
||||
export const CYCLE_DEFAULTS: Record<string, number> = {
|
||||
L1: 0,
|
||||
L2: 0,
|
||||
V1: 30,
|
||||
V2: 35,
|
||||
V3: 10,
|
||||
V4: 15,
|
||||
T: 15,
|
||||
rt: 1.7,
|
||||
f: 1,
|
||||
qs: 1.72,
|
||||
k: 1,
|
||||
Es: 0.6,
|
||||
Cms: 37.4,
|
||||
t3: 0.8,
|
||||
t4: 0.42,
|
||||
t5: 0.5,
|
||||
t6: 1.5,
|
||||
};
|
||||
/** 로직 입력 이름 → 셈 결과 칸 */
|
||||
export const CYCLE_FILL: Record<string, "cm" | "t2" | "L"> = {
|
||||
Cm: "cm",
|
||||
사이클시간: "cm",
|
||||
t2: "t2",
|
||||
운반거리: "L",
|
||||
};
|
||||
|
||||
export interface CycleResult {
|
||||
q: number;
|
||||
n: number;
|
||||
t1: number;
|
||||
t2: number;
|
||||
cm: number;
|
||||
L: number;
|
||||
}
|
||||
export interface CycleRef {
|
||||
given?: Record<string, unknown> | null;
|
||||
cycle?: Record<string, unknown> | null;
|
||||
own?: string[] | null;
|
||||
}
|
||||
export type Dist = { L1?: number | null; L2?: number | null } | null | undefined;
|
||||
|
||||
/** 소수 둘째 자리 반올림(0.5 올림 · 파이썬 짝 `r2`) */
|
||||
export const r2 = (value: number): number => Math.floor(value * 100 + 0.5) / 100;
|
||||
const div = (a: number, b: number): number => (b ? a / b : 0);
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
/** 사이클 입력(빈 칸 = 기본값 · t1 있으면 그 값) → {q, n, t1, t2, cm, L} */
|
||||
export function cycleTime(inputs: Record<string, unknown> | null | undefined): CycleResult {
|
||||
const v: Record<string, number> = {};
|
||||
for (const [k, d] of Object.entries(CYCLE_DEFAULTS)) v[k] = num(inputs?.[k]) ?? d;
|
||||
const q = r2(div(v.T, v.rt) * v.f);
|
||||
const n = r2(div(q, v.qs * v.k));
|
||||
const t1 = num(inputs?.t1) ?? r2(div(v.Cms * n, 60 * v.Es));
|
||||
const t2 = r2((div(v.L1, v.V1) + div(v.L1, v.V2) + div(v.L2, v.V3) + div(v.L2, v.V4)) * 60);
|
||||
const cm = r2(t1 + t2 + v.t3 + v.t4 + v.t5 + v.t6);
|
||||
return { q, n, t1, t2, cm, L: Math.floor((v.L1 + v.L2) * 1000 + 0.5) / 1000 };
|
||||
}
|
||||
|
||||
/** 연결 `ref.cycle` + 거리이정표 거리 — 덮은 L1 · L2(`ref.own`)는 그대로 */
|
||||
export function cycleInputs(ref: CycleRef | null | undefined, dist: Dist): Record<string, unknown> {
|
||||
const cycle: Record<string, unknown> = { ...(ref?.cycle || {}) };
|
||||
const own = new Set(ref?.own || []);
|
||||
for (const name of ["L1", "L2"] as const)
|
||||
if (!own.has(name) && num(dist?.[name]) !== null) cycle[name] = dist?.[name];
|
||||
if (!own.has("t1")) delete cycle.t1;
|
||||
return cycle;
|
||||
}
|
||||
|
||||
/** 로직 입력값 다시 채움 — names = 그 로직 입력 이름 · 덮은 칸은 그대로 */
|
||||
export function fillGiven(ref: CycleRef, names: string[], dist: Dist): Record<string, unknown> {
|
||||
const own = new Set(ref.own || []);
|
||||
const got = cycleTime(cycleInputs(ref, dist));
|
||||
const given: Record<string, unknown> = { ...(ref.given || {}) };
|
||||
for (const [name, slot] of Object.entries(CYCLE_FILL))
|
||||
if (names.includes(name) && !own.has(name)) given[name] = got[slot];
|
||||
return given;
|
||||
}
|
||||
@@ -4,15 +4,16 @@
|
||||
거울 시험 `resources/tester/test_244_1_b08_milestone.py`.
|
||||
조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈.
|
||||
|
||||
표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면
|
||||
표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 ·
|
||||
`base_chain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면
|
||||
시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 ·
|
||||
포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) ·
|
||||
끝수 = 소수 셋째 자리(반올림).
|
||||
구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고
|
||||
pick = 참(화면 「도로 구분 고르기」).
|
||||
도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간 = 2차포장 · 1차포장 · 비포장 ·
|
||||
그 밖 = 포장 · 비포장 ·
|
||||
포장 = 2차 + 1차 + 포장.
|
||||
구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7).
|
||||
도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`road_of`) — 시점에 닿는 기준점 구간 = 1차포장 ·
|
||||
그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는
|
||||
구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리).
|
||||
옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrate_milestone` 로 새 꼴(`bases[]` · `to`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +23,15 @@ from typing import Any
|
||||
|
||||
ROADS = ("2차포장", "1차포장", "포장", "비포장")
|
||||
KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"}
|
||||
# 구간 자리 → 포장 종류(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:
|
||||
"""그 구간 도로 구분 — 비포장만 사용자 값 · 그 밖(빈 값 · 옛 값) = 자리의 포장 종류."""
|
||||
return "비포장" if road == "비포장" else PAVED[kind]
|
||||
|
||||
|
||||
def r3(value: float) -> float:
|
||||
@@ -35,16 +45,10 @@ def leg_km(leg: dict[str, Any]) -> float | None:
|
||||
return leg.get("auto_km") if km is None else km
|
||||
|
||||
|
||||
def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> bool:
|
||||
"""도로 구분마다 더함 — 안 고른 구간이 있으면 참."""
|
||||
pick = False
|
||||
def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None, kind: str) -> None:
|
||||
"""도로 구분마다 더함 — 구간 자리로 포장 종류(152 · 안 고른 구간 없음)."""
|
||||
for leg in legs or []:
|
||||
key = KEYS.get(leg.get("road"))
|
||||
if key:
|
||||
sums[key] += float(leg_km(leg) or 0)
|
||||
else:
|
||||
pick = True
|
||||
return pick
|
||||
sums[KEYS[road_of(leg.get("road"), kind)]] += float(leg_km(leg) or 0)
|
||||
|
||||
|
||||
def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float:
|
||||
@@ -55,20 +59,66 @@ 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}
|
||||
pick = add_legs(sums, route.get("legs"))
|
||||
if base.get("enabled") and route.get("via_base", True):
|
||||
pick = add_legs(sums, base.get("legs")) or pick
|
||||
# 안 이은 거래처 = 구간 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])
|
||||
add_legs(sums, [mid], "mid")
|
||||
paved = sums["s2"] + sums["s1"] + sums["pv"]
|
||||
out.append(
|
||||
{
|
||||
@@ -85,7 +135,7 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list
|
||||
"unpaved": r3(sums["un"]),
|
||||
"total": r3(paved + sums["un"]),
|
||||
"note": row.get("note") or "",
|
||||
"pick": pick,
|
||||
"pick": False,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -6,20 +6,115 @@
|
||||
* 거울 시험 `resources/tester/test_244_1_b08_milestone.py`.
|
||||
* 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈.
|
||||
*
|
||||
* 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면
|
||||
* 표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 ·
|
||||
* `baseChain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면
|
||||
* 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 ·
|
||||
* 끝수 = 소수 셋째 자리(반올림)
|
||||
* 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고
|
||||
* pick = 참(화면 「도로 구분 고르기」)
|
||||
* 도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 ·
|
||||
* 비포장 · 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장 · 포장 = 2차 + 1차 + 포장
|
||||
* 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7)
|
||||
* 도로 구분(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];
|
||||
/** 구간 도로 구분 고르기 — 기준점 거침 = 2차 · 1차 · 비포장 · 아니면 포장 · 비포장(244-13) */
|
||||
export const roadsFor = (viaBase: boolean): Road[] =>
|
||||
viaBase ? ["2차포장", "1차포장", "비포장"] : ["포장", "비포장"];
|
||||
/** 구간 자리(152 · 254) — 거래처 · 기준점 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) ·
|
||||
* 시점 → 중점(mid) */
|
||||
export type SegKind = "via" | "base" | "direct" | "mid";
|
||||
/** 포장 종류 = 선 자리로 정함(152) — → 기준점 = 2차 · 기준점 → 시점 = 1차 · 거래처 → 시점 = 포장 */
|
||||
export const PAVED: Record<SegKind, Road> = {
|
||||
via: "2차포장",
|
||||
base: "1차포장",
|
||||
direct: "포장",
|
||||
mid: "포장",
|
||||
};
|
||||
/** 그 구간 도로 구분 — 사용자는 비포장만 고름 · 그 밖(빈 값 · 옛 값) = 자리의 포장 종류 */
|
||||
export const roadOf = (road: Road | null | undefined, kind: SegKind): Road =>
|
||||
road === "비포장" ? "비포장" : PAVED[kind];
|
||||
/** 도로 구분을 선 자리 포장 종류로 바꾼 사본(152) — 다이어그램 선 글 · 그림용(저장 값은 그대로) */
|
||||
export function withRoads(data: MilestoneData): MilestoneData {
|
||||
const fix = (legs: Leg[], kind: SegKind): Leg[] =>
|
||||
legs.map((leg) => ({ ...leg, road: roadOf(leg.road, kind) }));
|
||||
return {
|
||||
...data,
|
||||
site: { ...data.site, mid_road: roadOf(data.site.mid_road, "mid") },
|
||||
bases: data.bases.map((b) => ({ ...b, legs: fix(b.legs, baseKind(b)) })),
|
||||
routes: data.routes.map((r) => ({ ...r, legs: fix(r.legs, routeKind(r)) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 이음 대상 = 시점(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";
|
||||
const base = data.bases.find((b) => b.id === from);
|
||||
if (base) return baseKind(base);
|
||||
const route = data.routes.find((r) => r.id === from);
|
||||
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;
|
||||
export type Dir = (typeof DIRS)[number];
|
||||
@@ -45,28 +140,52 @@ export interface MilestoneRoute {
|
||||
/** 업체 이름 = 하위 탭 이름 */
|
||||
name: string;
|
||||
address: string;
|
||||
/** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */
|
||||
via_base: boolean;
|
||||
/** 잇는 곳(254) — "site"(시점으로 바로) · 기준점 id */
|
||||
to: string;
|
||||
dir: Dir | null;
|
||||
lon?: number | null;
|
||||
lat?: number | null;
|
||||
/** 업체 → 기준점(거침) · 시점 구간 */
|
||||
/** 다이어그램에 끌어 놓은 자리(153 · 왼 위 · 그림 좌표) — 없으면 방위 자동 자리 */
|
||||
pos?: [number, number] | null;
|
||||
/** 선 이음(155) — false = 아직 안 이음(요소 추가 뒤 · 연결 점으로 이음 · 구간 km 안 셈) · 없음 = 이음(옛 자료) */
|
||||
linked?: boolean;
|
||||
/** 수기 입력(155) — 카카오 길찾기 안 함 · km 손 입력만 */
|
||||
manual?: boolean;
|
||||
/** 운반 묶음 키(251 · 총괄자재 · B09 운반 줄과 이음) — 없음 = 사용자 [요소 추가] 상자 */
|
||||
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;
|
||||
/** 기준점 → 시점 공통 구간 */
|
||||
legs: Leg[];
|
||||
site: {
|
||||
mid_station_m: number | null;
|
||||
mid_road: Road;
|
||||
/** 길찾기용 시점 덮개(255-2 · 경로 탭 시점 끌기 · 노선은 그대로) — 없음 = 노선 첫 점 */
|
||||
start_pin?: { lon: number; lat: number } | null;
|
||||
};
|
||||
bases: MilestoneBase[];
|
||||
routes: MilestoneRoute[];
|
||||
}
|
||||
|
||||
@@ -97,15 +216,9 @@ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 10
|
||||
/** 구간 km — 사용자 값 우선 · 없으면 자동 값 */
|
||||
export const legKm = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null;
|
||||
|
||||
/** 도로 구분마다 더함 — 안 고른 구간이 있으면 참 */
|
||||
function addLegs(sums: Record<Key, number>, legs: Leg[] | undefined): boolean {
|
||||
let pick = false;
|
||||
for (const leg of legs ?? []) {
|
||||
const key = leg.road ? KEYS[leg.road] : undefined;
|
||||
if (key) sums[key] += Number(legKm(leg) ?? 0);
|
||||
else pick = true;
|
||||
}
|
||||
return pick;
|
||||
/** 도로 구분마다 더함 — 구간 자리로 포장 종류(152 · 안 고른 구간 없음) */
|
||||
function addLegs(sums: Record<Key, number>, legs: Leg[] | undefined, kind: SegKind): void {
|
||||
for (const leg of legs ?? []) sums[KEYS[roadOf(leg.road, kind)]] += Number(legKm(leg) ?? 0);
|
||||
}
|
||||
|
||||
/** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */
|
||||
@@ -116,18 +229,21 @@ 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 };
|
||||
let pick = addLegs(sums, route.legs);
|
||||
if (base?.enabled && route.via_base !== false) pick = addLegs(sums, base.legs) || pick;
|
||||
if (row.dest === "mid") addLegs(sums, [mid]);
|
||||
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({
|
||||
route: route.id,
|
||||
@@ -143,7 +259,7 @@ export function computeMilestone(
|
||||
unpaved: r3(sums.un),
|
||||
total: r3(paved + sums.un),
|
||||
note: row.note ?? "",
|
||||
pick,
|
||||
pick: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -36,7 +37,8 @@ CHILD = __file__ # 자식으로 돌릴 스크립트
|
||||
NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움"
|
||||
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
|
||||
LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색
|
||||
TAIL = " — 그림 올리기를 쓰세요."
|
||||
TAIL = " — [그림 변경]으로 넣으세요."
|
||||
PLACE_VIEW = {"w": 480, "h": 320, "level": 4} # 장소 그림(256-1) — 표지 가운데 · 고정 줌
|
||||
|
||||
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초)
|
||||
|
||||
@@ -45,17 +47,22 @@ class CaptureError(Exception):
|
||||
"""까닭 한 줄(키 · 예외 원문 없음)."""
|
||||
|
||||
|
||||
def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
|
||||
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail."""
|
||||
def page_html(
|
||||
key: str, lines: list[Any], marks: list[dict[str, Any]], view: dict[str, int] | None = None
|
||||
) -> str:
|
||||
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.
|
||||
view = 크기 · 줌 고정(장소 그림 · 첫 표지 가운데 · 경로 맞춤 안 함)."""
|
||||
w, h = (view["w"], view["h"]) if view else (WIDTH, HEIGHT)
|
||||
level = view["level"] if view else 7
|
||||
data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c")
|
||||
sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false"
|
||||
return f"""<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0">
|
||||
<div id="m" style="width:{WIDTH}px;height:{HEIGHT}px"></div>
|
||||
<div id="m" style="width:{w}px;height:{h}px"></div>
|
||||
<script src="{sdk}" onerror="window.__fail=1"></script>
|
||||
<script>window.kakao && kakao.maps.load(() => {{
|
||||
const D = {data}, K = kakao.maps, at = (p) => new K.LatLng(p[0], p[1]);
|
||||
const pts = [...D.marks.map((m) => [m.lat, m.lon]), ...D.lines.flat()].map(at);
|
||||
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: 7 }});
|
||||
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: {level} }});
|
||||
D.lines.forEach((l) => new K.Polyline({{ map, path: l.map(at), strokeWeight: 6,
|
||||
strokeColor: "{LINE_COLOR}", strokeOpacity: 0.9 }}));
|
||||
D.marks.forEach((m) => {{
|
||||
@@ -68,9 +75,11 @@ def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
|
||||
new K.CustomOverlay({{ map, position: p, content: s, yAnchor: 2.6 }});
|
||||
}});
|
||||
K.event.addListener(map, "tilesloaded", () => (window.__done = true));
|
||||
const b = new K.LatLngBounds();
|
||||
pts.forEach((p) => b.extend(p));
|
||||
map.setBounds(b, 60, 60, 60, 60);
|
||||
if ({"false" if view else "true"}) {{
|
||||
const b = new K.LatLngBounds();
|
||||
pts.forEach((p) => b.extend(p));
|
||||
map.setBounds(b, 60, 60, 60, 60);
|
||||
}}
|
||||
}});</script></body></html>"""
|
||||
|
||||
|
||||
@@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""자식 프로세스 몸통 — 그림 찍어 `<out>/<route_id>.png` 저장 → {saved, reason}."""
|
||||
key = os.environ.get(KAKAO_JS, "").strip()
|
||||
try:
|
||||
png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"])
|
||||
png = shoot(page_html(key, job["lines"], job["marks"], job.get("view")), job["origin"])
|
||||
except CaptureError as error:
|
||||
return {"saved": False, "reason": str(error)}
|
||||
out_dir = Path(job["out"])
|
||||
@@ -160,25 +169,36 @@ def spawn(job: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def make_route_image(
|
||||
out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]]
|
||||
out_dir: Path,
|
||||
route_id: str,
|
||||
lines: list[Any],
|
||||
marks: list[dict[str, Any]],
|
||||
view: dict[str, int] | None = None,
|
||||
what: str = "경로 그림",
|
||||
tail: str = TAIL,
|
||||
) -> dict[str, Any]:
|
||||
"""그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림."""
|
||||
key = os.environ.get(KAKAO_JS, "").strip()
|
||||
if not key:
|
||||
return {
|
||||
"saved": False,
|
||||
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}",
|
||||
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){tail}",
|
||||
}
|
||||
if not any(lines) and not marks:
|
||||
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"}
|
||||
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{tail}"}
|
||||
origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/")
|
||||
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks}
|
||||
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks, "view": view}
|
||||
with _lock:
|
||||
got = spawn({**job, "origin": origin})
|
||||
if not got.get("saved"):
|
||||
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"}
|
||||
return {"saved": False, "reason": f"{what} 자동 캡처 실패 — {got.get('reason')}{tail}"}
|
||||
return {"saved": True, "reason": None}
|
||||
|
||||
|
||||
def make_place_image(out_dir: Path, place_id: str, mark: dict[str, Any]) -> dict[str, Any]:
|
||||
"""장소 그림(256-1) — 작은 지도 · 표지 하나 → `<out_dir>/<상자 id>.png`."""
|
||||
return make_route_image(out_dir, place_id, [], [mark], PLACE_VIEW, "장소 그림", "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(capture(json.loads(sys.stdin.buffer.read()))))
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
* 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면
|
||||
* 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 ·
|
||||
* 같은 방위 여럿 = 옆으로 나란히
|
||||
* 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5)
|
||||
* 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
|
||||
* 기준점 자리 = 기준점 주소 좌표 → 잇는 곳(시점 · 다른 기준점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) —
|
||||
* 옛 8방 무시(244-5) · 여럿(254) = 잇는 곳이 먼저 놓이게 사슬 차례
|
||||
* 선 = 거래처 → to(기준점 · 시점) · 기준점 → to · 시점 → 중점 — 글 = 구간 줄마다 두 줄(1줄 도로 구분 ·
|
||||
* 2줄 km · 254 · `legText` 가 줄바꿈 글자로 나눔)
|
||||
* 처음 자리 선 길이(254) = 그 구간 km 비례(`lineLen` · 로그 · 최소 · 최대) — 잇는 곳 테두리 → 상자 테두리
|
||||
* 상자 높이 = 내용(254 · 항목 · 이름 · 주소 줄바꿈 `wrap` 최대 3줄 · 좌표) · 기준점 상자도 주소
|
||||
* 화면 = 북 위(SVG y 아래로 = 남)
|
||||
* 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌)
|
||||
* [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」
|
||||
@@ -18,7 +22,7 @@
|
||||
* 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc";
|
||||
import type { Dir, Leg, MilestoneBase, MilestoneData } from "./B08_Quantity_Milestone_Calc";
|
||||
|
||||
export interface DiagramBox {
|
||||
id: string;
|
||||
@@ -34,6 +38,8 @@ export interface DiagramBox {
|
||||
notes?: string[];
|
||||
/** 호버 = 줄이지 않은 주소 */
|
||||
tip?: string;
|
||||
/** 장소 그림 자리(254-3 · 글 아래 · 그림 없으면 없음) */
|
||||
pic?: Rect;
|
||||
}
|
||||
|
||||
export interface DiagramLine {
|
||||
@@ -77,11 +83,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 +97,19 @@ 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;
|
||||
/** 장소 그림(254-3) — 상자 안쪽 폭 · 서버 그림 480 × 320 과 같은 3:2 */
|
||||
export const PIC = { w: 132, h: 88 };
|
||||
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 +137,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 +206,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 +225,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 +252,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 +262,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) return null;
|
||||
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 +332,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 +360,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 +372,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,8 +425,10 @@ export function layoutMilestone(
|
||||
mid_address?: string | null;
|
||||
} | null,
|
||||
midKm: number,
|
||||
pics: ReadonlySet<string> = new Set(), // 254-3 장소 그림 있는 상자 id
|
||||
): DiagramLayout {
|
||||
const boxes: DiagramBox[] = [];
|
||||
const picH = (id: string): number => (pics.has(id) ? PIC.h + INSET : 0);
|
||||
const pending: Array<[string, string, Leg[]]> = [];
|
||||
const byId = new Map<string, DiagramBox>();
|
||||
const center = (id: string): [number, number] => {
|
||||
@@ -362,11 +436,19 @@ export function layoutMilestone(
|
||||
return [b.x + b.w / 2, b.y + b.h / 2];
|
||||
};
|
||||
/** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */
|
||||
// 153 사용자가 끌어 놓은 자리(왼 위 · 그림 좌표) — 있으면 방위 자리 · 밀어 냄 대신 그 자리
|
||||
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);
|
||||
for (let i = 0; out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) {
|
||||
const pos = posOf(box.id);
|
||||
if (pos) [box.x, box.y] = pos;
|
||||
// 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;
|
||||
}
|
||||
@@ -406,31 +488,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) + picH(b.id);
|
||||
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<{
|
||||
@@ -441,31 +560,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 || "거래처",
|
||||
@@ -474,16 +586,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) + picH(r.id),
|
||||
});
|
||||
}
|
||||
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(
|
||||
@@ -491,20 +608,23 @@ 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],
|
||||
);
|
||||
line(g.id, g.anchor === "base" ? "base" : "start", g.legs);
|
||||
if (g.linked) line(g.id, g.anchor === "site" ? "start" : g.anchor, g.legs);
|
||||
});
|
||||
}
|
||||
|
||||
// 254-3 장소 그림 = 글 아래 · 안쪽 여백 안(그림 없으면 자리 없음)
|
||||
for (const b of boxes)
|
||||
if (pics.has(b.id)) b.pic = { x: b.x + INSET, y: b.y + b.h - INSET - PIC.h, ...PIC };
|
||||
const lines = pending.map(([from, to, legs]) =>
|
||||
edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!),
|
||||
);
|
||||
@@ -523,18 +643,10 @@ export function layoutMilestone(
|
||||
};
|
||||
}
|
||||
|
||||
/** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/link/by/car/이름,위도,경도/…` · 경유 ≤ 5) */
|
||||
export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: number }>): string {
|
||||
const part = (p: { name: string; lon: number; lat: number }): string =>
|
||||
`${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`;
|
||||
return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`;
|
||||
}
|
||||
|
||||
/** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */
|
||||
/** 선 글 한 줄 → 그 구간 — 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 +662,7 @@ export function setLeg(
|
||||
data.site.mid_station_m = patch.km == null ? null : Math.round(patch.km * 1000 * 1000) / 1000;
|
||||
return data;
|
||||
}
|
||||
const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs;
|
||||
const leg = legs?.[i];
|
||||
const leg = ownerOf(data, from)?.legs[i];
|
||||
if (leg) Object.assign(leg, patch);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 ·
|
||||
표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` ·
|
||||
좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄
|
||||
POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 ·
|
||||
이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름
|
||||
GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/<id>.png`
|
||||
· `auto/<id>.png` · `<id>.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음)
|
||||
GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 ·
|
||||
시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램
|
||||
지금 중점 측점(244-12 · 상자 작은 글씨)
|
||||
@@ -24,17 +28,29 @@
|
||||
그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자
|
||||
그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님).
|
||||
그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움.
|
||||
장소 그림(256-1) = `place/<상자 id>.png` · [저장] 뒤 서버가 좌표가 바뀐(또는 그림 없는) 상자만
|
||||
다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 ·
|
||||
GET 답 `place_images` {상자 id: "place/<id>.png"} · `place_times` {상자 id: 바뀐 시각 ms}
|
||||
(캐시 깨기).
|
||||
경로 그림(256-2) = [저장] 뒤 같은 길로 경로 탭과 같은 전체 경로(거래처 → 기준점 사슬 → 시점 ·
|
||||
구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · 표지 = `mapMarks` 차례) → `auto/<경로 id>.png`
|
||||
· 지문(표지 · 수기) 바뀐 경로만 · 화면 [길찾기] 그림(거래처 구간만)이 들어오면 지문을 지워 다음
|
||||
[저장]에 전체로.
|
||||
공사지 = 시점(노선 첫 점 · 측점 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,16 +58,24 @@ 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
|
||||
|
||||
from B01_Dashboard.B01_Dashboard_Map import reverse_geocode
|
||||
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
|
||||
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3
|
||||
from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image
|
||||
from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import (
|
||||
SITE,
|
||||
base_chain,
|
||||
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
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_geometry import (
|
||||
@@ -67,8 +91,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"]
|
||||
@@ -92,34 +119,59 @@ class Place(BaseModel):
|
||||
dir: Dir | None = None
|
||||
lon: float | None = Field(default=None, ge=-180, le=180)
|
||||
lat: float | None = Field(default=None, ge=-90, le=90)
|
||||
# 153 다이어그램에 끌어 놓은 자리(왼 위 · 그림 좌표) — 없으면 방위 자동 자리
|
||||
pos: tuple[float, float] | None = None
|
||||
manual: bool = False # 155 수기 입력 — 카카오 길찾기 안 함
|
||||
legs: list[Leg] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
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
|
||||
linked: bool = True # 155 False = 아직 선 안 이음(요소 추가 뒤)
|
||||
rows: list[Row] = Field(default_factory=list, max_length=50)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -203,15 +255,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()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -221,13 +287,99 @@ 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 route_chain(data: dict[str, Any], route: dict[str, Any], start: list[float] | None) -> list:
|
||||
"""경로 그림 사슬 — [(이름, 경도, 위도, 수기)] · 화면 `mapMarks` 와 같은 차례 ·
|
||||
좌표 없는 상자 뺌."""
|
||||
owners = [(route.get("name") or "거래처", route)]
|
||||
if route.get("linked", True) is not False:
|
||||
owners += [
|
||||
(b.get("name") or "기준점", b) for b in base_chain(data, route.get("to") or SITE)
|
||||
]
|
||||
out = [(n, o.get("lon"), o.get("lat"), bool(o.get("manual"))) for n, o in owners]
|
||||
if start:
|
||||
out.append(("시점", start[0], start[1], False))
|
||||
return [c for c in out if c[1] is not None and c[2] is not None]
|
||||
|
||||
|
||||
def route_image(root: str | Path, route_id: str, chain: list) -> dict[str, Any]:
|
||||
"""경로 그림 한 장 — 구간마다 카카오 길 선(수기 · 실패 = 곧은 선) → 캡처 → 지문 적음."""
|
||||
lines = []
|
||||
for (_, lon1, lat1, manual), (_, lon2, lat2, _) in zip(chain, chain[1:]):
|
||||
path = []
|
||||
if not manual:
|
||||
got = directions_km(LonLat(lon=lon1, lat=lat1), LonLat(lon=lon2, lat=lat2))
|
||||
path = got.get("path") or []
|
||||
lines.append(path or [[lat1, lon1], [lat2, lon2]])
|
||||
marks = [{"name": n, "lon": lon, "lat": lat} for n, lon, lat, _ in chain]
|
||||
got = make_route_image(folder(root) / AUTO, route_id, lines, marks)
|
||||
if got["saved"]:
|
||||
_set_print(root, f"{AUTO}/{route_id}", _print(chain))
|
||||
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)
|
||||
start = site_info(root, None)["start_lonlat"]
|
||||
have = images(root, AUTO)
|
||||
for route in data["routes"]:
|
||||
chain = route_chain(data, route, start)
|
||||
key = f"{AUTO}/{route['id']}"
|
||||
if route.get("linked", True) is False or len(chain) < 2:
|
||||
continue # 안 이은 거래처 = 경로 그림 없음
|
||||
if prints.get(key) != _print(chain) or route["id"] not in have:
|
||||
route_image(root, route["id"], chain)
|
||||
|
||||
|
||||
def _route_id(route: str) -> str:
|
||||
try:
|
||||
return Route(id=route).id
|
||||
@@ -235,16 +387,32 @@ def _route_id(route: str) -> str:
|
||||
raise HTTPException(status_code=400, detail="경로 id 가 바르지 않습니다.") from error
|
||||
|
||||
|
||||
async def _haul(project_id: UUID) -> list[dict[str, Any]]:
|
||||
"""운반 묶음(251) — 자재 집계 못 읽으면 빈 목록(거리이정표는 그대로)."""
|
||||
try:
|
||||
return haul_groups(await material_sheets(project_id))
|
||||
except Exception: # noqa: BLE001 — 자재 집계 없음 · 실패 = 자동 상자 없음
|
||||
return []
|
||||
|
||||
|
||||
@router.get(PATH)
|
||||
async def milestone_read(project_id: UUID) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
return await asyncio.to_thread(lambda: payload(root, read_data(root)))
|
||||
groups = await _haul(project_id)
|
||||
return await asyncio.to_thread(lambda: payload(root, with_haul_routes(read_data(root), groups)))
|
||||
|
||||
|
||||
@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)
|
||||
if not os.environ.get(
|
||||
"PYTEST_CURRENT_TEST"
|
||||
): # 남의 시험 [저장] = 실제 카카오 · 브라우저 안 부름
|
||||
tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임
|
||||
return out
|
||||
|
||||
|
||||
@router.get(PATH + "/site-address")
|
||||
@@ -354,18 +522,41 @@ async def milestone_image_auto(
|
||||
lines = [[list(p) for p in line] for line in body.lines]
|
||||
marks = [m.model_dump() for m in body.marks]
|
||||
got = await asyncio.to_thread(make_route_image, folder(root) / AUTO, route_id, lines, marks)
|
||||
if got["saved"]: # 거래처 구간만 그림 — 다음 [저장]에 전체 경로로 다시(256-2)
|
||||
await asyncio.to_thread(_set_print, root, f"{AUTO}/{route_id}", None)
|
||||
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
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
/* 틀 너비 = 표 너비(244-6) — 다이어그램은 너비를 보태지 않고 표에 맞춰 늘음 */
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
/* 149 — 페이지 폭 전체(다이어그램 · 표 같은 폭) */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.b08-milestone > .b08-milestone__diagram {
|
||||
contain: inline-size;
|
||||
/* 149 표도 폭 전체 — 열 너비는 비율대로 늘음 */
|
||||
.b08-milestone > .ui-data-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.b08-milestone > .ui-data-table .ui-data-table__table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.b08-milestone__route {
|
||||
@@ -52,7 +56,19 @@
|
||||
}
|
||||
|
||||
/* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */
|
||||
/* 153 · 155 · 157 · 159 [방위 배치] · [요소 추가] — 다이어그램 왼쪽 아래 · 단추 = 메인 탭 크기 */
|
||||
.b08-milestone__tools {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.b08-milestone__diagram {
|
||||
position: relative;
|
||||
/* 149 높이 고정 · 그림은 틀 비율로 넓힌 범위에 맞춤(스크롤 없음 · 244-8) */
|
||||
height: clamp(420px, 58vh, 760px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
@@ -61,7 +77,6 @@
|
||||
|
||||
.b08-milestone__diagram svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -76,23 +91,35 @@
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.b08-milestone__box text,
|
||||
.b08-milestone__plus text {
|
||||
.b08-milestone__box text {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
|
||||
/* 153 상자 색 — 공사지(시점 · 중점) = 강조색 · 기준점 = 경고색 · 거래처 = 성공색 · 옅은 채움 + 테두리(테마 변수 · 밝음 · 어두움 같이) */
|
||||
.b08-milestone__box.is-site rect {
|
||||
fill: var(--color-surface-raised);
|
||||
fill: color-mix(in srgb, var(--color-accent) 7%, var(--color-surface));
|
||||
stroke: var(--color-accent);
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.b08-milestone__box.is-base rect,
|
||||
.b08-milestone__box.is-start rect,
|
||||
.b08-milestone__box.is-mid rect {
|
||||
fill: color-mix(in srgb, var(--color-accent) 16%, var(--color-surface));
|
||||
stroke: var(--color-accent);
|
||||
}
|
||||
|
||||
.b08-milestone__box.is-route {
|
||||
.b08-milestone__box.is-base rect {
|
||||
fill: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
|
||||
stroke: var(--color-warning);
|
||||
}
|
||||
|
||||
.b08-milestone__box.is-route rect {
|
||||
fill: color-mix(in srgb, var(--color-success) 14%, var(--color-surface));
|
||||
stroke: var(--color-success);
|
||||
}
|
||||
|
||||
.b08-milestone__box.is-route,
|
||||
.b08-milestone__box.is-base {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -102,16 +129,6 @@
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.b08-milestone__plus {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b08-milestone__plus circle {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b08-milestone__km {
|
||||
fill: var(--color-text);
|
||||
font-size: 12px;
|
||||
@@ -154,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) */
|
||||
@@ -166,15 +183,15 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */
|
||||
.b08-milestone .is-pick-road {
|
||||
color: var(--color-danger);
|
||||
/* 254-3 상자 안 장소 그림 — 누름 · 끌기는 상자 몫 */
|
||||
.b08-milestone__pic {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 */
|
||||
/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 · 높이 어디서나 한 값(150) */
|
||||
.b08-milestone__map {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
height: 480px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -187,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);
|
||||
@@ -205,6 +235,18 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 255-3 칸 이름 = 내용 글 크기 이상 · 한 줄 · 소재지(주소 · 지도) 위 가로선 */
|
||||
.b08-milestone__modal .ui-field__label {
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b08-milestone__place-field {
|
||||
margin-top: var(--spacing-8);
|
||||
padding-top: var(--spacing-16);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 연결선 누름 자리 — 투명한 굵은 선(244-13) */
|
||||
.b08-milestone__line-hit {
|
||||
stroke: transparent;
|
||||
@@ -212,3 +254,64 @@
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254) · 끌어 기준점 · 공사지에 놓으면 연결 */
|
||||
.b08-milestone__connect {
|
||||
fill: var(--color-success);
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 2;
|
||||
cursor: crosshair;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.b08-milestone__box:hover .b08-milestone__connect {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.b08-milestone__wire {
|
||||
stroke: var(--color-success);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b08-milestone__box.is-drop-target rect {
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
|
||||
/* 157 경로 탭 머리 줄 — 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기 */
|
||||
.b08-milestone__route-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b08-milestone__route-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b08-milestone__route-addr {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 단가연결 램프(251-4) — 항목 칸 앞 · 노랑 = 없음 · 초록 = 연결 */
|
||||
[data-b08-milestone="table"] td.b08-milestone__lamp::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
[data-b08-milestone="table"] td.b08-milestone__lamp.is-linked::before {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,17 @@
|
||||
* 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도
|
||||
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌)
|
||||
* 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State`
|
||||
* 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] ·
|
||||
* [단가연결 풀기] — 창 = 「운반 단가」 모달(253-3 · B09 운반공 줄과 같음 · 그 행 거리) · 경로 `ref` ·
|
||||
* B09 운반공 줄이 받음
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B08_Quantity_UI_Milestone.css";
|
||||
import { createDataTable, type DataColumn } from "@ui/ui_template_data_table";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { 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 { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
|
||||
import {
|
||||
@@ -35,6 +41,18 @@ import {
|
||||
const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const;
|
||||
const ROAD = ["도로 구분 (km)"];
|
||||
const SUM = ["계 (km)"];
|
||||
/** 단가연결 램프 칸 class(`B08_Quantity_UI_Milestone.css`) */
|
||||
const LAMP = "b08-milestone__lamp";
|
||||
|
||||
/** 램프 호버 글 — 연결된 것 */
|
||||
export function refText(ref: Record<string, unknown> | null | undefined): string {
|
||||
if (!ref) return "단가연결 없음 — 줄 우클릭 [단가연결]";
|
||||
const what =
|
||||
ref.tab === "logic"
|
||||
? `단가산출 ${String(ref.code ?? "")} ${String(ref.name ?? "")}`
|
||||
: `일위대가 ${String(ref.name ?? ref.key ?? "")}`;
|
||||
return `단가연결 — ${what.trim()}`;
|
||||
}
|
||||
|
||||
/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 ·
|
||||
* 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */
|
||||
@@ -53,7 +71,7 @@ export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColu
|
||||
{ key: "un", label: "비포장", group: ROAD, ...KM },
|
||||
];
|
||||
return [
|
||||
{ key: "item", label: "항목", width: 120 },
|
||||
{ key: "item", label: "항목", width: 134 },
|
||||
{ key: "company", label: "거래처 이름", width: 120 },
|
||||
{ key: "dest", label: "목적지", width: 72 },
|
||||
...roads,
|
||||
@@ -70,7 +88,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: "" }],
|
||||
@@ -83,8 +101,9 @@ export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}):
|
||||
/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */
|
||||
function tableView(projectId: string): MilestoneView {
|
||||
const diagram = createDiagram(projectId, (init) => addRoute(projectId, init));
|
||||
let keys: Array<{ route: string; index: number }> = [];
|
||||
let pickRows: boolean[] = [];
|
||||
let keys: Array<{ route: string; index: number; L1: number; L2: number }> = [];
|
||||
const routeAt = (i: number) =>
|
||||
liveMilestone(projectId).routes.find((r) => r.id === keys[i]?.route);
|
||||
const makeTable = (columns: DataColumn[]) => {
|
||||
const t = createDataTable({
|
||||
columns,
|
||||
@@ -92,7 +111,7 @@ function tableView(projectId: string): MilestoneView {
|
||||
caption: "거리이정표",
|
||||
empty: "",
|
||||
editOnDouble: true,
|
||||
cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null),
|
||||
cellClass: (i, key) => (key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP),
|
||||
editable: (_i, key) => key === "item" || key === "company" || key === "note",
|
||||
onEdit: (i, key, text) => {
|
||||
const at = keys[i];
|
||||
@@ -118,6 +137,36 @@ function tableView(projectId: string): MilestoneView {
|
||||
});
|
||||
trackCells(t.root);
|
||||
t.root.dataset.b08Milestone = "table";
|
||||
attachHoverInfo(
|
||||
t.root,
|
||||
(cell) => refText(routeAt(Number(cell.closest("tr")?.dataset.i))?.ref),
|
||||
`td.${LAMP}`,
|
||||
);
|
||||
t.root.addEventListener("contextmenu", (event) => {
|
||||
const tr = (event.target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
const route = tr ? routeAt(Number(tr.dataset.i)) : undefined;
|
||||
if (!route) return;
|
||||
event.preventDefault();
|
||||
const link = (ref: Row | null, from?: Row): void =>
|
||||
editRoute(projectId, route.id, (_, r) => {
|
||||
// 호버 글 이름(사전조합 · 구조물 조합 = 들여온 대가 이름) — B09 는 tab · key 만 읽음
|
||||
r.ref = ref && { ...ref, name: ref.name ?? from?.name ?? "" };
|
||||
});
|
||||
const at = keys[Number(tr?.dataset.i)];
|
||||
pickAtCell([
|
||||
[
|
||||
"단가연결",
|
||||
() =>
|
||||
openHaulModal({
|
||||
ref: (route.ref as Row | null) ?? null,
|
||||
dist: at && { L1: at.L1, L2: at.L2 },
|
||||
name: route.rows[at?.index ?? 0]?.item || route.name || "운반",
|
||||
apply: link,
|
||||
}),
|
||||
],
|
||||
...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []),
|
||||
]);
|
||||
});
|
||||
return t;
|
||||
};
|
||||
let mode = "false:false";
|
||||
@@ -130,29 +179,26 @@ 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),
|
||||
),
|
||||
);
|
||||
table.root.replaceWith(fresh.root);
|
||||
table = fresh;
|
||||
}
|
||||
keys = rows.map(({ route, index }) => ({ route, index }));
|
||||
pickRows = rows.map((r) => r.pick);
|
||||
keys = rows.map(({ route, index, paved, unpaved }) => ({
|
||||
route,
|
||||
index,
|
||||
L1: paved,
|
||||
L2: unpaved,
|
||||
}));
|
||||
none.hidden = rows.length > 0;
|
||||
// 도로 구분 안 고른 구간 = 합계 칸에 「도로 구분 고르기」(그 km 는 합에 안 듦 · 244-7)
|
||||
table.setRows(
|
||||
rows.map((r) => ({
|
||||
...r,
|
||||
dest: `${DEST_TEXT[r.dest]} ▾`,
|
||||
total: r.pick ? "도로 구분 고르기" : r.total,
|
||||
})),
|
||||
);
|
||||
table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` })));
|
||||
diagram.refresh();
|
||||
};
|
||||
refresh();
|
||||
@@ -187,7 +233,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void {
|
||||
};
|
||||
tabs.replaceChildren(
|
||||
tab("table", "거리이정표"),
|
||||
...routes.map((r, i) => tab(r.id, r.name.trim() || `거래처 ${i + 1}`)),
|
||||
...routes.map((r, i) => tab(r.id, r.name.trim() || r.rows[0]?.item || `거래처 ${i + 1}`)),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,30 +7,44 @@
|
||||
* 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`)
|
||||
* 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율)
|
||||
* 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 = [+] 등록 모달 분류 「기준위치」(244-13 · [기준점 +] 없앰)
|
||||
* 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 고침 · 삭제) · 선 · 선 글 누름 = 연결선 모달
|
||||
* 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 수정 · 삭제) · 선 · 선 글 누름 = 연결선 모달
|
||||
* (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로)
|
||||
* 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소)
|
||||
* 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7)
|
||||
* 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) ·
|
||||
* 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km)
|
||||
* 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소)
|
||||
* 장소 그림(254-3) = 서버 `place/<상자 id>.png`(256-1) 을 상자 글 아래 작게 · 그림 없으면 자리 없음
|
||||
* 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점
|
||||
* 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소
|
||||
* ========================================================================== */
|
||||
|
||||
import { 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, type MilestoneRoute, type Road } from "./B08_Quantity_Milestone_Calc";
|
||||
import {
|
||||
chainVia,
|
||||
midKm,
|
||||
roadOf,
|
||||
segKind,
|
||||
withRoads,
|
||||
type MilestoneRoute,
|
||||
type Road,
|
||||
} from "./B08_Quantity_Milestone_Calc";
|
||||
import {
|
||||
layoutMilestone,
|
||||
legAt,
|
||||
makesLoop,
|
||||
ownerOf,
|
||||
panContent,
|
||||
panStart,
|
||||
setLeg,
|
||||
type DiagramBox,
|
||||
} from "./B08_Quantity_Milestone_Layout";
|
||||
import { openLegInfo } from "./B08_Quantity_UI_Milestone_LegModal";
|
||||
import { openPlaceModal, openSiteInfo } from "./B08_Quantity_UI_Milestone_Modal";
|
||||
import { fmtKm, openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milestone_Modal";
|
||||
import {
|
||||
findSegmentKm,
|
||||
liveMilestone,
|
||||
placeImageBlobUrl,
|
||||
placeImageKey,
|
||||
putMilestone,
|
||||
roadOptions,
|
||||
siteAddress,
|
||||
@@ -62,21 +76,23 @@ function editLegAt(
|
||||
const leg = legAt(data, from, i, midKm);
|
||||
if (!leg) return;
|
||||
document.querySelector(".b08-milestone__leg-edit")?.remove();
|
||||
const kind = segKind(data, from);
|
||||
const road = createSelectField({
|
||||
label: "도로 구분",
|
||||
options: roadOptions(chainVia(data, from), from !== "start", leg.road), // 시점 → 중점 = 늘 고름
|
||||
options: roadOptions(kind), // 포장 종류 = 선 자리(152) · 비포장만 고름
|
||||
compact: true,
|
||||
});
|
||||
road.select.value = leg.road ?? "";
|
||||
road.select.value = roadOf(leg.road, kind);
|
||||
const km = el("input", {
|
||||
className: "ui-input",
|
||||
attrs: { type: "number", min: "0", step: "any" },
|
||||
});
|
||||
km.value = leg.km == null ? "" : String(leg.km);
|
||||
km.value = leg.km == null ? "" : fmtKm(leg.km);
|
||||
const shown = km.value; // 안 고치면 저장 값 그대로 · 반올림 값으로 안 덮음
|
||||
km.setAttribute("aria-label", "km");
|
||||
if (leg.auto_km != null) {
|
||||
km.placeholder = String(leg.auto_km); // 빈칸 = 자동 값
|
||||
km.title = `자동 값 ${leg.auto_km} km`;
|
||||
km.placeholder = fmtKm(leg.auto_km); // 빈칸 = 자동 값
|
||||
km.title = `자동 값 ${fmtKm(leg.auto_km)} km`;
|
||||
}
|
||||
const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] });
|
||||
box.dataset.b08MilestoneLegEdit = `${from}:${i}`;
|
||||
@@ -93,7 +109,7 @@ function editLegAt(
|
||||
const value = km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null;
|
||||
const patch = {
|
||||
road: (road.select.value || null) as Road | null,
|
||||
...(km.value.trim() && value === null ? {} : { km: value }),
|
||||
...(km.value === shown || (km.value.trim() && value === null) ? {} : { km: value }),
|
||||
};
|
||||
putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch));
|
||||
};
|
||||
@@ -109,6 +125,27 @@ function editLegAt(
|
||||
km.select();
|
||||
}
|
||||
|
||||
/** 처음 배율(149) — 상자 범위가 틀에 차도록 당김(1 ~ 1.8배 · 가운데 맞춤) · 좌표 = viewBox(바탕 이동 vx · vy 뺌) */
|
||||
function fitBoxes(
|
||||
boxes: DiagramBox[],
|
||||
vx: number,
|
||||
vy: number,
|
||||
vw: number,
|
||||
vh: number,
|
||||
): ZoomPanState | undefined {
|
||||
if (!boxes.length) return undefined;
|
||||
const pad = 36;
|
||||
const x0 = Math.min(...boxes.map((b) => b.x)) - pad - vx;
|
||||
const y0 = Math.min(...boxes.map((b) => b.y)) - pad - vy;
|
||||
const x1 = Math.max(...boxes.map((b) => b.x + b.w)) + pad - vx;
|
||||
const y1 = Math.max(...boxes.map((b) => b.y + b.h)) + pad - vy;
|
||||
const scale = Math.min(1.8, Math.max(1, Math.min(vw / (x1 - x0), vh / (y1 - y0))));
|
||||
if (scale <= 1.001) return undefined;
|
||||
const cx = (x0 + x1) / 2;
|
||||
const cy = (y0 + y1) / 2;
|
||||
return { scale, tx: vw / 2 - cx * scale, ty: vh / 2 - cy * scale };
|
||||
}
|
||||
|
||||
/** 다이어그램 — refresh() 로 캐시 값 다시 그림 */
|
||||
export function createDiagram(
|
||||
projectId: string,
|
||||
@@ -118,32 +155,163 @@ export function createDiagram(
|
||||
host.dataset.b08Milestone = "diagram";
|
||||
// 틀 위 휠 = 줌만(244-8) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 새지 않음
|
||||
host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false });
|
||||
let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로
|
||||
let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로(사용자가 줌 · 팬 했을 때만)
|
||||
let userZoomed = false;
|
||||
// 사용자 줌 · 팬 = 그 배율 지킴 · 그 전엔 그릴 때마다 상자 범위에 맞춰 당김(149 · 글씨 읽힘 · 새 상자도 보임)
|
||||
host.addEventListener("wheel", () => (userZoomed = true));
|
||||
host.addEventListener("pointermove", (e) => e.buttons && (userZoomed = true));
|
||||
host.addEventListener("dblclick", () => (userZoomed = false)); // 공용 더블클릭 = 원배율
|
||||
const INFO: Record<DiagramBox["kind"], (id: string) => void> = {
|
||||
route: (id) => openPlaceModal(projectId, { add: false, id }),
|
||||
site: () => openSiteInfo(projectId),
|
||||
start: () => openSiteInfo(projectId),
|
||||
mid: () => openSiteInfo(projectId),
|
||||
base: () => openPlaceModal(projectId, { add: false, id: "base" }),
|
||||
base: (id) => openPlaceModal(projectId, { add: false, id }),
|
||||
};
|
||||
|
||||
const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => {
|
||||
const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 });
|
||||
const cx = box.x + box.w - 2;
|
||||
const cy = box.y + 2;
|
||||
g.append(
|
||||
svg("circle", { cx, cy, r: 11 }),
|
||||
svg("text", { x: cx, y: cy, "text-anchor": "middle", "dominant-baseline": "central" }, "+"),
|
||||
svg("title", {}, "항목 추가"),
|
||||
);
|
||||
g.dataset.b08MilestonePlus = viaBase ? "base" : "site";
|
||||
const go = (e: Event): void => {
|
||||
e.stopPropagation();
|
||||
openPlaceModal(projectId, { add: true, viaBase }, addRoute);
|
||||
};
|
||||
g.addEventListener("click", go);
|
||||
g.addEventListener("keydown", (e) => e.key === "Enter" && go(e));
|
||||
return g;
|
||||
/** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버)
|
||||
* 기준점을 옮기면 거친 거래처도 따라감(자리 안 정한 것만 · 레이아웃이 기준점 둘레로 놓음) */
|
||||
const dragBox = (g: SVGGElement, box: DiagramBox, shift: SVGGElement): void => {
|
||||
g.addEventListener("pointerdown", (down) => {
|
||||
if (down.button !== 0) return;
|
||||
down.stopPropagation(); // 빈 곳 팬 안 함
|
||||
const ctm = shift.getScreenCTM();
|
||||
const k = ctm ? ctm.a : 1; // 화면 px ÷ 그림 좌표
|
||||
let moved = false;
|
||||
const move = (e: PointerEvent): void => {
|
||||
const dx = (e.clientX - down.clientX) / k;
|
||||
const dy = (e.clientY - down.clientY) / k;
|
||||
if (!moved && Math.hypot(e.clientX - down.clientX, e.clientY - down.clientY) < 4) return;
|
||||
moved = true;
|
||||
g.setAttribute("transform", `translate(${dx} ${dy})`);
|
||||
};
|
||||
const up = (e: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
if (!moved) return;
|
||||
const swallow = (c: Event): void => c.stopPropagation(); // 끌고 난 누름 = 모달 아님
|
||||
g.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => g.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
const pos: [number, number] = [
|
||||
Math.round(box.x + (e.clientX - down.clientX) / k),
|
||||
Math.round(box.y + (e.clientY - down.clientY) / k),
|
||||
];
|
||||
const fresh = liveMilestone(projectId);
|
||||
const target = ownerOf(fresh, box.id);
|
||||
if (!target) return;
|
||||
target.pos = pos;
|
||||
userZoomed = true; // 옮긴 뒤 배율 그대로
|
||||
putMilestone(projectId, fresh);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
});
|
||||
};
|
||||
|
||||
/** 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254 · 테두리 안) · 끌어 기준점 상자 = 그 기준점으로
|
||||
* (to = 기준점 id) · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 자기 · 같은 연결 = 취소 · 기준점 돌기
|
||||
* (A → B → A) = 취소 + 알림 · 바뀌면 그 구간 새로(km 비움) + 길찾기 한 번(km · 거래처 = 경로 그림) */
|
||||
const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => {
|
||||
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) => {
|
||||
if (down.button !== 0) return;
|
||||
down.stopPropagation(); // 상자 끌기 · 팬 안 함
|
||||
down.preventDefault();
|
||||
const toLocal = (e: PointerEvent): [number, number] => {
|
||||
const m = shift.getScreenCTM();
|
||||
if (!m) return [cx, cy];
|
||||
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse());
|
||||
return [p.x, p.y];
|
||||
};
|
||||
const wire = svg("line", { class: "b08-milestone__wire", x1: cx, y1: cy, x2: cx, y2: cy });
|
||||
shift.append(wire);
|
||||
let over: Element | null = 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 === "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);
|
||||
wire.setAttribute("x2", String(x));
|
||||
wire.setAttribute("y2", String(y));
|
||||
const t = targetOf(e);
|
||||
const g = t ? shift.querySelector(`[data-b08-milestone-box="${t}"]`) : null;
|
||||
if (g !== over) {
|
||||
over?.classList.remove("is-drop-target");
|
||||
g?.classList.add("is-drop-target");
|
||||
over = g;
|
||||
}
|
||||
};
|
||||
const up = (e: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
wire.remove();
|
||||
over?.classList.remove("is-drop-target");
|
||||
const t = targetOf(e);
|
||||
const fresh = liveMilestone(projectId);
|
||||
const route = ownerOf(fresh, box.id);
|
||||
if (!t || !route) return;
|
||||
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);
|
||||
void findSegmentKm(projectId, route.id).then(toastKm);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
});
|
||||
return dot;
|
||||
};
|
||||
|
||||
// 153 · 159 [방위 배치] — 끌어 놓은 자리 모두 지우고 방위(소재지 좌표) 자동 자리로 · 늘 보임
|
||||
const arrange = createButton({
|
||||
label: "방위 배치",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const fresh = liveMilestone(projectId);
|
||||
fresh.bases.forEach((b) => (b.pos = null));
|
||||
fresh.routes.forEach((r) => (r.pos = null));
|
||||
userZoomed = false;
|
||||
putMilestone(projectId, fresh);
|
||||
},
|
||||
});
|
||||
arrange.dataset.b08MilestoneArrange = "";
|
||||
// 155 [요소 추가] — 기준점 · 거래처 등록(공사지 · 기준점 [+] 없앰) · 등록 뒤 연결 점으로 선 이음
|
||||
const addElem = createButton({
|
||||
label: "요소 추가",
|
||||
variant: "ghost",
|
||||
onClick: () => openPlaceModal(projectId, { add: true }, addRoute),
|
||||
});
|
||||
addElem.dataset.b08MilestoneAdd = "open";
|
||||
const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] });
|
||||
|
||||
/** 장소 그림(254-3 · 256-1) blob 주소 — 상자마다 키(이름#시각)가 바뀔 때만 다시 받음 */
|
||||
const picUrls = new Map<string, { key: string; url: Promise<string> }>();
|
||||
const picUrl = (id: string, key: string): Promise<string> => {
|
||||
let got = picUrls.get(id);
|
||||
if (!got || got.key !== key) {
|
||||
got?.url.then(URL.revokeObjectURL, () => undefined);
|
||||
got = { key, url: placeImageBlobUrl(projectId, id) };
|
||||
picUrls.set(id, got);
|
||||
}
|
||||
return got.url;
|
||||
};
|
||||
|
||||
const refresh = (): void => {
|
||||
@@ -153,7 +321,7 @@ export function createDiagram(
|
||||
// 시점 · 중점 주소 · 좌표(244-12) — 지금 중점 측점으로 서버 VWorld(뒤에서 · 오면 다시 그림)
|
||||
const addr = siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null);
|
||||
const layout = layoutMilestone(
|
||||
data,
|
||||
withRoads(data), // 선 글 도로 구분 = 선 자리 포장 종류(152)
|
||||
{
|
||||
start_lonlat: addr?.start_lonlat ?? site?.start_lonlat,
|
||||
mid_lonlat: addr?.mid_lonlat ?? site?.mid_lonlat,
|
||||
@@ -161,20 +329,26 @@ export function createDiagram(
|
||||
mid_address: addr?.mid_address,
|
||||
},
|
||||
mk,
|
||||
new Set([...data.bases, ...data.routes].map((o) => o.id).filter((id) => placeImageKey(id))),
|
||||
);
|
||||
const [vx, vy, vw, vh] = layout.view;
|
||||
// 틀 = 페이지 폭 전체 · 높이 고정(CSS · 149) — 그림 범위를 틀 비율로 넓혀 가운데 둠
|
||||
// (화면 px ↔ 그림 좌표 비율이 가로세로 같아야 팬 · 줌이 어긋나지 않음)
|
||||
const fw = Math.max(host.clientWidth, 320);
|
||||
const fh = Math.max(host.clientHeight, 240);
|
||||
const [lx, ly, lw, lh] = layout.view;
|
||||
const scale = Math.max(lw / fw, lh / fh);
|
||||
const vw = fw * scale;
|
||||
const vh = fh * scale;
|
||||
const vx = lx - (vw - lw) / 2;
|
||||
const vy = ly - (vh - lh) / 2;
|
||||
// viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김
|
||||
const root = svg("svg", {
|
||||
viewBox: `0 0 ${vw} ${vh}`,
|
||||
width: vw,
|
||||
height: vh,
|
||||
width: fw,
|
||||
height: fh,
|
||||
role: "img",
|
||||
"aria-label": "거리이정표 다이어그램",
|
||||
});
|
||||
// 틀 너비 = 아래 표 너비(244-6) · 높이는 비율대로(팬 · 줌 화면 px ↔ 그림 좌표가 어긋나지 않게)
|
||||
// 높이 48vh 넘으면 그만큼 좁혀 틀 안에 다 들어감(244-8 · 스크롤 없음)
|
||||
root.style.width = `min(100%, calc(48vh * ${vw / vh}))`;
|
||||
root.style.height = "auto";
|
||||
const layer = svg("g", {});
|
||||
const shift = svg("g", { transform: `translate(${-vx} ${-vy})` });
|
||||
layer.append(shift);
|
||||
@@ -204,9 +378,10 @@ export function createDiagram(
|
||||
...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]),
|
||||
];
|
||||
const x = box.x + box.w / 2;
|
||||
const th = box.pic ? box.pic.y - box.y : box.h; // 글 자리 = 그림 위
|
||||
const text = svg("text", {
|
||||
x,
|
||||
y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2,
|
||||
y: box.kind === "site" ? box.y + 18 : box.y + th / 2 - ((rows.length - 1) * 15) / 2,
|
||||
"text-anchor": "middle",
|
||||
"dominant-baseline": "central",
|
||||
});
|
||||
@@ -214,6 +389,24 @@ export function createDiagram(
|
||||
text.append(svg("tspan", { x, dy: i ? 15 : 0, ...(cls ? { class: cls } : {}) }, row)),
|
||||
);
|
||||
g.append(svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), text);
|
||||
// 254-3 장소 그림 — 글 아래 작게(그림 없으면 자리 없음 · 못 읽으면 지움)
|
||||
const key = box.pic && placeImageKey(box.id);
|
||||
if (box.pic && key) {
|
||||
const { x: px, y: py, w: pw, h: ph } = box.pic;
|
||||
const pic = svg("image", {
|
||||
class: "b08-milestone__pic",
|
||||
x: px,
|
||||
y: py,
|
||||
width: pw,
|
||||
height: ph,
|
||||
});
|
||||
pic.setAttribute("preserveAspectRatio", "xMidYMid slice");
|
||||
g.append(pic);
|
||||
picUrl(box.id, key).then(
|
||||
(url) => pic.setAttribute("href", url),
|
||||
() => pic.remove(),
|
||||
);
|
||||
}
|
||||
if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소
|
||||
// 상자 누름 = 모달(탭 이동 아님)
|
||||
const info = (): void => INFO[box.kind](box.id);
|
||||
@@ -224,16 +417,17 @@ export function createDiagram(
|
||||
info();
|
||||
});
|
||||
g.addEventListener("keydown", (e) => e.key === "Enter" && info());
|
||||
if (box.kind === "site") g.append(plus(box, false));
|
||||
if (box.kind === "base") g.append(plus(box, true));
|
||||
if (box.kind === "route" || box.kind === "base") dragBox(g, 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,
|
||||
@@ -241,34 +435,38 @@ 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);
|
||||
}
|
||||
host.replaceChildren(root);
|
||||
host.replaceChildren(root, tools);
|
||||
attachSvgZoomPan({
|
||||
svg: root,
|
||||
layer,
|
||||
widthPx: vw,
|
||||
heightPx: vh,
|
||||
pad: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
initial: zoomState,
|
||||
initial: userZoomed ? zoomState : fitBoxes(layout.boxes, vx, vy, vw, vh),
|
||||
onChange: (state) => (zoomState = state),
|
||||
wheelZoom: true,
|
||||
content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임)
|
||||
@@ -276,5 +474,17 @@ export function createDiagram(
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
// 틀 크기가 바뀌면(창 · 패널 접기 · 높이 = 58vh) 그 비율로 다시 그림 — 그림이 틀보다 크면 아래가 잘림(251)
|
||||
let lastW = 0;
|
||||
let lastH = 0;
|
||||
new ResizeObserver(() => {
|
||||
const w = host.clientWidth;
|
||||
const h = host.clientHeight;
|
||||
if (w && (Math.abs(w - lastW) > 1 || Math.abs(h - lastH) > 1)) {
|
||||
lastW = w;
|
||||
lastH = h;
|
||||
refresh();
|
||||
}
|
||||
}).observe(host);
|
||||
return { root: host, refresh };
|
||||
}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Milestone_KakaoMap.ts
|
||||
* 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 · 표지(보기만).
|
||||
* 거리이정표 카카오 지도 칸(PLAN 244-9 · 사용자 138번) — 등록 모달(소재지 고르기) · 연결선 모달(길 선 · 표지 끌기)
|
||||
* 한 벌(`editMap`) · 경로 하위 탭 지도 칸은 없앰(156 · 탭 = 그림만).
|
||||
*
|
||||
* SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key
|
||||
* (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기
|
||||
* 길 선 = 마지막 [길찾기] 응답 경로 좌표(`routePaths` · 메모리만 · 저장 안 함) · 표지 = 거래처 · 기준점 ·
|
||||
* 시점(`mapMarks`) · 경로 전체 맞춤
|
||||
* 길 선 = 마지막 [길찾기] 응답 경로 좌표(메모리만 · 저장 안 함) · 처음 한 번만 맞춤(153 · 그 뒤 보기 그대로)
|
||||
* 경로 그림 = 서버 자동 캡처(244-11 · `…_State` autoRouteImage) — 브라우저 캡처 없음(접속 주소 무관)
|
||||
* (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { mapMarks } from "./B08_Quantity_Milestone_Layout";
|
||||
import { request } from "./B08_Quantity_UI_Hydro";
|
||||
import { liveMilestone, routePaths, siteInfo } from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
export const NO_MAP =
|
||||
"카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요";
|
||||
"카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요";
|
||||
const SDK_FAIL = "KakaoSdkFail";
|
||||
const SDK = "https://dapi.kakao.com/v2/maps/sdk.js";
|
||||
const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선
|
||||
@@ -30,6 +28,8 @@ interface KMap {
|
||||
setCenter(p: LatLng): void;
|
||||
setLevel(level: number): void;
|
||||
relayout(): void;
|
||||
getBounds(): { contain(p: LatLng): boolean };
|
||||
panTo(p: LatLng): void;
|
||||
}
|
||||
interface KOverlay {
|
||||
setMap(map: KMap | null): void;
|
||||
@@ -74,7 +74,7 @@ export function loadKakaoMaps(projectId: string): Promise<KakaoMaps> {
|
||||
fail(
|
||||
Object.assign(
|
||||
new Error(
|
||||
`카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요`,
|
||||
`카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요`,
|
||||
),
|
||||
{ name: SDK_FAIL },
|
||||
),
|
||||
@@ -91,87 +91,6 @@ export function loadKakaoMaps(projectId: string): Promise<KakaoMaps> {
|
||||
return sdk;
|
||||
}
|
||||
|
||||
export interface RouteMapView {
|
||||
root: HTMLElement;
|
||||
/** 지금 값 · 마지막 [길찾기] 길 선으로 다시 그림 */
|
||||
draw: () => void;
|
||||
}
|
||||
|
||||
/** 경로 지도 칸 — 거래처 모달 · 경로 하위 탭 같은 한 벌 */
|
||||
export function routeMapView(projectId: string, routeId: string): RouteMapView {
|
||||
const box = el("div", { className: "b08-milestone__map" });
|
||||
const hint = el("p", { className: "b08-options__hint" });
|
||||
hint.dataset.b08MilestoneMapHint = "";
|
||||
let map: KMap | null = null;
|
||||
let drawn: KOverlay[] = [];
|
||||
let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로)
|
||||
|
||||
const draw = (): void => {
|
||||
const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
|
||||
const lines = routePaths(routeId);
|
||||
const sig = JSON.stringify([marks, lines]);
|
||||
if (map && sig === shown) return;
|
||||
loadKakaoMaps(projectId).then(
|
||||
(maps) =>
|
||||
requestAnimationFrame(() => {
|
||||
const points = [
|
||||
...marks.map((m) => new maps.LatLng(m.lat, m.lon)),
|
||||
...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)),
|
||||
];
|
||||
if (!points.length) {
|
||||
hint.textContent = "거래처 주소 · 노선 시점이 있어야 지도에 놓임";
|
||||
return;
|
||||
}
|
||||
box.hidden = false;
|
||||
map ??= new maps.Map(box, { center: points[0], level: 7 });
|
||||
map.relayout();
|
||||
drawn.forEach((o) => o.setMap(null));
|
||||
drawn = [
|
||||
...lines.map(
|
||||
(line) =>
|
||||
new maps.Polyline({
|
||||
path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)),
|
||||
strokeWeight: 6,
|
||||
strokeColor: LINE_COLOR,
|
||||
strokeOpacity: 0.9,
|
||||
}),
|
||||
),
|
||||
...marks.flatMap((m) => {
|
||||
const at = new maps.LatLng(m.lat, m.lon);
|
||||
return [
|
||||
new maps.Marker({ position: at, title: m.name }),
|
||||
new maps.CustomOverlay({
|
||||
position: at,
|
||||
content: el("span", { className: "b08-milestone__map-label", text: m.name }),
|
||||
yAnchor: 2.6,
|
||||
}),
|
||||
];
|
||||
}),
|
||||
];
|
||||
drawn.forEach((o) => o.setMap(map));
|
||||
const bounds = new maps.LatLngBounds();
|
||||
points.forEach((p) => bounds.extend(p));
|
||||
map.setBounds(bounds);
|
||||
shown = sig;
|
||||
hint.textContent = lines.length
|
||||
? "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦"
|
||||
: "[길찾기]를 누르면 길 선 · 경로 그림이 생김";
|
||||
}),
|
||||
(error: unknown) => {
|
||||
box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄(사진 올리기는 그대로)
|
||||
hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const root = el("div", {
|
||||
className: "b08-milestone__map-wrap",
|
||||
children: [hint, box],
|
||||
});
|
||||
draw();
|
||||
return { root, draw };
|
||||
}
|
||||
|
||||
/** 고르는 지도 표지(244-13) — key = 끌어 옮길 때 돌려받는 이름 */
|
||||
export interface MapPin {
|
||||
key: string;
|
||||
@@ -179,6 +98,8 @@ export interface MapPin {
|
||||
lon: number;
|
||||
lat: number;
|
||||
movable: boolean;
|
||||
/** 262-2 고정 시점 표지 — 끌기 · 맞춤 · 보기 이동에 안 쓰임 · 고르는 표지와 모양이 다름 */
|
||||
fixed?: boolean;
|
||||
}
|
||||
|
||||
export interface EditMap {
|
||||
@@ -202,13 +123,15 @@ export function editMap(
|
||||
hint.dataset.b08MilestoneMapHint = "";
|
||||
let map: KMap | null = null;
|
||||
let drawn: KOverlay[] = [];
|
||||
let fitted = false; // 153 — 처음 한 번만 맞춤 · 그 뒤 고름 · 끌기는 보기(중심 · 줌) 그대로
|
||||
|
||||
const set = (pins: MapPin[], lines: Array<Array<[number, number]>>): void => {
|
||||
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?.();
|
||||
@@ -236,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", () => {
|
||||
@@ -253,13 +187,20 @@ export function editMap(
|
||||
}),
|
||||
];
|
||||
drawn.forEach((o) => o.setMap(map));
|
||||
if (points.length > 1) {
|
||||
if (fitted) {
|
||||
// 보기 밖 표지만 그쪽으로 옮김(줌 그대로)
|
||||
const view = map.getBounds();
|
||||
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();
|
||||
points.forEach((p) => bounds.extend(p));
|
||||
map.setBounds(bounds);
|
||||
fitted = true;
|
||||
} else {
|
||||
map.setCenter(first);
|
||||
map.setLevel(points.length ? 4 : 9);
|
||||
fitted = points.length > 0;
|
||||
}
|
||||
}),
|
||||
(error: unknown) => {
|
||||
|
||||
@@ -3,20 +3,28 @@
|
||||
* 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름.
|
||||
*
|
||||
* 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7)
|
||||
* 도로 구분 = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · 비포장 ·
|
||||
* 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장(`roadOptions` · `chainVia`)
|
||||
* 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함 — → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 ·
|
||||
* 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`)
|
||||
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음)
|
||||
* 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정)
|
||||
* 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음)
|
||||
* 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { ROADS, type Leg, type Road } from "./B08_Quantity_Milestone_Calc";
|
||||
import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout";
|
||||
import {
|
||||
ROADS,
|
||||
roadOf,
|
||||
segKind,
|
||||
type Leg,
|
||||
type Road,
|
||||
type SegKind,
|
||||
} from "./B08_Quantity_Milestone_Calc";
|
||||
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 { fmtKm, toastKm } from "./B08_Quantity_UI_Milestone_Modal";
|
||||
import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place";
|
||||
import {
|
||||
findSegmentKm,
|
||||
@@ -25,6 +33,7 @@ import {
|
||||
roadOptions,
|
||||
segmentPath,
|
||||
siteInfo,
|
||||
startPoint,
|
||||
} from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
const km = (text: string): number | null => {
|
||||
@@ -35,25 +44,31 @@ const km = (text: string): number | null => {
|
||||
/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · 거리 km(빈칸 = 자동 값) · [×] · [+ 구간] */
|
||||
export function legsEditor(
|
||||
initial: Leg[],
|
||||
via: boolean,
|
||||
kind: SegKind,
|
||||
): { root: HTMLElement; value: () => Leg[] } {
|
||||
const list = el("div");
|
||||
const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = [];
|
||||
const rows: Array<{
|
||||
road: HTMLSelectElement;
|
||||
km: HTMLInputElement;
|
||||
auto: number | null;
|
||||
orig: number | null; // 안 고친 칸 = 원래 값 그대로(반올림 값으로 안 덮음)
|
||||
shown: string;
|
||||
}> = [];
|
||||
const add = (leg: Leg): void => {
|
||||
const road = createSelectField({
|
||||
label: "도로 구분",
|
||||
options: roadOptions(via, true, leg.road),
|
||||
options: roadOptions(kind), // 포장 종류 = 선 자리(152) · 비포장만 고름
|
||||
compact: true,
|
||||
});
|
||||
const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true });
|
||||
road.select.value = leg.road ?? "";
|
||||
dist.input.value = leg.km == null ? "" : String(leg.km);
|
||||
road.select.value = roadOf(leg.road, kind);
|
||||
dist.input.value = leg.km == null ? "" : fmtKm(leg.km);
|
||||
const auto = leg.auto_km ?? null;
|
||||
if (auto != null) {
|
||||
dist.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선
|
||||
dist.input.title = `자동 값 ${auto} km`;
|
||||
dist.input.placeholder = fmtKm(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선
|
||||
dist.input.title = `자동 값 ${fmtKm(auto)} km`;
|
||||
}
|
||||
const ref = { road: road.select, km: dist.input, auto };
|
||||
const ref = { road: road.select, km: dist.input, auto, orig: leg.km, shown: dist.input.value };
|
||||
rows.push(ref);
|
||||
const drop = createButton({
|
||||
label: "×",
|
||||
@@ -81,9 +96,9 @@ export function legsEditor(
|
||||
return {
|
||||
root: el("div", { children: [list, more] }),
|
||||
value: () =>
|
||||
rows.map(({ road, km: dist, auto }) => ({
|
||||
rows.map(({ road, km: dist, auto, orig, shown }) => ({
|
||||
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null,
|
||||
km: km(dist.value),
|
||||
km: dist.value === shown ? orig : km(dist.value),
|
||||
auto_km: auto,
|
||||
})),
|
||||
};
|
||||
@@ -102,17 +117,18 @@ function openMidLeg(projectId: string): void {
|
||||
mount: (body, close, actions) => {
|
||||
const road = createSelectField({
|
||||
label: "도로 구분",
|
||||
options: roadOptions(false, false, data.site.mid_road),
|
||||
options: roadOptions("mid"),
|
||||
compact: true,
|
||||
});
|
||||
road.select.value = data.site.mid_road;
|
||||
road.select.value = roadOf(data.site.mid_road, "mid");
|
||||
const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true });
|
||||
dist.input.value =
|
||||
data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000);
|
||||
dist.input.placeholder = site?.mid_default_m == null ? "" : String(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2
|
||||
data.site.mid_station_m == null ? "" : fmtKm(data.site.mid_station_m / 1000);
|
||||
dist.input.placeholder = site?.mid_default_m == null ? "" : fmtKm(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2
|
||||
const shown = dist.input.value; // 안 고치면 저장 값(m) 그대로
|
||||
dist.input.dataset.b08MilestoneLegKm = "start";
|
||||
const save = createButton({
|
||||
label: "고침",
|
||||
label: "수정",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
const fresh = liveMilestone(projectId);
|
||||
@@ -120,7 +136,7 @@ function openMidLeg(projectId: string): void {
|
||||
projectId,
|
||||
setLeg(fresh, "start", 0, {
|
||||
road: road.select.value as Road,
|
||||
km: km(dist.input.value),
|
||||
...(dist.input.value === shown ? {} : { km: km(dist.input.value) }),
|
||||
}),
|
||||
);
|
||||
close();
|
||||
@@ -136,10 +152,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({
|
||||
@@ -147,39 +163,39 @@ 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);
|
||||
let legs = legsEditor(legsOf(), via);
|
||||
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("");
|
||||
status.dataset.b08MilestoneLegStatus = "";
|
||||
/** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */
|
||||
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);
|
||||
};
|
||||
const redrawLegs = (): void => {
|
||||
const next = legsEditor(legsOf(), via);
|
||||
const next = legsEditor(legsOf(), kind);
|
||||
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 => {
|
||||
@@ -205,7 +221,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);
|
||||
@@ -223,7 +239,7 @@ export function openLegInfo(projectId: string, from: string): void {
|
||||
});
|
||||
find.dataset.b08MilestoneLegFind = "";
|
||||
const save = createButton({
|
||||
label: "고침",
|
||||
label: "수정",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
apply();
|
||||
@@ -232,20 +248,38 @@ export function openLegInfo(projectId: string, from: string): void {
|
||||
});
|
||||
save.dataset.b08MilestoneLegSave = "";
|
||||
actions.append(find, save);
|
||||
body.append(
|
||||
legs.root,
|
||||
hint(
|
||||
from === "base"
|
||||
? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)"
|
||||
: "빈칸 = 길찾기 자동 값 · 넣은 값이 우선",
|
||||
),
|
||||
status,
|
||||
el("h4", { text: "경로 — 다음(카카오) 지도" }),
|
||||
map.root,
|
||||
// 155 [수기 입력] — 카카오 길찾기 연결 끊음(자동 값 지움 · 길찾기 · 지도 숨김 · km 손 입력) · 풀면 다시 길찾기
|
||||
const owner = (d = liveMilestone(projectId)) => ownerOf(d, from);
|
||||
const manual = createCheckbox("수기 입력 — 카카오 길찾기 안 함(거리 손 입력)");
|
||||
manual.input.checked = !!owner()?.manual;
|
||||
manual.input.dataset.b08MilestoneManual = "";
|
||||
const auto = hint(
|
||||
isBase
|
||||
? "기준점 구간 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)"
|
||||
: "빈칸 = 길찾기 자동 값 · 넣은 값이 우선",
|
||||
);
|
||||
const mapHead = el("h4", { text: "경로 — 다음(카카오) 지도" });
|
||||
const showMode = (): void => {
|
||||
const off = manual.input.checked;
|
||||
find.hidden = auto.hidden = mapHead.hidden = map.root.hidden = off;
|
||||
status.textContent = off ? "수기 입력 — 거리를 손으로 넣으세요" : status.textContent;
|
||||
};
|
||||
manual.input.addEventListener("change", () => {
|
||||
const fresh = liveMilestone(projectId);
|
||||
const target = owner(fresh);
|
||||
if (!target) return;
|
||||
target.legs = legs.value().map((l) => (manual.input.checked ? { ...l, auto_km: null } : l));
|
||||
target.manual = manual.input.checked;
|
||||
putMilestone(projectId, fresh);
|
||||
redrawLegs();
|
||||
showMode();
|
||||
if (!manual.input.checked) void run(); // 다시 카카오 연결 = 길찾기 한 번
|
||||
});
|
||||
body.append(manual.root, legs.root, auto, status, mapHead, map.root);
|
||||
showMode();
|
||||
drawMap();
|
||||
// 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음)
|
||||
if (!segmentPath(from).length && seg.from && seg.to) void run(false);
|
||||
// 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음 · 수기 입력이면 안 함)
|
||||
if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to) void run(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
*
|
||||
* 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 ·
|
||||
* 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 ·
|
||||
* 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제]
|
||||
* 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정
|
||||
* 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리
|
||||
* 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [수정] · [삭제]
|
||||
* 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달
|
||||
* 연결(기준점 거침 · 시점으로 바로) = 다이어그램 연결 점 ● 끌기(154 · 155 · 등록 = 상자만 · 선 없음)
|
||||
* 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰)
|
||||
* 분류 기준위치 = 기준점(254 · 여럿 · 새 `bases` 원소 · 항목 · 목적지 없음 · 등록 = 상자만 · 연결 점으로 이음) ·
|
||||
* 방위 = 소재지 좌표로 자동 자리 · 기준점 [삭제] = 그 기준점에 이은 거래처 · 기준점은 안 이음으로
|
||||
* 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번
|
||||
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
|
||||
* 시점(공사지) 상자 = 중점 측점 km(저장 m · 255-1) · 아래 카카오 지도(시점 · 중점 표지)
|
||||
* 조작 = 캐시 · [저장] 때 서버
|
||||
* ========================================================================== */
|
||||
|
||||
import "../B01_Dashboard/B01_Dashboard_UI_Style.css";
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
@@ -23,11 +25,19 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview";
|
||||
import type { Dest, MilestoneRoute, MilestoneRow, Road } from "./B08_Quantity_Milestone_Calc";
|
||||
import {
|
||||
roadOf,
|
||||
type Dest,
|
||||
type MilestoneRoute,
|
||||
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 {
|
||||
capturePlace,
|
||||
findSegmentKm,
|
||||
liveMilestone,
|
||||
putMilestone,
|
||||
@@ -49,13 +59,14 @@ export const ITEMS: Record<"route" | "base", string[]> = {
|
||||
const line = (...children: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "b08-milestone__line-edit", children });
|
||||
|
||||
const head = (text: string): HTMLElement => el("h4", { text });
|
||||
|
||||
/** 길찾기 결과 한 줄 → 알림(까닭 줄 = 경고) */
|
||||
export function toastKm(text: string): void {
|
||||
if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success");
|
||||
}
|
||||
|
||||
/** 칸 · 글에 보이는 km — 3자리(1 m) 반올림 · 끝 0 지움(셈 값은 안 바꿈 · 261-1) */
|
||||
export const fmtKm = (km: number): string => String(Math.round(km * 1000) / 1000);
|
||||
|
||||
let listSeq = 0;
|
||||
|
||||
/** 항목(손 입력 + 제안 목록 · 244-14) · 목적지 ▾ 한 줄 — 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜 */
|
||||
@@ -69,7 +80,9 @@ function rowField(r: MilestoneRow, kind: "route" | "base") {
|
||||
const setKind = (k: "route" | "base"): void => {
|
||||
list.replaceChildren(...ITEMS[k].map((t) => el("option", { attrs: { value: t } })));
|
||||
item.input.placeholder = `예 ${ITEMS[k][0]}`;
|
||||
dest.root.style.visibility = k === "base" ? "hidden" : ""; // 기준점 → 시점 고정
|
||||
// 기준점 = 거쳐 가는 자리 — 항목 · 목적지 없음(149)
|
||||
// 255-3 자리도 비움 — 기준점 이름이 분류 옆 첫 줄(두 줄로 안 꺾임)
|
||||
item.root.style.display = dest.root.style.display = k === "base" ? "none" : "";
|
||||
};
|
||||
setKind(kind);
|
||||
dest.select.value = r.dest;
|
||||
@@ -77,23 +90,24 @@ function rowField(r: MilestoneRow, kind: "route" | "base") {
|
||||
return { item, dest, note: r.note, setKind };
|
||||
}
|
||||
|
||||
export type PlaceTarget = { add: true; viaBase: boolean } | { add: false; id: string };
|
||||
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
|
||||
? `항목 추가 — ${target.viaBase ? "기준점 거침" : "공사지로 바로"}`
|
||||
: editBase
|
||||
? `기준점 — ${data.base.name || "기준점"}`
|
||||
: `항목 정보 — ${route!.name || "거래처"}`;
|
||||
? "요소 추가 — 기준점 · 거래처"
|
||||
: base
|
||||
? base.name || "기준점"
|
||||
: route!.name || "거래처"; // 262-1 「—」 앞 글자 없음
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: "닫기",
|
||||
@@ -110,51 +124,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");
|
||||
const initRows: MilestoneRow[] = route?.rows.length
|
||||
? route.rows
|
||||
: [{ item: "", dest: "start", note: "" }];
|
||||
const rows = initRows.map((r) => rowField(r, kind));
|
||||
const [first] = rows;
|
||||
const more = el("div");
|
||||
const addRow = (field: ReturnType<typeof rowField>): void => {
|
||||
const drop = createButton({
|
||||
label: "×",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
rows.splice(rows.indexOf(field), 1);
|
||||
row.remove();
|
||||
},
|
||||
});
|
||||
const row = line(field.item.root, field.dest.root, drop);
|
||||
more.append(row);
|
||||
};
|
||||
rows.slice(1).forEach(addRow);
|
||||
const plusRow = createButton({
|
||||
label: "+ 줄",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const field = rowField({ item: "", dest: "start", note: "" }, kind);
|
||||
rows.push(field);
|
||||
addRow(field);
|
||||
},
|
||||
});
|
||||
const via = createCheckbox("기준점 거침");
|
||||
via.input.checked = route?.via_base ?? true;
|
||||
via.root.hidden = !route || !data.base.enabled;
|
||||
const old = editBase ? data.base : route;
|
||||
// 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠
|
||||
const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind);
|
||||
const old = base ?? route;
|
||||
const place = placePicker(
|
||||
projectId,
|
||||
{ address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null },
|
||||
@@ -163,59 +140,78 @@ export function openPlaceModal(
|
||||
const setKind = (k: "route" | "base"): void => {
|
||||
kind = k;
|
||||
if (nameLabel) nameLabel.textContent = k === "base" ? "기준점 이름" : "거래처 이름";
|
||||
rows.forEach((r) => r.setKind(k));
|
||||
more.hidden = plusRow.hidden = k === "base";
|
||||
first.setKind(k);
|
||||
};
|
||||
sort.select.addEventListener("change", () => setKind(sort.select.value as "route" | "base"));
|
||||
setKind(kind);
|
||||
const rowValues = (): MilestoneRow[] =>
|
||||
rows.map((r) => ({
|
||||
item: r.item.input.value.trim(),
|
||||
dest: r.dest.select.value as Dest,
|
||||
note: r.note,
|
||||
}));
|
||||
const rowValues = (): MilestoneRow[] => [
|
||||
{
|
||||
item: first.item.input.value.trim(),
|
||||
dest: first.dest.select.value as Dest,
|
||||
note: first.note,
|
||||
},
|
||||
...(route?.rows.slice(1) ?? []),
|
||||
];
|
||||
const moved = (p: { lon: number | null; lat: number | null }): boolean =>
|
||||
p.lon != null && (p.lon !== old?.lon || p.lat !== old?.lat);
|
||||
|
||||
const ok = createButton({
|
||||
label: target.add ? "등록" : "고침",
|
||||
label: target.add ? "등록" : "수정",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
const text = name.input.value.trim();
|
||||
const where = place.value();
|
||||
const fresh = liveMilestone(projectId);
|
||||
let segment: string | undefined; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번
|
||||
if (kind === "base") {
|
||||
Object.assign(fresh.base, where, { enabled: true, name: text, dir: null });
|
||||
if (!fresh.base.legs.length) fresh.base.legs = [{ road: null, km: null }];
|
||||
let placed: string | undefined; // 256-1 등록 · 좌표 바뀜 = 장소 그림 캡처(뒤에서)
|
||||
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 = placed = b.id;
|
||||
} else if (kind === "base") {
|
||||
// 254 새 기준점 = 상자만 · 선은 연결 점 ● 로 이음(이을 때 길찾기)
|
||||
placed = `b${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
|
||||
fresh.bases.push({
|
||||
id: placed,
|
||||
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();
|
||||
Object.assign(r, where, {
|
||||
name: text || r.name,
|
||||
via_base: via.input.checked,
|
||||
rows: rowValues(),
|
||||
});
|
||||
putMilestone(projectId, fresh);
|
||||
if (moved(where) || r.via_base !== route.via_base) segment = r.id;
|
||||
if (moved(where)) segment = placed = r.id;
|
||||
} else {
|
||||
if (!text) return name.setError("거래처 이름을 넣으세요.");
|
||||
const same = fresh.routes.find((r) => r.name.trim() === text);
|
||||
if (same) {
|
||||
same.rows.push(...rowValues());
|
||||
if (where.lon != null) Object.assign(same, where);
|
||||
putMilestone(projectId, fresh);
|
||||
}
|
||||
const viaBase = target.add && target.viaBase;
|
||||
const id =
|
||||
same?.id ??
|
||||
addRoute?.({ name: text, via_base: viaBase, dir: null, rows: rowValues(), ...where });
|
||||
if (id && where.lon != null) segment = id;
|
||||
// 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기)
|
||||
placed = addRoute?.({
|
||||
name: text,
|
||||
to: "site",
|
||||
linked: false,
|
||||
dir: null,
|
||||
rows: rowValues(),
|
||||
...where,
|
||||
});
|
||||
}
|
||||
close();
|
||||
if (segment) void findSegmentKm(projectId, segment).then(toastKm);
|
||||
if (placed && where.lon != null && where.lat != null)
|
||||
void capturePlace(projectId, placed, {
|
||||
name: text || (kind === "base" ? "기준점" : "거래처"),
|
||||
lon: where.lon,
|
||||
lat: where.lat,
|
||||
});
|
||||
},
|
||||
});
|
||||
ok.dataset.b08MilestoneInfo = "save";
|
||||
@@ -223,12 +219,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();
|
||||
},
|
||||
@@ -241,10 +242,6 @@ export function openPlaceModal(
|
||||
className: "b08-milestone__grid",
|
||||
children: [sort.root, first.item.root, name.root, first.dest.root],
|
||||
}),
|
||||
more,
|
||||
plusRow,
|
||||
via.root,
|
||||
head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"),
|
||||
place.root,
|
||||
);
|
||||
},
|
||||
@@ -259,72 +256,74 @@ export interface SiteAddress {
|
||||
mid_address: string | null;
|
||||
}
|
||||
|
||||
/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */
|
||||
/** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */
|
||||
export function openSiteInfo(projectId: string): void {
|
||||
openModal({
|
||||
title: "공사지 — 시점 · 중점",
|
||||
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 ? fmtKm(site.mid_default_m / 1000) : "";
|
||||
mid.input.value =
|
||||
data.site.mid_station_m == null ? "" : fmtKm(data.site.mid_station_m / 1000);
|
||||
const shown = mid.input.value; // 안 고치면 저장 값(m) 그대로 · 반올림 값으로 안 덮음
|
||||
mid.input.dataset.b08Milestone = "mid_station_m";
|
||||
const road = createSelectField({
|
||||
label: "시점 → 중점 도로",
|
||||
options: roadOptions(false, false, data.site.mid_road),
|
||||
options: roadOptions("mid"),
|
||||
compact: true,
|
||||
});
|
||||
road.select.value = data.site.mid_road;
|
||||
road.select.value = roadOf(data.site.mid_road, "mid");
|
||||
const save = createButton({
|
||||
label: "고침",
|
||||
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,
|
||||
...(text === shown ? {} : { 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"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,12 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Milestone_Route.ts
|
||||
* 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달).
|
||||
* 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4 · 157) — 머리 줄(거래처 이름 · 주소 + [그림 변경] · [초기화]) +
|
||||
* 길찾기 지도(연결선 모달과 같은 카카오 지도 · 표지 끌어 소재지 옮김) · 사용자 그림을 올리면 그 그림 · [초기화] = 지도로
|
||||
*
|
||||
* 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자
|
||||
* 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버
|
||||
* 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기
|
||||
* [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · 경유 = 기준점 좌표(거침) ·
|
||||
* 도착 = 목적지 좌표) — 보기만
|
||||
* 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지(보기만) · JS 키 없음 = 안내 한 줄
|
||||
* [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버)
|
||||
* 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157)
|
||||
* 255-2 시점도 끌기 = 길찾기용 덮개(`site.start_pin`) · 표지 옮기면 그 자리에 닿는 구간 모두 다시 길찾기 ·
|
||||
* 열 때 선 없는 사슬 구간 모두 길찾기 · 실패 = 안내 한 줄 · 끌기는 그림 캡처 안 함
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -17,26 +16,31 @@ 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 { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout";
|
||||
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 {
|
||||
autoImageNote,
|
||||
canRevertImage,
|
||||
capturePlace,
|
||||
deleteImage,
|
||||
findSegmentKm,
|
||||
hasUserImage,
|
||||
imageBlobUrl,
|
||||
liveMilestone,
|
||||
putMilestone,
|
||||
routeImageKey,
|
||||
siteInfo,
|
||||
segmentPath,
|
||||
startPoint,
|
||||
touchingSegments,
|
||||
uploadImage,
|
||||
} from "./B08_Quantity_UI_Milestone_State";
|
||||
import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap";
|
||||
|
||||
let menu: MapContextMenu | null = null;
|
||||
let lastCell: Element | null = null;
|
||||
|
||||
/** 표 칸 누름 자리 기억 — 「▾」 메뉴를 그 칸 아래에 엶 */
|
||||
export function trackCells(root: HTMLElement): void {
|
||||
root.addEventListener("click", (e) => (lastCell = (e.target as Element).closest("td")), true);
|
||||
for (const type of ["click", "contextmenu"])
|
||||
root.addEventListener(type, (e) => (lastCell = (e.target as Element).closest("td")), true);
|
||||
}
|
||||
|
||||
export function pickAtCell(items: MapContextMenuItem[]): void {
|
||||
@@ -70,131 +74,152 @@ export interface MilestoneView {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/** 새 창 길찾기 지점 — 거래처 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */
|
||||
export function routePoints(
|
||||
d: MilestoneData,
|
||||
r: MilestoneRoute,
|
||||
): Array<{ name: string; lon: number; lat: number }> | null {
|
||||
const site = siteInfo();
|
||||
const dest = r.rows[0]?.dest ?? "start";
|
||||
const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat;
|
||||
if (r.lon == null || r.lat == null || !to) return null;
|
||||
const points = [{ name: r.name || "거래처", lon: r.lon, lat: r.lat }];
|
||||
if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null)
|
||||
points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat });
|
||||
points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] });
|
||||
return points;
|
||||
}
|
||||
|
||||
export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
const title = el("h4");
|
||||
const HINT =
|
||||
"경로 그림 = [길찾기] 때 서버가 지도에 길 선을 그려 자동으로 만듦 · [그림 올리기] = 내 그림 우선";
|
||||
const hint = el("p", { className: "b08-options__hint", text: HINT });
|
||||
hint.dataset.b08MilestoneRouteHint = "";
|
||||
// 157 머리 줄 = 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기
|
||||
const name = el("strong", { className: "b08-milestone__route-name" });
|
||||
const address = el("span", { className: "b08-milestone__route-addr" });
|
||||
|
||||
// 그림 — 경로 하나 = 그림 하나
|
||||
// 몸 = 사용자 그림 있으면 그 그림 · 없으면 길찾기 지도(연결선 모달과 같은 지도 · 표지 끌어 소재지 옮김)
|
||||
const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } });
|
||||
const noImage = el("p", { className: "ui-general-block__empty", text: "길찾기 그림 없음" });
|
||||
let shown: string | null | undefined;
|
||||
const loadImage = (force = false): void => {
|
||||
const name = routeImageKey(routeId);
|
||||
if (!force && name === shown) return;
|
||||
shown = name;
|
||||
img.hidden = !name;
|
||||
noImage.hidden = !!name;
|
||||
const loadImage = (): void => {
|
||||
const key = hasUserImage(routeId) ? routeImageKey(routeId) : null;
|
||||
img.hidden = !key;
|
||||
if (key === shown) return;
|
||||
shown = key;
|
||||
if (img.src) URL.revokeObjectURL(img.src);
|
||||
img.removeAttribute("src");
|
||||
if (name)
|
||||
if (key)
|
||||
imageBlobUrl(projectId, routeId).then(
|
||||
(url) => (img.src = url),
|
||||
(error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"),
|
||||
);
|
||||
};
|
||||
const put = (picked: File): Promise<void> =>
|
||||
uploadImage(projectId, routeId, picked).then(
|
||||
() => loadImage(true),
|
||||
(error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"),
|
||||
);
|
||||
const file = el("input", { attrs: { type: "file", accept: "image/*" } });
|
||||
file.hidden = true;
|
||||
file.addEventListener("change", () => {
|
||||
const picked = file.files?.[0];
|
||||
file.value = "";
|
||||
if (picked) void put(picked);
|
||||
if (picked)
|
||||
void uploadImage(projectId, routeId, picked).then(
|
||||
() => refresh(),
|
||||
(error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"),
|
||||
);
|
||||
});
|
||||
|
||||
const kakao = createButton({
|
||||
label: "다음 지도에서 길찾기",
|
||||
size: "sm",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
const d = liveMilestone(projectId);
|
||||
const r = d.routes.find((x) => x.id === routeId);
|
||||
const points = r ? routePoints(d, r) : null;
|
||||
if (!points)
|
||||
return showToast(
|
||||
"거래처 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.",
|
||||
"info",
|
||||
);
|
||||
window.open(kakaoRouteUrl(points), "_blank", "noopener");
|
||||
},
|
||||
});
|
||||
kakao.dataset.b08MilestoneKakao = "";
|
||||
const upload = createButton({
|
||||
label: "그림 올리기",
|
||||
size: "sm",
|
||||
const change = createButton({
|
||||
label: "그림 변경",
|
||||
variant: "ghost",
|
||||
onClick: () => file.click(),
|
||||
});
|
||||
const revert = createButton({
|
||||
label: "자동 그림으로 되돌리기",
|
||||
size: "sm",
|
||||
change.dataset.b08MilestoneImageChange = "";
|
||||
// 초기화 = 사용자 그림만 지우고 길찾기 지도로 되돌림
|
||||
const reset = createButton({
|
||||
label: "초기화",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void deleteImage(projectId, routeId, true).catch((error: unknown) =>
|
||||
showToast(`자동 그림으로 되돌리지 못했습니다 — ${String(error)}`, "error"),
|
||||
),
|
||||
});
|
||||
revert.dataset.b08MilestoneRevert = "";
|
||||
const removeImage = createButton({
|
||||
label: "그림 지우기",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void deleteImage(projectId, routeId).catch((error: unknown) =>
|
||||
showToast(`그림을 지우지 못했습니다 — ${String(error)}`, "error"),
|
||||
void deleteImage(projectId, routeId, true).then(
|
||||
() => refresh(),
|
||||
(error: unknown) => showToast(`초기화하지 못했습니다 — ${String(error)}`, "error"),
|
||||
),
|
||||
});
|
||||
reset.dataset.b08MilestoneRevert = "";
|
||||
|
||||
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: true }); // 255-2 시점도 끌기(길찾기 덮개)
|
||||
};
|
||||
if (!r) return out;
|
||||
add(r.id, r.name || "거래처", r.lon, r.lat);
|
||||
if (r.linked === false) return out;
|
||||
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 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 });
|
||||
const label = fresh.bases.some((b) => b.id === key) ? "기준점" : "거래처";
|
||||
void capturePlace(projectId, key, { name: target.name || label, lon, lat }); // 256-1 장소 그림
|
||||
}
|
||||
putMilestone(projectId, fresh);
|
||||
await refind(touchingSegments(fresh, key));
|
||||
};
|
||||
const map = editMap(projectId, {
|
||||
hint: "",
|
||||
onMove: (key, lon, lat) => void moveTo(key, lon, lat),
|
||||
});
|
||||
const drawMap = (): void => map.set(pins(), lines());
|
||||
let asked = false; // 길 선이 없으면 열 때 한 번 길찾기(사슬 선 모두 · 그림 · 알림 없음)
|
||||
const ensurePath = (): void => {
|
||||
if (asked) return;
|
||||
asked = true;
|
||||
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 mapView = routeMapView(projectId, routeId);
|
||||
const root = el("section", {
|
||||
className: "b08-milestone__route ui-card",
|
||||
attrs: { "data-b08-milestone-route": routeId },
|
||||
children: [
|
||||
title,
|
||||
el("div", {
|
||||
className: "b08-milestone__bar",
|
||||
children: [kakao, upload, revert, removeImage, file],
|
||||
className: "b08-milestone__route-head",
|
||||
children: [
|
||||
el("div", { className: "b08-milestone__route-title", children: [name, address] }),
|
||||
el("div", { className: "b08-milestone__bar", children: [change, reset, file] }),
|
||||
],
|
||||
}),
|
||||
hint,
|
||||
mapView.root,
|
||||
noImage,
|
||||
status,
|
||||
img,
|
||||
map.root,
|
||||
],
|
||||
});
|
||||
|
||||
const refresh = (): void => {
|
||||
function refresh(): void {
|
||||
const d = liveMilestone(projectId);
|
||||
const r = d.routes.find((x) => x.id === routeId);
|
||||
if (!r) return;
|
||||
const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"];
|
||||
title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`;
|
||||
name.textContent = `${r.name || "거래처"} → ${DEST_TEXT[r.rows[0]?.dest ?? "start"]}`;
|
||||
address.textContent = r.address || "";
|
||||
loadImage();
|
||||
revert.hidden = !canRevertImage(routeId);
|
||||
hint.textContent = autoImageNote(routeId) ?? HINT;
|
||||
mapView.draw();
|
||||
};
|
||||
const user = hasUserImage(routeId);
|
||||
reset.hidden = !user;
|
||||
map.root.hidden = user;
|
||||
if (!user) {
|
||||
drawMap();
|
||||
ensurePath();
|
||||
}
|
||||
}
|
||||
refresh();
|
||||
return { root, refresh };
|
||||
}
|
||||
|
||||
@@ -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 { roadsFor, type MilestoneData } 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>();
|
||||
@@ -81,11 +105,10 @@ export function siteAddress(projectId: string, midM: number | null): SiteAddress
|
||||
}
|
||||
export const routeImages = (): Record<string, string> => saved?.images ?? {};
|
||||
|
||||
/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */
|
||||
export const canRevertImage = (routeId: string): boolean => {
|
||||
/** 사용자가 올린 그림이 있음(157 · 자동 그림과 다른 이름) — 경로 탭 = 그 그림 · 없으면 길찾기 지도 */
|
||||
export const hasUserImage = (routeId: string): boolean => {
|
||||
const name = saved?.images[routeId];
|
||||
const auto = saved?.auto_images?.[routeId];
|
||||
return !!name && !!auto && name !== auto;
|
||||
return !!name && name !== saved?.auto_images?.[routeId];
|
||||
};
|
||||
|
||||
/** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */
|
||||
@@ -95,16 +118,14 @@ export const routeImageKey = (routeId: string): string | null => {
|
||||
return name ? `${name}#${imageRev.get(routeId) ?? 0}` : null;
|
||||
};
|
||||
|
||||
/** 경로 그림 자동 상태 한 줄(만드는 중 · 실패 까닭) — 없으면 null */
|
||||
const autoNotes = new Map<string, string>();
|
||||
export const autoImageNote = (routeId: string): string | null => autoNotes.get(routeId) ?? 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),
|
||||
);
|
||||
}
|
||||
|
||||
/** 고친 한 벌을 캐시에 · 화면에 알림 */
|
||||
@@ -185,12 +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,
|
||||
);
|
||||
autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)");
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
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 }>(
|
||||
@@ -205,36 +225,60 @@ export async function autoRouteImage(
|
||||
imageRev.set(routeId, (imageRev.get(routeId) ?? 0) + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — 그림 올리기를 쓰세요.`;
|
||||
reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — [그림 변경]으로 넣으세요.`;
|
||||
}
|
||||
if (reason) {
|
||||
autoNotes.set(routeId, reason);
|
||||
showToast(reason, "warning");
|
||||
} else autoNotes.delete(routeId);
|
||||
if (reason) showToast(reason, "warning");
|
||||
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());
|
||||
}
|
||||
|
||||
/** 도로 구분 ▾ — via = 기준점 거쳐 시점 들어가는 구간(2차 · 1차 · 비포장) · 아니면 포장 · 비포장(244-13) ·
|
||||
* blank = 「도로 구분 고르기」 빈 값(자동 판정 없음 · 244-7) · 목록 밖 옛 값도 보임 */
|
||||
export function roadOptions(
|
||||
via: boolean,
|
||||
blank: boolean,
|
||||
current?: string | null,
|
||||
): Array<{ value: string; text: string }> {
|
||||
const roads: string[] = roadsFor(via);
|
||||
if (current && !roads.includes(current)) roads.push(current);
|
||||
return [
|
||||
...(blank ? [{ value: "", text: "도로 구분 고르기" }] : []),
|
||||
...roads.map((r) => ({ value: r, text: r })),
|
||||
];
|
||||
/** 도로 구분 ▾(152) — 그 자리 포장 종류 · 비포장 둘(포장 종류는 선 자리로 정함 · `roadOf`) */
|
||||
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) ?? [];
|
||||
/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */
|
||||
@@ -249,7 +293,9 @@ export async function findSegmentKm(
|
||||
image = true,
|
||||
): Promise<string> {
|
||||
const data = liveMilestone(projectId);
|
||||
const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null);
|
||||
const owner = ownerOf(data, from);
|
||||
if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함
|
||||
const seg = segmentEnds(data, from, startPoint(data));
|
||||
if (!seg) return "";
|
||||
if (!seg.from || !seg.to) {
|
||||
const missing = seg.text.split(" → ")[seg.from ? 1 : 0];
|
||||
@@ -271,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))
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { request } from "./B08_Quantity_UI_Hydro";
|
||||
@@ -85,14 +85,11 @@ export function onOptionsChange(host: HTMLElement, fn: () => void): void {
|
||||
|
||||
const pctText = (value: number): string => String(Math.round(value * 1e6) / 1e6);
|
||||
|
||||
/** 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식). */
|
||||
function card(id: string, title: keyof typeof ui_locales, ...body: HTMLElement[]): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
||||
const section = createPanelCard(L(title));
|
||||
section.dataset.b08Options = id;
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = L(title);
|
||||
section.append(heading, ...body);
|
||||
section.append(...body);
|
||||
return section;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,8 +82,9 @@
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* 칸과의 틈 = 공용 카드 틈(269) */
|
||||
.b08-options__hint {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Haul import bill_key, haul_groups
|
||||
from B08_Quantity.B08_Quantity_Material_Calc import _structure_row
|
||||
|
||||
WHY_EARTH = "조합 밖 토공 산근 — 연결 없음"
|
||||
WHY_READY = "준비공 대가 — 연결 없음"
|
||||
WHY_HAUL = "운반 산근 — 연결 없음(운반거리 원천 없음)"
|
||||
WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)"
|
||||
WHY_STRUCTURE = "대가 호표 — 연결 없음(일위대가 145-2)"
|
||||
|
||||
#: 토공 항목 — (공종, 엑셀 명칭, 단위, 토공집계표 (구분, 종류, 규격 | None) | None)
|
||||
@@ -46,16 +48,6 @@ EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [
|
||||
STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3"}
|
||||
SKIP_COLUMNS = ("pipe:cp|",)
|
||||
|
||||
#: 운반공 — (명칭, 단위, B08 총괄자재 줄 키(합) | None)
|
||||
HAUL: list[tuple[str, str, tuple[str, ...] | None]] = [
|
||||
("중기운반", "대", None),
|
||||
("기계운반", "대", None),
|
||||
("모래운반", "㎥", ("sand",)),
|
||||
("자갈운반", "㎥", ("gravel",)),
|
||||
("시멘트운반", "대", ("cement",)),
|
||||
("철근운반", "ton", ("rebar_d13", "rebar_d16", "rebar_d19", "rebar_d22", "rebar_d25")),
|
||||
]
|
||||
|
||||
|
||||
def _same(row: dict[str, Any], want: tuple[str, str, str | None]) -> bool:
|
||||
group, kind, spec = want
|
||||
@@ -113,28 +105,51 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def haul_items(materials: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
rows = ((materials or {}).get("material") or {}).get("rows") or []
|
||||
amount = {row.get("key"): row.get("amount") for row in rows}
|
||||
def haul_items(
|
||||
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 그대로) +
|
||||
거리이정표 행(253-1 · `B08_Quantity_Haul.haul_rows` · 행 하나 = 줄 하나 · 둘째 행부터 수량 빈칸) ·
|
||||
이름 = 거리이정표 항목명 그대로(261-4 · 「운반」 안 붙임 · 비면 거래처 · 그것도 없으면 묶음 이름 · 「운반」) ·
|
||||
연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김) ·
|
||||
`dist{L1, L2}` = 그 행 거리(253-3 「운반 단가」 모달 · 없으면 None)."""
|
||||
rows = routes or []
|
||||
by_key = {r["key"]: r for r in rows}
|
||||
extra: dict[str, list[dict[str, Any]]] = {} # 자동 상자 둘째 행부터 — 묶음 줄 바로 뒤
|
||||
for r in rows:
|
||||
if r.get("haul") and r["key"] != bill_key(str(r["haul"])):
|
||||
extra.setdefault(str(r["haul"]), []).append(r)
|
||||
|
||||
def line(key: str, name: str, unit: str, qty: Any, note: str) -> dict[str, Any]:
|
||||
ref = (refs or {}).get(key)
|
||||
route = by_key.get(key) or {}
|
||||
return {
|
||||
"key": key,
|
||||
"section": "3",
|
||||
"name": name,
|
||||
"spec": "",
|
||||
"unit": unit,
|
||||
"qty": qty,
|
||||
"note": note,
|
||||
"ref": ref,
|
||||
"dist": {"L1": route["L1"], "L2": route["L2"]} if route else None,
|
||||
"why": WHY_LOGIC if (ref or {}).get("tab") == "logic" else WHY_HAUL,
|
||||
}
|
||||
|
||||
out = []
|
||||
for n, (name, unit, keys) in enumerate(HAUL, 1):
|
||||
qty = None
|
||||
if keys:
|
||||
found = [amount[k] for k in keys if amount.get(k) is not None]
|
||||
qty = round(sum(found), 6) if found else None
|
||||
for g in haul_groups(materials):
|
||||
key = bill_key(g["key"])
|
||||
name = (by_key.get(key) or {}).get("name") or g["name"]
|
||||
out.append(
|
||||
{
|
||||
"key": f"haul:{n}",
|
||||
"section": "3",
|
||||
"name": name,
|
||||
"spec": "",
|
||||
"unit": unit,
|
||||
"qty": qty,
|
||||
"note": "B08 총괄자재" if qty is not None else "",
|
||||
"ref": None,
|
||||
"why": WHY_HAUL,
|
||||
}
|
||||
line(key, name, g["unit"], g["qty"], "B08 총괄자재" if g["qty"] is not None else "")
|
||||
)
|
||||
for r in extra.get(g["key"], []):
|
||||
out.append(line(r["key"], r.get("name") or g["name"], "", None, "B08 거리이정표"))
|
||||
for r in rows:
|
||||
if r.get("user"):
|
||||
out.append(line(r["key"], r.get("name") or "운반", "", None, "B08 거리이정표"))
|
||||
return out
|
||||
|
||||
|
||||
@@ -143,7 +158,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")),
|
||||
*haul_items(b08.get("materials"), b08.get("haul_refs"), b08.get("haul_routes")),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -10,19 +10,43 @@
|
||||
대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) ·
|
||||
단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} ·
|
||||
[저장] 때 줄이 안 쓰는 사본은 지움(`prune`) · 층 읽기 권한 = 부르는 쪽(`M02_Layer_Access.grant`).
|
||||
|
||||
운반 값 사본(263-2) — 운반공 줄 연결
|
||||
`{tab: dg, key, lib, given{줄 id: {입력: 값}}, own[줄 id|입력]}` ·
|
||||
담은 로직 줄(id = `{참조 id}#{차례}` · 맨 위 줄 = `#{차례}`)의 거리 칸
|
||||
(운반거리 · Cm · 사이클시간 · t2) =
|
||||
거리이정표 거리로 셈(짝 `B08_Quantity_Haul_Cycle`) · 그 밖 = 손 값 → 값 묶음 `값` ·
|
||||
폴더 = `{종류}@{층}@{주인}@{키}~{값 해시 8}`(값 없으면 해시 없음 · 옛 이름 그대로 읽힘) ·
|
||||
같은 항목 같은 값 = 같은 사본 ·
|
||||
manifest `원값` = 덮기 전 값(다른 값 사본을 이 사본에서 다시 만듦) ·
|
||||
단가 묶음 = 로직 · 자재 키로만 모음 → 값이 바뀌어도 그대로 ·
|
||||
셈 때(`resolve_hauls`) 서버가 거리로 값을 다시 셈 → 다른 해시면 새 사본 ·
|
||||
줄 연결 lib · key 바꿔 셈.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Haul_Cycle import FILL, cycle_inputs, cycle_time
|
||||
from B09_Estimation.B09_Estimation_Store import folder as store_folder
|
||||
from B09_Estimation.B09_Estimation_Store import ref_rows
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, build_snapshot, now_text, origin_of
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
MANIFEST_NAME,
|
||||
VALUES_FILE,
|
||||
build_snapshot,
|
||||
now_text,
|
||||
origin_of,
|
||||
seed_values,
|
||||
)
|
||||
from M02_MasterTemplete.M02_Item_Source import (
|
||||
LABOR_FILE,
|
||||
PRICE_FILE,
|
||||
@@ -34,17 +58,28 @@ from M02_MasterTemplete.M02_Item_Source import (
|
||||
|
||||
LIBRARY = "library"
|
||||
KINDS = ("combo", "precombo")
|
||||
#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`)
|
||||
NAME = re.compile(r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}$")
|
||||
#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`) · 값 해시(263-2 · 없을 수 있음)
|
||||
NAME = re.compile(
|
||||
r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$"
|
||||
)
|
||||
|
||||
|
||||
def library_dir(project_root: str | Path) -> Path:
|
||||
return store_folder(project_root) / LIBRARY
|
||||
|
||||
|
||||
def folder_name(kind: str, layer: str, owner: str | None, key: str) -> str:
|
||||
"""고른 서고 것 → 사본 폴더 이름 — 규칙 밖이면 ValueError."""
|
||||
def tag(values: dict[str, Any]) -> str:
|
||||
"""값 묶음 해시 8 — 같은 값 = 같은 표지."""
|
||||
text = json.dumps(values, sort_keys=True, ensure_ascii=False)
|
||||
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
def folder_name(
|
||||
kind: str, layer: str, owner: str | None, key: str, values: dict[str, Any] | None = None
|
||||
) -> str:
|
||||
"""고른 서고 것 → 사본 폴더 이름(값 있으면 `~해시`) — 규칙 밖이면 ValueError."""
|
||||
name = f"{kind}@{layer}@{(owner or '-').replace('/', '-')}@{key}"
|
||||
name += f"~{tag(values)}" if values else ""
|
||||
if not NAME.match(name):
|
||||
raise ValueError(f"서고 항목 모양이 틀림 「{name}」")
|
||||
return name
|
||||
@@ -64,6 +99,130 @@ def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, dict]:
|
||||
"""서고 원본 → (사본 파일들, 원본 표지)."""
|
||||
if kind not in KINDS:
|
||||
raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나")
|
||||
folder_name(kind, layer, owner, key) # 모양 먼저(원본 읽기 전)
|
||||
if kind == "combo":
|
||||
item = {"layer": layer, "owner": owner, "key": key}
|
||||
item["name"] = item_name(item)
|
||||
return build_snapshot(item), origin_of(item)
|
||||
files = _precombo_files(layer, owner, key)
|
||||
return files, files.pop("_원본")
|
||||
|
||||
|
||||
# ── 운반 값 사본(263-2) ────────────────────────────────────────────────
|
||||
def _logic(code: str) -> dict[str, Any]:
|
||||
try:
|
||||
return dict(m01.loaded()[1].logic(code) or {})
|
||||
except Exception: # noqa: BLE001 — 없는 로직 = 입력 없음
|
||||
return {}
|
||||
|
||||
|
||||
def _line_at(labor: Any, lid: str) -> dict[str, Any] | None:
|
||||
rid, _, at = lid.rpartition("#")
|
||||
doc = (labor or {}).get("줄") if not rid else ((labor or {}).get("참조사본") or {}).get(rid)
|
||||
lines = doc.get("담은로직") if isinstance(doc, dict) else None
|
||||
if not at.isdigit() or not isinstance(lines, list) or int(at) >= len(lines):
|
||||
return None
|
||||
line = lines[int(at)]
|
||||
return line if isinstance(line, dict) and line.get("로직") else None
|
||||
|
||||
|
||||
def lines_of(files: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""사본 안 로직 줄 `(줄 id, 줄)` — 맨 위 줄 → 굳힌 참조 문서 줄."""
|
||||
labor = files.get(LABOR_FILE) or {}
|
||||
docs = [("", labor.get("줄")), *((labor.get("참조사본") or {}).items())]
|
||||
return [
|
||||
(f"{rid}#{i}", one)
|
||||
for rid, doc in docs
|
||||
for i, one in enumerate((doc or {}).get("담은로직") or [])
|
||||
if isinstance(one, dict) and one.get("로직")
|
||||
]
|
||||
|
||||
|
||||
def haul_values(
|
||||
lines: list[tuple[str, dict[str, Any]]], given: Any, own: Any, dist: Any
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""값 묶음 `{줄 id: {입력: 값}}` — 거리 칸 = 거리로 셈(`own` 이면 손 값) · 그 밖 = 손 값."""
|
||||
given = given if isinstance(given, dict) else {}
|
||||
own = set(own if isinstance(own, list) else [])
|
||||
got = cycle_time(cycle_inputs(None, dist)) if isinstance(dist, dict) else None
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for lid, line in lines:
|
||||
hand = given.get(lid) if isinstance(given.get(lid), dict) else {}
|
||||
vals = {}
|
||||
for name in (str(i.get("이름")) for i in _logic(str(line["로직"])).get("입력") or []):
|
||||
if got and name in FILL and f"{lid}|{name}" not in own:
|
||||
vals[name] = got[FILL[name]]
|
||||
elif isinstance(hand.get(name), (str, int, float)):
|
||||
vals[name] = hand[name]
|
||||
if vals:
|
||||
out[lid] = vals
|
||||
return out
|
||||
|
||||
|
||||
def _apply(files: dict[str, Any], kind: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""값 덮기 → 덮기 전 값(`원값` · 없던 칸 = None)."""
|
||||
labor = files.get(LABOR_FILE)
|
||||
old: dict[str, Any] = {}
|
||||
for lid, vals in values.items():
|
||||
if (line := _line_at(labor, lid)) is None:
|
||||
continue
|
||||
given = dict(line.get("입력값") or {})
|
||||
old[lid] = {k: given.get(k) for k in vals}
|
||||
line["입력값"] = {**given, **vals}
|
||||
if kind == "combo" and old:
|
||||
files[VALUES_FILE] = seed_values(labor)
|
||||
return old
|
||||
|
||||
|
||||
def _restore(files: dict[str, Any], kind: str, old: Any) -> None:
|
||||
"""`원값` 되돌림 — 덮기 전 사본 내용."""
|
||||
labor = files.get(LABOR_FILE)
|
||||
for lid, vals in (old if isinstance(old, dict) else {}).items():
|
||||
if (line := _line_at(labor, lid)) is None:
|
||||
continue
|
||||
given = dict(line.get("입력값") or {})
|
||||
for k, v in vals.items():
|
||||
if v is None:
|
||||
given.pop(k, None)
|
||||
else:
|
||||
given[k] = v
|
||||
line["입력값"] = given
|
||||
if kind == "combo" and old:
|
||||
files[VALUES_FILE] = seed_values(labor)
|
||||
|
||||
|
||||
def _write(project_root: str | Path, name: str, files: dict, manifest: dict, values: dict) -> None:
|
||||
target = library_dir(project_root) / name
|
||||
old = _apply(files, str(manifest.get("종류")), values)
|
||||
if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리
|
||||
shutil.rmtree(target)
|
||||
target.mkdir(parents=True)
|
||||
for file, content in files.items():
|
||||
if isinstance(content, bytes):
|
||||
(target / file).write_bytes(content)
|
||||
else:
|
||||
atomic_write_json(target / file, content)
|
||||
extra = {"값": values, "원값": old} if values else {}
|
||||
atomic_write_json(target / MANIFEST_NAME, {**manifest, **extra}) # 마지막 — 있으면 다 쓴 폴더
|
||||
|
||||
|
||||
def _read_copy(folder: Path) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||||
"""다 쓴 사본 → (원값 되돌린 파일들, manifest(값 · 원값 뺌)) — 없으면 None."""
|
||||
manifest = read_json(folder / MANIFEST_NAME)
|
||||
if not NAME.match(folder.name) or not isinstance(manifest, dict):
|
||||
return None
|
||||
files: dict[str, Any] = {}
|
||||
for path in sorted(folder.iterdir()):
|
||||
if path.is_file() and path.name != MANIFEST_NAME:
|
||||
files[path.name] = read_json(path) if path.suffix == ".json" else path.read_bytes()
|
||||
_restore(files, str(manifest.get("종류")), manifest.get("원값"))
|
||||
return files, {k: v for k, v in manifest.items() if k not in ("값", "원값")}
|
||||
|
||||
|
||||
def import_copy(
|
||||
project_root: str | Path,
|
||||
kind: str,
|
||||
@@ -71,33 +230,105 @@ def import_copy(
|
||||
owner: str | None,
|
||||
key: str,
|
||||
who: dict[str, Any] | None = None,
|
||||
haul: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""서고 것 하나를 프로젝트 사본으로 — `{폴더, 만듦}` · 이미 있으면 그대로(만듦 거짓) ·
|
||||
haul = 운반 값(263-2 · `{given, own, dist}`) ·
|
||||
원본이 없으면 LookupError · 모양이 틀리면 ValueError."""
|
||||
if kind not in KINDS:
|
||||
raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나")
|
||||
name = folder_name(kind, layer, owner, key)
|
||||
target = library_dir(project_root) / name
|
||||
if isinstance(read_json(target / MANIFEST_NAME), dict):
|
||||
haul = haul or {}
|
||||
lib = haul.get("lib") # 있는 사본에서(굳힌 내용 · 같은 항목만)
|
||||
if isinstance(lib, str) and lib.split("~")[0] == folder_name(kind, layer, owner, key):
|
||||
if name := resolve(project_root, {**haul, "lib": lib}, haul.get("dist")):
|
||||
return {"폴더": name, "만듦": False}
|
||||
files, origin = _build(kind, layer, owner, key)
|
||||
values = haul_values(lines_of(files), haul.get("given"), haul.get("own"), haul.get("dist"))
|
||||
name = folder_name(kind, layer, owner, key, values)
|
||||
if isinstance(read_json(library_dir(project_root) / name / MANIFEST_NAME), dict):
|
||||
return {"폴더": name, "만듦": False}
|
||||
if kind == "combo":
|
||||
item = {"layer": layer, "owner": owner, "key": key}
|
||||
item["name"] = item_name(item)
|
||||
files, origin = build_snapshot(item), origin_of(item)
|
||||
else:
|
||||
files = _precombo_files(layer, owner, key)
|
||||
origin = files.pop("_원본")
|
||||
if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리
|
||||
shutil.rmtree(target)
|
||||
target.mkdir(parents=True)
|
||||
for file, content in files.items():
|
||||
atomic_write_json(target / file, content)
|
||||
stamp = now_text()
|
||||
manifest = {"종류": kind, "원본": origin, "가져온날": stamp, "가져온이": who}
|
||||
atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더
|
||||
manifest = {"종류": kind, "원본": origin, "가져온날": now_text(), "가져온이": who}
|
||||
_write(project_root, name, files, manifest, values)
|
||||
return {"폴더": name, "만듦": True}
|
||||
|
||||
|
||||
def library_inputs(
|
||||
project_root: str | Path, kind: str, layer: str, owner: str | None, key: str, lib: Any = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""담은 로직 입력 · 값(263-2 · 읽기만) — 있는 사본(`lib`)이면 그 덮기 전 값 ·
|
||||
없으면 서고 원본."""
|
||||
got = None
|
||||
if isinstance(lib, str) and NAME.match(lib):
|
||||
got = _read_copy(library_dir(project_root) / lib)
|
||||
files = got[0] if got else _build(kind, layer, owner, key)[0]
|
||||
out = []
|
||||
for lid, line in lines_of(files):
|
||||
logic = _logic(str(line["로직"]))
|
||||
out.append(
|
||||
{
|
||||
"id": lid,
|
||||
"로직": line["로직"],
|
||||
"이름": logic.get("이름") or line["로직"],
|
||||
"입력": logic.get("입력") or [],
|
||||
"값": line.get("입력값") or {},
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def resolve(project_root: str | Path, ref: dict[str, Any], dist: Any) -> str | None:
|
||||
"""운반 연결 사본 → 지금 거리 · 손 값의 사본 폴더(없으면 그 사본에서 새로 만듦) ·
|
||||
그 사본이 지워졌으면(prune 뒤 옛 연결) 같은 바탕 사본 → 서고 원본에서 · 못 하면 None."""
|
||||
lib = str(ref.get("lib") or "")
|
||||
if not NAME.match(lib):
|
||||
return None
|
||||
base = lib.split("~")[0]
|
||||
got = _read_copy(library_dir(project_root) / lib)
|
||||
if got is None: # 같은 바탕 사본
|
||||
same = [f for f, _m in copies(project_root) if f.name.split("~")[0] == base]
|
||||
got = _read_copy(same[0]) if same else None
|
||||
if got is None:
|
||||
kind, layer, owner, key = base.split("@")
|
||||
owner = None if owner == "-" else owner.replace("-", "/")
|
||||
try:
|
||||
got = _build(kind, layer, owner, key)
|
||||
except (LookupError, ValueError):
|
||||
return None
|
||||
got = (got[0], {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None})
|
||||
files, manifest = got
|
||||
values = haul_values(lines_of(files), ref.get("given"), ref.get("own"), dist)
|
||||
name = base + (f"~{tag(values)}" if values else "")
|
||||
target = library_dir(project_root) / name / MANIFEST_NAME
|
||||
if not isinstance(read_json(target), dict):
|
||||
_write(project_root, name, files, manifest, values)
|
||||
return name
|
||||
|
||||
|
||||
def resolve_hauls(project_root: str | Path, user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""셈 사용자 값 — 운반공 줄 서고 사본 연결(덮개 줄 · 거리이정표 연결)을 지금 거리의 사본으로."""
|
||||
dist = user.get("haul_dist") or {}
|
||||
|
||||
def fix(ref: Any, key: Any) -> Any:
|
||||
if key not in dist or not (isinstance(ref, dict) and ref.get("tab") == "dg"):
|
||||
return ref
|
||||
name = resolve(project_root, ref, dist[key]) if ref.get("lib") else None
|
||||
return {**ref, "lib": name} if name and name != ref["lib"] else ref
|
||||
|
||||
bill = user.get("bill") if isinstance(user.get("bill"), dict) else {}
|
||||
rows = [
|
||||
{**r, "ref": fix(r["ref"], r.get("key"))} if isinstance(r, dict) and "ref" in r else r
|
||||
for r in bill.get("rows") or []
|
||||
]
|
||||
hauls = {k: fix(ref, k) for k, ref in (user.get("haul_refs") or {}).items()}
|
||||
return {**user, "bill": {**bill, "rows": rows}, "haul_refs": hauls}
|
||||
|
||||
|
||||
def fix_keys(user: dict[str, Any], libs: dict[str, str]) -> None:
|
||||
"""사본 폴더 → 대가 블록 id — 운반 연결 key 를 바뀐 사본 블록으로(제자리)."""
|
||||
for row in ref_rows(user):
|
||||
ref = row.get("ref")
|
||||
if isinstance(ref, dict) and ref.get("tab") == "dg" and ref.get("lib") in libs:
|
||||
ref["key"] = libs[ref["lib"]]
|
||||
|
||||
|
||||
def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
|
||||
"""다 쓴 사본 `(폴더, manifest)` — 폴더 이름 차례."""
|
||||
base = library_dir(project_root)
|
||||
@@ -110,15 +341,11 @@ def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
|
||||
|
||||
|
||||
def used_folders(user: dict[str, Any]) -> set[str]:
|
||||
"""설계내역서 덮개 줄이 잇는 서고 사본 폴더."""
|
||||
rows = ((user or {}).get("bill") or {}).get("rows") or []
|
||||
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더."""
|
||||
return {
|
||||
str(ref["lib"])
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
and isinstance(ref := row.get("ref"), dict)
|
||||
and ref.get("tab") == "dg"
|
||||
and ref.get("lib")
|
||||
for row in ref_rows(user)
|
||||
if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -416,7 +416,7 @@ def compute_bill(
|
||||
if price_of is not None and item.get("owners"):
|
||||
parts = split_by_block(item, price_of)
|
||||
else:
|
||||
parts = [(item, ref_price(item.get("ref"), books, hopyo))]
|
||||
parts = [(item, ref_price(item.get("ref"), books, hopyo, item["key"]))]
|
||||
for part, prices in parts:
|
||||
items.append(_item_row(*_covered(part, prices, cover.get(part["key"]), books, hopyo)))
|
||||
for item in _material_items(inputs, base["compare"]):
|
||||
@@ -490,11 +490,10 @@ def compute_bill(
|
||||
gaps.extend(row.pop("_gaps", []))
|
||||
row.pop("_home", None)
|
||||
row["no"] = ""
|
||||
if row["kind"] == "title":
|
||||
empty = f" 빈 줄 {row['blank']}" if row["blank"] else ""
|
||||
row["note"] = ("(제외금액)" if row["key"] == "sec:G" else "") + empty
|
||||
if row["kind"] == "title": # 257-2 비고 「빈 줄 n」 없음(blank 수는 줄 값 그대로)
|
||||
row["note"] = "(제외금액)" if row["key"] == "sec:G" else ""
|
||||
elif row["kind"] == "group":
|
||||
row["note"] = f"빈 줄 {row['blank']}" if row["blank"] else ""
|
||||
row["note"] = ""
|
||||
if row["kind"] != "item" and row["cost"] == "direct" and row["level"] <= 2:
|
||||
up = row["parent"] # 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
|
||||
seq[up] = seq.get(up, 0) + 1
|
||||
|
||||
@@ -386,7 +386,7 @@ export function computeBill(
|
||||
const parts: Array<[Row, Row | null]> =
|
||||
priceOf !== null && item.owners && Object.keys(item.owners).length
|
||||
? splitByBlock(item, priceOf)
|
||||
: [[item, refPrice(item.ref ?? null, book, hopyo)]];
|
||||
: [[item, refPrice(item.ref ?? null, book, hopyo, item.key)]];
|
||||
for (const [part, prices] of parts)
|
||||
items.push(itemRow(...covered(part, prices, cover[part.key], book, hopyo)));
|
||||
}
|
||||
@@ -458,10 +458,9 @@ export function computeBill(
|
||||
delete row._gaps;
|
||||
delete row._home;
|
||||
row.no = "";
|
||||
if (row.kind === "title")
|
||||
row.note =
|
||||
(row.key === "sec:G" ? "(제외금액)" : "") + (row.blank ? ` 빈 줄 ${row.blank}` : "");
|
||||
else if (row.kind === "group") row.note = row.blank ? `빈 줄 ${row.blank}` : "";
|
||||
// 257-2 비고 「빈 줄 n」 없음(blank 수는 줄 값 그대로)
|
||||
if (row.kind === "title") row.note = row.key === "sec:G" ? "(제외금액)" : "";
|
||||
else if (row.kind === "group") row.note = "";
|
||||
if (row.kind !== "item" && row.cost === "direct" && row.level <= 2) {
|
||||
// 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
|
||||
const up = row.parent;
|
||||
|
||||
@@ -6,10 +6,18 @@
|
||||
[저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈
|
||||
POST /api/projects/{id}/estimation/prices
|
||||
[단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘
|
||||
POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks}
|
||||
「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 ·
|
||||
저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로)
|
||||
POST /api/projects/{id}/estimation/library {kind, layer, owner, key}
|
||||
설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로
|
||||
(있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} ·
|
||||
층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`)
|
||||
층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 →
|
||||
[저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`) ·
|
||||
운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`)
|
||||
POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?}
|
||||
「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음)
|
||||
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
|
||||
|
||||
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
|
||||
단가 = `B09_Estimation_Prices` ·
|
||||
@@ -25,11 +33,15 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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
|
||||
@@ -48,6 +60,11 @@ from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import importer
|
||||
|
||||
try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이)
|
||||
from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft
|
||||
except ImportError: # pragma: no cover
|
||||
schedule_draft = None
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
|
||||
|
||||
|
||||
@@ -103,10 +120,36 @@ def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> t
|
||||
return sheets, compute_summary(sheets, hopyo_lines(sheets))
|
||||
|
||||
|
||||
def haul_routes(root: Path) -> list[dict[str, Any]]:
|
||||
"""거리이정표 행 → 운반공 줄(253-1 · `haul_rows` · 거리 L1 · L2 포함) — 못 읽으면 빈 목록."""
|
||||
try:
|
||||
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]:
|
||||
"""셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결 · 거리(화면엔 안 보냄) ·
|
||||
운반 사본 연결 = 지금 거리의 사본(263-2)."""
|
||||
user = {**store.read_user(root), **haul_user(haul_routes(root))}
|
||||
return library.resolve_hauls(root, user)
|
||||
|
||||
|
||||
async def _demand(root: Path) -> list[str]:
|
||||
"""단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2)."""
|
||||
"""단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2) +
|
||||
거리이정표 운반 연결(251-5)."""
|
||||
user = await asyncio.to_thread(calc_user, root) # 운반 값 사본 먼저(263-2)
|
||||
keys = await asyncio.to_thread(linked_resource_keys, root)
|
||||
user = await asyncio.to_thread(store.read_user, root)
|
||||
hand = await asyncio.to_thread(sangeun.hand_resource_keys, user)
|
||||
return [*keys, *(k for k in hand if k not in keys)]
|
||||
|
||||
@@ -121,11 +164,16 @@ 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)
|
||||
hauls = await asyncio.to_thread(haul_routes, root)
|
||||
user = await asyncio.to_thread(store.read_user, root)
|
||||
calc = await asyncio.to_thread(library.resolve_hauls, root, {**user, **haul_user(hauls)})
|
||||
inputs["bill"] = bill_items({**b08, **calc, "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)
|
||||
sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, user)
|
||||
library.fix_keys(calc, inputs["daega"].get("libs") or {}) # 263-2 바뀐 사본 = 그 블록
|
||||
sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc)
|
||||
if schedule_draft:
|
||||
schedule_draft.attach(inputs, sheets, user)
|
||||
return {
|
||||
"inputs": inputs,
|
||||
"user": user,
|
||||
@@ -151,7 +199,12 @@ 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"))
|
||||
await asyncio.to_thread(library.prune, root, user) # 204-2 줄이 안 잇는 서고 사본
|
||||
calc = await asyncio.to_thread(calc_user, root) # 263-2 운반 값 사본 = 지금 거리
|
||||
if (rows := calc["bill"].get("rows")) != ((user.get("bill") or {}).get("rows") or []):
|
||||
# 바뀐 사본 연결 = 저장 값에도(응답 user → 화면 캐시 · 다음 [저장]이 옛 lib 안 씀)
|
||||
bill = {**user["bill"], "rows": rows}
|
||||
await asyncio.to_thread(store.write_user, root, {**user, "bill": bill})
|
||||
await asyncio.to_thread(library.prune, root, calc) # 204-2 줄이 안 잇는 서고 사본
|
||||
keys = await _demand(root)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
book = await asyncio.to_thread(prices.extend_book, book, keys)
|
||||
@@ -168,11 +221,53 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]:
|
||||
return await payload(project_id, root)
|
||||
|
||||
|
||||
class PreviewBody(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=40)
|
||||
given: dict[str, Any] = Field(default_factory=dict)
|
||||
rate: float | None = None
|
||||
picks: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def logic_preview(root: Path, body: PreviewBody) -> dict[str, Any]:
|
||||
"""로직 한 산근 미리보기 — 단가 = 굳힌 책(없는 줄 = 지금 M01) · 사용자 대비표 고름."""
|
||||
src = {"code": body.code, "given": sangeun._clean_given(body.given), "rate": body.rate,
|
||||
"picks": body.picks} # fmt: skip
|
||||
hands = [("preview", "미리보기", src)]
|
||||
keys = sangeun.hand_resource_keys({"haul_refs": {"preview": {"tab": "logic", **src}}})
|
||||
user = store.read_user(root)
|
||||
base = compute_base(prices.build_inputs(store.read_book(root), keys), user)
|
||||
block = sangeun.Solver(base).add(sangeun.hand_whole(hands), src, None)
|
||||
text = [
|
||||
*block["head"],
|
||||
*(t for ln in block["lines"] for t in ln.get("text") or []),
|
||||
block["foot"],
|
||||
]
|
||||
return {
|
||||
"name": block["name"],
|
||||
"lines": [t for t in text if t],
|
||||
"unit": block["unit"],
|
||||
"price": block["sum"],
|
||||
"parts": {p: block[p] for p in sangeun.PARTS},
|
||||
"why": block["why"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/logic-preview")
|
||||
async def preview_logic(project_id: UUID, body: PreviewBody) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
return await asyncio.to_thread(logic_preview, root, body)
|
||||
|
||||
|
||||
class LibraryBody(BaseModel):
|
||||
kind: str
|
||||
layer: str
|
||||
owner: str | None = None
|
||||
key: str
|
||||
# 263-2 운반공 줄 — 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리 · 있는 사본
|
||||
given: dict[str, Any] | None = None
|
||||
own: list[str] | None = None
|
||||
dist: dict[str, Any] | None = None
|
||||
lib: str | None = None
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/library")
|
||||
@@ -183,7 +278,14 @@ async def import_library(
|
||||
root = await _project_root(project_id)
|
||||
try:
|
||||
made = await asyncio.to_thread(
|
||||
library.import_copy, root, body.kind, got.layer, got.owner, body.key, importer(session)
|
||||
library.import_copy,
|
||||
root,
|
||||
body.kind,
|
||||
got.layer,
|
||||
got.owner,
|
||||
body.key,
|
||||
importer(session),
|
||||
{"given": body.given, "own": body.own, "dist": body.dist, "lib": body.lib},
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
@@ -194,5 +296,22 @@ async def import_library(
|
||||
return {**made, "key": key, "payload": out}
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/library-inputs")
|
||||
async def library_inputs(
|
||||
project_id: UUID, body: LibraryBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
got = access.grant(session, body.layer, body.owner)
|
||||
root = await _project_root(project_id)
|
||||
try:
|
||||
lines = await asyncio.to_thread(
|
||||
library.library_inputs, root, body.kind, got.layer, got.owner, body.key, body.lib
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
return {"lines": lines}
|
||||
|
||||
|
||||
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
|
||||
router.include_router(quotes_router)
|
||||
|
||||
@@ -37,7 +37,9 @@ 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
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
@@ -474,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 = []
|
||||
for row in ((user or {}).get("bill") or {}).get("rows") or []:
|
||||
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 자동 줄 연결 덮개
|
||||
|
||||
@@ -30,6 +30,11 @@ from urllib.parse import unquote
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS
|
||||
|
||||
try: # 252 공기 초안(draft 폴더 · 지우면 공기 값 없이)
|
||||
from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule
|
||||
except ImportError: # pragma: no cover
|
||||
clean_schedule = None
|
||||
|
||||
FOLDER = ("B09_Estimation", "v1")
|
||||
BOOK = "price_book.json"
|
||||
USER = "user_values.json"
|
||||
@@ -94,6 +99,21 @@ def _text(value: Any) -> str:
|
||||
return str(value)[:200] if isinstance(value, (str, int, float)) else ""
|
||||
|
||||
|
||||
def ref_rows(user: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""연결 있는 줄 모음 — 설계내역서 덮개 줄 + 거리이정표 운반 연결
|
||||
(251-5 · `haul_refs{줄 키: ref}` · 서버 셈 때만 붙임) ·
|
||||
같은 줄 키에 사용자 연결 덮개가 있으면 그것만."""
|
||||
rows = [r for r in ((user or {}).get("bill") or {}).get("rows") or [] if isinstance(r, dict)]
|
||||
covered = {r.get("key") for r in rows if "ref" in r}
|
||||
haul = (user or {}).get("haul_refs") or {}
|
||||
extra = [
|
||||
{"key": key, "name": "", "ref": ref}
|
||||
for key, ref in haul.items()
|
||||
if key not in covered and isinstance(ref, dict)
|
||||
]
|
||||
return [*rows, *extra]
|
||||
|
||||
|
||||
def _clean_ref(ref: Any) -> dict[str, Any] | None:
|
||||
"""손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름) ·
|
||||
dg `lib` = 서고 사본 폴더(204-2 · [저장] 때 안 쓰는 사본 지움)."""
|
||||
@@ -102,13 +122,29 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None:
|
||||
tab = ref.get("tab")
|
||||
if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]:
|
||||
lib = ref.get("lib") if tab == "dg" else None
|
||||
return {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})}
|
||||
out = {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})}
|
||||
given, own = ref.get("given"), ref.get("own") # 263-2 운반 값 사본 손 값 · 덮은 칸
|
||||
if isinstance(lib, str) and isinstance(given, dict):
|
||||
out["given"] = {
|
||||
str(lid): {str(k): v for k, v in vals.items() if isinstance(v, (str, int, float))}
|
||||
for lid, vals in given.items()
|
||||
if isinstance(vals, dict)
|
||||
}
|
||||
if isinstance(lib, str) and isinstance(own, list):
|
||||
out["own"] = [str(x)[:200] for x in own if isinstance(x, str)][:200]
|
||||
return out
|
||||
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
|
||||
|
||||
|
||||
@@ -218,7 +254,10 @@ def clean_user(raw: Any) -> dict[str, Any]:
|
||||
if isinstance(rates, dict):
|
||||
estimate["rates"] = {str(k): _num(v) for k, v in rates.items()}
|
||||
bill = clean_bill(raw.get("bill"))
|
||||
return {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})}
|
||||
out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})}
|
||||
if clean_schedule and (schedule := clean_schedule(raw.get("schedule"))):
|
||||
out["schedule"] = schedule
|
||||
return out
|
||||
|
||||
|
||||
def read_user(project_root: str | Path) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Haul_Modal.ts
|
||||
* 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결.
|
||||
* 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) ·
|
||||
* 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음 ·
|
||||
* 사전조합 · 구조물 조합 = 담은 로직 이름별 입력 `fetchLibInputs` · 263-2) ·
|
||||
* 오른 = 계산 절차 — 거리 줄 · 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle` · 8-2-8 기본값 · 거리 칸 있을 때만) +
|
||||
* 로직 산출 줄(서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만).
|
||||
* 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) ·
|
||||
* 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값).
|
||||
* 조합 [적용] = 거리 · 손 값 사본(서버 · 같은 값 = 같은 사본) · 연결 {tab dg, key, lib, given, own}.
|
||||
* 연결 없는 줄도 같은 모달(빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본).
|
||||
* 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
CYCLE_DEFAULTS,
|
||||
CYCLE_FILL,
|
||||
cycleInputs,
|
||||
cycleTime,
|
||||
fillGiven,
|
||||
type Dist,
|
||||
} from "../B08_Quantity/B08_Quantity_Haul_Cycle";
|
||||
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
fetchLibInputs,
|
||||
importLib,
|
||||
openPriceLink,
|
||||
parseLib,
|
||||
type LibLine,
|
||||
type LibPick,
|
||||
type Picked,
|
||||
type PriceLinkOpts,
|
||||
} from "./B09_Estimation_UI_Tabs_Bill_Link";
|
||||
|
||||
export interface HaulModalOpts extends PriceLinkOpts {
|
||||
/** 지금 연결(없으면 null) */
|
||||
ref: Row | null;
|
||||
/** 그 행 거리 — L1 포장 계 · L2 비포장(km) */
|
||||
dist: Dist;
|
||||
/** 줄 이름(제목) */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 옛 사이클 손 칸(253-3) — 새 모달은 받지 않음 */
|
||||
const OLD_OWN = new Set(["L1", "L2", "t1"]);
|
||||
const FROM_DIST = "거리에서";
|
||||
|
||||
const fmt = (v: number | null | undefined): string =>
|
||||
v == null || !Number.isFinite(v) ? "—" : String(v);
|
||||
|
||||
/** 계산 절차 — 사이클 식 줄(거창 단가산출 꼴) */
|
||||
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);
|
||||
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 = 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`,
|
||||
];
|
||||
}
|
||||
|
||||
interface Preview {
|
||||
lines: string[];
|
||||
unit: string;
|
||||
price: number | null;
|
||||
why: string | null;
|
||||
}
|
||||
|
||||
async function preview(code: string, given: Row, rate: unknown, picks: unknown): Promise<Preview> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) throw new Error("프로젝트 없음");
|
||||
const res = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/logic-preview`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, given, rate: rate ?? null, picks: picks ?? null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as Preview;
|
||||
}
|
||||
|
||||
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 libRef = o.ref?.tab === "dg" && o.ref.lib ? parseLib(String(o.ref.lib)) : null;
|
||||
const st = {
|
||||
/** 고른 것 — 단가산출 아닌 것(조합 · 호표) */
|
||||
other: (libRef
|
||||
? { ...libRef, name: String(o.ref?.name ?? ""), unit: "" }
|
||||
: o.ref && !logicRef
|
||||
? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } }
|
||||
: null) as Picked | null,
|
||||
/** 조합 담은 로직 줄(263-2) · 손 값 {줄 id: {입력: 값}} · 덮은 칸 `줄 id|입력` */
|
||||
lines: [] as LibLine[],
|
||||
libGiven: structuredClone((libRef && (o.ref?.given as Row)) || {}) as Record<string, Row>,
|
||||
libOwn: new Set<string>((libRef && (o.ref?.own as string[])) || []),
|
||||
code: String(logicRef?.code ?? ""),
|
||||
name: String(logicRef?.name ?? ""),
|
||||
given: { ...((logicRef?.given as Row) || {}) } as Row,
|
||||
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({
|
||||
title: `운반 단가 — ${o.name}`,
|
||||
closeLabel: "닫기",
|
||||
wide: true,
|
||||
dialogClass: "b09-haul-modal",
|
||||
mount: (body, close, head) => {
|
||||
const title = el("strong");
|
||||
const linkBox = el("div", {
|
||||
attrs: { style: "display:flex;gap:8px;align-items:center;margin-bottom:8px" },
|
||||
});
|
||||
const left = el("div", { attrs: { style: "overflow:auto;min-width:0" } });
|
||||
const steps = el("pre", {
|
||||
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] });
|
||||
// 사이클 = 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 isLib = (p: Picked | null): p is LibPick & { name: string; unit: string } =>
|
||||
p?.kind === "combo" || p?.kind === "precombo";
|
||||
/** 거리 칸 값(거리 없으면 undefined · 서버 짝 `Bill_Library.haul_values`) */
|
||||
const fromDist = (name: string): unknown =>
|
||||
o.dist ? fillGiven({ given: {}, own: [], cycle: {} }, [name], o.dist)[name] : undefined;
|
||||
|
||||
const redraw = (): void => {
|
||||
const fills =
|
||||
(st.code !== "" && names().some((n) => n in CYCLE_FILL)) ||
|
||||
(isLib(st.other) &&
|
||||
!!o.dist &&
|
||||
st.lines.some((l) => l.입력.some((i) => i.이름 in CYCLE_FILL)));
|
||||
const input = cycleInputs(ref(), o.dist);
|
||||
const head2 = fills
|
||||
? [
|
||||
`거리 — 포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km`,
|
||||
...cycleLines(input),
|
||||
"",
|
||||
]
|
||||
: [];
|
||||
window.clearTimeout(timer);
|
||||
const my = ++seq;
|
||||
if (isLib(st.other)) {
|
||||
steps.textContent = [
|
||||
...head2,
|
||||
`${KIND_TEXT[st.other.kind]} — [적용]하면 거리 · 손 값 사본(같은 값 = 같은 사본)`,
|
||||
"단가 = [저장] 뒤 서버 셈",
|
||||
].join("\n");
|
||||
return;
|
||||
}
|
||||
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");
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (my === seq) steps.textContent = [...head2, `못 셈 — ${String(error)}`].join("\n");
|
||||
},
|
||||
);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const row = (text: string, cells: HTMLElement[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
children: [
|
||||
el("span", { className: "m02-inputval__name", text }),
|
||||
el("div", { className: "m02-inputval__cell", children: cells }),
|
||||
],
|
||||
});
|
||||
const shows: Array<() => void> = [];
|
||||
|
||||
/** 입력 칸 하나 — auto = 거리에서 채울 값(아니면 undefined) · set(null = 비움) */
|
||||
const valueRow = (
|
||||
input: ItemInput,
|
||||
auto: () => unknown,
|
||||
hand: () => unknown,
|
||||
set: (value: unknown) => void,
|
||||
): HTMLElement => {
|
||||
const now = auto();
|
||||
const field = createValueControl(
|
||||
input,
|
||||
((now === undefined ? hand() : now) as string | number | null) ?? null,
|
||||
(next, bad) => {
|
||||
if (bad) return;
|
||||
const empty = next === null || next === "";
|
||||
const num = typeof next === "string" && Number.isFinite(Number(next));
|
||||
set(empty ? null : num ? Number(next) : next);
|
||||
update();
|
||||
},
|
||||
);
|
||||
field.control.setAttribute("data-labor-input", input.이름);
|
||||
const tag = el("span", { className: "m02-labin__muted" });
|
||||
shows.push(() => {
|
||||
const value = auto();
|
||||
tag.textContent = value === undefined ? "" : FROM_DIST;
|
||||
if (value !== undefined && document.activeElement !== field.control)
|
||||
field.control.value = String(value ?? "");
|
||||
});
|
||||
return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]);
|
||||
};
|
||||
const logicRows = (): HTMLElement[] =>
|
||||
st.inputs.map((input) => {
|
||||
const name = input.이름;
|
||||
const fill = name in CYCLE_FILL;
|
||||
return valueRow(
|
||||
input,
|
||||
() => (fill && !st.own.has(name) ? filled()[name] : undefined),
|
||||
() => st.given[name],
|
||||
(value) => {
|
||||
if (value === null) delete st.given[name];
|
||||
else st.given[name] = value;
|
||||
if (fill && value !== null) st.own.add(name);
|
||||
else st.own.delete(name);
|
||||
},
|
||||
);
|
||||
});
|
||||
/** 조합 = 담은 로직 이름별 입력(263-2) */
|
||||
const libRows = (): HTMLElement[] =>
|
||||
st.lines.flatMap((line) => [
|
||||
el("h5", { text: `${line.이름} (${line.로직})`, attrs: { style: "margin:8px 0 2px" } }),
|
||||
...line.입력.map((input) => {
|
||||
const name = input.이름;
|
||||
const id = `${line.id}|${name}`;
|
||||
const fill = name in CYCLE_FILL;
|
||||
return valueRow(
|
||||
input,
|
||||
() => (fill && !st.libOwn.has(id) ? fromDist(name) : undefined),
|
||||
() => st.libGiven[line.id]?.[name] ?? line.값[name],
|
||||
(value) => {
|
||||
const mine = (st.libGiven[line.id] ??= {});
|
||||
if (value === null) delete mine[name];
|
||||
else mine[name] = value;
|
||||
if (fill && value !== null) st.libOwn.add(id);
|
||||
else st.libOwn.delete(id);
|
||||
},
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
/** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */
|
||||
const update = (): 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 lines = st.code
|
||||
? st.inputs.length
|
||||
? logicRows()
|
||||
: [muted("입력 없음")]
|
||||
: isLib(st.other)
|
||||
? st.lines.length
|
||||
? libRows()
|
||||
: [muted("담은 로직 입력 없음")]
|
||||
: [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")];
|
||||
left.replaceChildren(
|
||||
el("h4", { text: "옵션 값", attrs: { style: "margin:12px 0 4px" } }),
|
||||
...lines,
|
||||
...(oldCycle && st.code
|
||||
? [muted("옛 사이클 손 값 — 셈은 8-2-8 기본값 · [적용]하면 지움")]
|
||||
: []),
|
||||
);
|
||||
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]?.입력 ?? [];
|
||||
const keep = new Set(st.inputs.map((i) => i.이름));
|
||||
for (const k of Object.keys(st.given)) if (!keep.has(k)) delete st.given[k];
|
||||
refresh();
|
||||
},
|
||||
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
|
||||
);
|
||||
};
|
||||
/** 조합 담은 로직 입력 읽고 왼쪽 새로 */
|
||||
const useLib = (p: LibPick): void => {
|
||||
fetchLibInputs(p).then(
|
||||
(lines) => {
|
||||
st.lines = lines;
|
||||
refresh();
|
||||
},
|
||||
(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 = [];
|
||||
st.lines = [];
|
||||
st.libGiven = {};
|
||||
st.libOwn.clear();
|
||||
if (isLib(p)) useLib(p);
|
||||
refresh();
|
||||
};
|
||||
const change = createButton({
|
||||
label: "변경",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void openPriceLink({ ...o, onPick: usePicked }),
|
||||
});
|
||||
linkBox.append(title, change);
|
||||
const ok = createButton({
|
||||
label: "적용",
|
||||
onClick: () => {
|
||||
const p = st.other;
|
||||
if (!st.code && !p) return showToast("연결할 것을 먼저 고르세요", "error");
|
||||
if (isLib(p)) {
|
||||
const haul = { given: st.libGiven, own: [...st.libOwn], dist: o.dist };
|
||||
const { kind, layer, owner, key, lib } = p;
|
||||
void importLib(o, { kind, layer, owner, key, lib, haul });
|
||||
} 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();
|
||||
},
|
||||
});
|
||||
head.append(ok);
|
||||
body.append(
|
||||
linkBox,
|
||||
el("div", {
|
||||
attrs: {
|
||||
style:
|
||||
"display:grid;grid-template-columns:minmax(320px,1fr) minmax(360px,1.4fr);gap:16px;min-height:60vh",
|
||||
},
|
||||
children: [left, right],
|
||||
}),
|
||||
);
|
||||
if (st.code) useLogic(st.code, st.name);
|
||||
else {
|
||||
if (libRef) useLib(libRef);
|
||||
refresh();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -30,9 +30,9 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */
|
||||
/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈(257-1 머리 글 안 줄바꿈 글자도 살림) */
|
||||
[data-b09-table] thead th {
|
||||
white-space: normal;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -130,12 +130,33 @@
|
||||
--pick-r: inset -2px 0 0 var(--color-danger);
|
||||
}
|
||||
|
||||
/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) */
|
||||
/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) · 257-3 초록 테두리
|
||||
* (바탕 색 아님 — 입력 칸 테두리(outline)와 달리 안쪽 상자 그림자) */
|
||||
.b09-estimation .ui-data-table__table tbody td.is-picked {
|
||||
background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas));
|
||||
box-shadow: inset 0 0 0 2px var(--color-success);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 257-1 설계내역서 단가연결 칸 = 램프(B08 구조물집계표 램프 꼴 · 글 숨김 · 풍선 = 글) — 노랑 = 없음 · 초록 = 연결 */
|
||||
.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp {
|
||||
font-size: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp.is-linked::before {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
/* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */
|
||||
.b09-estimation .ui-data-table__table tbody td.is-edited {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
@@ -158,9 +179,9 @@
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
/* 122 데이터 점검 [업데이트] — 제목 아래 틈 = 카드 왼쪽 여유와 같게 */
|
||||
/* 122 데이터 점검 [업데이트] — 제목 아래 틈 = 카드 왼쪽 여유와 같게(공용 카드 틈 8 + 4) */
|
||||
.b09-options [data-b09-refresh] {
|
||||
margin-top: calc(var(--spacing-8) + var(--spacing-4));
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { cellColumn, createDataTable, type DataTable } from "@ui/ui_template_data_table";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -34,6 +34,7 @@ import { createTabBars } from "./B09_Estimation_UI_TabBar";
|
||||
import { createCheckPanel } from "./B09_Estimation_UI_Check";
|
||||
import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor";
|
||||
import {
|
||||
DRAFT,
|
||||
rowIndex,
|
||||
TABS,
|
||||
type Link,
|
||||
@@ -61,7 +62,16 @@ import {
|
||||
} from "./B09_Estimation_UI_Tabs_Summary";
|
||||
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
|
||||
import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun";
|
||||
import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
|
||||
import {
|
||||
isGroupTab,
|
||||
lineHint,
|
||||
pickClass,
|
||||
renderGroupPanel,
|
||||
unpickOnRelease,
|
||||
} from "./B09_Estimation_UI_Tabs_Groups";
|
||||
|
||||
// 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음)
|
||||
import.meta.glob("./draft/*_UI.ts", { eager: true });
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -136,7 +146,7 @@ export function computeSheets(payload: Row, user: Row): Row {
|
||||
books,
|
||||
);
|
||||
const { gaps: daegaGaps, ...daega } = labor;
|
||||
return {
|
||||
const out: Row = {
|
||||
...base,
|
||||
...kept,
|
||||
...daega,
|
||||
@@ -144,6 +154,8 @@ export function computeSheets(payload: Row, user: Row): Row {
|
||||
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 요율(사용자 ?? 자동 166-1) · 손입력
|
||||
total: computeTotal(base.bill?.sums, user.estimate, payload.rate_table),
|
||||
};
|
||||
for (const extend of DRAFT.extend) extend(payload, user, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderB09Estimation(root: HTMLElement): void {
|
||||
@@ -186,7 +198,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1)
|
||||
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
|
||||
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
|
||||
const build = BUILDERS[tab];
|
||||
const build = BUILDERS[tab] ?? DRAFT.builders[tab];
|
||||
if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움"));
|
||||
const ctx: TabContext = { sheets, payload, user, setUser };
|
||||
const spec = build(ctx);
|
||||
@@ -234,6 +246,17 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const box = cell.getBoundingClientRect();
|
||||
openMenuAt(box.left, box.bottom, items);
|
||||
});
|
||||
// 257-3 칸 우클릭 메뉴 — 누른 칸 자리에 B09 공용 메뉴(줄 메뉴가 없는 표)
|
||||
if (spec.cellMenu)
|
||||
made.root.addEventListener("contextmenu", (event) => {
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
|
||||
const column = cell ? spec.columns[cellColumn(cell)] : undefined;
|
||||
const items =
|
||||
cell && column && spec.cellMenu?.(Number(cell.parentElement?.dataset.i), column.key);
|
||||
if (!items?.length) return;
|
||||
event.preventDefault();
|
||||
openMenuAt(event.clientX, event.clientY, items);
|
||||
});
|
||||
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 공용 호버 정보(185-3)
|
||||
if (spec.tip)
|
||||
attachHoverInfo(
|
||||
@@ -268,13 +291,17 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
|
||||
const at = focus === undefined ? -1 : rowIndex(spec.rows, focus);
|
||||
if (bill) jumpTo(bill.rows, at);
|
||||
// 257-2 설계내역서 밖 탭도 이동해 온 줄 = 같은 빨강 상자(그 줄 하나 · 줄 누름 · Esc 로 풂)
|
||||
const range: [number, number] | null = !bill && at >= 0 ? [at, at] : null;
|
||||
const rangeClass = range ? (i: number) => pickClass(range, i) : undefined;
|
||||
const table = makeTable(
|
||||
bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined,
|
||||
bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : rangeClass,
|
||||
);
|
||||
if (range) unpickOnRelease(table.root);
|
||||
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
|
||||
if (level) bars.setTools(level.tools);
|
||||
panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []));
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, !bill)); // 설계내역서 = 스크롤만(표시 = 빨강)
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
|
||||
};
|
||||
|
||||
const apply = (next: Row, fromServer: boolean): void => {
|
||||
@@ -332,18 +359,15 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
}),
|
||||
});
|
||||
reset.dataset.b09BillReset = "";
|
||||
const section = document.createElement("section");
|
||||
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = "데이터 점검";
|
||||
section.append(heading, refresh, check.list);
|
||||
// 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식)
|
||||
const section = createPanelCard("데이터 점검");
|
||||
section.append(refresh, check.list);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(save, reset);
|
||||
const left = document.createElement("div");
|
||||
left.className = "ui-sidebar-panel b09-options";
|
||||
left.append(section, actions);
|
||||
left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions);
|
||||
attachCollapsible(left);
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
|
||||
@@ -59,8 +59,7 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1]
|
||||
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
|
||||
export const TAB_GROUPS: Array<[string, TabId[]]> = [
|
||||
["공사원가계산서", ["cost"]],
|
||||
["총괄설계내역서", ["total"]],
|
||||
["설계내역서", ["bill"]],
|
||||
["설계내역", ["total", "bill"]],
|
||||
["일위대가", ["dg_list", "dg"]],
|
||||
["단가산출", ["sg_list", "sg"]],
|
||||
["환율및기초자료", ["basis"]],
|
||||
@@ -112,6 +111,8 @@ export interface TabTable {
|
||||
cellClass?: (index: number, key: string) => string | null | undefined;
|
||||
/** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */
|
||||
choices?: (index: number, key: string) => MapContextMenuItem[] | null;
|
||||
/** 칸 우클릭 메뉴(257-3 · 줄 메뉴가 없는 표 · 빈 것 = 안 엶) */
|
||||
cellMenu?: (index: number, key: string) => MapContextMenuItem[] | null;
|
||||
/** 레벨(201-1 · 공용 표 level · 보기 전용) — 그 열 들여쓰기 */
|
||||
level?: DataTableOptions["level"];
|
||||
/** 같은 값 세로 병합할 열(201-1 · 공용 표 mergeSame) */
|
||||
@@ -120,6 +121,15 @@ export interface TabTable {
|
||||
|
||||
export type TabBuild = (ctx: TabContext) => TabTable | string;
|
||||
|
||||
/** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) —
|
||||
* builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) ·
|
||||
* panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) */
|
||||
export const DRAFT: {
|
||||
builders: Record<string, TabBuild>;
|
||||
extend: Array<(payload: Row, user: Row, sheets: Row) => void>;
|
||||
panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>;
|
||||
} = { builders: {}, extend: [], panels: [] };
|
||||
|
||||
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
|
||||
export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({
|
||||
key,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`)
|
||||
*
|
||||
* 자재단가대비표 적용 단가 칸 누름 = 고르기 메뉴(183-3 · 값 있는 출처 · 견적 · 「최저(자동)」 →
|
||||
* `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) ·
|
||||
* `pick` · 옛 수기 `applied` 지움 · 257-3 우클릭 「적용 단가 바꾸기」 = 같은 목록) ·
|
||||
* 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처 · 초록 테두리) ·
|
||||
* 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력
|
||||
* · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
|
||||
* 견적서 글자 = 견적 단가 수기 줄만(185-5 · 나머지 빈칸 · 빈칸 누름 = 안 엶)
|
||||
@@ -177,6 +178,14 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
else delete materials[key];
|
||||
setUser({ ...user, materials });
|
||||
};
|
||||
const chooseApplied = (r: Row): MapContextMenuItem[] =>
|
||||
appliedChoices(r, (s) => {
|
||||
const cell = mine(r.key);
|
||||
delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂
|
||||
if (s) cell.pick = s;
|
||||
else delete cell.pick;
|
||||
put(r.key, cell);
|
||||
});
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "번호", 0),
|
||||
@@ -215,18 +224,15 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
else cell.quote = v;
|
||||
put(String(row.key), cell);
|
||||
},
|
||||
// 183-3 — 고른 출처(없으면 최저) 단가 칸 강조 · 적용 단가 칸 누름 = 고르기 메뉴
|
||||
// 183-3 — 고른 출처(없으면 최저) 단가 칸 강조(257-3 초록 테두리) · 적용 단가 칸 누름 = 고르기 메뉴
|
||||
cellClass: (i, key) => (key === `p:${rows[i]?.applied_from}` ? "is-picked" : null),
|
||||
choices: (i, key) => {
|
||||
choices: (i, key) => (key === "applied" && rows[i] ? chooseApplied(rows[i]) : null),
|
||||
// 257-3 우클릭 「적용 단가 바꾸기」 → 출처 목록(적용 단가 칸 · 고른 출처 단가 칸 · 최저가여도 바꿈)
|
||||
cellMenu: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (key !== "applied" || !r) return null;
|
||||
return appliedChoices(r, (s) => {
|
||||
const cell = mine(r.key);
|
||||
delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂
|
||||
if (s) cell.pick = s;
|
||||
else delete cell.pick;
|
||||
put(r.key, cell);
|
||||
});
|
||||
if (!r || (key !== "applied" && key !== `p:${r.applied_from}`)) return null;
|
||||
const children = chooseApplied(r).filter((x): x is [string, () => void] => Array.isArray(x));
|
||||
return [{ label: "적용 단가 바꾸기", children }];
|
||||
},
|
||||
onCell: (i, key) => {
|
||||
const row = out[i];
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
|
||||
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
|
||||
* 단가연결 칸(램프) = 항목 줄(자재 · 조달수수료 빼고) · 램프 누름 = 우클릭 [연결] 과 같은 메뉴
|
||||
* 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) ·
|
||||
* 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력
|
||||
* ========================================================================== */
|
||||
@@ -34,10 +34,12 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const bill: Row = sheets.bill || { rows: [], totals: null };
|
||||
// 손질 · 메뉴 = 셈 줄 그대로(raw) · 표 = 번호 붙인 이름(no = 레벨 1 · 2 묶음 차례)
|
||||
const raw: DataRow[] = bill.rows;
|
||||
// 257-1 연결 칸 = 램프(글은 CSS 가 숨김 · 풍선 = 글) — 연결 = 호표 줄(ref) 또는 손 줄 비고(대가N)
|
||||
const linkText = (r: Row): string => (!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결");
|
||||
const rows: DataRow[] = raw.map((r: Row) => ({
|
||||
...r,
|
||||
name: r.no ? `${r.no} ${r.name}` : r.name,
|
||||
connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "연결"} ▾` : "",
|
||||
connect: linkable(r) ? `${linkText(r)} ▾` : "",
|
||||
}));
|
||||
const direct: Row = bill.totals?.direct || {};
|
||||
const pair = (key: string, label: string) => [
|
||||
@@ -57,14 +59,14 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
...pair("labor", "노무비"),
|
||||
...pair("material", "재료비"),
|
||||
...pair("expense", "경비"),
|
||||
text("connect", "연결", 96),
|
||||
{ ...text("connect", "단가\n연결", 44), title: "단가연결" },
|
||||
text("note", "비고", 200),
|
||||
],
|
||||
rows,
|
||||
total: { name: "순공사비 계(관급 제외)", ...direct },
|
||||
frozen: 1,
|
||||
hint:
|
||||
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" +
|
||||
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" +
|
||||
(direct.blank
|
||||
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`
|
||||
: ""),
|
||||
@@ -88,9 +90,21 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
if (r.key !== "fee") return editCell(ctx, raw, i, key, v);
|
||||
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
|
||||
},
|
||||
cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null),
|
||||
cellClass: (i, key) => {
|
||||
const r = rows[i];
|
||||
const lamp =
|
||||
key === "connect" && r && linkable(raw[i])
|
||||
? `b09-bill__lamp${linkText(raw[i]) === "단가연결" ? "" : " is-linked"}`
|
||||
: "";
|
||||
return [lamp, edited(r, key) ? "is-edited" : ""].filter(Boolean).join(" ") || null;
|
||||
},
|
||||
tip: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (r && key === "connect" && linkable(raw[i])) {
|
||||
const auto = (r.auto_ref as Row | null)?.key ?? "빈칸";
|
||||
const back = edited(r, key) ? ` — 자동 값 ${auto} — 우클릭 [자동값으로 되돌리기]` : "";
|
||||
return linkText(raw[i]) + back;
|
||||
}
|
||||
if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)";
|
||||
if (!r || !edited(r, key)) return null;
|
||||
const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`];
|
||||
|
||||
@@ -225,7 +225,13 @@ function linkFor(ctx: TabContext, rows: DataRow[], i: number) {
|
||||
const row = rows[i];
|
||||
const e = entryOf(freeze(ctx.user, rows), String(row.key));
|
||||
const ref: Row | null = e && "ref" in e ? e.ref : (row.ref ?? null);
|
||||
return linkItems(ctx, ref, applyRef(ctx, rows, i));
|
||||
const key = String(row.key);
|
||||
// 253-3 운반공 줄 = 「운반 단가」 모달(거리 = 서버가 준 항목 `dist`)
|
||||
const item = key.startsWith("haul:")
|
||||
? ((ctx.payload.inputs?.bill?.items as Row[]) || []).find((it) => it.key === key)
|
||||
: undefined;
|
||||
const haul = item && { dist: item.dist ?? null, name: String(row.name ?? item.name ?? "") };
|
||||
return linkItems(ctx, ref, applyRef(ctx, rows, i), haul);
|
||||
}
|
||||
|
||||
/** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */
|
||||
@@ -252,8 +258,8 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
|
||||
else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent));
|
||||
});
|
||||
if (linkable(row)) {
|
||||
const [picks, more] = linkFor(ctx, rows, i);
|
||||
parts[1].push({ label: "연결", children: picks }, ...more);
|
||||
const [picks, more] = linkFor(ctx, rows, i); // 251-6 단가연결 = 모달 하나(옛 하위 메뉴 다섯)
|
||||
parts[1].push(...picks, ...more);
|
||||
}
|
||||
// 옮김 · 레벨 — 같은 부모 형제와 자손째
|
||||
if (!fixed(key)) {
|
||||
@@ -355,7 +361,8 @@ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void
|
||||
const box = lastCell?.getBoundingClientRect();
|
||||
if (!shared || !box || !linkable(rows[i])) return;
|
||||
const [picks, more] = linkFor(ctx, rows, i);
|
||||
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
|
||||
if (!more.length) return picks[0][1](); // 251-6 아직 연결 없음 = 단가연결 모달 바로
|
||||
shared.open(box.left, box.bottom, [...picks, "-", ...more]);
|
||||
}
|
||||
|
||||
/** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새
|
||||
* 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 ·
|
||||
* 연결 풀기.
|
||||
* 251 단가연결 = 한 모달(`openPriceLink` · 종류 고르기 + 검색) — B09 설계내역서 · B08 거리이정표 같이 씀 ·
|
||||
* 단가산출 = 옵션 값 창(`askInputs` · 160)
|
||||
*
|
||||
* 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름)
|
||||
* 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 ·
|
||||
@@ -12,59 +14,28 @@
|
||||
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { openItemPicker } from "@ui/ui_template_item_picker";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { loadRows } from "@ui/ui_template_item_picker";
|
||||
import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data";
|
||||
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
|
||||
import type { Dist } from "../B08_Quantity/B08_Quantity_Haul_Cycle";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { openHaulModal } from "./B09_Estimation_UI_Haul_Modal";
|
||||
import type { TabContext } from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
/** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */
|
||||
export type ApplyRef = (ref: Row | null, from?: Row) => void;
|
||||
export type Leaf = [string, () => void];
|
||||
|
||||
/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */
|
||||
function pickHopyo(ctx: TabContext, apply: ApplyRef, tab: "dg" | "sg"): void {
|
||||
const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || [];
|
||||
if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error");
|
||||
const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`);
|
||||
openModal({
|
||||
title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기",
|
||||
closeLabel: "닫기",
|
||||
mount: (body, close) => {
|
||||
const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" });
|
||||
const find = (): void => {
|
||||
const q = finder.search.value.trim().toLowerCase();
|
||||
const found = listing.filter(
|
||||
(r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q),
|
||||
);
|
||||
finder.hint.textContent = `${found.length} / ${listing.length}`;
|
||||
finder.setRows(
|
||||
found.map((r) => ({
|
||||
key: String(r.key),
|
||||
tag: label(r),
|
||||
name: String(r.name || ""),
|
||||
sub: String(r.spec || ""),
|
||||
unit: String(r.unit || ""),
|
||||
onPick: () => {
|
||||
close();
|
||||
apply({ tab, key: r.key }, r);
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
finder.search.addEventListener("input", find);
|
||||
find();
|
||||
body.append(finder.root);
|
||||
requestAnimationFrame(() => finder.search.focus());
|
||||
},
|
||||
});
|
||||
}
|
||||
const LAYER_TEXT: Record<PickerLayer, string> = {
|
||||
system: "마스터",
|
||||
company: "회사",
|
||||
personal: "개인",
|
||||
};
|
||||
|
||||
/** M01 로직 → 입력값 창 → 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
|
||||
function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void {
|
||||
export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void {
|
||||
fetchLogicInputs([code]).then(
|
||||
(got) => {
|
||||
const logic = got.로직[0] ?? null;
|
||||
@@ -91,33 +62,78 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void
|
||||
);
|
||||
}
|
||||
|
||||
/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) ·
|
||||
* lib = 있는 사본 폴더 · haul = 운반 값(263-2 · 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리) */
|
||||
export type LibPick = {
|
||||
kind: "combo" | "precombo";
|
||||
layer: string;
|
||||
owner: string | null;
|
||||
key: string;
|
||||
lib?: string;
|
||||
haul?: { given: Row; own: string[]; dist: Dist };
|
||||
};
|
||||
/** 담은 로직 줄 입력 · 값(263-2 · `POST …/estimation/library-inputs`) */
|
||||
export interface LibLine {
|
||||
id: string;
|
||||
로직: string;
|
||||
이름: string;
|
||||
입력: ItemInput[];
|
||||
값: Row;
|
||||
}
|
||||
|
||||
/** 사본 폴더 이름 → 서고 항목(`종류@층@주인@키~해시` · 짝 `Bill_Library.folder_name`) */
|
||||
export function parseLib(lib: string): LibPick | null {
|
||||
const [kind, layer, owner, key] = lib.split("~")[0].split("@");
|
||||
if ((kind !== "combo" && kind !== "precombo") || !key) return null;
|
||||
return { kind, layer, owner: owner === "-" ? null : owner.replace("-", "/"), key, lib };
|
||||
}
|
||||
|
||||
async function postLib(path: string, body: Row): Promise<Row> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) throw new Error("프로젝트 없음");
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const got = (await response.json().catch(() => null)) as Row | null;
|
||||
if (!response.ok || !got) {
|
||||
const detail = got?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 서고 항목 담은 로직 입력 · 값(있는 사본이면 그 덮기 전 값) */
|
||||
export async function fetchLibInputs(lib: LibPick): Promise<LibLine[]> {
|
||||
const { kind, layer, owner, key } = lib;
|
||||
const got = await postLib("library-inputs", { kind, layer, owner, key, lib: lib.lib ?? null });
|
||||
return (got.lines as LibLine[]) || [];
|
||||
}
|
||||
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(
|
||||
ctx: TabContext,
|
||||
apply: ApplyRef,
|
||||
pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string },
|
||||
export async function importLib(
|
||||
o: { apply: ApplyRef; onPayload?: (payload: Row) => void },
|
||||
lib: LibPick,
|
||||
): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) return;
|
||||
const apply = o.apply;
|
||||
const { kind, layer, owner, key } = lib;
|
||||
const pick = { kind, layer, owner, key, ...(lib.lib && { lib: lib.lib }), ...lib.haul };
|
||||
showToast("가져오는 중…", "info");
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pick),
|
||||
});
|
||||
const got = (await response.json().catch(() => null)) as Row | null;
|
||||
if (!response.ok || !got) {
|
||||
const detail = got?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
const got = await postLib("library", pick);
|
||||
if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦");
|
||||
const { user: _user, ...rest } = got.payload as Row;
|
||||
Object.assign(ctx.payload, rest);
|
||||
o.onPayload?.(rest);
|
||||
const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key);
|
||||
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
|
||||
const hand = lib.haul ? { given: lib.haul.given, own: lib.haul.own } : {};
|
||||
apply({ tab: "dg", key: got.key, lib: got.폴더, ...hand }, from);
|
||||
showToast(
|
||||
got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "가져온 호표로 연결함",
|
||||
"success",
|
||||
@@ -127,39 +143,95 @@ async function importLib(
|
||||
}
|
||||
}
|
||||
|
||||
/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */
|
||||
function pickCombo(ctx: TabContext, apply: ApplyRef): void {
|
||||
void openItemPicker({ current: null, canCopy: false }).then((got) => {
|
||||
if (got.action !== "apply") return;
|
||||
const { layer, owner, key } = got.item;
|
||||
void importLib(ctx, apply, { kind: "combo", layer, owner, key });
|
||||
});
|
||||
/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 ·
|
||||
* haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 「운반 단가」 모달([변경] = 같은 목록 · 263-1) */
|
||||
export function linkItems(
|
||||
ctx: TabContext,
|
||||
ref: Row | null,
|
||||
apply: ApplyRef,
|
||||
haul?: { dist: Dist; name: string },
|
||||
): [Leaf[], Leaf[]] {
|
||||
const more: Leaf[] = [];
|
||||
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({ ...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)]);
|
||||
return [[["단가연결", haul ? haulModal : priceLink]], more];
|
||||
}
|
||||
|
||||
/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */
|
||||
function pickPrecombo(ctx: TabContext, apply: ApplyRef): void {
|
||||
openLogicPicker(() => undefined, {
|
||||
/** 단가연결 모달(251 · 159 · 160) — 한 목록에 단가산출 로직 · 일위대가 사전조합 · 구조물 조합(마스터 · 회사 ·
|
||||
* 개인 층 단추) · 호표(B09 · 있을 때) · 종류 고르기 + 검색 + 구분 · 상세구분 거름 ·
|
||||
* 단가산출 = 옵션 값 창(`askInputs` · 160) · 사전조합 · 구조물 조합 = 프로젝트 사본 들여옴 → `{tab: dg, key, lib}` */
|
||||
export interface PriceLinkOpts {
|
||||
apply: ApplyRef;
|
||||
/** B09 호표 목록(일위대가 · 단가산출) — 없으면 그 종류 없음(B08) */
|
||||
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,
|
||||
key: String(r.key),
|
||||
tag: tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`,
|
||||
name: String(r.name || ""),
|
||||
sub: String(r.spec || ""),
|
||||
unit: String(r.unit || ""),
|
||||
onPick: () => pick({ kind: tab, ref: { tab, key: r.key }, from: r }),
|
||||
}));
|
||||
// 구조물 조합 = 마스터 · 회사 · 개인 한 목록(B08 [선택] 나무와 같은 원천)
|
||||
const combos = (await loadRows().catch(() => [])).map((r) => ({
|
||||
kind: "combo",
|
||||
key: `${r.layer}:${r.owner ?? ""}:${r.key}`,
|
||||
tag: "구조물 조합",
|
||||
name: itemName(r),
|
||||
sub: `${LAYER_TEXT[r.layer ?? "system"]} · ${r.key}`,
|
||||
unit: String(r.단위 || ""),
|
||||
onPick: () =>
|
||||
pick({
|
||||
kind: "combo",
|
||||
layer: r.layer ?? "system",
|
||||
owner: r.owner ?? null,
|
||||
key: r.key,
|
||||
name: itemName(r),
|
||||
unit: String(r.단위 || ""),
|
||||
}),
|
||||
}));
|
||||
openLogicPicker((logic) => pick({ kind: "logic", code: logic.키, name: logic.이름 }), {
|
||||
layer: "personal",
|
||||
owner: null,
|
||||
libOnly: true,
|
||||
title: "일위대가 사전조합 고르기",
|
||||
pick: (ref) =>
|
||||
void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }),
|
||||
title: "단가연결",
|
||||
extra: [...combos, ...hop("dg"), ...hop("sg")],
|
||||
kindFilter: [
|
||||
{ value: "logic", text: "단가산출" },
|
||||
{ value: "precombo", text: "일위대가 사전조합" },
|
||||
{ value: "combo", text: "구조물 조합" },
|
||||
...(o.hopyo
|
||||
? [
|
||||
{ value: "dg", text: "일위대가 호표" },
|
||||
{ value: "sg", text: "단가산출 호표" },
|
||||
]
|
||||
: []),
|
||||
],
|
||||
pick: (ref, name) =>
|
||||
pick({ kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키, name, unit: "" }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */
|
||||
export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] {
|
||||
const more: Leaf[] = [];
|
||||
if (ref?.tab === "logic")
|
||||
more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]);
|
||||
if (ref) more.push(["연결 풀기", () => apply(null)]);
|
||||
const picks: Leaf[] = [
|
||||
["일위대가 호표", () => pickHopyo(ctx, apply, "dg")],
|
||||
["단가산출 호표", () => pickHopyo(ctx, apply, "sg")],
|
||||
["구조물 조합", () => pickCombo(ctx, apply)],
|
||||
["일위대가 사전조합", () => pickPrecombo(ctx, apply)],
|
||||
["단가산출", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름))],
|
||||
];
|
||||
return [picks, more];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user