Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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; // 유량 아직 없음 — 다음 응답까지 빈칸
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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 });
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 를 손으로 넣으세요.`;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음"
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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("|")),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user