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

This commit is contained in:
2026-10-11 21:31:39 +09:00
229 changed files with 14204 additions and 1821 deletions
+2
View File
@@ -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 사용자 확정 「출력물 전체를 공유해도 됨」).
+2
View File
@@ -110,6 +110,8 @@ export const STATE_REGISTRY = {
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
"structure-notes": { bucket: "draft", scope: "route" },
/** B06 횡단 [상세] 모달 표 위 CAD 칸(260-1) — 측점키(0.01 m) → CAD 엔티티 묶음. [저장]이 내보냄. */
"structure-note-cads": { bucket: "draft", scope: "route" },
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
"detail-table": { bucket: "draft", scope: "route" },
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
+15 -7
View File
@@ -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();
};
// 더블클릭 원복.
+3 -2
View File
@@ -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 };
+10 -9
View File
@@ -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
+63 -15
View File
@@ -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;
};
}
+4 -3
View File
@@ -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 {
+10
View File
@@ -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);
+17 -10
View File
@@ -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;
+47 -16
View File
@@ -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 = []
+39
View File
@@ -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:
+90 -18
View File
@@ -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"])
)
+4 -7
View File
@@ -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):
+1 -1
View File
@@ -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 -5
View File
@@ -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,
+86 -39
View File
@@ -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).
+2 -40
View File
@@ -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 };
});
}
+4 -10
View File
@@ -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),
+14 -8
View File
@@ -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 });
+31 -56
View File
@@ -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);
}
+16
View File
@@ -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,
+15 -1
View File
@@ -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>[];
}
+2
View File
@@ -94,6 +94,8 @@ export const USER_TOUCHED_KEYS = [
"cut_slope_ratio_user",
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
// 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님.
"structure_note_cad",
// [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
"detail_table",
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
+17 -13
View File
@@ -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))
# 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면
+16 -3
View File
@@ -155,6 +155,8 @@ USER_TOUCHED_KEYS = (
"cut_slope_ratio_user",
# 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
# 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님.
"structure_note_cad",
# [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
"detail_table",
# 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`)
@@ -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),
+164 -35
View File
@@ -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})
+12 -1
View File
@@ -138,7 +138,7 @@ class BoxAdjustPatch(BaseModel):
class CrossPickPiece(BaseModel):
"""[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고)."""
line: Literal["design", "ground", "spoil", "structure"]
line: Literal["design", "ground", "spoil", "structure", "rock"]
start: list[float] = Field(..., min_length=2, max_length=2)
end: list[float] = Field(..., min_length=2, max_length=2)
@@ -179,6 +179,15 @@ class CrossDetailTable(BaseModel):
columns: list[CrossDetailColumn] = Field(default_factory=list, max_length=4)
class CrossNoteCad(BaseModel):
"""[상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(엔티티 · 층) 그대로 · B07 이 표 위에 놓음."""
model_config = ConfigDict(extra="allow")
entities: list[dict[str, Any]] = Field(default_factory=list, max_length=2000)
layers: list[dict[str, Any]] | None = None
class CrossSectionPatch(BaseModel):
"""확정 시 측점별 data.design에 병합할 프론트 세션 보관값."""
@@ -246,6 +255,8 @@ class CrossSectionPatch(BaseModel):
ford_pipe_length_m: float | None = Field(default=None, ge=0)
# 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움.
structure_note: str | None = Field(default=None, max_length=2000)
# 표 위 CAD 칸(260-1) — 엔티티가 빈 칸 = 비움(옛 글 대신 아무것도 안 놓음)
structure_note_cad: CrossNoteCad | None = None
# [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈
detail_table: CrossDetailTable | None = None
+186
View File
@@ -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);
}
+7 -3
View File
@@ -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",
+123 -54
View File
@@ -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);
}
+214 -28
View File
@@ -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) => {
+10 -3
View File
@@ -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");
}
+3 -1
View File
@@ -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;
+1 -7
View File
@@ -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 -10
View File
@@ -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);
+2 -44
View File
@@ -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);
+165
View File
@@ -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
+102
View File
@@ -0,0 +1,102 @@
"""B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력.
⚠ 짝 = `B08_Quantity_Haul_Cycle.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_253_2_haul_cycle.py`.
조작 중은 TS 짝(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 이 파일로 다시 채움(정본).
식(거창 25 설계서 단가산출 1308~1347행 꼴 · 끝수 = 소수 둘째 자리 반올림)
q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es)
t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + t2 + t3 + t4 + t5 + t6
L1 = 거리이정표 그 행 포장 계(2차 + 1차 + 포장) · L2 = 비포장 · 속도 0 = 그 항 0.
로직 입력 채움(이름으로) — `Cm` · `사이클시간` = Cm · `t2` = t2 · `운반거리` = L1 + L2 ·
`own` = 사용자가 덮은 칸(L1 · L2 · t1 · 로직 입력 이름) — 그 값이 이김.
기본값 = 거창 설계서(혼합골재 덤프 15톤) · 속도 · t 표 = master_data CC000054~058(화면이 고름).
"""
from __future__ import annotations
import math
from typing import Any
DEFAULTS: dict[str, float] = {
"L1": 0.0,
"L2": 0.0,
"V1": 30.0,
"V2": 35.0,
"V3": 10.0,
"V4": 15.0,
"T": 15.0,
"rt": 1.7,
"f": 1.0,
"qs": 1.72,
"k": 1.0,
"Es": 0.6,
"Cms": 37.4,
"t3": 0.8,
"t4": 0.42,
"t5": 0.5,
"t6": 1.5,
}
#: 로직 입력 이름 → 셈 결과 칸
FILL = {"Cm": "cm", "사이클시간": "cm", "t2": "t2", "운반거리": "L"}
def r2(value: float) -> float:
"""소수 둘째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음)."""
return math.floor(value * 100 + 0.5) / 100
def _div(a: float, b: float) -> float:
return a / b if b else 0.0
def _num(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value) if math.isfinite(value) else None
def cycle_time(inputs: dict[str, Any] | None) -> dict[str, float]:
"""사이클 입력(빈 칸 = 기본값 · `t1` 있으면 그 값) → {q, n, t1, t2, cm, L}."""
given = {k: _num(v) for k, v in (inputs or {}).items()}
v = {k: given[k] if given.get(k) is not None else d for k, d in DEFAULTS.items()}
q = r2(_div(v["T"], v["rt"]) * v["f"])
n = r2(_div(q, v["qs"] * v["k"]))
t1 = given.get("t1")
t1 = r2(_div(v["Cms"] * n, 60 * v["Es"])) if t1 is None else t1
l1, l2 = v["L1"], v["L2"]
t2 = r2((_div(l1, v["V1"]) + _div(l1, v["V2"]) + _div(l2, v["V3"]) + _div(l2, v["V4"])) * 60)
cm = r2(t1 + t2 + v["t3"] + v["t4"] + v["t5"] + v["t6"])
return {
"q": q,
"n": n,
"t1": t1,
"t2": t2,
"cm": cm,
"L": math.floor((l1 + l2) * 1000 + 0.5) / 1000,
}
def cycle_inputs(ref: dict[str, Any] | None, dist: dict[str, Any] | None) -> dict[str, Any]:
"""연결 `ref.cycle` + 거리이정표 거리(`dist{L1, L2}`) — 덮은 L1 · L2(`ref.own`)는 그대로."""
cycle = dict((ref or {}).get("cycle") or {})
own = set((ref or {}).get("own") or [])
for name in ("L1", "L2"):
if name not in own and _num((dist or {}).get(name)) is not None:
cycle[name] = dist[name]
if "t1" not in own:
cycle.pop("t1", None)
return cycle
def fill_given(
ref: dict[str, Any], names: list[str], dist: dict[str, Any] | None
) -> dict[str, Any]:
"""로직 입력값 다시 채움 — `names` = 그 로직 입력 이름 · 덮은 칸(`ref.own`)은 그대로."""
own = set(ref.get("own") or [])
got = cycle_time(cycle_inputs(ref, dist))
given = dict(ref.get("given") or {})
for name, slot in FILL.items():
if name in names and name not in own:
given[name] = got[slot]
return given
+93
View File
@@ -0,0 +1,93 @@
/* =============================================================================
* B08_Quantity_Haul_Cycle.ts
* B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력.
*
* ⚠ 짝 = `B08_Quantity_Haul_Cycle.py`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_253_2_haul_cycle.py`.
* 조작 중은 이 파일(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 짝으로 다시 채움(정본).
*
* 식 = q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) ·
* t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + … + t6 · 끝수 = 소수 둘째 자리 ·
* 속도 0 = 그 항 0 · 로직 입력 채움 = Cm · 사이클시간 = Cm · t2 · 운반거리 = L1 + L2 ·
* `own` = 사용자가 덮은 칸(그 값이 이김).
* ========================================================================== */
export const CYCLE_DEFAULTS: Record<string, number> = {
L1: 0,
L2: 0,
V1: 30,
V2: 35,
V3: 10,
V4: 15,
T: 15,
rt: 1.7,
f: 1,
qs: 1.72,
k: 1,
Es: 0.6,
Cms: 37.4,
t3: 0.8,
t4: 0.42,
t5: 0.5,
t6: 1.5,
};
/** 로직 입력 이름 → 셈 결과 칸 */
export const CYCLE_FILL: Record<string, "cm" | "t2" | "L"> = {
Cm: "cm",
사이클시간: "cm",
t2: "t2",
운반거리: "L",
};
export interface CycleResult {
q: number;
n: number;
t1: number;
t2: number;
cm: number;
L: number;
}
export interface CycleRef {
given?: Record<string, unknown> | null;
cycle?: Record<string, unknown> | null;
own?: string[] | null;
}
export type Dist = { L1?: number | null; L2?: number | null } | null | undefined;
/** 소수 둘째 자리 반올림(0.5 올림 · 파이썬 짝 `r2`) */
export const r2 = (value: number): number => Math.floor(value * 100 + 0.5) / 100;
const div = (a: number, b: number): number => (b ? a / b : 0);
const num = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
/** 사이클 입력(빈 칸 = 기본값 · t1 있으면 그 값) → {q, n, t1, t2, cm, L} */
export function cycleTime(inputs: Record<string, unknown> | null | undefined): CycleResult {
const v: Record<string, number> = {};
for (const [k, d] of Object.entries(CYCLE_DEFAULTS)) v[k] = num(inputs?.[k]) ?? d;
const q = r2(div(v.T, v.rt) * v.f);
const n = r2(div(q, v.qs * v.k));
const t1 = num(inputs?.t1) ?? r2(div(v.Cms * n, 60 * v.Es));
const t2 = r2((div(v.L1, v.V1) + div(v.L1, v.V2) + div(v.L2, v.V3) + div(v.L2, v.V4)) * 60);
const cm = r2(t1 + t2 + v.t3 + v.t4 + v.t5 + v.t6);
return { q, n, t1, t2, cm, L: Math.floor((v.L1 + v.L2) * 1000 + 0.5) / 1000 };
}
/** 연결 `ref.cycle` + 거리이정표 거리 — 덮은 L1 · L2(`ref.own`)는 그대로 */
export function cycleInputs(ref: CycleRef | null | undefined, dist: Dist): Record<string, unknown> {
const cycle: Record<string, unknown> = { ...(ref?.cycle || {}) };
const own = new Set(ref?.own || []);
for (const name of ["L1", "L2"] as const)
if (!own.has(name) && num(dist?.[name]) !== null) cycle[name] = dist?.[name];
if (!own.has("t1")) delete cycle.t1;
return cycle;
}
/** 로직 입력값 다시 채움 — names = 그 로직 입력 이름 · 덮은 칸은 그대로 */
export function fillGiven(ref: CycleRef, names: string[], dist: Dist): Record<string, unknown> {
const own = new Set(ref.own || []);
const got = cycleTime(cycleInputs(ref, dist));
const given: Record<string, unknown> = { ...(ref.given || {}) };
for (const [name, slot] of Object.entries(CYCLE_FILL))
if (names.includes(name) && !own.has(name)) given[name] = got[slot];
return given;
}
+71 -21
View File
@@ -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
+152 -36
View File
@@ -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,
});
});
}
+34 -14
View File
@@ -11,6 +11,7 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · v
캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 ·
키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가
드라이버 하위 프로세스를 못 만듦(249-1).
장소 그림(256-1) = 같은 길 · 작은 지도(480×320 · 표지 하나 · 주소 둘레 줌) → `place/<상자 id>.png`.
"""
from __future__ import annotations
@@ -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()))))
+210 -99
View File
@@ -6,8 +6,12 @@
* 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면
* 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 ·
* 같은 방위 여럿 = 옆으로 나란히
* 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5)
* 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
* 기준점 자리 = 기준점 주소 좌표 → 잇는 곳(시점 · 다른 기준점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) —
* 옛 8방 무시(244-5) · 여럿(254) = 잇는 곳이 먼저 놓이게 사슬 차례
* 선 = 거래처 → to(기준점 · 시점) · 기준점 → to · 시점 → 중점 — 글 = 구간 줄마다 두 줄(1줄 도로 구분 ·
* 2줄 km · 254 · `legText` 가 줄바꿈 글자로 나눔)
* 처음 자리 선 길이(254) = 그 구간 km 비례(`lineLen` · 로그 · 최소 · 최대) — 잇는 곳 테두리 → 상자 테두리
* 상자 높이 = 내용(254 · 항목 · 이름 · 주소 줄바꿈 `wrap` 최대 3줄 · 좌표) · 기준점 상자도 주소
* 화면 = 북 위(SVG y 아래로 = 남)
* 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌)
* [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」
@@ -18,7 +22,7 @@
* 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표
* ========================================================================== */
import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc";
import type { Dir, Leg, MilestoneBase, MilestoneData } from "./B08_Quantity_Milestone_Calc";
export interface DiagramBox {
id: string;
@@ -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;
}
+209 -18
View File
@@ -9,6 +9,10 @@
POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 ·
표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` ·
좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄
POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 ·
이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름
GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/<id>.png`
· `auto/<id>.png` · `<id>.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음)
GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 ·
시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램
지금 중점 측점(244-12 · 상자 작은 글씨)
@@ -24,17 +28,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
+131 -28
View File
@@ -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);
}
+64 -18
View File
@@ -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}`)),
);
};
+271 -61
View File
@@ -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);
},
});
}
+131 -132
View File
@@ -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"),
);
},
});
+21 -13
View File
@@ -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,
},
]
: []),
],
[],
);
};
+124 -99
View File
@@ -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 };
}
+88 -43
View File
@@ -11,16 +11,25 @@
* 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐)
* 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) ·
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) ·
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
* 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` ·
* [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl`
* 기준점 여럿(254) = `bases[]` · 길 선 표 키 = 거래처 · 기준점 id · 옛 꼴 캐시 = 읽을 때 새 꼴(`migrateMilestone`)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { request } from "./B08_Quantity_UI_Hydro";
import { 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))
+4 -7
View File
@@ -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;
}
+2 -1
View File
@@ -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);
}
+45 -30
View File
@@ -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")),
]
}
+259 -32
View File
@@ -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")
}
+4 -5
View File
@@ -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
+4 -5
View File
@@ -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;
+128 -9
View File
@@ -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)
+18 -2
View File
@@ -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 자동 줄 연결 덮개
+44 -5
View File
@@ -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();
}
},
});
}
+27 -6
View File
@@ -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);
}
/* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */
+37 -13
View File
@@ -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({
+12 -2
View File
@@ -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,
+17 -11
View File
@@ -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];
+19 -5
View File
@@ -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