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

This commit is contained in:
2026-10-10 23:56:53 +09:00
204 changed files with 16232 additions and 662 deletions
+3 -1
View File
@@ -46,7 +46,9 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000
# Public URL for email links (프론트엔드 주소)
APP_PUBLIC_BASE_URL=http://localhost:5173
KAKAO_REST_API_KEY=ea05e4c86be146d9e28586d3f25d5a08
KAKAO_JS_KEY=98ac6edef14ffbca300d90e540644b68
# kakao app ID 1603330
# Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f
# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA
# 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B
+2
View File
@@ -185,6 +185,8 @@ export const STATE_REGISTRY = {
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
"b08-options": { bucket: "draft", scope: "project" },
/** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */
"b08-milestone": { bucket: "draft", scope: "project" },
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
"b09-estimation": { bucket: "draft", scope: "project" },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
+4 -2
View File
@@ -67,6 +67,8 @@ export interface SvgZoomPanOptions {
content?: ContentBounds;
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
wheelZoom?: boolean;
/** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */
panStart?: (event: PointerEvent) => boolean;
}
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
@@ -146,8 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다.
if (event.button !== 1) return;
// 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다.
if (!(options.panStart ? options.panStart(event) : event.button === 1)) return;
event.preventDefault();
panning = true;
moved = false;
+31
View File
@@ -0,0 +1,31 @@
/* =============================================================================
* A00_Common/design/design_pick_escape.ts
* 풀기 한 길 — Esc = 고름 한 칸 비움 (233-3 · 조사 AF (나)).
*
* 값을 비우면 구독한 화면이 모두 따라 풂 — 종단(측점선 · 알약) · 3D(띠 · 핀 · 부재) · 배수유역도 ·
* 좌측 폼 · 횡단 카드 · 범위 테두리 · 포장층 · 형상. 입력 칸 · 창(모달) · 메뉴 · 종단 도구가 먼저
* 받은 Esc(입력 취소 · 창 닫기 · 도구 끝)는 그쪽 몫이라 건드리지 않음.
* ========================================================================== */
import { NO_SELECTION, selectDesign } from "./design_selection";
/** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */
export const ESCAPE_FROM = "esc";
const OWN_ESCAPE =
"input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop";
/** 보기마다 건다 — 보이는 보기 하나만 받음 · 보기를 떠나면 뗌. */
export function bindEscapeRelease(
projectId: string | null,
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
): void {
const onKey = (event: KeyboardEvent): void => {
if (event.key !== "Escape" || event.defaultPrevented || !host.visible()) return;
const target = event.target as HTMLElement | null;
if (target?.closest?.(OWN_ESCAPE) || document.querySelector(".ui-modal__backdrop")) return;
selectDesign(projectId, NO_SELECTION, { from: ESCAPE_FROM });
};
window.addEventListener("keydown", onKey);
host.onDispose(() => window.removeEventListener("keydown", onKey));
}
+152
View File
@@ -0,0 +1,152 @@
/* =============================================================================
* A00_Common/design/design_pick_profile.ts
* 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) ·
* 3D(측점 띠 · 핀 · 부재 · 233-2) · 배수유역도(관 마커 · 유역 · 측점 마름모 · 233-3) (조사 AF (나) ② ③ ⑤).
*
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고,
* 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이
* 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는
* 그릴 때마다 지금 관 목록에서 다시 찾으므로 관이 옮겨져도 끊기지 않는다.
* ========================================================================== */
import {
nearestWithin,
readSelection,
subscribeSelection,
type DesignSelection,
} from "./design_selection";
import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape";
import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
/** 종단도가 켤 것 — 측점선(알약은 그 측점 짝을 종단도가 맞춤) 또는 알약(구조물 · 관 `pipe-*`). */
export type ProfilePick = { stationId: string | null } | { structureId: string };
export interface ProfilePickSource {
/** 구조물 측점(수동 + 관 투영) — id 는 `irregularStationId` 를 씌우기 전 값. */
irregular: () => ReadonlyArray<{ id: string; chainage_m: number; origin?: string }>;
/** 관 지점 정본(배수유역도). */
pipes: () => ReadonlyArray<{ chainage_m: number }>;
/** 일반 측점 카드. */
sections: () => ReadonlyArray<{ station_id: string; chainage_m: number }>;
}
export interface ProfilePickTarget {
setSelectedStation: (stationId: string | null) => void;
setSelectedStructure: (structureId: string | null) => void;
/** 배수유역도 — 관 마커 · 유역(알림 없이) · 측점 마름모. */
drainage?: {
selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void;
markStation: (chainageM: number | null) => void;
} | null;
}
/** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */
const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6;
/**
* 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1)
* ② 고른 측점 그대로(일반 카드 → 구조물 측점) ③ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1).
*/
export function profilePickOf(
selection: DesignSelection | null,
source: ProfilePickSource,
): ProfilePick {
if (!selection || selection.chainageM === null) return { stationId: null };
if (selection.structureId) return { structureId: selection.structureId };
const at = selection.chainageM;
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,
);
if (own) return { stationId: irregularStationId(own.id) };
const target = graphTargetAt(null, at, source.pipes(), source.sections());
return "pipeChainageM" in target
? { structureId: `pipe-${target.pipeChainageM.toFixed(2)}` }
: target;
}
export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void {
if ("structureId" in pick) target.setSelectedStructure(pick.structureId);
else target.setSelectedStation(pick.stationId);
}
/** 배수유역도 = 그 자리 관 마커 · 유역(없으면 풂) + 측점 마름모 — 재계산이 지운 뒤에도 다시 얹음(a3 · a6). */
export function paintMapPick(
map: NonNullable<ProfilePickTarget["drainage"]>,
selection: DesignSelection | null,
): void {
const at = selection?.chainageM ?? null;
map.selectPipeAtChainage(at, false);
map.markStation(at);
}
export interface ViewerPickTarget {
markers: { selectStation: (stationId: string | null, notify?: boolean) => void };
structurePick: {
select: (pick: { chainageM: number; kind: string; key?: string } | null) => void;
};
}
/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) ·
* 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */
export function viewerPickOf(
selection: (DesignSelection & { partKey?: string }) | null,
source: ProfilePickSource,
): { stationId: string | null; member: { chainageM: number; kind: string; key?: string } | null } {
if (!selection || selection.chainageM === null) return { stationId: null, member: null };
const at = selection.chainageM;
const pick = profilePickOf(selection, source);
const own = selection.structureId
? nearestWithin(source.irregular(), at, (entry) => entry.chainage_m)
: undefined;
const stationId = "stationId" in pick ? pick.stationId : own ? irregularStationId(own.id) : null;
return { stationId, member: { chainageM: at, kind: "", key: selection.partKey } };
}
export function paintViewerPick(
target: ViewerPickTarget,
pick: ReturnType<typeof viewerPickOf>,
): void {
target.markers.selectStation(pick.stationId, false); // 칠하기만 — 알림이 사슬을 다시 돌리지 않게
target.structurePick.select(pick.member);
}
/**
* 종단 보기(종단도 · 3D · 배수유역도)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이
* 바뀐 뒤 · 재계산 뒤 · 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠)
* 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. Esc(풀기 한 길)는 좌측 폼까지 닫음.
*/
export function bindProfilePick(
projectId: string | null,
target: {
profile: ProfilePickTarget;
viewer?: ViewerPickTarget;
form?: { hasSelection: () => boolean; selectById: (id: null) => void };
},
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
source: ProfilePickSource,
): { paint: () => void } {
const paint = (): void => {
if (!host.visible()) return;
const selection = readSelection(projectId);
paintProfilePick(target.profile, profilePickOf(selection, source));
if (target.viewer) paintViewerPick(target.viewer, viewerPickOf(selection, source));
if (target.profile.drainage) paintMapPick(target.profile.drainage, selection);
};
host.onDispose(
subscribeSelection(projectId, (_selection, from) => {
paint();
if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection())
target.form.selectById(null);
}),
);
bindEscapeRelease(projectId, host);
return { paint };
}
+4 -3
View File
@@ -77,6 +77,7 @@ import {
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
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";
@@ -341,7 +342,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
scheduleCrossPreview();
if (redrawPending) return;
redrawPending = true;
requestAnimationFrame(() => {
frameOf(root, () => {
redrawPending = false;
draw();
});
@@ -443,7 +444,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
});
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw));
bindProfileCollapse(root, panelHandle, () => frameOf(root, draw));
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
function syncDrainageSpans(): void {
@@ -482,7 +483,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
nextDetail.balloon_offsets ?? undefined,
);
draw();
requestAnimationFrame(draw);
frameOf(root, draw);
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
+6 -2
View File
@@ -6,6 +6,7 @@
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { viewOf } from "@util/common_util_view_window";
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
const SCROLL_SETTLE_MS = 80;
@@ -29,15 +30,18 @@ export function bindProfileViewport(deps: ProfileViewportDeps): () => void {
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
let settledScrollLeft = 0;
let scrollSettleTimer = 0;
/** 시계를 건 창 — 새 창으로 빼면 그 창 시계를 쓴다(가려진 메인 창 시계는 늦게 돎 · 230-1). */
let timerView: Window = window;
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
body.addEventListener("scroll", () => {
if (deps.editing()) return;
const fitted = deps.fitElevation();
window.clearTimeout(scrollSettleTimer);
timerView.clearTimeout(scrollSettleTimer);
if (!needsFullRedraw(fitted)) return;
scrollSettleTimer = window.setTimeout(() => {
timerView = viewOf(body);
scrollSettleTimer = timerView.setTimeout(() => {
if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = body.scrollLeft;
deps.draw();
+1 -1
View File
@@ -13,7 +13,7 @@
import { readState, writeState } from "../b_page_state";
/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */
/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51;
export interface DesignSelection {
+16 -18
View File
@@ -39,6 +39,7 @@ import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pi
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
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";
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import {
@@ -191,7 +192,7 @@ export async function mountCrossView(
if (!isWallSelecting()) focusStationAt(chainageM, revealOnly);
},
releaseCard: () => releaseCross(),
markStructure: (structureId) => sectionView.selectMark(structureId),
markStructure: (structureId, span) => sectionView.selectMark(structureId, span),
applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
});
@@ -387,7 +388,7 @@ export async function mountCrossView(
: null;
if (!target) {
structuresPanel.showAtChainage(null);
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다.
releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2)
return;
}
const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null;
@@ -494,23 +495,20 @@ export async function mountCrossView(
if (applied) appliedPick = signature;
}
/** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */
// 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2)
const crossPick = bindCrossPick(projectId, host, {
sections: () => sectionDetail?.cross_sections ?? [],
controls: stationControls,
releaseForm: () => structuresPanel.showAtChainage(null),
view: sectionView,
reapply: () => {
appliedPick = null;
restoreStructurePick();
},
});
/** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */
function releaseCross(): void {
const { revetOffset, ford, box } = stationControls;
const lit = (sectionDetail?.cross_sections ?? []).filter(
(s) =>
revetOffset.highlightFor(s) ||
ford.selectedFor(s.chainage_m) ||
box.selectedFor(s.chainage_m),
);
for (const s of lit) {
if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null);
if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null);
if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null);
}
structuresPanel.releasePipe();
sectionView.focusStation(null);
sectionView.refreshCards(lit.map((s) => s.chainage_m));
crossPick.release();
appliedPick = null;
selectDesign(projectId, NO_SELECTION, { from: "b06" });
}
+38 -40
View File
@@ -31,14 +31,9 @@ import {
restoreStructurePick,
wireStructurePick,
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
coversAtStation,
NO_SELECTION,
nearestWithin,
readSelection,
selectDesign,
} from "./design_selection";
import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection";
import type { DesignPageHost } from "./design_page";
import { bindProfilePick } from "./design_pick_profile";
import {
bindDesignView,
designReady,
@@ -173,8 +168,8 @@ export async function mountProfileView(
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM),
onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => {
@@ -220,7 +215,7 @@ export async function mountProfileView(
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
const uphillOverrides = loadUphillOverrides(activeProjectId);
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({
stations: () => bridge.irregularStations(),
isSyncing: () => selectionSyncing,
setSyncing: (value) => {
@@ -244,9 +239,16 @@ export async function mountProfileView(
remember: (chainageM) => rememberPick(chainageM, null),
});
/** 배수유역도 유역 · 관 고름도 고름 한 칸에(사슬 도중 되울림은 안 적음 · 233-3). */
const pickFromMap = (chainageM: number | null): void => {
if (!selectionSyncing) rememberPick(chainageM, null);
selectStationOfPipe(chainageM);
};
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
function restorePick(): void {
profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1)
if (panel.structures.hasSelection()) return;
const pick = viewer.structurePick;
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
@@ -318,45 +320,31 @@ export async function mountProfileView(
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
},
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
// 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3)
onPipeFacilitySelect: (chainage) => {
rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다.
if (selectionSyncing) return;
selectionSyncing = true;
try {
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
const station =
chainage === null
? undefined
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
selectGraphAt(id, chainage);
syncBasinHighlight(id);
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
profilePanel.drainage.selectPipeAtChainage(chainage);
viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2)
} finally {
selectionSyncing = false;
}
rememberPick(chainage, null);
if (!selectionSyncing) profilePick.paint();
},
onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3)
onStructureSelect: (structure) => {
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
if (selectionSyncing) return;
selectionSyncing = true;
try {
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
} finally {
selectionSyncing = false;
}
// 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2)
if (!selectionSyncing) profilePick.paint();
},
},
shared,
);
wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe);
// 3D 부재 고름도 측점 고름과 같은 거름 — 노면공 id 를 고름 한 칸에 적지 않음(231-1)
wireStructurePick(
viewer.structurePick,
activeProjectId,
panel.structures,
selectStationOfPipe,
(entry) => coversAtStation(bridge.typeGroup(entry.type_id)),
);
/** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의
* 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
@@ -402,6 +390,17 @@ export async function mountProfileView(
},
isRestoring: () => restoring,
});
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
const profilePick = bindProfilePick(
activeProjectId,
{ profile: profilePanel, viewer, form: panel.structures },
host,
{
irregular: () => bridge.irregularStations(),
pipes: () => lastPipes ?? [],
sections: () => currentSectionDetail?.cross_sections ?? [],
},
);
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
@@ -558,8 +557,7 @@ export async function mountProfileView(
);
return;
}
viewer.markers.selectStation(null);
profilePanel.setSelectedStation(null);
profilePick.paint();
},
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
onHide: () => {
@@ -8,7 +8,7 @@
* ========================================================================== */
import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address";
import { cellStyleAt } from "./spreadsheet_cellstyle";
import { cellStyleAt, shownHAlign } from "./spreadsheet_cellstyle";
import { createCalcEngine } from "./spreadsheet_graph";
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
import { resolveName } from "./spreadsheet_names";
@@ -110,7 +110,7 @@ export function areaTable(ask: TableAsk) {
: { 글: "", 정렬: "left" as const };
const key = `${sheet.id}!${toA1(r, c)}`;
값[key] = shown.글;
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]);
const align: TableCell["align"] = ALIGN[shownHAlign(style.가로)] ?? "center"; // 안 준 칸 = 가운데(237-1)
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
};
const cells = rows.map((r) =>
@@ -139,9 +139,9 @@ function borderCss(side: BorderSide | undefined, scale: number): string {
}
}
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). 안 준 칸(일반) = 가운데(237-1). */
const HALIGN_CSS: Record<HAlign, string> = {
general: "left",
general: "center",
left: "left",
right: "right",
center: "center",
@@ -149,13 +149,23 @@ const HALIGN_CSS: Record<HAlign, string> = {
centerContinuous: "center",
};
/** 칸에 맞춤을 안 준(없음 · 일반) 보이는 가로 맞춤 = 가운데 — 화면 · 도구줄 · 대화상자 · 내보내기 공통(237-1). */
export function shownHAlign(h: HAlign | undefined): HAlign {
return !h || h === "general" ? "center" : h;
}
/** 칸에 맞춤을 안 준 보이는 세로 맞춤 = 가운데(237-1). */
export function shownVAlign(v: VAlign | undefined): VAlign {
return v ?? "center";
}
const VALIGN_CSS: Record<VAlign, string> = {
top: "flex-start",
center: "center",
bottom: "flex-end",
};
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 = 가운데).
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
export function applyCellStyle(
cell: HTMLElement,
@@ -178,7 +188,7 @@ export function applyCellStyle(
s.borderLeft = borderCss(borders.왼, scale);
s.borderBottom = borderCss(borders.아래, scale);
s.borderRight = borderCss(borders.오른, scale);
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
s.justifyContent = VALIGN_CSS[shownVAlign(style.세로)];
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
let text = cell.firstElementChild as HTMLElement | null;
@@ -187,7 +197,7 @@ export function applyCellStyle(
text.className = "aislo-cell__text";
cell.replaceChildren(text);
}
text.style.textAlign = HALIGN_CSS[hAlign];
text.style.textAlign = HALIGN_CSS[shownHAlign(hAlign)];
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
}
@@ -14,6 +14,7 @@
import { showToast } from "@ui/ui_template_elements";
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { clipWholeToUsed } from "./spreadsheet_grid_cells";
import { formatValue } from "./spreadsheet_numfmt";
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
@@ -70,10 +71,11 @@ function styleToCss(style: CellStyle): string {
if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${style.채움}`);
if (style.가로 && style.가로 !== "general") {
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`);
}
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`);
// 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양
const h = shownHAlign(style.가로);
parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`);
const v = shownVAlign(style.세로);
parts.push(`vertical-align:${v === "center" ? "middle" : v}`);
if (style.줄바꿈) parts.push("white-space:pre-wrap");
if (style.테두리) {
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
+1 -1
View File
@@ -294,7 +294,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
ta.style.fontWeight = s.굵게 ? "bold" : "";
ta.style.fontStyle = s.기울임 ? "italic" : "";
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
ta.style.textAlign = s.가로 === "left" || s.가로 === "right" ? s.가로 : "center";
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
ta.style.width = "0";
ta.style.height = "0";
@@ -23,7 +23,7 @@ import {
type NegStyle,
} from "./spreadsheet_format_code";
import { dialogKeys } from "./spreadsheet_actions";
import { isAutoInk } from "./spreadsheet_cellstyle";
import { isAutoInk, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { formatValue } from "./spreadsheet_numfmt";
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
@@ -233,7 +233,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["centerContinuous", "선택 영역 가운데"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
hSelect.value = draft.가로 ?? "general";
hSelect.value = shownHAlign(draft.가로);
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
const vSelect = el("select", {
@@ -243,7 +243,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["bottom", "아래"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
vSelect.value = draft.세로 ?? "bottom";
vSelect.value = shownVAlign(draft.세로);
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
@@ -13,6 +13,7 @@ import {
isAutoInk,
resolveBoxBorders,
setCellText,
shownHAlign,
type ResolvedBorders,
} from "./spreadsheet_cellstyle";
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
@@ -139,7 +140,7 @@ export function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
}
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
general: "left",
general: "center",
left: "left",
right: "right",
center: "center",
@@ -218,7 +219,7 @@ function cellDisplay(
const scalar = scalarAt(ctx, sheet, r, c);
const formatted = formatValue(scalar, code);
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬;
const hAlign: HAlign = shownHAlign(hAlign0); // 안 준 칸 = 가운데(237-1)
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
const isNumeric =
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
@@ -13,6 +13,7 @@
import { createButton, el } from "@ui/ui_template_elements";
import { confirmMerge } from "./spreadsheet_actions";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { cellToText, textToCell } from "./spreadsheet_editor";
import { st } from "./spreadsheet_text";
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
@@ -528,8 +529,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
strikeBtn.classList.toggle("is-on", !!style.취소선);
textColor.value = style.글자색 ?? "#000000";
fillColor.value = style.채움 ?? "#ffffff";
hAlignSelect.value = style.가로 ?? "general";
vAlignSelect.value = style.세로 ?? "bottom";
hAlignSelect.value = shownHAlign(style.가로);
vAlignSelect.value = shownVAlign(style.세로);
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
numfmtInput.value = style.형식 ?? "";
const range = ctx.selection.범위[0];
+27
View File
@@ -114,3 +114,30 @@ def _search_sync(query: str, size: int) -> list[dict[str, Any]]:
async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
return await asyncio.to_thread(_search_sync, query.strip(), size)
def _reverse_sync(lon: float, lat: float) -> str | None:
"""좌표 → 주소 한 줄(VWorld getAddress · 도로명 먼저 · 없으면 지번)."""
query = urllib.parse.urlencode(
{
"service": "address",
"request": "getAddress",
"version": "2.0",
"crs": "epsg:4326",
"point": f"{lon},{lat}",
"type": "both",
"format": "json",
"key": VWORLD_API_KEY,
}
)
try:
body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8"))
except Exception:
return None
items = (body.get("response") or {}).get("result") or []
texts = {item.get("type"): item.get("text") for item in items if isinstance(item, dict)}
return texts.get("road") or texts.get("parcel") or None
async def reverse_geocode(lon: float, lat: float) -> str | None:
return await asyncio.to_thread(_reverse_sync, lon, lat)
@@ -19,6 +19,8 @@ export interface AddressFieldHandle {
root: HTMLElement;
/** 저장할 한 줄 주소. 비면 null. */
value: () => string | null;
/** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */
point: () => { lat: number; lon: number } | null;
}
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
@@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
label: "지도 확인",
variant: "ghost",
onClick: async function onB01_Address_Map_Click() {
await map.show(base.input.value.trim());
picked = await map.show(base.input.value.trim());
},
});
mapBtn.hidden = true;
@@ -102,6 +104,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
let manual = false;
let picked: { lat: number; lon: number } | null = null;
function pick(item: AddressCandidate): void {
base.input.value = candidateLine(item);
@@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
zip.hidden = !item.zipcode;
list.hidden = true;
list.innerHTML = "";
picked = { lat: item.lat, lon: item.lon };
map.showPoint(item.lat, item.lon);
detail.input.focus();
}
@@ -160,7 +164,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
});
// 이미 있는 주소는 열자마자 지도로 보여 준다.
if (initial && initial.trim()) void map.show(initial.trim());
if (initial && initial.trim()) void map.show(initial.trim()).then((p) => (picked ??= p));
return {
root,
@@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
return merged || null;
},
point: () => picked,
};
}
+6 -4
View File
@@ -32,7 +32,8 @@ function tileIndex(
export function buildAddressMap(): {
root: HTMLElement;
show: (address: string) => Promise<void>;
/** 찾은 좌표를 돌려줌 — 못 찾으면 null. */
show: (address: string) => Promise<{ lat: number; lon: number } | null>;
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
showPoint: (lat: number, lon: number) => void;
} {
@@ -110,17 +111,18 @@ export function buildAddressMap(): {
note.textContent = message;
}
const show = async (address: string): Promise<void> => {
const show = async (address: string): Promise<{ lat: number; lon: number } | null> => {
if (!address.trim()) {
fail("주소를 먼저 입력하십시오.");
return;
return null;
}
const point = await geocodeAddress(address).catch(() => null);
if (!point) {
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
return;
return null;
}
draw(point.lat, point.lon);
return { lat: point.lat, lon: point.lon };
};
return { root, show, showPoint: draw };
+2 -1
View File
@@ -40,6 +40,7 @@ import {
resampleRoute,
type RoutePoint,
} from "./B04_PreProcess_UI_RouteSamples";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -296,7 +297,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
function selectBasinOfPipe(pipeIndex: number): void {
const pipe = pipes[pipeIndex];
if (!pipe) return;
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < 0.51);
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < PIPE_MATCH_M);
selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null;
say(summary());
}
+25 -12
View File
@@ -15,7 +15,12 @@ import aiomysql
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import generate_irregular_sections
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
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_Schema import RouteConfirmRequest
@@ -350,17 +355,25 @@ async def _append_irregular_cross_sections(
save_sampling_snapshot(project_root, request)
stored_options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
irregular_stations = await asyncio.to_thread(
generate_irregular_sections,
project_root,
str(route["route_data_path"]),
request.filter_key,
request.method,
request.smooth,
extra_stations=request.extra_stations(),
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
route_data_path = str(route["route_data_path"])
def _generate() -> list[dict[str, Any]]:
# 측점 목록 = 서버 정본(관 · 구조물)에서 — 화면 목록(관 · A군)을 받아 적으면 서버가 심은
# 소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 종료 측점이 통째로 빠짐(238-1).
polyline = _load_route_polyline(project_root, route_data_path)
extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
return generate_irregular_sections(
project_root,
route_data_path,
request.filter_key,
request.method,
request.smooth,
extra_stations=extras,
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
irregular_stations = await asyncio.to_thread(_generate)
if not irregular_stations:
return
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
@@ -12,6 +12,7 @@ import {
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
const FORD_KINDS: Record<string, number> = {
ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M,
@@ -39,7 +40,7 @@ export function fillFordHeights(
): boolean {
let filled = false;
for (const wait of [...pending]) {
const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < 0.51);
const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < PIPE_MATCH_M);
if (!point?.facility || !(point.facility in FORD_KINDS)) {
pending.delete(wait); // 관이 사라졌거나 종류가 바뀜
continue;
@@ -48,7 +49,9 @@ export function fillFordHeights(
pending.delete(wait); // 사용자가 넣은 값 — 안 덮음
continue;
}
const flow = basins.find((entry) => Math.abs(entry.chainage_m - point.chainage_m) < 0.51);
const flow = basins.find(
(entry) => Math.abs(entry.chainage_m - point.chainage_m) < PIPE_MATCH_M,
);
const width = Number(point.options?.ford_width_m) || FORD_KINDS[point.facility];
const need = fordHeightM(flow?.design_flow_m3s ?? null, width);
if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸
+27 -13
View File
@@ -1,5 +1,6 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import { viewOf } from "../common_util/common_util_view_window";
import {
computeDetailBasins,
fetchDetailPipePoints,
@@ -29,6 +30,8 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { snapStructureStation } from "../common_util/common_util_structure_station";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
@@ -172,6 +175,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
let offsetX = 0;
let offsetY = 0;
let frameHandle = 0;
/** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */
let frameView: Window = window;
let loadSequence = 0;
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
/** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */
@@ -284,24 +289,25 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage);
/** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */
function syncPipeSelection(): void {
function syncPipeSelection(notify = true): void {
const index = pipeEditor.selected();
deleteButton.disabled = index === null;
const picked = index === null ? null : pipeEditor.pipes()[index];
selectBasin(basinIndexOfPipe(basins, picked ?? null));
selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);
// 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은
// Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply)
// 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게.
const pickedChainage = picked ? picked.chainage_m : null;
if (pickedChainage !== lastNotifiedPipeChainage) {
lastNotifiedPipeChainage = pickedChainage;
callbacks.onPipeSelected?.(pickedChainage);
if (notify) callbacks.onPipeSelected?.(pickedChainage);
}
}
function scheduleDraw(): void {
if (frameHandle) return;
frameHandle = window.requestAnimationFrame(() => {
frameView = viewOf(root);
frameHandle = frameView.requestAnimationFrame(() => {
frameHandle = 0;
draw();
});
@@ -567,15 +573,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
movePipe(fromChainage, toChainage) {
const index = pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < 0.51);
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < PIPE_MATCH_M);
if (index < 0) return;
pipeEditor.moveTo(index, toChainage);
const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게)
facilityStore.move(pipeEditor.pipes()[index].chainage_m, to);
pipeEditor.moveTo(index, to);
},
selectBasinByChainage(chainageM) {
const picked =
chainageM === null
? null
: (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < 0.51) ?? null);
: (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < PIPE_MATCH_M) ?? null);
// 밖에서 온 요청이므로 되돌려 보내지 않는다(그래프 ↔ 유역 순환 차단).
selectBasin(picked ? picked.index : null, false);
},
@@ -601,12 +609,16 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
scheduleDraw();
},
addPipe(chainageM, attributes) {
chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
facilityStore.set(chainageM, attributes ?? null);
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits.add(chainageM);
pipeEditor.addAtChainage(chainageM);
},
updatePipeFacility(fromChainageM, toChainageM, attributes) {
// 옮겼으면 정수 m · 속성 열쇠도 그 자리로(234-1) — 값만 고친 옛 소수 관은 제자리
if (Math.abs(toChainageM - fromChainageM) > 0.005)
toChainageM = snapStructureStation(toChainageM);
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
const previous = facilityStore.get(fromChainageM);
facilityStore.set(fromChainageM, null);
@@ -617,7 +629,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
// attach가 새 위치로 시설 정보를 승계한다.
const index = pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < 0.51);
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M);
if (index >= 0) pipeEditor.moveTo(index, toChainageM);
else void analyze();
} else {
@@ -630,21 +642,23 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
removePipe(chainageM) {
const index = pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51);
.findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M);
if (index < 0) return;
pipeEditor.select(index);
pipeEditor.deleteSelected();
},
selectPipeAtChainage(chainageM) {
selectPipeAtChainage(chainageM, notify = true) {
const index =
chainageM === null
? null
: pipeEditor.pipes().findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51);
: pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M);
const next = index === -1 ? null : index;
if (pipeEditor.selected() === next) return;
// select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다.
pipeEditor.select(next);
syncPipeSelection();
syncPipeSelection(notify);
scheduleDraw();
},
async savePipes() {
@@ -667,7 +681,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
dispose() {
loadSequence += 1;
if (frameHandle) {
window.cancelAnimationFrame(frameHandle);
frameView.cancelAnimationFrame(frameHandle);
frameHandle = 0;
}
resizeObserver.disconnect();
@@ -45,7 +45,8 @@ export interface DrainagePanel {
selectBasinByChainage: (chainageM: number | null) => void;
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
* 이미 같으면 무시(2026-08-17 전역 선택 동기화). */
selectPipeAtChainage: (chainageM: number | null) => void;
/** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(밖으로 안 알림 · 재계산 뒤 다시 얹음 · 233-3). */
selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void;
/** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */
markStation: (chainageM: number | null) => void;
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6).
+4 -3
View File
@@ -21,6 +21,7 @@ import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_U
import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -352,7 +353,7 @@ export function reconcilePipes(
return null;
}
return next.map((chainage) => {
const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < 0.51);
const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < PIPE_MATCH_M);
return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" };
});
}
@@ -516,7 +517,7 @@ export function createProgressReporter(progress: {
/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */
export function pipeMarkerColor(basins: ReadonlyArray<DetailBasin>, chainageM: number): string {
const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < 0.51);
const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < PIPE_MATCH_M);
if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb");
return basinColor(basin.index).replace(/0\.45\)$/, "1)");
}
@@ -527,7 +528,7 @@ export function basinIndexOfPipe(
pipe: { chainage_m: number } | null,
): number | null {
if (!pipe) return null;
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51);
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < PIPE_MATCH_M);
return basin ? basin.index : null;
}
+11 -9
View File
@@ -1,6 +1,7 @@
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { snapStructureStation } from "../common_util/common_util_structure_station";
// 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제.
// 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선
@@ -74,6 +75,9 @@ export function createPipeEditor(
/** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다
* (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */
let dragDownAt: { x: number; y: number } | null = null;
/** 노선 안 · 관 기준 측점 = 정수 m(일반 측점 1 m 안이면 그 측점 · 234-1). */
const onRoute = (chainageM: number): number =>
Math.min(snapStructureStation(Math.max(0, Math.min(totalChainage, chainageM))), totalChainage);
/** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */
function screenToMetric(
@@ -199,14 +203,14 @@ export function createPipeEditor(
moveTo(index, chainageM) {
const pipe = pipeList[index];
if (!pipe) return;
pipe.chainage_m = Math.max(0, Math.min(totalChainage, chainageM));
pipe.chainage_m = onRoute(chainageM);
pipe.reason = "confirmed";
sortPipes();
onChange();
onCommit();
},
addAtChainage(chainageM) {
const clamped = Math.max(0, Math.min(totalChainage, chainageM));
const clamped = onRoute(chainageM);
pipeList.push({ chainage_m: clamped, reason: "confirmed" });
sortPipes();
selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6);
@@ -224,18 +228,17 @@ export function createPipeEditor(
if (!metric) return null;
const nearest = nearestChainage(metric.x, metric.y);
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return null;
return Number(nearest.chainage.toFixed(2));
return onRoute(nearest.chainage);
},
addAt(view, screenX, screenY) {
const metric = screenToMetric(view, screenX, screenY);
if (!metric) return false;
const nearest = nearestChainage(metric.x, metric.y);
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false;
pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" });
const at = onRoute(nearest.chainage);
pipeList.push({ chainage_m: at, reason: "confirmed" });
sortPipes();
selectedIndex = pipeList.findIndex(
(pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6,
);
selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - at) < 1e-6);
onChange();
onCommit();
return true;
@@ -271,8 +274,7 @@ export function createPipeEditor(
if (!metric) return true;
const nearest = nearestChainage(metric.x, metric.y);
if (!nearest) return true;
const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage));
pipeList[draggingIndex].chainage_m = clamped;
pipeList[draggingIndex].chainage_m = onRoute(nearest.chainage);
pipeList[draggingIndex].reason = "confirmed";
dragMoved = true;
onChange();
@@ -7,6 +7,7 @@
import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
/** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
* 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
@@ -20,7 +21,7 @@ export function buildPipeReports(
): DrainagePipeReport[] {
return pipes.map((pipe) => {
const chainage = Math.round(pipe.chainage_m * 100) / 100;
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M);
const attributes = facilityStore.get(chainage);
return {
chainage_m: chainage,
@@ -23,6 +23,7 @@ import {
pickPipeDiameter,
type StructureType,
} from "@config/config_frontend";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
export interface IrregularStation {
id: string;
@@ -480,7 +481,9 @@ export function createIrregularStationsSection(
// 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다.
const restoredGhost =
entry.structureType === undefined &&
incomingChainages.some((chainage) => Math.abs(entry.chainage_m - chainage) < 0.51);
incomingChainages.some(
(chainage) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M,
);
if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1);
}
pipes.forEach((pipe) => {
@@ -488,7 +491,7 @@ export function createIrregularStationsSection(
const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null);
const { station, remainder } = splitChainage(chainage);
const kept = previousPipes.find(
(entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < 0.51,
(entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M,
);
const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE;
const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective);
@@ -530,7 +533,9 @@ export function createIrregularStationsSection(
callbacks.onChange([...stations]);
},
removeByChainage(chainageM) {
const index = stations.findIndex((entry) => Math.abs(entry.chainage_m - chainageM) < 0.51);
const index = stations.findIndex(
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
);
if (index < 0) return null;
const [removed] = stations.splice(index, 1);
if (editingId === removed.id) loadForm(null);
@@ -540,7 +545,7 @@ export function createIrregularStationsSection(
},
moveByChainage(fromChainageM, toChainageM) {
const index = stations.findIndex(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < 0.51,
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (index < 0) return false;
const { station, remainder } = splitChainage(toChainageM);
+10 -3
View File
@@ -397,6 +397,8 @@ export function createRouteMarkers(
sprite.position.copy(position);
sprite.position.y += 3.4;
sprite.renderOrder = 10;
// 라벨은 읽기용 — 띠 가운데 위에 떠 누름을 가로채지 않음(232-1)
sprite.raycast = () => undefined;
return sprite;
}
@@ -477,6 +479,8 @@ export function createRouteMarkers(
head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다.
head.position.y = PIN_HEIGHT + 1.7;
group.add(beam, head);
// 핀은 표시만 — 누름을 가로채지 않음(띠 가운데 누름이 핀에 먼저 맞았음 · 232-1)
beam.raycast = head.raycast = () => undefined;
group.renderOrder = 9;
group.visible = false;
interactionGroup.add(group);
@@ -490,7 +494,8 @@ export function createRouteMarkers(
if (center) selectionPin.position.copy(center);
}
function selectStation(stationId: string | null): void {
/** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(알림 없음 · 233-2). */
function selectStation(stationId: string | null, notify = true): void {
selectedStationId = stationId;
stationGroup.children.forEach((object) => {
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다.
@@ -499,7 +504,7 @@ export function createRouteMarkers(
(object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15);
});
syncSelectionPin();
stationSelectionListener?.(selectedStationId);
if (notify) stationSelectionListener?.(selectedStationId);
}
return {
@@ -520,7 +525,9 @@ export function createRouteMarkers(
deleteSelected,
selectObject(object: THREE.Object3D | undefined) {
if (typeof object?.userData.stationId === "string") {
selectStation(object.userData.stationId);
// 같은 띠 다시 누름 = 풂(232-1)
const id = object.userData.stationId as string;
selectStation(id === selectedStationId ? null : id);
selectionListener?.(null);
return;
}
@@ -33,6 +33,7 @@ import type { createRoutePanel } from "./B05_Profile_UI_Panel";
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";
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
export interface PipeProjection {
@@ -114,7 +115,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
);
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
gone.forEach((station) => {
const same = followedStation(station, gone, born);
// 없으면 맞춘 자리의 새 측점 — [정수 맞춤]이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1)
const same = followedStation(station, gone, born) ?? followSnapped(station, born);
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
});
irregularStations = stations;
+3
View File
@@ -69,6 +69,8 @@ interface PanelCallbacks {
onPipeFacilityRemove: (chainageM: number) => void;
/** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */
onPipeFacilitySelect: (chainageM: number | null) => void;
/** 목록 다시 누름 · 새로 놓기 = 풀기 한 길(233-3). */
onListRelease?: () => void;
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
}
@@ -104,6 +106,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
onPipeUpdate: callbacks.onPipeFacilityUpdate,
onPipeRemove: callbacks.onPipeFacilityRemove,
onPipeSelect: callbacks.onPipeFacilitySelect,
onListRelease: callbacks.onListRelease,
},
});
+8 -4
View File
@@ -12,6 +12,7 @@ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_stat
import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights";
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
import { viewOf } from "../common_util/common_util_view_window";
const MIN_CHART_HEIGHT = 100;
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
@@ -123,9 +124,12 @@ export interface ProfileDrag {
export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
const { root, body, massHaul, tableOverlay } = ctx;
let resizeTimer = 0;
/** 시계는 종단이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
let timerView: Window = window;
const scheduleDraw = (): void => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(ctx.draw, 120);
timerView.clearTimeout(resizeTimer);
timerView = viewOf(root);
resizeTimer = timerView.setTimeout(ctx.draw, 120);
};
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
@@ -211,9 +215,9 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
mainDragCooldown = false;
},
scheduleDraw,
cancelDraw: () => window.clearTimeout(resizeTimer),
cancelDraw: () => timerView.clearTimeout(resizeTimer),
dispose() {
window.clearTimeout(resizeTimer);
timerView.clearTimeout(resizeTimer);
heightResizer.dispose();
window.removeEventListener("pointerup", clearDragFlags);
window.removeEventListener("pointercancel", clearDragFlags);
+13 -6
View File
@@ -23,6 +23,7 @@ import {
useDesignStore,
} from "../A00_Common/design/design_store";
import { clearState } from "../A00_Common/b_page_state";
import { viewOf } from "../common_util/common_util_view_window";
/** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */
const BUTTON_CLEARANCE_PX = 23;
@@ -38,13 +39,18 @@ export const HOLD_INTERVAL_MS = 100;
* 버튼이 아니라 이 컨트롤러가 window 이벤트로 들고 있어야 반복이 끊기거나
* 반대로 멈추지 않는 상황이 생기지 않는다.
*/
function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } {
function createHoldRepeater(node: Node): {
start: (action: () => void) => void;
stop: () => void;
} {
let delayTimer = 0;
let repeatTimer = 0;
/** 시계는 버튼 층이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
let timerView: Window = window;
function stop(): void {
window.clearTimeout(delayTimer);
window.clearInterval(repeatTimer);
timerView.clearTimeout(delayTimer);
timerView.clearInterval(repeatTimer);
delayTimer = 0;
repeatTimer = 0;
window.removeEventListener("pointerup", stop);
@@ -54,8 +60,9 @@ function createHoldRepeater(): { start: (action: () => void) => void; stop: () =
function start(action: () => void): void {
stop();
delayTimer = window.setTimeout(() => {
repeatTimer = window.setInterval(action, HOLD_INTERVAL_MS);
timerView = viewOf(node);
delayTimer = timerView.setTimeout(() => {
repeatTimer = timerView.setInterval(action, HOLD_INTERVAL_MS);
}, HOLD_DELAY_MS);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
@@ -329,7 +336,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
layer.className = "b05-profile-edit";
layer.style.width = `${width}px`;
const repeater = createHoldRepeater();
const repeater = createHoldRepeater(layer);
const edited = new Set(Object.keys(alignment.edits.station_offsets));
/**
@@ -11,6 +11,7 @@
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
import { frameOf } from "../common_util/common_util_view_window";
/** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */
const MIN_CHART_HEIGHT = 100;
@@ -182,7 +183,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
function scheduleLightSync(): void {
if (lightSyncPending) return;
lightSyncPending = true;
requestAnimationFrame(() => {
frameOf(deps.body, () => {
lightSyncPending = false;
syncHeightsLight();
});
+9 -14
View File
@@ -56,6 +56,7 @@ import { createPanelResizer } from "@ui/ui_template_resizer";
import "@util/common_util_mass_haul.css";
import "./B05_Profile_UI_Style_MassHaul.css";
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
import { frameOf } from "../common_util/common_util_view_window";
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
/* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
@@ -134,8 +135,6 @@ export interface RouteMassHaulDrawParams {
widthPx: number;
selectedStationId: string | null;
onSelectStation: (stationId: string) => void;
/** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */
onClearSelection?: () => void;
/**
* 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛
* 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정:
@@ -235,7 +234,7 @@ export function createRouteMassHaulPanel(
syncHandlePosition();
if (resizeRedrawPending) return;
resizeRedrawPending = true;
requestAnimationFrame(() => {
frameOf(overlay, () => {
resizeRedrawPending = false;
onChanged();
});
@@ -249,13 +248,6 @@ export function createRouteMassHaulPanel(
pendingChip.hidden = true;
overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip);
// 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시).
let clearSelection: (() => void) | undefined;
scroll.addEventListener("click", (event) => {
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
clearSelection?.();
});
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
attachWheelHorizontalScroll(scroll);
@@ -367,7 +359,6 @@ export function createRouteMassHaulPanel(
function draw(params: RouteMassHaulDrawParams): void {
lastParams = params;
clearSelection = params.onClearSelection;
overlay.hidden = !open;
syncHandlePosition();
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
@@ -488,9 +479,13 @@ export function createRouteMassHaulPanel(
);
drawnOnce = true;
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
scheduleMassHaulSettle(windowState, () => {
if (lastParams) draw(lastParams);
});
scheduleMassHaulSettle(
windowState,
() => {
if (lastParams) draw(lastParams);
},
overlay,
);
}
return {
@@ -10,6 +10,7 @@
* ========================================================================== */
import { isCtrlKey } from "../A00_Common/key_shortcut";
import { viewOf } from "../common_util/common_util_view_window";
import type {
AlignmentBase,
AlignmentEdits,
@@ -30,8 +31,9 @@ import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
import { createFreeDraw } from "../A00_Common/design/design_profile_free";
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
import { stepStructureStation } from "../common_util/common_util_structure_station";
/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */
/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */
const KEY_STEP_M = 0.1;
/** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */
@@ -233,8 +235,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
// 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해
// 0.1m 만 움직이고 멈춤(2026-09-04 실측).
const fromM = holdChainageM ?? station.chainage_m;
const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3));
if (next < 0) return false;
const next = stepStructureStation(fromM, key === "ArrowRight" ? 1 : -1);
if (next === fromM) return false;
ctx.moveStation({ ...station, chainage_m: fromM }, next);
holdStation = station;
holdChainageM = next;
@@ -254,11 +256,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
* 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */
const HOLD_SETTLE_MS = 700;
let settleTimer = 0;
/** 시계는 패널이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
let timerView: Window = window;
/** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */
function stopHold(): void {
window.clearTimeout(holdDelayTimer);
window.clearInterval(holdRepeatTimer);
timerView.clearTimeout(holdDelayTimer);
timerView.clearInterval(holdRepeatTimer);
holdDelayTimer = 0;
holdRepeatTimer = 0;
if (heldKey === null) return;
@@ -267,8 +271,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
holdStation = null;
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", stopHold);
window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(() => {
timerView.clearTimeout(settleTimer);
settleTimer = timerView.setTimeout(() => {
settleTimer = 0;
history.resume();
}, HOLD_SETTLE_MS);
@@ -278,7 +282,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
function flushHold(): void {
stopHold();
if (!settleTimer) return;
window.clearTimeout(settleTimer);
timerView.clearTimeout(settleTimer);
settleTimer = 0;
history.resume();
}
@@ -292,8 +296,9 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
heldKey = key;
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", stopHold);
holdDelayTimer = window.setTimeout(() => {
holdRepeatTimer = window.setInterval(() => {
timerView = viewOf(ctx.root); // 앞선 덩어리는 flushHold 가 이미 닫음
holdDelayTimer = timerView.setTimeout(() => {
holdRepeatTimer = timerView.setInterval(() => {
if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다.
}, HOLD_INTERVAL_MS);
}, HOLD_DELAY_MS);
+2 -14
View File
@@ -199,8 +199,8 @@ export function renderProfile(ctx: ProfileRenderContext): void {
const chartWrap = document.createElement("div");
chartWrap.className = "b05-profile__chart";
chartWrap.style.height = `${chartHeight}px`;
// 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시).
// 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation).
// 빈 곳 누름은 고름을 안 풂 — 풀기는 Esc · 같은 것 다시 누름 한 길(3D 와 같은 규칙 · 233-4).
// 도구([직선화] · [쉬프트] · [자유지정]) 중 빈 곳 누름은 그 도구 몫 그대로.
chartWrap.addEventListener("click", (event) => {
// [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면
// 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02).
@@ -219,15 +219,6 @@ export function renderProfile(ctx: ProfileRenderContext): void {
if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return;
}
}
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
if (ctx.selectedStationId() !== null) ctx.selectStation(null);
// 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지
// 리셋되는 해제 신호가 여기서만 나갈 수 있다(2026-08-18 사용자 보고).
if (ctx.selectedStructureId() !== null) {
ctx.setSelectedStructureId(null);
callbacks?.onStructureSelect?.(null);
ctx.redraw();
}
});
const designProfiles = alignment
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
@@ -454,9 +445,6 @@ export function renderProfile(ctx: ProfileRenderContext): void {
widthPx: width,
selectedStationId,
onSelectStation: ctx.selectStation,
onClearSelection: () => {
if (ctx.selectedStationId() !== null) ctx.selectStation(null);
},
};
massHaul.draw(massHaulParams);
// 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시
@@ -17,6 +17,7 @@ import {
} from "./B05_Profile_UI_IrregularStations";
import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
import type { PickGroup } from "./B05_Profile_UI_Structures_Panel_Types";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
/** 사이드 「구조물 배치」 군 · 종류 드롭다운 목록(`section.pickGroups()`)을 그대로 구조물군 → 종류
* 2단 메뉴 항목으로(129-1 — 종단그래프 · 배수유역도 · 3D · B06 종단 공용 · 가린 군 · 종류는 뺌).
@@ -118,7 +119,7 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti
}
/** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */
export const SAME_CHAINAGE_M = 0.51;
export const SAME_CHAINAGE_M = PIPE_MATCH_M;
/**
* 측점선 id ↔ 알약(구조물) id 짝짓기.
@@ -15,6 +15,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout";
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
import { frameOf } from "../common_util/common_util_view_window";
/* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
const HEIGHT_KEY = stateKey("table-height") ?? "";
@@ -87,7 +88,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
syncHandlePosition();
if (resizeRedrawPending) return;
resizeRedrawPending = true;
requestAnimationFrame(() => {
frameOf(overlay, () => {
resizeRedrawPending = false;
onChanged();
});
+10 -5
View File
@@ -18,6 +18,8 @@
* 유토곡선·측점 테이블)이 함께 얻는다.
* ========================================================================== */
import { viewOf } from "../common_util/common_util_view_window";
/** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 —
* 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */
const EASE = 0.35;
@@ -42,11 +44,13 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
/** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */
let applied = -1;
let frame = 0;
/** `frame` 을 받아 둔 창 — 걷을 때도 같은 창에서 걷는다. */
let frameView: Window = window;
/** 직전 프레임 시각 — 좁히는 양을 **흐른 시간**에 맞추는 데 쓴다. */
let lastFrameAt = 0;
function stop(): void {
if (frame) cancelAnimationFrame(frame);
if (frame) frameView.cancelAnimationFrame(frame);
frame = 0;
target = -1;
applied = -1;
@@ -72,7 +76,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
const ratio = 1 - Math.pow(1 - EASE, elapsed / BASE_FRAME_MS);
scroller.scrollLeft = scroller.scrollLeft + rest * ratio;
applied = scroller.scrollLeft;
frame = requestAnimationFrame(step);
frame = frameView.requestAnimationFrame(step);
}
scroller.addEventListener(
@@ -89,7 +93,8 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
target = next;
if (!frame) {
applied = scroller.scrollLeft;
frame = requestAnimationFrame(step);
frameView = viewOf(scroller);
frame = frameView.requestAnimationFrame(step);
}
event.preventDefault();
},
@@ -128,7 +133,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
};
scroller.addEventListener("click", swallow, { capture: true, once: true });
// 클릭이 안 오는 경우(휠 버튼 등)를 대비해 다음 프레임에 걷어낸다.
requestAnimationFrame(() =>
viewOf(scroller).requestAnimationFrame(() =>
scroller.removeEventListener("click", swallow, { capture: true }),
);
}
@@ -164,7 +169,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
// 끄는 동안 글자가 파랗게 잡히지 않게 한다. 포인터 잡기를 미룬 뒤로 브라우저가
// 기본 글자 선택을 시작했다(2026-09-06 사용자 지적). 이미 잡힌 것도 푼다.
scroller.style.userSelect = "none";
window.getSelection()?.removeAllRanges();
viewOf(scroller).getSelection()?.removeAllRanges();
}
scroller.scrollLeft = dragLeft - dx;
scroller.scrollTop = dragTop - dy;
@@ -17,6 +17,7 @@ import {
} from "../A00_Common/design/design_selection";
import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick";
import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
import type { StructureInstance } from "./B05_Profile_Api_Structures";
/** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍에 구조물 id 가 붙는다. */
export interface StructurePickHandoff {
@@ -53,18 +54,21 @@ export function wireStructurePick(
structures: StructuresSection,
/** 기존 선택 동기화(`selectStationOfPipe`) — 누가거리 하나로 전 화면을 맞춘다. */
selectStation: (chainageM: number | null) => void,
/** 측점이 덮어 열어도 되는 구조물 — 노면공은 뺌(측점선 · 카드 고름과 같은 거름 · 231-1). */
coverable?: (entry: StructureInstance) => boolean,
): void {
controls.onPick = (pick: StructurePick | null): void => {
// null = 같은 부재 다시 누름(풂) — 빈 곳 누름은 여기까지 안 옴(232-1)
if (!pick) {
selectDesign(projectId, NO_SELECTION, { from: "b05" });
selectStation(null);
return;
}
const selection: DesignSelection = { chainageM: pick.chainageM, structureId: null };
const { structureId } = structures.resolveAt(selection);
const { structureId } = structures.resolveAt(selection, coverable);
selectDesign(projectId, { ...selection, structureId }, { partKey: pick.key, from: "b05" });
selectStation(pick.chainageM);
if (!structures.hasSelection()) structures.selectAt({ ...selection, structureId });
if (!structures.hasSelection()) structures.selectAt({ ...selection, structureId }, coverable);
};
}
@@ -10,6 +10,7 @@
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
import { DEFAULT_BASIN_SHAPE } from "../B06_Section/B06_Section_UI_Cross_Culvert_Basin";
import { fillPipeHeights } from "../common_util/common_util_culvert_sets";
import { snapStructureStation } from "../common_util/common_util_structure_station";
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
const wrapper = document.createElement("label");
@@ -141,7 +142,7 @@ export function stationFields(
): StationFields {
const station = numberInput("1", "0");
station.placeholder = "측점";
const remainder = numberInput("0.1", "0");
const remainder = numberInput("1", "0"); // 기준 측점 = 정수 m(234-1)
remainder.placeholder = "+m";
const pair = document.createElement("div");
pair.className = "b05-structure__station-pair";
@@ -195,7 +196,8 @@ export function stationFields(
const valid =
Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0;
markInvalid(!valid);
return valid ? stationNo * intervalM + rest : null;
// 친 값은 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 안 고친 칸은 위에서 옛 값 그대로
return valid ? snapStructureStation(stationNo * intervalM + rest) : null;
},
write(chainageM, intervalM) {
written = null;
@@ -23,6 +23,7 @@ import {
type SummaryItem,
} from "../common_util/common_util_summary_item";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc";
import {
containerTitle,
@@ -229,7 +230,8 @@ export function renderStructureList(params: StructureListParams): void {
} else {
const { pipe } = row;
const picked =
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05;
selectedPipeChainage !== null &&
Math.abs(selectedPipeChainage - pipe.chainage_m) < PIPE_MATCH_M; // 고름 찾는 거리 한 값(233-3)
item.classList.toggle("is-selected", picked);
station.textContent = formatStation(pipe.chainage_m, intervalM);
item.addEventListener("click", () => params.onSelectPipe(pipe));
@@ -22,6 +22,7 @@ import {
import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { frameOf } from "../common_util/common_util_view_window";
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
* 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여
@@ -268,7 +269,8 @@ function buildBalloon(
return span;
}),
);
requestAnimationFrame(() => flipIfClipped(balloon));
// 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2).
queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon)));
return balloon;
}
+23 -28
View File
@@ -14,7 +14,6 @@
* ========================================================================== */
import {
structureAnchorM,
type StructureInstance,
type StructuresSectionOptions,
type StructurePlacement,
@@ -48,6 +47,7 @@ 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";
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
@@ -363,7 +363,7 @@ export function createStructuresSection(
if (tempPipeChainage === null) return;
const chainage = tempPipeChainage;
tempPipeChainage = null;
if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < 0.51) {
if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < PIPE_MATCH_M) {
selectedPipeChainage = null;
}
callbacks.onPipeRemove(chainage);
@@ -542,7 +542,13 @@ export function createStructuresSection(
return {
root,
facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15)
listRoot: list,
listRoot: snapDock(list, {
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) {
@@ -560,10 +566,10 @@ export function createStructuresSection(
// 이 배열을 참조로 받아 두므로, 새 배열로 바꾸면 그쪽이 옛 배열을 고쳐
// 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인).
structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry })));
// 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다.
// [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2).
// 실시간 반영 목록이면 폼 안 닫음(고치던 칸) · [저장] 뒤 같은 id · 관 폼은 그대로(53-2 · 233-3)
const kept = structures.find((entry) => entry.structure_id === editingId) ?? null;
if (!applyingLive) loadForm(editingId === null ? null : kept);
if (!applyingLive && selectedPipeChainage === null)
loadForm(editingId === null ? null : kept);
renderList();
},
getStructures: () => [...structures],
@@ -587,12 +593,12 @@ export function createStructuresSection(
syncButtons();
}
}
if (
selectedPipeChainage !== null &&
!pipeFacilities.some(
(pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < PIPE_MATCH_M,
)
) {
const kept = pipeFacilities.find(
(pipe) => Math.abs(pipe.chainage_m - (selectedPipeChainage ?? NaN)) < PIPE_MATCH_M,
);
if (kept)
selectedPipeChainage = kept.chainage_m; // 조금 옮겨진 관도 고름 따라감(233-3)
else if (selectedPipeChainage !== null) {
// 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다.
selectedPipeChainage = null;
facilityOptions.setFacility(null);
@@ -616,7 +622,8 @@ export function createStructuresSection(
if (!pipe) return;
loadPipeForm(pipe);
},
resolveAt: (selection) => resolveSelection(selection, pipeFacilities, structures),
resolveAt: (selection, coverable) =>
resolveSelection(selection, pipeFacilities, structures, coverable),
selectAt(selection, coverable) {
const hit = resolveSelection(selection, pipeFacilities, structures, coverable);
if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM);
@@ -636,6 +643,7 @@ export function createStructuresSection(
loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null);
},
addAt(chainageM, typeId) {
chainageM = snapStructureStation(chainageM); // 정수 m · 일반 측점 붙기(234-1)
const type = typeMap().get(typeId);
if (!type) return;
// 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서
@@ -669,21 +677,8 @@ export function createStructuresSection(
moveById(structureId, toChainageM) {
const index = structures.findIndex((entry) => entry.structure_id === structureId);
if (index < 0) return false;
const target = structures[index];
if (target.placement === "interval") {
// 구간은 길이를 유지한 채 통째로 옮긴다 — 기준점을 끌면 시작·종료가 따라온다.
const length = (target.end_m ?? 0) - (target.start_m ?? 0);
const anchorOffset = structureAnchorM(target) - (target.start_m ?? 0);
const start = toChainageM - anchorOffset;
structures[index] = {
...target,
chainage_m: toChainageM,
start_m: start,
end_m: start + length,
};
} else {
structures[index] = { ...target, chainage_m: toChainageM };
}
// 구간은 길이를 유지한 채 통째로 — 기준 · 시작 · 끝 모두 정수 m(234-1)
structures[index] = movedStructure(structures[index], toChainageM);
emit();
return true;
},
@@ -22,6 +22,10 @@ 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 {
readGroupItems,
readSummaryItem,
@@ -32,6 +36,7 @@ import type {
PipeFacilityItem,
StructuresCallbacks,
} from "./B05_Profile_UI_Structures_Panel_Types";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
/** 패널이 쥔 값 중 폼 왕복·저장에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
export interface StructuresCommitContext {
@@ -168,7 +173,7 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte
// 관으로 본다(다른 곳의 관 매칭 기준과 동일).
const isTemp =
ctx.tempPipeChainage() !== null &&
Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < 0.51;
Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < PIPE_MATCH_M;
if (isTemp) {
ctx.setTempPipeChainage(pipe.chainage_m);
ctx.setSelectedPipeChainage(pipe.chainage_m);
@@ -304,8 +309,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
beforeInput?.focus();
return;
}
start = Math.max(anchor - before, 0);
end = start + length;
// 시작 · 끝도 정수 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));
} else if (placement === "interval") {
start = ctx.startFields.read(step, true);
end = ctx.endFields.read(step, true);
@@ -171,8 +171,11 @@ export interface StructuresSection {
hasSelection: () => boolean;
/** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */
selectById: (structureId: string | null) => void;
/** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). */
resolveAt: (selection: DesignSelection) => ResolvedSelection;
/** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). `coverable` = selectAt 과 같음. */
resolveAt: (
selection: DesignSelection,
coverable?: (entry: StructureInstance) => boolean,
) => ResolvedSelection;
/** 고름 한 값으로 폼을 연다(관 → 구조물 순) — 어느 화면에서 골라도 같은 거리 · 같은 순서다.
* 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. `coverable` = 덮어 열어도 되는 구조물(측점 고름 · 216-1). */
selectAt: (
@@ -0,0 +1,160 @@
/* =============================================================================
* B05_Profile_UI_Structures_Snap.ts
* 구조물 기준 측점 정수 맞춤(234-1) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과
* 기존 소수 자료를 옮기는 [정수 맞춤] 단추(좌측 구조물 목록 머리 · B05 · B06 한 벌).
*
* 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 단추는 확인 창 뒤
* 구조물은 한 번에, 관은 하나씩 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집
* 따라가기(213-2) · 시설 속성 이어받기가 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다.
* ========================================================================== */
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
import {
isSnappedStation,
snapStructureStation,
} from "../common_util/common_util_structure_station";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
export { snapStructureStation };
import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types";
/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤. */
export function movedStructure(target: StructureInstance, toChainageM: number): StructureInstance {
const anchor = snapStructureStation(toChainageM);
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;
return {
...target,
chainage_m: Math.min(Math.max(anchor, start), end),
start_m: start,
end_m: end,
};
}
/** 사라진 측점의 계획고 편집이 따라갈 새 측점 — 맞춘 자리(0.006m 안 · 213-2 와 같은 폭)의 것. */
export function followSnapped<T extends { chainage_m: number }>(
station: T,
born: ReadonlyArray<T>,
): T | null {
const to = snapStructureStation(station.chainage_m);
return born.find((next) => Math.abs(next.chainage_m - to) < 0.006) ?? null;
}
const offGrid = (value: number | null | undefined): boolean =>
typeof value === "number" && !isSnappedStation(value);
/** 기준 · 시작 · 끝 중 하나라도 맞춤 밖(소수)인 구조물인가. */
export const structureOffGrid = (structure: StructureInstance): boolean =>
offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m);
export interface SnapPlan {
/** 목록 자리 · 옮긴 모양. */
structures: Array<{ index: number; next: StructureInstance }>;
pipes: Array<{ from: number; to: number; pipe: PipeFacilityItem }>;
/** 맞춘 자리에 다른 관이 있어 그대로 둔 관 수. */
skipped: number;
}
/** 옮길 것 셈 — 구조물은 옮긴 모양, 관은 (옛 자리 → 새 자리) · 자리 겹친 관은 건너뜀. */
export function snapPlan(
structures: ReadonlyArray<StructureInstance>,
pipes: ReadonlyArray<PipeFacilityItem>,
): SnapPlan {
const moved = structures.flatMap((structure, index) =>
structureOffGrid(structure)
? [{ index, next: movedStructure(structure, structureAnchorM(structure)) }]
: [],
);
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))
.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)) {
plan.skipped += 1;
return;
}
taken.push(to);
plan.pipes.push({ from: pipe.chainage_m, to, pipe });
});
return plan;
}
/** 관 한 건의 지금 속성 그대로 — 폼 [수정]과 같은 꼴(비운 칸 = 지움 규칙에 옛 값이 다 실림). */
export const pipeAttributes = (pipe: PipeFacilityItem): FacilityAttributes => ({
facility: pipe.facility,
...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}),
...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}),
options: { ...(pipe.options ?? {}) },
summary_item: pipe.summary_item ?? null,
group_items: pipe.group_items ?? null,
});
export interface SnapDockContext {
/** 패널이 쥔 구조물 배열(참조) — 옮긴 것을 제자리에 갈아 끼움. */
structures: StructureInstance[];
pipes: () => ReadonlyArray<PipeFacilityItem>;
/** 목록이 바뀜 알림(한 번). */
emit: () => void;
/** 관 한 건 옮김 — 폼 [수정]과 같은 길. */
movePipe: (from: number, to: number, attributes: FacilityAttributes) => void;
/** 열린 폼을 닫음 — 옛 측점을 쥔 폼이 다음 [수정]에 되돌리지 않게. */
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> {
const plan = snapPlan(ctx.structures, ctx.pipes());
const count = plan.structures.length + plan.pipes.length;
if (!count) {
showToast(
plan.skipped
? `소수 측점 관 ${plan.skipped}건은 맞출 자리에 다른 관이 있어 그대로 둡니다.`
: "소수 기준 측점이 없습니다.",
"info",
);
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;
}
@@ -650,3 +650,15 @@
.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);
}
+1
View File
@@ -259,6 +259,7 @@ export function createRouteViewer(): RouteViewer {
camera: cameraRig.camera,
group: () => corridor.group(),
blocked: () => markerInput.blocked(),
pressedMarker: () => markerInput.pressedMarker(),
});
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
@@ -24,6 +24,8 @@ export interface MarkerPointerControls {
beginMoveSelected: () => void;
/** 마커를 잡고 있거나 이동 대기 중인가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */
blocked: () => boolean;
/** 마지막 누름이 마커 · 측점 띠에 맞았나 — 3D 구조물 고름이 그 누름을 「빈 곳」으로 읽지 않게(232-1). */
pressedMarker: () => boolean;
/** 등록한 캔버스 이벤트를 모두 떼어낸다. */
dispose: () => void;
}
@@ -43,6 +45,7 @@ export function bindMarkerPointerControls(options: {
let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null;
let draggingMarker = false;
let lastDragPoint: { x: number; y: number; z: number } | null = null;
let downOnMarker = false;
/** 화면 좌표 → 정규화 장치 좌표(-1~1). */
function pointerOf(clientX: number, clientY: number): THREE.Vector2 {
@@ -86,7 +89,14 @@ export function bindMarkerPointerControls(options: {
function markerHit(event: PointerEvent): THREE.Object3D | undefined {
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera());
return raycaster.intersectObject(markers.group, true)[0]?.object;
// 숨은 것(꺼 둔 측점선 · 라벨)은 누름을 가로채지 않음 — three 레이캐스트는 visible 을 안 봄(232-1)
return raycaster.intersectObject(markers.group, true).find((entry) => shown(entry.object))
?.object;
}
function shown(object: THREE.Object3D | null): boolean {
for (let node = object; node; node = node.parent) if (!node.visible) return false;
return true;
}
function finishMarkerInteraction(selectCandidate: boolean): void {
@@ -105,6 +115,7 @@ export function bindMarkerPointerControls(options: {
function handlePointerDown(event: PointerEvent): void {
if (event.button !== 0) return;
const hit = markerHit(event);
downOnMarker = !!hit;
const pointId = markers.pointIdForObject(hit);
if (pointId) {
dragCandidate = {
@@ -184,6 +195,7 @@ export function bindMarkerPointerControls(options: {
movingSelected = true;
},
blocked: () => dragCandidate !== null || draggingMarker || movingSelected,
pressedMarker: () => downOnMarker,
dispose: () => {
canvas.removeEventListener("dragover", handleDragOver);
canvas.removeEventListener("drop", handleDrop);
@@ -20,7 +20,7 @@ export interface StructurePick {
}
export interface StructurePickControls {
/** 3D에서 골랐을 때 알린다. null = 빈 곳(지형·리본) 클릭으로 해제. */
/** 3D에서 골랐을 때 알린다. null = 같은 부재 다시 누름으로 해제(빈 곳 누름은 안 알림 · 232-1). */
onPick?: (pick: StructurePick | null) => void;
/** 좌측 패널·종단 그래프에서 고른 것을 3D 강조에 반영한다(부재키 없이 측점만). */
selectAtChainage: (chainageM: number | null) => void;
@@ -47,8 +47,10 @@ export function bindStructurePick(options: {
group: () => THREE.Object3D | null;
/** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */
blocked: () => boolean;
/** 이 누름이 마커 · 측점 띠에 맞았나 — 참이면 그 누름은 마커 몫(빈 곳 누름 아님 · 232-1). */
pressedMarker?: () => boolean;
}): StructurePickControls {
const { canvas, camera, group, blocked } = options;
const { canvas, camera, group, blocked, pressedMarker } = options;
let selected: StructurePick | null = null;
let down: { x: number; y: number; pointerId: number } | null = null;
@@ -94,7 +96,7 @@ export function bindStructurePick(options: {
});
}
/** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=해제)이다. */
/** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=빈 곳)이다. */
function pickAt(clientX: number, clientY: number): StructurePick | null {
const root = group();
if (!root || !root.visible) return null;
@@ -120,7 +122,7 @@ export function bindStructurePick(options: {
function handleDown(event: PointerEvent): void {
down =
event.button === 0 && !blockedBeforeDown && !blocked()
event.button === 0 && !blockedBeforeDown && !blocked() && !pressedMarker?.()
? { x: event.clientX, y: event.clientY, pointerId: event.pointerId }
: null;
}
@@ -131,7 +133,14 @@ export function bindStructurePick(options: {
if (!start || event.pointerId !== start.pointerId) return;
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return;
if (blocked()) return;
selected = pickAt(event.clientX, event.clientY);
const pick = pickAt(event.clientX, event.clientY);
// 빈 곳(지형 · 리본) 누름은 고름을 안 풂(08-05 사용자 결정 · 232-1) — 같은 부재 다시 누름만 풂
if (!pick) return;
const same =
selected !== null &&
Math.abs(selected.chainageM - pick.chainageM) < 0.01 &&
selected.key === pick.key;
selected = same ? null : pick;
applyHighlight();
controls.onPick?.(selected);
}
+3
View File
@@ -39,6 +39,7 @@ import {
pavementSpecsFrom,
type PavementStructureInput,
} from "@util/common_util_structure_pavement";
import { setGeneralStations } from "../common_util/common_util_structure_station";
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,6 +64,7 @@ async function withDraftWalls(
detail: SectionDetailResponse,
projectId: string,
): Promise<SectionDetailResponse> {
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
const drafts = readPendingStructures(projectId);
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
@@ -214,6 +216,7 @@ export function replaceSectionDetail(
): void {
cache.set(keyOf(projectId, routeId), detail);
writeState("section-detail", detail, projectId, routeId);
setGeneralStations(detail.longitudinal?.stations);
}
/**
@@ -143,7 +143,7 @@ export function selectionSource(
/* ── 모달 배선 ─────────────────────────────────────────────────────────── */
export interface DetailSelect {
/** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(고름 지움). */
/** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(같은 자리 조각 고름은 유지 · 233-2). */
redraw: () => void;
/** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */
setMode: (kind: "area" | "length" | null) => void;
@@ -152,6 +152,10 @@ export interface DetailSelect {
detach: () => void;
}
/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */
const pieceKey = (piece: SplitPiece | undefined): string =>
piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : "";
export function attachDetailSelect(input: {
cad: HTMLElement;
design: () => CrossDesign | undefined;
@@ -209,10 +213,12 @@ export function attachDetailSelect(input: {
return {
redraw() {
selected = [];
// 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(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");
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : []));
const toPx = axes ? mToPx(axes) : null;
hits = toPx
? pieces.map((piece) => {
@@ -0,0 +1,83 @@
/* =============================================================================
* B06_Section_UI_Page_Pick_Sync.ts
* 횡단 보기 강조를 고름 한 칸에 묶음 (233-2 · 조사 AF (나) ④).
*
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 횡단 보기가 보일 때 다른 곳(풀기 Esc 등)이 값을
* 바꾸면 구독으로 받아 카드 · 범위 테두리(`--span`) · 포장층 · 벽 · 세월교 · BOX 형상 강조를 그 값으로
* 다시 세운다 — 값이 비면 모두 풂, 값이 있으면 넘김 적용(`applyStructurePick`)과 같은 길로 다시 얹음.
* 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음.
* 풀기(Esc · 카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 · 범위 테두리 ·
* 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4).
* ========================================================================== */
import { subscribeSelection } 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";
export interface CrossPickDeps {
sections: () => ReadonlyArray<CrossSection>;
controls: Pick<StationControls, "revetOffset" | "ford" | "box">;
/** 좌측 폼(구조물 · 관) 닫기. */
releaseForm: () => void;
view: {
focusStation: (stationId: string | null) => void;
refreshCards: (chainages: ReadonlyArray<number>) => void;
selectMark: (structureId: string | null, span?: boolean) => void;
};
/** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */
reapply: () => void;
}
let releasing = false;
/** 횡단 보기 강조를 모두 끔 — 카드 · 벽 · 세월교 · BOX · 폼 관 · 범위 테두리 · 포장층(값은 안 건드림).
* 카드를 풀면 카드 리스너가 다시 풀기를 부름 — 되돌아온 부름은 건너뜀. */
export function releaseCrossHighlights(deps: CrossPickDeps): void {
if (releasing) return;
releasing = true;
try {
releaseAll(deps);
} finally {
releasing = false;
}
}
function releaseAll(deps: CrossPickDeps): void {
const { revetOffset, ford, box } = deps.controls;
const lit = deps
.sections()
.filter(
(s) =>
revetOffset.highlightFor(s) ||
ford.selectedFor(s.chainage_m) ||
box.selectedFor(s.chainage_m),
);
for (const s of lit) {
if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null);
if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null);
if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null);
}
deps.releaseForm();
deps.view.focusStation(null);
deps.view.selectMark(null);
deps.view.refreshCards(lit.map((s) => s.chainage_m));
}
/** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음.
* `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */
export function bindCrossPick(
projectId: string | null,
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
deps: CrossPickDeps,
): { release: () => void } {
host.onDispose(
subscribeSelection(projectId, (selection, from) => {
if (!host.visible() || from === "b06") return;
if (selection.chainageM === null) releaseCrossHighlights(deps);
else deps.reapply();
}),
);
bindEscapeRelease(projectId, host); // 풀기 한 길(233-3)
return { release: () => releaseCrossHighlights(deps) };
}
@@ -19,7 +19,7 @@ import type { RevetPick } from "./B06_Section_UI_Cross_Culvert";
import type { StructurePickHandoff } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
coversChainage,
isNear,
nearestWithin,
type SelectableStructure,
} from "../A00_Common/design/design_selection";
@@ -57,7 +57,8 @@ export function applyStructurePick(
const covering = structures.find((entry) => coversChainage(entry, handoff.at));
const target =
(byId && cardOfStructure(sections, byId, handoff.at)) ||
sections.find((section) => isNear(section.chainage_m, handoff.at)) ||
// 찾는 거리 안 가장 가까운 카드 — 첫 카드면 관 80.17 이 이웃 측점 80 카드로 감(231-1)
nearestWithin(sections, handoff.at, (section) => section.chainage_m) ||
(covering && cardOfStructure(sections, covering, handoff.at));
if (!target) return false;
// 조정창은 앞칸(측 이름)만 본다 — 뒤칸은 3D 강조를 부재 하나로 좁히는 몫이다.
@@ -83,7 +84,7 @@ export function applyStructurePick(
type Sections = SectionDetailResponse["cross_sections"];
/** 구조물이 자리한 카드 — 구간형이면 그 안의 첫 카드, 점형이면 가장 가까운 카드. 옹벽 · 기슭막이는
* 횡단 제원이라 시작 측점 카드가 주인이다(관은 `isNear` 로 카드를 바로 찾는다). */
* 횡단 제원이라 시작 측점 카드가 주인이다(관은 `nearestWithin` 으로 카드를 바로 찾는다). */
function cardOfStructure(
sections: Sections,
structure: SelectableStructure,
@@ -48,6 +48,7 @@ import {
type DesignSelection,
} from "../A00_Common/design/design_selection";
import { showToast } from "@ui/ui_template_elements";
import { snapStructureStation } from "../common_util/common_util_structure_station";
import {
adjustDockRoot,
refreshAdjustSlots,
@@ -89,8 +90,8 @@ export interface B06StructuresPanelDeps {
focusChainage: (chainageM: number, revealOnly?: boolean) => void;
/** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */
releaseCard?: () => void;
/** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */
markStructure?: (structureId: string | null) => void;
/** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름 · span 끔 = 범위 테두리 없이 · 137-4). */
markStructure?: (structureId: string | null, span?: boolean) => void;
/** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */
onMarks?: (structures: StructureInstance[], types: StructureType[]) => void;
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
@@ -167,6 +168,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group);
/** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */
let preferredId: string | null = null;
/** 측점 고름(카드 하나 · 측점만 남은 고름 다시 세움)이 폼을 여는 중 — 폼 알림이 카드를 옮기지 않게(231-1). */
let openingAtStation = false;
/** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */
const focusOf = (structure: StructureInstance): number => {
const anchor = structureAnchorM(structure);
@@ -220,13 +223,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
const pave = !coverable(structure);
const id = structure.structure_id ?? null;
preferredId = id;
deps.focusChainage(focusOf(structure), pave);
// 측점 고름(카드 하나 · 다시 세움)이 연 폼 = 누른 카드 그대로 — 구조물 기준 카드로 옮겨 가지 않음(189-1 · 231-1)
if (!openingAtStation) deps.focusChainage(focusOf(structure), pave);
preferredId = null;
// 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2)
deps.markStructure?.(id);
// 카드 고름 리스너를 안 거치니 세션 고름은 여기서(B05 로 가도 그 노면공)
deps.markStructure?.(id, !openingAtStation); // 측점 고름이 연 것 = 카드 하나 · 범위 테두리 없음(231-1)
// 목록 · 알약 · 형상 고름은 늘 고름 한 칸에 그 구조물 — 같은 카드라 카드 리스너가 안 돌아도
// 오감 · 새로 고침 뒤 그 폼(188-1 · 231-2) · 측점 고름이 연 것은 측점만(카드 리스너 몫 · 189-1)
const at = { chainageM: focusOf(structure), structureId: id };
if (pave) selectDesign(deps.projectId, at, { from: "b06" });
if (!openingAtStation) selectDesign(deps.projectId, at, { from: "b06" });
},
getInterval: deps.stationInterval,
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
@@ -235,7 +240,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
showToast(PIPE_ADD_GUIDE, "error");
return;
}
const at = Number(chainageM.toFixed(2));
const at = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) {
showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error");
return;
@@ -259,11 +264,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
// 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다
// (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다.
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
// 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는
// 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M)
movePipeTo(fromChainageM, toChainageM);
// 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1)
if (Math.abs(fromChainageM - toChainageM) > 0.005) movePipeTo(fromChainageM, toChainageM);
if (attributes.summary_item !== undefined || attributes.group_items !== undefined) {
// 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다.
const mine = pipeFacilities.find(
@@ -406,11 +408,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
const pick = readStructurePick(deps.projectId);
const picked = currentChainageM ?? pick?.at ?? null;
// 관이면 그 관, 옹벽 · 기슭막이 같은 구조물이면 저장된 구조물 id 로 폼을 세운다.
// 측점만 남은 고름은 노면공을 덮어 열지 않음 — 카드 고름과 같은 거름(152-5 · 231-1)
if (picked !== null) {
section.selectAt({
chainageM: picked,
structureId: currentChainageM === null ? (pick?.structureId ?? null) : null,
});
const structureId = currentChainageM === null ? (pick?.structureId ?? null) : null;
openingAtStation = structureId === null;
try {
section.selectAt({ chainageM: picked, structureId }, coverable);
} finally {
openingAtStation = false;
}
}
} catch (error) {
showToast(
@@ -472,7 +478,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
showToast(PIPE_MOVE_GUIDE, "error");
return;
}
const to = Number(toChainageM.toFixed(2));
const to = snapStructureStation(toChainageM); // 정수 m · 일반 측점 붙기(234-1)
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
@@ -567,7 +573,14 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
section.selectPipeByChainage(hit.pipeChainageM);
// 알약은 폼 뒤 — 폼이 연 구조물을 닫으며 알약을 끄므로(165-2 · 측점선 없는 물넘이포장도 53-2)
deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);
} else section.selectById(hit.structureId);
} else {
openingAtStation = preferredId === null;
try {
section.selectById(hit.structureId);
} finally {
openingAtStation = false;
}
}
return { chainageM, structureId: hit.structureId };
},
};
+1 -4
View File
@@ -418,10 +418,7 @@ export function createSectionView(
// 적혀 있어 무엇을 보는지는 이미 분명하다.
const grid = document.createElement("div");
grid.className = "b06-section__grid";
// 카드 사이 빈 자리를 누르면 선택 해제 — 카드 안 클릭은 카드가 먼저 처리한다.
grid.addEventListener("click", (event) => {
if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection();
});
// 카드 사이 · 밖 빈 자리 누름은 고름을 안 풂 — 풀기는 Esc · 같은 카드 다시 누름(233-4)
// 노면공 형상 누름 = 목록 고름(범위 카드 전부 · 형상 강조) — 카드 고름보다 먼저 가로챔(189-1)
grid.addEventListener(
"click",
@@ -15,6 +15,7 @@ import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations";
import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
import { pickGroups } from "../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
/** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */
export interface SectionStructureEdit {
@@ -57,7 +58,7 @@ export function moveMarkById(
if (!station) return;
// 측점선 id 로 온 경우 — 그 측점 자리에 선 구조물을 찾는다(관 매칭과 같은 0.51m).
const near = input.structures.find(
(entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < 0.51,
(entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < PIPE_MATCH_M,
);
if (near) moveStructureMark(input.structures, input.edit, String(near.structure_id), toChainageM);
}
@@ -15,7 +15,7 @@ import {
type DesignPageShared,
} from "../A00_Common/design/design_shared";
import { readState } from "../A00_Common/b_page_state";
import { coversChainage } from "../A00_Common/design/design_selection";
import { coversChainage, PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch";
import type {
CrossSection,
@@ -40,7 +40,7 @@ import {
} from "./B06_Section_UI_Section_View_Menu";
/** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */
const MATCH_M = 0.51;
const MATCH_M = PIPE_MATCH_M;
/** 종단 우클릭의 옛 자유 텍스트 구조물 → 레지스트리 타입(종단 보기와 같은 매핑). */
const LEGACY_TYPE_IDS: Record<string, string> = {
기성막이: "revetment",
@@ -0,0 +1,91 @@
"""B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함.
⚠ 짝 = `B08_Quantity_Milestone_Calc.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_244_1_b08_milestone.py`.
조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈.
표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면
시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 ·
포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) ·
끝수 = 소수 셋째 자리(반올림).
구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고
pick = 참(화면 「도로 구분 고르기」).
도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간 = 2차포장 · 1차포장 · 비포장 ·
그 밖 = 포장 · 비포장 ·
포장 = 2차 + 1차 + 포장.
"""
from __future__ import annotations
import math
from typing import Any
ROADS = ("2차포장", "1차포장", "포장", "비포장")
KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"}
def r3(value: float) -> float:
"""소수 셋째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음)."""
return math.floor(value * 1000 + 0.5) / 1000
def leg_km(leg: dict[str, Any]) -> float | None:
"""구간 km — 사용자 값 우선 · 없으면 자동 값."""
km = leg.get("km")
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
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
def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float:
"""시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000."""
station = (data.get("site") or {}).get("mid_station_m")
if station is None:
station = mid_default_m
return float(station or 0) / 1000
def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list[dict[str, Any]]:
"""표 줄 — 경로 차례 · 경로 안 줄 차례."""
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
if row.get("dest") == "mid":
add_legs(sums, [mid])
paved = sums["s2"] + sums["s1"] + sums["pv"]
out.append(
{
"route": route.get("id"),
"index": index,
"item": row.get("item") or "",
"company": route.get("name") or "",
"dest": row.get("dest") or "start",
"s2": r3(sums["s2"]),
"s1": r3(sums["s1"]),
"pv": r3(sums["pv"]),
"un": r3(sums["un"]),
"paved": r3(paved),
"unpaved": r3(sums["un"]),
"total": r3(paved + sums["un"]),
"note": row.get("note") or "",
"pick": pick,
}
)
return out
+151
View File
@@ -0,0 +1,151 @@
/* =============================================================================
* B08_Quantity_Milestone_Calc.ts
* B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림).
*
* ⚠ 짝 = `B08_Quantity_Milestone_Calc.py`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_244_1_b08_milestone.py`.
* 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈.
*
* 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면
* 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 ·
* 끝수 = 소수 셋째 자리(반올림)
* 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고
* pick = 참(화면 「도로 구분 고르기」)
* 도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 ·
* 비포장 · 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장 · 포장 = 2차 + 1차 + 포장
* ========================================================================== */
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차포장", "비포장"] : ["포장", "비포장"];
/** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */
export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const;
export type Dir = (typeof DIRS)[number];
export type Dest = "start" | "mid";
export interface Leg {
/** null = 사용자가 안 고름(자동 판정 없음 · 244-7) */
road: Road | null;
/** 사용자 값 — 있으면 우선 */
km: number | null;
/** [길찾기] 자동 값(km 만 저장 · 경로 좌표 · 응답은 저장 안 함) */
auto_km?: number | null;
}
export interface MilestoneRow {
item: string;
dest: Dest;
note: string;
}
export interface MilestoneRoute {
id: string;
/** 업체 이름 = 하위 탭 이름 */
name: string;
address: string;
/** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */
via_base: boolean;
dir: Dir | null;
lon?: number | null;
lat?: number | null;
/** 업체 → 기준점(거침) · 시점 구간 */
legs: Leg[];
rows: MilestoneRow[];
}
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[];
};
routes: MilestoneRoute[];
}
export interface MilestoneTableRow {
route: string;
index: number;
item: string;
company: string;
dest: Dest;
s2: number;
s1: number;
pv: number;
un: number;
paved: number;
unpaved: number;
total: number;
note: string;
/** 도로 구분 안 고른 구간이 있음 */
pick: boolean;
}
type Key = "s2" | "s1" | "pv" | "un";
const KEYS: Record<string, Key> = { "2차포장": "s2", "1차포장": "s1", 포장: "pv", 비포장: "un" };
/** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */
export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000;
/** 구간 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;
}
/** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */
export function midKm(data: MilestoneData, midDefaultM: number | null): number {
const station = data.site?.mid_station_m ?? midDefaultM;
return Number(station ?? 0) / 1000;
}
/** 표 줄 — 경로 차례 · 경로 안 줄 차례 */
export function computeMilestone(
data: MilestoneData,
midDefaultM: number | null,
): MilestoneTableRow[] {
const base = data.base;
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]);
const paved = sums.s2 + sums.s1 + sums.pv;
out.push({
route: route.id,
index,
item: row.item ?? "",
company: route.name ?? "",
dest: row.dest ?? "start",
s2: r3(sums.s2),
s1: r3(sums.s1),
pv: r3(sums.pv),
un: r3(sums.un),
paved: r3(paved),
unpaved: r3(sums.un),
total: r3(paved + sums.un),
note: row.note ?? "",
pick,
});
});
}
return out;
}
@@ -0,0 +1,184 @@
"""B08 거리이정표 경로 그림 자동 캡처(PLAN 244-11 · 사용자 139번) — 서버 헤드리스 Edge.
[등록] · [길찾기] 뒤 화면이 그 경로의 길 선(마지막 길찾기 경로 좌표) · 표지를 보냄 →
서버가 헤드리스 Edge(Playwright channel msedge · 없으면 chrome · 번들 chromium 안 씀)로
카카오 지도 JS + 길 선 + 표지 + 경로 맞춤 → tilesloaded 뒤 1200×800 PNG → `<폴더>/<경로 id>.png`
(사용자 그림이 있으면 화면은 사용자 그림 우선).
페이지 주소 = .env KAKAO_CAPTURE_ORIGIN(없으면 http://localhost:5173) + /__route_capture —
page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · vite 안 떠도 됨).
경로 좌표는 그림 만든 뒤 버림(저장 = 그림 · km) · 동시 요청 = 하나씩(잠금) · 실패 = 까닭 한 줄 ·
키 값은 돌려주지도 적지도 않음.
캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 ·
키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가
드라이버 하위 프로세스를 못 만듦(249-1).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import threading
import time
import urllib.parse
from pathlib import Path
from typing import Any
KAKAO_JS = "KAKAO_JS_KEY"
ORIGIN_KEY = "KAKAO_CAPTURE_ORIGIN"
DEFAULT_ORIGIN = "http://localhost:5173"
PAGE = "/__route_capture"
WIDTH, HEIGHT = 1200, 800
TIMEOUT_S = 20
CHILD_TIMEOUT_S = 25 # 자식 프로세스 전체(파이썬 · 드라이버 기동 포함)
CHILD = __file__ # 자식으로 돌릴 스크립트
NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움"
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색
TAIL = " — 그림 올리기를 쓰세요."
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초)
class CaptureError(Exception):
"""까닭 한 줄(키 · 예외 원문 없음)."""
def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail."""
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>
<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 }});
D.lines.forEach((l) => new K.Polyline({{ map, path: l.map(at), strokeWeight: 6,
strokeColor: "{LINE_COLOR}", strokeOpacity: 0.9 }}));
D.marks.forEach((m) => {{
const p = at([m.lat, m.lon]);
new K.Marker({{ map, position: p, title: m.name }});
const s = document.createElement("span");
s.textContent = m.name;
s.style.cssText = "background:#fff;border:1px solid #333;border-radius:4px;padding:2px 6px;"
+ "font:600 13px sans-serif;white-space:nowrap";
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);
}});</script></body></html>"""
def shoot(html: str, origin: str) -> bytes:
"""헤드리스 브라우저로 그 페이지 → 지도 칸 PNG. 실패 = CaptureError(까닭 한 줄)."""
try:
from playwright.sync_api import Error as PwError
from playwright.sync_api import TimeoutError as PwTimeout
from playwright.sync_api import sync_playwright
except ImportError as error:
raise CaptureError("서버에 Playwright 가 없습니다(pip install playwright)") from error
url = origin + PAGE
deadline = time.monotonic() + TIMEOUT_S
def left() -> float:
return max(1.0, deadline - time.monotonic()) * 1000
try:
with sync_playwright() as p:
browser = None
for channel in CHANNELS:
try:
browser = p.chromium.launch(channel=channel, headless=True, timeout=left())
break
except PwError:
continue
if browser is None:
raise CaptureError(NO_BROWSER)
try:
page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT})
page.route(
url,
lambda route: route.fulfill(
status=200, content_type="text/html; charset=utf-8", body=html
),
)
page.goto(url, timeout=left())
page.wait_for_function("window.__done || window.__fail", timeout=left())
if page.evaluate("window.__fail"):
raise CaptureError(
f"카카오 지도 JS 가 캡처 주소({origin})를 받지 않음(키 도메인 등록 확인)"
)
page.wait_for_timeout(500) # 마지막 타일 · 표지 그림
return page.locator("#m").screenshot(timeout=left())
finally:
browser.close()
except CaptureError:
raise
except PwTimeout as error:
raise CaptureError(f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남") from error
except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음
raise CaptureError(NO_BROWSER) from error
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"])
except CaptureError as error:
return {"saved": False, "reason": str(error)}
out_dir = Path(job["out"])
out_dir.mkdir(parents=True, exist_ok=True)
new = out_dir / f".{job['route_id']}.png.new"
new.write_bytes(png)
new.replace(out_dir / f"{job['route_id']}.png")
return {"saved": True, "reason": None}
def spawn(job: dict[str, Any]) -> dict[str, Any]:
"""자식 파이썬 프로세스로 capture — 서버 이벤트 루프 정책과 무관(기본 Proactor)."""
try:
done = subprocess.run(
[sys.executable, CHILD],
input=json.dumps(job).encode(),
capture_output=True,
timeout=CHILD_TIMEOUT_S,
)
except subprocess.TimeoutExpired:
return {"saved": False, "reason": f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남"}
except OSError:
return {"saved": False, "reason": NO_BROWSER}
try:
return json.loads(done.stdout.decode().strip().splitlines()[-1])
except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음
return {"saved": False, "reason": NO_BROWSER}
def make_route_image(
out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]]
) -> 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}",
}
if not any(lines) and not marks:
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}
with _lock:
got = spawn({**job, "origin": origin})
if not got.get("saved"):
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"}
return {"saved": True, "reason": None}
if __name__ == "__main__":
print(json.dumps(capture(json.loads(sys.stdin.buffer.read()))))
@@ -0,0 +1,569 @@
/* =============================================================================
* B08_Quantity_Milestone_Layout.ts
* 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림).
*
* 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 거래처들
* 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면
* 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 ·
* 같은 방위 여럿 = 옆으로 나란히
* 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5)
* 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
* 화면 = 북 위(SVG y 아래로 = 남)
* 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌)
* [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」
* 연결선 구간(244-13) = `segmentEnds` · 도로 구분 갈래 = `chainVia`(기준점 거침 = 2차 · 1차 · 비포장)
* 바탕(244-12) = 공사지 둘레 거래처 · 기준점 자리까지 담는 고정 범위 `BASE_VIEW` · 넘는 상자가 생길 때만 넓힘
* 작은 글씨(244-12) = 시점 · 중점 주소 · 좌표(위도, 경도 소수 5자리) · 거래처 주소 — 길면 줄임표(`clip`) + 호버
* 여유(244-12 ④) = 상자 사이 GAP(겹치면 바깥 방향으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 ·
* 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표
* ========================================================================== */
import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc";
export interface DiagramBox {
id: string;
kind: "site" | "start" | "mid" | "base" | "route";
x: number;
y: number;
w: number;
h: number;
label: string;
/** 거래처 상자 윗줄 — 표 줄 항목(244-6 · 예 「장비」) */
items?: string;
/** 이름 아래 작은 글씨 줄(244-12 · 주소 · 좌표) */
notes?: string[];
/** 호버 = 줄이지 않은 주소 */
tip?: string;
}
export interface DiagramLine {
from: string;
to: string;
x1: number;
y1: number;
x2: number;
y2: number;
labels: string[];
/** 글마다 호버(사용자 km ≠ 자동 값이면 「자동 값 ○○ km」 · 아니면 "") */
tips: string[];
/** 선 글 자리(가운데 · 244-12 ④ 상자 · 다른 선 · 다른 글 피함) · 폭 · 높이 */
lx: number;
ly: number;
lw: number;
lh: number;
}
export interface DiagramLayout {
boxes: DiagramBox[];
lines: DiagramLine[];
view: [number, number, number, number];
}
type LonLat = [number, number] | null | undefined;
/** 8방 → 방위각(북 0 · 시계 방향) */
export const DIR_DEG: Record<Dir, number> = {
N: 0,
NE: 45,
E: 90,
SE: 135,
S: 180,
SW: 225,
W: 270,
NW: 315,
};
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;
export const BASE_VIEW: [number, number, number, number] = [
-(REACH + BOX.w / 2 + PAD),
-(REACH + BOX.h / 2 + PAD),
2 * (REACH + BOX.w / 2 + PAD),
2 * (REACH + BOX.h / 2 + PAD),
];
/** 여유(244-12 ④) — 상자 사이 · 선 끝 · 상자 안 글 */
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 };
const KM_LINE = 16;
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** 글 폭 어림 — 한글 · 한자 = 글 크기 · 나머지 = 0.6 배 */
export const textW = (text: string, size: number): number =>
[...text].reduce(
(w, ch) =>
w + (/[\u1100-\u11ff\u3130-\u318f\u3400-\u9fff\uac00-\ud7a3]/.test(ch) ? 1 : 0.6) * size,
0,
);
/** 한 줄 — 폭 넘으면 줄임표 */
export function clip(text: string, maxW: number, size: number): string {
if (textW(text, size) <= maxW) return text;
let out = "";
for (const ch of text) {
if (textW(`${out}${ch}…`, size) > maxW) break;
out += ch;
}
return `${out}…`;
}
/** 상자 작은 글씨 — 주소(줄임) · 좌표(위도, 경도 소수 5자리) */
const notesOf = (w: number, address: string | null | undefined, lonlat?: LonLat): string[] => [
...(address ? [clip(address, w - 2 * INSET, FONT.note)] : []),
...(lonlat ? [`${lonlat[1].toFixed(5)}, ${lonlat[0].toFixed(5)}`] : []),
];
/** 두 네모가 gap 안으로 붙거나 겹침 */
export const hits = (a: Rect, b: Rect, gap = 0): boolean =>
a.x < b.x + b.w + gap && b.x < a.x + a.w + gap && a.y < b.y + b.h + gap && b.y < a.y + a.h + gap;
/** 선분이 네모에 들어가는 자리(0~1 · Liang–Barsky) · 안 지나면 null */
function segEnter(x1: number, y1: number, x2: number, y2: number, r: Rect): number | null {
let t0 = 0;
let t1 = 1;
const dx = x2 - x1;
const dy = y2 - y1;
for (const [p, q] of [
[-dx, x1 - r.x],
[dx, r.x + r.w - x1],
[-dy, y1 - r.y],
[dy, r.y + r.h - y1],
]) {
if (p === 0) {
if (q < 0) return null;
continue;
}
const t = q / p;
if (p < 0) t0 = Math.max(t0, t);
else t1 = Math.min(t1, t);
if (t0 > t1) return null;
}
return t0;
}
/** 선분이 네모를 지남 */
export const segHits = (x1: number, y1: number, x2: number, y2: number, r: Rect): boolean =>
segEnter(x1, y1, x2, y2, r) !== null;
/** 상자 가운데 → 테두리까지 = 선 방향(dx, dy)의 몇 배 */
const toEdge = (b: Rect, dx: number, dy: number): number =>
Math.min(dx ? b.w / 2 / Math.abs(dx) : Infinity, dy ? b.h / 2 / Math.abs(dy) : Infinity);
/** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */
export function bearingDeg(from: [number, number], to: [number, number]): number {
const dx = (to[0] - from[0]) * Math.cos((((from[1] + to[1]) / 2) * Math.PI) / 180);
const dy = to[1] - from[1];
return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360;
}
/** 자리 방위 — 경위도 둘 다 → 방위각 · 아니면 8방 · 없으면 null */
export function placeDeg(
own: { dir: Dir | null; lon?: number | null; lat?: number | null },
anchor: LonLat,
): number | null {
if (own.lon != null && own.lat != null && anchor) return bearingDeg(anchor, [own.lon, own.lat]);
return own.dir ? DIR_DEG[own.dir] : null;
}
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`;
});
/** 자동 값 호버 — 사용자 km 가 자동 값과 다를 때만 */
export const legTip = (leg: Leg): string =>
leg.auto_km != null && leg.km != null && leg.km !== leg.auto_km
? `자동 값 ${leg.auto_km} km`
: "";
/** [길찾기] 자동 km → 그 구간 첫 줄(없으면 도로 구분 안 고른 줄 하나) · 사용자 km 는 그대로 */
export function setAutoKm(legs: Leg[], km: number): Leg[] {
if (!legs.length) legs.push({ road: null, km: null, auto_km: km });
else legs[0].auto_km = km;
return legs;
}
export interface KmSegment {
/** route = 거래처 구간 · base = 기준점 → 시점 공통 구간 */
key: "route" | "base";
text: string;
from: [number, number] | null;
to: [number, number] | null;
}
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(시점 → 중점 = 손 입력 · 길찾기 안 함) */
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;
return {
key: "route",
text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`,
from: pointOf(route),
to: viaBase ? pointOf(data.base) : (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;
}
/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점
* (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */
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];
}
/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */
export function mapMarks(
data: MilestoneData,
routeId: string,
start: LonLat,
): Array<{ name: string; lon: number; lat: number }> {
const route = data.routes.find((r) => r.id === routeId);
if (!route) return [];
const out: Array<{ name: string; lon: number; lat: number }> = [];
const add = (name: string, p: [number, number] | null): void => {
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));
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;
if (legs) setAutoKm(legs, km);
return data;
}
const unit = (deg: number): [number, number] => {
const rad = (deg * Math.PI) / 180;
return [Math.sin(rad), -Math.cos(rad)];
};
/** 선 = 두 상자 테두리에서 끊김(④ · 상자 안으로 안 들어감) · end = 끝 테두리(바깥 → 시점 = 공사지 바탕 테두리 ·
* 바탕 안 중점 · 시점 상자를 가로지르지 않음) · 글 자리는 `placeLabels` */
function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): DiagramLine {
const [ax, ay] = [a.x + a.w / 2, a.y + a.h / 2];
const [bx, by] = [b.x + b.w / 2, b.y + b.h / 2];
const [dx, dy] = [bx - ax, by - ay];
const len = Math.hypot(dx, dy) || 1;
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);
return {
from: a.id,
to: b.id,
x1: ax + dx * t1,
y1: ay + dy * t1,
x2: ax + dx * t2,
y2: ay + dy * t2,
labels,
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,
};
}
export const labelRect = (ln: DiagramLine): Rect => ({
x: ln.lx - ln.lw / 2,
y: ln.ly - ln.lh / 2,
w: ln.lw,
h: ln.lh,
});
/** 선 글 자리(④) — 선 위 가운데부터 앞뒤 · 위아래 차례로 보아 상자 · 다른 선 · 다른 글을 안 덮는 첫 자리
* (시점 → 중점 글은 공사지 바탕 안이 제자리) · 다 덮이면 가장 적게 덮는 자리 */
function placeLabels(lines: DiagramLine[], boxes: DiagramBox[]): void {
const placed: Rect[] = [];
for (const ln of lines) {
if (!ln.labels.length) continue;
const [dx, dy] = [ln.x2 - ln.x1, ln.y2 - ln.y1];
const len = Math.hypot(dx, dy) || 1;
const [px, py] = [-dy / len, dx / len];
const side = (Math.abs(px) * ln.lw + Math.abs(py) * ln.lh) / 2 + 4;
const score = (r: Rect): number =>
boxes.filter((b) => !(b.kind === "site" && ln.from === "start") && hits(r, b, 2)).length +
lines.filter((o) => o !== ln && segHits(o.x1, o.y1, o.x2, o.y2, r)).length +
placed.filter((p) => hits(r, p, 2)).length;
let best: { r: Rect; s: number; x: number; y: number } | null = null;
for (const t of [0.5, 0.4, 0.6, 0.3, 0.7, 0.2, 0.8]) {
for (const o of [0, side, -side]) {
const x = ln.x1 + dx * t + px * o;
const y = ln.y1 + dy * t + py * o;
const r = labelRect({ ...ln, lx: x, ly: y });
const s = score(r);
if (!best || s < best.s) best = { r, s, x, y };
if (s === 0) break;
}
if (best?.s === 0) break;
}
ln.lx = best!.x;
ln.ly = best!.y;
placed.push(best!.r);
}
}
export function layoutMilestone(
data: MilestoneData,
site: {
start_lonlat?: LonLat;
mid_lonlat?: LonLat;
start_address?: string | null;
mid_address?: string | null;
} | null,
midKm: number,
): DiagramLayout {
const boxes: DiagramBox[] = [];
const pending: Array<[string, string, Leg[]]> = [];
const byId = new Map<string, DiagramBox>();
const center = (id: string): [number, number] => {
const b = byId.get(id)!;
return [b.x + b.w / 2, b.y + b.h / 2];
};
/** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */
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++) {
box.x += out[0] * 4;
box.y += out[1] * 4;
}
boxes.push(box);
byId.set(box.id, box);
};
const line = (from: string, to: string, legs: Leg[]): void => {
pending.push([from, to, legs]);
};
// 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) · 사이 = 시점 → 중점 선 · 글
put({
id: "site",
kind: "site",
x: -SITE.w / 2,
y: -SITE.h / 2,
w: SITE.w,
h: SITE.h,
label: "공사지",
});
for (const [id, label, x, address, lonlat] of [
["start", "시점", -SITE.w / 2 + 12, site?.start_address, site?.start_lonlat],
["mid", "중점", SITE.w / 2 - 12 - NODE.w, site?.mid_address, site?.mid_lonlat],
] as const) {
put({
id,
kind: id,
x,
y: -SITE.h / 2 + 34,
w: NODE.w,
h: NODE.h,
label,
notes: notesOf(NODE.w, address, lonlat),
tip: address ?? undefined,
});
}
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;
put(
{
id: "base",
kind: "base",
x: cx - BOX.w / 2,
y: cy - BOX.h / 2,
w: BOX.w,
h: BOX.h,
label: data.base.name || "기준점",
},
[ux, uy],
);
line("base", "start", data.base.legs);
}
// 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히
const groups = new Map<
string,
Array<{
id: string;
label: string;
items: string;
address: string;
anchor: string;
deg: number;
legs: Leg[];
}>
>();
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) };
});
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(" · ");
groups.get(key)!.push({
id: r.id,
label: r.name || "거래처",
items,
address: r.address,
anchor,
deg: d,
legs: r.legs,
});
}
for (const group of groups.values()) {
group.forEach((g, k) => {
const [ax, ay] = center(g.anchor === "base" ? "base" : "site");
const [ux, uy] = unit(g.deg);
// 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④)
const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP;
const off = (k - (group.length - 1) / 2) * spread;
const reach = g.anchor === "site" ? R_BASE : R_ROUTE; // 공사지 둘레 = 기준점과 같은 거리(선 글 자리)
const cx = ax + ux * reach - uy * off;
const cy = ay + uy * reach + ux * off;
put(
{
id: g.id,
kind: "route",
x: cx - BOX.w / 2,
y: cy - BOX.h / 2,
w: BOX.w,
h: BOX.h,
label: g.label,
items: g.items,
notes: notesOf(BOX.w, g.address),
tip: g.address,
},
[ux, uy],
);
line(g.id, g.anchor === "base" ? "base" : "start", g.legs);
});
}
const lines = pending.map(([from, to, legs]) =>
edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!),
);
placeLabels(lines, boxes);
// 바탕 = 고정 범위 · 넘는 상자가 있을 때만 그쪽으로 넓힘(새 상자가 와도 다시 줌아웃 없음)
const [bx, by, bw, bh] = BASE_VIEW;
const xs = [bx + PAD, bx + bw - PAD, ...boxes.flatMap((b) => [b.x, b.x + b.w])];
const ys = [by + PAD, by + bh - PAD, ...boxes.flatMap((b) => [b.y, b.y + b.h])];
const minX = Math.min(...xs) - PAD;
const minY = Math.min(...ys) - PAD;
return {
boxes,
lines,
view: [minX, minY, Math.max(...xs) + PAD - minX, Math.max(...ys) + PAD - minY],
};
}
/** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/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"(시점 → 중점) */
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;
}
/** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */
export function setLeg(
data: MilestoneData,
from: string,
i: number,
patch: { road?: Leg["road"]; km?: number | null },
): MilestoneData {
if (from === "start") {
if (patch.road) data.site.mid_road = patch.road;
if (patch.km !== undefined)
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];
if (leg) Object.assign(leg, patch);
return data;
}
/** 다이어그램 팬 시작 — 가운데 버튼 · 빈 곳 왼쪽 끌기(상자 · 선 글 · [+] 위는 그 화면 고르기 몫 · 244-6) */
export const PAN_SKIP =
".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus, .b08-milestone__line-hit";
export function panStart(e: { button: number; target: EventTarget | null }): boolean {
if (e.button === 1) return true;
const target = e.target as { closest?: (selector: string) => unknown } | null;
return e.button === 0 && !target?.closest?.(PAN_SKIP);
}
/** 팬 한계 — 원배율에서도 그림 둘레 한 폭씩 끌어 옮길 수 있게(공용 팬은 이 범위까지 엶) */
export const panContent = (w: number, h: number) => ({ x: -w, y: -h, width: 3 * w, height: 3 * h });
+1 -1
View File
@@ -219,7 +219,7 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any])
" ".join(filter(None, (names[k]["name"], names[k]["spec"], names[k]["unit"])))
for k in lost
)
notes.append(f"2공구 열 맞춤 없음 {len(lost)}종(총괄 끝 줄 그대로) — {listed}")
notes.append(f"2공구 열 맞춤 없음 {len(lost)}종 — {listed}")
return {
"source": source,
"overrides": overrides,
@@ -0,0 +1,440 @@
"""B08 거리이정표 길(PLAN 244-1) — 공사지 · 기준점 · 경로(업체) · 표 · 경로 그림. DB 표 없음.
GET /api/projects/{id}/quantity/milestone {data, site, table, images}
PUT /api/projects/{id}/quantity/milestone [저장] — 입력만 · 표는 서버가 셈
POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체)
GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기
DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기(자동 그림까지) ·
&user_only=true = 사용자 그림만(「자동 그림으로 되돌리기」 · 244-11)
POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 ·
표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` ·
좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄
GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 ·
시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램
지금 중점 측점(244-12 · 상자 작은 글씨)
POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오
길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함)
+ 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함)
GET /api/projects/{id}/quantity/milestone/reverse?lon=&lat= 지도에서 고른 소재지(244-13) →
VWorld 주소 한 줄(서버 단독 · 저장 안 함 · 못 찾음 = null)
GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 ·
공개용 · .env KAKAO_JS_KEY · 없으면 null)
정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 사용자
그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자
그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님).
그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움.
공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례
(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례).
표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝).
"""
from __future__ import annotations
import asyncio
import json
import math
import os
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, 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_Router_Hydro import _project_root
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 (
expected_route_csv_path,
interpolate_vertex,
planned_route_initial_path,
planned_route_working_path,
read_planned_route_csv,
)
router = APIRouter(prefix="/api/projects", tags=["B08 Milestone"])
PATH = "/{project_id}/quantity/milestone"
FOLDER = Path("B08_Quantity") / "milestone"
DATA_FILE = "milestone.json"
AUTO = "auto" # 자동 그림 하위 폴더(244-11)
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13)
Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
class Leg(BaseModel):
road: Road | None = "2차포장" # None = 사용자가 안 고름(244-7 · 표에 「도로 구분 고르기」)
km: float | None = Field(default=None, ge=0, le=10000) # 사용자 값 — 있으면 우선
auto_km: float | None = Field(default=None, ge=0, le=10000) # [길찾기] 자동 값(km 만 저장)
class Row(BaseModel):
item: str = Field(default="", max_length=100)
dest: Literal["start", "mid"] = "start"
note: str = Field(default="", max_length=200)
class Place(BaseModel):
name: str = Field(default="", max_length=100)
address: str = Field(default="", max_length=200)
dir: Dir | None = None
lon: float | None = Field(default=None, ge=-180, le=180)
lat: float | None = Field(default=None, ge=-90, le=90)
legs: list[Leg] = Field(default_factory=list, max_length=20)
class Route(Place):
id: str = Field(pattern=ROUTE_ID)
via_base: bool = True
rows: list[Row] = Field(default_factory=list, max_length=50)
class Base(Place):
enabled: bool = False
class Site(BaseModel):
mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000)
mid_road: Road = "비포장"
class Milestone(BaseModel):
site: Site = Field(default_factory=Site)
base: Base = Field(default_factory=Base)
routes: list[Route] = Field(default_factory=list, max_length=100)
@model_validator(mode="after")
def _unique_ids(self) -> Milestone:
ids = [route.id for route in self.routes]
if len(ids) != len(set(ids)):
raise ValueError("경로 id 가 겹칩니다.")
return self
def folder(root: str | Path) -> Path:
return Path(root) / FOLDER
def read_data(root: str | Path) -> dict[str, Any]:
"""저장본 — 없거나 깨졌으면 빈 꼴."""
path = folder(root) / DATA_FILE
try:
return Milestone.model_validate_json(path.read_text(encoding="utf-8")).model_dump()
except (OSError, ValidationError, json.JSONDecodeError):
return Milestone().model_dump()
def _route_vertices(root: Path) -> list[Any]:
for path in (
planned_route_working_path(root),
planned_route_initial_path(root),
expected_route_csv_path(root),
design_route_csv_path(root),
):
route = read_planned_route_csv(path) if path.is_file() else None
if route is not None and len(route.vertices) >= 2:
return route.vertices
return []
def _lonlat(root: Path, x: float, y: float) -> list[float] | None:
"""작업 좌표계 → 경도 · 위도(다이어그램 방위 · 244-3 지도) — 못 하면 None."""
try:
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
crs = project_epsg_from_prj(root)
lon, lat = Transformer.from_crs(crs, "EPSG:4326", always_xy=True).transform(x, y)
return [round(lon, 7), round(lat, 7)]
except Exception: # 좌표계 못 읽음 = 좌표 없음(방위는 사용자 8방)
return None
def site_info(root: str | Path, mid_station_m: float | None) -> dict[str, Any]:
"""노선 연장 · 중점 기본(연장 ÷ 2) · 시점 · 중점 경위도."""
root = Path(root)
vertices = _route_vertices(root)
if not vertices:
return {"length_m": None, "mid_default_m": None, "start_lonlat": None, "mid_lonlat": None}
length = vertices[-1].chainage_m
mid_default = r3(length / 2)
mx, my, _ = interpolate_vertex(
vertices, mid_default if mid_station_m is None else mid_station_m
)
return {
"length_m": r3(length),
"mid_default_m": mid_default,
"start_lonlat": _lonlat(root, vertices[0].x, vertices[0].y),
"mid_lonlat": _lonlat(root, mx, my),
}
def route_images(root: str | Path, route_id: str, sub: str = "") -> list[Path]:
path = folder(root) / sub
if not path.is_dir():
return []
return sorted(
f for f in path.glob(f"{route_id}.*") if f.is_file() and f.suffix.lower() in IMAGE_EXT
)
def images(root: str | Path, sub: str = "") -> dict[str, str]:
"""경로 id → 그림 파일 이름(폴더가 목록 · sub = 하위 폴더 · 이름에 붙음)."""
path = folder(root) / sub
if not path.is_dir():
return {}
return {
f.stem: f"{sub}/{f.name}" if sub else f.name
for f in sorted(path.iterdir())
if f.is_file() and f.suffix.lower() in IMAGE_EXT and not f.name.startswith(".")
}
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)
return {
"data": data,
"site": site,
"table": compute_milestone(data, site["mid_default_m"]),
"images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선
"auto_images": auto,
}
def save_data(root: str | Path, body: Milestone) -> dict[str, Any]:
data = body.model_dump()
path = folder(root)
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 stem, name in images(root, sub).items():
if stem not in keep:
(path / name).unlink(missing_ok=True)
return payload(root, data)
def _route_id(route: str) -> str:
try:
return Route(id=route).id
except ValidationError as error:
raise HTTPException(status_code=400, detail="경로 id 가 바르지 않습니다.") from error
@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)))
@router.put(PATH)
async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]:
root = await _project_root(project_id)
return await asyncio.to_thread(save_data, root, body)
@router.get(PATH + "/site-address")
async def milestone_site_address(
project_id: UUID, mid_m: Annotated[float | None, Query(ge=0)] = None
) -> dict[str, Any]:
"""시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소.
mid_m = 다이어그램 지금 중점 측점(캐시 · 244-12) · 없으면 저장본."""
root = await _project_root(project_id)
data = await asyncio.to_thread(read_data, root)
mid = data["site"]["mid_station_m"] if mid_m is None else mid_m
site = await asyncio.to_thread(site_info, root, mid)
project = await get_project_by_id(str(project_id)) or {}
async def where(lonlat: list[float] | None) -> str | None:
return await reverse_geocode(*lonlat) if lonlat else None
start, mid = await asyncio.gather(where(site["start_lonlat"]), where(site["mid_lonlat"]))
return {
"region": project.get("region") or "",
"start_lonlat": site["start_lonlat"],
"mid_lonlat": site["mid_lonlat"],
"start_address": start,
"mid_address": mid,
}
@router.get(PATH + "/reverse")
async def milestone_reverse(
project_id: UUID,
lon: Annotated[float, Query(ge=-180, le=180)],
lat: Annotated[float, Query(ge=-90, le=90)],
) -> dict[str, Any]:
"""지도에서 고른 소재지(244-13) — 좌표 → VWorld 주소(못 찾음 = null)."""
return {"address": await reverse_geocode(lon, lat)}
@router.post(PATH + "/image")
async def milestone_image_upload(
project_id: UUID, route: str = Query(...), file: UploadFile = File(...)
) -> dict[str, Any]:
route_id = _route_id(route)
ext = Path(file.filename or "").suffix.lower()
if ext not in IMAGE_EXT:
raise HTTPException(status_code=400, detail="그림 파일(png · jpg · gif · webp · bmp)만.")
root = await _project_root(project_id)
path = folder(root)
path.mkdir(parents=True, exist_ok=True)
try:
await save_upload_stream(file, path / f".{route_id}{ext}.new")
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
for old in route_images(root, route_id):
old.unlink(missing_ok=True)
(path / f".{route_id}{ext}.new").replace(path / f"{route_id}{ext}")
return {"name": f"{route_id}{ext}"}
@router.get(PATH + "/image")
async def milestone_image(project_id: UUID, route: str = Query(...)):
route_id = _route_id(route)
root = await _project_root(project_id)
found = route_images(root, route_id) + route_images(root, route_id, AUTO)
if not found:
raise HTTPException(status_code=404, detail="경로 그림이 없습니다.")
return FileResponse(found[0]) # 사용자 그림 우선
@router.delete(PATH + "/image")
async def milestone_image_delete(
project_id: UUID, route: str = Query(...), user_only: bool = False
) -> dict[str, Any]:
route_id = _route_id(route)
root = await _project_root(project_id)
auto = route_images(root, route_id, AUTO)
for old in route_images(root, route_id) + ([] if user_only else auto):
old.unlink(missing_ok=True)
return {"name": f"{AUTO}/{auto[0].name}" if user_only and auto else None}
class Mark(BaseModel):
name: str = Field(default="", max_length=100)
lon: float = Field(ge=-180, le=180)
lat: float = Field(ge=-90, le=90)
Lat = Annotated[float, Field(ge=-90, le=90)]
Lon = Annotated[float, Field(ge=-180, le=180)]
class AutoImage(BaseModel):
"""길 선 = 구간마다 [위도, 경도] 목록(마지막 [길찾기] 응답) · 표지 = 거래처 · 기준점 · 시점."""
lines: list[Annotated[list[tuple[Lat, Lon]], Field(max_length=20000)]] = Field(
default_factory=list, max_length=20
)
marks: list[Mark] = Field(default_factory=list, max_length=10)
@router.post(PATH + "/image/auto")
async def milestone_image_auto(
project_id: UUID, body: AutoImage, route: str = Query(...)
) -> dict[str, Any]:
"""경로 그림 자동(244-11) — 서버 헤드리스 캡처 → auto/<경로 id>.png · 좌표는 저장 안 함."""
route_id = _route_id(route)
root = await _project_root(project_id)
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)
return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]}
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
def _kakao_open(request: urllib.request.Request) -> bytes:
with urllib.request.urlopen(request, timeout=8) as response:
return response.read()
def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]:
"""카카오 길찾기 → {km, reason, path} — km = 총 거리 ÷ 1000 소수 1자리 · 실패 = reason 한 줄 ·
path = 경로 좌표 [위도, 경도] 목록(성공 때만 · 지도 길 선 · 저장 안 함).
키 · 응답 원문은 돌려주지도 적지도 않음(까닭 = 고정 글 · 카카오 result_msg 만)."""
key = os.environ.get(KAKAO_KEY, "").strip()
if not key:
return {
"km": None,
"reason": f"카카오 길찾기 키가 없습니다(.env {KAKAO_KEY}) — km 를 손으로 넣으세요.",
}
query = urllib.parse.urlencode(
{
"origin": f"{origin.lon},{origin.lat}",
"destination": f"{destination.lon},{destination.lat}",
"priority": "RECOMMEND",
}
)
request = urllib.request.Request(
f"{KAKAO_URL}?{query}", headers={"Authorization": f"KakaoAK {key}"}
)
try:
body = json.loads(_kakao_open(request).decode("utf-8"))
except urllib.error.HTTPError as error:
reason = {
401: "카카오가 키를 받지 않았습니다(키 · 앱 설정 확인)",
403: "카카오가 길찾기를 막았습니다(앱 · 권한 확인)",
429: "오늘 카카오 길찾기 요청 수를 넘었습니다",
}.get(error.code, f"카카오 길찾기 응답 오류(HTTP {error.code})")
return {"km": None, "reason": f"{reason} — km 를 손으로 넣으세요."}
except (
Exception
): # 끊김 · 시간 넘김 · 깨진 응답 — 원문 · 예외 글은 내지 않음(키가 섞일 수 있음)
return {"km": None, "reason": "카카오 길찾기에 닿지 못했습니다 — km 를 손으로 넣으세요."}
route = ((body or {}).get("routes") or [{}])[0] if isinstance(body, dict) else {}
distance = (route.get("summary") or {}).get("distance")
if route.get("result_code") != 0 or not isinstance(distance, (int, float)):
msg = str(route.get("result_msg") or "결과 없음")[:80]
return {"km": None, "reason": f"길찾기 실패 — {msg} · km 를 손으로 넣으세요."}
path: list[list[float]] = []
try: # vertexes = 경도 · 위도 번갈아(도로마다)
for section in route.get("sections") or []:
for road in section.get("roads") or []:
v = [float(n) for n in road.get("vertexes") or []]
path += [[lat, lon] for lon, lat in zip(v[0::2], v[1::2])]
except (TypeError, ValueError, AttributeError):
path = [] # 깨진 좌표 = 길 선만 없음(km 는 그대로)
return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None, "path": path}
@router.post(PATH + "/directions")
async def milestone_directions(project_id: UUID, body: Directions) -> dict[str, Any]:
"""[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음)."""
return await asyncio.to_thread(directions_km, body.origin, body.destination)
KAKAO_JS = "KAKAO_JS_KEY"
@router.get(PATH + "/map-key")
async def milestone_map_key(project_id: UUID) -> dict[str, Any]:
"""카카오 지도 JavaScript 키(공개용 · 도메인 등록으로 막힘) — 없으면 null = 화면 안내 한 줄."""
return {"key": os.environ.get(KAKAO_JS, "").strip() or None}
+5 -8
View File
@@ -15,6 +15,7 @@ import { API_BASE_URL } from "@config/config_frontend";
import { createButton, createCard } from "@ui/ui_template_elements";
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
import { stationLabel } from "../common_util/common_util_station";
import { footNote } from "./B08_Quantity_UI_Foot";
import { stationInterval } from "./B08_Quantity_UI_Summary";
import {
renderEarthworkSummary,
@@ -216,17 +217,13 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number)
host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요."));
return;
}
const c = (kind: string): string => table.conversion_factors[kind]?.compacted.toFixed(2) ?? "";
const flagged = table.rows.filter((row) => row.notes.length);
const info = [
note(
`평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` +
`측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 암 ` +
`${table.conversion_factors.ripping_rock?.compacted.toFixed(3) ?? ""}(연암 리핑 ` +
`${table.rock_c.ripping_rock} · 경암 발파 ${table.rock_c.blasting_rock} 비율 가중) · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` +
`단위 m · ㎡ · ㎥`,
footNote(
`노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` +
`측점 ${table.station_count} · 단위 m · ㎡ · ㎥`,
),
...(flagged.length ? [note(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []),
...(flagged.length ? [footNote(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []),
];
const grid = createDataTable({
columns: COLUMNS,
@@ -9,6 +9,7 @@
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
import { note, tableCard, type EarthworkTable, type HaulList } from "./B08_Quantity_UI_Earthwork";
import { footNote } from "./B08_Quantity_UI_Foot";
const fmt = (value: number | null | undefined): string =>
value == null || !Number.isFinite(value)
@@ -26,9 +27,8 @@ const SUMMARY_COLUMNS: DataColumn[] = [
/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */
export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void {
const info = note(
"토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " +
"연암 · 경암 = 암 × 좌측 패널 비율 · 「비율 미정」 = 반영 비율 확인 뒤 채움(- = 아직 값 없음)",
const info = footNote(
"연암 · 경암은 좌측 패널 비율대로 나눈 암 · 「비율 미정」은 비율 확인 뒤 채움",
);
const grid = createDataTable({
columns: SUMMARY_COLUMNS,
@@ -50,7 +50,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable)
function haulNote(table: EarthworkTable): HTMLElement[] {
return table.haul_saved
? []
: [note("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")];
: [footNote("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")];
}
/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */
@@ -58,7 +58,7 @@ function haulSheet(
host: HTMLElement,
table: EarthworkTable,
groups: Array<[string, HaulList]>,
info: string,
caption: string,
): void {
const columns: DataColumn[] = [
{ key: "no", label: "번호", width: 56 },
@@ -98,10 +98,10 @@ function haulSheet(
rows,
frozen: 1,
total,
caption: info,
caption,
fillViewport: true,
});
host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages));
host.replaceChildren(tableCard(haulNote(table), grid.root, averages));
}
export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void {
@@ -112,7 +112,7 @@ export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): vo
["덤프트럭", table.haul.dump_truck],
["불도우저", table.haul.dozer],
],
"유토곡선 배분의 덤프 · 도저 띠와 떨어진 구간 운반 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m",
"덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m",
);
}
@@ -121,6 +121,6 @@ export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void {
host,
table,
[["종방향무대", table.haul.free_haul]],
"유토곡선 배분의 무대(20 m 안) 띠 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m",
"종방향 무대운반거리 — 단위 ㎥ · m",
);
}
+15
View File
@@ -0,0 +1,15 @@
/* =============================================================================
* B08_Quantity_UI_Foot.ts
* B08 표 아래 안내 · 경고 줄(PLAN 202-1 · 236-1) — 늘 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문.
* 한 줄만 남겨 창 끝까지 셈(199-1)에 한 줄 높이만 듦 → 페이지 스크롤 0.
* ========================================================================== */
import { attachHoverInfo } from "@ui/ui_template_hover_info";
export function footNote(text: string): HTMLElement {
const p = document.createElement("p");
p.className = "ui-general-block__empty b08-quantity__foot";
p.textContent = text;
attachHoverInfo(p, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null));
return p;
}
+11 -16
View File
@@ -34,6 +34,7 @@ import {
type MaterialSheets,
type SummarySource,
} from "./B08_Quantity_Material_Calc";
import { footNote } from "./B08_Quantity_UI_Foot";
import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options";
export type MaterialSheetId = "material" | "concrete" | "quantity";
@@ -51,11 +52,9 @@ interface Payload {
const BASIS =
"구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)";
const HINTS: Record<MaterialSheetId, string> = {
material:
"합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널",
concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음",
quantity:
"수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1 · 콘크리트(기계) · 모르터 1:3 단위수량 → 콘크리트소요",
material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널",
concrete: "㎥당 배합은 칸에서 고침",
quantity: "단위수량은 칸에 수기 입력",
};
const HIDDEN = "값 없는 줄은 숨김";
@@ -190,9 +189,7 @@ function excelDiffs(s: MaterialSheets): string {
if (want != null && (row.surcharge ?? 0) !== want)
out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`);
}
return out.length
? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거`
: "";
return out.length ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")}` : "";
}
/** 콘크리트소요 원천 = 콘크리트(기계) · 모르터 1:3 열 + 그 둘에 맞는 사본 자재 열(짝 계산 `matched` 그대로) */
@@ -213,7 +210,7 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string {
const blank = s.quantity.rows.filter((row) => [...keys].every((k) => row.units[k] == null));
const copied = blank.filter((row) => source.snapshot?.units?.[row.id]).length;
return blank.length
? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 비었음 ${blank.length}줄(사본 자재에 콘크리트 · 모르터 없음 ${copied} · 사본 자재 없음 · 미연결 ${blank.length - copied}) — 자재수량 탭 그 열에 넣음`
? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 빈 ${blank.length}줄(사본에 없음 ${copied} · 미연결 ${blank.length - copied}) — 자재수량 탭에서 넣음`
: "";
}
@@ -340,10 +337,7 @@ export async function renderMaterialSheet(
const toolbar = document.createElement("div");
toolbar.className = "b08-material__toolbar";
const notes = document.createElement("div");
notes.append(
note(BASIS),
note(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`),
);
notes.append(footNote(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`));
if (sheet !== "quantity") {
// 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로
const blank = blankUnitRows(sheets);
@@ -352,12 +346,12 @@ export async function renderMaterialSheet(
...(payload.notes ?? []),
];
if (why.length) {
const reason = note(`빈 칸 까닭 — ${why.join(" · ")}`);
const reason = footNote(`빈 칸 까닭 — ${why.join(" · ")}`);
reason.dataset.b08MaterialWhy = "";
notes.append(reason);
}
}
const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
const concreteNote = footNote(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
concreteNote.dataset.b08MaterialConcreteWhy = "";
if (sheet === "concrete") notes.append(concreteNote);
toolbar.append(save);
@@ -373,6 +367,7 @@ export async function renderMaterialSheet(
caption: BASIS,
fillViewport: true,
editable: (i, key) => built.editable(i, key),
editOnDouble: true, // 243-1 값은 더블클릭 · Enter · F2 로만 고침
onEdit: (i, key, text) => {
const value = parse(text);
if (value !== undefined) {
@@ -390,7 +385,7 @@ export async function renderMaterialSheet(
},
});
table.root.dataset.b08Material = sheet;
const excel = note("");
const excel = footNote("");
excel.dataset.b08MaterialExcel = "";
const redraw = (): void => {
built = BUILD[sheet](sheets, overrides);
+214
View File
@@ -0,0 +1,214 @@
/* B08 거리이정표(244) — 경로 컨테이너 · 단추 줄 · 그림. 색은 테마 변수만(밝음 · 어두움 같이). */
.b08-milestone {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
/* 틀 너비 = 표 너비(244-6) — 다이어그램은 너비를 보태지 않고 표에 맞춰 늘음 */
width: fit-content;
max-width: 100%;
}
.b08-milestone > .b08-milestone__diagram {
contain: inline-size;
}
.b08-milestone__route {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-16);
}
.b08-milestone__route h4 {
margin: var(--spacing-8) 0 0;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.b08-milestone__head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8) var(--spacing-16);
}
.b08-milestone__head > .ui-field {
min-width: 160px;
}
.b08-milestone__bar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.b08-milestone__image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
align-self: flex-start;
border: 1px solid var(--color-border);
}
/* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */
.b08-milestone__diagram {
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
}
.b08-milestone__diagram svg {
display: block;
margin: 0 auto;
font-size: 13px;
}
.b08-milestone__line {
stroke: var(--color-text-secondary);
stroke-width: 1.5;
}
.b08-milestone__box rect {
fill: var(--color-surface);
stroke: var(--color-text-secondary);
stroke-width: 1.2;
}
.b08-milestone__box text,
.b08-milestone__plus text {
fill: var(--color-text);
}
.b08-milestone__box.is-site rect {
fill: var(--color-surface-raised);
stroke-dasharray: 4 3;
}
.b08-milestone__box.is-base rect,
.b08-milestone__box.is-start rect,
.b08-milestone__box.is-mid rect {
stroke: var(--color-accent);
}
.b08-milestone__box.is-route {
cursor: pointer;
}
.b08-milestone__box.is-route:hover rect,
.b08-milestone__box.is-route:focus-visible rect {
stroke: var(--color-accent);
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;
paint-order: stroke;
stroke: var(--color-surface);
stroke-width: 4px;
stroke-linejoin: round;
}
/* 모달 구간 · 표 줄 — 칸 · [×] 한 줄(244-4) */
.b08-milestone__line-edit {
display: flex;
align-items: flex-end;
gap: 8px;
}
.b08-milestone__diagram svg.is-panning {
cursor: grabbing;
}
/* 선 글 바로 고침(244-4 ⑥) — 글 아래 작은 판 */
.b08-milestone__km tspan {
cursor: pointer;
}
.b08-milestone__leg-edit {
position: fixed;
z-index: 1000;
display: flex;
align-items: flex-end;
gap: 6px;
padding: 6px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.b08-milestone__leg-edit .ui-input {
width: 80px;
}
/* 거래처 상자 윗줄 = 항목(244-6) */
.b08-milestone__items {
fill: var(--color-text-secondary);
font-size: 12px;
}
/* 상자 작은 글씨 = 주소 · 좌표(244-12) */
.b08-milestone__note {
fill: var(--color-text-secondary);
font-size: 10px;
}
/* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */
.b08-milestone .is-pick-road {
color: var(--color-danger);
}
/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 */
.b08-milestone__map {
width: 100%;
height: 360px;
border: 1px solid var(--color-border);
}
.b08-milestone__map-label {
padding: 2px 6px;
border: 1px solid #3c3c3c;
background: #fff;
color: #141414;
font-size: 12px;
white-space: nowrap;
}
/* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */
.ui-modal.b08-milestone__modal {
width: min(960px, 94vw);
--modal-max: 90vh;
}
.b08-milestone__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
align-items: end;
}
.b08-milestone__place {
margin: 6px 0;
font-weight: 600;
}
/* 연결선 누름 자리 — 투명한 굵은 선(244-13) */
.b08-milestone__line-hit {
stroke: transparent;
stroke-width: 14;
pointer-events: stroke;
cursor: pointer;
}
+219
View File
@@ -0,0 +1,219 @@
/* =============================================================================
* B08_Quantity_UI_Milestone.ts
* B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(거래처마다 · 이름 = 거래처 이름).
* 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음).
*
* [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 도로 구분 · 계 · 비고) — 열(244-13) = 기준점 있음 =
* 2차포장 · 1차포장 (· 포장) · 비포장 + 계 포장 · 비포장 · 합계 / 없음 = 포장 · 비포장 · 합계
* 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈
* 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌)
* 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State`
* ========================================================================== */
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 { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
import {
liveMilestone,
loadMilestone,
onMilestoneChange,
putMilestone,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
import {
DEST_TEXT,
editRoute,
pickAtCell,
routeView,
trackCells,
type MilestoneView,
} from "./B08_Quantity_UI_Milestone_Route";
const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const;
const ROAD = ["도로 구분 (km)"];
const SUM = ["계 (km)"];
/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 ·
* 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */
export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] {
const roads: DataColumn[] = withBase
? [
{ key: "s2", label: "2차포장", group: ROAD, ...KM },
{ key: "s1", label: "1차포장", group: ROAD, ...KM },
...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []),
{ key: "un", label: "비포장", group: ROAD, ...KM },
{ key: "paved", label: "포장", group: SUM, ...KM },
{ key: "unpaved", label: "비포장", group: SUM, ...KM },
]
: [
{ key: "paved", label: "포장", group: ROAD, ...KM },
{ key: "un", label: "비포장", group: ROAD, ...KM },
];
return [
{ key: "item", label: "항목", width: 120 },
{ key: "company", label: "거래처 이름", width: 120 },
{ key: "dest", label: "목적지", width: 72 },
...roads,
{ key: "total", label: "합계", group: SUM, ...KM, width: 108 },
{ key: "note", label: "비고", width: 160 },
];
}
/** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */
export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}): string {
const data = liveMilestone(projectId);
const id = `r${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
data.routes.push({
id,
name: `거래처 ${data.routes.length + 1}`,
address: "",
via_base: true,
dir: null,
legs: [{ road: null, km: null }], // 도로 구분 = 사용자 고름(244-7)
rows: [{ item: "", dest: "start", note: "" }],
...init,
});
putMilestone(projectId, data);
return id;
}
/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */
function tableView(projectId: string): MilestoneView {
const diagram = createDiagram(projectId, (init) => addRoute(projectId, init));
let keys: Array<{ route: string; index: number }> = [];
let pickRows: boolean[] = [];
const makeTable = (columns: DataColumn[]) => {
const t = createDataTable({
columns,
rows: [],
caption: "거리이정표",
empty: "",
editOnDouble: true,
cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null),
editable: (_i, key) => key === "item" || key === "company" || key === "note",
onEdit: (i, key, text) => {
const at = keys[i];
if (!at) return;
editRoute(projectId, at.route, (_, r) => {
if (key === "company") r.name = text;
else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text;
});
},
onCellClick: (i, key) => {
const at = keys[i];
if (key !== "dest" || !at) return;
pickAtCell(
(["start", "mid"] as const).map((dest) => [
DEST_TEXT[dest],
() =>
editRoute(projectId, at.route, (_, r) => {
if (r.rows[at.index]) r.rows[at.index].dest = dest;
}),
]),
);
},
});
trackCells(t.root);
t.root.dataset.b08Milestone = "table";
return t;
};
let mode = "false:false";
let table = makeTable(milestoneColumns(false, false));
const none = el("p", {
className: "ui-general-block__empty",
text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.",
});
const refresh = (): void => {
const data = liveMilestone(projectId);
const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null);
// 기준점 생김 · 없앰 = 열 바뀜 → 표 새로
const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`;
if (next !== mode) {
mode = next;
const fresh = makeTable(
milestoneColumns(
data.base.enabled,
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);
none.hidden = rows.length > 0;
// 도로 구분 안 고른 구간 = 합계 칸에 「도로 구분 고르기」(그 km 는 합에 안 듦 · 244-7)
table.setRows(
rows.map((r) => ({
...r,
dest: `${DEST_TEXT[r.dest]} ▾`,
total: r.pick ? "도로 구분 고르기" : r.total,
})),
);
diagram.refresh();
};
refresh();
return {
root: el("div", { className: "b08-milestone", children: [diagram.root, none, table.root] }),
refresh,
};
}
export function renderMilestone(host: HTMLElement, projectId: string): void {
const tabs = el("div", {
className: "b08-quantity__tabs b08-structure__tabs",
attrs: { role: "tablist" },
});
const view = el("div");
let current = "table";
let body: MilestoneView | null = null;
const drawTabs = (): void => {
const routes = liveMilestone(projectId).routes;
const tab = (id: string, label: string): HTMLButtonElement => {
const button = createButton({
label,
variant: id === current ? "filled" : "ghost",
size: "sm",
onClick: () => id !== current && open(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === current));
button.dataset.b08MilestoneTab = id;
return button;
};
tabs.replaceChildren(
tab("table", "거리이정표"),
...routes.map((r, i) => tab(r.id, r.name.trim() || `거래처 ${i + 1}`)),
);
};
function open(id: string): void {
current = id;
drawTabs();
body = id === "table" ? tableView(projectId) : routeView(projectId, id);
view.replaceChildren(body.root);
}
host.replaceChildren(
tabs,
el("p", { className: "ui-general-block__empty", text: "거리이정표를 불러오는 중…" }),
);
loadMilestone(projectId).then(
() => {
host.replaceChildren(tabs, view);
open("table");
onMilestoneChange(host, () => {
// 지운 경로 탭이면 [거리이정표] 로 · 아니면 탭 이름 · 몸만 다시 셈(입력 칸은 그대로)
if (current !== "table" && !liveMilestone(projectId).routes.some((r) => r.id === current))
return open("table");
drawTabs();
body?.refresh();
});
},
(error: unknown) => showToast(`거리이정표를 불러오지 못했습니다 — ${String(error)}`, "error"),
);
}
@@ -0,0 +1,280 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_Diagram.ts
* [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`.
*
* 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 거래처들 · 선마다 「도로 구분 km」 글
* 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 거래처 = 항목 정보 · 공사지 = 시점 주소 ·
* 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`)
* 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율)
* 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 = [+] 등록 모달 분류 「기준위치」(244-13 · [기준점 +] 없앰)
* 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 고침 · 삭제) · 선 · 선 글 누름 = 연결선 모달
* (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로)
* 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소)
* 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7)
* 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점
* 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소
* ========================================================================== */
import { createSelectField, el } 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,
layoutMilestone,
legAt,
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 {
liveMilestone,
putMilestone,
roadOptions,
siteAddress,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
const NS = "http://www.w3.org/2000/svg";
function svg<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
text?: string,
): SVGElementTagNameMap[K] {
const node = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
if (text !== undefined) node.textContent = text;
return node;
}
/** 선 글 바로 고침 — 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소 */
function editLegAt(
projectId: string,
anchor: Element,
from: string,
i: number,
midKm: number,
): void {
const data = liveMilestone(projectId);
const leg = legAt(data, from, i, midKm);
if (!leg) return;
document.querySelector(".b08-milestone__leg-edit")?.remove();
const road = createSelectField({
label: "도로 구분",
options: roadOptions(chainVia(data, from), from !== "start", leg.road), // 시점 → 중점 = 늘 고름
compact: true,
});
road.select.value = leg.road ?? "";
const km = el("input", {
className: "ui-input",
attrs: { type: "number", min: "0", step: "any" },
});
km.value = leg.km == null ? "" : String(leg.km);
km.setAttribute("aria-label", "km");
if (leg.auto_km != null) {
km.placeholder = String(leg.auto_km); // 빈칸 = 자동 값
km.title = `자동 값 ${leg.auto_km} km`;
}
const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] });
box.dataset.b08MilestoneLegEdit = `${from}:${i}`;
const at = anchor.getBoundingClientRect(); // 팬 · 줌 뒤 글 자리 그대로
box.style.left = `${at.left}px`;
box.style.top = `${at.bottom + 4}px`;
let done = false;
const finish = (apply: boolean): void => {
if (done) return;
done = true;
box.remove();
if (!apply) return;
const n = Number(km.value);
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 }),
};
putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch));
};
box.addEventListener("keydown", (e) => {
if (e.key === "Enter") finish(true);
if (e.key === "Escape") finish(false);
});
box.addEventListener("focusout", (e) => {
if (!box.contains(e.relatedTarget as Node | null)) finish(true);
});
document.body.append(box);
km.focus();
km.select();
}
/** 다이어그램 — refresh() 로 캐시 값 다시 그림 */
export function createDiagram(
projectId: string,
addRoute: (init: Partial<MilestoneRoute>) => string,
): { root: HTMLElement; refresh: () => void } {
const host = el("div", { className: "b08-milestone__diagram" });
host.dataset.b08Milestone = "diagram";
// 틀 위 휠 = 줌만(244-8) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 새지 않음
host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false });
let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로
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" }),
};
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;
};
const refresh = (): void => {
const data = liveMilestone(projectId);
const site = siteInfo();
const mk = midKm(data, site?.mid_default_m ?? null);
// 시점 · 중점 주소 · 좌표(244-12) — 지금 중점 측점으로 서버 VWorld(뒤에서 · 오면 다시 그림)
const addr = siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null);
const layout = layoutMilestone(
data,
{
start_lonlat: addr?.start_lonlat ?? site?.start_lonlat,
mid_lonlat: addr?.mid_lonlat ?? site?.mid_lonlat,
start_address: addr?.start_address,
mid_address: addr?.mid_address,
},
mk,
);
const [vx, vy, vw, vh] = layout.view;
// viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김
const root = svg("svg", {
viewBox: `0 0 ${vw} ${vh}`,
width: vw,
height: vh,
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);
root.append(layer);
// 선 = 공사지 바탕 위 · 시점 · 중점 · 거래처 상자 아래(244-12 · 시점 → 중점 선이 바탕에 안 가림)
const lineLayer = svg("g", {});
for (const ln of layout.lines) {
const ends = { x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 };
// 누르는 자리 = 투명한 굵은 선(244-13 · 연결선 모달)
const hit = svg("line", { class: "b08-milestone__line-hit", ...ends });
hit.dataset.b08MilestoneLine = ln.from;
hit.addEventListener("click", (e) => {
e.stopPropagation();
openLegInfo(projectId, ln.from);
});
lineLayer.append(svg("line", { class: "b08-milestone__line", ...ends }), hit);
}
for (const box of layout.boxes) {
const g = svg("g", { class: `b08-milestone__box is-${box.kind}` });
g.dataset.b08MilestoneBox = box.id;
// 글 줄 = 항목(거래처 윗줄) · 이름 · 작은 글씨(주소 · 좌표) — 상자 가운데 맞춤
const rows: Array<[string, string]> = [
...(box.kind === "route" && box.items
? [[box.items, "b08-milestone__items"] as [string, string]]
: []),
[box.label, ""],
...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]),
];
const x = box.x + box.w / 2;
const text = svg("text", {
x,
y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2,
"text-anchor": "middle",
"dominant-baseline": "central",
});
rows.forEach(([row, cls], i) =>
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);
if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소
// 상자 누름 = 모달(탭 이동 아님)
const info = (): void => INFO[box.kind](box.id);
g.setAttribute("role", "button");
g.setAttribute("tabindex", "0");
g.addEventListener("click", (e) => {
e.stopPropagation(); // 공사지 안 시점 · 중점 상자 = 한 번만
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));
shift.append(g);
if (box.kind === "site") shift.append(lineLayer);
}
// 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄
for (const ln of layout.lines) {
// 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④)
const x = ln.lx;
const y = ln.ly - ((ln.labels.length - 1) * 16) / 2;
const text = svg("text", {
class: "b08-milestone__km",
x,
y,
"text-anchor": "middle",
"dominant-baseline": "central",
});
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);
});
shift.append(text);
}
host.replaceChildren(root);
attachSvgZoomPan({
svg: root,
layer,
widthPx: vw,
heightPx: vh,
pad: { left: 0, right: 0, top: 0, bottom: 0 },
initial: zoomState,
onChange: (state) => (zoomState = state),
wheelZoom: true,
content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임)
panStart, // 가운데 버튼 · 빈 곳 왼쪽 끌기
});
};
refresh();
return { root: host, refresh };
}
@@ -0,0 +1,276 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_KakaoMap.ts
* 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 · 표지(보기만).
*
* SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key
* (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기
* 길 선 = 마지막 [길찾기] 응답 경로 좌표(`routePaths` · 메모리만 · 저장 안 함) · 표지 = 거래처 · 기준점 ·
* 시점(`mapMarks`) · 경로 전체 맞춤
* 경로 그림 = 서버 자동 캡처(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 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요";
const SDK_FAIL = "KakaoSdkFail";
const SDK = "https://dapi.kakao.com/v2/maps/sdk.js";
const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선
interface LatLng {
getLat(): number;
getLng(): number;
}
interface KMap {
setBounds(bounds: object): void;
setCenter(p: LatLng): void;
setLevel(level: number): void;
relayout(): void;
}
interface KOverlay {
setMap(map: KMap | null): void;
}
interface KMarker extends KOverlay {
getPosition(): LatLng;
}
interface KakaoMaps {
load(done: () => void): void;
LatLng: new (lat: number, lng: number) => LatLng;
LatLngBounds: new () => { extend(p: LatLng): void };
Map: new (box: HTMLElement, o: { center: LatLng; level: number }) => KMap;
Polyline: new (o: {
path: LatLng[];
strokeWeight: number;
strokeColor: string;
strokeOpacity: number;
}) => KOverlay;
Marker: new (o: { position: LatLng; title: string; draggable?: boolean }) => KMarker;
event: {
addListener(target: object, type: string, fn: (e: { latLng: LatLng }) => void): void;
};
CustomOverlay: new (o: { position: LatLng; content: HTMLElement; yAnchor: number }) => KOverlay;
}
let sdk: Promise<KakaoMaps> | null = null;
/** SDK 한 번 싣기 — 키 없음 · 못 받음 = NO_MAP 으로 거절(다음 부름 때 다시) */
export function loadKakaoMaps(projectId: string): Promise<KakaoMaps> {
sdk ??= (async () => {
const { key } = await request<{ key: string | null }>(
`/projects/${projectId}/quantity/milestone/map-key`,
);
if (!key) throw new Error(NO_MAP);
const w = window as unknown as { kakao?: { maps: KakaoMaps } };
if (!w.kakao?.maps) {
await new Promise<void>((done, fail) => {
const script = document.createElement("script");
script.src = `${SDK}?appkey=${encodeURIComponent(key)}&autoload=false`;
script.onload = () => done();
script.onerror = () =>
fail(
Object.assign(
new Error(
`카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요`,
),
{ name: SDK_FAIL },
),
);
document.head.append(script);
});
}
const maps = w.kakao?.maps;
if (!maps) throw new Error(NO_MAP);
await new Promise<void>((done) => maps.load(done));
return maps;
})();
sdk.catch(() => (sdk = null));
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;
name: string;
lon: number;
lat: number;
movable: boolean;
}
export interface EditMap {
root: HTMLElement;
/** 표지 · 길 선 다시 놓고 맞춤 */
set: (pins: MapPin[], lines: Array<Array<[number, number]>>) => void;
}
/** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */
export function editMap(
projectId: string,
opts: {
onMove: (key: string, lon: number, lat: number) => void;
onClick?: (lon: number, lat: number) => void;
center?: () => [number, number] | null;
hint: string;
},
): EditMap {
const box = el("div", { className: "b08-milestone__map" });
const hint = el("p", { className: "b08-options__hint", text: opts.hint });
hint.dataset.b08MilestoneMapHint = "";
let map: KMap | null = null;
let drawn: KOverlay[] = [];
const set = (pins: MapPin[], lines: Array<Array<[number, number]>>): void => {
loadKakaoMaps(projectId).then(
(maps) =>
requestAnimationFrame(() => {
const points = [
...pins.map((p) => new maps.LatLng(p.lat, p.lon)),
...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)),
];
const c = opts.center?.();
const first = points[0] ?? (c ? new maps.LatLng(c[1], c[0]) : null);
if (!first) return void (box.hidden = true);
box.hidden = false;
if (!map) {
map = new maps.Map(box, { center: first, level: 7 });
if (opts.onClick)
maps.event.addListener(map, "click", (e) =>
opts.onClick!(e.latLng.getLng(), e.latLng.getLat()),
);
}
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,
}),
),
...pins.flatMap((p) => {
const at = new maps.LatLng(p.lat, p.lon);
const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable });
if (p.movable)
maps.event.addListener(marker, "dragend", () => {
const to = marker.getPosition();
opts.onMove(p.key, to.getLng(), to.getLat());
});
return [
marker,
new maps.CustomOverlay({
position: at,
content: el("span", { className: "b08-milestone__map-label", text: p.name }),
yAnchor: 2.6,
}),
];
}),
];
drawn.forEach((o) => o.setMap(map));
if (points.length > 1) {
const bounds = new maps.LatLngBounds();
points.forEach((p) => bounds.extend(p));
map.setBounds(bounds);
} else {
map.setCenter(first);
map.setLevel(points.length ? 4 : 9);
}
}),
(error: unknown) => {
box.hidden = true;
hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP;
},
);
};
return {
root: el("div", { className: "b08-milestone__map-wrap", children: [hint, box] }),
set,
};
}
@@ -0,0 +1,251 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_LegModal.ts
* 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름.
*
* 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7)
* 도로 구분 = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · 비포장 ·
* 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장(`roadOptions` · `chainVia`)
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음)
* 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정)
* 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음)
* 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`)
* ========================================================================== */
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 { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
import { toastKm } from "./B08_Quantity_UI_Milestone_Modal";
import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place";
import {
findSegmentKm,
liveMilestone,
putMilestone,
roadOptions,
segmentPath,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
const km = (text: string): number | null => {
const n = Number(text);
return text.trim() && Number.isFinite(n) && n >= 0 ? n : null;
};
/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · 거리 km(빈칸 = 자동 값) · [×] · [+ 구간] */
export function legsEditor(
initial: Leg[],
via: boolean,
): { root: HTMLElement; value: () => Leg[] } {
const list = el("div");
const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = [];
const add = (leg: Leg): void => {
const road = createSelectField({
label: "도로 구분",
options: roadOptions(via, true, leg.road),
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);
const auto = leg.auto_km ?? null;
if (auto != null) {
dist.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선
dist.input.title = `자동 값 ${auto} km`;
}
const ref = { road: road.select, km: dist.input, auto };
rows.push(ref);
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(ref), 1);
row.remove();
},
});
const row = el("div", {
className: "b08-milestone__line-edit",
children: [road.root, dist.root, drop],
});
row.dataset.b08MilestoneLeg = "";
list.append(row);
};
(initial.length ? initial : [{ road: null, km: null }]).forEach(add);
const more = createButton({
label: "+ 구간",
size: "sm",
variant: "ghost",
onClick: () => add({ road: null, km: null }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map(({ road, km: dist, auto }) => ({
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null,
km: km(dist.value),
auto_km: auto,
})),
};
}
const hint = (text: string): HTMLElement => el("p", { className: "b08-options__hint", text });
/** 시점 → 중점 — 도로 구분 · 거리 늘 손 입력(빈칸 = 노선 ÷ 2) */
function openMidLeg(projectId: string): void {
const data = liveMilestone(projectId);
const site = siteInfo();
openModal({
title: "구간 — 시점 → 중점",
closeLabel: "닫기",
dialogClass: "b08-milestone__modal",
mount: (body, close, actions) => {
const road = createSelectField({
label: "도로 구분",
options: roadOptions(false, false, data.site.mid_road),
compact: true,
});
road.select.value = data.site.mid_road;
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
dist.input.dataset.b08MilestoneLegKm = "start";
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
const fresh = liveMilestone(projectId);
putMilestone(
projectId,
setLeg(fresh, "start", 0, {
road: road.select.value as Road,
km: km(dist.input.value),
}),
);
close();
},
});
save.dataset.b08MilestoneLegSave = "";
actions.append(save);
body.append(
el("div", { className: "b08-milestone__grid", children: [road.root, dist.root] }),
hint("시점 → 중점 = 늘 손 입력(길찾기 없음) · 빈칸 = 노선 ÷ 2"),
);
},
});
}
/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "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 seg = segmentEnds(liveMilestone(projectId), from, start());
if (!seg) return;
openModal({
title: `구간 — ${seg.text}`,
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 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);
if (!target) return;
target.legs = legs.value();
putMilestone(projectId, fresh);
};
const redrawLegs = (): void => {
const next = legsEditor(legsOf(), via);
legs.root.replaceWith(next.root);
legs = next;
};
const pins = (): MapPin[] => {
const d = liveMilestone(projectId);
const route = d.routes.find((r) => r.id === 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);
else add("start", "시점", start()?.[0], start()?.[1]);
if (from === "base") add("start", "시점", start()?.[0], start()?.[1]);
return out;
};
const drawMap = (): void => {
const path = segmentPath(from);
map.set(pins(), path.length >= 2 ? [path] : []);
};
const run = async (image = true): Promise<void> => {
find.disabled = true;
status.textContent = "길찾기 중…";
const typed = JSON.stringify(legs.value()); // 기다리는 동안 손댄 칸은 그대로
const text = await findSegmentKm(projectId, from, image).finally(
() => (find.disabled = false),
);
status.textContent = text;
if (image) toastKm(text);
if (JSON.stringify(legs.value()) === typed) redrawLegs();
drawMap();
};
// 끝점 끌어 옮김 = 그 소재지 고침(주소 다시) + 다시 길찾기
const map = editMap(projectId, {
hint: "경로 = 카카오 길찾기 · 거래처 · 기준점 표지를 끌어 옮기면 다시 길찾기",
onMove: (key, lon, lat) => {
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);
if (!target) return;
Object.assign(target, { lon, lat, address });
putMilestone(projectId, fresh);
void run();
});
},
});
const find = createButton({
label: "길찾기",
variant: "ghost",
onClick: () => {
apply();
void run();
},
});
find.dataset.b08MilestoneLegFind = "";
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
apply();
close();
},
});
save.dataset.b08MilestoneLegSave = "";
actions.append(find, save);
body.append(
legs.root,
hint(
from === "base"
? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)"
: "빈칸 = 길찾기 자동 값 · 넣은 값이 우선",
),
status,
el("h4", { text: "경로 — 다음(카카오) 지도" }),
map.root,
);
drawMap();
// 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음)
if (!segmentPath(from).length && seg.from && seg.to) void run(false);
},
});
}
@@ -0,0 +1,331 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_Modal.ts
* 거리이정표 다이어그램 모달(PLAN 244-4 · 244-13 · 사용자 147번 5 · 8) — 페이지 이동 없이 창에서 보고 고침.
*
* 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 ·
* 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 ·
* 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제]
* 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정
* 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리
* 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
* 조작 = 캐시 · [저장] 때 서버
* ========================================================================== */
import "../B01_Dashboard/B01_Dashboard_UI_Style.css";
import { createCheckbox } from "@ui/ui_template_checkbox";
import {
createButton,
createInputField,
createSelectField,
el,
showConfirmDialog,
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 { request } from "./B08_Quantity_UI_Hydro";
import { placePicker } from "./B08_Quantity_UI_Milestone_Place";
import {
findSegmentKm,
liveMilestone,
putMilestone,
roadOptions,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
const DEST_OPTIONS = [
{ value: "start", text: "시점" },
{ value: "mid", text: "중점" },
];
/** 분류 → 항목 고르기 목록(거래처 = 견본 「거리이정표」 시트 다섯) */
export const ITEMS: Record<"route" | "base", string[]> = {
route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"],
base: ["기준점"],
};
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");
}
let listSeq = 0;
/** 항목(손 입력 + 제안 목록 · 244-14) · 목적지 ▾ 한 줄 — 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜 */
function rowField(r: MilestoneRow, kind: "route" | "base") {
const item = createInputField({ label: "항목", compact: true });
const list = el("datalist", { attrs: { id: `b08-milestone-items-${++listSeq}` } });
item.input.setAttribute("list", list.id);
item.input.value = r.item;
item.root.append(list);
const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS });
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" : ""; // 기준점 → 시점 고정
};
setKind(kind);
dest.select.value = r.dest;
item.input.dataset.b08MilestoneAdd = "item";
return { item, dest, note: r.note, setKind };
}
export type PlaceTarget = { add: true; viaBase: boolean } | { add: false; id: string };
/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */
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 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 || "거래처"}`;
openModal({
title,
closeLabel: "닫기",
dialogClass: "b08-milestone__modal",
mount: (body, close, actions) => {
let kind: "route" | "base" = editBase ? "base" : "route";
const sort = createSelectField({
label: "분류",
compact: true,
options: [
{ value: "base", text: "기준위치" },
{ value: "route", text: "거래처" },
],
});
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.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;
const place = placePicker(
projectId,
{ address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null },
() => name.input.value.trim() || (kind === "base" ? "기준점" : "거래처"),
);
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";
};
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 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 ? "등록" : "고침",
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 }];
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;
} 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;
}
close();
if (segment) void findSegmentKm(projectId, segment).then(toastKm);
},
});
ok.dataset.b08MilestoneInfo = "save";
const remove = createButton({
label: "삭제",
variant: "danger",
onClick: async () => {
const label = editBase ? data.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); // 경로 탭도 같이
putMilestone(projectId, fresh);
close();
},
});
remove.dataset.b08MilestoneInfo = "delete";
remove.hidden = target.add;
actions.append(remove, ok);
body.append(
el("div", {
className: "b08-milestone__grid",
children: [sort.root, first.item.root, name.root, first.dest.root],
}),
more,
plusRow,
via.root,
head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"),
place.root,
);
},
});
}
export interface SiteAddress {
region: string;
start_lonlat: [number, number] | null;
mid_lonlat: [number, number] | null;
start_address: string | null;
mid_address: string | null;
}
/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */
export function openSiteInfo(projectId: string): void {
openModal({
title: "공사지 — 시점 · 중점",
closeLabel: "닫기",
mount: (body, close, actions) => {
const text = (label: string, value: string): HTMLElement =>
el("p", { className: "b08-options__hint", text: `${label} — ${value}` });
// 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6)
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)",
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.dataset.b08Milestone = "mid_station_m";
const road = createSelectField({
label: "시점 → 중점 도로",
options: roadOptions(false, false, data.site.mid_road),
compact: true,
});
road.select.value = data.site.mid_road;
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
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);
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"),
);
},
});
}
@@ -0,0 +1,144 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_Place.ts
* 거리이정표 등록 모달 「소재지」 칸(PLAN 244-13 · 사용자 147번 5) — 주소 찾기 · 지도에서 고르기만(직접 입력 없음).
*
* 주소 찾기 = B01 회사 주소와 같은 서버 후보 검색(VWorld 대리 · `searchAddress`) → 후보 고름 = 소재지 · 표지
* 지도 = 다음(카카오) 지도 JS(`…_KakaoMap` editMap) — 지도 누름 = 그 자리 소재지 · 표지 끌어 옮김 = 다시 고름 ·
* 고른 좌표 → 서버 GET …/milestone/reverse(VWorld 주소 한 줄 · 못 찾음 = 위도, 경도 글)
* 값 = 주소 글 · 경도 · 위도(캐시 · [저장] 때 서버)
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
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";
export interface Place {
address: string;
lon: number | null;
lat: number | null;
}
export interface PlaceHandle {
root: HTMLElement;
value: () => Place;
}
/** 지번 후보 = 지번 줄이 시 · 도까지 갖춤(B01 주소 칸과 같은 고름) */
const lineOf = (c: AddressCandidate): string =>
(c.category === "parcel" ? c.parcel : c.road) || c.road || c.parcel;
/** 좌표 → 주소 한 줄(서버 VWorld) · 못 찾음 = 「위도, 경도」 글 */
export async function reverseAddress(projectId: string, lon: number, lat: number): Promise<string> {
const got = await request<{ address: string | null }>(
`/projects/${projectId}/quantity/milestone/reverse?lon=${lon}&lat=${lat}`,
).catch(() => null);
return got?.address || `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
}
/** 소재지 칸 — name() = 지도 표지 이름(거래처 · 기준점 이름) */
export function placePicker(projectId: string, initial: Place, name: () => string): PlaceHandle {
let place: Place = { ...initial };
let seq = 0; // 늦게 온 주소가 새 자리를 덮지 않게
const query = createInputField({
label: "주소 찾기",
compact: true,
placeholder: "도로명 · 지번 · 건물명 일부 (예 병곡리 968-31)",
});
const find = createButton({ label: "주소 찾기", variant: "ghost", onClick: () => void search() });
find.dataset.b08MilestonePlace = "find";
const list = el("div", { className: "b01-dashboard__address-list" });
list.hidden = true;
const where = el("p", { className: "b08-milestone__place" });
where.dataset.b08MilestonePlace = "value";
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,
});
const draw = (): void => {
where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`;
map.set(
place.lon != null && place.lat != null
? [
{
key: "place",
name: name() || "소재지",
lon: place.lon,
lat: place.lat,
movable: true,
},
]
: [],
[],
);
};
async function moveTo(lon: number, lat: number): Promise<void> {
const mine = ++seq;
place = { ...place, lon, lat };
draw();
const address = await reverseAddress(projectId, lon, lat);
if (mine !== seq) return;
place.address = address;
draw();
}
async function search(): Promise<void> {
const text = query.input.value.trim();
if (text.length < 2) return showToast("주소를 두 글자 이상 넣으세요.", "error");
find.disabled = true;
try {
const items = await searchAddress(text);
list.hidden = false;
list.replaceChildren(
...(items.length
? items.map((item) => {
const option = el("button", {
className: "b01-dashboard__address-item",
attrs: { type: "button" },
children: [
el("strong", { text: lineOf(item) }),
el("small", { text: [item.building, item.zipcode].filter(Boolean).join(" · ") }),
],
});
option.addEventListener("click", () => {
seq++;
place = { address: lineOf(item), lon: item.lon, lat: item.lat };
list.hidden = true;
draw();
});
return option;
})
: [el("p", { text: "찾은 주소가 없습니다 — 다른 낱말로 찾거나 지도에서 고르세요." })]),
);
} catch {
showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하세요.", "error");
} finally {
find.disabled = false;
}
}
// 검색 칸 Enter = 찾기만(모달 안 닫힘)
query.input.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
void search();
});
draw();
return {
root: el("div", {
className: "b08-milestone__place-field",
children: [
el("div", { className: "b08-milestone__line-edit", children: [query.root, find] }),
list,
where,
map.root,
],
}),
value: () => ({ ...place }),
};
}
@@ -0,0 +1,200 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_Route.ts
* 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달).
*
* 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자
* 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버
* 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기
* [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · 경유 = 기준점 좌표(거침) ·
* 도착 = 목적지 좌표) — 보기만
* 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지(보기만) · JS 키 없음 = 안내 한 줄
* ========================================================================== */
import {
createMapContextMenu,
type MapContextMenu,
type MapContextMenuItem,
} 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 {
autoImageNote,
canRevertImage,
deleteImage,
imageBlobUrl,
liveMilestone,
putMilestone,
routeImageKey,
siteInfo,
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);
}
export function pickAtCell(items: MapContextMenuItem[]): void {
const box = lastCell?.getBoundingClientRect();
if (!box) return;
menu ??= createMapContextMenu("b08-milestone");
if (!menu.element.isConnected) {
menu.element.style.position = "fixed";
document.body.append(menu.element);
}
menu.open(box.left, box.bottom, items);
}
export const DEST_TEXT: Record<Dest, string> = { start: "시점", mid: "중점" };
/** 그 경로를 고쳐 캐시에 */
export function editRoute(
projectId: string,
routeId: string,
fn: (data: MilestoneData, route: MilestoneRoute) => void,
): void {
const data = liveMilestone(projectId);
const route = data.routes.find((r) => r.id === routeId);
if (!route) return;
fn(data, route);
putMilestone(projectId, data);
}
export interface MilestoneView {
root: HTMLElement;
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 = "";
// 그림 — 경로 하나 = 그림 하나
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;
if (img.src) URL.revokeObjectURL(img.src);
img.removeAttribute("src");
if (name)
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);
});
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",
variant: "ghost",
onClick: () => file.click(),
});
const revert = createButton({
label: "자동 그림으로 되돌리기",
size: "sm",
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"),
),
});
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],
}),
hint,
mapView.root,
noImage,
img,
],
});
const 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}` : ""}`;
loadImage();
revert.hidden = !canRevertImage(routeId);
hint.textContent = autoImageNote(routeId) ?? HINT;
mapView.draw();
};
refresh();
return { root, refresh };
}
@@ -0,0 +1,285 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_State.ts
* 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길(좌측 패널 카드는 244-6 에서 없앰 ·
* 공사지 · 기준점은 다이어그램 모달).
*
* 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 +
* 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음.
* 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴).
* [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 ·
* 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음)
* 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐)
* 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) ·
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
* ========================================================================== */
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 type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal";
export interface SiteInfo {
length_m: number | null;
mid_default_m: number | null;
start_lonlat: [number, number] | null;
mid_lonlat: [number, number] | null;
}
interface Payload {
data: MilestoneData;
site: SiteInfo;
images: Record<string, string>;
auto_images: Record<string, string>;
}
export const EMPTY: MilestoneData = {
site: { mid_station_m: null, mid_road: "비포장" },
base: { enabled: false, name: "", address: "", dir: null, legs: [] },
routes: [],
};
const changes = new EventTarget();
let saved: Payload | null = null;
let savedFor: string | null = null;
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
/** 저장본 읽기(프로젝트마다 한 번 · force = 다시) */
export async function loadMilestone(projectId: string, force = false): Promise<Payload> {
if (!saved || savedFor !== projectId || force) {
saved = await request<Payload>(`/projects/${projectId}/quantity/milestone`);
savedFor = projectId;
changes.dispatchEvent(new Event("change"));
}
return saved;
}
export const siteInfo = (): SiteInfo | null => saved?.site ?? null;
/** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 ·
* 오면 다시 그림) · 아직 · 실패 = null(좌표만) */
const siteAddresses = new Map<string, SiteAddress | null>();
export function siteAddress(projectId: string, midM: number | null): SiteAddress | null {
const key = `${projectId}:${midM ?? ""}`;
if (!siteAddresses.has(key)) {
siteAddresses.set(key, null);
const query = midM == null ? "" : `?mid_m=${midM}`;
request<SiteAddress>(`/projects/${projectId}/quantity/milestone/site-address${query}`).then(
(s) => {
siteAddresses.set(key, s);
changes.dispatchEvent(new Event("change"));
},
() => undefined,
);
}
return siteAddresses.get(key) ?? null;
}
export const routeImages = (): Record<string, string> => saved?.images ?? {};
/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */
export const canRevertImage = (routeId: string): boolean => {
const name = saved?.images[routeId];
const auto = saved?.auto_images?.[routeId];
return !!name && !!auto && name !== auto;
};
/** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */
const imageRev = new Map<string, number>();
export const routeImageKey = (routeId: string): string | null => {
const name = saved?.images[routeId];
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;
/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */
export function liveMilestone(projectId: string): MilestoneData {
return clone(readState<MilestoneData>("b08-milestone", projectId) ?? saved?.data ?? EMPTY);
}
/** 고친 한 벌을 캐시에 · 화면에 알림 */
export function putMilestone(projectId: string, data: MilestoneData): void {
writeState("b08-milestone", data, projectId);
changes.dispatchEvent(new Event("change"));
}
/** host 가 붙어 있는 동안 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌 */
export function onMilestoneChange(host: HTMLElement, fn: () => void): void {
const listener = (): void => {
if (host.isConnected) fn();
else changes.removeEventListener("change", listener);
};
changes.addEventListener("change", listener);
}
/** [저장] — 캐시가 있을 때만 PUT · 서버가 표를 다시 셈 */
export async function saveMilestone(projectId: string): Promise<void> {
const draft = readState<MilestoneData>("b08-milestone", projectId);
if (!draft) return;
saved = await request<Payload>(`/projects/${projectId}/quantity/milestone`, {
method: "PUT",
body: JSON.stringify(draft),
});
savedFor = projectId;
clearState("b08-milestone", projectId);
changes.dispatchEvent(new Event("change"));
}
const imageUrl = (projectId: string, routeId: string): string =>
`${API_BASE_URL}/projects/${projectId}/quantity/milestone/image?route=${encodeURIComponent(routeId)}`;
async function call(input: string, init: RequestInit = {}): Promise<Response> {
const response = await fetch(input, { credentials: "include", ...init });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { detail?: unknown } | null;
throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`);
}
return response;
}
/** 경로 그림 — 올리기(교체) · 지우기는 즉시 서버 · 목록 고쳐 알림 */
export async function uploadImage(projectId: string, routeId: string, file: File): Promise<void> {
const form = new FormData();
form.append("file", file);
const body = (await (
await call(imageUrl(projectId, routeId), { method: "POST", body: form })
).json()) as { name: string };
if (saved) saved.images = { ...saved.images, [routeId]: body.name };
changes.dispatchEvent(new Event("change"));
}
/** 그림 지우기(자동 그림까지) · userOnly = 사용자 그림만 = 자동 그림으로 되돌리기 */
export async function deleteImage(
projectId: string,
routeId: string,
userOnly = false,
): Promise<void> {
const url = imageUrl(projectId, routeId) + (userOnly ? "&user_only=true" : "");
const body = (await (await call(url, { method: "DELETE" })).json()) as { name: string | null };
if (saved) {
const rest = { ...saved.images };
const auto = { ...saved.auto_images };
delete rest[routeId];
if (body.name) rest[routeId] = body.name;
else delete auto[routeId];
saved.images = rest;
saved.auto_images = auto;
}
changes.dispatchEvent(new Event("change"));
}
/** 경로 그림 자동(244-11) — 길 선 · 표지만 보냄 → 서버 헤드리스 캡처 → 자동 그림(사용자 그림 우선) */
export async function autoRouteImage(
projectId: string,
routeId: string,
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"));
let reason: string | null;
try {
const res = await request<{ name: string | null; reason: string | null }>(
`/projects/${projectId}/quantity/milestone/image/auto?route=${encodeURIComponent(routeId)}`,
{ method: "POST", body: JSON.stringify({ lines, marks }) },
);
reason = res.reason;
if (res.name && saved) {
const user = saved.images[routeId] && saved.images[routeId] !== saved.auto_images?.[routeId];
saved.auto_images = { ...saved.auto_images, [routeId]: res.name };
if (!user) saved.images = { ...saved.images, [routeId]: res.name };
imageRev.set(routeId, (imageRev.get(routeId) ?? 0) + 1);
}
} catch (error) {
reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — 그림 올리기를 쓰세요.`;
}
if (reason) {
autoNotes.set(routeId, reason);
showToast(reason, "warning");
} else autoNotes.delete(routeId);
changes.dispatchEvent(new Event("change"));
}
/** 그림 → 화면 주소(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 })),
];
}
/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */
const paths = new Map<string, Array<[number, number]>>();
export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? [];
/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */
export const routePaths = (routeId: string): Array<Array<[number, number]>> =>
paths.has(routeId) ? [paths.get(routeId)!] : [];
/** [길찾기] 한 구간(연결선 · 244-13) — 서버가 카카오로 km · 자동 값을 캐시에(sameKm = 같으면 안 씀) ·
* 거래처 쪽 구간 = 자동 그림(뒤에서) · 돌려줌 = 결과 한 줄(까닭 줄엔 「 — 」) */
export async function findSegmentKm(
projectId: string,
from: string,
image = true,
): Promise<string> {
const data = liveMilestone(projectId);
const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null);
if (!seg) return "";
if (!seg.from || !seg.to) {
const missing = seg.text.split(" → ")[seg.from ? 1 : 0];
return `${seg.text} — ${missing} 좌표가 없습니다(소재지 · 노선) · km 를 손으로 넣으세요.`;
}
try {
const res = await request<{
km: number | null;
reason: string | null;
path?: Array<[number, number]>;
}>(`/projects/${projectId}/quantity/milestone/directions`, {
method: "POST",
body: JSON.stringify({
origin: { lon: seg.from[0], lat: seg.from[1] },
destination: { lon: seg.to[0], lat: seg.to[1] },
}),
});
if (res.km == null) return `${seg.text} — ${res.reason ?? "길찾기 실패"}`;
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);
putMilestone(projectId, fresh);
} else changes.dispatchEvent(new Event("change"));
if (image && seg.key === "route" && paths.has(from))
void autoRouteImage(projectId, from, routePaths(from)); // 뒤에서 · km 알림 안 기다림
return `${seg.text} ${res.km} km`;
} catch (error) {
return `${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`;
}
}
+6 -1
View File
@@ -1,12 +1,15 @@
/* =============================================================================
* B08_Quantity_UI_Options.ts
* 좌측 패널(PLAN 122 · 160-1) — 메인 탭과 같은 컨테이너 넷(구조물 집계표 · 자재 · 토공 · 수리집수면적표 · 접이)
* 좌측 패널(PLAN 122 · 160-1 · 244-1) — 메인 탭과 같은 컨테이너 다섯(구조물 집계표 · 자재 · 토공 · 수리집수면적표 ·
* 거리이정표 · 접이)
* + [저장] 줄.
*
* 구조물 집계표 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m`
* 자재 › 할증 모래 · 자갈 · 시멘트 %(PLAN 124) — 총괄자재수량집계표 그 세 줄 · 0~100
* 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100)
* 수리집수면적표 — 빈 자리(다음 옵션)
* 거리이정표 — 카드 없음(244-6 · 공사지 · 기준점은 다이어그램 모달) · 캐시 `b08-milestone` 은
* [저장] 한 번에 같이 PUT(`…_UI_Milestone_State`)
* 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) ·
* [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음.
* ========================================================================== */
@@ -24,6 +27,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { request } from "./B08_Quantity_UI_Hydro";
import { clampPct } from "./B08_Quantity_Options_Calc";
import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc";
import { saveMilestone } from "./B08_Quantity_UI_Milestone_State";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -210,6 +214,7 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void
surcharge: body.material_surcharge_pct,
};
clearState("b08-options", projectId);
await saveMilestone(projectId);
show();
showToast("수량 산출 옵션을 저장했습니다.", "success");
onSaved();
+15
View File
@@ -24,6 +24,16 @@
visibility: hidden;
}
/* 236-1 표 아래 안내 · 경고 줄 — 늘 한 줄(줄임표 · 잘리면 공용 호버로 전문) */
.b08-quantity__foot {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-caption);
}
/* 자재 집계표 셋(121) — 안내 글 · [저장] 한 줄 */
.b08-material__toolbar {
display: flex;
@@ -56,6 +66,11 @@
vertical-align: middle;
}
/* 247-1 — 모두 연결 = 같은 램프 초록 */
[data-b08-summary="table"] td.b08-summary__lamp.is-linked::before {
background: var(--color-success);
}
/* 좌측 패널 옵션(122) — 연암 · 경암 두 칸 나란히 · 설명 한 줄 */
.b08-options__row {
display: grid;
+4 -1
View File
@@ -2,7 +2,7 @@
* B08_Quantity_UI_Page.ts
* 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61)
*
* 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1). 위 탭 단추 = 공용
* 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1) · 거리이정표(244-1). 위 탭 단추 = 공용
* 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1). 구조물도는 집계표 행을 눌러 여는 모달(95-3).
* 자재 아래 보조 탭 = 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표(121-1 `…_UI_Material`).
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
@@ -22,6 +22,7 @@ import { renderMaterialSheet } from "./B08_Quantity_UI_Material";
import { renderHydro } from "./B08_Quantity_UI_Hydro";
import { renderEarthwork } from "./B08_Quantity_UI_Earthwork";
import { createOptionsPanel } from "./B08_Quantity_UI_Options";
import { renderMilestone } from "./B08_Quantity_UI_Milestone";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -32,6 +33,7 @@ const TABS = [
{ id: "material", label: "B08_Quantity_Tab_Materials" },
{ id: "earthwork", label: "B08_Quantity_Tab_Earthwork" },
{ id: "hydrology", label: "B08_Quantity_Tab_Hydrology" },
{ id: "milestone", label: "B08_Quantity_Tab_Milestone" },
] as const;
type TabId = (typeof TABS)[number]["id"];
@@ -101,6 +103,7 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): voi
}
if (tab === "hydrology" && projectId) return void renderHydro(view, projectId);
if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId);
if (tab === "milestone" && projectId) return renderMilestone(view, projectId);
view.append(pendingNote());
}
+12 -3
View File
@@ -8,6 +8,7 @@
* 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 미연결 시설은 그 측점
* 줄만 측점 칸 왼쪽 노랑 램프(값 빈칸 · 서버 `unlinked` · PLAN 154-1 · 198-1)
* · 램프 호버(공용 `ui_template_hover_info`) = 그 측점 빈 조합 칸(160-2 · 화면만)
* · 시설이 모두 조합에 이은 측점 줄 = 같은 자리 초록 램프(호버 「모두 연결됨」 · 247-1)
* 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격
* 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는
* 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로
@@ -46,6 +47,7 @@ import {
} from "./B08_Quantity_Summary_Data";
import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc";
import type { SheetsRequest } from "./B08_Quantity_UI_Drawing";
import { footNote } from "./B08_Quantity_UI_Foot";
import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options";
function L(key: keyof typeof ui_locales): string {
@@ -100,6 +102,8 @@ async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
const PIPE_TOP = "관 공";
/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */
const LAMP_CLASS = "b08-summary__lamp";
/** 모두 연결 = 같은 램프 초록(247-1) */
const LINKED_CLASS = `${LAMP_CLASS} is-linked`;
const LAMP_W = 14;
const stationKey = (station: number): number => Math.round(station * 100);
/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */
@@ -158,6 +162,8 @@ function buildTable(
const missing = stations.map((station) =>
unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")),
);
// 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프
const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0);
const table = createDataTable({
columns: [
{ key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W },
@@ -178,7 +184,8 @@ function buildTable(
})),
total,
onRowClick: (i) => onRowClick(stations[i], labels[i]),
cellClass: (i, key) => (key === "station" && missing[i] ? LAMP_CLASS : null),
cellClass: (i, key) =>
key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null,
frozen: 1,
empty: "",
caption: L("B08_Quantity_Tab_StructureSummary"),
@@ -190,7 +197,9 @@ function buildTable(
table.root,
(cell) => {
const why = missing[Number(cell.closest("tr")?.dataset.i)];
return why && `${L("B08_Quantity_Summary_LampTip")} — ${why}`;
return why
? `${L("B08_Quantity_Summary_LampTip")} — ${why}`
: L("B08_Quantity_Summary_LampOk");
},
`td.${LAMP_CLASS}`,
);
@@ -258,7 +267,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
const summary = live();
const parts: HTMLElement[] = [];
if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`));
for (const text of summary?.notes ?? []) parts.push(note(text));
if (summary?.notes.length) parts.push(footNote(summary.notes.join(" · "))); // 236-1 한 줄
if (!columns.length && !summary?.columns.length)
parts.push(note(L("B08_Quantity_Summary_NoCombo")));
const draw = (): HTMLElement =>
+1 -1
View File
@@ -56,7 +56,7 @@ PARTS = ("labor", "material", "expense")
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
DEPTH = 4 # 하위 산근 깊이 상한
_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …)
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)"
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음"
EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음"
+70 -15
View File
@@ -66,11 +66,6 @@
font-size: var(--text-caption);
}
/* 182 설계내역서 묶음 줄 굵게 · 레벨 들여쓰기 = 공용 표 level 옵션(201-1) */
.b09-estimation .ui-data-table__table tbody tr.is-group td {
font-weight: var(--font-weight-bold);
}
/* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */
.b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child {
background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px);
@@ -81,16 +76,33 @@
text-align: left;
}
/* 200-2 레벨 도구 줄 — 단추 넷 + 안내 한 줄 */
.b09-estimation__level {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
/* 235-1 설계내역서 고른 줄 — 줄 전체 상자 테두리(공용 is-focus 밑줄 · 배경 대신) */
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td {
box-shadow:
inset 0 2px 0 var(--color-accent),
inset 0 -2px 0 var(--color-accent);
}
.b09-estimation__level .b09-estimation__hint {
margin: 0;
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td:first-child {
box-shadow:
inset 0 2px 0 var(--color-accent),
inset 0 -2px 0 var(--color-accent),
inset 2px 0 0 var(--color-accent);
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td:last-child {
box-shadow:
inset 0 2px 0 var(--color-accent),
inset 0 -2px 0 var(--color-accent),
inset -2px 0 0 var(--color-accent);
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus:not(:hover) td:not(.is-edited) {
background-color: var(--color-canvas);
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus.is-alt:not(:hover) td:not(.is-edited) {
background-color: var(--dt-zebra);
}
/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */
@@ -146,6 +158,11 @@
background: var(--color-warning);
}
/* 122 데이터 점검 [업데이트] — 제목 아래 틈 = 카드 왼쪽 여유와 같게 */
.b09-options [data-b09-refresh] {
margin-top: calc(var(--spacing-8) + var(--spacing-4));
}
/* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */
.b09-check__head,
.b09-check__note {
@@ -162,13 +179,51 @@
/* 줄 끌기(204-1) — 놓을 자리 · 레벨 미리 보기 선 · 못 놓는 자리 = 빨간 점선 */
.b09-estimation__drop {
--drop-c: var(--color-primary);
position: fixed;
z-index: 1000;
height: 0;
border-top: 2px solid var(--color-primary);
border-top: 2px solid var(--drop-c);
color: var(--drop-c);
pointer-events: none;
}
.b09-estimation__drop.is-bad {
border-top: 2px dashed var(--color-danger);
--drop-c: var(--color-danger);
border-top-style: dashed;
}
/* 121 선 머리 = 레벨 시작 점 + 「어디 안 · 몇 레벨」 · 들어갈 폴더 줄 = 테두리 · 끄는 줄 = 흐리게 */
.b09-estimation__drop::before {
content: "";
position: absolute;
top: -6px;
left: -5px;
width: 8px;
height: 8px;
border: 2px solid;
border-radius: 50%;
background: var(--color-canvas);
}
.b09-estimation__drop-tag {
position: absolute;
top: -11px;
padding: 1px var(--spacing-8);
border: 1px solid;
border-radius: var(--radius-pills);
background: var(--color-surface-raised);
font-size: var(--text-caption);
white-space: nowrap;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-drop-into td {
box-shadow:
inset 0 2px 0 var(--color-primary),
inset 0 -2px 0 var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary) 14%, transparent);
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-drag-src td {
opacity: 0.45;
}
+17 -11
View File
@@ -52,7 +52,7 @@ import {
} from "./B09_Estimation_UI_Tabs_Base";
import { buildBill } from "./B09_Estimation_UI_Tabs_Bill";
import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
import {
buildSumExpense,
buildSumLabor,
@@ -61,7 +61,7 @@ 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, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -204,6 +204,9 @@ export function renderB09Estimation(root: HTMLElement): void {
empty: "",
caption: TABS.find(([id]) => id === tab)?.[1],
editable: spec.editable,
// 243-1 고칠 수 있는 칸 = 모든 표 더블클릭 · Enter · F2 (설계내역서 키 = 레벨 키 몫)
editOnDouble: spec.editOnDouble ?? true,
keyEdit: !(spec as BillTable).menu,
onEdit: spec.onEdit,
rowClass: extra
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
@@ -243,7 +246,7 @@ export function renderB09Estimation(root: HTMLElement): void {
);
return made;
};
const hint = spec.hint ? note(spec.hint) : null;
const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄
// 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표
if (isGroupTab(tab)) {
const redraw = (): void => show(tab);
@@ -260,15 +263,18 @@ export function renderB09Estimation(root: HTMLElement): void {
}),
);
}
// 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조 · 202-1 안내 줄 = 표 아래
// 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력
// 239-1 · 242-1 이동해 온 줄 = 그 줄 + 자손 빨강 외곽선(is-pick) · 고른 줄 상자와 한때 하나(오면 고름 풂)
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined);
const level = bill ? levelBar(table.root, bill) : null;
panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []));
if (focus !== undefined) {
const index = rowIndex(spec.rows, focus);
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
}
const at = focus === undefined ? -1 : rowIndex(spec.rows, focus);
if (bill) jumpTo(bill.rows, at);
const table = makeTable(
bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined,
);
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)); // 설계내역서 = 스크롤만(표시 = 빨강)
};
const apply = (next: Row, fromServer: boolean): void => {
+2
View File
@@ -99,6 +99,8 @@ export interface TabTable {
/** 칸 누름 → 갈 줄(호표 칸) */
link?: (index: number, key: string) => Link | null;
editable?: (index: number, key: string) => boolean;
/** 고칠 칸 = 더블클릭 · Enter · F2 때만 글 상자(235-1 · 공용 표 editOnDouble) */
editOnDouble?: boolean;
onEdit?: (index: number, key: string, text: string) => void;
/** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */
onCell?: (index: number, key: string) => void;
@@ -52,14 +52,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) =>
priceList(
sheets.labor || [],
"단가(원/인)",
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`,
`굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`,
);
export const buildExpense: TabBuild = ({ sheets, payload }) =>
priceList(
sheets.expense || [],
"기준가(천원)",
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`,
`굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`,
);
function madeText(payload: Row): string {
@@ -104,7 +104,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => {
rows,
frozen: 2,
mergeSame: ["part"],
hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표",
hint: "인건비 줄을 누르면 노무비목록표로 이동",
link: (i) => {
const row = rows[i];
return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null;
@@ -196,7 +196,7 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
],
rows: out,
frozen: 2,
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸(견적 단가를 넣은 줄만) 누름 = 파일 올리기 · 보기 · 출처 단가 칸에 마우스 = 출처 뜻(책 · 호 · 쪽 · 기준일)",
hint: "「적용 단가」 칸을 누르면 출처 고르기 · 「관급 · 별도」 칸을 누르면 구분 바꿈 · 견적 단가는 수기 입력",
tip: (i, key) =>
key.startsWith("p:") && rows[i]
? sourceInfo(
@@ -297,7 +297,7 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) }
: null,
frozen: 2,
hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`,
hint: LINK_HINT,
link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null),
};
};
@@ -317,7 +317,7 @@ export const buildMachine: TabBuild = ({ sheets }): TabTable => {
],
rows,
frozen: 2,
hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`,
hint: LINK_HINT,
link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null),
};
};
@@ -388,7 +388,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
],
rows,
frozen: 1,
hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭",
hint: LINK_HINT,
link: (i) => links[i] ?? null,
level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")),
};
+19 -6
View File
@@ -9,6 +9,8 @@
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
* 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) ·
* 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -24,6 +26,8 @@ import {
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
const TEXT_KEYS = ["name", "spec", "unit"];
/** 폴더 줄 수식 칸 — 자손 합(셈) */
const SUM_KEYS = ["sum", "labor", "material", "expense"];
export const buildBill: TabBuild = (ctx): BillTable => {
const { sheets, user, setUser } = ctx;
@@ -60,10 +64,11 @@ export const buildBill: TabBuild = (ctx): BillTable => {
total: { name: "순공사비 계(관급 제외)", ...direct },
frozen: 1,
hint:
"기본 로직이 짠 내역 = 초기값 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(우클릭 · 고친 칸 = 색 칸 · 풍선 = 자동 값) · " +
"금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 묶음 금액 = 아래 항목 합 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재(관급자재대 밖으로 못 옮김) · " +
"조달수수료 율(%)은 수량 칸 · 「연결 ▾」 = 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · 처음으로 = 왼쪽 [내역 초기화] · " +
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" +
(direct.blank
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`
: ""),
editOnDouble: true,
editable: (i, key) => {
const r = rows[i];
if (!r) return false;
@@ -86,6 +91,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null),
tip: (i, key) => {
const r = rows[i];
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}`];
return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`;
@@ -93,10 +99,17 @@ export const buildBill: TabBuild = (ctx): BillTable => {
rowClass: (i) => {
const r = rows[i];
if (!r) return null;
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
// 240 폴더 줄 = 처음 · 레벨 고쳐 생긴 것 모두 같은 줄 꼴(글자 크기 = 레벨 · 공용 is-lvN)
const title = r.kind === "item" ? "" : "is-title";
return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null;
},
level: { key: "name", of: (i) => rows[i]?.level as number | undefined },
// 228-1 레벨 = 셈 나무 깊이(들여 · 내어쓰기 · 끌기 = 다시 셈 · 다시 그림) · 글꼴 = 레벨 · 묶음(빈 공종도)
level: {
key: "name",
of: (i) => rows[i]?.level as number | undefined,
group: (i) => rows[i]?.kind !== "item",
tree: true,
},
menu: (i) => billMenu(ctx, raw, i),
drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before),
onCell: (i, key) => {
@@ -5,6 +5,7 @@
* 놓을 자리 = 줄 사이 틈(포인터가 줄 가운데 위 = 그 줄 앞) · 끄는 줄 자손 = 함께 감 · 자기 자손 안 = 못 놓음
* 레벨 = 위 줄 + 1(위 줄 아래로 · 항목이면 저절로 묶음) ~ 아래 줄 레벨 · 맨 위(1) = 맨 위 줄만
* 놓기 = 표 drop(`…_Bill_Edit` dropMove — 메뉴와 같은 지킬 것) · 미리 보기 선(못 놓음 = is-bad · 커서 not-allowed)
* 미리 보기(121) = 선 머리 글 「○○ 안 · 레벨 N」 + 들어갈 폴더 줄 테두리 + 끄는 줄 흐리게 · 좌우 한 단 = 들여쓰기 폭(--dt-indent)
* 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름)
* ========================================================================== */
@@ -64,9 +65,15 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri
const tr = target.closest<HTMLElement>("tr[data-i]");
if (!tr || !root.contains(tr)) return;
const from = Number(tr.dataset.i);
const step = parseFloat(getComputedStyle(tr).fontSize) || 14;
const step = parseFloat(getComputedStyle(tr).getPropertyValue("--dt-indent")) || 14;
const line = document.createElement("div");
line.className = "b09-estimation__drop";
const tag = document.createElement("span");
tag.className = "b09-estimation__drop-tag";
line.append(tag);
const rowEl = (i: number): HTMLElement | null => root.querySelector(`tr[data-i="${i}"]`);
let into: HTMLElement | null = null;
let lifted: HTMLElement[] = [];
let on = false;
let run: (() => void) | null = null;
let seen = "";
@@ -76,6 +83,13 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri
if (!on) {
on = true;
document.body.append(line);
const lv = Number(table.rows[from]?.level ?? 1);
for (let i = from; i === from || Number(table.rows[i]?.level ?? 1) > lv; i++) {
const el = rowEl(i);
if (!el) break;
el.classList.add("is-drag-src");
lifted.push(el);
}
document.body.style.userSelect = "none";
window.getSelection()?.removeAllRanges();
}
@@ -95,14 +109,24 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri
if (key !== seen) {
seen = key;
run = at ? table.drop!(from, at.parent, at.before) : null;
const p = at?.parent ? table.rows.findIndex((r) => String(r.key) === at.parent) : -1;
into?.classList.remove("is-drop-into");
into = p >= 0 ? rowEl(p) : null;
into?.classList.add("is-drop-into");
tag.textContent = !run
? "못 놓음"
: `${p >= 0 ? `「${String(table.rows[p].name ?? "").trim()}」 안` : "맨 위"} · 레벨 ${at!.level}`;
}
const cell = tr.querySelector("td.ui-data-table__lv") ?? tr;
const box = root.getBoundingClientRect();
const left = box.left + ((at?.level ?? want) - 1) * step + 8;
const left = cell.getBoundingClientRect().left + ((at?.level ?? want) - 1) * step + 8;
Object.assign(line.style, {
top: `${y - 1}px`,
left: `${left}px`,
width: `${Math.max(box.right - left, 40)}px`,
});
// 머리 글 = 명칭 칸 바로 오른쪽 · 선 위에 걸침(줄 이름을 덮지 않게)
tag.style.left = `${Math.max(12, cell.getBoundingClientRect().right - left + 8)}px`;
line.classList.toggle("is-bad", !run);
document.body.style.cursor = run ? "grabbing" : "not-allowed";
};
@@ -111,6 +135,9 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
line.remove();
into?.classList.remove("is-drop-into");
for (const el of lifted) el.classList.remove("is-drag-src");
lifted = [];
document.body.style.userSelect = "";
document.body.style.cursor = "";
if (!on) return;
@@ -5,12 +5,14 @@
* 저장 = 사용자 값 `user.bill` v2(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
* {v: 2, rows: [표 차례 {key · parent(없음 = 맨 위) · 고친 칸만}], gone: [지운 자동 줄 키]}
* 자동 줄 = 키 · 부모 + 고친 칸(name · spec · unit · qty · ref · null = 일부러 빈칸 · 없음 = 원천)
* 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위 묶음 · cost direct
* 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위로 내어 쓴 줄(묶음 · cost direct)
* 첫 손질 = 보이는 줄의 키 · 부모 · 차례만 v2 로 굳힘(옛 꼴 qty · 손 줄 칸은 옮김 · 자동 값은 안 옮김)
* 계약 = `Calc_Bill.py` 머리 — 셈 출력 줄 parent · auto(기본 로직 줄) · auto_{칸}(고친 칸의 자동 값) · level ·
* 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비)
* 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기
* 바뀌는 옮김 · 자재 줄 삭제(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 삭제
* 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리)
* 더함(246) = 「항목 추가」 하나 — 누른 줄이 항목 = 바로 아래 같은 부모 · 폴더 = 그 폴더 첫 하위 ·
* 맨 위 1단(새 공종) = 레벨 단추 · ← 로 내어 씀(관급자재대 앞)
* 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 ·
* 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`)
* ========================================================================== */
@@ -33,7 +35,6 @@ export type BillTable = TabTable & {
};
const NEW_NAME = "새 줄";
const NEW_SECTION = "새 공종";
/** 고칠 칸 — 자동 줄은 덮개 */
const FIELDS = ["name", "spec", "unit", "qty", "ref"];
/** 깊이 한도 — 1 = 맨 위 공종 */
@@ -242,26 +243,13 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
if (run) parts[part].push([label, run]);
};
// 더함 — 위 · 아래(같은 부모) · 하위(묶음) · 새 공종(맨 위 · 관급자재대 앞)
add(0, "위에 줄 추가", (w, e) => {
if (!e.parent) return false;
const at = find(w.bill.rows, key);
w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, newItem(e.parent));
});
add(0, "아래에 줄 추가", (w, e) => {
if (!e.parent || key === "fee") return false;
w.bill.rows.splice(find(w.bill.rows, key) + 1, 0, newItem(e.parent));
});
add(0, "아래에 하위 줄 추가", (w) => {
if (!isGroup(w, key)) return false;
w.bill.rows.push(newItem(key));
});
add(0, "새 공종 추가(1단)", (w, e) => {
// 항목 추가 — 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모(조달수수료 = 관급 묶음 끝 · 셈이 그 뒤에 둠)
add(0, "항목 추가", (w, e) => {
const list = w.bill.rows;
const from = find(list, key) < 0 ? String(e.parent) : key;
const top = chain(list, from).at(-1)!;
const at = find(list, top.key) + (costOf(w, list, from) === "supply" ? 0 : 1);
list.splice(at, 0, { key: newKey(), kind: "group", cost: "direct", name: NEW_SECTION });
const at = find(list, key);
if (isGroup(w, key)) list.splice(at + 1, 0, newItem(key));
else if (!e.parent) return false;
else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent));
});
if (linkable(row)) {
const [picks, more] = linkFor(ctx, rows, i);
@@ -292,10 +280,18 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
});
add(2, "내어쓰기 ←", (w, e) => {
const up = e.parent ? entryOf(w, e.parent) : undefined;
if (!up?.parent) return false; // 맨 위로는 안 냄(새 공종 = 메뉴)
e.parent = up.parent;
if (!up || (!up.parent && !canHold(key))) return false; // 자재 줄 = 1단 안 됨
pull(w.bill.rows, key);
w.bill.rows.splice(find(w.bill.rows, up.key) + 1, 0, e);
if (up.parent) e.parent = up.parent;
else {
// 새 공종(1단) = 직접비 맨 위 · 손 줄은 묶음(자재 줄 돈 갈래 바뀜 = fits 가 막음)
delete e.parent;
e.cost = "direct";
if (!row.auto) Object.assign(e, { kind: "group", qty: null, ref: null });
}
// 맨 위로 = 관급자재대 앞 · 그 밖 = 옛 부모 바로 뒤
const top = !up.parent && costOf(w, w.bill.rows, up.key) === "supply";
w.bill.rows.splice(find(w.bill.rows, up.key) + (top ? 0 : 1), 0, e);
});
}
if (!row.auto && row.kind === "item")
@@ -315,7 +311,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
});
// 지움 — 자식은 지운 줄 부모로 · 자동 줄 = gone · 자식 있는 맨 위 묶음은 못 지움
if (!fixed(key) && !key.startsWith("mat:"))
add(3, "줄 지우기", (w, e) => {
add(3, "줄 삭제", (w, e) => {
const list = w.bill.rows;
if (!e.parent && list.some((c) => c.parent === key)) return false;
for (const c of list) if (c.parent === key) c.parent = e.parent;
@@ -1,12 +1,17 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Level.ts
* B09 설계내역서 레벨 도구 줄(PLAN 200-2) — 표 위 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · 키 · 안내 한 줄.
* B09 설계내역서 레벨 단추(PLAN 200-2 · 229-1) — 보조 탭 줄 오른쪽 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] ·
* 키 · 안내 한 줄(표 아래).
*
* 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) · 안내 글 = 표 아래(202-1)
* 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로)
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) ·
* 꼴 = 줄 전체 상자 테두리(235-1 · 페이지 CSS) · 글 칸도 한 번 누름 = 고름 · 더블클릭 · Enter · F2 = 입력(공용 표)
* 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂)
* 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄)
* 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름
* 242-1 이동해 온 범위(총괄 등 → 그 줄 + 자손 · 빨강 is-pick) = 고름과 한때 하나 — 오면 고름 풂 ·
* 줄 누름 · 방향키 · Esc · 끌어 놓음 = 빨강 풂
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -23,24 +28,19 @@ const ACTS: [string, string][] = [
["↓ 아래로", "아래로 ↓"],
];
const GUIDE =
"레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기";
const GUIDE_MORE =
"레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " +
"들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " +
"내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " +
"위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " +
"끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " +
"키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " +
"묶음 줄로 바꾸기 · 추가 · 지우기 = 줄 우클릭";
const GUIDE = "줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력";
let picked: string | null = null;
/** 242-1 이동해 온 범위 [첫 줄, 끝 줄] — 고름과 한때 하나 */
let jump: [number, number] | null = null;
/** 지금 그린 도구 줄 — 키가 부름 */
let live: {
root: HTMLElement;
bar: HTMLElement;
tools: HTMLElement[];
run: (label: string) => void;
sync: () => void;
edit: (index: number) => void;
index: () => number;
} | null = null;
let keysOn = false;
@@ -48,6 +48,23 @@ let keysOn = false;
export const pickedClass = (row: DataRow | undefined): string | null =>
row && picked !== null && String(row.key) === picked ? "is-focus" : null;
/** 그 줄 + 자손(뒤 줄 중 레벨이 더 깊은 연속 줄) 끝 줄 번호 */
export function subtreeEnd(rows: DataRow[], at: number): number {
const lv = Number(rows[at]?.level ?? 1);
let end = at;
while (end + 1 < rows.length && Number(rows[end + 1]?.level ?? 1) > lv) end++;
return end;
}
/** 242-1 이동해 온 줄(-1 = 없음) — 범위 = 그 줄 + 자손 · 오면 고름 풂(Shell 이 표 그리기 전에) */
export function jumpTo(rows: DataRow[], at: number): void {
jump = at >= 0 ? [at, subtreeEnd(rows, at)] : null;
if (jump) picked = null;
}
/** 지금 빨강 범위 — Shell 줄 class 곁값(pickClass) */
export const jumpRange = (): [number, number] | null => jump;
/** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */
export function menuActs(table: BillTable, index: number): Map<string, () => void> {
const items = index >= 0 && table.menu ? table.menu(index) : [];
@@ -76,9 +93,21 @@ function listenKeys(): void {
if (keysOn) return;
keysOn = true;
document.addEventListener("keydown", (event) => {
if (!live?.root.isConnected || picked === null) return;
if (!live?.root.isConnected || (picked === null && !jump)) return;
const at = document.activeElement;
if (at && at !== document.body && !live.root.contains(at) && !live.bar.contains(at)) return;
if (
at &&
at !== document.body &&
!live.root.contains(at) &&
!live.tools.includes(at as HTMLElement)
)
return;
// 242-1 빨강 중 = 방향키 · Esc 가 빨강만 풂(고른 줄 없음)
if (picked === null) {
if (event.key !== "Escape" && !keyAct(event, editing(at))) return;
jump = null;
return live.sync();
}
// Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂
if (event.key === "Escape") {
if (!editing(at)) {
@@ -88,6 +117,12 @@ function listenKeys(): void {
(at as HTMLElement).blur();
return live.root.focus();
}
// Enter · F2 = 고른 줄 입력 시작(235-1) — 도구 단추 위 Enter 는 단추 몫
if ((event.key === "Enter" || event.key === "F2") && !editing(at)) {
if (live.tools.includes(at as HTMLElement) || event.isComposing) return;
event.preventDefault();
return live.edit(live.index());
}
const label = keyAct(event, editing(at));
if (!label) return;
event.preventDefault();
@@ -95,8 +130,12 @@ function listenKeys(): void {
});
}
/** 표 위 레벨 도구 줄 — 표 root 에 줄 누름(고름)을 붙임 */
export function levelBar(root: HTMLElement, table: BillTable): HTMLElement {
/** 레벨 단추 넷(보조 탭 줄로) · 안내 글(표 아래로) — 표 root 에 줄 누름(고름)을 붙임 */
export function levelBar(
root: HTMLElement,
table: BillTable,
edit: (index: number) => void = () => {},
): { tools: HTMLElement[]; guide: HTMLElement } {
const index = (): number =>
picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked);
const run = (label: string): void => menuActs(table, index()).get(label)?.();
@@ -110,17 +149,17 @@ export function levelBar(root: HTMLElement, table: BillTable): HTMLElement {
button.dataset.b09Level = label;
return button;
});
const guide = document.createElement("span");
guide.className = "b09-estimation__hint";
const guide = document.createElement("p");
guide.className = "ui-general-block__empty b09-estimation__line-hint";
guide.textContent = GUIDE;
attachHoverInfo(guide, GUIDE_MORE);
const bar = document.createElement("div");
bar.className = "b09-estimation__level";
bar.append(...buttons, guide);
attachHoverInfo(guide, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); // 236-2 잘릴 때만
const sync = (): void => {
const acts = menuActs(table, index());
ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label)));
for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus");
if (!jump)
for (const tr of root.querySelectorAll("tr.is-pick"))
tr.classList.remove("is-pick", "is-pick-top", "is-pick-end");
root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus");
};
root.addEventListener("click", (event) => {
@@ -128,11 +167,15 @@ export function levelBar(root: HTMLElement, table: BillTable): HTMLElement {
const row = tr ? table.rows[Number(tr.dataset.i)] : undefined;
if (!row) return;
picked = String(row.key);
jump = null;
sync();
});
attachDrag(root, table, (key) => (picked = key));
attachDrag(root, table, (key) => {
picked = key;
jump = null;
});
sync();
live = { root, bar, run, sync };
live = { root, tools: buttons, run, sync, edit, index };
listenKeys();
return bar;
return { tools: buttons, guide };
}
@@ -100,7 +100,7 @@ async function importLib(
): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) return;
showToast("서고에서 들여오는 중…", "info");
showToast("가져오는 중…", "info");
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, {
method: "POST",
@@ -119,11 +119,11 @@ async function importLib(
const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key);
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
showToast(
got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함",
got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "가져온 호표로 연결함",
"success",
);
} catch (error) {
showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error");
showToast(`못 가져옴 — ${error instanceof Error ? error.message : error}`, "error");
}
}
@@ -157,12 +157,9 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L
const picks: Leaf[] = [
["일위대가 호표", () => pickHopyo(ctx, apply, "dg")],
["단가산출 호표", () => pickHopyo(ctx, apply, "sg")],
["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)],
["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)],
[
"단가산출 로직(M01 · 새 산근)",
() => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)),
],
["구조물 조합", () => pickCombo(ctx, apply)],
["일위대가 사전조합", () => pickPrecombo(ctx, apply)],
["단가산출", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름))],
];
return [picks, more];
}
@@ -6,6 +6,8 @@
* 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음
* 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로)
* 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선
* 248-1 빨강 하나(공용 고름 강조 안 붙임 · 스크롤만) · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 ·
* 누른 줄만 고름(is-focus) · Esc = 고름도 풂
* 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2
* 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래
* 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1)
@@ -17,6 +19,7 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info";
import {
clampColumns,
createGroupCards,
unpickCards,
type GroupCard,
type GroupFoot,
type GroupLine,
@@ -178,13 +181,28 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine
/* ── 그리기 ───────────────────────────────────────────────────────────── */
/** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */
function lineHint(hint: HTMLElement | null): HTMLElement | null {
if (!hint) return null;
export function lineHint(hint: HTMLElement | null): HTMLElement | null {
if (!hint || hint.classList.contains("b09-estimation__line-hint")) return hint;
hint.classList.add("b09-estimation__line-hint");
attachHoverInfo(hint, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null));
return hint;
}
/** 248-1 지금 그린 묶음 판 빨강 풀기(esc = 고름도) — 방향키 · Esc 가 부름 */
let live: { root: HTMLElement; unpick: (esc: boolean) => void } | null = null;
let keysOn = false;
const UNPICK_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Escape"]);
function listenKeys(): void {
if (keysOn) return;
keysOn = true;
document.addEventListener("keydown", (event) => {
if (!live?.root.isConnected || !UNPICK_KEYS.has(event.key)) return;
if ((event.target as Element | null)?.closest?.("input, textarea, select")) return;
live.unpick(event.key === "Escape");
});
}
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
const chosen = new Map<TabId, GroupView>();
@@ -227,11 +245,28 @@ export function renderGroupPanel(o: {
placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽
});
cards.dataset.b09Table = o.tab;
live = { root: cards, unpick: (esc) => unpickCards(cards, esc) };
listenKeys();
return [cards, ...(lead ? [lead] : [])];
}
o.setTools([toggle]);
const range = pickRange(o.spec.rows, o.focus);
const table = o.makeTable((i) => pickClass(range, i));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
let range = pickRange(o.spec.rows, o.focus);
let chose = -1; // 누른 줄(다시 그려도 유지)
const table = o.makeTable((i) => (i === chose ? "is-focus" : pickClass(range, i)));
// 줄 class 만 고침(다시 그리지 않음 — 더블클릭 입력 그대로)
const mark = (row: number): void => {
range = null;
chose = row;
for (const tr of table.root.querySelectorAll("tr.is-pick, tr.is-focus"))
tr.classList.remove("is-pick", "is-pick-top", "is-pick-end", "is-focus");
table.root.querySelector(`tr[data-i="${row}"]`)?.classList.add("is-focus");
};
table.root.addEventListener("click", (event) => {
const tr = (event.target as Element).closest<HTMLElement>("tr[data-i]");
mark(tr ? Number(tr.dataset.i) : chose);
});
live = { root: table.root, unpick: (esc) => mark(esc ? -1 : chose) };
listenKeys();
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
return [table.root, ...(lead ? [lead] : [])];
}
@@ -27,8 +27,7 @@ const EMPTY =
"연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]";
/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */
const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1)
const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0";
const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김";
const BLANK = "빈 줄은 금액을 모르는 줄(합계는 아는 줄만)";
/** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */
const blankNote = (b: Row): string => (b.blank ? `빈 줄 ${b.blank}` : "");
const blankTip = (b: Row | undefined): string | null =>
@@ -65,7 +64,7 @@ export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => {
],
rows,
frozen: 3,
hint: `호표 = 일위대가표 블록 합계 · ${BLANK} · 줄을 누르면 그 블록으로`,
hint: `줄을 누르면 그 블록으로 이동 · ${BLANK}`,
link: (i: number): Link | null => (rows[i] ? { tab: "dg", key: String(rows[i].key) } : null),
tip: (i, key) =>
key === "spec"
@@ -152,7 +151,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
],
rows,
frozen: 1,
hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`,
hint: `줄을 누르면 단가를 낸 탭으로 이동 · ${BLANK}`,
link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
level: blockLevel("name", (i) => titles.has(i)),
@@ -56,9 +56,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => {
],
rows,
frozen: 2,
hint:
"산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" +
notes(sheets),
hint: "줄을 누르면 그 블록으로 이동" + notes(sheets),
link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),
tip: (i, key) => (key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) : null),
};
@@ -159,8 +157,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
rows,
frozen: 1,
hint:
"산출내역 = 값이 든 식(B09 굳힌 단가 * 수량 = 금액 · 작업량 Q = ROUND 2) · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" +
notes(sheets),
"줄을 누르면 단가를 낸 탭으로 이동 · 자재 적용 단가를 고치면 [저장] 뒤 반영" + notes(sheets),
link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
level: blockLevel("text", (i) => titles.has(i)),
@@ -18,8 +18,7 @@ import {
type TabTable,
} from "./B09_Estimation_UI_Tabs";
const HINT =
"총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표";
const HINT = "줄을 누르면 목록표로 이동";
/** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */
const hint = (payload: Row, extra = ""): string => {
@@ -28,7 +27,7 @@ const hint = (payload: Row, extra = ""): string => {
};
/** 줄이 없을 때 표 위 맨 앞 안내 */
const EXPENSE_NONE = "경비 갈래 자원 없음 — 중기 · 자재로 셈 · 경비 로직이 들어오면 채워짐";
const EXPENSE_NONE = "경비 자원 없음";
function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): TabBuild {
return ({ payload }): TabTable => {
@@ -86,7 +85,7 @@ export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
}
: null,
frozen: 2,
hint: hint(payload, " · 비목마다 ROUND(시간 × 시간당, 0)"),
hint: hint(payload),
link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null),
};
};
+42 -7
View File
@@ -2,7 +2,8 @@
* B09_Estimation_UI_Tabs_Total.ts
* B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`.
*
* 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
* 총괄 = 맨 위 내역 줄(239-1 · 설계내역서 레벨 1 · 2 집계 + 순공사비 계 · 누름 = 설계내역서 그 줄 빨강) +
* 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
* 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1)
* 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
@@ -84,6 +85,42 @@ const nameLevel = (rows: DataRow[]) => ({
of: (i: number) => rows[i]?.level as number | undefined,
});
/** 239-1 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도 — 합이 순공사비) ·
* 금액 = 설계내역서 셈 그대로 · 끝 = 순공사비 계 · 누름 = 설계내역서 그 줄(`bill`) */
function billRows(bill: Row | undefined): DataRow[] {
const top = (bill?.rows || []).filter(
(r: Row) => r.cost === "direct" && (r.level === 1 || (r.level === 2 && r.kind !== "item")),
);
if (!top.length) return [];
const direct: Row = bill?.totals?.direct || {};
const amounts = (r: Row) => ({
amount: r.sum ?? null,
labor: r.labor ?? null,
material: r.material ?? null,
expense: r.expense ?? null,
});
const blank = direct.blank ? ` · 빈 줄 ${direct.blank} — 아는 금액만` : "";
return [
...top.map((r: Row) => ({
key: `bill:${r.key}`,
part: "내역",
name: r.no ? `${r.no} ${r.name}` : r.name,
...amounts(r),
note: "누르면 설계내역서 그 줄로",
level: r.level,
bill: r.key,
})),
{
key: "bill_sum",
part: "내역",
name: "순공사비 계",
...amounts(direct),
note: `설계내역서 레벨 1 합(관급 제외)${blank}`,
level: 1,
},
];
}
function rateText(formula: string, rate: Num): string {
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
}
@@ -139,6 +176,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
const sums: Row = t.sums || {};
const env = est.environment_kind === "road" ? "road" : "other";
const rows: DataRow[] = [
...billRows(ctx.sheets.bill),
row("in_price", "요율", "추정가격(원)", null, "일반관리비 · 이윤 · 고용보험 구간", {
hand: hand("in_price"),
}),
@@ -211,16 +249,13 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
rowClass: (i) => levelClass(rows[i]),
level: nameLevel(rows),
mergeSame: ["part"],
hint:
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
"수기 입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " +
"공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음",
hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값",
editable: (i, key) =>
(key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) ||
(key === "hand" && String(rows[i]?.key) in HAND),
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
onCell: (i, key) => clickEstimate(ctx, String(rows[i]?.key), key, rows[i]?.from),
link: (i) => (rows[i]?.bill ? { tab: "bill", key: String(rows[i].bill) } : null),
tip: (i, key) => {
const r = rows[i];
if (!r?.rt || (key !== "rate" && key !== "candidate")) return null;
@@ -292,7 +327,7 @@ export const buildCost: TabBuild = (ctx): TabTable => {
rowClass: (i) => levelClass(rows[i]),
level: nameLevel(rows),
mergeSame: ["group", "part"],
hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄",
hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동",
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
link: (i) => {
+3 -1
View File
@@ -47,6 +47,7 @@ from common_util.common_util_route_geometry import (
interpolate_vertex,
is_uphill_at,
)
from common_util.common_util_structure_station import snap_candidates
from common_util.common_util_wamis_rainfall import idf_intensity
from config.config_system import (
DRAINAGE_BOX_THRESHOLD_MM,
@@ -198,7 +199,8 @@ def build_detail(
)
for pipe in routing.base_pipes
]
pipes = place_pipes(vertices, base, routing.strength_curve)
# 자동 관 자리 = 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 확정본(위)은 단추로만
pipes = snap_candidates(place_pipes(vertices, base, routing.strength_curve), vertices)
detail = DrainageDetail(
route_lonlat=routing.route_lonlat,

Some files were not shown because too many files have changed in this diff Show More